diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 41ee7f0c72..cc50de67f7 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -16,6 +16,7 @@ body: Many "bugs" are expected behaviour or a known limit. **Please check these before filing — it's the fastest way to an answer:** - **[FAQ](https://github.com/ryanbr/noop/blob/main/docs/FAQ.md)** — live HR, recovery/sleep, steps, importing, privacy, updates. - **[WHOOP 5.0 / MG status](https://github.com/ryanbr/noop/wiki/WHOOP-5.0-and-MG-Status)** — SpO₂, steps, recovery/sleep on the newer straps (the most common topic). + - **[Oura ring support](https://github.com/ryanbr/noop/blob/main/README.md#oura-ring-support)** — experimental; what a ring can and cannot give NOOP (Sleep and Strain but no Recovery on a ring-only day, no HRV or respiratory rate, live HR is partial). - **[Troubleshooting](https://github.com/ryanbr/noop/wiki/Troubleshooting)** — pairing, "syncs but no data", generic HR straps. --- @@ -51,12 +52,16 @@ body: - type: dropdown id: strap attributes: - label: WHOOP strap model + label: WHOOP strap / Oura ring model + description: The model NOOP shows on the Devices screen for the device this report is about. options: - WHOOP 4.0 - WHOOP 5.0 - WHOOP MG - - No strap — imported data only + - Oura Ring 3 (Gen3) + - Oura Ring 4 + - Oura Ring 5 + - No strap or ring — imported data only - Not sure validations: required: true @@ -67,7 +72,8 @@ body: multiple: true description: Tick all that apply. options: - - Live Bluetooth (strap connected) + - Live Bluetooth (WHOOP strap connected) + - Live Bluetooth (Oura ring connected) - WHOOP CSV export import - Apple Health import - Health Connect import @@ -147,7 +153,7 @@ body: required: true - label: I checked the [Troubleshooting guide](https://github.com/ryanbr/noop/wiki/Troubleshooting) — especially "history syncs but no data" (most often the strap isn't banking history to flash; charge it to 100% and reconnect). required: true - - label: I checked the [FAQ](https://github.com/ryanbr/noop/blob/main/docs/FAQ.md) and — if I'm on a WHOOP 5.0 / MG — the [5.0/MG status page](https://github.com/ryanbr/noop/wiki/WHOOP-5.0-and-MG-Status), and this isn't an already-known limit (e.g. SpO₂, or recovery/sleep on the 5/MG). + - label: I checked the [FAQ](https://github.com/ryanbr/noop/blob/main/docs/FAQ.md) and — if I'm on a WHOOP 5.0 / MG — the [5.0/MG status page](https://github.com/ryanbr/noop/wiki/WHOOP-5.0-and-MG-Status), or — if I'm on an Oura ring — the [Oura ring support](https://github.com/ryanbr/noop/blob/main/README.md#oura-ring-support) table, and this isn't an already-known limit (e.g. SpO₂, recovery/sleep on the 5/MG, or no Recovery / HRV on a ring-only day). required: true - label: I've left out anything personal (strap serial, account email, raw export). required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index ee49509b55..7e913d6515 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -30,4 +30,4 @@ that covers it. "Builds and unit tests pass" alone is not enough for BLE work. ## Related issues - + 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..372ac13573 100644 --- a/.github/workflows/app-build.yml +++ b/.github/workflows/app-build.yml @@ -13,9 +13,29 @@ 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/**' + # The watch app and its complications, for the same reason StrandTests is named above: the + # NOOPiOS scheme BUILDS them (NOOPiOS embeds NOOPWatch, which embeds NOOPWatchComplications), + # but neither directory sits under any glob here, so a PR touching only them matched nothing + # and compiled nothing. A broken watch target merged unverified and surfaced on the next PR + # that happened to touch Strand/**, pointing at the wrong change. #2227 translated 25 + # complication strings and got no build at all, which is what surfaced this. + - 'NOOPWatch/**' + - 'NOOPWatchComplications/**' - 'Packages/**' - 'project.yml' - '.github/workflows/app-build.yml' diff --git a/.github/workflows/fork-release.yml b/.github/workflows/fork-release.yml index 98de15c7fe..503a4a19df 100644 --- a/.github/workflows/fork-release.yml +++ b/.github/workflows/fork-release.yml @@ -210,12 +210,23 @@ jobs: run: xcodegen generate - name: Build NOOP.app (macOS, unsigned, universal) # #51: 'platform=macOS' on an Apple-Silicon runner resolves to the host arch and thins the binary to - # arm64-only (project.yml's ONLY_ACTIVE_ARCH: NO is set only under configs.Release, so the Debug build - # inherits the default YES). That shipped an arm64-only app that won't launch on Intel Macs. Force a - # true universal build with the generic destination + explicit ARCHS/ONLY_ACTIVE_ARCH; these - # command-line settings override the config, so it stays Debug (unchanged shipping behaviour). + # arm64-only. That shipped an arm64-only app that won't launch on Intel Macs. Force a true universal + # build with the generic destination + explicit ARCHS/ONLY_ACTIVE_ARCH, which override whatever the + # configuration says and therefore keep holding after the switch below. + # + # #2393: Release, matching the iOS leg. macOS shipped Debug for no reason anyone chose — #51 was + # about architecture and said so, and "unchanged shipping behaviour" there meant that PR was not + # also changing the configuration. The artefact showed it: a 120 KB stub plus a 195 MB + # `.debug.dylib` and a `__preview.dylib`, against 53 MB for the same commit built Release. A + # reporter measured the shipped build costing a third to half a CPU core at idle, and unoptimised + # SwiftUI compounds a hot path that already ran every frame. + # + # What Release removes beyond the optimisation: `#if DEBUG` code, which here is previews, sample + # data and the `--demo-sync` screenshot harness (its own doc already says it is stripped from every + # Release build). The iOS artefact has shipped without all of that since it went Release, so this + # brings macOS into line rather than into new territory. Use a local Debug build for the harness. run: >- - xcodebuild -scheme Strand -configuration Debug + xcodebuild -scheme Strand -configuration Release -destination 'generic/platform=macOS' ARCHS="x86_64 arm64" ONLY_ACTIVE_ARCH=NO -derivedDataPath build/dd CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" diff --git a/.github/workflows/fork-testing-build.yml b/.github/workflows/fork-testing-build.yml index 9f38933296..46e726cb64 100644 --- a/.github/workflows/fork-testing-build.yml +++ b/.github/workflows/fork-testing-build.yml @@ -37,7 +37,56 @@ jobs: echo "ver=$VER" >> "$GITHUB_OUTPUT" # ROLLING release: recreate the single tag so stale (incl. old-version) assets never linger — # one stable URL, always the newest build, zero cleanup. (To KEEP a build, cut a one-off tag.) - gh release delete "$TAG" --repo "$GITHUB_REPOSITORY" --cleanup-tag --yes || true + # + # The delete USED to end in `|| true`, which could not tell "the tag is not there yet", + # which is the normal first-run case and fine, from "the delete failed", which makes the + # create below certain to fail (#2174). Build 499 hit a 500 from GitHub here, the create + # then stopped on "Release.tag_name already exists", and since meta gates all four build + # legs a blip in a metadata step threw away the Android, iOS and macOS builds behind it. + # The message a reader got described the symptom and pointed away from the cause. + delete_rolling_release() { + local out status attempt + for attempt in 1 2 3; do + out=$(gh release delete "$TAG" --repo "$GITHUB_REPOSITORY" --cleanup-tag --yes 2>&1) && status=0 || status=$? + [ "$status" -eq 0 ] && return 0 + # Nothing there to delete is success, not failure: first build, or an earlier run that + # got as far as deleting. Anything else is worth another go, GitHub 5xx being transient. + case "$(printf '%s' "$out" | tr '[:upper:]' '[:lower:]')" in + *"not found"*|*404*) return 0 ;; + esac + echo "release delete attempt ${attempt} of 3 did not succeed: ${out}" + sleep $((attempt * 5)) + done + return 1 + } + delete_rolling_release || { + echo "::error::Could not delete $TAG after 3 attempts. Not creating over it, because that fails with a message about the tag existing rather than about this." + exit 1 + } + # Assert rather than assume. A delete that reports success but leaves the release standing + # would otherwise be read by the create as a name clash, which is the confusion above. + # Known limit: a 5xx on this view reads the same as a clean 404, so a blip here lands back + # on the create's own error. That is where we were before, not somewhere worse, and failing + # the build on an inconclusive view would stop builds whose release really had gone. + if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "::error::$TAG still exists after a delete that reported success. Refusing to create over it." + exit 1 + fi + # The TAG has to go as well, and its own failure is the quiet one. `create` ignores --target + # when the tag already exists, so a release gone but a tag left behind does not fail: it + # publishes today's assets against a tag still pointing at the previous build's commit, and + # nothing says so. The release title carries $SHA from the workflow, so it would read as + # today's build while `git checkout testing-latest` handed someone yesterday's source. + # Deleting the ref directly is the same thing --cleanup-tag meant to do, so try once more + # before giving up rather than failing a build over a leftover. + if gh api "repos/$GITHUB_REPOSITORY/git/refs/tags/$TAG" >/dev/null 2>&1; then + echo "$TAG survived --cleanup-tag; deleting the ref directly." + gh api -X DELETE "repos/$GITHUB_REPOSITORY/git/refs/tags/$TAG" >/dev/null 2>&1 || true + if gh api "repos/$GITHUB_REPOSITORY/git/refs/tags/$TAG" >/dev/null 2>&1; then + echo "::error::The tag $TAG could not be removed. Creating now would publish this build against the previous build's commit, so stopping instead." + exit 1 + fi + fi gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --prerelease --target "${{ github.sha }}" \ --title "NOOP Staging — base ${VER} · ${DATE} · ${SHA}" \ --notes "Rolling **latest** testing build — refreshed in place, so this tag is always the newest. NOOP main + ryanbr's open PRs + vetted community PRs, built with the **NOOP Staging** identity (installs **beside** the official app, separate data). Filenames carry the base app version (\`v${VER}\`). @@ -103,8 +152,11 @@ jobs: - name: Build NOOP.app (macOS, unsigned, universal) # #51: force a true universal (x86_64 + arm64) build — 'platform=macOS' on an Apple-Silicon runner # thins to arm64-only. Mirrors the release workflow so testing builds match what ships. + # #2393: Release, for the reasons spelled out in fork-release.yml. A testing build that is not + # built the way the release is cannot be used to judge how the release performs, which is the one + # thing a testing build is for. run: >- - xcodebuild -scheme Strand -configuration Debug + xcodebuild -scheme Strand -configuration Release -destination 'generic/platform=macOS' ARCHS="x86_64 arm64" ONLY_ACTIVE_ARCH=NO -derivedDataPath build/dd CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" diff --git a/.github/workflows/i18n-coverage.yml b/.github/workflows/i18n-coverage.yml index 7a9d96bcd6..92e2c4b655 100644 --- a/.github/workflows/i18n-coverage.yml +++ b/.github/workflows/i18n-coverage.yml @@ -34,8 +34,43 @@ jobs: with: fetch-depth: 0 - # `github.base_ref` is set ONLY for pull_request; on a push to main it is empty, which would - # pass a bare `origin/`. Fall back to main. (The ref is retained for workflow compatibility — - # `--ci` is a whole-tree gate, so the value it resolves to does not change the verdict.) + # Which commit the audit diffs against. Since #524 this DECIDES the verdict: `--ci` exempts + # any violation already present at the base, so the gate reports what this change introduced + # rather than auditing the whole tree. That is the point of #524 (a regression on main no + # longer red-flags every open PR), and it makes the base ref load-bearing in two ways. + # + # `fetch-depth: 0` above is one of them. The audit reads the base through `git show`, so a + # shallow clone misses every base lookup. That direction fails safe, since nothing is exempt + # and the gate fails loudly rather than passing, but it fails for a reason nobody would guess. + # + # The other is this resolution. `github.base_ref` is set ONLY for pull_request. On a push it is + # empty, and the obvious fallback of origin/main is the PUSHED COMMIT ITSELF: the run would + # compare the tree against itself and could never fail. Verified by planting a hardcoded + # literal, base=HEAD passes and base=HEAD~1 catches it. That would have quietly retired the + # case the push trigger exists for, described at the top of this file: a release pushes + # straight to main with no PR, and appchangelog-gen.py emits the What's New title as a raw + # Kotlin literal. So a push diffs against `github.event.before`, the tip before the push, + # which spans every commit the push carried rather than just the last one. + - name: Resolve the base to diff against + id: base + run: | + set -euo pipefail + if [ -n "${{ github.base_ref }}" ]; then + base="origin/${{ github.base_ref }}" # pull_request: the branch it targets + else + before="${{ github.event.before }}" # push: the tip before this push + # All-zeros on a branch's first push, and absent for a manual run. Neither can be + # diffed, so fall back to the parent: still a real comparison, just narrower. + if [ -n "$before" ] \ + && [ "$before" != "0000000000000000000000000000000000000000" ] \ + && git cat-file -e "$before^{commit}" 2>/dev/null; then + base="$before" + else + base="HEAD~1" + fi + fi + echo "ref=$base" >> "$GITHUB_OUTPUT" + echo "diffing against $base" + - name: i18n audit (regression gate) - run: python3 Tools/i18n_audit.py --ci origin/${{ github.base_ref || 'main' }} + run: python3 Tools/i18n_audit.py --ci "${{ steps.base.outputs.ref }}" diff --git a/.github/workflows/parity-governance.yml b/.github/workflows/parity-governance.yml new file mode 100644 index 0000000000..c8b422427e --- /dev/null +++ b/.github/workflows/parity-governance.yml @@ -0,0 +1,87 @@ +# These are the scanner's own unit and repository-acceptance tests, plus the +# explicitly named legacy R-R preservation contract. Keep that companion test +# exact rather than widening back to every Tools/tests file. +# +# On a PULL REQUEST, run the multi-minute suite only when its implementation, +# authority, named protection tests or workflow change; ordinary product-source +# changes belong to the later ledger/ratchet gate, not to repeated self-testing +# of the scanner. +# +# It also runs DAILY on main. One of these tests is not about the scanner at +# all: the repository acceptance asserts that the checked-in authority still +# reproduces from the CURRENT product source. Product changes are what +# invalidates it, and product changes are exactly what both filters exclude, so +# main could carry stale authority indefinitely with nothing red until some +# later PR touched Tools/ and inherited the whole accumulated drift. +# +# That happened four times, and three landed on an outside contributor's PR that +# had caused none of it. The schedule finds it within a day, on main, where +# re-deriving is a two-minute maintainer action. +# +# Deliberately a schedule rather than an unfiltered push: this suite is pinned +# NOT to run on product source (see test_core_tools_filter_covers_every_ +# governance_tool_path, which asserts both filters and forbids product globs), +# and a periodic check keeps that true. See the history in #1534. + +name: Parity Governance CI + +on: + pull_request: + branches: [main] + paths: + - 'Tools/issue_ref.py' + - 'Tools/parity_*.py' + - 'Tools/parity_*.json' + - 'Tools/parity_case_specs/**' + - 'Tools/tests/test_parity_*.py' + - 'Tools/tests/test_rr_legacy_preservation_contract.py' + - '.github/workflows/parity-governance.yml' + push: + branches: [main] + paths: + - 'Tools/issue_ref.py' + - 'Tools/parity_*.py' + - 'Tools/parity_*.json' + - 'Tools/parity_case_specs/**' + - 'Tools/tests/test_parity_*.py' + - 'Tools/tests/test_rr_legacy_preservation_contract.py' + - '.github/workflows/parity-governance.yml' + # Daily, so authority staleness is found on a schedule instead of by whoever + # next edits Tools/. See the header note. + schedule: + - cron: '17 4 * * *' + workflow_dispatch: + +concurrency: + group: parity-governance-${{ github.ref }} + cancel-in-progress: true + +jobs: + parity-governance: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Run parity-governance tests + run: | + set -o pipefail + python3 -m unittest -v \ + tests.test_parity_ledger \ + tests.test_parity_governance_acceptance \ + tests.test_rr_legacy_preservation_contract \ + tests.test_parity_disposition_kinds \ + tests.test_parity_harness \ + 2>&1 | tee "$RUNNER_TEMP/out.txt" + ran=$(grep -oE '^Ran [0-9]+ test' "$RUNNER_TEMP/out.txt" | grep -oE '[0-9]+') + echo "collected ${ran:-0} tests" + if [ "${ran:-0}" -lt 159 ]; then + echo "::error::expected at least 159 parity-governance tests, collected ${ran:-0} — discovery is broken, not the suite" + exit 1 + fi + working-directory: Tools diff --git a/.github/workflows/source-hygiene.yml b/.github/workflows/source-hygiene.yml index f7ea31469f..9495ac9c76 100644 --- a/.github/workflows/source-hygiene.yml +++ b/.github/workflows/source-hygiene.yml @@ -38,3 +38,17 @@ jobs: # point — fix the comment instead. - name: Detached doc comments (regression gate) run: python3 Tools/doc_comment_lint.py + + # The constructed arithmetic examples that docs/PROTOCOL_SENSORS.md and PROTOCOL_ECG.md tell + # readers to run. Stdlib-only and finishes in well under a second, so it rides along in this + # already-running job on every PR rather than behind a docs/** filter, which would need a + # workflow or runner of its own. Fails on any assertion, so a doc edit cannot break it silently. + - name: Protocol doc examples (arithmetic check) + run: python3 docs/protocol-examples/validate_examples.py + + # docs/PROTOCOL_IMPLEMENTATION.md names client code as file plus symbol, without line numbers. + # Those references rot silently when code moves or is renamed: nobody re-reads the documentation + # during a Swift rename. The stdlib-only existence check covers both file and symbol in under a + # second, so it runs beside the validator on every PR rather than behind a docs/** filter. + - name: Protocol doc source references (existence check) + run: python3 docs/protocol-examples/check_source_references.py 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..1479de962d 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: @@ -60,19 +73,51 @@ jobs: fi working-directory: Tools/linux-capture - # The Tools/ suites: the i18n audit that gates every PR's translations, and the What's New - # generator (#878). Neither ran before — i18n-coverage.yml invokes `i18n_audit.py --ci` but never - # its test file. `discover`, not named modules, for the same reason as above: naming them meant the - # generator's tests would have been added and silently never run. Run from inside Tools/ because - # the suites import their modules by bare name. - - name: Run Tools/ tests + # Parity governance has its own path-filtered workflow because that suite scans the repository and + # is intentionally much slower. Keep this core floor independent so growth in parity cannot hide + # accidental deletion here; these six source-reference checks raise it to 114. + - name: Run core Tools/ tests run: | - set -o pipefail # see above — without this a red suite exits 0 through the pipe. - python3 -m unittest discover -p "test_*.py" -v 2>&1 | tee "$RUNNER_TEMP/out.txt" + set -o pipefail + mapfile -t files < <(find . -maxdepth 1 -name 'test_*.py' -printf '%f\n' | sort) + modules=("${files[@]%.py}") + python3 -m unittest -v "${modules[@]}" 2>&1 | tee "$RUNNER_TEMP/out.txt" ran=$(grep -oE '^Ran [0-9]+ test' "$RUNNER_TEMP/out.txt" | grep -oE '[0-9]+') echo "collected ${ran:-0} tests" - if [ "${ran:-0}" -lt 45 ]; then - echo "::error::expected at least 45 tests, collected ${ran:-0} — discovery is broken, not the suite" + if [ "${ran:-0}" -lt 114 ]; then + echo "::error::expected at least 114 core Tools tests, collected ${ran:-0} — discovery is broken, not the suite" 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/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..e7756815e3 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,288 @@ +# AGENTS.md — working on NOOP + +Guidance for anyone (human or AI agent) submitting a pull request. This is the high-signal map; +[`docs/CONTRIBUTING.md`](docs/CONTRIBUTING.md) is the full guide (BLE safety contract, design-system +rules, add-a-metric/screen/command recipes), [`docs/BUILD.md`](docs/BUILD.md) covers signing/pairing, +and [`docs/IOS.md`](docs/IOS.md) covers the iOS target. Read this first; follow the links for depth. + +## What NOOP is (and the hard scope limits) + +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 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 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 + *implementations* and literals, not every fact learned from one: a protocol offset may be + re-derived with attribution as an unvalidated candidate — see the "facts vs code" bullet in + [`docs/CONTRIBUTING.md`](docs/CONTRIBUTING.md) before telling a contributor no.) + +Licensing: by opening a PR you agree your contribution is under the repo's +[PolyForm Noncommercial 1.0.0](LICENSE) license. + +## Architecture at a glance + +Core logic lives in **cross-platform Swift packages**; each platform is a thin app layer over them. +The **macOS app is the reference implementation**; **Android is a full shipped app**; **iOS is a +build-from-source target** folded into the same repo. + +| Layer | Path | What lives here | +|---|---|---| +| Protocol (pure) | `Packages/WhoopProtocol`, `Packages/OuraProtocol` | BLE frame parse, CRC, command/event/packet decode. **No CoreBluetooth.** Builds on Linux; also builds the `whoop-decode` CLI. | +| Storage | `Packages/WhoopStore` | GRDB/SQLite persistence: migrations, streams, caches. | +| Analytics (pure) | `Packages/StrandAnalytics` | HRV / recovery / strain / sleep / correlation math. Database-free. | +| Import | `Packages/StrandImport` | WHOOP CSV + Apple Health importers. | +| Design system | `Packages/StrandDesign` | SwiftUI palette / components / charts. | +| macOS + shared app | `Strand/` (scheme **Strand**, product `NOOP`, macOS 13+) | `BLE/` (CoreBluetooth), `Collect/`, `Data/` (Repository), `Screens/`, `App/` (`RootView`/`ContentView` = sidebar shell). Shared with iOS where a file isn't macOS-only. | +| iOS-only app | `StrandiOS/` (scheme **NOOPiOS**, iOS 17+), `StrandiOSShared/`, `StrandiOSWidgets/`, `NOOPWatch*` | `StrandiOSApp` (@main), `RootTabView` (the iOS tab shell — no macOS analogue), iOS widgets, watch app. | +| Android app | `android/` (Kotlin, Compose, Room; flavors `Full`/`Demo`) | `com.noop.{ble,collect,data,ingest,analytics,protocol,ui,widget,…}` — mirrors the Swift layering with its own reimplementations. | + +`project.yml` is the **XcodeGen source of truth**; `Strand.xcodeproj/` is generated — never hand-edit +or commit it. Re-run `xcodegen generate` after adding/removing files or editing `project.yml`. + +**Where new code goes:** the more "wire-level" (bytes) or "math-level" a change is, the deeper into +`Packages/` it belongs — and the more it must be covered by a `swift test` that runs with no app, no +strap, no CoreBluetooth. Never add `import AppKit` / `import UIKit` / `import CoreBluetooth` under +`Packages/`; guard framework code with `#if canImport(AppKit)` / `#elseif canImport(UIKit)`. + +## The cross-platform parity contract (the #1 rule) + +Android is an independent reimplementation of the same logic, **not** a port that shares code with +Swift. So: + +- **Analytics and stored data must be byte-identical across Swift and Kotlin.** If you change a + decoder, an analytics formula, a migration, or a stored value on one platform, change the twin on + the other in the same PR (or explicitly call out why not). "It's Compose vs SwiftUI" is *not* a + license to let the numbers diverge. +- **Verify byte-identical by oracle, not by eye.** Extract the pure helper, compile the Swift twin + standalone (`swiftc -O twin.swift main.swift -o t && ./t`) over the whole input space or a spread of + cases, and paste that stdout verbatim as the expected literal in the Kotlin test. Reading the two + implementations side by side does not catch what this does: it found a Swift helper trimming its input + where the Kotlin one only checked blank-ness — invisible in review, and it would have surfaced as two + field logs that disagreed. Note the oracle only guards the direction it is written in; a matching test + on the other side is what stops Swift drifting. +- **UI parity is feature-level, not pixel-level.** SwiftUI Charts vs Compose Canvas legitimately + differ; the *behavior* and the *data* must not. +- **Cross-platform hashes/dedup keys must use a platform-neutral algorithm** (e.g. FNV-1a over UTF-16 + code units) — never `hashValue` (Swift randomizes it) or Kotlin `hashCode` if the value crosses the + `.noopbak` boundary. +- **The `.noopbak` backup whitelist is a byte-identical contract.** `BackupSettings.swift` + (`Packages/WhoopStore`) and `BackupSettingsCodec` (`android/…/data/BackupSettings.kt`) must carry + the same canonical keys + JSON kinds. Only Int/Double/String cross the wire — no dates/objects. +- **Room (Android) and GRDB (iOS) migrations must agree** on the resulting schema. Column order in a + Room `CREATE TABLE` must match the entity field order; pin migrations with tests. + +## Build, test & CI — and what actually validates your change + +**This is the part people get wrong.** Know exactly what covers your change before you claim it works. + +### Prerequisites (toolchain & packages) +Versions are pinned by the repo — install these before the loops below: +- **JDK 17** — Android + Gradle (`sourceCompatibility`/`jvmTarget` are 17 in `android/app/build.gradle.kts`). + Gradle **8.7** is provisioned by `android/gradlew`; don't install a system Gradle. +- **Android SDK** — `platform-tools`, `platforms;android-34`, `build-tools;34.0.0` (match `compileSdk` / + build-tools in `android/app/build.gradle.kts`). Point Gradle at it via `android/local.properties` + (`sdk.dir=…`, gitignored) or `$ANDROID_HOME`. +- **Swift toolchain ≥ 5.9** — the pure packages declare `swift-tools-version: 5.9`; a 6.x toolchain builds + them. On **macOS** this ships with Xcode (also required for the app targets); on **Linux** use a + swift.org toolchain. +- **Linux system packages** (a swift.org toolchain tarball does not bundle its build/runtime deps): + `build-essential libc6-dev` — the C runtime / crt objects the linker needs; without them `swift build` + fails at link with `cannot find Scrt1.o … -lc`. Plus `libncurses-dev libxml2 libcurl4 zlib1g-dev + libedit2 pkg-config unzip`. +- **Android build-tools on non-x86-64 Linux (e.g. arm64):** Google ships `aapt2` / `d8` as **x86-64 only**, + so resource processing dies with `aapt2 … Syntax error` / `Exec format error` on an arm64 host unless x86 + emulation is present — install `qemu-user-static binfmt-support` and the kernel runs them transparently. + macOS and x86-64 Linux are unaffected. + +### Fast local loops +```bash +# Swift packages (fastest; no Xcode, no strap): +cd Packages/WhoopProtocol && swift build && swift test # also OuraProtocol +# Android JVM unit tests (run on Linux/macOS, no device): +cd android && ./gradlew testFullDebugUnitTest # add --tests "com.noop.…" to filter +cd android && ./gradlew compileFullDebugKotlin # compile the whole app module +# macOS app (needs Xcode on macOS): +xcodegen generate && xcodebuild -project Strand.xcodeproj -scheme Strand \ + -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO build +``` + +### What each CI job covers — and the gaps +| Workflow | Covers | Runner | Default state | +|---|---|---|---| +| `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 | **active** — auto-runs on PRs touching its paths (`Strand/**`, `StrandTests/**`, `StrandiOS*/**`, `NOOPWatch*/**`, `Packages/**`, `project.yml`). NO push trigger, so a direct commit to `main` needs a manual dispatch. | +| `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** | +| `i18n-coverage.yml` | Diff-scoped translation gate (`Tools/i18n_audit.py --ci`) | ubuntu | **active** | +| `tools-python.yml` | `unittest discover` over `Tools/` and `Tools/linux-capture` | ubuntu | **active**, path-filtered | +| `prune-stale-branches.yml` | Deletes branches whose PR merged or closed unmerged | ubuntu | **active**, weekly + dispatch | +| `fork-testing-build.yml` / `fork-release.yml` | Staging / release builds (apk + mac + ios) | — | on dispatch | + +**The trap:** `swift-packages` does **NOT** compile the app targets. So if you touch **app-target +Swift** — anything under `Strand/`, `StrandiOS/`, `StrandiOSShared/`, `StrandiOSWidgets/` (Views, +`AppModel`, `BLEManager`, `Repository`, `RootTabView`, widget publish, …) — **no default CI validates +it**, because `app-build.yml` is disabled. A compile error there (e.g. `'self' used before all stored +properties are initialized`) will pass every green check and still be broken. If you change app-target +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:** `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 + on-demand dispatch: writing them is not the same as having run them. +- **BLE behavior cannot be CI- or Linux-tested.** Anything on the CoreBluetooth / offload / live-HR + path (`Strand/BLE`, `Strand/Collect`, Android `com.noop.ble`) must be **validated on a real strap**; + compile-success proves nothing about connection behavior. Say what you tested on hardware. + +## Hard rules before you touch these areas + +- **BLE (read [`docs/CONTRIBUTING.md`](docs/CONTRIBUTING.md) §BLE safety contract first):** never add + **destructive** commands — firmware/DFU, ship-mode, power-cycle, force-trim, fuel-gauge reset, or + anything else that can brick, wipe, or permanently alter the device. The ban is on *destructive*, not + on writing: most of the curated set writes (toggle realtime HR, arm/cancel the alarm, start/stop raw + data), and reversibility is the test. A new non-trivial command is **issue-first** — justify why it is + reversible, confirmation-gate it, never send it automatically, and document its payload and its + on-device verification before any code. That is the bar `rebootStrap` (#166) and the Oura Test Centre + feature-mode write (#2105) were each held to. A write whose effect cannot be shown to reverse is + treated as destructive until proven otherwise. CRC-gate every inbound frame; keep the connection path + stable; no hardcoded hex frame bytes in app code — protocol facts live in the decoders/schema. +- **`didBond` is load-bearing well beyond the handshake — check every reader before you make a strap + deliberately not bond.** At least three independent mechanisms treat "connected but never bonded" as a + fault to be corrected: the bond watchdog bounces the link once its window expires, the #982 never-bonded + detector counts self-drops toward pausing auto-reconnect, and the bond-refusal give-up latches on it. + A change that legitimately leaves a strap unbonded — suppressing an unanswerable handshake, deferring + one while an OS pairing is in flight — silently re-arms all of them, and each will undo the change a few + seconds or a few drops later while reporting a cause that never happened (#1635). Not-bonding is only + evidence of a fault when we were actually *trying* to bond. +- **A diagnostic may only assert what it can attribute.** Repeatedly in the #1635 investigation a line + claimed more than it observed and sent the diagnosis backwards: a `GATT_*` status rendered through the + `BluetoothStatusCodes` table (different enumeration, colliding small integers), a bond declared from a + completion nobody checked the characteristic of, "we write WITH RESPONSE" printed before the code that + decides whether to write at all, "the strap refused" for a local `SecurityException`, and a *persisted* + refusal blaming a strap for a read that our own in-flight pairing broke. Prefer silence, or name the + gap. Conversely, do not let a path go quiet: replacing a wrong line with no line removed the evidence + that identified the bug. Gate per-connect readouts behind the Test Centre domain; leave rare-event + evidence (a state transition, a mismatch) always-on, since it costs nothing when nothing happens and is + what is missing when someone reports a problem without Test Centre enabled. +- **Two readouts of one fact must not be able to disagree.** The same rule as the bullet above, one + layer up: it applies to what a SCREEN states, not only to what a log line claims. The Alarms screen is + the worked example. It carried two pickers both labelled "Wake time", holding different values, only + one of which woke anybody (#2353), and the per-day list under the wind-down card silently re-timed the + strap alarm while its copy said it moved the reminder (#1864). The repair work then introduced the + same shape five more times: a line naming the base alarm time where an override day fires at another, + a card that hid where its own time came from, a countdown for an alarm that would never arm, a + countdown and its date stamp resolved from two clocks a tick apart, and a deadline printed three times + in one card. + Three defences, in order of preference: do not show the fact twice (the second copy is usually noise, + as a date stamp beside figures that already carry the time); when it must appear twice, resolve both + from ONE gated funnel and ONE clock, passed in rather than read separately; and gate that funnel on + whether the thing will actually happen, since a countdown is a promise and an unarmed alarm has none + to make. Counting gated call sites is the weak version of that last test and it passes while a fourth + reader resolves the fact by itself: assert the single resolver instead. +- **A gate must be able to fail on the change that caused it.** The same rule again, moved out to CI: + when a check cannot see what invalidates it, the failure lands on whoever pushes next and reads as + their fault. Three live instances, all hit in one day. The parity-governance path filter excludes + product source, so ordinary feature merges move the derived sets, the stored authority stops + reproducing, and nothing reports it until the 04:17 schedule fails against a commit that changed + none of it; that needed hand repair twice in a day, and in between, a contributor running + `parity_ratchet.py --base upstream/main` gets an error belonging to main rather than to their branch. + The governance discovery floor is an exact count, so a legitimate future removal of one test prints + "discovery is broken, not the suite" on main against a change that removed nothing. And GitHub's + fork-PR approval gate re-arms on every force-push, parking workflows at `action_required`, which the + check-runs API reports as a total of zero rather than as a failure. + Two defences. Never gate a CI poll on `failures == 0`: an approval-parked or not-yet-registered roster + has no failures and is not green. Require `non-success == 0` plus a stable total plus a roster floor + for the paths touched, which is the only thing that stopped a PR merging with its compile legs unrun + (#2343). And where a gate's trigger structurally cannot include what invalidates it, say so in the + error text, so the person holding the failure can tell whose it is. +- **Device / strap model resolution:** map a registry `model` label to a family through the ONE + canonical resolver (`DeviceFamily.forRegistryModel` on both platforms), never a scattered + string compare — the wizard stores `"4.0"`, other paths `"WHOOP 4.0"`, and single-spelling checks + silently miss straps. Reads must thread the registry's **active** strap id, not a raw BLE address. +- **`doc_comment_lint` reports at the wrong line on purpose — do not chase it.** The baseline is a + *per-file count* of grandfathered sites, not a set of line numbers (deliberately: a line-keyed baseline + goes stale constantly). So adding one new detached doc comment makes the file overflow its budget and + the failures print against **other, pre-existing** sites — often nowhere near your edit. Look at what + you just inserted, not at the lines it names. The usual cause is inserting a declaration directly above + an existing one, which lands your code between that neighbour's doc block and the thing it documents: + insert **above the neighbour's doc block**, or after the previous declaration's closing brace. +- **Design system is law:** UI uses only design tokens — `StrandPalette` / `StrandFont` / shared + components on Apple, `Palette` / `Metrics` on Android. No hardcoded colors, fonts, or spacing. +- **Migrations:** add a versioned migration + a test; never mutate an existing migration. Watch for + data-loss traps (window-wide deletes, backfill rewrites) — prefer additive/transactional changes. +- **Deriving a physiological signal from raw sensor data — validate against the artifact, not one + match:** the WHOOP optical/motion buffers are fixed-N-samples-per-record, so autocorrelation/spectral + methods can manufacture a peak at the record period that *looks* physiological and coincidentally + matches the WHOOP app on a stable night — that's why the PPG→HR estimate (#194) was withdrawn. A + single "matched WHOOP" night is **not** validation. Prove the method **tracks a varying input** + (different subjects, or nights where the true value moves; for synthetic tests, recover *multiple* + injected values, not one). Until it does, land it as **instrumentation** (decode + store + log the + estimate beside the incumbent) or behind a **default-off Experimental toggle** — never make it the + default or feed it a downstream gate (recovery, illness) on thin evidence. (WHOOP 4.0 motion is + separately too sparse to reliably stage sleep or tell in-bed from out-of-bed — see #345.) + +## iOS / Android specifics worth knowing + +- **iOS is `NOOPiOS`**, not `Strand`. `ContentView`/`RootView` (the macOS sidebar) are excluded from + iOS; the iOS shell is `RootTabView`. A file shared with macOS (`TodayView`, `Repository`, analytics) + must keep compiling for **both** — check the `Strand` (macOS) build too when you edit shared files. +- **Android** is Compose + Room, flavors `Full` (real) and `Demo`. Profile/prefs live in + SharedPreferences; the DB is Room. UI state uses a `mutate {}` recomposition-counter idiom in places. +- iOS/macOS deployment targets: macOS 13.0, iOS 17.0 (see `project.yml`). + +## PR & commit conventions + +- **One concern per PR.** Keep a protocol change, a schema migration, and a UI change separate. +- **Use English for all repository-facing text:** PR titles and descriptions, commit messages, + issues, review comments, documentation, and code comments. App translations remain in their + intended target languages. +- **Leave issue closure to maintainers.** Reference related issues neutrally with `Refs #N`. + Avoid GitHub auto-closing keyword + issue references in commit messages, PR descriptions, and + comments, including quoted text; maintainers decide when a report is resolved and its issue can + be closed. +- **Show your verification.** BLE → what you tested on hardware. Analytics → the method + a test. + UI → confirms design tokens only. App-target Swift → that you compiled the app (CI won't). +- **Keep generated artifacts out of git** (`Strand.xcodeproj/`, `build/`, `.build/`, `*.app`, + DerivedData). Commit `project.yml`, not the generated project. `Package.resolved` is fine. +- **Cross-platform:** if the change applies to both platforms, do both (or say why not). +- **Versioning (SemVer):** bump `MARKETING_VERSION` in `project.yml` **and** `versionName` in + `android/app/build.gradle.kts` together; build numbers increment independently. The parts are + counters, not decimals (`2.0.10` follows `2.0.9`). +- **Voice:** docs/comments are neutral, third-person, project-voice. Keep upstream credits intact. +- **Release-note credits use GitHub handles (#736).** In a release's contributor section, credit + **third-party** work by `@handle`, not by display name — a plain name is invisible to GitHub, so it + neither notifies the contributor nor links to their profile. A display name may accompany the handle, + but the handle is what makes the credit real: `Thanks to @tigercraft4 (Sleep/Health refactors), + @digitalerdude (workout backfill), …`. + - Credit both **merged PR authors** and the **issue reporters** whose reports drove a fix — a good bug + report with a strap log is often the harder half. + - **Only third-party contributors.** The maintainer's own handles (`@ryanbr` / `@Fanboynz`) are left + out: self-credit adds noise and self-mentions notify nobody. + - Collect the handles with **`Tools/release-contributors.sh `**, which lists every + third-party merged PR and every issue *closed as completed* in the range, plus a ready credit line, + with the maintainer's own handles and bot accounts filtered out. A tag argument is bounded at that + tag's exact instant, so the previous release's work is not re-credited. Writing *what* each person + contributed is still by hand — that's the judgement part; hunting logins is not. Its output is a work + list to prune, not a finished line: a reporter whose issue is not worth calling out in the notes can + be left to the closing "everyone who filed the reports behind these fixes". `Tools/release.sh` warns + when the notes it is about to publish credit no `@handle`. + +When in doubt, open an issue to coordinate first, and prefer the smallest change that's correct and +covered by a test that runs without a strap. 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 ba4fc498cd..c74be4f4c8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,202 +1,5 @@ -# CLAUDE.md — working on NOOP +# CLAUDE.md -Guidance for anyone (human or AI agent) submitting a pull request. This is the high-signal map; -[`docs/CONTRIBUTING.md`](docs/CONTRIBUTING.md) is the full guide (BLE safety contract, design-system -rules, add-a-metric/screen/command recipes), [`docs/BUILD.md`](docs/BUILD.md) covers signing/pairing, -and [`docs/IOS.md`](docs/IOS.md) covers the iOS target. Read this first; follow the links for depth. +Shared repository guidance lives in [AGENTS.md](AGENTS.md). -## 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 -**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). - -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 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 - *implementations* and literals, not every fact learned from one: a protocol offset may be - re-derived with attribution as an unvalidated candidate — see the "facts vs code" bullet in - [`docs/CONTRIBUTING.md`](docs/CONTRIBUTING.md) before telling a contributor no.) - -Licensing: by opening a PR you agree your contribution is under the repo's -[PolyForm Noncommercial 1.0.0](LICENSE) license. - -## Architecture at a glance - -Core logic lives in **cross-platform Swift packages**; each platform is a thin app layer over them. -The **macOS app is the reference implementation**; **Android is a full shipped app**; **iOS is a -build-from-source target** folded into the same repo. - -| Layer | Path | What lives here | -|---|---|---| -| Protocol (pure) | `Packages/WhoopProtocol`, `Packages/OuraProtocol` | BLE frame parse, CRC, command/event/packet decode. **No CoreBluetooth.** Builds on Linux; also builds the `whoop-decode` CLI. | -| Storage | `Packages/WhoopStore` | GRDB/SQLite persistence: migrations, streams, caches. | -| Analytics (pure) | `Packages/StrandAnalytics` | HRV / recovery / strain / sleep / correlation math. Database-free. | -| Import | `Packages/StrandImport` | WHOOP CSV + Apple Health importers. | -| Design system | `Packages/StrandDesign` | SwiftUI palette / components / charts. | -| macOS + shared app | `Strand/` (scheme **Strand**, product `NOOP`, macOS 13+) | `BLE/` (CoreBluetooth), `Collect/`, `Data/` (Repository), `Screens/`, `App/` (`RootView`/`ContentView` = sidebar shell). Shared with iOS where a file isn't macOS-only. | -| iOS-only app | `StrandiOS/` (scheme **NOOPiOS**, iOS 17+), `StrandiOSShared/`, `StrandiOSWidgets/`, `NOOPWatch*` | `StrandiOSApp` (@main), `RootTabView` (the iOS tab shell — no macOS analogue), iOS widgets, watch app. | -| Android app | `android/` (Kotlin, Compose, Room; flavors `Full`/`Demo`) | `com.noop.{ble,collect,data,ingest,analytics,protocol,ui,widget,…}` — mirrors the Swift layering with its own reimplementations. | - -`project.yml` is the **XcodeGen source of truth**; `Strand.xcodeproj/` is generated — never hand-edit -or commit it. Re-run `xcodegen generate` after adding/removing files or editing `project.yml`. - -**Where new code goes:** the more "wire-level" (bytes) or "math-level" a change is, the deeper into -`Packages/` it belongs — and the more it must be covered by a `swift test` that runs with no app, no -strap, no CoreBluetooth. Never add `import AppKit` / `import UIKit` / `import CoreBluetooth` under -`Packages/`; guard framework code with `#if canImport(AppKit)` / `#elseif canImport(UIKit)`. - -## The cross-platform parity contract (the #1 rule) - -Android is an independent reimplementation of the same logic, **not** a port that shares code with -Swift. So: - -- **Analytics and stored data must be byte-identical across Swift and Kotlin.** If you change a - decoder, an analytics formula, a migration, or a stored value on one platform, change the twin on - the other in the same PR (or explicitly call out why not). "It's Compose vs SwiftUI" is *not* a - license to let the numbers diverge. -- **UI parity is feature-level, not pixel-level.** SwiftUI Charts vs Compose Canvas legitimately - differ; the *behavior* and the *data* must not. -- **Cross-platform hashes/dedup keys must use a platform-neutral algorithm** (e.g. FNV-1a over UTF-16 - code units) — never `hashValue` (Swift randomizes it) or Kotlin `hashCode` if the value crosses the - `.noopbak` boundary. -- **The `.noopbak` backup whitelist is a byte-identical contract.** `BackupSettings.swift` - (`Packages/WhoopStore`) and `BackupSettingsCodec` (`android/…/data/BackupSettings.kt`) must carry - the same canonical keys + JSON kinds. Only Int/Double/String cross the wire — no dates/objects. -- **Room (Android) and GRDB (iOS) migrations must agree** on the resulting schema. Column order in a - Room `CREATE TABLE` must match the entity field order; pin migrations with tests. - -## Build, test & CI — and what actually validates your change - -**This is the part people get wrong.** Know exactly what covers your change before you claim it works. - -### Prerequisites (toolchain & packages) -Versions are pinned by the repo — install these before the loops below: -- **JDK 17** — Android + Gradle (`sourceCompatibility`/`jvmTarget` are 17 in `android/app/build.gradle.kts`). - Gradle **8.7** is provisioned by `android/gradlew`; don't install a system Gradle. -- **Android SDK** — `platform-tools`, `platforms;android-34`, `build-tools;34.0.0` (match `compileSdk` / - build-tools in `android/app/build.gradle.kts`). Point Gradle at it via `android/local.properties` - (`sdk.dir=…`, gitignored) or `$ANDROID_HOME`. -- **Swift toolchain ≥ 5.9** — the pure packages declare `swift-tools-version: 5.9`; a 6.x toolchain builds - them. On **macOS** this ships with Xcode (also required for the app targets); on **Linux** use a - swift.org toolchain. -- **Linux system packages** (a swift.org toolchain tarball does not bundle its build/runtime deps): - `build-essential libc6-dev` — the C runtime / crt objects the linker needs; without them `swift build` - fails at link with `cannot find Scrt1.o … -lc`. Plus `libncurses-dev libxml2 libcurl4 zlib1g-dev - libedit2 pkg-config unzip`. -- **Android build-tools on non-x86-64 Linux (e.g. arm64):** Google ships `aapt2` / `d8` as **x86-64 only**, - so resource processing dies with `aapt2 … Syntax error` / `Exec format error` on an arm64 host unless x86 - emulation is present — install `qemu-user-static binfmt-support` and the kernel runs them transparently. - macOS and x86-64 Linux are unaffected. - -### Fast local loops -```bash -# Swift packages (fastest; no Xcode, no strap): -cd Packages/WhoopProtocol && swift build && swift test # also OuraProtocol -# Android JVM unit tests (run on Linux/macOS, no device): -cd android && ./gradlew testFullDebugUnitTest # add --tests "com.noop.…" to filter -cd android && ./gradlew compileFullDebugKotlin # compile the whole app module -# macOS app (needs Xcode on macOS): -xcodegen generate && xcodebuild -project Strand.xcodeproj -scheme Strand \ - -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO build -``` - -### 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** | -| `app-build.yml` | **Compile-only** of the **app targets** (`Strand` macOS + `NOOPiOS` iOS). 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** | -| `i18n-coverage.yml` | Diff-scoped translation gate (`Tools/i18n_audit.py --ci`) | ubuntu | **active** | -| `tools-python.yml` | `unittest discover` over `Tools/` and `Tools/linux-capture` | ubuntu | **active**, path-filtered | -| `prune-stale-branches.yml` | Deletes branches whose PR merged or closed unmerged | ubuntu | **active**, weekly + dispatch | -| `fork-testing-build.yml` / `fork-release.yml` | Staging / release builds (apk + mac + ios) | — | on dispatch | - -**The trap:** `swift-packages` does **NOT** compile the app targets. So if you touch **app-target -Swift** — anything under `Strand/`, `StrandiOS/`, `StrandiOSShared/`, `StrandiOSWidgets/` (Views, -`AppModel`, `BLEManager`, `Repository`, `RootTabView`, widget publish, …) — **no default CI validates -it**, because `app-build.yml` is disabled. A compile error there (e.g. `'self' used before all stored -properties are initialized`) will pass every green check and still be broken. If you change app-target -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. -- **App targets** (`Strand`, `NOOPiOS`) need **Xcode on macOS**; there is no Linux/CI unit-test target - for them (`StrandTests` runs only under `xcodebuild … test` on macOS). -- **BLE behavior cannot be CI- or Linux-tested.** Anything on the CoreBluetooth / offload / live-HR - path (`Strand/BLE`, `Strand/Collect`, Android `com.noop.ble`) must be **validated on a real strap**; - compile-success proves nothing about connection behavior. Say what you tested on hardware. - -## Hard rules before you touch these areas - -- **BLE (read [`docs/CONTRIBUTING.md`](docs/CONTRIBUTING.md) §BLE safety contract first):** never add - destructive/write commands to hardware; CRC-gate every inbound frame; keep the connection path - stable; no hardcoded hex frame bytes in app code — protocol facts live in the decoders/schema. -- **Device / strap model resolution:** map a registry `model` label to a family through the ONE - canonical resolver (`DeviceFamily.forRegistryModel` on both platforms), never a scattered - string compare — the wizard stores `"4.0"`, other paths `"WHOOP 4.0"`, and single-spelling checks - silently miss straps. Reads must thread the registry's **active** strap id, not a raw BLE address. -- **Design system is law:** UI uses only design tokens — `StrandPalette` / `StrandFont` / shared - components on Apple, `Palette` / `Metrics` on Android. No hardcoded colors, fonts, or spacing. -- **Migrations:** add a versioned migration + a test; never mutate an existing migration. Watch for - data-loss traps (window-wide deletes, backfill rewrites) — prefer additive/transactional changes. -- **Deriving a physiological signal from raw sensor data — validate against the artifact, not one - match:** the WHOOP optical/motion buffers are fixed-N-samples-per-record, so autocorrelation/spectral - methods can manufacture a peak at the record period that *looks* physiological and coincidentally - matches the WHOOP app on a stable night — that's why the PPG→HR estimate (#194) was withdrawn. A - single "matched WHOOP" night is **not** validation. Prove the method **tracks a varying input** - (different subjects, or nights where the true value moves; for synthetic tests, recover *multiple* - injected values, not one). Until it does, land it as **instrumentation** (decode + store + log the - estimate beside the incumbent) or behind a **default-off Experimental toggle** — never make it the - default or feed it a downstream gate (recovery, illness) on thin evidence. (WHOOP 4.0 motion is - separately too sparse to reliably stage sleep or tell in-bed from out-of-bed — see #345.) - -## iOS / Android specifics worth knowing - -- **iOS is `NOOPiOS`**, not `Strand`. `ContentView`/`RootView` (the macOS sidebar) are excluded from - iOS; the iOS shell is `RootTabView`. A file shared with macOS (`TodayView`, `Repository`, analytics) - must keep compiling for **both** — check the `Strand` (macOS) build too when you edit shared files. -- **Android** is Compose + Room, flavors `Full` (real) and `Demo`. Profile/prefs live in - SharedPreferences; the DB is Room. UI state uses a `mutate {}` recomposition-counter idiom in places. -- iOS/macOS deployment targets: macOS 13.0, iOS 17.0 (see `project.yml`). - -## PR & commit conventions - -- **One concern per PR.** Keep a protocol change, a schema migration, and a UI change separate. -- **Show your verification.** BLE → what you tested on hardware. Analytics → the method + a test. - UI → confirms design tokens only. App-target Swift → that you compiled the app (CI won't). -- **Keep generated artifacts out of git** (`Strand.xcodeproj/`, `build/`, `.build/`, `*.app`, - DerivedData). Commit `project.yml`, not the generated project. `Package.resolved` is fine. -- **Cross-platform:** if the change applies to both platforms, do both (or say why not). -- **Versioning (SemVer):** bump `MARKETING_VERSION` in `project.yml` **and** `versionName` in - `android/app/build.gradle.kts` together; build numbers increment independently. The parts are - counters, not decimals (`2.0.10` follows `2.0.9`). -- **Voice:** docs/comments are neutral, third-person, project-voice. Keep upstream credits intact. -- **Release-note credits use GitHub handles (#736).** In a release's contributor section, credit - **third-party** work by `@handle`, not by display name — a plain name is invisible to GitHub, so it - neither notifies the contributor nor links to their profile. A display name may accompany the handle, - but the handle is what makes the credit real: `Thanks to @tigercraft4 (Sleep/Health refactors), - @digitalerdude (workout backfill), …`. - - Credit both **merged PR authors** and the **issue reporters** whose reports drove a fix — a good bug - report with a strap log is often the harder half. - - **Only third-party contributors.** The maintainer's own handles (`@ryanbr` / `@Fanboynz`) are left - out: self-credit adds noise and self-mentions notify nobody. - - Collect the handles with **`Tools/release-contributors.sh `**, which lists every - third-party merged PR and every issue *closed as completed* in the range, plus a ready credit line, - with the maintainer's own handles and bot accounts filtered out. A tag argument is bounded at that - tag's exact instant, so the previous release's work is not re-credited. Writing *what* each person - contributed is still by hand — that's the judgement part; hunting logins is not. Its output is a work - list to prune, not a finished line: a reporter whose issue is not worth calling out in the notes can - be left to the closing "everyone who filed the reports behind these fixes". `Tools/release.sh` warns - when the notes it is about to publish credit no `@handle`. - -When in doubt, open an issue to coordinate first, and prefer the smallest change that's correct and -covered by a test that runs without a strap. +@AGENTS.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5c4355bd42..704bc97ba0 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`. @@ -80,7 +99,10 @@ generated output (`Strand.xcodeproj/`) or any secrets, keystores, or `local.prop 2. Fill in the [PR template](.github/PULL_REQUEST_TEMPLATE.md). 3. For anything on the BLE path, state what you tested **on real hardware** and on which strap. A green build is not proof a command behaves correctly. -4. For analytics changes, add a test and cite the method. +4. For analytics changes, add a test and cite the method. If the change alters a + scoring output, follow [`docs/VALIDATION_PROTOCOL.md`](docs/VALIDATION_PROTOCOL.md) + and paste its checklist into the PR — a scoring change with no held-out number + may ship behind a flag, but may not be described as an improvement. 5. For UI changes, use `StrandDesign` tokens only — no hardcoded colors, fonts, or spacing. diff --git a/NOOPWatchComplications/Localizable.xcstrings b/NOOPWatchComplications/Localizable.xcstrings index b6141ffdf0..13ede6b1f4 100644 --- a/NOOPWatchComplications/Localizable.xcstrings +++ b/NOOPWatchComplications/Localizable.xcstrings @@ -44,6 +44,18 @@ "state": "translated", "value": "%@ %lld" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ %lld" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ %lld" + } } } }, @@ -90,6 +102,18 @@ "state": "translated", "value": "Kalibracja %@" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@校准中" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@校準中" + } } } }, @@ -136,6 +160,18 @@ "state": "translated", "value": "%@ niedostępny" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@不可用" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@不可用" + } } } }, @@ -246,6 +282,18 @@ "state": "translated", "value": "Energia %lld na 100" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "能量 %lld,满分 100" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "能量 %lld,滿分 100" + } } } }, @@ -292,6 +340,18 @@ "state": "translated", "value": "Energia %lld · %lld bpm%@" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "能量 %lld · %lld bpm%@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "能量 %lld · %lld bpm%@" + } } } }, @@ -338,6 +398,18 @@ "state": "translated", "value": "Energia %lld%@" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "能量 %lld%@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "能量 %lld%@" + } } } }, @@ -384,6 +456,18 @@ "state": "translated", "value": "Kalibracja Energia" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "能量校准中" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "能量校準中" + } } } }, @@ -430,6 +514,18 @@ "state": "translated", "value": "Kalibracja Energia, wymaga więcej danych" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "能量校准中,需要更多数据" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "能量校準中,需要更多資料" + } } } }, @@ -476,6 +572,18 @@ "state": "translated", "value": "Energia nieaktualny, ostatnia synchronizacja %@. Otwórz NOOP na iPhonie." } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "能量数据已过期,上次同步 %@。请在 iPhone 上打开 NOOP。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "能量資料已過期,上次同步 %@。請在 iPhone 上開啟 NOOP。" + } } } }, @@ -522,6 +630,18 @@ "state": "translated", "value": "Energia nieaktualne · %@" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "能量已过期 · %@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "能量已過期 · %@" + } } } }, @@ -568,6 +688,18 @@ "state": "translated", "value": "Energia niedostępny" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "能量不可用" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "能量不可用" + } } } }, @@ -614,6 +746,18 @@ "state": "translated", "value": "Energia · %@" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "能量 · %@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "能量 · %@" + } } } }, @@ -660,6 +804,18 @@ "state": "translated", "value": "Energia · kal" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "能量 · 校准" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "能量 · 校準" + } } } }, @@ -706,6 +862,18 @@ "state": "translated", "value": "Energia –" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "能量 –" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "能量 –" + } } } }, @@ -880,6 +1048,18 @@ "state": "translated", "value": "NOOP · otwórz na iPhonie" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "NOOP · 在 iPhone 上打开" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "NOOP · 在 iPhone 上開啟" + } } } }, @@ -926,6 +1106,18 @@ "state": "translated", "value": "NOOP. %@, %@, %@." } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "NOOP。%@、%@、%@。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "NOOP。%@、%@、%@。" + } } } }, @@ -972,6 +1164,18 @@ "state": "translated", "value": "NOOP. Nie ma jeszcze danych. Otwórz NOOP na swoim iPhonie, aby przeprowadzić synchronizację." } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "NOOP。暂无数据,请在 iPhone 上打开 NOOP 进行同步。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "NOOP。暫無資料,請在 iPhone 上開啟 NOOP 進行同步。" + } } } }, @@ -1018,6 +1222,18 @@ "state": "translated", "value": "NOOP. Wyniki nieaktualne, ostatnia synchronizacja %@. Otwórz NOOP na iPhonie, aby odświeżyć." } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "NOOP。评分已过期,上次同步 %@。请在 iPhone 上打开 NOOP 以刷新。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "NOOP。評分已過期,上次同步 %@。請在 iPhone 上開啟 NOOP 以重新整理。" + } } } }, @@ -1064,6 +1280,18 @@ "state": "translated", "value": "Brak danych, otwórz NOOP na iPhonie" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无数据,请在 iPhone 上打开 NOOP" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "無資料,請在 iPhone 上開啟 NOOP" + } } } }, @@ -1110,6 +1338,18 @@ "state": "translated", "value": "Otwórz NOOP" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "打开 NOOP" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "開啟 NOOP" + } } } }, @@ -1284,6 +1524,18 @@ "state": "translated", "value": "jakiś czas temu" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "一段时间前" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "一段時間前" + } } } }, @@ -1330,6 +1582,18 @@ "state": "translated", "value": "kal" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "校准" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "校準" + } } } }, @@ -1376,6 +1640,18 @@ "state": "translated", "value": "stary" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "过期" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "過期" + } } } }, @@ -1422,6 +1698,18 @@ "state": "translated", "value": "otwórz iPhone'a" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "打开 iPhone" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "開啟 iPhone" + } } } }, @@ -1468,6 +1756,18 @@ "state": "translated", "value": "nieświeży" } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已过期" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "已過期" + } } } } 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..cc5249c663 100644 --- a/Packages/OuraProtocol/Sources/OuraProtocol/Commands.swift +++ b/Packages/OuraProtocol/Sources/OuraProtocol/Commands.swift @@ -20,9 +20,21 @@ 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 + // The exercise-HR (AWHR) feature id — data arrives as `0x73`/`0x74`. Server-flag-gated per + // OURA_PROTOCOL.md s7.1 [ring4-ble]. Per s7.5, open_oura reports this one respects a local + // `setFeatureMode` write on a consumer ring. + public static let featureExerciseHR: UInt8 = 0x03 + // The CVA PPG sampler feature id — feeds `0x81` raw PPG. Server-flag-gated per OURA_PROTOCOL.md + // s7.1 [ring4-ble]. Per s7.5, open_oura reports this one respects a local `setFeatureMode` write + // on a consumer ring. + public static let featureCvaPpg: UInt8 = 0x0D // MARK: - Pre-auth / identity (unauthenticated OK) @@ -45,9 +57,19 @@ public enum OuraCommands { // MARK: - Notifications / state - /// SetNotification (enable all): `1c 01 3f`. `00`=none, `3f`/`bf`=all. Per OURA_PROTOCOL.md s4.1. - public static func enableAllNotifications() -> OuraCommand { - OuraCommand(label: "notify_all", bytes: [0x1C, 0x01, 0x3F]) + /// The SetNotification mask NOOP has always sent: `3f`. `3f`/`bf`=all per OURA_PROTOCOL.md s4.1. + public static let notificationMaskDefault: UInt8 = 0x3F + /// The SetNotification mask the official app sends (`ff`, 08-25 HCI capture). Its only known + /// difference from `3f` is the two high bits, and whether those are what makes the ring pack ~10 + /// packets per notification (OURA_PROTOCOL.md s2.3, the 9x drain) is the open A/B. Test Centre only. + public static let notificationMaskFull: UInt8 = 0xFF + + /// SetNotification (enable all): `1c 01 `, `3f` by default. `00`=none, `3f`/`bf`=all. Per + /// OURA_PROTOCOL.md s4.1. A non-default mask carries its value in the label (`notify_all(ff)`) so the + /// `-> notify_all` strap-log line shows which session shape an A/B ran under. + public static func enableAllNotifications(mask: UInt8 = notificationMaskDefault) -> OuraCommand { + let label = mask == notificationMaskDefault ? "notify_all" : String(format: "notify_all(%02x)", mask) + return OuraCommand(label: label, bytes: [0x1C, 0x01, mask]) } /// SetNotification (disable): `1c 01 00`. Per OURA_PROTOCOL.md s4.1. @@ -148,13 +170,33 @@ 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]) } + // MARK: - Feature-mode write (s7.5; UNVALIDATED, opt-in only) + + /// Read any feature's status: `2f 02 20 ` — same read verb as `spo2ReadStatus`/ + /// `realStepsReadStatus`, generalized so the feature-mode write below can re-probe after writing. + public static func featureReadStatus(_ feature: UInt8) -> OuraCommand { + OuraCommand(label: "feature_status_\(String(feature, radix: 16))", bytes: [0x2F, 0x02, 0x20, feature]) + } + + /// Write a feature's MODE: `2f 03 22 `. UNVALIDATED on NOOP's own hardware — see + /// OURA_PROTOCOL.md s7.5: [open_oura-feat] reports this write bypassing the account gate for + /// several features on a consumer ring, tested there only with mode=0x01 (automatic); mode=0x00 + /// ("off") always reverts. Gated to Test Centre / explicit user action only — nothing in + /// `OuraDriver`'s own flow produces this call. + public static func setFeatureMode(_ feature: UInt8, mode: UInt8) -> OuraCommand { + OuraCommand(label: "EXPERIMENT_set_feature_\(String(feature, radix: 16))_mode\(mode)", + bytes: [0x2F, 0x03, 0x22, feature, mode]) + } + /// The ordered live-HR enable triplet (read, enable, subscribe). The driver gates each on its ACK. /// Per OURA_PROTOCOL.md s5.6. public static func liveHREnableSequence() -> [OuraCommand] { diff --git a/Packages/OuraProtocol/Sources/OuraProtocol/Decoders.swift b/Packages/OuraProtocol/Sources/OuraProtocol/Decoders.swift index b8ed4a8e45..ba4db9b98a 100644 --- a/Packages/OuraProtocol/Sources/OuraProtocol/Decoders.swift +++ b/Packages/OuraProtocol/Sources/OuraProtocol/Decoders.swift @@ -308,11 +308,14 @@ public enum OuraDecoders { /// Decode the 0x7B spo2_stable_event: a SINGLE uint16 BIG-endian at bytes 6-7. This is the lone /// exception to the LE default. Per OURA_PROTOCOL.md s6.6. Returns nil on a short body. + /// + /// Tagged `OuraSpO2Channel.stableUnit`, NOT the 0x6F percentage tag: the u16's scale is unpinned, so + /// the sample must not pass the percentage-only persist gate in `OuraStreamMapping`. Kotlin matches. public static func decodeSpO2Stable(_ rec: OuraRecord) -> OuraSpO2? { let b = rec.payload guard b.count >= 2 else { return nil } let value = u16be(b, 0) // BIG-endian footgun - return OuraSpO2(ringTimestamp: rec.ringTimestamp, value: value) + return OuraSpO2(ringTimestamp: rec.ringTimestamp, value: value, unit: OuraSpO2Channel.stableUnit) } // MARK: - SpO2 DC, sign-magnitude deltas (0x77; s6.7) diff --git a/Packages/OuraProtocol/Sources/OuraProtocol/Framing.swift b/Packages/OuraProtocol/Sources/OuraProtocol/Framing.swift index e82d94c124..f4f3e58dd1 100644 --- a/Packages/OuraProtocol/Sources/OuraProtocol/Framing.swift +++ b/Packages/OuraProtocol/Sources/OuraProtocol/Framing.swift @@ -173,23 +173,50 @@ public extension OuraFraming { let payload = end > 6 ? Array(bytes[6..= minRecordLen`, every declared end inside the value, and the last one + /// ending on the value's last byte — or nil when it does not. This is the strict counterpart of the + /// lenient single-packet `parseRecord` above: it never clamps, never guesses, and a value that fails + /// the tiling at any packet is rejected whole (the caller then falls back to the one lenient packet). + /// Byte-identical twin of Kotlin `OuraFraming.tiledRecords`. + static func tiledRecords(_ bytes: [UInt8]) -> [OuraRecord]? { + var out: [OuraRecord] = [] + var i = 0 + while i < bytes.count { + guard i + 2 <= bytes.count else { return nil } + let len = Int(bytes[i + 1]) + let end = i + 2 + len + guard len >= minRecordLen, end <= bytes.count, + let rec = parseRecord(Array(bytes[i.. [OuraRecord] { guard let rec = OuraFraming.parseRecord(fragment) else { return [] } + // A PACKED notification carries several complete packets back to back. The ring does this on + // some sessions and not others: every NOOP drain captured to date arrives one packet per + // ≤ 20-byte notification, but the same ring serving the official app on the same link (same + // MTU 203, same get_events bytes) packs ~10 packets into each 196–200-byte notification — + // 38,136 packets in 3,613 notifications on 2026-09-15, every one tiling the value exactly, and + // the one-packet read above kept 3,613 of them. Walk the packets ONLY when the whole value tiles + // into two or more well-formed ones; anything else is the single lenient packet, byte-identical + // to before. That keeps the phantom-storm guarantee: a lone packet whose `len` disagrees with + // the notification length still yields exactly that packet unless its tail happens to tile + // into whole packets ending on the last byte — which no value in 40,696 captured notifications + // does. + if let packed = OuraFraming.tiledRecords(fragment), packed.count >= 2 { return packed } return [rec] } diff --git a/Packages/OuraProtocol/Sources/OuraProtocol/OuraAuthWatchdog.swift b/Packages/OuraProtocol/Sources/OuraProtocol/OuraAuthWatchdog.swift new file mode 100644 index 0000000000..963673ad53 --- /dev/null +++ b/Packages/OuraProtocol/Sources/OuraProtocol/OuraAuthWatchdog.swift @@ -0,0 +1,58 @@ +import Foundation + +/// Escalation policy for an auth handshake the ring does not answer (#2304). +/// +/// The connect sequence is link up → discover → enable notifications → `get_nonce`, and from there the +/// app waits for the nonce. Nothing else is armed until `auth OK` reaches `.streaming` (the re-engage tick, +/// the history-fetch timer), so a ring that accepts the link and the subscription but never answers the +/// nonce left the session parked as "connected" until the OS dropped the link for its own reasons — on +/// the #2303 capture that was 52 and 55 minutes across five consecutive links. Healthy sessions never +/// wait: every answered `get_nonce` on file gets its nonce within the same second (one 5 s outlier over +/// ~270 handshakes), and the only `get_nonce` without a nonce are the ones where the link itself dropped +/// 1–3 s later — which takes the disconnect path and cancels the watchdog before it can fire. +/// +/// Pure so it is testable without a `CBCentralManager` / `BluetoothGatt`; the transport owns the timer +/// and the writes, and asks this table what to do when the timer fires. Every step is one the transport +/// already makes on every connection (a `get_nonce` write, a CCCD toggle, a disconnect) — nothing new +/// is sent to the ring, and silence is never turned into a pairing verdict. +public enum OuraAuthWatchdog { + /// How long a `get_nonce` may go unanswered before the first escalation. The healthy figure is + /// ≤ 1 s with a single 5 s outlier; 10 s leaves room for a slow first connection and is still + /// hundreds of times shorter than what happened on #2303. Re-armed after each escalation. + public static let nonceTimeout: TimeInterval = 10 + + /// What the transport should do when the nonce timer fires. Ordered: the sequence is + /// `.resendNonce` → `.toggleNotify` → `.dropLink`, at most once each per session, then the link is + /// dropped and the ordinary reconnect backoff takes over. No loop. + public enum Step: Equatable, Sendable { + /// Still inside the timeout (a timer fired early or was re-armed): keep waiting. + case wait + /// Re-send `get_nonce` once (1/3) — the write may simply not have reached the ring. + case resendNonce + /// Toggle the notify subscription off and back on, then re-send `get_nonce` (2/3) — a stale + /// subscription delivers nothing, and the CCCD round-trip re-establishes it. + case toggleNotify + /// Cancel the connection and let the normal reconnect path bring the ring back (3/3). + case dropLink + } + + /// The escalation table. Kotlin twin of `OuraAuthWatchdog.step` (the Kotlin side takes milliseconds + /// against `NONCE_TIMEOUT_MS`; same thresholds, same order, same attempt semantics). + /// + /// The claim is on the FUNCTION deliberately. `parity_ledger.resolved_file_pairs` derives file + /// authority from resolved FUNCTION pairs, so a twin reference in the type or file header pairs + /// nothing: this file and its Kotlin counterpart sat in `unpaired_files` with identical stems and a + /// claim already on the Kotlin object, because neither side named a twin where the ledger reads one. + /// + /// - Parameters: + /// - secondsSinceNonceRequest: age of the most recent `get_nonce` write of this session. + /// - attempt: how many escalations have already been taken this session (0 = none yet). + public static func step(secondsSinceNonceRequest: TimeInterval, attempt: Int) -> Step { + guard secondsSinceNonceRequest >= nonceTimeout else { return .wait } + switch attempt { + case ..<1: return .resendNonce + case 1: return .toggleNotify + default: return .dropLink + } + } +} diff --git a/Packages/OuraProtocol/Sources/OuraProtocol/OuraDriver.swift b/Packages/OuraProtocol/Sources/OuraProtocol/OuraDriver.swift index dd1b13a018..9636c2b71e 100644 --- a/Packages/OuraProtocol/Sources/OuraProtocol/OuraDriver.swift +++ b/Packages/OuraProtocol/Sources/OuraProtocol/OuraDriver.swift @@ -60,10 +60,28 @@ public final class OuraDriver { /// needsKeyInstall and writes nothing dangerous. Only an explicit opt-in adopt flow sets this true. /// Per OURA_PROTOCOL.md s3.2 (the 0x24 SetAuthKey is a DANGEROUS, one-time provisioning write). public let allowKeyInstall: Bool + /// The SetNotification mask both handshake paths send (`.ready` and the post-install re-auth). + /// `OuraCommands.notificationMaskDefault` (`3f`) unless the Test Centre A/B asks for the official + /// app's `ff` — see `OuraCommands.notificationMaskFull`. Reversible: the next session sends the + /// mask it is constructed with, nothing persists on the ring. + public let notificationMask: UInt8 public private(set) var phase: OuraDriverPhase = .idle /// Tracks how many of the live-HR enable triplet ACKs have been seen. private var liveHREnableStep = 0 + /// Whether auth success should arm the live-HR enable triplet (`dhr_read` / `dhr_enable` / + /// `dhr_subscribe`). Default true — the historical behaviour. The app clears it for a connect it + /// makes while its live-HR stream is suspended (screen off overnight): before this flag every + /// reconnect ran the triplet unconditionally, the app's suspend guard undid it one second later, + /// and the ring logged `DHR_mode:3` → `DHR_mode:0` on each visit. On a Ring 5 overnight capture + /// (issue #2075's reporter, 2026-09-16) four of the five interruptions of the ring's own SpO2 + /// session started on exactly the second of such a connect (3–49 min each, ≈ 2 h of a 9 h night). + /// With the flag false the driver goes straight to `.streaming` — authenticated and idle — so the + /// history drain, SyncTime and status reads run as before and no daytime-HR write is made at all. + /// The ring's own night suite is the thing being left alone; the Oura app never runs live mode + /// during a sync either (OURA_PROTOCOL.md s5.6). Read once, at the auth-success step; changing it + /// later has no effect on a session already past that step. + public var liveHRWanted = true /// The most recent ring time seen on any record, used to stamp live-HR pushes (which are not TLV /// records and carry no timestamp of their own). private var lastRingTimestamp: UInt32 = 0 @@ -90,11 +108,13 @@ public final class OuraDriver { public init(ringGen: OuraRingGen, authKey: [UInt8]?, allowTierB: Bool = false, allowKeyInstall: Bool = false, + notificationMask: UInt8 = OuraCommands.notificationMaskDefault, nowMsProvider: @escaping () -> Int64 = { Int64(Date().timeIntervalSince1970 * 1000) }) { self.ringGen = ringGen self.authKey = authKey self.allowTierB = allowTierB self.allowKeyInstall = allowKeyInstall + self.notificationMask = notificationMask self.nowMsProvider = nowMsProvider } @@ -112,7 +132,7 @@ public final class OuraDriver { } phase = .authenticating // Enable notifications, then request the auth nonce. SyncTime can follow auth. - return [OuraCommands.enableAllNotifications(), + return [OuraCommands.enableAllNotifications(mask: notificationMask), OuraCommand(label: "get_nonce", bytes: OuraAuth.getAuthNonceCommand())] case .nonceReceived(let nonce): @@ -130,6 +150,13 @@ public final class OuraDriver { case .authCompleted(let status): switch status { case .success: + // A connect the app does not want live HR for (suspended night) skips the triplet + // entirely: `.streaming` here means "authenticated, idle", which is all the history + // fetch / SyncTime / status reads need. Nothing is written to the daytime-HR feature. + guard liveHRWanted else { + phase = .streaming + return [] + } phase = .enablingLiveHR liveHREnableStep = 0 // Begin the live-HR enable triplet (gen-appropriate; gen3 verified, gen4/5 same path). @@ -206,10 +233,19 @@ public final class OuraDriver { public func keyInstallAcknowledged() -> [OuraCommand] { guard phase == .installingKey, installedKey != nil else { return [] } phase = .authenticating - return [OuraCommands.enableAllNotifications(), + return [OuraCommands.enableAllNotifications(mask: notificationMask), OuraCommand(label: "get_nonce", bytes: OuraAuth.getAuthNonceCommand())] } + /// The `get_nonce` request again, for the auth watchdog (#2304) — and ONLY while the driver is still + /// waiting for a nonce. Outside `.authenticating` there is nothing to retry and this returns nil, so + /// the transport can never re-open the handshake on a session that already moved on (streaming, a + /// pairing dead-end, stopped). Same bytes as the `.ready` step; the phase is left untouched. + public func authNonceRetryCommand() -> OuraCommand? { + guard phase == .authenticating else { return nil } + return OuraCommand(label: "get_nonce", bytes: OuraAuth.getAuthNonceCommand()) + } + /// Stop: reset the flow so a fresh session re-runs auth (the app key is session-scoped, s3.1). public func stop() { phase = .stopped @@ -246,21 +282,72 @@ 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. Both readings fit the window only + /// when `window >= 9 × lowerBound`, i.e. a ring under ~5 days of clock; that case is settled by + /// `syncTimeAnchorAdjacencyTicks` below, never by preference. + public static let syncTimeAnchorWindowTicks: Int64 = 38_880_000 // 45 days of 100 ms ticks + + /// How close to `lowerBoundTicks` a reading must sit to be IDENTIFIED rather than merely plausible, and + /// how far a ticks reading may trail the bound. One hour of 100 ms ticks. Two facts set it: + /// (1) the bound can post-date the reply — `OuraHistoryDrain.maxSeenRingTime` is fed by records that + /// land AFTER the 0x13 was answered, and the ring keeps ticking, so the reply's own clock legitimately + /// sits a few ticks (or a whole drain's worth) BELOW the newest record; (2) the two readings differ by + /// `9 × value`, so once the drain's ring-times are within an hour of one of them, the other is days away + /// and the unit is settled by the ring's own records, not by a guess. Found on a Ring 5 with under five + /// days of clock (2026-09-15, a user bundle): a reply 22 ticks below the drain's newest record was + /// excluded as "before the floor", the ×10 reading was the only one left inside the 45-day window, and + /// the whole session was filed 41 days in the past. + public static let syncTimeAnchorAdjacencyTicks: Int64 = 36_000 // 1 hour 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). A candidate is plausible iff it falls in `[lowerBoundTicks − adjacency, lowerBoundTicks + + /// syncTimeAnchorWindowTicks]` — the ring's clock at the reply sits after any ring-time known BEFORE + /// the reply, and at most `syncTimeAnchorAdjacencyTicks` before one learned after it. Then: + /// + /// - only the ticks reading fits → ticks (the ordinary case on a ring with days of clock: ×10 lands + /// beyond the window); + /// - both fit (a ring under ~5 days of clock) → the reading within `syncTimeAnchorAdjacencyTicks` of + /// the bound, if exactly one is — the drain's own ring-times identify the unit; otherwise nil, and + /// the caller parks the reply until the drain has caught up to the present; + /// - only the ×10 reading fits → it, but ONLY when adjacent. No capture on either ring generation has + /// ever produced a seconds-unit reply (every anchor on file resolved as ticks), so the label alone + /// does not earn adoption; the drain's ring-times must corroborate it. A ticks reply that has fallen + /// more than an hour behind the bound is therefore nil, never silently ×10. + /// + /// `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 - 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 } - return UInt32(fits[0]) + public static func syncTimeAnchorCandidate(responseValue: UInt32, lowerBoundTicks: UInt32) -> UInt32? { + guard lowerBoundTicks > 0 else { return nil } + let lower = Int64(lowerBoundTicks) + let floor = lower - syncTimeAnchorAdjacencyTicks + let upper = lower + syncTimeAnchorWindowTicks + let ticks = Int64(responseValue) + let secondsX10 = Int64(responseValue) * 10 + func plausible(_ v: Int64) -> Bool { v >= floor && v <= upper && v <= Int64(UInt32.max) } + func adjacent(_ v: Int64) -> Bool { abs(v - lower) <= syncTimeAnchorAdjacencyTicks } + switch (plausible(ticks), plausible(secondsX10)) { + case (true, false): + return UInt32(ticks) + case (false, true): + return adjacent(secondsX10) ? UInt32(secondsX10) : nil + case (true, true): + if adjacent(ticks), !adjacent(secondsX10) { return UInt32(ticks) } + if adjacent(secondsX10), !adjacent(ticks) { return UInt32(secondsX10) } + return nil + case (false, false): + return nil + } } /// Adopt a ring-time→UTC anchor from the 0x13 SyncTime response pair (`rt` = the ring's clock counter diff --git a/Packages/OuraProtocol/Sources/OuraProtocol/OuraEvents.swift b/Packages/OuraProtocol/Sources/OuraProtocol/OuraEvents.swift index 4c5022c931..2733ef1c4b 100644 --- a/Packages/OuraProtocol/Sources/OuraProtocol/OuraEvents.swift +++ b/Packages/OuraProtocol/Sources/OuraProtocol/OuraEvents.swift @@ -120,6 +120,89 @@ public struct OuraSpO2: Equatable, Sendable, Codable { } } +/// Which physical quantity an `OuraSpO2.unit` tag describes. +/// +/// The ring sends TWO things down the same `.spo2` event and they are three orders of magnitude apart: +/// 0x6F/0x7B carry a firmware-computed PERCENTAGE, and 0x77 carries a raw DC perfusion magnitude +/// (-1,016 … 11,709,098 in one overnight capture). Only the unit tag survives decode to tell them +/// apart, so anything that reports a value to a human has to ask this before it names it. +/// +/// WHY THIS TYPE EXISTS RATHER THAN A `unit == "raw"` CHECK AT EACH SITE. The strap log's +/// "first SpO2 decoded" line printed whichever sample the drain happened to serve first, with no +/// channel in the text — so on one reconnect it read `value 93 (raw)` and on the next +/// `value 101144 (dc_raw)`, from the same ring, minutes apart. A reporter read the five-digit one as a +/// percentage and opened a defect against SpO2 that was never wrong. A log line may only assert what +/// it can attribute; this makes the attribution a value rather than a string comparison. +/// +/// Both known tags are matched EXACTLY; anything else is `.unknown`, which names its tag and never +/// claims a percentage. Treating "not perfusion" as a percentage would print a case variant or a future +/// tag's magnitude with a `%` on it, which is the defect this type exists to stop. `OuraStreamMapping` +/// (both platforms) keeps its own `unit == "raw"` allow-list for the same reason from the other side: +/// it is a persistence gate, so an unrecognised unit falls on the "do not store" side there and on the +/// "do not call it a percentage" side here. Kotlin twin: `OuraSpO2Channel`. +public enum OuraSpO2Channel: String, Equatable, Sendable, Codable { + /// 0x6F — a firmware-computed SpO2 percentage. (The unit tag is the legacy string `"raw"`, which + /// names the CHANNEL, not the quantity; see `decodeSpO2PerSample`.) + case percentage + /// 0x77 — a raw DC perfusion magnitude. Not a percentage, and never stored as one. + case perfusion + /// A channel whose scale is not pinned (0x7B's `stableUnit`), or a unit tag no decoder stamps (a + /// case variant, or a future tag). Named, never a percentage. + case unknown + + /// The unit tag 0x6F stamps on its samples (`OuraSpO2`'s default). + public static let percentageUnit = "raw" + /// The unit tag 0x77 stamps on its samples. + public static let perfusionUnit = "dc_raw" + /// The unit tag 0x7B stamps on its sample. It deliberately resolves to `.unknown`: the value is a + /// big-endian u16 whose scale no source pins (OURA_PROTOCOL.md s6.6), and the golden vector decodes to + /// 970, not a percentage. It used to share the 0x6F `"raw"` tag, which let it through the + /// `OuraStreamMapping` percentage-only persist gate into the same `SpO2Sample.red` column as the + /// ring's real percentages. Promote it to `.percentage` only once a capture pins its scale. + public static let stableUnit = "stable_raw" + + /// Resolve a sample's channel from its unit tag. Both known tags match exactly (case-sensitive, like + /// the tag); anything else is `.unknown` rather than a guess. + /// Kotlin twin: `OuraSpO2Channel.forUnit`. + public static func forUnit(_ unit: String) -> OuraSpO2Channel { + switch unit { + case percentageUnit: return .percentage + case perfusionUnit: return .perfusion + default: return .unknown + } + } + + /// How this channel is named in the strap log. Spelled out rather than printed as the unit tag: + /// `"raw"` names the CHANNEL, not the quantity, and reads as "unprocessed" to everyone who has not + /// read `decodeSpO2PerSample`. + public var logLabel: String { + switch self { + case .percentage: return "SpO2 percentage" + case .perfusion: return "SpO2 raw DC perfusion (NOT a percentage)" + case .unknown: return "SpO2 sample on an unrecognised channel (NOT known to be a percentage)" + } + } + + /// The strap-log body for "the first sample of this channel arrived this session", WITHOUT either + /// platform's `Oura: ` prefix (each source adds its own, as it does for every other line). + /// + /// It lives here, not at the two call sites, for the reason the whole type exists: the two platforms + /// must not be able to disagree about what they call these numbers. The unit tag is still printed, + /// so a log can be matched back to the decoder, and the `%` is appended ONLY on the percentage + /// channel — a perfusion magnitude with a `%` on it is the original bug in a new costume. + /// Kotlin twin: `OuraSpO2Channel.firstDecodedLogLine`, oracle-tested against this function's output. + public static func firstDecodedLogLine(value: Int, unit: String) -> String { + let c = forUnit(unit) + return "first \(c.logLabel) decoded (last night) - \(value)\(c == .percentage ? " %" : "")" + + " (channel \"\(unit)\")" + } +} + +public extension OuraSpO2 { + /// What this sample's number actually is. See `OuraSpO2Channel`. + var channel: OuraSpO2Channel { OuraSpO2Channel.forUnit(unit) } +} + /// One decoded skin-temperature sample in hundredths of a degree C scaled to C (value already / 100). public struct OuraTemp: Equatable, Sendable, Codable { public let ringTimestamp: UInt32 diff --git a/Packages/OuraProtocol/Sources/OuraProtocol/OuraHistoryDrain.swift b/Packages/OuraProtocol/Sources/OuraProtocol/OuraHistoryDrain.swift index 295ac93459..6853c878f2 100644 --- a/Packages/OuraProtocol/Sources/OuraProtocol/OuraHistoryDrain.swift +++ b/Packages/OuraProtocol/Sources/OuraProtocol/OuraHistoryDrain.swift @@ -20,6 +20,24 @@ public struct OuraHistoryDrain: Sendable, Equatable { /// A healthy full pull finishes in ~1-2 min; past this something upstream is wrong — stop, keep the /// banked progress, and let the periodic re-fetch try again later. public static let maxDrainSeconds: TimeInterval = 300 + /// The ring's clock tick rate (OURA_PROTOCOL.md s5.5: 100 ms/tick by default). Used only by + /// `anchorsAreContinuous` below, never by ring-time -> UTC conversion (that stays in `OuraDriver`). + public static let ringTicksPerSecond: Double = 10 + /// Ordinary jitter tolerance for `anchorsAreContinuous` (#2097): how far the ring's clock may appear + /// to fall behind wall-clock across a gap before it counts as a real power-cycle pause rather than + /// anchor-receipt latency noise (BLE round-trip variance on the two receipt timestamps). A genuine + /// power-cycle pause is measured in minutes (13m41s observed on one dead-battery reboot) — nowhere + /// close to this margin, so it stays generous without risking a false "continuous" read. + public static let anchorContinuityMaxPauseSeconds: Double = 20 + /// Minimum wall-clock gap `anchorsAreContinuous` will judge; below this the check declines rather + /// than guessing. 10 s, not the original 30: the test above is an ABSOLUTE margin + /// (`anchorContinuityMaxPauseSeconds`, 20 s), not a rate, so a few seconds of receipt-latency noise + /// inside a 10 s gap still leaves most of that margin — and a genuine power-cycle pause is minutes, + /// which cannot hide inside a short gap either (it shows up as the wall gap itself). The 30 s floor + /// declined on a real 27 s reconnect (2026-09-12, 268 ticks / 27 s = 9.93/s, ring clock plainly + /// continuous) and the old full re-pull fired — a fast relaunch-and-reconnect is the cheapest way + /// to produce a stale replay, so it is the case the floor must not exclude (#2097). + public static let anchorContinuityMinGapSeconds: Double = 10 private var minBytesLeftSeen: UInt32 = .max private var stallCount = 0 @@ -74,7 +92,22 @@ public struct OuraHistoryDrain: Sendable, Equatable { public mutating func noteStoredRingTime(_ rt: UInt32, resumeCursorAtFetchStart: UInt32) { guard rt <= Self.maxPlausibleResumeTicks else { return } if rt > maxStoredRingTime { maxStoredRingTime = rt } - if resumeCursorAtFetchStart > 0, rt < resumeCursorAtFetchStart { sawPreResumeData = true } + if Self.predatesResume(ringTime: rt, resumeCursorAtFetchStart: resumeCursorAtFetchStart) { sawPreResumeData = true } + } + + /// Whether a record stamped `ringTime` was served from BEFORE where this fetch resumed — the one + /// comparison behind `sawPreResumeData`, exposed so a caller can apply it to a whole hypnogram burst. + /// A cursor of 0 is a full pull with no floor, so nothing predates it. + /// + /// WHY A BURST NEEDS THIS (2026-09-13, Gen 3, Oura-app handoff): "discarding the stale replay" in the + /// #2097 judge keeps the CURSOR, but every replayed record has already been ingested by then. + /// Time-series rows dedupe on their primary keys; the hypnogram assembler does not — it received the + /// last two sleep-phase records of two nights, re-served from the second client's position without + /// their 0x49 windows, and end-anchored each pair at its write time into a 52-minute session whose + /// codes were not the night's tail at all. A burst whose write moment predates the resume cursor was + /// already banked from its own drain; the persist refuses it instead of handing it to the dedup. + public static func predatesResume(ringTime: UInt32, resumeCursorAtFetchStart: UInt32) -> Bool { + resumeCursorAtFetchStart > 0 && ringTime < resumeCursorAtFetchStart } /// Record ANY history record's ring-time toward the in-session continuation cursor (anchored or not), @@ -101,14 +134,43 @@ public struct OuraHistoryDrain: Sendable, Equatable { /// The cursor to persist at drain end, given the current cursor and whether `maxStoredRingTime` /// resolves under the CURRENT anchor. A reboot (`sawPreResumeData`) resets to 0 (honest full pull next - /// connect); otherwise the cursor advances to `maxStoredRingTime` only if it moved forward AND - /// resolves. Unchanged in every other case. - public func resumeCursorAtDrainEnd(currentCursor: UInt32, resolvesUnderAnchor: Bool) -> UInt32 { - if sawPreResumeData { return 0 } + /// connect) UNLESS `anchorConfirmsContinuity` says the ring's own clock never paused (#2097) — in + /// that case the stale replay came from something other than a power-cycle (observed: a second BLE + /// client serving the ring in between), so it is treated like ordinary stale data: discarded, cursor + /// left to the same forward-only-if-resolving rule as any other drain. Otherwise the cursor advances + /// to `maxStoredRingTime` only if it moved forward AND resolves; unchanged in every other case. + public func resumeCursorAtDrainEnd(currentCursor: UInt32, resolvesUnderAnchor: Bool, + anchorConfirmsContinuity: Bool = false) -> UInt32 { + if sawPreResumeData, !anchorConfirmsContinuity { return 0 } if maxStoredRingTime > currentCursor, resolvesUnderAnchor { return maxStoredRingTime } return currentCursor } + /// Whether two `0x13 SyncTime` anchors observed across a connect-to-connect gap are consistent with + /// the ring's clock having ticked continuously the whole time — i.e. NOT a genuine power-cycle (#2097). + /// + /// `OuraHistoryDrain.sawPreResumeData` alone cannot tell a real ring reboot from a second BLE client + /// (e.g. the Oura app) having served the ring in between and left NOOP's resume cursor looking stale: + /// both produce the identical "a stored sample is older than where we sought" signature. But a real + /// power-cycle does not reset the ring's free-running tick counter toward zero — it PAUSES it while + /// the ring is off (measured: a dead-battery reboot lost 13m41s of ticks against wall-clock, bracketed + /// by two sub-second no-charge controls) — so comparing elapsed ring-ticks against elapsed wall-clock + /// across the gap distinguishes the two: continuous ticking means nothing paused the ring's clock. + /// + /// Declines to judge (returns `false`, the safe default that keeps today's "treat as reboot" + /// behavior) when the gap is too short to measure meaningfully, or when `current` somehow precedes + /// `previous` in ring-ticks (never observed; not a continuity claim either way). + public static func anchorsAreContinuous(previous: (ringTicks: UInt32, unixSeconds: Int64), + current: (ringTicks: UInt32, unixSeconds: Int64)) -> Bool { + let wallDelta = Double(current.unixSeconds - previous.unixSeconds) + guard wallDelta >= anchorContinuityMinGapSeconds, current.ringTicks >= previous.ringTicks else { + return false + } + let ringSeconds = Double(current.ringTicks - previous.ringTicks) / ringTicksPerSecond + let fellBehindBy = wallDelta - ringSeconds + return fellBehindBy <= anchorContinuityMaxPauseSeconds + } + /// Sanitize a cursor loaded from persistence: a value above the plausibility ceiling is pre-fix /// garbage and must reset to a full pull. public static func sanitizeLoadedCursor(_ persisted: UInt32) -> UInt32 { diff --git a/Packages/OuraProtocol/Sources/OuraProtocol/UserInfo.swift b/Packages/OuraProtocol/Sources/OuraProtocol/UserInfo.swift new file mode 100644 index 0000000000..0f30c8e353 --- /dev/null +++ b/Packages/OuraProtocol/Sources/OuraProtocol/UserInfo.swift @@ -0,0 +1,215 @@ +import Foundation + +// User-info setters (opcode 0x20) and their 0x21 replies. +// +// EXPERIMENTAL / QUARANTINED. These are the only builders in this package that write user data to +// the ring, and nothing in OuraDriver's flow produces them - they exist to answer one question: +// does writing 0x20 change what tag 0x5c reports? See OuraUserInfoWrite for why that is open. +// +// Platform-pure value types. Facts cited per OURA_PROTOCOL.md; wire shapes per [open_oura-cheat] +// (docs/horizon-ring3-protocol-cheatsheet.md), which records these five setters as tested-success on +// a Ring 3 - writing ZEROS. No published capture writes a real value, which is exactly the gap. + +/// The `0x20` user-info fields, keyed by the `type` byte the ring echoes back in its `0x21` reply. +/// +/// Value widths are taken from the tested request shapes in [open_oura-cheat]: gender/unit carry two +/// value bytes, height/weight three, date-of-birth nine. The width is a FACT (the ring acked those +/// exact lengths); the *encoding* inside those bytes is not - see `OuraUserInfoWrite`. +public enum OuraUserInfoField: UInt8, CaseIterable, Sendable { + case gender = 0x02 + case height = 0x03 + case weight = 0x04 + case dateOfBirth = 0x05 + case unit = 0x06 + + /// Number of value bytes that follow the type byte, per the acked shapes in [open_oura-cheat]. + public var valueByteCount: Int { + switch self { + case .gender, .unit: return 2 + case .height, .weight: return 3 + case .dateOfBirth: return 9 + } + } + + /// The label used in the strap log. Never carries the value itself. + public var label: String { + switch self { + case .gender: return "gender" + case .height: return "height" + case .weight: return "weight" + case .dateOfBirth: return "dob" + case .unit: return "unit" + } + } +} + +/// The ring's `0x21` reply to a `0x20` write: `21 02 `. `result == 0` is success on +/// every shape [open_oura-cheat] recorded. +public struct OuraUserInfoAck: Equatable, Sendable { + public let field: OuraUserInfoField + public let result: UInt8 + public var isSuccess: Bool { result == 0 } + public init(field: OuraUserInfoField, result: UInt8) { self.field = field; self.result = result } +} + +/// Builders for the `0x20` user-info setters, plus the encoding candidate that maps a NOOP user +/// profile onto them. +/// +/// ⚠️ **The value encoding is a CANDIDATE, not a decoded fact.** Every published `0x20` write sets +/// its field to zero, so no capture pins down the units, scale, or byte order of a non-zero value. +/// What is known: tag `0x5c` reports height and weight as **single bytes** reading 176 and 75 - i.e. +/// whole cm and whole kg - so a little-endian integer in cm/kg is the encoding most likely to land in +/// those bytes. That is a hypothesis this type exists to TEST, and `writeThenVerify` documents the +/// procedure. Do not present a write as having "set the profile" until a subsequent `0x5c` read shows +/// the value moved. +public enum OuraUserInfoWrite { + /// Build a raw `0x20` write: `20 `, `len` = 1 + the field's value width. + /// + /// Throws if `value` is not exactly `field.valueByteCount` bytes - the ring acked those specific + /// lengths and a different one is an untested shape. + public static func command(_ field: OuraUserInfoField, value: [UInt8]) throws -> OuraCommand { + guard value.count == field.valueByteCount else { + throw OuraUserInfoError.wrongValueWidth(field: field, + expected: field.valueByteCount, + got: value.count) + } + let len = UInt8(1 + value.count) + return OuraCommand(label: "EXPERIMENT_set_user_\(field.label)", + bytes: [0x20, len, field.rawValue] + value) + } + + /// The zero/"empty" writes recorded as tested-success in [open_oura-cheat]. Useful as the restore + /// step after an experiment, and as the golden shapes the tests pin. + public static func clear(_ field: OuraUserInfoField) -> OuraCommand { + // Safe by construction: the width comes from the field itself, so `command` cannot throw. + (try? command(field, value: [UInt8](repeating: 0, count: field.valueByteCount))) + ?? OuraCommand(label: "EXPERIMENT_set_user_\(field.label)", bytes: []) + } + + /// Encode an unsigned integer little-endian into exactly `width` bytes. + /// + /// Throws if the value does not fit, rather than silently truncating - a truncated anthropometric + /// write would land a plausible-looking wrong number on the ring. + public static func encodeLE(_ value: UInt32, width: Int) throws -> [UInt8] { + let maxValue: UInt64 = (UInt64(1) << (8 * UInt64(width))) - 1 + guard UInt64(value) <= maxValue else { + throw OuraUserInfoError.valueOutOfRange(value: value, width: width) + } + return (0..> (8 * UInt32($0))) & 0xFF) } + } + + /// Height in whole centimetres, little-endian across the field's three bytes. CANDIDATE encoding. + public static func height(cm: UInt32) throws -> OuraCommand { + try command(.height, value: try encodeLE(cm, width: OuraUserInfoField.height.valueByteCount)) + } + + /// Weight in whole kilograms, little-endian across the field's three bytes. CANDIDATE encoding. + /// + /// Whole kg is the candidate because `0x5c` byte 1 reads 75 on a ring whose firmware default is + /// documented as 75 kg. If the ring instead wants grams, this write lands 62 g and `0x5c` will not + /// move to 62 - which is precisely the outcome that would falsify the candidate. + public static func weight(kg: UInt32) throws -> OuraCommand { + try command(.weight, value: try encodeLE(kg, width: OuraUserInfoField.weight.valueByteCount)) + } + + /// Gender as the code `0x5c` byte 2 uses: 0 male, 1 female, anything else unspecified + /// [open_oura-evt]. CANDIDATE encoding - that mapping is read off the *event* decoder, and nothing + /// proves the setter shares it. + public static func gender(_ code: UInt8) throws -> OuraCommand { + try command(.gender, value: try encodeLE(UInt32(code), width: OuraUserInfoField.gender.valueByteCount)) + } + + /// Map NOOP's `sex` string onto the `0x5c` gender code. Anything that is not male/female is + /// "unspecified", which is the value `bmr_schofield` documents as the both-sexes average + /// [open_oura-evt] - so nonbinary and unset both land on the ring's own neutral path. + public static func genderCode(forSex sex: String) -> UInt8 { + switch sex.lowercased() { + case "male": return 0 + case "female": return 1 + default: return 2 + } + } + + /// ⚠️ **There is no `0x20` age setter.** `0x5c` byte 0 reports age in years, but the only related + /// write is date-of-birth (`type 5`, nine value bytes) and no capture shows its layout. So of the + /// four `0x5c` fields, three have a plausible direct setter and **age does not**. This returns nil + /// rather than guessing a nine-byte DOB encoding; fill it in only once a capture pins the shape. + public static func dateOfBirthCommand(year: Int, month: Int, day: Int) -> OuraCommand? { nil } +} + +/// Errors from building a user-info write. Both are programmer errors caught before any byte reaches +/// the ring. +public enum OuraUserInfoError: Error, Equatable, Sendable { + case wrongValueWidth(field: OuraUserInfoField, expected: Int, got: Int) + case valueOutOfRange(value: UInt32, width: Int) +} + +/// Parse a `0x21` user-info reply. Returns nil for anything that is not exactly `21 02 ` +/// with a known type, so an unrelated `0x21` frame is never reported as an ack. +public func parseOuraUserInfoAck(_ bytes: [UInt8]) -> OuraUserInfoAck? { + guard bytes.count == 4, bytes[0] == 0x21, bytes[1] == 0x02, + let field = OuraUserInfoField(rawValue: bytes[2]) else { return nil } + return OuraUserInfoAck(field: field, result: bytes[3]) +} + +// MARK: - Reading back what the ring reports (tag 0x5c) + +/// One decoded `0x5c` `user_information` record — the read-back side of the experiment. +/// +/// The field meanings are [open_oura-evt]'s inference (`_status: inferred`), not a recovered parser, +/// so they are reported as named bytes and nothing here treats them as authoritative. NOOP does not +/// score or store any of it; the record exists to answer whether a `0x20` write moves these bytes. +public struct OuraUserInfoRecord: Equatable, Sendable { + public let ringTimestamp: UInt32 + public let ageYears: UInt8 + public let weightKg: UInt8 + public let sexCode: UInt8 + public let heightCm: UInt8 + /// The verbatim four payload bytes, so a log line can show what arrived even if the inferred + /// field split above turns out to be wrong. + public let raw: [UInt8] + + public init(ringTimestamp: UInt32, raw: [UInt8]) { + self.ringTimestamp = ringTimestamp + self.raw = raw + self.ageYears = raw.count > 0 ? raw[0] : 0 + self.weightKg = raw.count > 1 ? raw[1] : 0 + self.sexCode = raw.count > 2 ? raw[2] : 0 + self.heightCm = raw.count > 3 ? raw[3] : 0 + } + + /// `28 4b 02 b0` — the value every capture of this ring has produced, and the same bytes + /// [open_oura-evt] read off a factory-reset ring. Used only to label a log line "(defaults)". + public static let firmwareDefaults: [UInt8] = [0x28, 0x4B, 0x02, 0xB0] + public var isFirmwareDefault: Bool { raw == Self.firmwareDefaults } + + /// Hex of the payload, for the strap log. + public var rawHex: String { raw.map { String(format: "%02x", $0) }.joined() } +} + +/// The `0x5c` event tag. +public let ouraUserInfoTag: UInt8 = 0x5C + +/// Scan a TLV byte buffer for `0x5c` records. +/// +/// Walks ` ` (where an event body is `rt:u32LE` + payload), so it works whether the +/// caller hands over one record or a whole concatenated notification. Malformed or truncated tails +/// stop the walk rather than throwing — this runs on every inbound notification and must never be +/// able to break ingest. +public func scanOuraUserInfoRecords(tlvBytes: [UInt8]) -> [OuraUserInfoRecord] { + var out: [OuraUserInfoRecord] = [] + var i = 0 + while i + 2 <= tlvBytes.count { + let tag = tlvBytes[i] + let len = Int(tlvBytes[i + 1]) + let end = i + 2 + len + guard end <= tlvBytes.count else { break } + if tag == ouraUserInfoTag, len >= 4 { + let rt = UInt32(tlvBytes[i + 2]) | UInt32(tlvBytes[i + 3]) << 8 + | UInt32(tlvBytes[i + 4]) << 16 | UInt32(tlvBytes[i + 5]) << 24 + out.append(OuraUserInfoRecord(ringTimestamp: rt, raw: Array(tlvBytes[(i + 6).. String { + b.map { String(format: "%02x", $0) }.joined() + } + // MARK: - Outer frame func testParseOuterFrame() { @@ -165,17 +169,93 @@ final class FramingTests: XCTestCase { // MARK: - One packet per notification (no buffering, no multi-record loop, no resync) - func testFeedReturnsAtMostOneRecordPerNotification() { - // What LOOKS like two packed records is treated as ONE lenient packet: the first record only, - // trailing bytes ignored. The ring never packs several events into one notification (it streams - // one event per notification + a 0x11 summary), so this is the honest interpretation. + func testFeedWalksANotificationThatTilesExactlyIntoSeveralPackets() { + // Two complete packets whose declared lengths land exactly on each other and on the value's last + // byte are BOTH returned. The ring packs like this when serving the official app (2026-09-15, + // 38,136 packets in 3,613 notifications, every value tiling exactly); reading only the first + // packet dropped nine in ten. let r = OuraReassembler() let recs = r.feed(bytes("7b060200010003ca" + "4e0602000100006c")) + XCTAssertEqual(recs.map { $0.type }, [0x7B, 0x4E]) + XCTAssertEqual(recs.map { $0.ringTimestamp }, [65538, 65538]) + XCTAssertEqual(recs.map { $0.payload }, [bytes("03ca"), bytes("006c")]) + } + + func testFeedFallsBackToOneLenientPacketWhenTheTailDoesNotTile() { + // The first packet is followed by three bytes that do not form a packet: the tiling fails, so + // the notification is read as ONE lenient packet (the pre-packed behaviour, byte-identical) — + // the tail is neither walked nor buffered. This is the phantom-storm guarantee kept. + let r = OuraReassembler() + let recs = r.feed(bytes("7b060200010003ca" + "4e0602")) XCTAssertEqual(recs.count, 1) XCTAssertEqual(recs[0].type, 0x7B) XCTAssertEqual(recs[0].payload, bytes("03ca")) } + func testFeedKeepsALonePacketWhoseLenDisagreesWithTheNotification() { + // A 20-byte value whose `len` says 10: the lenient single read clamps the payload to `len` (as + // before) and the remaining bytes (`d5 55 …`) do not tile into a packet, so nothing is minted + // from them. Oracle: standalone twin, 2026-09-15. + let r = OuraReassembler() + let recs = r.feed(bytes("5a0a1dbdb40200fffffff7d7d555555555543fff")) + XCTAssertEqual(recs.count, 1) + XCTAssertEqual(recs[0].ringTimestamp, 45399325) + XCTAssertEqual(recs[0].payload, bytes("00fffffff7d7")) + } + + func testFeedOnRealPackedNotificationsFromTheRing() { + // Two 184/188-byte notifications captured verbatim from a Gen 3 ring on 2026-09-15 07:39:25 + // (raw diagnostics sidecar), while it served the official app's history request. Expected + // records are the standalone Swift twin's output over the same bytes, pasted verbatim (the same + // literal gates the Kotlin twin). Ten packets each, of mixed tags, ring-times ascending. + let r = OuraReassembler() + let a = r.feed(bytes( + "5a1209e7b30206f00000005555555555555555405a120ae7b302070000014555555555555545f0ff5a120be7b30208" + + "fffffffffffffffffffff7f555580b0ce7b302185f1033563c645a120de7b302095555555555555555555555557f4f" + + "0f0ee7b302772514020d0100008000004c120fe7b30201001f00d9007f0047013b3405146e1118e7b3028a7c7b797b" + + "7a7a947c919051616e1127e7b30204797a7d797b80d0dfe7a7bdca60122ee7b3027a7c797a8180bbb96572889d1761")) + XCTAssertEqual(a.map { String(format: "%02x rt=%u payload=%@", $0.type, $0.ringTimestamp, hexBytes($0.payload)) }, [ + "5a rt=45344521 payload=06f0000000555555555555555540", + "5a rt=45344522 payload=070000014555555555555545f0ff", + "5a rt=45344523 payload=08fffffffffffffffffffff7f555", + "58 rt=45344524 payload=185f1033563c64", + "5a rt=45344525 payload=095555555555555555555555557f", + "4f rt=45344526 payload=772514020d010000800000", + "4c rt=45344527 payload=01001f00d9007f0047013b340514", + "6e rt=45344536 payload=8a7c7b797b7a7a947c91905161", + "6e rt=45344551 payload=04797a7d797b80d0dfe7a7bdca", + "60 rt=45344558 payload=7a7c797a8180bbb96572889d1761", + ]) + let b = r.feed(bytes( + "751231e7b3028d0d8d0d8d0d8d0d8d0d870d870d461232e7b302870dfc0c4c0b640d7a0d8d0d7d0d690633e7b302ed0d" + + "6f123ce7b3024d66666666666666676767676768771243e7b302beff03fef6fe0100020d0e06ff016e1152e7b30280" + + "80807c7c7e2f5e795c879a00771260e7b3023afc020805040a09fcf5fcfdfd036e1161e7b3020a807d7b7e7d76e1d0" + + "e5dfd4b0601267e7b3027d7b7e7d777996b4cdb38d92886161107ae7b3021a1800288a0000ac3f0000cb")) + XCTAssertEqual(b.map { String(format: "%02x rt=%u payload=%@", $0.type, $0.ringTimestamp, hexBytes($0.payload)) }, [ + "75 rt=45344561 payload=8d0d8d0d8d0d8d0d8d0d870d870d", + "46 rt=45344562 payload=870dfc0c4c0b640d7a0d8d0d7d0d", + "69 rt=45344563 payload=ed0d", + "6f rt=45344572 payload=4d66666666666666676767676768", + "77 rt=45344579 payload=beff03fef6fe0100020d0e06ff01", + "6e rt=45344594 payload=8080807c7c7e2f5e795c879a00", + "77 rt=45344608 payload=3afc020805040a09fcf5fcfdfd03", + "6e rt=45344609 payload=0a807d7b7e7d76e1d0e5dfd4b0", + "60 rt=45344615 payload=7d7b7e7d777996b4cdb38d928861", + "61 rt=45344634 payload=1a1800288a0000ac3f0000cb", + ]) + XCTAssertEqual(r.bufferedByteCount, 0, "a packed value is walked in place, never buffered") + } + + func testFeedOnAnOrdinarySinglePacketNotificationIsUnchanged() { + // A 20-byte one-packet value from a NOOP drain (2026-09-15 04:20): exactly one record, the + // whole payload — the tiling walk yields a single packet and the single lenient read wins. + let r = OuraReassembler() + let recs = r.feed(bytes("5a121dbdb40200fffffff7d7d555555555543fff")) + XCTAssertEqual(recs.count, 1) + XCTAssertEqual(recs[0].ringTimestamp, 45399325) + XCTAssertEqual(recs[0].payload, bytes("00fffffff7d7d555555555543fff")) + } + func testFeedNeverBuffersAcrossNotifications() { // A too-short notification is dropped whole (no leftover bytes carried); the NEXT notification is // parsed independently. This is the property the old buffering reassembler lacked — a leftover @@ -219,22 +299,92 @@ 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), + // A seconds-unit response (481_000 s = 4.81M ticks) fits only when multiplied x10 - but that unit + // has never been observed, so it is adopted only when the x10 reading is ADJACENT to the floor. + // 396_067 ticks (11 h) away from it -> nil: the reply parks until the drain corroborates it. + XCTAssertNil(OuraDriver.syncTimeAnchorCandidate(responseValue: 481_000, lowerBoundTicks: 4_413_933)) + // ...and the same seconds-unit reply against a floor the drain has carried to the present -> + // adjacent (20 ticks) -> the x10 reading is identified by the ring's own records. + XCTAssertEqual(OuraDriver.syncTimeAnchorCandidate(responseValue: 481_000, lowerBoundTicks: 4_810_020), 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)) + // Young ring (floor under window/9 ~ 5 days): BOTH readings inside the window. Adjacency decides: + // 10_000 ticks (17 min) from the floor -> ticks; the x10 reading is 1.36M ticks away. + XCTAssertEqual(OuraDriver.syncTimeAnchorCandidate(responseValue: 150_000, lowerBoundTicks: 140_000), + 150_000) + // Young ring, both fit, NEITHER adjacent (a stale cursor 100_000 ticks = 2.8 h behind) -> nil, + // parked until the drain's ring-times reach the present. + XCTAssertNil(OuraDriver.syncTimeAnchorCandidate(responseValue: 500_000, lowerBoundTicks: 400_000)) + } + + /// The 2026-09-15 Ring 5 capture (a user bundle): the ring had restarted five days earlier, so its + /// clock was under window/9 and both readings fit the window all week. At connect the reply parked + /// (floor = the 18-min-stale cursor); on the retry the floor was `maxSeenRingTime`, which the drain + /// had already pushed 22 ticks PAST the reply - the old rule excluded the ticks reading as "before + /// the floor", the x10 reading was the only one left, and the whole session was filed 41 days in the + /// past (`device rt 40064980 [seconds x10, raw 0x003d2262]`). Every value below is verbatim from + /// that capture's `report.txt` / `oura-raw.jsonl`. + func testSyncTimeAnchorCandidateYoungRingDoesNotAdoptTheSecondsX10Reading() { + // 10:13:20 reply raw 0x003d2262 = 4_006_498; the 0x42 served two seconds later carries rt 4_006_520. + // Retry floor = maxSeenRingTime = 4_006_520 -> the ticks reading trails it by 22 -> still plausible, + // adjacent -> ticks. The x10 reading (40_064_980) fits the window too but is 36M ticks away. + XCTAssertEqual(OuraDriver.syncTimeAnchorCandidate(responseValue: 4_006_498, lowerBoundTicks: 4_006_520), + 4_006_498) + // The 10:29:50 connect: raw 0x003d4914 = 4_016_404 against maxSeenRingTime 4_016_416. + XCTAssertEqual(OuraDriver.syncTimeAnchorCandidate(responseValue: 4_016_404, lowerBoundTicks: 4_016_416), + 4_016_404) + // At connect the floor was the persisted cursor 3_995_770, 18 min stale: within the adjacency + // window, so the new rule resolves it at connect instead of parking it. + XCTAssertEqual(OuraDriver.syncTimeAnchorCandidate(responseValue: 4_006_498, lowerBoundTicks: 3_995_770), + 4_006_498) + // A young-ring reply whose floor has run more than an hour AHEAD of it (a retry long after receipt): + // the ticks reading is no longer plausible, the x10 reading fits but is not adjacent -> nil. The + // honest answer; the old rule returned 40_064_980 here. + XCTAssertNil(OuraDriver.syncTimeAnchorCandidate(responseValue: 4_006_498, lowerBoundTicks: 4_050_000)) + // A reading exactly one tick below the floor (the smallest possible post-reply advance) -> ticks. + XCTAssertEqual(OuraDriver.syncTimeAnchorCandidate(responseValue: 28_073_724, lowerBoundTicks: 28_073_725), + 28_073_724) + } + + /// 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/OuraAuthWatchdogTests.swift b/Packages/OuraProtocol/Tests/OuraProtocolTests/OuraAuthWatchdogTests.swift new file mode 100644 index 0000000000..0d98e8272e --- /dev/null +++ b/Packages/OuraProtocol/Tests/OuraProtocolTests/OuraAuthWatchdogTests.swift @@ -0,0 +1,122 @@ +import XCTest +@testable import OuraProtocol + +/// `OuraAuthWatchdog.step` — the escalation table for an unanswered `get_nonce` (#2304) — and the +/// driver's `authNonceRetryCommand`, the only way the transport may re-send the nonce request. +/// +/// The policy is a pure function precisely so it can be tested: the transports own a +/// `CBCentralManager` / `BluetoothGatt` and cannot be built in a unit test, so without this split the +/// decision would have zero coverage. These tests pin the table and the driver gate only; that the +/// escalation clears the watchdog on a healthy connect (no expiry line across a night of reconnects) is +/// a strap claim and is called out as owed in the PR body. +final class OuraAuthWatchdogTests: XCTestCase { + private let key: [UInt8] = Array(0..<16) + + // MARK: - The table + + func testWaitsInsideTheTimeoutWhateverTheAttempt() { + // Healthy handshakes answer within 1 s (one 5 s outlier over ~270); nothing inside the timeout + // is an escalation, however many steps were already taken. + for seconds in [0.0, 0.5, 1, 5, 9.99] { + for attempt in 0...3 { + XCTAssertEqual(OuraAuthWatchdog.step(secondsSinceNonceRequest: seconds, attempt: attempt), .wait, + "\(seconds)s / attempt \(attempt) is inside the timeout") + } + } + } + + func testEscalatesInOrderThenDrops() { + // The bounded sequence: re-send once, toggle once, then drop the link. Every later attempt is a + // drop too — there is no fourth step and no wrap-around. + XCTAssertEqual(OuraAuthWatchdog.step(secondsSinceNonceRequest: 10, attempt: 0), .resendNonce) + XCTAssertEqual(OuraAuthWatchdog.step(secondsSinceNonceRequest: 10, attempt: 1), .toggleNotify) + XCTAssertEqual(OuraAuthWatchdog.step(secondsSinceNonceRequest: 10, attempt: 2), .dropLink) + for attempt in 3...10 { + XCTAssertEqual(OuraAuthWatchdog.step(secondsSinceNonceRequest: 10, attempt: attempt), .dropLink) + } + } + + func testTimeoutBoundaryIsInclusive() { + XCTAssertEqual(OuraAuthWatchdog.step(secondsSinceNonceRequest: 9.999, attempt: 0), .wait) + XCTAssertEqual(OuraAuthWatchdog.step(secondsSinceNonceRequest: 10.0, attempt: 0), .resendNonce) + } + + func testALateTimerStillEscalates() { + // iOS may hold a suspended app's timer for minutes; when it finally fires the wait is far past + // the timeout and must still take the next step, not a fresh `.wait`. + XCTAssertEqual(OuraAuthWatchdog.step(secondsSinceNonceRequest: 3_120, attempt: 0), .resendNonce) + XCTAssertEqual(OuraAuthWatchdog.step(secondsSinceNonceRequest: 3_120, attempt: 2), .dropLink) + } + + func testNegativeAttemptCountsAsNone() { + // A defensive reading of an underflowed counter: treat it as "no escalation yet". + XCTAssertEqual(OuraAuthWatchdog.step(secondsSinceNonceRequest: 10, attempt: -1), .resendNonce) + } + + // MARK: - The driver gate + + func testRetryCommandIsTheReadyStepsGetNonce() { + let d = OuraDriver(ringGen: .gen3, authKey: key) + let onReady = d.nextStep(after: .ready) + XCTAssertEqual(d.phase, .authenticating) + let retry = d.authNonceRetryCommand() + XCTAssertNotNil(retry) + XCTAssertEqual(retry?.label, "get_nonce") + XCTAssertEqual(retry?.bytes, onReady[1].bytes, "the retry is byte-identical to the first request") + XCTAssertEqual(d.phase, .authenticating, "asking for the retry never moves the phase") + } + + func testRetryCommandIsNilOutsideAuthenticating() { + // Idle: nothing has been requested yet. + let idle = OuraDriver(ringGen: .gen3, authKey: key) + XCTAssertNil(idle.authNonceRetryCommand()) + + // No key: `.ready` goes to needsKeyInstall, and there is no handshake to retry. + let keyless = OuraDriver(ringGen: .gen3, authKey: nil) + _ = keyless.nextStep(after: .ready) + XCTAssertEqual(keyless.phase, .needsKeyInstall) + XCTAssertNil(keyless.authNonceRetryCommand()) + + // Past auth: a session that reached the enable triplet / streaming must never be re-opened. + let past = OuraDriver(ringGen: .gen3, authKey: key) + _ = past.nextStep(after: .ready) + _ = past.nextStep(after: .authCompleted(.success)) + XCTAssertEqual(past.phase, .enablingLiveHR) + XCTAssertNil(past.authNonceRetryCommand()) + + // A rejected handshake is a verdict, not silence. + let failed = OuraDriver(ringGen: .gen3, authKey: key) + _ = failed.nextStep(after: .ready) + _ = failed.nextStep(after: .authCompleted(.authError)) + XCTAssertEqual(failed.phase, .authFailed(.authError)) + XCTAssertNil(failed.authNonceRetryCommand()) + + // Stopped. + let stopped = OuraDriver(ringGen: .gen3, authKey: key) + _ = stopped.nextStep(after: .ready) + stopped.stop() + XCTAssertNil(stopped.authNonceRetryCommand()) + } + + func testWatchdogNeverReachesAuthFailed() { + // The bench reproduction from the issue: drive the driver to `.authenticating`, walk the whole + // escalation with no nonce, and assert the driver is never told anything — it stays + // `.authenticating` and never becomes `.authFailed`. (The transport's drop is a link event; the + // driver only ever sees a nonce or an auth status.) + let d = OuraDriver(ringGen: .gen3, authKey: key) + _ = d.nextStep(after: .ready) + var attempt = 0 + var steps: [OuraAuthWatchdog.Step] = [] + while attempt < 3 { + let step = OuraAuthWatchdog.step(secondsSinceNonceRequest: OuraAuthWatchdog.nonceTimeout, attempt: attempt) + steps.append(step) + if step == .resendNonce || step == .toggleNotify { + XCTAssertNotNil(d.authNonceRetryCommand()) + } + attempt += 1 + } + XCTAssertEqual(steps, [.resendNonce, .toggleNotify, .dropLink]) + XCTAssertEqual(d.phase, .authenticating) + if case .authFailed = d.phase { XCTFail("silence must never become an auth verdict") } + } +} diff --git a/Packages/OuraProtocol/Tests/OuraProtocolTests/OuraDriverTests.swift b/Packages/OuraProtocol/Tests/OuraProtocolTests/OuraDriverTests.swift index 1216146e08..1f75323eac 100644 --- a/Packages/OuraProtocol/Tests/OuraProtocolTests/OuraDriverTests.swift +++ b/Packages/OuraProtocol/Tests/OuraProtocolTests/OuraDriverTests.swift @@ -28,6 +28,7 @@ final class OuraDriverTests: XCTestCase { let onReady = d.nextStep(after: .ready) XCTAssertEqual(d.phase, .authenticating) XCTAssertEqual(onReady.map { $0.label }, ["notify_all", "get_nonce"]) + XCTAssertEqual(onReady[0].bytes, [0x1C, 0x01, 0x3F]) // the default mask, unchanged XCTAssertEqual(onReady[1].bytes, [0x2F, 0x01, 0x2B]) // nonce -> submit proof. @@ -58,6 +59,46 @@ final class OuraDriverTests: XCTestCase { XCTAssertEqual(d.phase, .streaming) } + // MARK: - Suspended connect: auth success without the live-HR triplet + + /// With `liveHRWanted` cleared (the app's screen-off suspend is in force), auth success goes straight + /// to `.streaming` and writes NOTHING to the daytime-HR feature: no `dhr_read`, no `dhr_enable`, no + /// `dhr_subscribe`. The history path still works from that phase, and a stray enable ACK (the ring + /// answering something else on the shared sub-op) cannot restart the triplet. + func testAuthSuccessSkipsLiveHRTripletWhenNotWanted() { + let d = OuraDriver(ringGen: .gen3, authKey: key) + d.liveHRWanted = false + _ = d.nextStep(after: .ready) + _ = d.nextStep(after: .nonceReceived(bytes("0102030405060708090a0b0c0d0e0f"))) + + let onAuth = d.nextStep(after: .authCompleted(.success)) + XCTAssertTrue(onAuth.isEmpty, "a suspended connect must not arm daytime HR") + XCTAssertEqual(d.phase, .streaming) + + // A stray enable ACK outside `.enablingLiveHR` is inert — the triplet does not start late. + XCTAssertTrue(d.nextStep(after: .enableAckReceived).isEmpty) + XCTAssertEqual(d.phase, .streaming) + + // The drain runs from `.streaming` exactly as after a full triplet. + let fetch = d.nextStep(after: .startHistoryFetch(cursor: 0)) + XCTAssertEqual(fetch.map { $0.label }, ["flush_buffer", "get_events"]) + XCTAssertEqual(d.phase, .fetchingHistory) + _ = d.nextStep(after: .historyCursorAdvanced(cursor: 0, moreData: false)) + XCTAssertEqual(d.phase, .streaming) + } + + /// The default is the historical behaviour: `liveHRWanted` is true and auth success starts the + /// triplet with `dhr_read` (byte-for-byte the sequence `testFullEnableSequence` pins). + func testLiveHRWantedDefaultsToArmingTheTriplet() { + let d = OuraDriver(ringGen: .gen3, authKey: key) + XCTAssertTrue(d.liveHRWanted) + _ = d.nextStep(after: .ready) + _ = d.nextStep(after: .nonceReceived(bytes("0102030405060708090a0b0c0d0e0f"))) + let onAuth = d.nextStep(after: .authCompleted(.success)) + XCTAssertEqual(onAuth.map { $0.label }, ["dhr_read"]) + XCTAssertEqual(d.phase, .enablingLiveHR) + } + // MARK: - Honest pairing path when no key func testNoKeyDrivesNeedsKeyInstall() { @@ -321,7 +362,7 @@ final class OuraDriverTests: XCTestCase { // 0x7B SpO2 stable record -> one spo2 event (970, BE). let rec = OuraFraming.parseRecord(bytes("7b060200010003ca"))! let events = d.ingest(record: rec) - XCTAssertEqual(events, [.spo2(OuraSpO2(ringTimestamp: rt, value: 970))]) + XCTAssertEqual(events, [.spo2(OuraSpO2(ringTimestamp: rt, value: 970, unit: OuraSpO2Channel.stableUnit))]) } func testIngestUnknownTagYieldsNothing() { @@ -651,7 +692,7 @@ final class OuraDriverTests: XCTestCase { // The ring streams one event per notification; feed them separately, not packed. A 0x7B SpO2 // notification, then a 0x46 temp notification (whose payload holds two int16 samples). let spo2 = d.ingest(notification: bytes("7b060200010003ca"), reassembler: reassembler) - XCTAssertEqual(spo2, [.spo2(OuraSpO2(ringTimestamp: rt, value: 970))]) + XCTAssertEqual(spo2, [.spo2(OuraSpO2(ringTimestamp: rt, value: 970, unit: OuraSpO2Channel.stableUnit))]) let temp = d.ingest(notification: bytes("460802000100420e470e"), reassembler: reassembler) XCTAssertEqual(temp, [ .temp(OuraTemp(ringTimestamp: rt, celsius: 36.50)), @@ -659,15 +700,27 @@ final class OuraDriverTests: XCTestCase { ]) } - func testIngestNotificationDecodesOnlyFirstPacketWhenBytesLookPacked() { - // Defensive: if a notification ever carries bytes that LOOK like two packed records, only the - // first is decoded (one lenient packet per notification) — the trailing bytes are ignored, never - // walked into phantom records. Documents the open_oura contract. + func testIngestNotificationDecodesEveryPacketWhenTheValueTilesExactly() { + // A notification that tiles exactly into two complete packets decodes BOTH (the ring packs like + // this when serving the official app, 2026-09-15 — see `OuraReassembler.feed`). The 0x46 temp + // record carries two 0.05 °C samples, so three events come out of the one value. let d = OuraDriver(ringGen: .gen3, authKey: key) let reassembler = OuraReassembler() let value = bytes("7b060200010003ca" + "460802000100420e470e") let events = d.ingest(notification: value, reassembler: reassembler) - XCTAssertEqual(events, [.spo2(OuraSpO2(ringTimestamp: rt, value: 970))]) + XCTAssertEqual(events, [.spo2(OuraSpO2(ringTimestamp: rt, value: 970, unit: OuraSpO2Channel.stableUnit)), + .temp(OuraTemp(ringTimestamp: rt, celsius: 36.5)), + .temp(OuraTemp(ringTimestamp: rt, celsius: 36.55))]) + } + + func testIngestNotificationDecodesOnlyFirstPacketWhenTheTailDoesNotTile() { + // Defensive, the phantom-storm guarantee: bytes after the first packet that do NOT form whole + // packets ending on the value's last byte are ignored, never walked into phantom records. + let d = OuraDriver(ringGen: .gen3, authKey: key) + let reassembler = OuraReassembler() + let value = bytes("7b060200010003ca" + "460802000100420e47") // 0x46's declared len overshoots + let events = d.ingest(notification: value, reassembler: reassembler) + XCTAssertEqual(events, [.spo2(OuraSpO2(ringTimestamp: rt, value: 970, unit: OuraSpO2Channel.stableUnit))]) } // MARK: - Generation-driven command set / MTU @@ -717,4 +770,34 @@ final class OuraDriverTests: XCTestCase { XCTAssertNotEqual(OuraCommands.getBattery().bytes.first, 0x0E) XCTAssertNotEqual(OuraCommands.getBattery().bytes.first, 0x1A) } + + // MARK: - SetNotification mask (the packed-notification A/B, OURA_PROTOCOL.md s2.3) + + /// The official app's `ff` mask reaches BOTH handshake paths (`.ready` and the post-install re-auth), + /// carries its value in the label so the strap log shows which shape the session ran under, and + /// changes nothing else: same nonce request, same phases. The default stays `3f`. + func testNotificationMaskFullReachesBothHandshakePaths() throws { + XCTAssertEqual(OuraCommands.enableAllNotifications().bytes, [0x1C, 0x01, 0x3F]) + XCTAssertEqual(OuraCommands.enableAllNotifications().label, "notify_all") + XCTAssertEqual(OuraCommands.enableAllNotifications(mask: OuraCommands.notificationMaskFull).bytes, + [0x1C, 0x01, 0xFF]) + XCTAssertEqual(OuraCommands.enableAllNotifications(mask: OuraCommands.notificationMaskFull).label, + "notify_all(ff)") + + let d = OuraDriver(ringGen: .gen3, authKey: key, notificationMask: OuraCommands.notificationMaskFull) + let onReady = d.nextStep(after: .ready) + XCTAssertEqual(d.phase, .authenticating) + XCTAssertEqual(onReady.map { $0.label }, ["notify_all(ff)", "get_nonce"]) + XCTAssertEqual(onReady[0].bytes, [0x1C, 0x01, 0xFF]) + XCTAssertEqual(onReady[1].bytes, [0x2F, 0x01, 0x2B]) + + // The post-install re-auth path sends the same mask. + let installing = OuraDriver(ringGen: .gen3, authKey: nil, allowKeyInstall: true, + notificationMask: OuraCommands.notificationMaskFull) + XCTAssertEqual(installing.nextStep(after: .ready), []) + XCTAssertNotNil(installing.beginKeyInstall(key: key)) + let onAck = installing.keyInstallAcknowledged() + XCTAssertEqual(onAck.map { $0.label }, ["notify_all(ff)", "get_nonce"]) + XCTAssertEqual(onAck[0].bytes, [0x1C, 0x01, 0xFF]) + } } diff --git a/Packages/OuraProtocol/Tests/OuraProtocolTests/OuraFeatureModeWriteTests.swift b/Packages/OuraProtocol/Tests/OuraProtocolTests/OuraFeatureModeWriteTests.swift new file mode 100644 index 0000000000..fdc91bd0de --- /dev/null +++ b/Packages/OuraProtocol/Tests/OuraProtocolTests/OuraFeatureModeWriteTests.swift @@ -0,0 +1,35 @@ +import XCTest +@testable import OuraProtocol + +/// The feature-mode WRITE (`2f 03 22 `) and its generalized status read (`2f 02 20 `) — +/// UNVALIDATED on NOOP's own hardware (OURA_PROTOCOL.md s7.5). These builders are pure byte construction; +/// they carry no gate of their own, so the caller (Test Centre only, per the plan) is what keeps this +/// off the automatic connect path. +final class OuraFeatureModeWriteTests: XCTestCase { + func testFeatureReadStatusGeneralizesTheExistingSpo2AndRealStepsProbes() { + XCTAssertEqual(OuraCommands.featureReadStatus(OuraCommands.featureSpO2).bytes, + OuraCommands.spo2ReadStatus().bytes) + XCTAssertEqual(OuraCommands.featureReadStatus(OuraCommands.featureRealSteps).bytes, + OuraCommands.realStepsReadStatus().bytes) + } + + func testSetFeatureModeBytesExactForSpo2AndRealSteps() { + XCTAssertEqual(OuraCommands.setFeatureMode(OuraCommands.featureSpO2, mode: 0x01).bytes, + [0x2F, 0x03, 0x22, 0x04, 0x01]) + XCTAssertEqual(OuraCommands.setFeatureMode(OuraCommands.featureRealSteps, mode: 0x00).bytes, + [0x2F, 0x03, 0x22, 0x0B, 0x00]) + } + + func testSetFeatureModeBytesExactForExerciseHrAndCvaPpg() { + XCTAssertEqual(OuraCommands.setFeatureMode(OuraCommands.featureExerciseHR, mode: 0x01).bytes, + [0x2F, 0x03, 0x22, 0x03, 0x01]) + XCTAssertEqual(OuraCommands.setFeatureMode(OuraCommands.featureCvaPpg, mode: 0x00).bytes, + [0x2F, 0x03, 0x22, 0x0D, 0x00]) + } + + func testSetFeatureModeSubOpIsTheEnableVerbNotTheReadVerb() { + // sub-op 0x22 is the SET-MODE write; 0x20 (used by featureReadStatus) is the READ. Never confuse them. + XCTAssertEqual(OuraCommands.setFeatureMode(OuraCommands.featureSpO2, mode: 0x01).bytes[2], 0x22) + XCTAssertEqual(OuraCommands.featureReadStatus(OuraCommands.featureSpO2).bytes[2], 0x20) + } +} 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/OuraProtocol/Tests/OuraProtocolTests/OuraHistoryDrainTests.swift b/Packages/OuraProtocol/Tests/OuraProtocolTests/OuraHistoryDrainTests.swift index 621465829f..6fd6a5d7ff 100644 --- a/Packages/OuraProtocol/Tests/OuraProtocolTests/OuraHistoryDrainTests.swift +++ b/Packages/OuraProtocol/Tests/OuraProtocolTests/OuraHistoryDrainTests.swift @@ -104,6 +104,86 @@ final class OuraHistoryDrainTests: XCTestCase { "a reboot forces 0 (full pull) even if a forward sample also arrived") } + // MARK: #2097 - a SyncTime anchor confirming clock continuity overrides the reboot reset + + func testAnchorConfirmsContinuityKeepsCursorInsteadOfFullReset() { + var d = OuraHistoryDrain() + d.noteStoredRingTime(500, resumeCursorAtFetchStart: 1000) // stale sample -> sawPreResumeData + XCTAssertTrue(d.sawPreResumeData) + XCTAssertEqual(d.resumeCursorAtDrainEnd(currentCursor: 1000, resolvesUnderAnchor: true, + anchorConfirmsContinuity: true), 1000, + "continuity confirmed (#2097): not a reboot - discard the stale replay, keep the cursor") + } + + func testAnchorConfirmsContinuityStillAdvancesOnRealForwardProgress() { + var d = OuraHistoryDrain() + d.noteStoredRingTime(3_453_828, resumeCursorAtFetchStart: 1000) // forward... + d.noteStoredRingTime(500, resumeCursorAtFetchStart: 1000) // ...plus a stale replay + XCTAssertTrue(d.sawPreResumeData) + XCTAssertEqual(d.resumeCursorAtDrainEnd(currentCursor: 1000, resolvesUnderAnchor: true, + anchorConfirmsContinuity: true), 3_453_828, + "continuity confirmed: genuine forward progress still commits normally") + } + + func testAnchorContinuityDefaultsToOldRebootBehaviorWhenOmitted() { + var d = OuraHistoryDrain() + d.noteStoredRingTime(500, resumeCursorAtFetchStart: 1000) + XCTAssertEqual(d.resumeCursorAtDrainEnd(currentCursor: 1000, resolvesUnderAnchor: true), 0, + "omitting anchorConfirmsContinuity must not change today's behavior") + } + + // MARK: #2097 - anchorsAreContinuous (the SyncTime anchor comparison itself) + + func testAnchorsAreContinuousOnTheReal20260911N2Gap() { + // The witnessed n=2 occurrence: 12:29:26 -> 14:00:02 local, 5436s wall, 54357 ring ticks + // (~9.999 ticks/s) spanning the Oura-app handoff. The ring's clock never paused. + let previous = (ringTicks: UInt32(42_236_951), unixSeconds: Int64(0)) + let current = (ringTicks: UInt32(42_291_308), unixSeconds: Int64(5436)) + XCTAssertTrue(OuraHistoryDrain.anchorsAreContinuous(previous: previous, current: current)) + } + + func testAnchorsAreContinuousDetectsARealPause() { + // A genuine power-cycle: ticks paused for 821s (matches the measured 13m41s dead-battery + // reboot) somewhere inside a 3600s wall-clock gap. + let previous = (ringTicks: UInt32(1_000_000), unixSeconds: Int64(0)) + let tickedSeconds = 3600 - 821 + let current = (ringTicks: UInt32(1_000_000) + UInt32(tickedSeconds * 10), unixSeconds: Int64(3600)) + XCTAssertFalse(OuraHistoryDrain.anchorsAreContinuous(previous: previous, current: current)) + } + + func testAnchorsAreContinuousToleratesOrdinaryJitter() { + // 5s of receipt-latency noise across an otherwise-continuous gap must not false-positive as a + // pause (well under anchorContinuityMaxPauseSeconds). + let previous = (ringTicks: UInt32(1_000_000), unixSeconds: Int64(0)) + let current = (ringTicks: UInt32(1_000_000 + 3_000), unixSeconds: Int64(305)) // 305s wall, 300s ticked + XCTAssertTrue(OuraHistoryDrain.anchorsAreContinuous(previous: previous, current: current)) + } + + func testAnchorsAreContinuousDeclinesOnTooShortAGap() { + let previous = (ringTicks: UInt32(1_000_000), unixSeconds: Int64(0)) + let current = (ringTicks: UInt32(1_000_050), unixSeconds: Int64(5)) // below the 10s minimum + XCTAssertFalse(OuraHistoryDrain.anchorsAreContinuous(previous: previous, current: current), + "too short a gap to judge - declines rather than guessing") + } + + func testAnchorsAreContinuousOnTheReal20260912FastReconnect() { + // 2026-09-12 19:38:56 -> 19:39:23: an app relaunch (Xcode install), state restoration resumed + // the still-connected ring, and the next connect came 27s later with a stale replay. Ring + // ticks 43358625 -> 43358893 = 268 ticks over 27s wall (9.93/s): plainly continuous, but the + // original 30s floor declined and the old full re-pull from 2026-07-24 fired. Must judge, and + // must say continuous. + let previous = (ringTicks: UInt32(43_358_625), unixSeconds: Int64(0)) + let current = (ringTicks: UInt32(43_358_893), unixSeconds: Int64(27)) + XCTAssertTrue(OuraHistoryDrain.anchorsAreContinuous(previous: previous, current: current), + "a 27s gap with a continuous ring clock is judgeable and continuous") + } + + func testAnchorsAreContinuousDeclinesWhenTicksWentBackward() { + let previous = (ringTicks: UInt32(1_000_000), unixSeconds: Int64(0)) + let current = (ringTicks: UInt32(999_000), unixSeconds: Int64(100)) // never observed; decline, not confirm + XCTAssertFalse(OuraHistoryDrain.anchorsAreContinuous(previous: previous, current: current)) + } + // MARK: loaded-cursor sanitize + reset func testSanitizeLoadedCursor() { @@ -180,4 +260,46 @@ final class OuraHistoryDrainTests: XCTestCase { XCTAssertEqual(d.maxStoredRingTime, 2_900_000) XCTAssertEqual(d.maxSeenRingTime, 3_000_000, "stored notes don't inflate the seen max") } + + /// `predatesResume` is the ONE comparison behind `sawPreResumeData`, applied to whole hypnogram + /// bursts by the app layer (a re-served night's tail must not become a phantom session — 2026-09-13). + /// The expected column is the standalone-compiled Swift twin's stdout, verbatim, over the same cases the + /// Kotlin test carries, so both platforms are pinned to the same table rather than to each other. + func testPredatesResumeOracleTable() { + let cases: [(UInt32, UInt32)] = [ + (42_867_280, 43_895_637), (43_731_586, 43_895_637), (43_895_636, 43_895_637), (43_895_637, 43_895_637), + (43_895_638, 43_895_637), (43_897_237, 43_895_637), (0, 43_895_637), (1, 43_895_637), + (42_867_280, 0), (0, 0), (UInt32.max, 0), (UInt32.max, UInt32.max), (UInt32.max - 1, UInt32.max), + (5, 1), (1, 5), (0, 1), + ] + let got = cases.map { "\($0.0),\($0.1),\(OuraHistoryDrain.predatesResume(ringTime: $0.0, resumeCursorAtFetchStart: $0.1))" } + .joined(separator: "\n") + XCTAssertEqual(got, """ +42867280,43895637,true +43731586,43895637,true +43895636,43895637,true +43895637,43895637,false +43895638,43895637,false +43897237,43895637,false +0,43895637,true +1,43895637,true +42867280,0,false +0,0,false +4294967295,0,false +4294967295,4294967295,false +4294967294,4294967295,true +5,1,false +1,5,true +0,1,true +""") + } + + func testPredatesResumeIsWhatFlagsPreResumeData() { + var d = OuraHistoryDrain() + d.noteStoredRingTime(43_731_586, resumeCursorAtFetchStart: 43_895_637) + XCTAssertTrue(d.sawPreResumeData, "the drain flag and the burst gate must agree on the same sample") + var e = OuraHistoryDrain() + e.noteStoredRingTime(43_897_237, resumeCursorAtFetchStart: 43_895_637) + XCTAssertFalse(e.sawPreResumeData) + } } diff --git a/Packages/OuraProtocol/Tests/OuraProtocolTests/OuraSpO2ChannelTests.swift b/Packages/OuraProtocol/Tests/OuraProtocolTests/OuraSpO2ChannelTests.swift new file mode 100644 index 0000000000..adc7d51ab2 --- /dev/null +++ b/Packages/OuraProtocol/Tests/OuraProtocolTests/OuraSpO2ChannelTests.swift @@ -0,0 +1,113 @@ +import XCTest +@testable import OuraProtocol + +/// `OuraSpO2Channel` is the one resolver that says what an SpO2 sample's number IS. It exists because +/// the strap log used to print a 0x77 perfusion magnitude and a 0x6F percentage under the same words, +/// on the same ring, minutes apart. These tests pin the two things that made that possible: the unit +/// tags the decoders actually stamp, and the resolver's disposition for each. +final class OuraSpO2ChannelTests: XCTestCase { + + // MARK: - The resolver itself + + func testPerfusionUnitResolvesToPerfusion() { + XCTAssertEqual(OuraSpO2Channel.forUnit("dc_raw"), .perfusion) + XCTAssertEqual(OuraSpO2Channel.perfusionUnit, "dc_raw") + } + + func testPercentageUnitResolvesToPercentage() { + XCTAssertEqual(OuraSpO2Channel.forUnit("raw"), .percentage) + } + + /// Both known tags match EXACTLY; anything else is `.unknown`. Treating "not perfusion" as a + /// percentage would print a case variant or a future tag's magnitude with a `%` on it — the defect + /// this type exists to stop, in a new costume. + func testUnknownUnitIsUnknownNotAPercentage() { + XCTAssertEqual(OuraSpO2Channel.forUnit("raw_adc"), .unknown) + XCTAssertEqual(OuraSpO2Channel.forUnit(""), .unknown) + XCTAssertEqual(OuraSpO2Channel.forUnit("DC_RAW"), .unknown) // case-sensitive, like the tag + XCTAssertEqual(OuraSpO2Channel.forUnit("RAW"), .unknown) + XCTAssertEqual(OuraSpO2Channel.percentageUnit, "raw") + } + + /// An unknown channel names its tag and never carries a `%`. + func testUnknownChannelLineHasNoPercentSign() { + let line = OuraSpO2Channel.firstDecodedLogLine(value: 3, unit: "DC_RAW") + XCTAssertEqual(line, + #"first SpO2 sample on an unrecognised channel (NOT known to be a percentage) decoded (last night) - 3 (channel "DC_RAW")"#) + XCTAssertFalse(line.contains("%"), line) + } + + func testSampleAccessorMatchesTheResolver() { + let pct = OuraSpO2(ringTimestamp: 1, value: 93) + let dc = OuraSpO2(ringTimestamp: 1, value: 101_144, unit: "dc_raw") + XCTAssertEqual(pct.channel, .percentage) + XCTAssertEqual(dc.channel, .perfusion) + } + + // MARK: - The log line + + /// The exact strings a reader will see. Pinned here so a rename on THIS side is a failing test + /// rather than a silent divergence from `OuraSpO2ChannelOracleTest`, whose literals are this + /// function's own stdout. The two directions together are what stop either platform drifting. + func testFirstDecodedLogLineText() { + XCTAssertEqual(OuraSpO2Channel.firstDecodedLogLine(value: 93, unit: "raw"), + #"first SpO2 percentage decoded (last night) - 93 % (channel "raw")"#) + XCTAssertEqual(OuraSpO2Channel.firstDecodedLogLine(value: 101_144, unit: "dc_raw"), + #"first SpO2 raw DC perfusion (NOT a percentage) decoded (last night) - 101144 (channel "dc_raw")"#) + } + + /// A negative perfusion magnitude is real (the 0x77 accumulator goes below zero) and must not pick + /// up a `%`. This is the original defect in its most misleading form: `-288 %`. + func testNegativePerfusionNeverGetsAPercentSign() { + let line = OuraSpO2Channel.firstDecodedLogLine(value: -288, unit: "dc_raw") + XCTAssertFalse(line.contains("%"), line) + XCTAssertTrue(line.contains("NOT a percentage"), line) + } + + /// The percentage channel keeps its unit tag in the text, so a log still ties back to the decoder + /// even though the words no longer repeat the tag. + func testPercentageLineStillNamesItsUnitTag() { + XCTAssertTrue(OuraSpO2Channel.firstDecodedLogLine(value: 95, unit: "raw").contains(#"channel "raw""#)) + } + + // MARK: - The decoders really do stamp those tags + + /// Without this the resolver could be right about strings nothing produces. 0x6F yields the + /// percentage channel; 0x77 yields perfusion. Both are fed real-shaped bodies, not hand-set units. + func testDecodedSpO2PerSampleIsThePercentageChannel() throws { + let rec = OuraRecord(type: 0x6F, ringTimestamp: 100, payload: [0x00, 95, 96, 97]) + let out = try XCTUnwrap(OuraDecoders.decodeSpO2PerSample(rec)) + XCTAssertFalse(out.isEmpty) + XCTAssertTrue(out.allSatisfy { $0.channel == .percentage }, "0x6F must read as a percentage") + } + + func testDecodedSpO2DCIsThePerfusionChannel() throws { + // hasBase (bit 6) + a 24-bit LE base, then one sign-magnitude delta. + let rec = OuraRecord(type: 0x77, ringTimestamp: 100, + payload: [0x40, 0x2C, 0xA0, 0x00, 0x05]) + let out = try XCTUnwrap(OuraDecoders.decodeSpO2DC(rec)) + XCTAssertFalse(out.isEmpty) + XCTAssertTrue(out.allSatisfy { $0.channel == .perfusion }, "0x77 must read as perfusion") + // And the magnitudes really are the three-orders-of-magnitude-apart kind that started this. + XCTAssertGreaterThan(out[0].value, 100, "a perfusion base is not a percentage") + } + + /// 0x7B carries a single BIG-endian u16 whose scale nothing pins (the golden vector decodes to 970), + /// so it gets its own tag and resolves to `.unknown` — NOT `.percentage`. Sharing the 0x6F tag let it + /// through `OuraStreamMapping`'s percentage-only persist gate into the SpO2 % column. + func testDecodedSpO2StableIsNotThePercentageChannel() throws { + let rec = OuraRecord(type: 0x7B, ringTimestamp: 100, payload: [0x00, 0x60]) + let s = try XCTUnwrap(OuraDecoders.decodeSpO2Stable(rec)) + XCTAssertEqual(s.unit, OuraSpO2Channel.stableUnit) + XCTAssertEqual(s.channel, .unknown) + XCTAssertNotEqual(OuraSpO2Channel.stableUnit, OuraSpO2Channel.percentageUnit) + } + + /// The 0x7B log line never claims a percentage. + func testStableChannelLineHasNoPercentSign() { + let line = OuraSpO2Channel.firstDecodedLogLine(value: 970, unit: OuraSpO2Channel.stableUnit) + XCTAssertEqual(line, + #"first SpO2 sample on an unrecognised channel (NOT known to be a percentage) decoded (last night) - 970 (channel "stable_raw")"#) + XCTAssertFalse(line.contains("%"), line) + } +} diff --git a/Packages/OuraProtocol/Tests/OuraProtocolTests/UserInfoTests.swift b/Packages/OuraProtocol/Tests/OuraProtocolTests/UserInfoTests.swift new file mode 100644 index 0000000000..ba0d2d7051 --- /dev/null +++ b/Packages/OuraProtocol/Tests/OuraProtocolTests/UserInfoTests.swift @@ -0,0 +1,130 @@ +import XCTest +@testable import OuraProtocol + +/// Pins the `0x20` user-info setters against the shapes [open_oura-cheat] records as tested-success +/// on a Ring 3, and guards the encoding helpers that turn a NOOP profile into value bytes. +final class UserInfoTests: XCTestCase { + + private func hex(_ c: OuraCommand) -> String { + c.bytes.map { String(format: "%02x", $0) }.joined() + } + + /// The five zero-writes upstream actually put on a ring and got `result 0` back for. If any of + /// these strings changes, our builder has drifted from the only shapes known to be accepted. + func testClearCommandsMatchTheUpstreamTestedShapes() { + XCTAssertEqual(hex(OuraUserInfoWrite.clear(.gender)), "2003020000") + XCTAssertEqual(hex(OuraUserInfoWrite.clear(.height)), "200403000000") + XCTAssertEqual(hex(OuraUserInfoWrite.clear(.weight)), "200404000000") + XCTAssertEqual(hex(OuraUserInfoWrite.clear(.unit)), "2003060000") + XCTAssertEqual(hex(OuraUserInfoWrite.clear(.dateOfBirth)), "200a05000000000000000000") + } + + /// `len` is 1 (the type byte) + the field's value width, for every field. + func testLengthByteCountsTypePlusValue() { + for field in OuraUserInfoField.allCases { + let bytes = OuraUserInfoWrite.clear(field).bytes + XCTAssertEqual(bytes[0], 0x20, "\(field) opcode") + XCTAssertEqual(Int(bytes[1]), 1 + field.valueByteCount, "\(field) len") + XCTAssertEqual(bytes[2], field.rawValue, "\(field) type") + XCTAssertEqual(bytes.count, 2 + 1 + field.valueByteCount, "\(field) total") + } + } + + /// The candidate encoding: whole cm / whole kg, little-endian. 175 -> af 00 00, 62 -> 3e 00 00. + func testProfileValuesEncodeLittleEndianInWholeUnits() throws { + XCTAssertEqual(hex(try OuraUserInfoWrite.height(cm: 175)), "200403af0000") + XCTAssertEqual(hex(try OuraUserInfoWrite.weight(kg: 62)), "2004043e0000") + // A value that needs a second byte must land in the second byte, not be truncated. + XCTAssertEqual(try OuraUserInfoWrite.encodeLE(300, width: 3), [0x2C, 0x01, 0x00]) + } + + /// Truncation would put a plausible-looking wrong number on the ring, so it must throw instead. + func testOverWideValueThrowsRatherThanTruncating() { + XCTAssertThrowsError(try OuraUserInfoWrite.encodeLE(256, width: 1)) { error in + XCTAssertEqual(error as? OuraUserInfoError, .valueOutOfRange(value: 256, width: 1)) + } + XCTAssertEqual(try? OuraUserInfoWrite.encodeLE(255, width: 1), [0xFF]) + } + + /// A wrong-width value is an untested wire shape and must never be built. + func testWrongValueWidthThrows() { + XCTAssertThrowsError(try OuraUserInfoWrite.command(.height, value: [0x01])) { error in + XCTAssertEqual(error as? OuraUserInfoError, + .wrongValueWidth(field: .height, expected: 3, got: 1)) + } + } + + /// male/female map to the `0x5c` codes; everything else takes the ring's own neutral path. + func testGenderCodeMapping() { + XCTAssertEqual(OuraUserInfoWrite.genderCode(forSex: "male"), 0) + XCTAssertEqual(OuraUserInfoWrite.genderCode(forSex: "female"), 1) + XCTAssertEqual(OuraUserInfoWrite.genderCode(forSex: "nonbinary"), 2) + XCTAssertEqual(OuraUserInfoWrite.genderCode(forSex: "Male"), 0, "case-insensitive") + XCTAssertEqual(OuraUserInfoWrite.genderCode(forSex: ""), 2, "unset is unspecified") + } + + /// Age has no setter - the builder returns nil rather than inventing a DOB layout. + func testNoAgeSetterIsOfferedYet() { + XCTAssertNil(OuraUserInfoWrite.dateOfBirthCommand(year: 1963, month: 5, day: 27)) + } + + /// The `0x21` ack parses only its exact shape. + func testAckParsing() { + XCTAssertEqual(parseOuraUserInfoAck([0x21, 0x02, 0x03, 0x00]), + OuraUserInfoAck(field: .height, result: 0)) + XCTAssertEqual(parseOuraUserInfoAck([0x21, 0x02, 0x04, 0x07])?.isSuccess, false) + XCTAssertNil(parseOuraUserInfoAck([0x21, 0x02, 0x99, 0x00]), "unknown field type") + XCTAssertNil(parseOuraUserInfoAck([0x21, 0x02, 0x03]), "short frame") + XCTAssertNil(parseOuraUserInfoAck([0x2F, 0x02, 0x03, 0x00]), "not a 0x21 frame") + } + + /// Every builder is labelled EXPERIMENT_ so a strap log never reads as routine traffic. + func testAllWritesAreLabelledExperiment() { + for field in OuraUserInfoField.allCases { + XCTAssertTrue(OuraUserInfoWrite.clear(field).label.hasPrefix("EXPERIMENT_"), + "\(field) label: \(OuraUserInfoWrite.clear(field).label)") + } + } + + // MARK: - 0x5c read-back + + /// The record every capture of this ring has produced, decoded under [open_oura-evt]'s inference. + func testDecodesTheObservedDefaultRecord() { + let bytes: [UInt8] = [0x5C, 0x08, 0xFC, 0xEA, 0x5D, 0x01, 0x28, 0x4B, 0x02, 0xB0] + let recs = scanOuraUserInfoRecords(tlvBytes: bytes) + XCTAssertEqual(recs.count, 1) + let r = try! XCTUnwrap(recs.first) + XCTAssertEqual(r.ringTimestamp, 0x015D_EAFC) + XCTAssertEqual(r.ageYears, 40) + XCTAssertEqual(r.weightKg, 75) + XCTAssertEqual(r.sexCode, 2) + XCTAssertEqual(r.heightCm, 176) + XCTAssertEqual(r.rawHex, "284b02b0") + XCTAssertTrue(r.isFirmwareDefault) + } + + /// A written-through value must read back as NOT the default - that is the experiment's readout. + func testANonDefaultRecordIsNotFlaggedDefault() { + let bytes: [UInt8] = [0x5C, 0x08, 0x00, 0x00, 0x00, 0x00, 0x3F, 0x3E, 0x00, 0xAF] + let r = try! XCTUnwrap(scanOuraUserInfoRecords(tlvBytes: bytes).first) + XCTAssertEqual([r.ageYears, r.weightKg, r.sexCode, r.heightCm], [63, 62, 0, 175]) + XCTAssertFalse(r.isFirmwareDefault) + } + + /// The scanner walks a concatenated notification and picks 0x5c out from among other tags. + func testScannerFindsTheRecordAmongOtherTags() { + let bytes: [UInt8] = [0x43, 0x03, 0x01, 0x02, 0x03] // some other tag + + [0x5C, 0x08, 0x01, 0x00, 0x00, 0x00, 0x28, 0x4B, 0x02, 0xB0] + + [0x4A, 0x04, 0x00, 0x00, 0x00, 0x00] // and another + let recs = scanOuraUserInfoRecords(tlvBytes: bytes) + XCTAssertEqual(recs.count, 1) + XCTAssertEqual(recs.first?.heightCm, 176) + } + + /// A truncated tail must stop the walk, never crash - this runs on every inbound notification. + func testTruncatedTailIsIgnoredNotFatal() { + XCTAssertEqual(scanOuraUserInfoRecords(tlvBytes: [0x5C, 0x08, 0x01, 0x02]).count, 0) + XCTAssertEqual(scanOuraUserInfoRecords(tlvBytes: []).count, 0) + XCTAssertEqual(scanOuraUserInfoRecords(tlvBytes: [0x5C]).count, 0) + } +} 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..9d533a876c --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/AlarmReadback.swift @@ -0,0 +1,94 @@ +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. +/// +/// #2322 extends that from the same strap to the same ARM. `alarm.lastReportedEpoch` is written ONLY when +/// the readback frame decodes; the decode-failure branch logs raw hex and leaves the old value standing. +/// So an arm whose readback never decoded leaves a FRESH sent epoch beside a STALE reported one, and the +/// export then prints a confident "strap didn't accept the time" assembled from two different arms. +/// Decode failures are not hypothetical: the strap log attached to issue #2302 carries one on a +/// neighbouring frame, which is where this was noticed. That issue is about HRV, not alarms. +/// +/// The arrival times were already persisted for both halves (`alarm.lastArmAt`, `alarm.lastReportedAt`) and +/// simply were not consulted; comparing them is the whole guard. +/// +/// 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 + /// The readback on hand is OLDER than the arm it is being compared against, so it answered an + /// EARLIER arm. Same strap, but not the same question, and nothing can be concluded. + case staleReadback + } + + /// `sentAt` and `reportedAt` are WALL-CLOCK arrival stamps for the two halves, in whatever unit this + /// platform's store already holds (Apple: `Date().timeIntervalSince1970`). Only their ORDER is read, + /// never their difference, so the unit does not have to match the Kotlin twin's; both sides compare + /// two stamps taken from their OWN clock. `nil` on either means "not recorded" (an install that + /// predates the keys), and staleness is then not judged rather than guessed. + public static func verdict( + sentEpoch: Int, + reportedEpoch: Int, + sentDeviceId: String?, + reportedDeviceId: String?, + // Ordered to match the Kotlin twin, where `toleranceS` has to keep its historical position + // because Kotlin permits positional calls. Labels make the order harmless here; matching it + // keeps the two signatures readable side by side. + toleranceS: Int = AlarmReadback.toleranceS, + sentAt: Double? = nil, + reportedAt: Double? = nil + ) -> Verdict { + guard let sentDeviceId, !sentDeviceId.isEmpty, + let reportedDeviceId, !reportedDeviceId.isEmpty else { return .unattributed } + guard sentDeviceId == reportedDeviceId else { return .differentStrap } + // Ordered AFTER attribution on purpose: a cross-strap pair is unusable whether or not it is also + // stale, and naming the strap problem first sends the reader to the real one. + if let sentAt, let reportedAt, sentAt > 0, reportedAt > 0, reportedAt < sentAt { + return .staleReadback + } + 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)" + case .staleReadback: return " (readback predates this arm — not comparable)" + } + } + + /// Whether this verdict may advance the consecutive-rejection streak. Only a proven same-strap, + /// same-arm disagreement counts: an unattributed, cross-strap or stale reading must leave the streak + /// untouched rather than reset it, since none of the three 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/AnalysisPhaseTally.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalysisPhaseTally.swift new file mode 100644 index 0000000000..29778d3b99 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalysisPhaseTally.swift @@ -0,0 +1,46 @@ +import Foundation + +/// Where an `analyzeRecent` pass spends the time AFTER its per-day loop. +/// +/// #1538 added a cost line, but it brackets the day loop and is emitted the moment that loop returns. Every +/// phase after it — the baseline folds, pass 2, the persist, the weekly metrics, the steps calibration, the +/// sleep writes and heals, the workout rescore — was outside the tally, so a pass whose time went somewhere +/// after the loop reported a small `prep`/`score` and no explanation for the rest. The steps calibration +/// alone re-folded sixty days of gravity per pass under that blind spot. +/// +/// This is the same idea at the other end of the pass: stamp each phase as it completes and print them in +/// order, so the dominant phase names itself instead of being inferred from what the day-loop line does not +/// account for. +public enum AnalysisPhaseTally { + /// Render ordered `(name, seconds)` phases as one line, each in milliseconds. + /// + /// Phases print in the order given, which is execution order — reading the line top to bottom is reading + /// the pass front to back. The total is included because the phases deliberately do NOT have to sum to + /// the pass: anything not bracketed shows up as the difference, which is itself the signal that a phase + /// is still unmeasured. + /// + /// `scope` names WHAT was bracketed, and it is a parameter rather than a constant because the two + /// platforms can afford to measure different amounts: Android's `analyzeRecentOnCpu` sits under a + /// JaCoCo method-size ratchet that leaves no room for the marks, so it brackets only its persist + /// helper. A line that said `postLoop` while timing part of the post-loop would misreport its own + /// total, which is the one thing a cost line must not do. Byte-identical to the Kotlin + /// `AnalysisPhaseTally.logLine`. + public static func logLine(scope: String, _ phases: [(name: String, seconds: Double)]) -> String { + let parts = phases.map { "\($0.name)=\(millis($0.seconds))ms" } + let total = phases.reduce(0.0) { $0 + $1.seconds } + return "analyzeRecent \(scope) total=\(millis(total))ms " + + (parts.isEmpty ? "(no phases)" : parts.joined(separator: " ")) + } + + /// Seconds to whole milliseconds, floored at zero. + /// + /// The clamp is the parity contract, not defensive habit. These are `Date()` differences, and a wall + /// clock that steps backwards mid-pass (an NTP correction is enough) yields a negative interval — the + /// one input where Swift's round-half-away-from-zero and Kotlin's round-half-up disagree, at exactly + /// -0.5 ms. Clamping first makes both sides provably identical instead of identical only while the + /// clock behaves. A phase cannot take less than no time, so nothing true is lost. + private static func millis(_ seconds: Double) -> Int { + guard seconds.isFinite, seconds > 0 else { return 0 } + return Int((seconds * 1000).rounded()) + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift index 48b55ce8a3..1644b6a803 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift @@ -110,6 +110,10 @@ public enum AnalyticsEngine { /// so the caller persists NULL there rather than a fabricated array. Feeds the H7 re-onset CONFIRM /// guard on the NEXT pass; never overrides the derived hypnogram. Empty on a WHOOP 4.0. (#175) public let sessionSleepStateByStart: [Int: [Int]] + /// The bounds of the day's MAIN-night group, the SAME `mainGroup` the sleep aggregates and the + /// refused-main-night HRV rule use; empty when the day has no main night. Exposed so the `hrv diag` + /// line can describe the night the #1118 gate actually judged instead of re-deriving it (#2425). + public let mainNightBlocks: [SleepStageTotals.NightBlock] public init(daily: DailyMetric, sleepSessions: [SleepSession], cachedSleep: [CachedSleepSession], workouts: [ExerciseSession], @@ -122,8 +126,10 @@ public enum AnalyticsEngine { sessionSleepStateByStart: [Int: [Int]] = [:], chargeDrivers: [ChargeDriver] = [], skinTempRelative: SkinTempRelative? = nil, - detectionFunnel: WorkoutDetector.DetectionFunnel? = nil) { + detectionFunnel: WorkoutDetector.DetectionFunnel? = nil, + mainNightBlocks: [SleepStageTotals.NightBlock] = []) { self.daily = daily; self.sleepSessions = sleepSessions + self.mainNightBlocks = mainNightBlocks self.cachedSleep = cachedSleep; self.workouts = workouts self.detectionFunnel = detectionFunnel self.recovery = recovery; self.strain = strain @@ -175,6 +181,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 +306,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 +519,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 +572,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 +687,33 @@ 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 + // Resting Heart Rate: Use PrimarySessionRestingHR (arithmetic sample mean of the longest/primary + // sleep session, #1169), eliminating daytime nap floor distortion. + // #804: Preserve ring/device-provided resting HR when present in `providedSleep`. + // Cleanly falls back to physiologySessions.compactMap { $0.restingHR }.min() when coverage is sparse. + let providedPrimaryRHR = physiologySessions.max(by: { ($0.end - $0.start) < ($1.end - $1.start) }) + .flatMap { p in providedSleep.first(where: { $0.start == p.start && $0.end == p.end })?.restingHR } + let restingHRDaily: Int? = providedPrimaryRHR + ?? primarySessionRestingHR(sessions: physiologySessions, hr: hr).map { Int($0.rounded()) } + ?? physiologySessions.compactMap { $0.restingHR }.min() // Daily avg HRV = in-bed-weighted mean of per-session avg HRV. let avgHRVDaily: Double? = { if deepHrvWindow { @@ -652,13 +723,27 @@ 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 + // A main night REFUSED by the #1118 over-count gate is not replaced by the day's naps. The + // day-wide pool below rests on "the main overnight dominates" (see `physiologySessions`), which + // holds only while that night has a value: once the gate makes it nil it carries zero weight, + // and a 26-minute nap became 100 % of the day's HRV and was folded into the baseline as a + // night. So when a main-group session's HRV is nil BECAUSE the gate refused it, only the main + // group is pooled, and the day holds (nil) unless another fragment of that night measured + // cleanly. A main night that simply banked no R-R is not refused, so #1884's fill-in from the + // day's other sessions is unchanged. Byte-parity twin of Kotlin `avgHRVDaily`. + let mainNightRefused = mainGroup.contains { s in + s.avgHRV == nil && SleepStager.sessionHrvOverCounted(start: s.start, end: s.end, rr: rr) + } + let hrvPool = mainNightRefused + ? physiologySessions.filter { p in mainGroup.contains { $0.start == p.start && $0.end == p.end } } + : physiologySessions + let pairs = hrvPool.compactMap { s -> (Double, Double)? in s.avgHRV.map { ($0, Double(s.end - s.start)) } } guard !pairs.isEmpty else { return nil } @@ -675,7 +760,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) }() @@ -712,7 +797,33 @@ public enum AnalyticsEngine { // `wholeNight` is the pooled-window mean it equals on single-session nights and the apples-to- // apples baseline for the deepOnly/lastSWS comparison (all three are pooled window means). let reported = avgHRVDaily.map { "\(r2($0))ms" } ?? "nil" - hrvTraceSink("hrv nightSummary reported=\(reported) wholeNight=\(meanMs(withR)) deepOnly=\(meanMs(deepW)) lastSWS=\(meanMs(lastSws)) nWin=\(withR.count) nDeep=\(deepW.count)") + // #2128: say WHY `reported` is nil, but ONLY when the night printed real window means beside + // it. That is the confusing case: a nil next to `wholeNight=31.55ms` reads as a value that went + // missing, when the usual cause is the #1118 gate refusing an over-counted night on purpose. A + // night with no windows at all explains itself and pays nothing here. + // + // The `hrv diag` row above carries `rrIntegrity`, but that verdict is scored over the whole DAY + // while the gate runs per SESSION, so the two disagree exactly when it matters: a day reading + // `underCovered` can still hold a session the gate refused. Reading the adjacent line is what + // led #2128 to be filed against intended behaviour. + // + // Derived from the two existing calls rather than by re-classifying the beats. Inside + // `sessionAvgHRV` the ONLY paths to nil are "no window yielded an RMSSD" and the gate, so + // windows-with-RMSSD plus a nil value IS the gate, with nothing restated that could later + // disagree with it. It says `overCount` rather than naming a verdict because that function pins + // every over-count to crossSecondOverCount internally to avoid a sort, so the specific label + // would be a distinction it does not actually make. + // + // NOT in deep-window mode. That branch re-derives from `sessionHrvWindows` and never reads + // `s.avgHRV`, so the gate plays no part in its nil and naming it would be a diagnostic + // asserting a cause it did not verify. `nDeep` on this same line already explains that case. + let refused = !deepHrvWindow && avgHRVDaily == nil && !withR.isEmpty + && physiologySessions.contains { s in + SleepStager.sessionHrvWindows(start: s.start, end: s.end, rr: rrSorted, stages: []) + .contains { $0.rmssd != nil } + && SleepStager.sessionAvgHRV(start: s.start, end: s.end, rr: rrSorted) == nil + } + hrvTraceSink("hrv nightSummary reported=\(reported) \(refused ? "refused=overCount " : "")wholeNight=\(meanMs(withR)) deepOnly=\(meanMs(deepW)) lastSWS=\(meanMs(lastSws)) nWin=\(withR.count) nDeep=\(deepW.count)") } // Nightly APPROXIMATE respiratory rate (breaths/min) from the R-R stream via @@ -801,8 +912,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 @@ -900,7 +1015,21 @@ public enum AnalyticsEngine { activeKcalEst: activeKcalEst, spo2Red: nightlySpo2Raw?.red, spo2Ir: nightlySpo2Raw?.ir, - avgSdnn: avgSDNNDaily) + avgSdnn: avgSDNNDaily, + // 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, + // "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 ──────────────────────────────────────────────────────── @@ -948,11 +1077,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, @@ -965,7 +1097,31 @@ public enum AnalyticsEngine { sessionSleepStateByStart: sessionSleepStateByStart, chargeDrivers: chargeDrivers, skinTempRelative: skinTempRelative, - detectionFunnel: detectionFunnel) + detectionFunnel: detectionFunnel, + mainNightBlocks: mainGroup.map { SleepStageTotals.NightBlock(start: $0.start, end: $0.end) }) + } + + /// The R-R rows the always-on `hrv diag` line describes (#2425). Pure. Byte-parity twin of Kotlin + /// `AnalyticsEngine.hrvDiagnosticRows`. + /// + /// The line used to pool every sleep session of the day and divide their beat-time by first-to-last + /// beat, so the hours BETWEEN a night and a nap entered the denominator. On a real day whose main night + /// the #1118 gate refused at 1.31 coverage, the pooled line printed `coverage=0.48 + /// rrIntegrity=underCovered`: the log said "not an over-count" about a night refused for one, and the + /// HRV card's over-count flag, read from the same verdict, agreed with the log rather than the gate. + /// + /// So the line now reads the MAIN-night group (`DayResult.mainNightBlocks`, the same group scoring uses) + /// over the gate's own inclusive `[start, end]` window. On a one-block night that is exactly the set of + /// beats `SleepStager.sessionHrvOverCounted` judged, so the over-count half of the verdict cannot + /// disagree with the gate. A bridged multi-fragment night still spans its short bridge gap, which can + /// only pull coverage DOWN. A day with no main night keeps the old pooled set (`fallback`), since there + /// is no night to describe. + public static func hrvDiagnosticRows(_ rr: [RRInterval], mainNight: [SleepStageTotals.NightBlock], + fallback: [SleepStageTotals.NightBlock]) -> [RRInterval] { + if mainNight.isEmpty { + return rr.filter { r in fallback.contains { r.ts >= $0.start && r.ts < $0.end } } + } + return rr.filter { r in mainNight.contains { r.ts >= $0.start && r.ts <= $0.end } } } // MARK: - Rest composite (Charge/Effort/Rest) @@ -1153,10 +1309,18 @@ public enum AnalyticsEngine { /// No wear gate (unlike skin temp): the strap streams SpO2 only on-wrist, so there's nothing to /// exclude, and this name — matching the Kotlin `nightlySpo2RawMeans` twin — avoids the "worn" /// prefix's false implication of a gate. (#93) + /// + /// TWO-CHANNEL ROWS ONLY (`ir > 0`). The same table holds the Oura ring's single-channel rows, which + /// `OuraStreamMapping` writes as `red` = the ring's reading and `ir = 0` (an unread channel). Averaging + /// those in produced an `ir` mean of 0 beside a `red` mean of ~97, so the Health "Raw SpO₂" tile — the + /// `(red + ir) / 2` ADC mean — read a ring night's ~97 % as "~49 ADC"; a pre-#2152 `dc_raw` row (up to + /// ~11.7M) inflated it further. A ring has no red/IR ADC pair, so its night yields nil here and the + /// tile stays empty; its SpO2 lives on the `nightlySpo2CeilingMean` path. A WHOOP 4.0 sample with a + /// zero IR reading was never a usable ADC pair either. Byte-parity twin of the Kotlin function. static func nightlySpo2RawMeans(_ sessions: [SleepSession], spo2: [SpO2Sample]) -> (red: Int, ir: Int)? { guard !sessions.isEmpty, !spo2.isEmpty else { return nil } var redSum = 0, irSum = 0, kept = 0 - for s in spo2 where sessions.contains(where: { $0.start <= s.ts && s.ts <= $0.end }) { + for s in spo2 where s.ir > 0 && sessions.contains(where: { $0.start <= s.ts && s.ts <= $0.end }) { redSum += s.red; irSum += s.ir; kept += 1 } guard kept > 0 else { return nil } diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyzeRecentDayCache.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyzeRecentDayCache.swift index 8654ee8bd1..37f7e31550 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyzeRecentDayCache.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyzeRecentDayCache.swift @@ -17,6 +17,29 @@ import Foundation /// it only has to invalidate correctly on one platform; the Kotlin twin (`AnalyzeRecentDayCache`) mirrors /// the shape but the two key strings are NOT required to match byte-for-byte across platforms. public enum AnalyzeRecentDayCache { + + /// WHICH part of a day's cache key moved, for the miss reason on the reuse line (#2073). + /// + /// The line has only ever reported how many nights were reused. A healthy pass reuses all but today, + /// whose heart rate is still growing; a pass that reuses NOTHING has had something shared by every + /// day's key change, and the two cases need different fixes. A field log showed 0 of 21 on 13 passes + /// out of 17, each costing about 50 seconds of prep and 1.75M row reads, and the reuse count alone + /// could not say why. `rrAlias5` is called out separately because it is the one input computed ONCE + /// per pass and folded into all 21 keys, so it alone can turn a single flip into a total miss. + /// + /// Keys are `owner|hrCount:hrMaxTs:anchor:detail|streams`, so the segment that differs names the + /// cause. Pure, and pinned by the same table of cases as the Kotlin twin. + public static func missReason(cachedKey: String, freshKey: String) -> String { + if cachedKey == freshKey { return "none" } + let a = cachedKey.split(separator: "|", maxSplits: 2, omittingEmptySubsequences: false) + let b = freshKey.split(separator: "|", maxSplits: 2, omittingEmptySubsequences: false) + if a.count < 3 || b.count < 3 { return "shape" } + if a[0] != b[0] { return "owner" } + if a[1] != b[1] { return "hr" } + let ra = a[2].range(of: "rrAlias5=").map { String(a[2][$0.upperBound...]) } ?? "" + let rb = b[2].range(of: "rrAlias5=").map { String(b[2][$0.upperBound...]) } ?? "" + return ra != rb ? "rrAlias5" : "streams" + } /// The per-day reuse key. Reuse a cached day iff this string is unchanged since the scan was cached. /// /// - `hrCount` / `hrMaxTs`: the night-window HR fingerprint (row count + newest timestamp) — the SAME @@ -31,11 +54,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 +80,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/AutoWorkoutDetector+Trace.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/AutoWorkoutDetector+Trace.swift index 62dd6c26d7..e6272484e9 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/AutoWorkoutDetector+Trace.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/AutoWorkoutDetector+Trace.swift @@ -19,6 +19,12 @@ import WhoopProtocol extension AutoWorkoutDetector { + private struct ShadowPair { + let candidateIndex: Int + let savedIndex: Int + let overlapS: Int + } + /// Side-effect-free diagnostic twin of `detect(...)`: returns the SAME `[DetectedWorkout]` detect would, /// plus the trace. The returned windows ARE `detect(...)`'s verbatim, so the trace can never disagree /// with what the Today card actually suggests. The trace logs the inputs + the thresholds, then walks the @@ -26,17 +32,21 @@ extension AutoWorkoutDetector { /// survived or dropped, mirroring the algorithm exactly. The Kotlin twin is /// `AutoWorkoutDetectorTrace.detectTrace`. /// - /// - Parameters mirror `detect(...)` exactly. `path` tags the call ("autoDetect" / "manualReview") so a - /// report shows which entry point produced it. + /// - Parameters mirror `detect(...)` exactly. `minimumSustainedMinutes` defaults to the published + /// 12-minute policy; explicit 10/15-minute values are for local shadow diagnostics only. `path` tags + /// the call ("autoDetect" / "manualReview") so a report shows which entry point produced it. public static func detectTrace(hr: [(ts: Int, bpm: Int)], restingBpm: Int?, motion: [MotionPoint]? = nil, savedSpans: [SavedWorkoutSpan] = [], + minimumSustainedMinutes: Double = minSustainedMin, path: String = "autoDetect") -> (results: [DetectedWorkout], trace: [String]) { // The result the Today card reads, verbatim, so the trace cannot diverge from it. - let results = detect(hr: hr, restingBpm: restingBpm, motion: motion, savedSpans: savedSpans) + let results = detect(hr: hr, restingBpm: restingBpm, motion: motion, + savedSpans: savedSpans, + minimumSustainedMinutes: minimumSustainedMinutes) var lines: [String] = [] let floor = (restingBpm ?? defaultRestingHR) + elevatedMarginBPM @@ -50,7 +60,7 @@ extension AutoWorkoutDetector { // Thresholds applied (the autoDetectThresholds capture). Stated once so a report carries the // calibration the windows were judged against. lines.append("autoDetect thresholds elevatedMargin=\(elevatedMarginBPM)bpm " - + "minSustainedMin=\(minSustainedMin) maxDipS=\(maxDipS) mergeGapS=\(mergeGapS) " + + "minSustainedMin=\(minimumSustainedMinutes) maxDipS=\(maxDipS) mergeGapS=\(mergeGapS) " + "motionConfirmMean=\(motionConfirmMean)") // Rebuild the SAME merged windows the detector forms (sustained spans tolerating dips, then merge), @@ -67,7 +77,9 @@ extension AutoWorkoutDetector { var spanEnd = 0 var dipStart: Int? = nil func closeSpan() { - if let s = spanStart, Double(spanEnd - s) >= minSustainedMin * 60.0 { spans.append((s, spanEnd)) } + if let s = spanStart, Double(spanEnd - s) >= minimumSustainedMinutes * 60.0 { + spans.append((s, spanEnd)) + } spanStart = nil dipStart = nil } @@ -85,7 +97,7 @@ extension AutoWorkoutDetector { if spans.isEmpty { lines.append("autoDetect why=noSustainedSpan " - + "(no contiguous run held >=\(minSustainedMin)min above \(floor)bpm)") + + "(no contiguous run held >=\(minimumSustainedMinutes)min above \(floor)bpm)") lines.append("autoDetect result windows=0") return (results, lines) } @@ -129,6 +141,226 @@ extension AutoWorkoutDetector { + "(offered the most recent that is not saved or dismissed)") return (results, lines) } + + /// Compare shadow candidates with manually/imported labelled workouts, without suppressing overlaps. + /// Candidates and labels are canonical-sorted, then paired one-to-one with a deterministic weighted + /// assignment. The assignment maximises cardinality first and total overlap second, so a broad label cannot + /// strand a candidate with no alternative and an equally large matching cannot retain a lower-overlap set. + /// Results therefore do not depend on database/union iteration order. A match requires strictly positive + /// overlap; endpoint-only contact is not evidence that the detector found the labelled workout. + /// + /// When `hrForObservability` is supplied, a label is observable only when its longest recorded HR run + /// (no sample gap above the detector's 90-second dip tolerance) spans the shorter of the label duration + /// and this policy's qualification duration. This retains genuinely short labelled workouts as evidence, + /// but two isolated points cannot manufacture a policy miss. Unobservable labels are reported separately + /// and never counted as misses. `matched` counts pairs, `unmatched` candidates left over, and `missed` + /// observable labelled spans left over. Median onset/end errors are absolute seconds for matched pairs, + /// or `n/a` when nothing matched. + /// + /// The caller must exclude legacy automatically detected rows from `savedSpans`: shadow mode measures + /// against user-confirmed/imported labels, not against the detector's own prior output. This formatter is + /// pure and returns only aggregate counts; it cannot save, dismiss, classify, or score a workout. + public static func shadowComparisonLine(policyMinutes: Double, + candidates: [DetectedWorkout], + savedSpans: [SavedWorkoutSpan], + hrForObservability: [(ts: Int, bpm: Int)]? = nil) -> String { + let orderedCandidates = candidates.sorted { + if $0.startSec != $1.startSec { return $0.startSec < $1.startSec } + if $0.endSec != $1.endSec { return $0.endSec < $1.endSec } + if $0.avgBpm != $1.avgBpm { return $0.avgBpm < $1.avgBpm } + if $0.peakBpm != $1.peakBpm { return $0.peakBpm < $1.peakBpm } + return $0.durationMin < $1.durationMin + } + let labelPartition = shadowLabelPartition( + savedSpans: savedSpans, + hr: hrForObservability, + policyMinutes: policyMinutes) + let orderedSaved = labelPartition.observable.sorted { + if $0.startSec != $1.startSec { return $0.startSec < $1.startSec } + return $0.endSec < $1.endSec + } + var possibleByCandidate = Array(repeating: [ShadowPair](), count: orderedCandidates.count) + for (candidateIndex, candidate) in orderedCandidates.enumerated() { + for (savedIndex, saved) in orderedSaved.enumerated() { + let overlapS = min(candidate.endSec, saved.endSec) + - max(candidate.startSec, saved.startSec) + guard overlapS > 0 else { continue } + possibleByCandidate[candidateIndex].append(ShadowPair( + candidateIndex: candidateIndex, + savedIndex: savedIndex, + overlapS: overlapS)) + } + } + let matchedPairs = maximumCardinalityOverlapPairs( + possibleByCandidate: possibleByCandidate, + savedCount: orderedSaved.count) + + let onsetErrors = matchedPairs.map { + abs(orderedCandidates[$0.candidateIndex].startSec - orderedSaved[$0.savedIndex].startSec) + } + let endErrors = matchedPairs.map { + abs(orderedCandidates[$0.candidateIndex].endSec - orderedSaved[$0.savedIndex].endSec) + } + let matched = matchedPairs.count + let missed = orderedSaved.count - matched + let unmatched = candidates.count - matched + return "workout shadow policy=\(shadowMinuteLabel(policyMinutes))min candidates=\(candidates.count) " + + "matched=\(matched) missed=\(missed) unobservable=\(labelPartition.unobservableCount) " + + "unmatched=\(unmatched) " + + "medianOnsetErrorS=\(medianErrorLabel(onsetErrors)) " + + "medianEndErrorS=\(medianErrorLabel(endErrors))" + } + + /// Run the published policy plus the supported 10/15-minute alternatives for local shadow diagnostics + /// and return aggregate trace lines only. Label spans are deliberately NOT passed into `detect`: doing + /// so would suppress every true overlap before it could be measured. No result from this function is a + /// published suggestion. + public static func shadowComparisonLines(hr: [(ts: Int, bpm: Int)], + restingBpm: Int?, + motion: [MotionPoint]? = nil, + savedSpans: [SavedWorkoutSpan], + policies: [Double] = ([minSustainedMin] + + shadowSustainedMinutes).sorted()) -> [String] { + policies.map { policy in + let candidates = detect(hr: hr, restingBpm: restingBpm, motion: motion, + minimumSustainedMinutes: policy) + return shadowComparisonLine(policyMinutes: policy, candidates: candidates, + savedSpans: savedSpans, hrForObservability: hr) + } + } + + /// Split labels by whether the detector input has enough contiguous-in-time HR to evaluate this policy. + private static func shadowLabelPartition(savedSpans: [SavedWorkoutSpan], + hr: [(ts: Int, bpm: Int)]?, + policyMinutes: Double) + -> (observable: [SavedWorkoutSpan], unobservableCount: Int) { + guard let hr else { return (savedSpans, 0) } + let timestamps = Array(Set(hr.map(\.ts))).sorted() + var observable: [SavedWorkoutSpan] = [] + observable.reserveCapacity(savedSpans.count) + for span in savedSpans { + let labelDuration = max(0, span.endSec - span.startSec) + let requiredCoverage = min(Double(labelDuration), max(0, policyMinutes * 60.0)) + var runStart: Int? + var previousTimestamp: Int? + var longestRun = 0 + for timestamp in timestamps { + if timestamp < span.startSec { continue } + if timestamp > span.endSec { break } + if let previousTimestamp, timestamp - previousTimestamp > maxDipS { + runStart = timestamp + } else if runStart == nil { + runStart = timestamp + } + if let runStart { longestRun = max(longestRun, timestamp - runStart) } + previousTimestamp = timestamp + } + if longestRun > 0, Double(longestRun) >= requiredCoverage { observable.append(span) } + } + return (observable, savedSpans.count - observable.count) + } + + /// Hungarian assignment over candidate rows and saved-label + dummy columns. Every positive-overlap + /// edge receives a cardinality bonus larger than the maximum possible total overlap, making the scalar + /// objective exactly lexicographic: match count first, summed overlap second. Canonical input order and + /// lowest-column tie breaks keep equal optima deterministic across Swift and Kotlin. + private static func maximumCardinalityOverlapPairs(possibleByCandidate: [[ShadowPair]], + savedCount: Int) -> [ShadowPair] { + let candidateCount = possibleByCandidate.count + guard candidateCount > 0, savedCount > 0, + let maxOverlap = possibleByCandidate.flatMap({ $0 }).map(\.overlapS).max() + else { return [] } + + let maximumMatches = min(candidateCount, savedCount) + let cardinalityBonus = Int64(maxOverlap) * Int64(maximumMatches) + 1 + let columnCount = savedCount + candidateCount + var weights = Array( + repeating: Array(repeating: Int64(0), count: columnCount), + count: candidateCount) + for options in possibleByCandidate { + for option in options { + weights[option.candidateIndex][option.savedIndex] = + cardinalityBonus + Int64(option.overlapS) + } + } + + // Minimum-cost Hungarian algorithm over negated weights. `p[column]` is its assigned 1-based row. + var rowPotential = Array(repeating: Int64(0), count: candidateCount + 1) + var columnPotential = Array(repeating: Int64(0), count: columnCount + 1) + var assignedRow = Array(repeating: 0, count: columnCount + 1) + var previousColumn = Array(repeating: 0, count: columnCount + 1) + let infinity = Int64.max / 4 + + for row in 1...candidateCount { + assignedRow[0] = row + var currentColumn = 0 + var minimumReducedCost = Array(repeating: infinity, count: columnCount + 1) + var usedColumn = Array(repeating: false, count: columnCount + 1) + repeat { + usedColumn[currentColumn] = true + let currentRow = assignedRow[currentColumn] + var delta = infinity + var nextColumn = 0 + for column in 1...columnCount where !usedColumn[column] { + let cost = -weights[currentRow - 1][column - 1] + let reducedCost = cost - rowPotential[currentRow] - columnPotential[column] + if reducedCost < minimumReducedCost[column] { + minimumReducedCost[column] = reducedCost + previousColumn[column] = currentColumn + } + if minimumReducedCost[column] < delta { + delta = minimumReducedCost[column] + nextColumn = column + } + } + for column in 0...columnCount { + if usedColumn[column] { + rowPotential[assignedRow[column]] += delta + columnPotential[column] -= delta + } else { + minimumReducedCost[column] -= delta + } + } + currentColumn = nextColumn + } while assignedRow[currentColumn] != 0 + + repeat { + let prior = previousColumn[currentColumn] + assignedRow[currentColumn] = assignedRow[prior] + currentColumn = prior + } while currentColumn != 0 + } + + var matches: [ShadowPair] = [] + for savedColumn in 1...savedCount { + let candidateRow = assignedRow[savedColumn] + guard candidateRow > 0, + let pair = possibleByCandidate[candidateRow - 1] + .first(where: { $0.savedIndex == savedColumn - 1 }) + else { continue } + matches.append(pair) + } + return matches.sorted { + if $0.candidateIndex != $1.candidateIndex { return $0.candidateIndex < $1.candidateIndex } + return $0.savedIndex < $1.savedIndex + } + } + + /// Locale-independent policy label for byte-identical Swift/Kotlin shadow lines. + private static func shadowMinuteLabel(_ value: Double) -> String { + if value.isFinite, abs(value) < 1e15, value.rounded() == value { + return String(Int(value)) + } + return String(value) + } + + private static func medianErrorLabel(_ values: [Int]) -> String { + guard !values.isEmpty else { return "n/a" } + let sorted = values.sorted() + let middle = sorted.count / 2 + if !sorted.count.isMultiple(of: 2) { return String(sorted[middle]) } + return shadowMinuteLabel((Double(sorted[middle - 1]) + Double(sorted[middle])) / 2.0) + } } /// Pure line formatters + the live-readout parser for the Workouts & GPS test mode. The app-target emitters @@ -164,14 +396,12 @@ public enum WorkoutsTrace { + "distanceM=\(Int(distanceM.rounded())) (filter: accuracy+speed gate)" } - /// An engine detected-bout decision line: the IntelligenceEngine derives a workout bout from the raw HR - /// stream, then either PERSISTS it (source "-noop", sport "detected") or DROPS it because it overlaps a - /// real session the user already logged (manual / imported), so the same bout is never counted twice. - /// This is the "auto workout appeared then vanished" seam (#975): a bout can persist on one pass then be - /// dropped on the next once the manual row lands, and without this line the export shows NO workouts - /// trace for the auto path at all. `verdict` is "persisted" / "droppedOverlap"; `durMin` is the whole- - /// minute bout length; on a drop, `overlapSource` names the real row it collided with. No PII (a source - /// label + minutes + bpm only). Mirrors the Kotlin `WorkoutsTrace.detectedBoutLine`. + /// An analytics detected-bout decision line. IntelligenceEngine derives a bout from raw HR/motion for + /// scoring and enrichment, but never creates or reconciles a visible generic workout from it. A bout + /// with no real overlap is `analyticsOnly`; one overlapping a manual/imported row is either + /// `droppedOverlapBackfilled` when it supplied missing metrics, or `droppedOverlap` when that row was + /// already complete. On an overlap, `overlapSource` names the real row. `durMin` is the whole-minute bout + /// length. No PII (a source label + minutes + bpm only). Mirrors Kotlin `WorkoutsTrace.detectedBoutLine`. public static func detectedBoutLine(verdict: String, durMin: Int, avgBpm: Int, diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/AutoWorkoutDetector.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/AutoWorkoutDetector.swift index bbe3210f6a..dfd4224ecc 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/AutoWorkoutDetector.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/AutoWorkoutDetector.swift @@ -8,18 +8,23 @@ import WhoopProtocol // outputs), verified by the mirrored unit tests on each platform. // // This is DELIBERATELY SEPARATE from `WorkoutDetector` (the exercise.py port that computes -// calories / zones / strain and writes the durable "detected" rows the IntelligenceEngine -// churns). This one is the lightweight, OPT-IN, NON-DESTRUCTIVE MVP that only ever SUGGESTS a +// calories / zones / strain for daily analytics and enriches already logged workouts). This one +// is the lightweight, OPT-IN, NON-DESTRUCTIVE confirmation detector that only ever SUGGESTS a // workout via a dismissible Today card — it never writes a row on its own. The user taps "Save" -// to turn a suggestion into a manual workout, or X to dismiss it forever. +// to turn a suggestion into a manual workout, or X to dismiss it forever. Its published policy +// remains frozen while the duration alternatives collect local shadow evidence. // -// The thresholds here are intentionally CONSERVATIVE (low sensitivity): a sustained ≥ 12-min +// The published thresholds here are intentionally CONSERVATIVE (low sensitivity): a sustained ≥ 12-min // elevation of HR ≥ resting + 30 bpm, brief (≤ 90 s) dips tolerated, near windows merged. Tuned // to avoid false positives from stress / caffeine / a brief flight of stairs, at the cost of // missing the odd short or gentle session — exactly right for a SUGGESTION you can decline. An // OPTIONAL continuous motion signal, when one is readily available, is required as confirmation; // with no motion series it runs HR-only. // +// The 10- and 15-minute alternatives are SHADOW policies only. Test Centre may run them beside the +// published 12-minute result to compare against labelled workouts, but they must not drive the Today +// card, persistence, classification, or downstream scores until real-world validation supports a change. +// // Pure / headless: no I/O, no clock. All ts/start/end are unix SECONDS. NOT medical advice. /// A candidate workout window the user can accept (Save) or reject (dismiss). All fields are @@ -61,6 +66,9 @@ public enum AutoWorkoutDetector { public static let elevatedMarginBPM = 30 /// A candidate must hold the elevated gate for a contiguous span of at least this long (12 min). public static let minSustainedMin: Double = 12.0 + /// Candidate duration policies evaluated only by local Test Centre shadow diagnostics. + /// The published confirmation path continues to use `minSustainedMin` (12 minutes). + public static let shadowSustainedMinutes: [Double] = [10.0, 15.0] /// A dip below the gate no longer than this does NOT break the span (a red light, a sip of water). public static let maxDipS = 90 /// Two detected windows whose gap is strictly less than this are merged into one (5 min). @@ -123,7 +131,7 @@ public enum AutoWorkoutDetector { /// (does not end the span) ONLY while the dip's wall-clock duration (from the first sub-threshold /// sample) stays <= `maxDipS`; a longer dip closes the span. The span's [start, end] are the /// first/last ELEVATED sample timestamps. - /// 3. Keep a span only when it lasts >= `minSustainedMin` (applied per-span, BEFORE merge). + /// 3. Keep a span only when it lasts >= `minimumSustainedMinutes` (applied per-span, BEFORE merge). /// 4. Merge two kept spans when the gap between them is strictly < `mergeGapS`. /// 5. If a motion series is supplied, drop a window unless its mean motion intensity over the /// window is >= `motionConfirmMean` (confirmation). With no motion series, HR-only — keep it. @@ -135,10 +143,13 @@ public enum AutoWorkoutDetector { /// - restingBpm: the nightly resting HR for the day; nil → `defaultRestingHR` (60). /// - motion: OPTIONAL continuous motion series for confirmation; nil/empty → HR-only. /// - savedSpans: already-saved workout windows to exclude by overlap. + /// - minimumSustainedMinutes: duration policy. The default is the published 12-minute policy; + /// callers may pass values from `shadowSustainedMinutes` only for local shadow diagnostics. public static func detect(hr: [(ts: Int, bpm: Int)], restingBpm: Int?, motion: [MotionPoint]? = nil, - savedSpans: [SavedWorkoutSpan] = []) -> [DetectedWorkout] { + savedSpans: [SavedWorkoutSpan] = [], + minimumSustainedMinutes: Double = minSustainedMin) -> [DetectedWorkout] { let seg = hr.sorted { $0.ts < $1.ts } if seg.isEmpty { return [] } @@ -153,7 +164,7 @@ public enum AutoWorkoutDetector { var dipStart: Int? = nil func closeSpan() { - if let s = spanStart, Double(spanEnd - s) >= minSustainedMin * 60.0 { + if let s = spanStart, Double(spanEnd - s) >= minimumSustainedMinutes * 60.0 { spans.append((s, spanEnd)) } spanStart = nil 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/ChargeDrivers.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/ChargeDrivers.swift index daae7e3f30..befbf878c6 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/ChargeDrivers.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/ChargeDrivers.swift @@ -130,8 +130,13 @@ extension RecoveryScorer { // No score => no real contributions to attribute (cold-start). recovery(...) enforces // the usable gate; mirror it so a nil headline never yields fabricated driver rows. + // #1988: the ROW is built from this baseline directly (its z, its mean, its verdict), not only + // through recovery(...), so gating the scorer alone would emit an RHR row scored against the + // synthetic midpoint while the headline excluded it. Normalised once here so every use below, + // score and row alike, sees the same thing. Twin of the Kotlin `rhrB`. + let rhrB = rhrBaseline.flatMap { $0.usable ? $0 : nil } guard let full = recovery(hrv: hrv, rhr: rhr, resp: resp, - hrvBaseline: hrvBaseline, rhrBaseline: rhrBaseline, + hrvBaseline: hrvBaseline, rhrBaseline: rhrB, respBaseline: respBaseline, sleepPerf: sleepPerf, skinTempDev: skinTempDev) else { return [] @@ -160,7 +165,7 @@ extension RecoveryScorer { // below is the full, unguarded HRV penalty. The verdict merely NAMES the detected pattern so the // UI can surface it while real firings accumulate. See the MARK header in RecoveryScorer.swift. let hrvZFull = zScore(hrv, mean: hrvBaseline.baseline, spread: hrvBaseline.spread) - let rhrZFull: Double? = rhrBaseline.map { zScore($0.baseline, mean: rhr, spread: $0.spread) } + let rhrZFull: Double? = rhrB.map { zScore($0.baseline, mean: rhr, spread: $0.spread) } let hrvSaturationDetected = parasympatheticSaturation(hrvZ: hrvZFull, rhrZ: rhrZFull).active // ── HRV (dominant driver; always present once the score exists) ────────── @@ -168,7 +173,7 @@ extension RecoveryScorer { drivers.append(ChargeDriver( label: "Heart rate variability", deltaPoints: points(recovery(hrv: hrvBaseline.baseline, rhr: rhr, resp: resp, - hrvBaseline: hrvBaseline, rhrBaseline: rhrBaseline, + hrvBaseline: hrvBaseline, rhrBaseline: rhrB, respBaseline: respBaseline, sleepPerf: sleepPerf, skinTempDev: skinTempDev)), valueText: "\(Int(hrv.rounded())) ms", @@ -178,11 +183,11 @@ extension RecoveryScorer { // ── Resting HR (lower vs baseline supports recovery) ───────────────────── // Neutral = resting HR at the baseline mean. - if let b = rhrBaseline { + if let b = rhrB { drivers.append(ChargeDriver( label: "Resting heart rate", deltaPoints: points(recovery(hrv: hrv, rhr: b.baseline, resp: resp, - hrvBaseline: hrvBaseline, rhrBaseline: rhrBaseline, + hrvBaseline: hrvBaseline, rhrBaseline: rhrB, respBaseline: respBaseline, sleepPerf: sleepPerf, skinTempDev: skinTempDev)), valueText: "\(Int(rhr.rounded())) bpm", @@ -195,7 +200,7 @@ extension RecoveryScorer { drivers.append(ChargeDriver( label: "Sleep quality", deltaPoints: points(recovery(hrv: hrv, rhr: rhr, resp: resp, - hrvBaseline: hrvBaseline, rhrBaseline: rhrBaseline, + hrvBaseline: hrvBaseline, rhrBaseline: rhrB, respBaseline: respBaseline, sleepPerf: sleepPerfCenter, skinTempDev: skinTempDev)), valueText: "\(Int((sp * 100).rounded()))%", @@ -209,7 +214,7 @@ extension RecoveryScorer { drivers.append(ChargeDriver( label: "Respiratory rate", deltaPoints: points(recovery(hrv: hrv, rhr: rhr, resp: b.baseline, - hrvBaseline: hrvBaseline, rhrBaseline: rhrBaseline, + hrvBaseline: hrvBaseline, rhrBaseline: rhrB, respBaseline: respBaseline, sleepPerf: sleepPerf, skinTempDev: skinTempDev)), valueText: String(format: "%.1f br/min", locale: Locale(identifier: "en_US_POSIX"), r), @@ -223,7 +228,7 @@ extension RecoveryScorer { drivers.append(ChargeDriver( label: "Skin temperature", deltaPoints: points(recovery(hrv: hrv, rhr: rhr, resp: resp, - hrvBaseline: hrvBaseline, rhrBaseline: rhrBaseline, + hrvBaseline: hrvBaseline, rhrBaseline: rhrB, respBaseline: respBaseline, sleepPerf: sleepPerf, skinTempDev: 0)), valueText: skinTempDevText(dev), 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..5b229c8909 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/ConnectionReadout.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/ConnectionReadout.swift @@ -227,23 +227,178 @@ 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. + /// + /// `rssiDbm` answers the question the END REASON raises and could not previously settle. The dominant + /// disconnect in a field log is the supervision timeout, which reads as "the strap went out of range + /// or stopped responding" - so the log names range as the leading suspect and then records nothing + /// about range. Apple never read link RSSI at all before this: `discoveredWhoops` carries a SCAN-time + /// reading, taken before the connection existed, which says nothing about the link that later died. + /// + /// Both halves are optional, and the pair is printed with its AGE, because a reading is only evidence + /// about the drop if it was taken near the drop. A value with no age, or the previous link's value + /// carried into this one, is the hazard this whole line exists to avoid: the caller MUST clear its + /// stash on teardown, exactly as it clears `linkUpSince`, or the epitaph invents the evidence it was + /// built to find. `never read on this link` is the honest answer and is printed as one. + /// + /// RSSI is NOT clamped. It is negative by nature, so `max(0, ...)` would erase every real reading; + /// implausible values are the caller's to reject at the stash, where the read's error is known. + /// + /// #2397: `rssiReads`, `rssiWorstDbm` and `rssiSumDbm` describe every reading the link took, not + /// just the last one. The periodic read landed in #2332 and its value was then discarded on each new + /// reading, so a link that took two hundred readings reported one: a field log carried 467 reads + /// across three links and two epitaphs naming two numbers. A last value alone cannot separate a link + /// that was marginal throughout from one that walked out of range, which is precisely what a + /// supervision timeout leaves a reader asking. The mean is computed HERE, from a sum and a count, + /// rather than passed in, so both platforms divide the same way on the same inputs. + /// + /// Milliseconds are printed raw: no float formatting, so the two platforms cannot round apart. + /// `rssiAgeMillis` shares the line's units, so a reader can compare it with `upMillis` directly + /// instead of converting. + public static func linkEpitaph(upMillis: Int, inboundFrames: Int, inboundBytes: Int, + cmdChannelFrames: Int, realtimeArmed: Bool, ended: String, + rssiDbm: Int?, rssiAgeMillis: Int?, + rssiReads: Int = 0, rssiWorstDbm: Int? = nil, + rssiSumDbm: Int = 0) -> String { + let armed = realtimeArmed ? "yes" : "no" + // #2397: the SHAPE of the link's signal, not just its last reading. Two readings is the floor: + // with one, worst and mean are the value already printed and the clause is noise. + let shape: String + if rssiReads >= 2, let rssiWorstDbm { + // Integer division, truncating toward zero on both platforms (Swift and Kotlin agree on + // negatives), so the two logs cannot round apart. The mean of a handful of dBm readings is a + // shape, not a measurement, and one dB of truncation does not change what it says. + shape = "; n=\(rssiReads) worst=\(rssiWorstDbm)dBm mean=\(rssiSumDbm / rssiReads)dBm" + } else { + shape = "" + } + let signal: String + if let rssiDbm { + if let rssiAgeMillis { + signal = "\(rssiDbm)dBm (read \(max(0, rssiAgeMillis))ms before the drop\(shape))" + } else { + signal = "\(rssiDbm)dBm (age unknown\(shape))" + } + } else { + signal = "never read on this link" + } + 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), signal=\(signal), 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/DaytimeBaselines.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/DaytimeBaselines.swift index 4e8942fe12..df32c9b4d5 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/DaytimeBaselines.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/DaytimeBaselines.swift @@ -118,12 +118,28 @@ public extension DaytimeStress { /// the scorer honestly runs HR-only rather than z-scoring against a 1–2-day, untrustworthy HRV /// baseline — the whole-baseline grain of the per-hour graceful-nil already in `rawScore`. static func foldDaytimeBaselines(days: [DaytimeDayStreams]) -> (hr: BaselineState, rmssd: BaselineState?) { + foldAggregates(days.map { + dayDaytimeAggregate(hr: $0.hr, rr: $0.rr, tzOffsetSeconds: $0.tzOffsetSeconds) + }) + } + + /// The same fold, taking days already reduced to their `dayDaytimeAggregate` pair. + /// + /// This exists so a CALLER can reduce each day as it reads it and let that day's raw samples go, + /// instead of holding thirty days of them at once (#2107). A day collapses to two `Double?`s here and + /// its streams are never read again, so keeping them alive bought nothing and cost a heap: thirty days + /// of a worn 5.0 is millions of sample objects, which is how a 256MB Android heap ran out. + /// + /// `foldDaytimeBaselines(days:)` is now literally this with the reduction done eagerly, so the + /// streaming caller and the list caller cannot drift apart: there is one fold, reached two ways. + /// Twin of Kotlin `DaytimeBaselines.foldAggregates`. + static func foldAggregates(_ aggregates: [(hr: Double?, rmssd: Double?)]) + -> (hr: BaselineState, rmssd: BaselineState?) { var hrAggs: [Double?] = [] var rmssdAggs: [Double?] = [] - hrAggs.reserveCapacity(days.count) - rmssdAggs.reserveCapacity(days.count) - for d in days { - let agg = dayDaytimeAggregate(hr: d.hr, rr: d.rr, tzOffsetSeconds: d.tzOffsetSeconds) + hrAggs.reserveCapacity(aggregates.count) + rmssdAggs.reserveCapacity(aggregates.count) + for agg in aggregates { hrAggs.append(agg.hr) rmssdAggs.append(agg.rmssd) } @@ -138,7 +154,16 @@ public extension DaytimeStress { /// unchanged default). This is the single graceful-degradation gate: a cold start, or a trailing /// window that is all sparse/imported days, keeps EXACTLY today's pre-existing day-relative behaviour. static func scoringMode(history days: [DaytimeDayStreams]) -> ScoringMode { - let baselines = foldDaytimeBaselines(days: days) + scoringModeFromAggregates(days.map { + dayDaytimeAggregate(hr: $0.hr, rr: $0.rr, tzOffsetSeconds: $0.tzOffsetSeconds) + }) + } + + /// `scoringMode(history:)` for days already reduced to aggregates. See `foldAggregates` for why + /// (#2107). Named rather than overloaded, so the twin stays findable by name from either side. + /// Twin of Kotlin `DaytimeBaselines.scoringModeFromAggregates`. + static func scoringModeFromAggregates(_ aggregates: [(hr: Double?, rmssd: Double?)]) -> ScoringMode { + let baselines = foldAggregates(aggregates) guard baselines.hr.usable else { return .dayRelative } // RMSSD stays out of the LIVE score until it has its own validation pass — see // `DaytimeStress.daytimeRMSSDScoringEnabled`. The baseline is still folded above (so the diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/DaytimeStress.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/DaytimeStress.swift index 43dddc000b..c2403e7fad 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/DaytimeStress.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/DaytimeStress.swift @@ -34,6 +34,20 @@ public enum DaytimeStress { public static let minHourHRSamples: Int = 300 /// Bucket width for the timeline, in seconds (one hour). public static let bucketSeconds: Int = 3_600 + /// How far the DISPLAY timeline slides its window between points. + /// + /// The scored unit stays a full `bucketSeconds` hour. This only decides how often that hour is + /// re-read, so a half-step gives two points an hour, each still an hour of data, rather than + /// half-hours of thinner data. Shrinking `bucketSeconds` itself would have been a scoring change + /// wearing a display change's clothes: the calm reference is a quartile ACROSS buckets, the + /// post-exercise shadow looks back exactly one bucket, and `sustainedHours` counts them. + /// + /// Half rather than a quarter because adjacent points then share half their samples instead of + /// three quarters. The curve is smoother either way, and a denser line invites the reader to see + /// detail it cannot resolve: a 30-minute spike still moves an hour's worth of weight, just sooner. + /// Half is the least overlap that still doubles the resolution, and it keeps every on-the-hour + /// point exactly where the hourly pass put it. Byte-twin of the Kotlin `timelineStepSeconds`. + public static let timelineStepSeconds: Int = 1_800 /// Band floor for "high" on the shared 0–3 scale (matches StressBand .high). public static let highBandFloor: Double = 2.0 /// Consecutive most-recent covered hours that must all be HIGH to flag sustained stress. @@ -215,10 +229,22 @@ public enum DaytimeStress { /// day-wide) and false for `.empty`. public let hrOnlyFallback: Bool + /// DISPLAY-ONLY sliding read of the same day: `hours` plus a point every + /// `timelineStepSeconds`, each still scored over a full `bucketSeconds` window against the + /// SAME reference `hours` used. Defaults to `hours` so a caller that never asked for it, and + /// every existing test, sees exactly what it saw before. + /// + /// Deliberately NOT the input to anything that counts hours. `sustainedHigh`, + /// `highStressMinutes`, `dayMean` and `peak` all stay on the non-overlapping `hours`, because + /// overlapping windows would count the same minute more than once. + public let timeline: [HourPoint] + public init(hours: [HourPoint], sustainedHigh: Bool, sustainedRun: Int, dayMean: Double?, peak: HourPoint?, activityMaskedHours: Int = 0, - highStressMinutes: Int = 0, hrOnlyFallback: Bool = false) { + highStressMinutes: Int = 0, hrOnlyFallback: Bool = false, + timeline: [HourPoint]? = nil) { self.hours = hours + self.timeline = timeline ?? hours self.sustainedHigh = sustainedHigh self.sustainedRun = sustainedRun self.dayMean = dayMean @@ -285,6 +311,15 @@ public enum DaytimeStress { return marginBPM / (-log(ratio)) } + /// The bucket a local timestamp falls in for a grid offset by `phase`. + /// + /// `phase = 0` is the on-the-hour grid every existing reading uses. `phase = timelineStepSeconds` + /// is the same grid slid forward, so its windows straddle the hour boundaries rather than + /// replacing them. + private static func bucketOf(_ localTs: Int, phase: Int) -> Int { + floorDiv(localTs - phase, bucketSeconds) * bucketSeconds + phase + } + // MARK: - Public API /// Build the daytime stress timeline from a day's banked HR + R-R. @@ -302,10 +337,16 @@ public enum DaytimeStress { /// opt-in: existing callers that don't pass `mode` keep the exact prior behaviour. /// /// Returns `.empty` when there isn't a single hour with enough HR to score. + /// - includeTimeline: also compute `Result.timeline`, the sliding read is OPT-IN because half the callers do not want it. + + /// The Stress screen reads `hours` and draws its own interactive timeline; making it pay for a + /// second pass of bucketing and one RMSSD per extra window, on the screen that already does three + /// 200 000-row reads, would be cost for nothing. The widget and the Today card ask for it. public static func analyze(hr: [HRSample], rr: [RRInterval], gravity: [GravitySample] = [], tzOffsetSeconds: Int = 0, - mode: ScoringMode = .dayRelative) -> Result { + mode: ScoringMode = .dayRelative, + includeTimeline: Bool = false) -> Result { // v7.0.2 perf (#707): buckets the day's full HR + R-R streams into per-hour aggregates and runs an // RMSSD per hour — invoked from the Stress view, so a `body` re-evaluation re-buckets the whole day. // Memoize on the streams' fingerprint + tz offset + scoring mode; result is a small `Result`, raw @@ -328,15 +369,19 @@ public enum DaytimeStress { Int(($0.x * 128).rounded()) &+ Int(($0.y * 128).rounded()) &* 257 &+ Int(($0.z * 128).rounded()) &* 66_049 }), - tz: tzOffsetSeconds, mode: modeKey) + // The flag is part of the KEY, not just the call. Without it a screen read (false) + // would seed the cache with a hourly-only result and the next widget read (true) would be + // handed it, silently losing the sliding series with nothing to show why. + tz: tzOffsetSeconds, mode: modeKey, includeTimeline: includeTimeline) return analyzeCache.value(key) { - analyzeUncached(hr: hr, rr: rr, gravity: gravity, tzOffsetSeconds: tzOffsetSeconds, mode: mode) + analyzeUncached(hr: hr, rr: rr, gravity: gravity, tzOffsetSeconds: tzOffsetSeconds, + mode: mode, includeTimeline: includeTimeline) } } private struct StressKey: Hashable { let hr: StreamFingerprint; let rr: StreamFingerprint; let gravity: StreamFingerprint - let tz: Int; let mode: ModeKey + let tz: Int; let mode: ModeKey; let includeTimeline: Bool } /// Hashable fingerprint of `ScoringMode` for the memo cache — `BaselineState` itself isn't @@ -351,58 +396,69 @@ public enum DaytimeStress { private static func analyzeUncached(hr: [HRSample], rr: [RRInterval], gravity: [GravitySample], - tzOffsetSeconds: Int, mode: ScoringMode) -> Result { + tzOffsetSeconds: Int, mode: ScoringMode, + includeTimeline: Bool) -> Result { guard !hr.isEmpty else { return .empty } // 1) Bucket HR + R-R into LOCAL hour-of-day buckets, keyed by the bucket start // (floored to the hour on the local clock). - var hrByBucket: [Int: [Double]] = [:] - for s in hr { - let local = s.ts + tzOffsetSeconds - let bucket = floorDiv(local, bucketSeconds) * bucketSeconds - hrByBucket[bucket, default: []].append(Double(s.bpm)) + func hrBuckets(_ phase: Int) -> [Int: [Double]] { + var m: [Int: [Double]] = [:] + for s in hr { m[bucketOf(s.ts + tzOffsetSeconds, phase: phase), default: []].append(Double(s.bpm)) } + return m } - var rrByBucket: [Int: [Double]] = [:] - for s in rr { - let local = s.ts + tzOffsetSeconds - let bucket = floorDiv(local, bucketSeconds) * bucketSeconds - rrByBucket[bucket, default: []].append(Double(s.rrMs)) + func rrBuckets(_ phase: Int) -> [Int: [Double]] { + var m: [Int: [Double]] = [:] + for s in rr { m[bucketOf(s.ts + tzOffsetSeconds, phase: phase), default: []].append(Double(s.rrMs)) } + return m } + let hrByBucket = hrBuckets(0) + let rrByBucket = rrBuckets(0) // 2) Per-hour mean HR + RMSSD (RMSSD via the shared HRV cleaner, so ectopic // beats can't fabricate variability). An hour with < minHourHRSamples HR is // left unscored (noData) — never invented. struct HourAgg { let bucket: Int; let meanHR: Double?; let rmssd: Double?; let nHR: Int } - let orderedBuckets = hrByBucket.keys.sorted() - var aggs: [HourAgg] = [] - aggs.reserveCapacity(orderedBuckets.count) - for b in orderedBuckets { - let hrs = hrByBucket[b] ?? [] - let mHR = hrs.count >= minHourHRSamples ? mean(hrs) : nil - let rrRes = HRVAnalyzer.analyze(rawRR: rrByBucket[b] ?? []) - aggs.append(HourAgg(bucket: b, meanHR: mHR, rmssd: rrRes.rmssd, nHR: hrs.count)) + func aggregate(_ hrGrid: [Int: [Double]], _ rrGrid: [Int: [Double]]) -> [HourAgg] { + let ordered = hrGrid.keys.sorted() + var out: [HourAgg] = [] + out.reserveCapacity(ordered.count) + for b in ordered { + let hrs = hrGrid[b] ?? [] + let mHR = hrs.count >= minHourHRSamples ? mean(hrs) : nil + let rrRes = HRVAnalyzer.analyze(rawRR: rrGrid[b] ?? []) + out.append(HourAgg(bucket: b, meanHR: mHR, rmssd: rrRes.rmssd, nHR: hrs.count)) + } + return out } + let aggs = aggregate(hrByBucket, rrByBucket) // 2b) Motion gate: bucket the day's gravity-derived activity by the SAME local hour and mark // each hour AMBULATORY when at least `activityMaskFraction` of its records clear the // calibrated walk floor (`WorkoutDetector.motionThreshold`) — reusing the exact activity // series `SedentaryDetector` / `WorkoutDetector` already trust. Empty gravity → no active // buckets → nothing masked below (byte-identical to the pre-motion behaviour). - var activeFracByBucket: [Int: Double] = [:] - if !gravity.isEmpty { + // Derived ONCE and re-bucketed per grid. `activitySeries` walks the whole day's gravity, so + // recomputing it for the second grid would have doubled the most expensive part of the motion + // gate to answer the same question about the same samples. + let activity = gravity.isEmpty ? [] : WorkoutDetector.activitySeries(gravity) + func activeFractions(_ phase: Int) -> [Int: Double] { + var out: [Int: Double] = [:] + guard !activity.isEmpty else { return out } var counts: [Int: (active: Int, total: Int)] = [:] - for p in WorkoutDetector.activitySeries(gravity) { - let local = p.ts + tzOffsetSeconds - let bucket = floorDiv(local, bucketSeconds) * bucketSeconds + for p in activity { + let bucket = bucketOf(p.ts + tzOffsetSeconds, phase: phase) var e = counts[bucket] ?? (0, 0) e.total += 1 if p.intensity > WorkoutDetector.motionThreshold { e.active += 1 } counts[bucket] = e } for (b, e) in counts where e.total > 0 { - activeFracByBucket[b] = Double(e.active) / Double(e.total) + out[b] = Double(e.active) / Double(e.total) } + return out } + let activeFracByBucket = activeFractions(0) func isAmbulatory(_ bucket: Int) -> Bool { (activeFracByBucket[bucket] ?? 0) >= activityMaskFraction } @@ -473,9 +529,18 @@ public enum DaytimeStress { } // 4) Score each waking-hour bucket on the shared 0–3 curve. - var points: [HourPoint] = [] - points.reserveCapacity(aggs.count) - for a in aggs { + // + // Written against a supplied bucket grid so the SAME expression scores the on-the-hour pass + // and the half-step display pass. One copy, so the two can never drift into scoring the same + // hour differently — which is the whole reason the sliding read reuses the references + // computed above rather than deriving its own. + func scoreGrid(_ gridAggs: [HourAgg], _ activeFrac: [Int: Double]) -> [HourPoint] { + func ambulatory(_ bucket: Int) -> Bool { + (activeFrac[bucket] ?? 0) >= activityMaskFraction + } + var points: [HourPoint] = [] + points.reserveCapacity(gridAggs.count) + for a in gridAggs { guard isWakingHour(a.bucket) else { continue } let hourOfDay = floorDiv(a.bucket, bucketSeconds) % 24 // The wall-clock bucket start (undo the local shift applied above). @@ -486,9 +551,9 @@ public enum DaytimeStress { // genuine cardiac recovery (a following hour already back at baseline scores normally). // Only meaningful when the hour actually HAD a reading to withhold — a no-HR hour is plain // `.noData`, not "masked". - let shadow = isAmbulatory(a.bucket - bucketSeconds) + let shadow = ambulatory(a.bucket - bucketSeconds) && a.meanHR != nil && refHR != nil && a.meanHR! > refHR! + postActivityShadowBPM - let masked = a.meanHR != nil && (isAmbulatory(a.bucket) || shadow) + let masked = a.meanHR != nil && (ambulatory(a.bucket) || shadow) // Score only when at least one signal is present AND HR cleared the count gate AND the // hour was not motion-masked (HR is the always-available anchor; RMSSD enriches it). let level: Double? = (a.meanHR != nil && !masked) @@ -498,9 +563,25 @@ public enum DaytimeStress { points.append(HourPoint(hour: hourOfDay, startTs: wallStart, level: level, meanHR: a.meanHR, rmssd: a.rmssd, maskedForActivity: masked)) + } + return points } + let points = scoreGrid(aggs, activeFracByBucket) let activityMaskedHours = points.reduce(0) { $0 + ($1.maskedForActivity ? 1 : 0) } + // 4b) The half-step DISPLAY timeline: the same hour-long window re-read every + // `timelineStepSeconds`, scored against the SAME references, and merged with the + // on-the-hour points. Every hourly point survives untouched; only the straddling + // midpoints are new, so the curve still passes through exactly the values scored above. + // Nothing that counts hours reads this — see `Result.timeline`. + let timeline: [HourPoint] = { + guard includeTimeline, + timelineStepSeconds > 0, timelineStepSeconds < bucketSeconds else { return points } + let midAggs = aggregate(hrBuckets(timelineStepSeconds), rrBuckets(timelineStepSeconds)) + return (points + scoreGrid(midAggs, activeFractions(timelineStepSeconds))) + .sorted { $0.startTs < $1.startTs } + }() + let scored = points.compactMap { p -> (HourPoint, Double)? in p.level.map { (p, $0) } } guard !scored.isEmpty else { // No scorable waking hour — still return the (unscored) timeline so the UI can @@ -510,7 +591,7 @@ public enum DaytimeStress { return points.isEmpty ? .empty : Result(hours: points, sustainedHigh: false, sustainedRun: 0, dayMean: nil, peak: nil, activityMaskedHours: activityMaskedHours, - highStressMinutes: 0, hrOnlyFallback: hrOnlyFallback) + highStressMinutes: 0, hrOnlyFallback: hrOnlyFallback, timeline: timeline) } // 5) Sustained-high flag: walk back from the latest SCORED hour while each is HIGH. @@ -531,7 +612,8 @@ public enum DaytimeStress { return Result(hours: points, sustainedHigh: sustained, sustainedRun: run, dayMean: dayMean, peak: peak, activityMaskedHours: activityMaskedHours, - highStressMinutes: highStressMinutes, hrOnlyFallback: hrOnlyFallback) + highStressMinutes: highStressMinutes, hrOnlyFallback: hrOnlyFallback, + timeline: timeline) } // MARK: - Helpers 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..be09ab27d0 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,22 @@ 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 + /// `HrvAnalyzer.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/LiftMetrics.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/LiftMetrics.swift new file mode 100644 index 0000000000..347f7c0347 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/LiftMetrics.swift @@ -0,0 +1,275 @@ +import Foundation +import WhoopStore + +// Training metrics for the Lift Log. +// +// Every figure here is arithmetic the user can redo by hand from their own logged sets. That is the +// whole design constraint: NOOP shows a few honest numbers rather than one invented score, because a +// composite "workout score out of 100" feels satisfying and tells you nothing about what to change. +// +// PURE. No store, no clock, no UI — the inputs are rows and the outputs are numbers, so the whole +// surface is unit-testable with no strap, no database and no simulator. +// +// WHAT IS DELIBERATELY ABSENT, and must stay absent: +// +// • Anything that feeds `workout.strain` or daily Effort. NOOP's strain is HR-measured (Karvonen +// %HRR -> Edwards TRIMP). There is no validated public path from typed sets/reps/weight to a +// cardiovascular-strain equivalent — WHOOP's own muscular load runs velocity-based algorithms +// over strap accelerometer/gyroscope under an unpublished model — and deriving one here is the +// exact case `CLAUDE.md` warns about after the withdrawn PPG->HR estimate (#194). +// +// • Per-exercise muscle weightings ("bench press = 0.7 triceps"). There is no published table to +// take them from. Inventing one would make every downstream per-muscle figure fiction wearing +// the costume of precision. The direct/indirect split is the resolution the evidence supports. +// +// • Acute:chronic workload ratios or any injury-risk / overtraining warning. The construct's +// validity is actively disputed, and a health warning from a non-medical app is either ignored +// or believed — both bad. + +public enum LiftMetrics { + + // MARK: - Performed or not + + /// Whether a set was performed, from its rep count. A set with ZERO reps was not: that is how a + /// finished session keeps the sets the user discards, so Edit sets can still fill them in, and it is + /// what a user types for a planned set they skipped. Every figure leaves such a set out, since + /// counting it would add a set nobody did. A set with no rep count (nil) still counts: it was done, + /// the number just was not typed. `WhoopStore.liftSetCounts` and `lastLiftSets` apply the same rule + /// in SQL. One function per platform, so the parity ledger pairs the twins unambiguously. + /// + /// The Kotlin twin is `LiftMetrics.isPerformed`. + public static func isPerformed(reps: Int?) -> Bool { + reps != 0 + } + + // MARK: - Volume load (tonnage) + + /// Σ (weight × reps) over WORKING sets, in kilograms. Nil when nothing countable was logged. + /// + /// Warm-ups are excluded because the training literature counts working sets, and a warm-up + /// double counted as volume would flatter every session. A set missing either its weight or its + /// reps contributes nothing rather than a guess. + /// + /// Good for: tracking progression WITHIN one exercise across weeks. Not comparable between + /// exercises — 100 kg of leg press is not 100 kg of squat — and not comparable between people. + /// + /// The Kotlin twin is `LiftMetrics.volumeLoadKg`. + public static func volumeLoadKg(_ sets: [LiftSetRow]) -> Double? { + let total = sets.reduce(into: 0.0) { sum, s in + guard !s.isWarmup, let w = s.weightKg, let r = s.reps, w > 0, r > 0 else { return } + sum += w * Double(r) + } + return total > 0 ? total : nil + } + + // MARK: - Session load (Foster sRPE-TL) + + /// Session RPE × duration in minutes. + /// + /// Foster's session-RPE training load. The reason it earns a place next to volume: it is + /// validated across BOTH resistance and endurance training, which makes it the only figure in + /// the app that puts a leg day and a run on one comparable scale. + /// + /// Nil when the session was not rated — a skipped rating must never be read as an effortless 0. + /// + /// The Kotlin twin is `LiftMetrics.sessionLoad`. + public static func sessionLoad(sessionRpe: Double?, durationSec: Int) -> Double? { + guard let rpe = sessionRpe, rpe > 0, durationSec > 0 else { return nil } + return rpe * (Double(durationSec) / 60.0) + } + + // MARK: - Estimated one-rep max (Epley) + + /// The rep ceiling above which a 1RM estimate stops being worth showing. + /// + /// Every 1RM formula is a straight-line fit to a curved relationship, and the error grows with + /// reps: a 20-rep set says far more about endurance than about maximal strength. Twelve is the + /// conventional upper bound where the formulas are least unreliable. + public static let oneRepMaxRepCeiling = 12 + + /// Epley: `w × (1 + reps/30)`. Nil for a set that cannot support an estimate. + /// + /// A single rep returns the weight itself — the formula's own +3.3% at one rep is an artefact of + /// the fit, not a claim that a single you just completed was really 3% heavier. + /// + /// The Kotlin twin is `LiftMetrics.estimatedOneRepMaxKg`. + public static func estimatedOneRepMaxKg(weightKg: Double?, reps: Int?) -> Double? { + guard let w = weightKg, let r = reps, w > 0, r > 0, r <= oneRepMaxRepCeiling else { return nil } + guard r > 1 else { return w } + return w * (1.0 + Double(r) / 30.0) + } + + // MARK: - Per-exercise summary + + public struct ExerciseSummary: Equatable { + public let exercise: String + /// Working sets only — the tally the dose-response literature is built on. + public let workingSets: Int + public let warmupSets: Int + public let volumeKg: Double? + /// The session's best set for this exercise, ranked by ESTIMATED 1RM rather than by raw + /// weight: 90 kg × 10 is a better set than 100 kg × 5, and ranking by weight alone would + /// hide that. Falls back to the heaviest set when no set supports an estimate. + public let bestWeightKg: Double? + public let bestReps: Int? + public let bestEstimatedOneRepMaxKg: Double? + + public init(exercise: String, workingSets: Int, warmupSets: Int, volumeKg: Double?, + bestWeightKg: Double?, bestReps: Int?, bestEstimatedOneRepMaxKg: Double?) { + self.exercise = exercise + self.workingSets = workingSets + self.warmupSets = warmupSets + self.volumeKg = volumeKg + self.bestWeightKg = bestWeightKg + self.bestReps = bestReps + self.bestEstimatedOneRepMaxKg = bestEstimatedOneRepMaxKg + } + } + + /// One summary per exercise, in the order the exercises were first performed — which is the + /// order they were done in, not alphabetical, because that is how a session reads back. A set that + /// was not performed (`isPerformed`) appears nowhere, and neither does an exercise with only such sets. + /// + /// The Kotlin twin is `LiftMetrics.perExercise`. + public static func perExercise(_ sets: [LiftSetRow]) -> [ExerciseSummary] { + var order: [String] = [] + var grouped: [String: [LiftSetRow]] = [:] + for s in sets.sorted(by: { $0.ord < $1.ord }) where isPerformed(reps: s.reps) { + if grouped[s.exercise] == nil { order.append(s.exercise) } + grouped[s.exercise, default: []].append(s) + } + return order.map { name in + let rows = grouped[name] ?? [] + let working = rows.filter { !$0.isWarmup } + + // Rank by estimated 1RM where possible; otherwise by raw weight, so an exercise logged + // only at high reps still reports a best set rather than nothing. + let best = working.max { a, b in + let ea = estimatedOneRepMaxKg(weightKg: a.weightKg, reps: a.reps) + let eb = estimatedOneRepMaxKg(weightKg: b.weightKg, reps: b.reps) + if let ea, let eb { return ea < eb } + if ea != nil { return false } + if eb != nil { return true } + return (a.weightKg ?? 0) < (b.weightKg ?? 0) + } + return ExerciseSummary( + exercise: name, + workingSets: working.count, + warmupSets: rows.count - working.count, + volumeKg: volumeLoadKg(rows), + bestWeightKg: best?.weightKg, + bestReps: best?.reps, + bestEstimatedOneRepMaxKg: estimatedOneRepMaxKg(weightKg: best?.weightKg, + reps: best?.reps)) + } + } + + // MARK: - RPE profile + + public struct RpeProfile: Equatable { + public let mean: Double? + public let ratedSets: Int + public let unratedSets: Int + public let setsAtOrAboveThreshold: Int + public let threshold: Double + + public init(mean: Double?, ratedSets: Int, unratedSets: Int, + setsAtOrAboveThreshold: Int, threshold: Double) { + self.mean = mean + self.ratedSets = ratedSets + self.unratedSets = unratedSets + self.setsAtOrAboveThreshold = setsAtOrAboveThreshold + self.threshold = threshold + } + } + + /// The default "this set was close to failure" line. Informational only. + public static let hardSetRpeThreshold = 8.0 + + /// How close to failure the working sets were. + /// + /// Reported SEPARATELY from the set counts and never as a filter on them. The tempting move is + /// to count only sets at RPE >= 7 toward a muscle's weekly total, since proximity to failure is + /// what makes a set count biologically. Doing that would compare a smaller number against + /// reference doses derived from UNFILTERED working-set counts — quietly changing the scale. + /// `unratedSets` is surfaced so a mean computed from three of twelve sets is visibly thin. + /// + /// The Kotlin twin is `LiftMetrics.rpeProfile`. + public static func rpeProfile(_ sets: [LiftSetRow], + threshold: Double = hardSetRpeThreshold) -> RpeProfile { + let working = sets.filter { !$0.isWarmup && isPerformed(reps: $0.reps) } + let rated = working.compactMap(\.rpe) + let mean = rated.isEmpty ? nil : rated.reduce(0, +) / Double(rated.count) + return RpeProfile(mean: mean, + ratedSets: rated.count, + unratedSets: working.count - rated.count, + setsAtOrAboveThreshold: rated.filter { $0 >= threshold }.count, + threshold: threshold) + } + + // MARK: - Sets per muscle + + public struct MuscleCounts: Equatable { + /// direct × 1.0 + indirect × 0.5 — the published fractional method. + public let fractional: [LiftMuscle: Double] + public let direct: [LiftMuscle: Int] + public let indirect: [LiftMuscle: Int] + + public init(fractional: [LiftMuscle: Double], direct: [LiftMuscle: Int], + indirect: [LiftMuscle: Int]) { + self.fractional = fractional + self.direct = direct + self.indirect = indirect + } + } + + /// Fractional set counts per muscle over the given sets. + /// + /// The 0.5 for an indirect set is NOT a house convention: the 2025 Sports Medicine dose-response + /// meta-regression compared counting a secondary mover's set as 1.0 ("total"), 0.5 + /// ("fractional") and 0.0 ("direct"), found the evidence strongest for fractional, and used it + /// in its primary models. The reference doses in `ReferenceDose` were derived under that same + /// operationalisation, so the credit and the doses have to move together or the comparison + /// silently stops meaning anything. + /// + /// Warm-ups and sets never performed (`isPerformed`) are excluded; nothing else is. An unclassified + /// exercise (nil primary) contributes to volume and session load but claims no muscle it was never + /// assigned. + /// + /// The Kotlin twin is `LiftMetrics.muscleCounts`. + public static func muscleCounts(_ sets: [LiftSetRow]) -> MuscleCounts { + var fractional: [LiftMuscle: Double] = [:] + var direct: [LiftMuscle: Int] = [:] + var indirect: [LiftMuscle: Int] = [:] + for s in sets where !s.isWarmup && isPerformed(reps: s.reps) { + if let p = s.primaryMuscle { + direct[p, default: 0] += 1 + fractional[p, default: 0] += LiftMuscle.directSetCredit + } + for m in s.secondaryMuscles where m != s.primaryMuscle { + indirect[m, default: 0] += 1 + fractional[m, default: 0] += LiftMuscle.indirectSetCredit + } + } + return MuscleCounts(fractional: fractional, direct: direct, indirect: indirect) + } + + // MARK: - The reference band + + /// Weekly fractional sets per muscle, from the same dose-response meta-regression the 0.5 + /// credit comes from. + /// + /// PRESENTED AS A BAND WITH ITS SOURCE NAMED, NEVER AS A PERSONAL PRESCRIPTION. NOOP is not a + /// medical device and does not tell anyone what their body needs; it says what the research + /// associates with growth and leaves the conclusion to the reader. + public enum ReferenceDose { + /// Below roughly this, hypertrophy is not reliably detectable. + public static let hypertrophyMinimumSetsPerWeek = 4.0 + /// Strength keeps improving from a single weekly set. + public static let strengthMinimumSetsPerWeek = 1.0 + /// Beyond roughly this, added volume stops reliably beating the smallest detectable effect + /// FOR STRENGTH. Hypertrophy has no identified ceiling — gains continue with strongly + /// diminishing returns, and the uncertainty widens as volume rises. + public static let strengthPlateauSetsPerWeek = 4.0 + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/LivePersistTrace.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/LivePersistTrace.swift new file mode 100644 index 0000000000..64ea3b0b7f --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/LivePersistTrace.swift @@ -0,0 +1,278 @@ +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)" + } + + /// The host-received line above, summarised: what a strap log carries about this transport when no + /// Test Centre mode is on. + /// + /// WHY. `standardHRHostReceivedLine` is written for EVERY standard-HR sample, which a streaming strap + /// produces once a second. In one 75-minute gym session on 22 Sep 2026 that was 3,009 of the log's 5,704 + /// lines — 52.8% of everything the log had to say about that session — and a 5/MG export in the #2386 + /// review had the same shape, half its transport lines crowding out the rest. Since #2386 the log is kept + /// on disk within 2 MB, so this ratio now decides how much history a bug report carries: about three + /// hours of a streaming strap, where a summarised stream carries a day. + /// + /// What the line exists for is kept. A sample the host REFUSED — an HR outside 30…220, an R-R outside + /// 250…3000 ms — is still written the moment it happens: that is the rare event, and it costs nothing + /// while nothing is wrong. The routine ones are counted and rendered once a window as a line that says + /// how many arrived, over how long, the widest gap between two of them (a stall a reader used to have to + /// find by eye), what was accepted and refused, and what is still pending. Full per-sample detail comes + /// back while the Test Centre's HRV or Connection mode is on, which is what those modes are for: gate the + /// per-sample readout behind the domain, leave the rare-event evidence always on (`AGENTS.md`). + /// + /// Twin of the Kotlin `StandardHrHostReceivedTrace` (`com.noop.ble`), line for line: same window, same + /// rules, same rendered text. + public struct StandardHRHostReceivedTrace: Sendable { + + /// One host-received observation: the same fields the per-sample line renders. + public struct Sample: Sendable, Equatable { + public var hostUnixSeconds: Int + public var acceptedHRRows: Int + public var acceptedRRRows: Int + public var rejectedHRRows: Int + public var rejectedRRRows: Int + public var pendingHRRows: Int + public var pendingRRRows: Int + + public init(hostUnixSeconds: Int, acceptedHRRows: Int, acceptedRRRows: Int, + rejectedHRRows: Int, rejectedRRRows: Int, + pendingHRRows: Int, pendingRRRows: Int) { + self.hostUnixSeconds = hostUnixSeconds + self.acceptedHRRows = acceptedHRRows + self.acceptedRRRows = acceptedRRRows + self.rejectedHRRows = rejectedHRRows + self.rejectedRRRows = rejectedRRRows + self.pendingHRRows = pendingHRRows + self.pendingRRRows = pendingRRRows + } + } + + private var firstSecond: Int? + private var lastSecond = 0 + private var samples = 0 + private var acceptedHR = 0 + private var acceptedRR = 0 + private var rejectedHR = 0 + private var rejectedRR = 0 + private var pendingHR = 0 + private var pendingRR = 0 + private var widestGap = 0 + + public init() {} + + /// The lines to write for this sample, in the order they should appear: a finished window's summary + /// first, because it describes the samples before this one. + /// + /// Twin of the Kotlin `StandardHrHostReceivedTrace.record`. + public mutating func record(_ sample: Sample, detailed: Bool) -> [String] { + var lines: [String] = [] + if let first = firstSecond, + // 60 s: a window long enough to turn a streaming hour into 60 lines, short enough that a stall + // still shows in one of them. The Kotlin twin uses the same literal; a named constant here + // would pair with the unrelated `windowSeconds` the ledger already tracks. + sample.hostUnixSeconds - first >= 60 || sample.hostUnixSeconds < first { + // A clock that went backwards ends the window too: a span is only honest within one clock. + if let summary = summaryLine() { lines.append(summary) } + let previousLast = lastSecond + let forwards = sample.hostUnixSeconds >= previousLast + reset() + // #2405: the gap that CROSSED this boundary is the one worth reporting, and it used to be + // the one gap that could not be. A stall of a minute or more forces this roll on the next + // sample, so it fell between two windows and appeared in neither, leaving `gapMaxSec` able + // to describe only stalls shorter than the window — the opposite of what it is read for. + // + // It is seeded into the NEW window rather than added to the summary just emitted: that + // summary describes the samples BEFORE the gap, and this window is the one the gap opens. + // So the line reads "since the previous summary, the widest gap was this". Not seeded + // across a backwards clock (no honest span) nor across `close()`, where a disconnect or a + // termination already accounts for the silence and a stall would be double-reported. + if forwards { widestGap = sample.hostUnixSeconds - previousLast } + } + if firstSecond == nil { + firstSecond = sample.hostUnixSeconds + } else { + widestGap = max(widestGap, sample.hostUnixSeconds - lastSecond) + } + lastSecond = sample.hostUnixSeconds + samples += 1 + acceptedHR += sample.acceptedHRRows + acceptedRR += sample.acceptedRRRows + rejectedHR += sample.rejectedHRRows + rejectedRR += sample.rejectedRRRows + pendingHR = sample.pendingHRRows + pendingRR = sample.pendingRRRows + if detailed || sample.rejectedHRRows > 0 || sample.rejectedRRRows > 0 { + lines.append(standardHRHostReceivedLine( + hostUnixSeconds: sample.hostUnixSeconds, + acceptedHRRows: sample.acceptedHRRows, acceptedRRRows: sample.acceptedRRRows, + rejectedHRRows: sample.rejectedHRRows, rejectedRRRows: sample.rejectedRRRows, + pendingHRRows: sample.pendingHRRows, pendingRRRows: sample.pendingRRRows)) + } + return lines + } + + /// The window so far, for a disconnect, a background flush or a termination: the last minute of a + /// session is exactly the part a report is taken for, and it must not leave with the process. + /// + /// Twin of the Kotlin `StandardHrHostReceivedTrace.close`. + public mutating func close() -> [String] { + guard let summary = summaryLine() else { return [] } + reset() + return [summary] + } + + /// Twin of the Kotlin `StandardHrHostReceivedTrace.summaryLine`. + private func summaryLine() -> String? { + guard let first = firstSecond, samples > 0 else { return nil } + return "standard-hr transport host-received summary" + + " windowSec=\(lastSecond - first) samples=\(samples) gapMaxSec=\(widestGap)" + + " acceptedHRRows=\(acceptedHR) acceptedRRRows=\(acceptedRR)" + + " rejectedHRRows=\(rejectedHR) rejectedRRRows=\(rejectedRR)" + + " pendingHRRows=\(pendingHR) pendingRRRows=\(pendingRR)" + } + + /// Twin of the Kotlin `StandardHrHostReceivedTrace.reset`. + private mutating func reset() { + firstSecond = nil + lastSecond = 0 + samples = 0 + acceptedHR = 0; acceptedRR = 0 + rejectedHR = 0; rejectedRR = 0 + pendingHR = 0; pendingRR = 0 + widestGap = 0 + } + } + + 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/PrimarySessionRestingHR.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/PrimarySessionRestingHR.swift index 5eac24c4e1..c64eac27fe 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/PrimarySessionRestingHR.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/PrimarySessionRestingHR.swift @@ -10,12 +10,19 @@ import Foundation /// references, a pre-declared dev/holdout split, no fitted offset) found the primary-session sample mean /// tracked both references far better: rounded MAE vs the official target 6.0→2.0 (dev) / 7.5→0.8 (holdout). /// -/// ## Deliberately PURE and UNWIRED -/// This computes the metric and is unit-tested, but **nothing consumes it yet**. The shipped headline AND the -/// recovery / strain / workout-detection / energy inputs all read `restingHRDaily` (the floor) in -/// `AnalyticsEngine`, so switching them is a re-baselining of core scores — which the issue itself says needs -/// a larger multi-participant, pre-declared holdout first. That is out of scope here; this lands the -/// transparent, testable definition so that validation and any later wiring have something concrete to use. +/// ## WIRED as of #2358 — this sets the shipped daily resting HR +/// It was landed pure and unwired, on the reasoning that switching the consumers is a re-baselining of core +/// scores and that the issue asks for a larger multi-participant holdout first. #2358 made the switch anyway, +/// as a maintainer call: `AnalyticsEngine.restingHRDaily` now prefers a device-provided primary-session value, +/// then THIS mean, and falls back to the old `restingHR.min()` floor only when coverage is sparse. So the +/// headline resting HR and everything reading it — recovery, strain, workout detection, energy — come from +/// here on any day with a covered primary session. +/// +/// What that means for the evidence below: the MAE figures are from ONE participant over five nights against +/// a pre-declared split, which is the holdout the issue says is not yet large enough. They justified building +/// the metric; they are thinner than the change they now carry. #2284 separately replaces what a session's +/// `restingHR` IS (deep-sleep mean rather than lowest 5-minute bin), which moves the `.min()` fallback under +/// this, so the comparison baseline those numbers were measured against no longer exists unchanged. /// /// ## Definition (documented per the issue) /// - **Primary session**: the LONGEST session by duration; ties resolve to the FIRST (stable). A shorter nap diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/RangeReport.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/RangeReport.swift index c3f24bacaa..5f7bd96048 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/RangeReport.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/RangeReport.swift @@ -120,6 +120,37 @@ public enum ReportMetric: String, CaseIterable, Sendable { } } +// MARK: - Display units + +/// The user's display preferences, resolved by the app layer and passed in (#1637). +/// +/// NOOP stores everything in SI and on its own native scales; two settings change only how a number +/// is *shown* — the temperature unit (°C / °F) and the Effort axis (native 0–100 / WHOOP 0–21). Every +/// in-app screen honours both, but the exported trends report rendered raw stored values with a +/// hardcoded unit, so a reader comparing the PDF against the app saw two different numbers for the +/// same night with nothing on the page to explain it. +/// +/// This engine stays pure and never reads UserDefaults; the app resolves the settings and hands them +/// over. `.stored` is the identity — °C and the native 0–100 axis, i.e. exactly what is on disk — +/// and is the default everywhere, so an untouched call site is byte-identical to before. +/// +/// Twin of `com.noop.analytics.ReportDisplayUnits`. +public struct ReportDisplayUnits: Equatable, Sendable { + /// Render temperatures in °F. A DEVIATION scales ×9/5 with no +32 offset (see `displayValue`). + public let fahrenheit: Bool + /// Multiplier onto the stored 0–100 Effort value: 1.0 for NOOP's native axis, 21/100 for WHOOP's + /// 0–21 Day Strain axis. Matches `UnitFormatter.effortScaleFactor` / the Kotlin twin. + public let effortFactor: Double + + public init(fahrenheit: Bool, effortFactor: Double) { + self.fahrenheit = fahrenheit + self.effortFactor = effortFactor + } + + /// The identity: values exactly as stored (°C, native 0–100 Effort). + public static let stored = ReportDisplayUnits(fahrenheit: false, effortFactor: 1.0) +} + /// Which way a metric moved across the range (by OLS slope vs a small threshold). public enum ReportTrend: String, Equatable, Sendable { case rising @@ -232,10 +263,15 @@ public enum RangeReportEngine { /// and missing days are simply absent; this is robust to sparse data. /// - start: inclusive range start, "yyyy-MM-dd". /// - end: inclusive range end, "yyyy-MM-dd". + /// - units: the user's display preferences, applied to the rendered `headlines` only (#1637). + /// Defaults to `.stored`, which reproduces the pre-#1637 strings exactly. Every `MetricRangeStat` + /// below stays in STORED units regardless — callers convert at render time via `displayValue`, + /// so the statistics remain comparable across settings and nothing on disk depends on a toggle. /// /// If `end` sorts before `start` the range is treated as empty (no metrics, 0 days). public static func build(metrics: [ReportMetric: [String: Double]], - start: String, end: String) -> RangeReport { + start: String, end: String, + units: ReportDisplayUnits = .stored) -> RangeReport { // A valid window requires start <= end (ISO string compare == chronological). guard start <= end else { return RangeReport(start: start, end: end, totalDays: 0, @@ -288,19 +324,55 @@ public enum RangeReportEngine { trend: trend, latest: latest)) } - let headlines = makeHeadlines(stats) + let headlines = makeHeadlines(stats, units: units) return RangeReport(start: start, end: end, totalDays: totalDays, metrics: stats, headlines: headlines) } + // MARK: - Display conversion + + /// A stored value rendered on the user's chosen axis (#1637). + /// + /// The ONE place the report converts, shared by the headline sentences below and the app layer's + /// metric cards, so the two surfaces of the same document can never disagree about a number. + /// Metrics with no user-configurable presentation pass through untouched. + /// + /// Skin temp is a signed DEVIATION from the personal baseline, so °F scales by 9/5 with NO +32 + /// offset — adding it would be wrong for a difference. Matches `SkinTempDisplay.numberString` + /// and `UnitFormatter.temperatureDeltaFromCelsius`. + public static func displayValue(_ v: Double, metric: ReportMetric, + units: ReportDisplayUnits) -> Double { + switch metric { + case .skinTempDev: return units.fahrenheit ? v * 9.0 / 5.0 : v + case .strain: return v * units.effortFactor + default: return v + } + } + + /// The unit suffix for a metric under the user's settings — `ReportMetric.unit` for everything + /// that has no display preference (#1637). + /// + /// Two metrics differ from the static suffix. Skin temp carries the `Δ` the whole app shows, + /// because the row is a deviation and a bare "°C" reads as an absolute wrist temperature. Effort + /// is unitless on its native 0–100 axis (unchanged), but names its denominator once rescaled — + /// a bare "12.4" where the reader expects 0–100 is the one thing a rescale must not produce. + public static func displayUnit(_ metric: ReportMetric, units: ReportDisplayUnits) -> String { + switch metric { + case .skinTempDev: return units.fahrenheit ? "Δ°F" : "Δ°C" + case .strain: return units.effortFactor == 1.0 ? "" : "/ 21" + default: return metric.unit + } + } + // MARK: - Headlines /// One plain-English line per present metric, ranked most-notable first. "Notable" /// is the absolute first→second-half change scaled by the metric's trend threshold, /// so movers on different units are comparable. Folds in good/bad framing. - static func makeHeadlines(_ stats: [MetricRangeStat]) -> [String] { + static func makeHeadlines(_ stats: [MetricRangeStat], + units: ReportDisplayUnits = .stored) -> [String] { let ranked = stats.sorted { salience($0) > salience($1) } - return ranked.map { headline($0) } + return ranked.map { headline($0, units: units) } } /// |half delta| normalised by the metric's trend threshold (a units-agnostic move). @@ -310,7 +382,11 @@ public enum RangeReportEngine { } /// Render one metric's headline. Trend word + good/bad framing + the two half means. - static func headline(_ s: MetricRangeStat) -> String { + /// + /// The two means are rendered through `displayValue` / `displayUnit`, so the sentence agrees with + /// the metric cards beside it and with the app (#1637). The TREND WORD and the good/bad framing + /// come from `s.trend`, computed on stored values, so a display toggle can never flip a verdict. + static func headline(_ s: MetricRangeStat, units: ReportDisplayUnits = .stored) -> String { let word: String switch s.trend { case .rising: word = "trending up" @@ -326,9 +402,12 @@ public enum RangeReportEngine { let good = (up == s.metric.higherIsBetter) frame = good ? " - a good sign" : " - worth a look" } - let unit = s.metric.unit.isEmpty ? "" : " \(s.metric.unit)" - return "\(s.metric.label) is \(word) (avg \(round1(s.firstHalfMean))\(unit) → " - + "\(round1(s.secondHalfMean))\(unit))\(frame)." + let rawUnit = displayUnit(s.metric, units: units) + let unit = rawUnit.isEmpty ? "" : " \(rawUnit)" + let first = displayValue(s.firstHalfMean, metric: s.metric, units: units) + let second = displayValue(s.secondHalfMean, metric: s.metric, units: units) + return "\(s.metric.label) is \(word) (avg \(round1(first))\(unit) → " + + "\(round1(second))\(unit))\(frame)." } // MARK: - Trend diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/RecoveryScorer+Trace.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/RecoveryScorer+Trace.swift index ffe76a6557..f6ef3a9251 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/RecoveryScorer+Trace.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/RecoveryScorer+Trace.swift @@ -11,6 +11,19 @@ import Foundation extension RecoveryScorer { + /// The trace's two-decimal rounding contract, shared by both platforms: round `x * 100` to the + /// nearest integer with half-ties AWAY FROM ZERO, divide by 100, and keep the IEEE sign bit — so a + /// driver term that rounds to zero from below still renders `-0.0`, as it does on Android (#1437). + /// + /// The rounding stays in the DOUBLE domain on purpose. Kotlin's `Math.round` returns a `Long`, which + /// saturates at 2^63-1, so `1e20` came back as `9.223372036854776e16` there while this side kept + /// `1e20` (#47). Nothing here is clamped: any finite input round-trips, and a non-finite one (or a + /// finite one whose `x * 100` overflows) passes through as the matching infinity or NaN. The Kotlin + /// twin is `RecoveryScorerTrace.r2`. + static func traceRound2(_ x: Double) -> Double { + (x * 100.0).rounded(.toNearestOrAwayFromZero) / 100.0 + } + /// Side-effect-free diagnostic twin of `recovery(...)`: returns the SAME score recovery(...) would, /// plus the per-term Charge breakdown trace. The four inputs (hrv / rhr / resp / sleepPerf) plus the /// skin-temp deviation each get a baseline line (mean / spread / nValid / status), a term line @@ -36,16 +49,20 @@ extension RecoveryScorer { -> (score: Double?, trace: [String]) { // Trace numbers use nearest rounding with half-ties away from zero on both platforms. - func r2(_ x: Double) -> Double { - (x * 100.0).rounded(.toNearestOrAwayFromZero) / 100.0 - } + func r2(_ x: Double) -> Double { traceRound2(x) } var lines: [String] = [] var nilTerms: [String] = [] + // #1988: the trace reads this baseline DIRECTLY for its own `charge baseline rhr` line, its + // rhrZ and the saturation guard, not only through recovery(). recovery() now drops an + // unusable one, so without the same gate here the trace would list an rhr term the score + // did not use, which is precisely the divergence the line below promises cannot happen. + let rhrB = rhrBaseline.flatMap { $0.usable ? $0 : nil } + // The score the dashboard reads, verbatim, so the trace cannot diverge from it. let score = recovery(hrv: hrv, rhr: rhr, resp: resp, - hrvBaseline: hrvBaseline, rhrBaseline: rhrBaseline, + hrvBaseline: hrvBaseline, rhrBaseline: rhrB, respBaseline: respBaseline, sleepPerf: sleepPerf, skinTempDev: skinTempDev) @@ -62,7 +79,7 @@ extension RecoveryScorer { // baseline arg here (skinTempDev is already a deviation), so it has no baseline line. lines.append("charge baseline hrv mean=\(r2(hrvBaseline.baseline)) spread=\(r2(hrvBaseline.spread)) " + "nValid=\(hrvBaseline.nValid) status=\(hrvBaseline.status.rawValue)") - if let b = rhrBaseline { + if let b = rhrB { lines.append("charge baseline rhr mean=\(r2(b.baseline)) spread=\(r2(b.spread)) " + "nValid=\(b.nValid) status=\(b.status.rawValue)") } @@ -78,7 +95,7 @@ extension RecoveryScorer { // Resting-HR z, computed up front so the saturation guard can read the HRV<->RHR coupling // before the HRV term is built. nil when there is no RHR baseline. Numerically identical to // the z recovery() builds for the RHR term (same expression, same inputs). - let rhrZForGuard: Double? = rhrBaseline.map { zScore($0.baseline, mean: rhr, spread: $0.spread) } + let rhrZForGuard: Double? = rhrB.map { zScore($0.baseline, mean: rhr, spread: $0.spread) } // HRV term: higher is better. (Always present once usable; the cold-start guard above returned.) // This is the RAW z, exactly as recovery() scores it: the parasympathetic-saturation easing is diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/RecoveryScorer.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/RecoveryScorer.swift index 2c411b1f63..2a3354b558 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]. @@ -411,8 +289,9 @@ public enum RecoveryScorer { /// - rhr: tonight's resting HR (bpm). /// - resp: tonight's respiration (raw or calibrated — z is scale-invariant); /// nil drops the term. - /// - hrvBaseline: HRV baseline (required for a score). - /// - rhrBaseline: resting-HR baseline; nil drops the RHR term. + /// - hrvBaseline: HRV baseline (required for a score — nil returns nil). + /// - rhrBaseline: resting-HR baseline; nil drops the RHR term. On the `BaselineState` overload an + /// UNUSABLE baseline (#1988) is treated as nil, so a synthetic cold-start midpoint never scores. /// - respBaseline: respiration baseline; nil drops the resp term. /// - sleepPerf: Rest quality (Rest composite ÷100, 0..1; was raw efficiency); /// nil drops the term. @@ -422,8 +301,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. @@ -449,6 +328,11 @@ public enum RecoveryScorer { // Cold-start gate: HRV is the dominant driver; if its baseline isn't // usable, refuse to score (more honest than a fabricated value). if !hrvBaselineUsable { return nil } + // Required-driver gate: the HRV baseline is REQUIRED for a score. It is Optional here only + // for callers that may not have one yet, and hrvBaselineUsable defaults to true, so without + // this an absent baseline let any other optional term (sleepPerf alone, say) produce a + // Charge score carrying no HRV term at all. + guard let hrvB = hrvBaseline else { return nil } var terms: [(z: Double, w: Double)] = [] @@ -459,9 +343,7 @@ public enum RecoveryScorer { // signature is still detected and reported out-of-band (Charge trace + ChargeDrivers verdict) // so real firings can be counted first. See the MARK header for why, and swap in // `parasympatheticSaturation(hrvZ:rhrZ:).easedHrvZ` here to enable it. - if let b = hrvBaseline { - terms.append((zScore(hrv, mean: b.mean, spread: b.spread), wHRV)) - } + terms.append((zScore(hrv, mean: hrvB.mean, spread: hrvB.spread), wHRV)) // RHR term: lower is better → (μ − x) / σ. if let b = rhrBaseline { terms.append((zScore(b.mean, mean: rhr, spread: b.spread), wRHR)) @@ -526,7 +408,13 @@ public enum RecoveryScorer { rhr: rhr, resp: resp, hrvBaseline: DriverBaseline(hrvBaseline), - rhrBaseline: rhrBaseline.map(DriverBaseline.init), + // #1988: an UNUSABLE resting-HR baseline is treated as absent. foldHistory returns + // the config's synthetic midpoint (about 75 bpm) for an empty or all-implausible + // history, which is nobody's resting HR, so scoring against it moved Charge on a + // baseline the user never had. Gated here, in the one place every BaselineState + // caller passes through, rather than at each call site: the headline and the driver + // breakdown then agree by construction. Mirrors hrvBaselineUsable below. + rhrBaseline: rhrBaseline.flatMap { $0.usable ? $0 : nil }.map(DriverBaseline.init), respBaseline: respBaseline.map(DriverBaseline.init), sleepPerf: sleepPerf, skinTempDev: skinTempDev, 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..83ec9737f8 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager+Trace.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager+Trace.swift @@ -15,6 +15,62 @@ 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. + /// + /// `day` leads the line (#2397). A re-score emits one of these per night, twenty-one of them + /// inside two seconds in one field log, and without the date they are an unattributable block: + /// asked which band last night used, a reader can only count lines and hope the emission order + /// matches the day order. The other funnel lines in this file already carry `day=`. + public static func hrOnlyGateLine(day: String, attempted: Bool, reason: String, + gravRows: Int, storedNights: Int) -> String { + "[sleep] hr-only gate day=\(day) 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. + /// + /// `day` leads for the reason given on the gate line above: these two travel as a pair, and a + /// pair that cannot be attributed to a night answers nothing about that night. + public static func hrOnlyLine(day: String, 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 day=\(day) 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 `SleepStagerTrace.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..796fb48d62 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,235 @@ 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(day: String, 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(day: day, 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( + day: day, + 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,70 +768,135 @@ 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 } + /// True when this night's sleep runs will ALL be dropped by `minSleepMin` yet add up to a plausible + /// night. Pure, so the rule can be tested without building gravity. + /// + /// The predicate is deliberately "today's answer is zero". Every run being under the floor means the + /// survival pass keeps none of them, so a bridge enabled on this basis can only turn nothing into + /// something; if any single run already clears the floor this is false, the sparse rule decides as + /// before, and a night that currently scores cannot change. + /// + /// Two runs at minimum, because one fragment is not a fragmented night. The SUM must clear the floor + /// so a handful of brief stirs does not become a night. Neither condition weakens what the bridge + /// itself checks. Kotlin twin: `isFragmentedToNothing`. + static func isFragmentedToNothing(_ sleepRunSpansS: [Int], minSleepS: Int) -> Bool { + sleepRunSpansS.count >= 2 + && !sleepRunSpansS.contains { $0 >= minSleepS } + && sleepRunSpansS.reduce(0, +) >= minSleepS + } + /// Per-pair explanation of `bridgeSparseSleep`, mirroring its rule EXACTLY (same adjacency walk, /// same `gap >= 0 && gap <= sparseBridgeGapMin*60`, same HR-band check) so the reasons describe what /// 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 @@ -1044,39 +1405,78 @@ public enum SleepStager { let flags = classifyStill(grav, deltas) var runs = buildRuns(grav, flags, sparse: sparse, hr: hrS, baseline: baseline) runs = mergePeriods(runs) - // Re-stitch sleep runs fragmented by pure gravity dropouts (sparse only) before minSleepMin. + let minSleepS = minSleepMin * 60 + // #1937: a night whose sleep runs are ALL shorter than minSleepMin yields NO session at all, + // however much sleep they add up to. A real capture: five runs, 298 minutes of detected sleep, + // every one dropped for being about a minute short, and the night vanished. + // + // The bridge that exists to re-stitch fragments was switched off, because it is gated on the + // night being SPARSE (#308, so a dense night kept its original path byte-for-byte) and the night + // was dense. On the reporting device gravity coverage was 99.9%: the rescue was declined + // precisely because the data was good. + // + // So the bridge also runs when the night is fragmented to nothing. The gate is deliberately + // "today's answer is zero": if any single run already clears the floor this is false and the + // sparse rule decides exactly as before, so a night that currently scores cannot change. It can + // only turn nothing into something. + // + // Requiring the SUM to clear the floor keeps a handful of brief stirs from becoming a night, and + // the bridge's own rules still apply underneath — a gap longer than sparseBridgeGapMin, an + // intervening active run that is too long, or HR above the sleep band all still refuse. This + // enables the attempt; it does not weaken what the attempt checks. + // + // Deliberately NOT passed to buildRuns above, which takes its own `sparse` for the HR-vouched + // gap rule. That decides how runs are FORMED, so widening it would change the input to + // everything downstream including nights that currently score. This only re-stitches runs that + // are already built, and only when every one of them was about to be discarded. + // Evaluated unconditionally rather than short-circuited behind `sparse`, so the trace can + // report what it actually was on a sparse night too. It is a filter/map/sum over a handful of + // runs. + let fragmentedToNothing = isFragmentedToNothing( + runs.filter { $0.stage == "sleep" }.map { $0.end - $0.start }, minSleepS: minSleepS) + let bridgeEnabled = sparse || fragmentedToNothing + // Re-stitch sleep runs fragmented by gravity dropouts, before minSleepMin. 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) - // 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. - if let traceSink, sparse { + let bridgeResult = bridgeSparseSleepTraced(runs, sparse: bridgeEnabled, hr: hrS, baseline: baseline) + let bridgeAttempts = bridgeResult.1 + runs = bridgeResult.0 + // Sleep & Rest test mode (E3): record the bridge result, so a night rescued from fragmentation + // is visible. Emitted whenever the bridge was ENABLED (sparse, or #1937's fragmented-to-nothing + // night) and only when tracing. Side-effect-only. + if let traceSink, bridgeEnabled { let runsAfterBridge = runs.filter { $0.stage == "sleep" }.count + // BOTH gates are reported, always, as their own k=v keys. The line used to hardcode + // `sparse=true`, which since #1937 could contradict the `sparse=false` on the summary line + // beside it. Emitting both (rather than one "why" naming the winner) keeps the key set + // identical on every night, which is what a reader diffing two nights, and any k=v parser, + // needs. 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. + detail: "sparse=\(sparse) fragmentedToNothing=\(fragmentedToNothing) " + + "gapMin=\(sparseBridgeGapMin) runsBefore=\(runsBeforeBridge) runsAfter=\(runsAfterBridge)")) + // #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)")) } } - let minSleepS = minSleepMin * 60 - var sessions: [SleepSession] = [] // Continuous-sleep chain tracking so a real overnight sleep that runs PAST the daytime-band // start (a late wake, or a brief morning stir then back to sleep that leaves the tail as its @@ -2256,12 +2656,21 @@ public enum SleepStager { return .noRespFallbackBar // resp never measured and the no-resp bar unmet } - /// Read-only REM-funnel triage for ONE in-bed window [start, end] (#688). Re-runs the SAME Stage-0→3 - /// staging seam `stageSession` uses (epoch grid → Cole–Kripke → features → classify → smooth → - /// re-impose), but instead of emitting a hypnogram it COUNTS where REM was lost. Changes NOTHING: - /// no label, no score, no session. Returns nil only when the window has too little gravity to grid - /// (mirroring `stageSession`'s degenerate fallback, which carries no REM to explain). The caller - /// logs `.summary`; tests assert the counts. Pure + deterministic. (#688) + /// Read-only REM-funnel triage for ONE in-bed window [start, end] (#688). Re-runs THIS type's + /// Stage-0→3 seam (epoch grid → Cole–Kripke → features → classify → smooth → re-impose), but + /// instead of emitting a hypnogram it COUNTS where REM was lost. Changes NOTHING: no label, no + /// score, no session. Returns nil only when the window has too little gravity to grid (mirroring + /// `stageSession`'s degenerate fallback, which carries no REM to explain). The caller logs + /// `.summary`; tests assert the counts. Pure + deterministic. (#688) + /// + /// WHICH HYPNOGRAM THIS EXPLAINS. V1's, always — this function is `SleepStager`'s own seam. It used + /// to say it explained "the SAME hypnogram" as the screen, and that has been false since V2 became + /// the default: the shipped hypnogram is staged by `SleepStagerV2` whenever + /// `experimentalSleepV2Enabled` says so, which is by default. On a 5/MG the two can be far apart, + /// because V1's primary REM gate needs the raw respiratory channel the hardware never emits while V2 + /// recovers respiration regularity from R-R: one field pair reported ~46 min REM here against hours + /// on the screen for the same night (#2365). The caller's line names both stagers for that reason + /// (#2366); do not restore the claim that they are one. public static func remFunnelDiagnostic(start: Int, end: Int, grav: [GravitySample], hr: [HRSample], rr: [RRInterval], resp: [RespSample]) -> REMFunnelDiagnostic? { @@ -2436,18 +2845,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()) } @@ -2462,15 +2954,54 @@ public enum SleepStager { } /// Mean RMSSD over 5-min tumbling windows across the session (ms), or nil. - /// Uses the same range-filter + ≥2-valid-interval rule as hrv.rmssd(). + /// A window counts only with `HRVAnalyzer.minBeats` clean intervals (see `sessionHrvWindows`). 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. + guard !sessionHrvOverCounted(start: start, end: end, rr: rr) else { return nil } + return vals.reduce(0, +) / Double(vals.count) + } + + /// Whether the #1118 coverage gate refuses a session's HRV: its own R-R, windowed [start, end] exactly + /// as `sessionAvgHRV` windows it, banks more beat-time than the wall clock allows. Pure. The ONE + /// definition of "refused": `sessionAvgHRV` gates on it, and `AnalyticsEngine` asks it whether a main + /// night's missing HRV was refused rather than never measured, so the two cannot disagree about which + /// nights were refused. Byte-parity twin of Kotlin `SleepStager.sessionHrvOverCounted`. + static func sessionHrvOverCounted(start: Int, end: Int, rr: [RRInterval]) -> Bool { + 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) + return !HRVAnalyzer.successiveDiffIsTrustworthy(verdict) } /// 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 +3012,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 @@ -2490,12 +3025,16 @@ public enum SleepStager { // #204/#195: gap-aware — a successive difference straddling a dropped beat is skipped so a // removed out-of-range/ectopic beat can't splice its neighbours into a spurious delta. let cleaned = HRVAnalyzer.cleanRRGapAware(bucket) - let rmssd: Double? = (cleaned.nn.count >= 2) ? HRVAnalyzer.rmssdGapAware(cleaned.nn, cleaned.contiguous) : nil + // The same `minBeats` floor the spot reading and the SDNN index apply. The night is a plain mean + // over windows, so without it a window left with a handful of clean beats (a dropout, a + // movement-shredded stretch, a short final window) weighed as much as a full five minutes. + let rmssd: Double? = (cleaned.nn.count >= HRVAnalyzer.minBeats) + ? HRVAnalyzer.rmssdGapAware(cleaned.nn, cleaned.contiguous) : nil let center = t + windowS / 2 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..1029e9defa --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/SlidingStreamWindow.swift @@ -0,0 +1,159 @@ +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 + /// How many reads were forced by the OWNER changing since the last one (#2073). Diagnostic only. + /// + /// `served` near zero already tells a reader the windows are declining, and `WindowedStreamPlan`'s own + /// doc names the three ways that happens: truncation, an owner flip, or a gap. `truncated` is already + /// on the line, so counting the flips separates the remaining two instead of leaving them as one + /// unanswerable number. A field log showed a stream reading 4,084 rows and serving none with + /// `truncated=0`, and there was no way to tell which of the other two it was. + public private(set) var ownerFlips = 0 + + /// Reads that bypassed reuse entirely because the caller passed `allowReuse: false` (#2073). + /// + /// This is the answer `ownerFlips` alone would get WRONG. A WHOOP 5 R-R read passes false, because + /// transport selection is range-dependent and a slice could mix transports, so that window can never + /// serve from its buffer: `served=0` there is BY DESIGN, not a decline. Without this count the line + /// said `served=0 truncated=0 ownerFlips=0` and invited a reader to conclude "a gap", which is the + /// opposite of true. The bypass also clears the buffer, so a genuine owner change on such a stream is + /// never even seen as a flip. + public private(set) var reuseOffReads = 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, allowReuse: Bool = true) async -> [T] { + // A range-dependent source choice is not composable: a slice or extension may select another + // transport. Read the whole interval and retain no buffer, while keeping cost/truncation truthful. + if !allowReuse { + reuseOffReads += 1 + _ = failedRead() + guard let full = await read(owner, from, to) else { return [] } + rowsRead += full.count + if full.count >= limit { truncatedReads += 1 } + return full + } + if let cached = self.owner, cached != owner { ownerFlips += 1 } + 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/Spo2ReTrace.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/Spo2ReTrace.swift index 5d5c9b653c..97e33ded21 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/Spo2ReTrace.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/Spo2ReTrace.swift @@ -19,9 +19,34 @@ import Foundation // rides in it). The Kotlin twin is Spo2ReTrace.kt; the emitted line is byte-identical on both platforms. public enum Spo2ReTrace { - /// Max records dumped per offload session. A handful is enough for an offline correlation pass and - /// keeps the strap log bounded; the Backfiller counter spans chunks and resets per session. - public static let maxSamples = 8 + /// Max records dumped per offload session, across all layout versions. A handful is enough for an + /// offline correlation pass and keeps the strap log bounded; the Backfiller counter spans chunks and + /// resets per session. + public static let maxSamples = 12 + + /// Max records dumped per DISTINCT layout version, so the budget cannot be spent entirely on the + /// layout we already understand. + /// + /// The dump used to take the first `maxSamples` decodable records in arrival order. On a strap that + /// emits a dominant layout plus a rarer one, that spends the whole budget inside the first chunk on + /// the dominant layout — and the rare one is precisely the one still unmapped. The log would then + /// announce "historical records use layout vN" while carrying no bytes of vN at all: proof a thing + /// exists, with no material to work on it. Stratifying guarantees every layout the strap emits gets + /// samples, including one at 1% of traffic, and stops re-dumping a layout already at parity. + public static let maxPerVersion = 3 + + /// Max records this dump may EXAMINE per session, separate from how many it may dump. + /// + /// The dump budget alone stopped bounding the work the moment `maxPerVersion` arrived: the loop's + /// outer guard counts DUMPS, so on a strap emitting one layout the per-version cap is reached at 3, + /// the dump count sticks below `maxSamples` forever, and every later chunk keeps re-examining every + /// frame to rediscover a version already at cap. + /// + /// Costs Apple far less than Android, which re-decodes each frame here while this side reads the + /// already-parsed record. It is applied on BOTH so the two platforms examine the same frames and so + /// dump the same records: a budget on one side only would silently diverge the two logs on a long + /// offload, which is the one thing this line's byte-identical promise cannot survive. + public static let maxExamined = 512 /// One record's RE line: the mapped SpO2 channels + timestamp + layout version, then the FULL frame /// hex (no prefix cap - a v24 record is ~84 B and the unmapped tail is exactly where a banked SpO2 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/StepsDetailDensity.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/StepsDetailDensity.swift new file mode 100644 index 0000000000..a7c5c2c253 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/StepsDetailDensity.swift @@ -0,0 +1,185 @@ +import Foundation + +/// The eight step-detail windows. Kotlin twin: `StepsDetailRange`. +public enum StepsDetailRange: String, CaseIterable, Sendable { + case week = "W" + case twoWeeks = "2W" + case threeWeeks = "3W" + case month = "M" + case threeMonths = "3M" + case sixMonths = "6M" + case year = "1Y" + case all = "ALL" + + /// Inclusive calendar-day count, or nil for all history. + /// Kotlin twin: `StepsDetailRange.dayCount`. + public func dayCount() -> Int? { + switch self { + case .week: return 7 + case .twoWeeks: return 14 + case .threeWeeks: return 21 + case .month: return 30 + case .threeMonths: return 90 + case .sixMonths: return 180 + case .year: return 365 + case .all: return nil + } + } + + /// Kotlin twin: `StepsDetailRange.granularity`. + fileprivate func granularity() -> StepsDetailGranularity { + switch self { + case .week, .twoWeeks, .threeWeeks, .month: return .daily + case .threeMonths: return .weekly + case .sixMonths, .year, .all: return .monthly + } + } +} + +/// One already-resolved daily step observation; Kotlin uses the same fields and validation boundary. +public struct StepsDetailReading: Equatable, Sendable { + public let day: String + public let value: Double + + public init(day: String, value: Double) { + self.day = day + self.value = value + } +} + +/// One chart bucket, retaining the raw sum and observed-day denominator. +/// Kotlin uses the same key, display anchor, sum, observed-day count and rounded mean. +public struct StepsDetailBucket: Equatable, Sendable { + public let key: String + public let displayDay: String + public let sum: Double + public let observedDayCount: Int + public let mean: Int + + public init(key: String, displayDay: String, sum: Double, + observedDayCount: Int, mean: Int) { + self.key = key + self.displayDay = displayDay + self.sum = sum + self.observedDayCount = observedDayCount + self.mean = mean + } +} + +private enum StepsDetailGranularity { + case daily + case weekly + case monthly +} + +/// Pure calendar projection for step-detail charts. Swift and Kotlin deliberately expose the same +/// inputs and outputs, and both are pinned by the one Android-hosted JSON oracle. +/// Kotlin twin: `StepsDetailDensity`. +public enum StepsDetailDensity { + /// Window, deduplicate and aggregate readings. The explicit anchor is optional so callers that + /// already know the latest valid measurement can reuse it without changing the contract. + /// Kotlin twin: `StepsDetailDensity.project`. + public static func project(readings: [StepsDetailReading], range: StepsDetailRange, + anchorDay: String? = nil) -> [StepsDetailBucket] { + var byDay: [LocalCalendarDate: Double] = [:] + for reading in readings { + guard let day = strictDay(reading.day), reading.value.isFinite, reading.value >= 0 else { + continue + } + byDay[day] = reading.value + } + + let anchor: LocalCalendarDate + if let anchorDay { + guard let parsed = strictDay(anchorDay) else { return [] } + anchor = parsed + } else { + guard let newest = byDay.keys.max() else { return [] } + anchor = newest + } + let lower = range.dayCount().map { anchor.adding(days: -($0 - 1)) } + + struct Accumulator { + let key: String + let displayDay: String + var sum: Double + var observedDayCount: Int + } + var buckets: [String: Accumulator] = [:] + for day in byDay.keys.sorted() { + guard day <= anchor, lower.map({ day >= $0 }) ?? true, let value = byDay[day] else { continue } + let identity = bucketIdentity(for: day, granularity: range.granularity()) + if var existing = buckets[identity.key] { + existing.sum += value + existing.observedDayCount += 1 + buckets[identity.key] = existing + } else { + buckets[identity.key] = Accumulator( + key: identity.key, displayDay: identity.displayDay, + sum: value, observedDayCount: 1) + } + } + + return buckets.values.sorted { $0.displayDay < $1.displayDay }.map { + StepsDetailBucket( + key: $0.key, + displayDay: $0.displayDay, + sum: $0.sum, + observedDayCount: $0.observedDayCount, + mean: positiveHalfUp($0.sum / Double($0.observedDayCount))) + } + } + + /// Parse exactly yyyy-MM-dd and reject impossible proleptic-Gregorian dates. + /// Kotlin twin: `StepsDetailDensity.strictDay`. + private static func strictDay(_ text: String) -> LocalCalendarDate? { + let bytes = Array(text.utf8) + guard bytes.count == 10, bytes[4] == 45, bytes[7] == 45 else { return nil } + for index in [0, 1, 2, 3, 5, 6, 8, 9] where !(48...57).contains(bytes[index]) { + return nil + } + guard let year = Int(text.prefix(4)), + let month = Int(text.dropFirst(5).prefix(2)), + let day = Int(text.suffix(2)), + (1...12).contains(month), + (1...daysInMonth(year: year, month: month)).contains(day) else { return nil } + return LocalCalendarDate(year: year, month: month, day: day) + } + + /// Proleptic-Gregorian month length. Kotlin twin: `StepsDetailDensity.daysInMonth`. + private static func daysInMonth(year: Int, month: Int) -> Int { + switch month { + case 2: return isLeapYear(year) ? 29 : 28 + case 4, 6, 9, 11: return 30 + default: return 31 + } + } + + /// Gregorian leap-year rule. Kotlin twin: `StepsDetailDensity.isLeapYear`. + private static func isLeapYear(_ year: Int) -> Bool { + year.isMultiple(of: 4) && (!year.isMultiple(of: 100) || year.isMultiple(of: 400)) + } + + /// Canonical bucket key and period-start display anchor. + /// Kotlin twin: `StepsDetailDensity.bucketIdentity`. + private static func bucketIdentity(for day: LocalCalendarDate, + granularity: StepsDetailGranularity) -> (key: String, displayDay: String) { + switch granularity { + case .daily: + return (day.key, day.key) + case .weekly: + let weekdayFromMonday = ((day.daysSinceEpoch + 3) % 7 + 7) % 7 + let monday = day.adding(days: -weekdayFromMonday).key + return (monday, monday) + case .monthly: + let first = LocalCalendarDate(year: day.year, month: day.month, day: 1).key + return (String(first.prefix(7)), first) + } + } + + /// Positive half-up rounding, independent of platform banker-rounding defaults. + /// Kotlin twin: `StepsDetailDensity.positiveHalfUp`. + private static func positiveHalfUp(_ value: Double) -> Int { + Int(floor(value + 0.5)) + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/StepsMotionCache.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/StepsMotionCache.swift new file mode 100644 index 0000000000..36809e2811 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/StepsMotionCache.swift @@ -0,0 +1,105 @@ +import Foundation + +/// Per-day reuse identity for the steps-calibration motion fold in `IntelligenceEngine.analyzeRecent`. +/// +/// The drain this closes: the calibration fits one coefficient from sixty days of strap motion, and it +/// re-folded all sixty on EVERY pass. Each day meant a `gravitySamples` read capped at 200,000 rows, so a +/// worn library paid millions of materialised rows per pass to re-derive numbers that had not moved. The +/// phase does not scale with the days being re-scored — it always reads the same sixty — so a two-day pass +/// could cost more than a twenty-one-day one, which is what made it hard to see in a cost line keyed on the +/// day loop. +/// +/// `StepsEstimateEngine.dayMotionIntensity` is a pure fold over one day's gravity stream. Nothing else +/// reaches it: no profile field, no baseline, no toggle, no other stream. So unlike `AnalyzeRecentDayCache` +/// there is no pass-config signature to invalidate against — the value changes exactly when that one day's +/// gravity changes, and the key below is the whole story. +/// +/// Unlike the day-scan cache this one PERSISTS across launches, and the paragraph above is why it can. A +/// persisted day scan would have to carry `dayCacheConfigSig`, which folds in baselines1 and the habitual +/// sleep terms — and `sleepConsistency` (1-CV over 28 nights) and `habitualMidsleepSec` (a circular mean) +/// shift with ANY night moving, so it would invalidate wholesale on exactly the passes it would need to +/// survive. Nothing pass-global reaches this fold, so the payload stays valid while gravity stands still and +/// the sixty-day fold is paid once per install instead of once per launch: measured 32.9 s -> 3.3 s on a +/// worn 60-day library, which the cold pass otherwise repaid after every relaunch. +/// +/// It stays a DERIVED cache and nothing else reads it, so the whole failure surface is one re-fold: a +/// payload that is missing, unreadable, or written by an older fold is discarded rather than repaired. +/// +/// It still does NOT cross the `.noopbak` boundary, and must not start: the key is deliberately absent from +/// both `BackupSettings` whitelists. A restore carries the settings and the record store, and this describes +/// neither — it describes gravity rows AS THEY WERE ON ONE DEVICE. Shipping it to another device would be +/// the one way to serve a fold whose key no longer witnesses anything, which is the failure every other +/// guard here exists to prevent. A restored device simply re-folds once. +public enum StepsMotionCache { + /// The per-day reuse key. Reuse a cached motion volume iff this string is unchanged. + /// + /// - `owner`: the resolved owning device the fold was measured against. A day whose owner flips between + /// straps must re-fold, and the fingerprint below is device-scoped, so this makes that explicit rather + /// than relying on two devices never producing an identical count and newest timestamp for one window. + /// - `gravityCount` / `gravityMaxTs`: the day window's gravity witness. Any gravity row added or removed + /// moves one of the two. Deliberately NOT the wider `dayStreamFingerprint`: that also counts HR, R-R, + /// respiration, SpO2, steps, skin temp and sleep state, so an ordinary HR offload would invalidate a + /// motion volume that cannot have changed by it. + public static func cacheKey(owner: String, gravityCount: Int, gravityMaxTs: Int) -> String { + "\(owner)|\(gravityCount)|\(gravityMaxTs)" + } + + /// The pass's one-line reuse readout, beside the phase cost line. + /// + /// `reused` and `folded` sum to the days scanned, so the ratio is readable without a second line. A pass + /// reporting `folded=60` every time means the key is moving when it should not, which is the failure + /// this cache can have and the reason the number is reported at all rather than assumed. + public static func logLine(reused: Int, folded: Int, size: Int) -> String { + "analyzeRecent stepsMotion reused=\(reused)/\(reused + folded) size=\(size)" + } + + /// The version of the FOLD the persisted values were produced by. Bump on any change to + /// `StepsEstimateEngine.dayMotionIntensity` that moves what it returns for the same samples. + /// + /// In memory this could not exist: a process cannot outlive the binary that filled it, so the fold that + /// produced a cached value is always the fold that would reproduce it. A persisted entry outlives its + /// build, and `cacheKey` witnesses the INPUTS only — a day whose gravity has not moved keys identically + /// across an app update, so without this the old volume would be served until that day's stream happened + /// to change. A bump discards every entry and costs one full re-fold, once, which is the cheap side. + public static let foldVersion = 1 + + /// Render the cache for storage. Days are emitted in sorted order so an unchanged cache renders to an + /// identical payload and the write is a no-op rather than churn. + /// + /// One line per day, `day\tkey\tmotion`, under a header naming `foldVersion`. Tab-separated because the + /// key itself contains `|`; the motion is written by raw bit pattern so it round-trips exactly and + /// locale-free, the same reason `cacheKey` encodes the skin anchor that way. + public static func serialize(_ entries: [String: (key: String, motion: Double)]) -> String { + var out = header + for day in entries.keys.sorted() { + guard let e = entries[day] else { continue } + out += "\n\(day)\t\(e.key)\t\(e.motion.bitPattern)" + } + return out + } + + /// Parse a stored payload. Returns empty on anything it cannot vouch for — a wrong/absent header (an + /// older `foldVersion` included), a malformed line, or an implausible entry count. Every rejection costs + /// one re-fold, so this discards rather than salvages: a half-trusted cache is the one failure mode that + /// could feed a stale volume into the calibration fit. + public static func deserialize(_ raw: String) -> [String: (key: String, motion: Double)] { + var lines = raw.split(separator: "\n", omittingEmptySubsequences: false) + guard lines.first == Substring(header) else { return [:] } + lines.removeFirst() + guard lines.count <= maxEntries else { return [:] } + var out: [String: (key: String, motion: Double)] = [:] + for line in lines { + let f = line.split(separator: "\t", omittingEmptySubsequences: false) + guard f.count == 3, !f[0].isEmpty, !f[1].isEmpty, let bits = UInt64(f[2]) else { continue } + out[String(f[0])] = (key: String(f[1]), motion: Double(bitPattern: bits)) + } + return out + } + + /// Payload header. Carries `foldVersion` so a fold change invalidates by failing the equality check. + private static var header: String { "stepsMotion v\(foldVersion)" } + + /// Upper bound on entries a payload may declare. The writer prunes to the calibration window every pass, + /// so a payload far above it did not come from this cache and is not worth parsing. + private static let maxEntries = 512 +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/StoreProbeTally.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/StoreProbeTally.swift new file mode 100644 index 0000000000..b14482bf0c --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/StoreProbeTally.swift @@ -0,0 +1,54 @@ +import Foundation + +/// How much of an `analyzeRecent` pass goes into its per-day PROBE queries, as calls and milliseconds. +/// +/// The blind spot this closes: with the steps-calibration fold now reused rather than recomputed, a warm +/// pass still reported ~3.3 s in the steps phase with `stepsMotion reused=60/60` beside it, so the fold was +/// provably not the cost and nothing said what was. The phase is bracketed as one number and sits outside +/// the `#1538` day-loop tally, so the remaining candidates — the sixty per-day probe queries, the one +/// `appleDaily` read, and the in-memory calibration fit — were indistinguishable from each other. +/// +/// Three lookups are worth counting, each measured one level down so the counts attribute themselves with +/// no bookkeeping at the call site. `gravityFp` has exactly one caller; the other two are one-sidedly +/// looser on Swift, where `resolveDayOwner` is also reached by a manual Test Centre skin-temp backfill and +/// can land in a concurrent pass's line. The call count is what reveals that, which is one reason it +/// prints. See `StoreProbeCounts`. +/// - `dayOwner`: `DeviceRegistryStore.dayOwner`, the resolver's LOCKED-override lookup, which runs on every +/// call ahead of any presence probe. +/// - `ownerHr`: `hasHrInWindow`, the day-owner resolver's per-candidate presence probe. Runs in BOTH the +/// scoring loop and the sixty-day steps loop, and is skipped entirely on a default single-strap install +/// (#970), so a two-strap library is the only one that pays it. +/// - `gravityFp`: `gravityFingerprint`, the steps loop's per-day motion witness. Steps-only. +/// +/// Read with the `stepsMotion reused=N/M` line, this is decisive rather than suggestive: a warm pass that +/// folded nothing and still spent its time here means the round trips ARE the cost and batching them into a +/// grouped query is worth building; one that does not means the cost is the `appleDaily` read or the fit, +/// and batching would have bought nothing. Measured, not guessed — the same reason the day-loop split and +/// the day-cache duration are measured rather than reasoned about. +/// +/// Instrumentation only: nothing reads these counts but the log line. +public enum StoreProbeTally { + /// Render ordered `(name, calls, seconds)` probes as one line. + /// + /// `calls` is printed beside the time because the two answer different questions: the time says whether + /// this is where the pass went, and the count says whether the loop issued the number of round trips it + /// was believed to (sixty per steps pass, one per day). A count that is right with a time that is large + /// is a slow query; a count that is wrong is a different bug entirely, and one line should not be able + /// to hide either. Byte-identical to the Kotlin `StoreProbeTally.logLine`. + public static func logLine(_ probes: [(name: String, calls: Int, seconds: Double)]) -> String { + let parts = probes.map { "\($0.name)=\($0.calls)/\(millis($0.seconds))ms" } + let total = probes.reduce(0.0) { $0 + $1.seconds } + return "analyzeRecent storeProbes total=\(millis(total))ms " + + (parts.isEmpty ? "(no probes)" : parts.joined(separator: " ")) + } + + /// Seconds to whole milliseconds, floored at zero. The clamp is the parity contract, not defensive + /// habit: a negative or non-finite input is where Swift's round-half-away-from-zero and Kotlin's + /// round-half-up disagree, at exactly -0.5 ms, and both sides render into one shared strap log. The + /// callers here cannot produce one (they count monotonic nanoseconds), which makes this a guarantee + /// about the renderer rather than a repair of its inputs. + private static func millis(_ seconds: Double) -> Int { + guard seconds.isFinite, seconds > 0 else { return 0 } + return Int((seconds * 1000).rounded()) + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/StrainScorer.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/StrainScorer.swift index fcbe83b842..aff399c2af 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/StrainScorer.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/StrainScorer.swift @@ -65,6 +65,35 @@ public enum StrainScorer { 24.0 * 60.0 * 1.0 * banisterScale * exp(b) } + /// The %HRR a waking, sedentary body sits at — the "cost of being alive", not training load. + /// + /// Banister pays at EVERY intensity by design, which is the point: it catches the intermittent work + /// Edwards zeroes. The side effect is that sixteen waking hours of doing nothing accumulate real + /// TRIMP, so a desk day cannot score zero however still you are — while the same day under Edwards, + /// whose first zone starts at 50% HRR, scores exactly zero. A 24 h day held at 5% HRR scores 0 under + /// Edwards and 45 under Banister on the shipped constants. That is not two recipes on one axis; that + /// is two axes (#1624). + /// + /// THIS IS THE ONE TUNED CONSTANT here, and it is a judgement rather than a measurement: low enough + /// not to erase genuine light activity, high enough that ordinary sitting nets to nothing. Resting HR + /// is measured asleep, so a waking body sits above it even at complete rest — precisely the gap this + /// closes. Treat it as calibratable, not as physiology. + public static let banisterSedentaryHRR: Double = 0.10 + + /// TRIMP per minute at `banisterSedentaryHRR` — the rate subtracted from every day. + static func banisterBaselineRatePerMinute(b: Double) -> Double { + banisterScale * banisterSedentaryHRR * exp(b * banisterSedentaryHRR) + } + + /// The sedentary TRIMP accrued over `minutes`, subtracted from a day's Banister TRIMP so the axis + /// starts where Edwards' does. Subtracted from the DENOMINATOR too, so the top is unmoved and a + /// theoretical maximum day still maps to exactly `maxStrain` — anchoring only the bottom would trade + /// one mismatched end for the other. + static func banisterBaseline(minutes: Double, b: Double) -> Double { + banisterBaselineRatePerMinute(b: b) * minutes + } + + /// The log-map denominator for a method, so a caller never has to know which constant belongs to /// which recipe. Ceiling + 1 in both cases, mirroring how `strainDenominator` was derived, so a /// theoretical maximum day maps to exactly `maxStrain` under either method. @@ -73,7 +102,10 @@ public enum StrainScorer { case .edwards: return strainDenominator case .banister: - return banisterDailyCeiling(b: sex.lowercased().hasPrefix("f") ? banisterBWomen : banisterBMen) + 1.0 + let b = sex.lowercased().hasPrefix("f") ? banisterBWomen : banisterBMen + // Ceiling MINUS a full day of sedentary baseline, matching what is subtracted from the day + // itself, so both ends of the axis line up with Edwards (#1624). + return banisterDailyCeiling(b: b) - banisterBaseline(minutes: 24.0 * 60.0, b: b) + 1.0 } } @@ -180,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) } @@ -232,12 +265,25 @@ public enum StrainScorer { return acc } + /// - Parameter floorRatePerMinute: per-minute rate treated as "no effort" and subtracted from EVERY + /// sample, floored at zero (#1624). Zero — the default — is the original, unfloored Banister recipe, + /// so every existing caller and test is byte-identical. Pass `banisterBaselineRatePerMinute` to + /// score the excess over a sedentary day, which is what the daily scorer does. + /// + /// Per SAMPLE, never as one lump off the total: a day quieter than the floor would otherwise run a + /// deficit that eats into real work done on top, and 90 minutes at 35% HRR inside an otherwise still + /// day would net negative and clamp to zero — erasing exactly the intermittent effort this recipe + /// exists to capture. static func banisterTRIMP(_ hr: [HRSample], restingHR: Double, hrReserve: Double, - durations: [Double], b: Double) -> Double { + durations: [Double], b: Double, + floorRatePerMinute: Double = 0.0) -> Double { var acc = 0.0 for i in hr.indices { let x = pctHRR(Double(hr[i].bpm), restingHR: restingHR, hrReserve: hrReserve) / 100.0 - if x > 0 { acc += durations[i] * x * banisterScale * exp(b * x) } + if x > 0 { + let rate = x * banisterScale * exp(b * x) + acc += durations[i] * max(rate - floorRatePerMinute, 0.0) + } } return acc } @@ -245,13 +291,19 @@ public enum StrainScorer { // MARK: - Logarithmic map /// Map accumulated TRIMP onto [0, 100] via 100 × ln(TRIMP+1) / ln(D), 2 dp. - /// TRIMP ≤ 0 → 0. + /// TRIMP ≤ 0 → 0, and D ≤ 1 (or NaN) → 0, being outside the map's domain. The output is + /// unbounded as D → 1⁺ — an upper clamp is tracked separately. /// /// The default D is **Edwards'**. A Banister TRIMP passed here without an explicit denominator is /// scored against the wrong ceiling and reads low — prefer `strain(…)`, which resolves the /// method's own denominator, or pass `logMapDenominator(method:sex:)` yourself. (#1545) public static func trimpToStrain(_ trimp: Double, denominator: Double = strainDenominator) -> Double { if trimp <= 0 { return 0 } + // D ≤ 1 (and NaN) is outside the map's domain: ln(1) = 0 divides to ±∞, ln(D) < 0 below 1 + // flips the sign, and ln(D) is NaN at or below 0. Out-of-domain D is no score, like + // TRIMP ≤ 0 — before this guard D = 1 returned +Inf here and a saturated 9.2e16 on Android + // for the same input (the denominator-domain fix). The default 7201 is unaffected. + guard denominator > 1 else { return 0 } let value = maxStrain * log(trimp + 1.0) / log(denominator) return (value * 100).rounded() / 100 } @@ -301,7 +353,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) @@ -313,12 +373,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 @@ -328,7 +429,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). @@ -341,7 +443,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 @@ -350,12 +459,19 @@ public enum StrainScorer { switch method { case .banister: let b = sex.lowercased().hasPrefix("f") ? banisterBWomen : banisterBMen + // Excess over the sedentary baseline for the SAME span, floored at zero. Without this a desk + // day scores ~45 on a 0-100 axis whose bottom is supposed to be no exertion (#1624). trimp = banisterTRIMP(hr, restingHR: restingHR, hrReserve: hrReserve, - durations: durations, b: b) + durations: durations, b: b, + floorRatePerMinute: banisterBaselineRatePerMinute(b: b)) case .edwards: 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/TestModeRegistry.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/TestModeRegistry.swift index 9ad1c22a55..69d0865ff1 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/TestModeRegistry.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/TestModeRegistry.swift @@ -72,7 +72,7 @@ public enum TestModeRegistry { // (the offWristFrac field of the offWrist gate) and restSubScores. Dropped, all unemitted, same // rationale Import used for firstFailingRow/failingFileSample/dedupMerge: hrDensity, perEpochFeatures, // hypnogramV1V2 (flipLine has no call site), ppgOnlyNight, gravityCoverage (only the sparseBridge - // gate's sparse= flag, which already rides gateTrace) and skinTempDsp (skin-temp is a Recovery input, + // gate's sparse=/fragmentedToNothing= flags, which already ride gateTrace) and skinTempDsp (skin-temp is a Recovery input, // never written to the sleep sink). The live-readout keys (hrDensityNow/gravityCoverageNow) are separate. captures: ["gateTrace", "wristOff", "restSubScores"], questionnaire: [ diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/TrainingLoadEngine.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/TrainingLoadEngine.swift index f539f79ce6..09c7f7bf6d 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/TrainingLoadEngine.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/TrainingLoadEngine.swift @@ -218,7 +218,7 @@ public enum TrainingLoadEngine { configuration: Configuration = .standard) -> Result { let baseOrdinal = dayOrdinal("2000-01-01")! let days = loads.enumerated().map { offset, load in - DailyLoad(day: dayString(ordinal: baseOrdinal + offset), load: load) + DailyLoad(day: LocalCalendarDate(daysSinceEpoch: baseOrdinal + offset).key, load: load) } return evaluate(days: days, configuration: configuration) } @@ -253,20 +253,6 @@ public enum TrainingLoadEngine { return era * 146_097 + dayOfEra - 719_468 } - private static func dayString(ordinal: Int) -> String { - let z = ordinal + 719_468 - let era = floorDiv(z, 146_097) - let dayOfEra = z - era * 146_097 - let yearOfEra = (dayOfEra - dayOfEra / 1_460 + dayOfEra / 36_524 - dayOfEra / 146_096) / 365 - var year = yearOfEra + era * 400 - let dayOfYear = dayOfEra - (365 * yearOfEra + yearOfEra / 4 - yearOfEra / 100) - let monthPrime = (5 * dayOfYear + 2) / 153 - let day = dayOfYear - (153 * monthPrime + 2) / 5 + 1 - let month = monthPrime + (monthPrime < 10 ? 3 : -9) - year += month <= 2 ? 1 : 0 - return String(format: "%04d-%02d-%02d", year, month, day) - } - private static func isLeapYear(_ year: Int) -> Bool { year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) } diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/UniversalTrace.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/UniversalTrace.swift index d03b531d25..5d585d3f67 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/UniversalTrace.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/UniversalTrace.swift @@ -18,6 +18,37 @@ import Foundation public enum UniversalTrace { + /// The R-R transport line: what a device has banked versus what its unit policy can actually score. + /// + /// #2117: a WHOOP 5 window is pinned to one transport, and a window holding no beat on a scorable + /// channel reads back EMPTY rather than falling back. Everything derived from beats then goes blank + /// (HRV, respiratory rate) while heart-rate-derived values carry on, which is what a wearer reports + /// as "HRV stopped working". The analyzer cannot explain it: handed nothing, it honestly says + /// nInput=0 and has no way to know whether the strap banked nothing or banked beats the policy + /// refused. + /// + /// These two facts separate those cases, and the store already computes both. Rides every export for + /// the same reason the clock-drift line does: the wearer who needs it is the one who did not know to + /// turn a mode on. Returns nil for a device the policy does not apply to, so a WHOOP 4 export is + /// unchanged. + /// + /// Twin of Kotlin `ConnectionTrace.rrTransportLine`. + public static func rrTransportLine(strictWhoop5: Bool, + firstRecordedUnix: Int?, + firstScorableUnix: Int?) -> String? { + guard strictWhoop5 else { return nil } + guard let firstRecordedUnix else { return "rrTransport recorded=none scorable=none" } + let recorded = "recorded=\(ConnectionTrace.isoDate(firstRecordedUnix))" + guard let firstScorableUnix else { + // Beats on disk, none the policy will score: the whole history predates transport labelling + // or sits on an excluded channel. This is the state that blanks every night at once. + return "rrTransport \(recorded) scorable=none unscorableHistory=yes" + } + let gapDays = max(0, Int((Double(firstScorableUnix - firstRecordedUnix) / 86_400).rounded())) + return "rrTransport \(recorded) scorable=\(ConnectionTrace.isoDate(firstScorableUnix)) " + + "unscorableHistory=\(gapDays > 0 ? "yes" : "no") gapDays=\(gapDays)" + } + /// The universal strap-clock line: the strap's newest banked-record timestamp vs wall clock, with a /// FUTURE-DATE flag (the tell of a wandering / un-clocked RTC), the optional banked span in days, and the /// firmware record-layout version the strap hands over. One line, tagged `.universal` by the caller, so 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/WatchRecovery.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/WatchRecovery.swift index 2bd8f85b13..695424473a 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/WatchRecovery.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/WatchRecovery.swift @@ -21,8 +21,8 @@ import Foundation // dominant driver either way. // // The hard honesty rule: we return nil recovery + `.calibrating` when today's SDNN is -// missing, OR the SDNN baseline isn't usable yet, OR we have fewer than `minBaselineNights` -// nights of history. We NEVER fabricate a number to fill a sparse week. Confidence comes +// missing, OR the SDNN baseline isn't usable yet, OR the baseline has accepted fewer than +// `minBaselineNights` nights. We NEVER fabricate a number to fill a sparse week. Confidence comes // straight from the existing `ScoreConfidence.charge(recovery:hrvBaseline:)`, so the watch // "calibrating → building → solid" arc is the same one the strap uses. public enum WatchRecovery { @@ -41,7 +41,8 @@ public enum WatchRecovery { } } - /// Minimum nights of SDNN history before we'll score recovery from the watch. The spec's + /// Minimum VALID nights (nights the baseline accepted, `BaselineState.nValid`) before we'll score + /// recovery from the watch — not raw history entries. The spec's /// honesty stance is to keep recovery "calibrating" for about a week of nights rather than /// ship a misleading number off a thin baseline. This sits ABOVE the baseline's own seed /// gate (`Baselines.minNightsSeed` = 4) deliberately: a strap user crosses the seed faster @@ -52,7 +53,8 @@ public enum WatchRecovery { /// /// - Parameters: /// - todaySDNN: today's HRV SDNN reading (ms), or nil if the watch logged none. - /// - todayRHR: today's resting HR (bpm), or nil to drop the RHR term. + /// - todayRHR: today's resting HR (bpm), or nil to drop the RHR term. The term is also + /// dropped when `rhrHistory` has not yet produced a usable RHR baseline. /// - sdnnHistory: ordered nightly SDNN values (oldest → newest), the baseline input. /// - rhrHistory: ordered nightly resting-HR values (oldest → newest). /// - Returns: a `Result` with recovery in [0,100] and a confidence tier, or nil recovery + @@ -71,21 +73,29 @@ public enum WatchRecovery { // Honesty gate: no number unless we have today's SDNN, a usable baseline, AND at least a // week of nights. Any miss → nil recovery + calibrating, never a fabricated value. + // The week is counted in nights the baseline ACCEPTED (`nValid`), not in raw history entries: + // a physiologically implausible reading is skip-and-held by `Baselines.update` and contributes + // nothing to the baseline, so counting it would let rejected values buy a score a week early. guard let sdnn = todaySDNN, hrvBase.usable, - sdnnHistory.count >= minBaselineNights else { + hrvBase.nValid >= minBaselineNights else { return Result(recovery: nil, confidence: .calibrating) } // Reuse the canonical Charge engine. Drop the resp / sleep / skin-temp terms (the watch // daily aggregate doesn't carry them here) — RecoveryScorer renormalises to HRV + RHR. - // RHR is optional: when the watch logged no resting HR today we pass the HRV-only path. + // RHR is optional: the term needs BOTH today's reading AND a usable personal RHR baseline. + // An empty or all-implausible RHR history folds to `foldHistory`'s synthetic midpoint (the + // config's min/max mean, e.g. 75 bpm), which is nobody's resting HR — scoring against it would + // move Charge on a fabricated baseline. Same `usable ? state : nil` gate the strap Charge + // surfaces already apply (TodayView / CoupledView); without a usable RHR baseline we fall back + // to the documented HRV-only path, exactly as when today's reading is missing. let recovery = RecoveryScorer.recovery( hrv: sdnn, rhr: todayRHR.map(Double.init) ?? rhrBase.baseline, // missing RHR → at-baseline (z≈0, neutral term) resp: nil, hrvBaseline: hrvBase, - rhrBaseline: todayRHR != nil ? rhrBase : nil, // drop the RHR term entirely if no reading + rhrBaseline: (todayRHR != nil && rhrBase.usable) ? rhrBase : nil, respBaseline: nil, sleepPerf: nil ) diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/WindowedStreamPlan.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/WindowedStreamPlan.swift new file mode 100644 index 0000000000..08966b6839 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/WindowedStreamPlan.swift @@ -0,0 +1,104 @@ +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, + hrOwnerFlips: Int = 0, + rrOwnerFlips: Int = 0, + hrReuseOff: Int = 0, + rrReuseOff: Int = 0 + ) -> String { + // #2073: `served` near zero says the windows are declining, and the doc above names three causes. + // `truncated` covered one. `ownerFlips` separates the other two, and `reuseOff` covers the case + // none of them describe: a stream the caller told not to reuse at all, where served=0 is by design + // and reading it as a decline is simply wrong. + "analyzeRecent windows hr[read=\(hrRead) served=\(hrServed) truncated=\(hrTruncated) " + + "ownerFlips=\(hrOwnerFlips) reuseOff=\(hrReuseOff)] " + + "rr[read=\(rrRead) served=\(rrServed) truncated=\(rrTruncated) " + + "ownerFlips=\(rrOwnerFlips) reuseOff=\(rrReuseOff)]" + } + + /// 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..97aee8e826 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/WorkoutDetector.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/WorkoutDetector.swift @@ -35,6 +35,14 @@ public struct UserProfile: Equatable, Sendable { self.age = age; self.sex = sex self.stepTicksPerStep = stepTicksPerStep } + + /// Every stored field, for a cache key that must change when the profile does (the per-cycle load + /// cache, `IntelligenceEngine`). Named explicitly rather than read from `String(describing:)`, whose + /// format is not a contract and costs reflection per call. A new field belongs here too. Doubles by + /// bit pattern, so the key is exact and locale-free. Twin of Kotlin `UserProfile.cacheKey`. + public var cacheKey: String { + "w=\(weightKg.bitPattern),h=\(heightCm.bitPattern),a=\(age.bitPattern),s=\(sex),t=\(stepTicksPerStep.bitPattern)" + } } /// A detected workout window. All intensity fields are APPROXIMATE. @@ -203,7 +211,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 +586,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 +644,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 +752,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 +764,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 +796,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..db31ca01da --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AlarmReadbackTests.swift @@ -0,0 +1,114 @@ +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)") + } + + // MARK: - #2322: same strap, but was it the same ARM? + + private let armedAt: Double = 1_789_728_000 // wall clock when the arm went out, seconds + + func testAReadbackOlderThanTheArmIsNotComparable() { + // The shape from #2322: an arm goes out, its readback frame fails to decode, so the PREVIOUS + // readback is still what is stored. Judging it blames the strap for answering a question it was + // never asked on this arm. + let v = AlarmReadback.verdict(sentEpoch: sent, reportedEpoch: reported, + sentDeviceId: "whoop-a", reportedDeviceId: "whoop-a", + sentAt: armedAt, reportedAt: armedAt - 60) + XCTAssertEqual(v, .staleReadback) + XCTAssertFalse(AlarmReadback.countsAsRejection(v), + "a readback from an earlier arm must not climb the streak") + XCTAssertFalse(AlarmReadback.clearsRejectionStreak(v), "nor may it clear a real refusal") + } + + func testAReadbackThatAnsweredThisArmIsStillJudged() { + // The guard must not swallow the real signal it sits in front of. + XCTAssertEqual(AlarmReadback.verdict(sentEpoch: sent, reportedEpoch: reported, + sentDeviceId: "whoop-a", reportedDeviceId: "whoop-a", + sentAt: armedAt, reportedAt: armedAt + 1), .mismatch) + XCTAssertEqual(AlarmReadback.verdict(sentEpoch: sent, reportedEpoch: sent + 5, + sentDeviceId: "whoop-a", reportedDeviceId: "whoop-a", + sentAt: armedAt, reportedAt: armedAt + 1), .matches) + } + + func testAnInstallWithNoArrivalStampsJudgesAsBefore() { + // nil means the key was never written (an install predating them). Staleness is then not judged + // rather than guessed, so behaviour is byte-identical to the pre-#2322 verdict. + XCTAssertEqual(AlarmReadback.verdict(sentEpoch: sent, reportedEpoch: reported, + sentDeviceId: "whoop-a", reportedDeviceId: "whoop-a"), + .mismatch) + XCTAssertEqual(AlarmReadback.verdict(sentEpoch: sent, reportedEpoch: reported, + sentDeviceId: "whoop-a", reportedDeviceId: "whoop-a", + sentAt: armedAt, reportedAt: nil), .mismatch) + } + + func testACrossStrapPairIsNamedBeforeStaleness() { + // Both faults at once: the strap problem is the one that sends the reader to the right device. + XCTAssertEqual(AlarmReadback.verdict(sentEpoch: sent, reportedEpoch: reported, + sentDeviceId: "whoop-a", reportedDeviceId: "whoop-b", + sentAt: armedAt, reportedAt: armedAt - 60), .differentStrap) + } + + func testStaleHasItsOwnSuffix() { + XCTAssertEqual(AlarmReadback.suffix(.staleReadback), " (readback predates this arm — not comparable)") + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalysisPhaseTallyTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalysisPhaseTallyTests.swift new file mode 100644 index 0000000000..5f3ea99736 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalysisPhaseTallyTests.swift @@ -0,0 +1,60 @@ +import XCTest +@testable import StrandAnalytics + +/// The post-loop phase line. Mirrored by the Kotlin `AnalysisPhaseTallyTest` against the SAME literals: +/// the two platforms name their own phases (the passes genuinely differ after the day loop), but the +/// rendering — order, rounding, the total, the empty case — is one contract. +final class AnalysisPhaseTallyTests: XCTestCase { + + func testPhasesRenderInOrderWithATotal() { + let line = AnalysisPhaseTally.logLine(scope: "postLoop", [ + (name: "baselines", seconds: 0.012), + (name: "score2", seconds: 1.1), + (name: "steps", seconds: 28.93), + ]) + XCTAssertEqual(line, "analyzeRecent postLoop total=30042ms baselines=12ms score2=1100ms steps=28930ms") + } + + /// The phases are printed in the order GIVEN, never sorted by cost. Execution order is the point: the + /// line is read front to back as the pass runs, and a sort would hide where in the pass the time sits. + func testOrderIsExecutionOrderNotCost() { + let line = AnalysisPhaseTally.logLine(scope: "postLoop", [ + (name: "steps", seconds: 28.93), + (name: "baselines", seconds: 0.012), + ]) + XCTAssertEqual(line, "analyzeRecent postLoop total=28942ms steps=28930ms baselines=12ms") + } + + /// A backwards wall-clock step (an NTP correction mid-pass is enough) yields a negative interval. + /// It floors at zero rather than printing a negative duration — and, more to the point, that is the + /// one input where Swift's round-half-away-from-zero and Kotlin's round-half-up would disagree. + func testNegativeAndNonFinitePhasesFloorAtZero() { + XCTAssertEqual(AnalysisPhaseTally.logLine(scope: "postLoop", [(name: "skew", seconds: -0.0005)]), + "analyzeRecent postLoop total=0ms skew=0ms") + XCTAssertEqual(AnalysisPhaseTally.logLine(scope: "postLoop", [(name: "nan", seconds: Double.nan)]), + "analyzeRecent postLoop total=0ms nan=0ms") + } + + /// The total is the sum of what was MEASURED, not of the pass. A phase nobody bracketed shows up as + /// the gap between this number and `re-score: done`, which is how the next blind spot gets found. + func testTotalIsTheSumOfMeasuredPhasesOnly() { + XCTAssertEqual(AnalysisPhaseTally.logLine(scope: "postLoop", [(name: "a", seconds: 0.5), (name: "b", seconds: 0.25)]), + "analyzeRecent postLoop total=750ms a=500ms b=250ms") + } + + /// The scope is the caller's, not a constant. Android brackets only its persist helper (the JaCoCo + /// method-size ratchet leaves no room in `analyzeRecentOnCpu`), so its line must not claim a `postLoop` + /// total it never measured. Same phases, different scope, different line. + func testScopeNamesWhatWasActuallyBracketed() { + let phases: [(name: String, seconds: Double)] = [(name: "weekly", seconds: 0.07), + (name: "steps", seconds: 28.93)] + XCTAssertEqual(AnalysisPhaseTally.logLine(scope: "persistSteps", phases), + "analyzeRecent persistSteps total=29000ms weekly=70ms steps=28930ms") + XCTAssertEqual(AnalysisPhaseTally.logLine(scope: "postLoop", phases), + "analyzeRecent postLoop total=29000ms weekly=70ms steps=28930ms") + } + + func testNoPhasesSaysSoRatherThanPrintingNothing() { + XCTAssertEqual(AnalysisPhaseTally.logLine(scope: "postLoop", []), "analyzeRecent postLoop total=0ms (no phases)") + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalyticsEngineHrOnlyDayTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalyticsEngineHrOnlyDayTests.swift new file mode 100644 index 0000000000..7df8b49f40 --- /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(day: "2026-09-23", 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/AnalyticsEngineRefusedMainNightHrvTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalyticsEngineRefusedMainNightHrvTests.swift new file mode 100644 index 0000000000..cdc98e6845 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalyticsEngineRefusedMainNightHrvTests.swift @@ -0,0 +1,83 @@ +import XCTest +@testable import StrandAnalytics +import WhoopProtocol + +/// A main night whose HRV the #1118 over-count gate REFUSED must not be replaced by the day's naps. +/// +/// Field case: a ring night read 1.31 coverage, the gate made its HRV nil, and the day's HRV became the +/// mean of two 26-minute naps, which the baseline then folded as a full night. The three cases pin the +/// rule and its boundary: refused ⇒ naps excluded; merely unmeasured (no R-R at all) ⇒ #1884's fill-in +/// from the other sessions is unchanged; clean ⇒ pooled as before. +/// Byte-parity twin of Kotlin `AnalyticsEngineRefusedMainNightHrvTest`: same fixtures, same expected values. +final class AnalyticsEngineRefusedMainNightHrvTests: XCTestCase { + private let profile = UserProfile(weightKg: 75, heightCm: 178, age: 30, sex: "male") + private let day = "2026-07-27" + + private var dayStart: Int { AnalyticsEngine.dayStartUtcSeconds(day) } + private var nightStart: Int { dayStart - 4 * 3600 } // 20:00 the evening before + private var nightEnd: Int { nightStart + 600 * 60 } // 06:00 on `day` + private var napStart: Int { dayStart + 13 * 3600 } // 13:00 on `day` + private var napEnd: Int { napStart + 40 * 60 } // 13:40 + + private func hr(_ start: Int, _ end: Int) -> [HRSample] { + stride(from: start, to: end, by: 30).map { HRSample(ts: $0, bpm: 52 + ($0 / 300) % 4) } + } + + /// One beat per second, alternating around 1000 ms by `swing`: coverage ~1.0, RMSSD = 2 * swing. + /// The night uses 20 (RMSSD 40) and the nap 30 (RMSSD 60), so a pooled day and a nap-only day read + /// differently. + private func cleanRR(_ start: Int, _ end: Int, swing: Int) -> [RRInterval] { + (start.. [RRInterval] { + (start.. [SleepSession] { + [SleepSession(start: nightStart, end: nightEnd, efficiency: 0.9, + stages: [StageSegment(start: nightStart, end: nightEnd, stage: "light")], + restingHR: nil, avgHRV: nil), + SleepSession(start: napStart, end: napEnd, efficiency: 0.9, + stages: [StageSegment(start: napStart, end: napEnd, stage: "light")], + restingHR: nil, avgHRV: nil)] + } + + private func analyze(nightRR: [RRInterval]) -> AnalyticsEngine.DayResult { + AnalyticsEngine.analyzeDay(day: day, hr: hr(nightStart, nightEnd) + hr(napStart, napEnd), + rr: nightRR + cleanRR(napStart, napEnd, swing: 30), profile: profile, + providedSleep: sessions()) + } + + func testARefusedMainNightIsNotReplacedByTheNap() { + let night = overCountedRR(nightStart, nightEnd) + XCTAssertTrue(SleepStager.sessionHrvOverCounted(start: nightStart, end: nightEnd, rr: night), + "precondition: the gate refuses this night") + XCTAssertNotNil(SleepStager.sessionAvgHRV(start: napStart, end: napEnd, rr: cleanRR(napStart, napEnd, swing: 30)), + "precondition: the nap on its own measures an HRV") + XCTAssertNil(analyze(nightRR: night).daily.avgHrv, + "the day holds rather than reporting the nap as the night") + } + + func testAMainNightWithNoRrStillFallsBackToTheNap() { + let res = analyze(nightRR: []) + XCTAssertEqual(res.daily.avgHrv ?? -1, 60, accuracy: 1e-9, + "not refused, only unmeasured: #1884's fill-in from the nap is unchanged") + } + + func testACleanMainNightIsPooledWithTheNapAsBefore() { + let res = analyze(nightRR: cleanRR(nightStart, nightEnd, swing: 20)) + // In-bed-weighted: (40 * 600 min + 60 * 40 min) / 640 min. + XCTAssertEqual(res.daily.avgHrv ?? -1, 41.25, accuracy: 1e-9) + } + + /// The helper is the gate: whatever it says, `sessionAvgHRV` does, on the same beats. + func testTheHelperAgreesWithTheValueGate() { + for rr in [overCountedRR(nightStart, nightEnd), cleanRR(nightStart, nightEnd, swing: 20)] { + XCTAssertEqual(SleepStager.sessionHrvOverCounted(start: nightStart, end: nightEnd, rr: rr), + SleepStager.sessionAvgHRV(start: nightStart, end: nightEnd, rr: rr) == nil) + } + } +} 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..e06ce1c7fa --- /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(day: "2026-09-23", 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/AutoWorkoutDetectorTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AutoWorkoutDetectorTests.swift index 24b44b9ce6..703b76e32a 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AutoWorkoutDetectorTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AutoWorkoutDetectorTests.swift @@ -15,6 +15,11 @@ final class AutoWorkoutDetectorTests: XCTestCase { (0.. [(ts: Int, bpm: Int)] { + (0...elapsedS).map { (ts: start + $0, bpm: bpm) } + } + private func grav(_ ts: Int, _ x: Double) -> GravitySample { GravitySample(ts: ts, x: x, y: 0, z: 1) } @@ -122,4 +127,39 @@ final class AutoWorkoutDetectorTests: XCTestCase { let hr = block(start - 300, 300, 65) + block(start, 20 * 60, 120) + block(start + 1200, 300, 65) XCTAssertEqual(AutoWorkoutDetector.detect(hr: hr, restingBpm: nil).count, 1) } + + func testTenMinuteShadowPolicyBoundary() { + let start = 10_000_000 + let below = elapsedSpan(start, 10 * 60 - 1, 120) + let exact = elapsedSpan(start, 10 * 60, 120) + + XCTAssertTrue(AutoWorkoutDetector.detect( + hr: below, restingBpm: 60, minimumSustainedMinutes: 10.0).isEmpty) + XCTAssertEqual(AutoWorkoutDetector.detect( + hr: exact, restingBpm: 60, minimumSustainedMinutes: 10.0).count, 1) + // The same exact ten-minute span must not change the published 12-minute result. + XCTAssertTrue(AutoWorkoutDetector.detect(hr: exact, restingBpm: 60).isEmpty) + } + + func testPublishedDefaultRemainsTwelveMinutes() { + let start = 10_500_000 + let below = elapsedSpan(start, 12 * 60 - 1, 120) + let exact = elapsedSpan(start, 12 * 60, 120) + + XCTAssertEqual(AutoWorkoutDetector.minSustainedMin, 12.0) + XCTAssertEqual(AutoWorkoutDetector.shadowSustainedMinutes, [10.0, 15.0]) + XCTAssertTrue(AutoWorkoutDetector.detect(hr: below, restingBpm: 60).isEmpty) + XCTAssertEqual(AutoWorkoutDetector.detect(hr: exact, restingBpm: 60).count, 1) + } + + func testFifteenMinuteShadowPolicyBoundary() { + let start = 11_000_000 + let below = elapsedSpan(start, 15 * 60 - 1, 120) + let exact = elapsedSpan(start, 15 * 60, 120) + + XCTAssertTrue(AutoWorkoutDetector.detect( + hr: below, restingBpm: 60, minimumSustainedMinutes: 15.0).isEmpty) + XCTAssertEqual(AutoWorkoutDetector.detect( + hr: exact, restingBpm: 60, minimumSustainedMinutes: 15.0).count, 1) + } } diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AutoWorkoutDetectorTraceTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AutoWorkoutDetectorTraceTests.swift index b6ed03c90d..830ee330c5 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AutoWorkoutDetectorTraceTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AutoWorkoutDetectorTraceTests.swift @@ -13,6 +13,10 @@ final class AutoWorkoutDetectorTraceTests: XCTestCase { (0.. [(ts: Int, bpm: Int)] { + (0...elapsedS).map { (ts: start + $0, bpm: bpm) } + } + func testTraceResultsAreByteIdenticalToDetect() { let start = 1_000_000 let durS = 20 * 60 @@ -64,6 +68,194 @@ final class AutoWorkoutDetectorTraceTests: XCTestCase { XCTAssertTrue(lines.contains { $0.contains("verdict=dropped why=motionNotConfirmed") }) } + func testTraceUsesExplicitShadowPolicyWithoutChangingDefault() { + let start = 2_000_000 + let hr = elapsedSpan(start, 10 * 60, 120) + + XCTAssertTrue(AutoWorkoutDetector.detect(hr: hr, restingBpm: 60).isEmpty) + let (traced, lines) = AutoWorkoutDetector.detectTrace( + hr: hr, restingBpm: 60, minimumSustainedMinutes: 10.0, path: "shadow") + + XCTAssertEqual(traced.count, 1) + XCTAssertTrue(lines.contains { $0.contains("path=shadow") }) + XCTAssertTrue(lines.contains { $0.contains("minSustainedMin=10.0") }) + } + + func testShadowComparisonLinesAreExactAndDoNotSuppressLabels() { + let start = 3_000_000 + let hr = elapsedSpan(start, 12 * 60, 120) + let labels = [SavedWorkoutSpan(startSec: start + 60, endSec: start + 11 * 60)] + + let lines = AutoWorkoutDetector.shadowComparisonLines( + hr: hr, restingBpm: 60, savedSpans: labels) + + XCTAssertEqual(lines, [ + "workout shadow policy=10min candidates=1 matched=1 missed=0 unobservable=0 unmatched=0 " + + "medianOnsetErrorS=60 medianEndErrorS=60", + "workout shadow policy=12min candidates=1 matched=1 missed=0 unobservable=0 unmatched=0 " + + "medianOnsetErrorS=60 medianEndErrorS=60", + "workout shadow policy=15min candidates=0 matched=0 missed=1 unobservable=0 unmatched=0 " + + "medianOnsetErrorS=n/a medianEndErrorS=n/a", + ]) + XCTAssertEqual(labels, [SavedWorkoutSpan(startSec: start + 60, endSec: start + 11 * 60)]) + } + + func testShadowComparisonCountsMatchedMissedAndUnmatchedIndependently() { + let candidates = [ + DetectedWorkout(startSec: 100, endSec: 200, avgBpm: 120, peakBpm: 140, durationMin: 1), + DetectedWorkout(startSec: 500, endSec: 600, avgBpm: 125, peakBpm: 145, durationMin: 1), + ] + let labels = [ + SavedWorkoutSpan(startSec: 200, endSec: 300), // endpoint-only contact is not a real overlap + SavedWorkoutSpan(startSec: 800, endSec: 900), + ] + + XCTAssertEqual( + AutoWorkoutDetector.shadowComparisonLine( + policyMinutes: 10.0, candidates: candidates, savedSpans: labels), + "workout shadow policy=10min candidates=2 matched=0 missed=2 unobservable=0 unmatched=2 " + + "medianOnsetErrorS=n/a medianEndErrorS=n/a") + } + + func testShadowComparisonPairsHighestOverlapOneToOneAndMediansErrors() { + let candidates = [ + DetectedWorkout(startSec: 90, endSec: 210, avgBpm: 120, peakBpm: 140, durationMin: 2), + DetectedWorkout(startSec: 480, endSec: 640, avgBpm: 125, peakBpm: 145, durationMin: 2), + DetectedWorkout(startSec: 550, endSec: 615, avgBpm: 125, peakBpm: 145, durationMin: 1), + ] + let labels = [ + SavedWorkoutSpan(startSec: 100, endSec: 200), + SavedWorkoutSpan(startSec: 500, endSec: 600), + ] + + // Candidate 1 has the larger overlap with label 1 and wins it; candidate 2 remains unmatched. + // Pair errors are [10, 20] at onset and [10, 40] at end, giving medians 15 and 25. + let expected = "workout shadow policy=15min candidates=3 matched=2 missed=0 unobservable=0 unmatched=1 " + + "medianOnsetErrorS=15 medianEndErrorS=25" + XCTAssertEqual( + AutoWorkoutDetector.shadowComparisonLine( + policyMinutes: 15.0, candidates: candidates, savedSpans: labels), + expected) + XCTAssertEqual( + AutoWorkoutDetector.shadowComparisonLine( + policyMinutes: 15.0, candidates: Array(candidates.reversed()), + savedSpans: Array(labels.reversed())), + expected, + "shadow matching must not depend on database/union ordering") + } + + func testShadowComparisonTieBreakIsInvariantToInputOrdering() { + let candidates = [ + DetectedWorkout(startSec: 50, endSec: 150, avgBpm: 120, peakBpm: 140, durationMin: 1), + DetectedWorkout(startSec: 100, endSec: 150, avgBpm: 125, peakBpm: 145, durationMin: 1), + DetectedWorkout(startSec: 1_000, endSec: 1_100, avgBpm: 130, peakBpm: 150, durationMin: 1), + ] + let labels = [ + SavedWorkoutSpan(startSec: 100, endSec: 200), + SavedWorkoutSpan(startSec: 950, endSec: 1_050), + SavedWorkoutSpan(startSec: 1_000, endSec: 1_050), + ] + // Equal-overlap optima use the canonical Hungarian tie-break: onset errors [0, 50], + // end errors [50, 50]. Reversing either source list must retain that same assignment. + let expected = "workout shadow policy=10min candidates=3 matched=2 missed=1 unobservable=0 unmatched=1 " + + "medianOnsetErrorS=25 medianEndErrorS=50" + + XCTAssertEqual( + AutoWorkoutDetector.shadowComparisonLine( + policyMinutes: 10.0, candidates: candidates, savedSpans: labels), + expected) + XCTAssertEqual( + AutoWorkoutDetector.shadowComparisonLine( + policyMinutes: 10.0, candidates: Array(candidates.reversed()), + savedSpans: Array(labels.reversed())), + expected) + } + + func testShadowComparisonMaximizesMatchCountBeforeOverlapPreference() { + let candidates = [ + DetectedWorkout(startSec: 0, endSec: 600, avgBpm: 120, peakBpm: 140, durationMin: 10), + DetectedWorkout(startSec: 900, endSec: 1_500, avgBpm: 125, peakBpm: 145, durationMin: 10), + ] + let labels = [ + SavedWorkoutSpan(startSec: 0, endSec: 1_000), + SavedWorkoutSpan(startSec: 0, endSec: 500), + ] + + XCTAssertEqual( + AutoWorkoutDetector.shadowComparisonLine( + policyMinutes: 10.0, candidates: candidates, savedSpans: labels), + "workout shadow policy=10min candidates=2 matched=2 missed=0 unobservable=0 unmatched=0 " + + "medianOnsetErrorS=450 medianEndErrorS=300") + } + + func testShadowComparisonMaximizesTotalOverlapAfterCardinality() { + let candidates = [ + DetectedWorkout(startSec: 0, endSec: 100, avgBpm: 120, peakBpm: 140, durationMin: 1), + DetectedWorkout(startSec: 50, endSec: 110, avgBpm: 125, peakBpm: 145, durationMin: 1), + ] + let labels = [ + SavedWorkoutSpan(startSec: 0, endSec: 100), + SavedWorkoutSpan(startSec: 90, endSec: 120), + ] + + // The diagonal assignment overlaps for 100 + 20 seconds. The other maximum-cardinality + // assignment overlaps for only 10 + 50 seconds and must not skew the boundary medians. + XCTAssertEqual( + AutoWorkoutDetector.shadowComparisonLine( + policyMinutes: 10.0, candidates: candidates, savedSpans: labels), + "workout shadow policy=10min candidates=2 matched=2 missed=0 unobservable=0 unmatched=0 " + + "medianOnsetErrorS=20 medianEndErrorS=5") + } + + func testShadowComparisonSeparatesLabelsWithoutRecordedHR() { + let start = 4_000_000 + let hr = elapsedSpan(start, 12 * 60, 120) + let labels = [ + SavedWorkoutSpan(startSec: start + 60, endSec: start + 11 * 60), + SavedWorkoutSpan(startSec: start + 5_000, endSec: start + 5_600), + ] + + let lines = AutoWorkoutDetector.shadowComparisonLines( + hr: hr, restingBpm: 60, savedSpans: labels, policies: [12.0, 15.0]) + + XCTAssertEqual(lines, [ + "workout shadow policy=12min candidates=1 matched=1 missed=0 unobservable=1 unmatched=0 " + + "medianOnsetErrorS=60 medianEndErrorS=60", + "workout shadow policy=15min candidates=0 matched=0 missed=1 unobservable=1 unmatched=0 " + + "medianOnsetErrorS=n/a medianEndErrorS=n/a", + ]) + } + + func testShadowComparisonDoesNotTreatTwoAdjacentSamplesAsCoverage() { + let sparseHR = [(ts: 5_000, bpm: 60), (ts: 5_001, bpm: 60)] + let label = SavedWorkoutSpan(startSec: 5_000, endSec: 5_600) + + XCTAssertEqual( + AutoWorkoutDetector.shadowComparisonLine( + policyMinutes: 10.0, + candidates: [], + savedSpans: [label], + hrForObservability: sparseHR), + "workout shadow policy=10min candidates=0 matched=0 missed=0 unobservable=1 unmatched=0 " + + "medianOnsetErrorS=n/a medianEndErrorS=n/a") + } + + func testShadowObservabilityUsesEachPoliciesQualificationWindow() { + let hr = elapsedSpan(0, 10 * 60, 60) + let label = SavedWorkoutSpan(startSec: 0, endSec: 15 * 60) + + XCTAssertEqual( + AutoWorkoutDetector.shadowComparisonLine( + policyMinutes: 10.0, candidates: [], savedSpans: [label], hrForObservability: hr), + "workout shadow policy=10min candidates=0 matched=0 missed=1 unobservable=0 unmatched=0 " + + "medianOnsetErrorS=n/a medianEndErrorS=n/a") + XCTAssertEqual( + AutoWorkoutDetector.shadowComparisonLine( + policyMinutes: 15.0, candidates: [], savedSpans: [label], hrForObservability: hr), + "workout shadow policy=15min candidates=0 matched=0 missed=0 unobservable=1 unmatched=0 " + + "medianOnsetErrorS=n/a medianEndErrorS=n/a") + } + func testWorkoutsTraceLineShapes() { XCTAssertEqual( WorkoutsTrace.sessionLine(event: "start", sportKey: "running", hrSamples: 0), @@ -79,14 +271,19 @@ final class AutoWorkoutDetectorTraceTests: XCTestCase { WorkoutsTrace.dedupLine(sportKey: "running", keptSource: "strap", droppedSource: "apple", keptRichness: 5, droppedRichness: 1), "dedup sport=running kept=strap(richness=5) dropped=apple(richness=1) (same activity, richer kept)") - // #975: the engine detected-bout decision line , persisted (no overlap) and dropped (overlaps a real). + // #1735/#2187: analytics bouts never create generic workout rows. They either remain analytics-only + // or enrich/drop against a real manual/imported workout. XCTAssertEqual( - WorkoutsTrace.detectedBoutLine(verdict: "persisted", durMin: 42, avgBpm: 148), - "detectedBout verdict=persisted durMin=42 avgBpm=148") + WorkoutsTrace.detectedBoutLine(verdict: "analyticsOnly", durMin: 42, avgBpm: 148), + "detectedBout verdict=analyticsOnly durMin=42 avgBpm=148") XCTAssertEqual( WorkoutsTrace.detectedBoutLine(verdict: "droppedOverlap", durMin: 42, avgBpm: 148, overlapSource: "manual"), "detectedBout verdict=droppedOverlap durMin=42 avgBpm=148 overlapSource=manual") + XCTAssertEqual( + WorkoutsTrace.detectedBoutLine(verdict: "droppedOverlapBackfilled", durMin: 42, avgBpm: 148, + overlapSource: "apple"), + "detectedBout verdict=droppedOverlapBackfilled durMin=42 avgBpm=148 overlapSource=apple") } func testWorkoutsReadoutParsesLastSession() { 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..0f72937683 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,8 +226,206 @@ 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)", + rssiDbm: nil, rssiAgeMillis: nil) + XCTAssertEqual(silent, + "Link epitaph: up 4123ms, inbound 0 frames / 0 bytes (cmd-channel 0), " + + "realtime armed=no, signal=never read on this link, " + + "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", + rssiDbm: -63, rssiAgeMillis: 28_400) + XCTAssertEqual(alive, + "Link epitaph: up 61000ms, inbound 812 frames / 40990 bytes (cmd-channel 9), " + + "realtime armed=yes, signal=-63dBm (read 28400ms before the drop), " + + "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", rssiDbm: nil, rssiAgeMillis: nil) + .hasPrefix("Link epitaph: up 0ms, inbound 0 frames / 0 bytes (cmd-channel 0)")) + } + + /// #2332: the signal half is only evidence about the DROP if its age travels with it, so the three + /// states are pinned separately. The nil-value case is the one that must never be filled in with a + /// stale reading from the previous link - it has to say so out loud. + func testLinkEpitaphSignal() { + func epitaph(_ rssi: Int?, _ age: Int?) -> String { + ConnectionReadout.linkEpitaph(upMillis: 1_000, inboundFrames: 5, inboundBytes: 10, + cmdChannelFrames: 0, realtimeArmed: false, ended: "status=8", + rssiDbm: rssi, rssiAgeMillis: age) + } + XCTAssertTrue(epitaph(nil, nil).contains("signal=never read on this link")) + // An age with no value is still no reading: the age alone must not manufacture one. + XCTAssertTrue(epitaph(nil, 4_000).contains("signal=never read on this link")) + XCTAssertTrue(epitaph(-92, nil).contains("signal=-92dBm (age unknown)")) + XCTAssertTrue(epitaph(-92, 1_587_000).contains("signal=-92dBm (read 1587000ms before the drop)")) + // RSSI must survive unclamped; clamping it to zero would erase every real reading. + XCTAssertFalse(epitaph(-92, 0).contains("signal=0dBm")) + XCTAssertTrue(epitaph(-92, 0).contains("signal=-92dBm (read 0ms before the drop)")) + // A negative age is a clock hiccup, not a reading from the future. + XCTAssertTrue(epitaph(-92, -5).contains("signal=-92dBm (read 0ms before the drop)")) + } + func testLastFrameLabel() { XCTAssertEqual(ConnectionReadout.lastFrameLabel(lastFrameUnix: 990, nowUnix: 1_002), "12s ago") XCTAssertEqual(ConnectionReadout.lastFrameLabel(lastFrameUnix: nil, nowUnix: 1_002), "no frames yet") } } + +/// #2117: the R-R transport line, which separates "banked nothing" from "banked beats the policy refused". +final class UniversalTraceRRTransportTests: XCTestCase { + + /// A device the WHOOP 5 unit policy does not govern says nothing at all, so a WHOOP 4 export is + /// byte-unchanged by this line existing. + func testANonStrictDeviceEmitsNothing() { + XCTAssertNil(UniversalTrace.rrTransportLine(strictWhoop5: false, firstRecordedUnix: 1_750_000_000, + firstScorableUnix: nil)) + } + + /// The state that blanks every night at once: beats on disk, none the policy will score. This is the + /// one worth recognising at a glance, because it looks identical to "no data" from the analyzer's side. + func testBeatsOnDiskButNoneScorableIsNamed() { + let line = UniversalTrace.rrTransportLine(strictWhoop5: true, firstRecordedUnix: 1_750_000_000, + firstScorableUnix: nil) + XCTAssertNotNil(line) + XCTAssertTrue(line!.contains("scorable=none")) + XCTAssertTrue(line!.contains("unscorableHistory=yes")) + } + + /// A strap that has never banked a beat is a DIFFERENT report from one whose beats were refused, and + /// the line must not blur them: no "unscorableHistory" claim when there is no history to be unscorable. + func testNoHistoryAtAllIsNotReportedAsUnscorable() { + let line = UniversalTrace.rrTransportLine(strictWhoop5: true, firstRecordedUnix: nil, + firstScorableUnix: nil) + XCTAssertEqual(line, "rrTransport recorded=none scorable=none") + XCTAssertFalse(line!.contains("unscorableHistory")) + } + + /// Partly scorable: the gap is what says how much history was refused, and it is the number that + /// distinguishes "upgraded last week" from "lost a year". + func testAPartlyScorableHistoryReportsTheGapInDays() { + let line = UniversalTrace.rrTransportLine(strictWhoop5: true, firstRecordedUnix: 1_750_000_000, + firstScorableUnix: 1_750_000_000 + 30 * 86_400) + XCTAssertNotNil(line) + XCTAssertTrue(line!.contains("unscorableHistory=yes")) + XCTAssertTrue(line!.contains("gapDays=30")) + } + + /// The WHOLE line, byte for byte, with the identical literal pinned on the Kotlin side. + /// + /// The other cases here assert fragments, which would let the two platforms drift on anything a + /// `contains` does not look at: field order, spacing, or the shared date format. A report is diffed + /// across platforms, so the line has to be the same line, not merely the same facts. + func testTheWholeLineIsPinnedByteForByte() { + XCTAssertEqual( + UniversalTrace.rrTransportLine(strictWhoop5: true, firstRecordedUnix: 1_750_000_000, + firstScorableUnix: 1_752_592_000), + "rrTransport recorded=2025-06-15 15:06:40 scorable=2025-07-15 15:06:40 unscorableHistory=yes gapDays=30" + ) + } + + /// Fully scorable from the first beat: nothing was refused, and the line must say so plainly rather + /// than leaving a reader to infer it from a missing field. + func testAFullyScorableHistorySaysSo() { + let line = UniversalTrace.rrTransportLine(strictWhoop5: true, firstRecordedUnix: 1_750_000_000, + firstScorableUnix: 1_750_000_000) + XCTAssertNotNil(line) + XCTAssertTrue(line!.contains("unscorableHistory=no")) + XCTAssertTrue(line!.contains("gapDays=0")) + } + + /// #2397: the link's signal SHAPE, from readings the periodic read already takes. + /// + /// A last value alone cannot separate a link that was marginal all along from one that walked out + /// of range, and a supervision timeout asks exactly that. The field log that prompted this carried + /// 467 readings across three links and reported two of them. Twin of the Kotlin test, so the two + /// platforms print the same bytes. + func testLinkEpitaphReportsTheSignalShape() { + let line = ConnectionReadout.linkEpitaph(upMillis: 60_000, inboundFrames: 100, + inboundBytes: 2_000, cmdChannelFrames: 0, + realtimeArmed: true, ended: "status=8", + rssiDbm: -58, rssiAgeMillis: 52_658, + rssiReads: 203, rssiWorstDbm: -88, + rssiSumDbm: -203 * 64) + XCTAssertTrue(line.contains("signal=-58dBm (read 52658ms before the drop; " + + "n=203 worst=-88dBm mean=-64dBm)"), line) + } + + /// One reading is not a shape: worst and mean would restate the value already printed. + func testLinkEpitaphOmitsTheShapeUntilThereAreTwoReadings() { + for n in 0...1 { + let line = ConnectionReadout.linkEpitaph(upMillis: 60_000, inboundFrames: 100, + inboundBytes: 2_000, cmdChannelFrames: 0, + realtimeArmed: true, ended: "status=8", + rssiDbm: -58, rssiAgeMillis: 1_000, + rssiReads: n, rssiWorstDbm: n == 0 ? nil : -58, + rssiSumDbm: -58 * n) + XCTAssertFalse(line.contains("n="), line) + XCTAssertTrue(line.contains("signal=-58dBm (read 1000ms before the drop)"), line) + } + } + + /// A link that never read carries no shape and says so in the words it already used. + func testLinkEpitaphNeverReadIsUnchanged() { + let line = ConnectionReadout.linkEpitaph(upMillis: 60_000, inboundFrames: 100, + inboundBytes: 2_000, cmdChannelFrames: 0, + realtimeArmed: true, ended: "status=8", + rssiDbm: nil, rssiAgeMillis: nil, + rssiReads: 0, rssiWorstDbm: nil, rssiSumDbm: 0) + XCTAssertTrue(line.contains("signal=never read on this link"), line) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DayCacheMissReasonTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DayCacheMissReasonTests.swift new file mode 100644 index 0000000000..7bad38ac08 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DayCacheMissReasonTests.swift @@ -0,0 +1,45 @@ +import XCTest +@testable import StrandAnalytics + +/// #2073: the day-cache reuse line reported only HOW MANY nights were reused, never why the rest missed. +/// +/// A field log showed `reused=0/21` on 13 of 17 passes, each costing about 50 seconds of prep and 1.75M +/// row reads, every 15 minutes on battery. A healthy pass reuses all but today, whose heart rate is still +/// growing, so a total miss means something shared by every key moved. Those are different bugs and the +/// count could not tell them apart. +/// +/// Keys are `owner|hrCount:hrMaxTs:anchor:detail|streams`. Same cases as the Kotlin twin. +final class DayCacheMissReasonTests: XCTestCase { + private func key(_ owner: String, _ hr: String, _ streams: String) -> String { "\(owner)|\(hr)|\(streams)" } + + func testAnIdenticalKeyIsNotAMiss() { + let k = key("my-whoop", "100:200:nil:s", "a|rrAlias5=true") + XCTAssertEqual(AnalyzeRecentDayCache.missReason(cachedKey: k, freshKey: k), "none") + } + + func testTodaysGrowingHeartRateIsNamedAsTheHrSegment() { + XCTAssertEqual(AnalyzeRecentDayCache.missReason( + cachedKey: key("my-whoop", "100:200:nil:s", "a|rrAlias5=true"), + freshKey: key("my-whoop", "140:260:nil:s", "a|rrAlias5=true")), "hr") + } + + func testAMovedOwnerIsNamedBecauseItInvalidatesEveryDayAtOnce() { + XCTAssertEqual(AnalyzeRecentDayCache.missReason( + cachedKey: key("my-whoop", "100:200:nil:s", "a|rrAlias5=true"), + freshKey: key("whoop-5A0", "100:200:nil:s", "a|rrAlias5=true")), "owner") + } + + /// The one input computed ONCE per pass and folded into all 21 keys, so a single flip is a total miss. + func testThePassGlobalRrAliasIsCalledOutSeparately() { + XCTAssertEqual(AnalyzeRecentDayCache.missReason( + cachedKey: key("my-whoop", "100:200:nil:s", "a|rrAlias5=true"), + freshKey: key("my-whoop", "100:200:nil:s", "a|rrAlias5=false")), "rrAlias5") + XCTAssertEqual(AnalyzeRecentDayCache.missReason( + cachedKey: key("my-whoop", "100:200:nil:s", "a|rrAlias5=true"), + freshKey: key("my-whoop", "100:200:nil:s", "b|rrAlias5=true")), "streams") + } + + func testAKeyThatIsNotTheExpectedShapeSaysSo() { + XCTAssertEqual(AnalyzeRecentDayCache.missReason(cachedKey: "nopipes", freshKey: "alsonone"), "shape") + } +} 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/DayOwnerReadIntegrationTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DayOwnerReadIntegrationTests.swift index 11c895e902..3d59c6989a 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DayOwnerReadIntegrationTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DayOwnerReadIntegrationTests.swift @@ -15,23 +15,37 @@ final class DayOwnerReadIntegrationTests: XCTestCase { private let day = "2026-06-15" /// Build candidates exactly as `IntelligenceEngine.resolveDayOwner` does, then resolve. + /// + /// This mirrors the SHIPPED shape, which is the only thing that makes an integration test worth + /// having: probe in (priority, original index) order and stop at the first candidate with data, + /// using the scalar `hasHrInWindow` presence check rather than fetching a row from the union read. + /// It used to build every candidate and hand the lot to `DayOwnerResolver.resolve`, which is what + /// production did before; left alone it would still have passed while quietly testing a path + /// nothing takes any more. private func resolveOwner(store: WhoopStore, registry: DeviceRegistryStore, from: Int, to: Int) async throws -> String? { if let locked = try registry.dayOwner(day)?.deviceId { return locked } let activeId = try registry.activeDeviceId() ?? "my-whoop" - var candidates: [DayOwnerResolver.Candidate] = [] - for d in try registry.all() where d.status != .archived { - let isImport = d.sourceKind == .cloudImport || d.sourceKind == .fileImport - // Mirrors IntelligenceEngine.resolveDayOwner: activity-file rides rank BELOW whole-day imports. - let priority: Int - if d.id == activeId { priority = 0 } - else if d.sourceKind == .activityFile { priority = 3 } - else if isImport { priority = 2 } - else { priority = 1 } - let hasData = !((try? await store.hrSamples(deviceId: d.id, from: from, to: to, limit: 1)) ?? []).isEmpty - candidates.append(.init(deviceId: d.id, priority: priority, hasData: hasData)) + let ranked: [(id: String, priority: Int)] = try registry.all() + .filter { $0.status != .archived } + .map { d in + let isImport = d.sourceKind == .cloudImport || d.sourceKind == .fileImport + // Mirrors IntelligenceEngine.resolveDayOwner: activity-file rides rank BELOW whole-day + // imports. + let priority: Int + if d.id == activeId { priority = 0 } + else if d.sourceKind == .activityFile { priority = 3 } + else if isImport { priority = 2 } + else { priority = 1 } + return (d.id, priority) + } + .enumerated() + .sorted { ($0.element.priority, $0.offset) < ($1.element.priority, $1.offset) } + .map(\.element) + for c in ranked { + if (try? await store.hasHrInWindow(deviceId: c.id, from: from, to: to)) == true { return c.id } } - return DayOwnerResolver.resolve(day: day, lockedOwner: nil, candidates: candidates) + return nil } func testActiveStrapOwnsDayAndReadReturnsOnlyItsSamples() async throws { diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DayOwnerResolverEquivalenceTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DayOwnerResolverEquivalenceTests.swift new file mode 100644 index 0000000000..9112212942 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DayOwnerResolverEquivalenceTests.swift @@ -0,0 +1,65 @@ +import XCTest +@testable import StrandAnalytics + +/// The day-owner probe short-circuits: it asks candidates in priority order and stops at the first with +/// data, instead of probing every candidate and then selecting. That is only safe while it agrees with +/// `DayOwnerResolver.resolve` on every input, so this asserts the agreement exhaustively rather than +/// leaving it to the argument in the comment. Mirrored by the Kotlin +/// `DayOwnerResolverEquivalenceTest`. +/// +/// The saving is one query per candidate per day. On the 60-day steps-calibration window with two straps +/// that is 120 probes a pass, and the active strap usually answers on the first. +final class DayOwnerResolverEquivalenceTests: XCTestCase { + + /// The short-circuit, over a pre-computed data map so it can be compared exhaustively. + private func shortCircuit(_ candidates: [(id: String, priority: Int)], + _ hasData: [String: Bool]) -> String? { + candidates.enumerated() + .sorted { ($0.element.priority, $0.offset) < ($1.element.priority, $1.offset) } + .first { hasData[$0.element.id] == true }?.element.id + } + + /// Every data pattern over a three-candidate set with distinct priorities. If the two ever disagree + /// the failure names the case. + func testTheShortCircuitAgreesWithTheResolverOnEveryCombination() { + let ids: [(id: String, priority: Int)] = [("active", 0), ("second", 1), ("import", 2)] + for mask in 0..<8 { + var hasData: [String: Bool] = [:] + for (i, c) in ids.enumerated() { hasData[c.id] = (mask >> i) & 1 == 1 } + let viaResolver = DayOwnerResolver.resolve( + day: "2026-09-09", lockedOwner: nil, + candidates: ids.map { + DayOwnerResolver.Candidate(deviceId: $0.id, priority: $0.priority, + hasData: hasData[$0.id]!) + }) + XCTAssertEqual(viaResolver, shortCircuit(ids, hasData), "data mask \(mask)") + } + } + + /// Priority, not list order, decides, which is why the probe sorts before it walks. + func testListOrderDoesNotDecide() { + let reversed: [(id: String, priority: Int)] = [("import", 2), ("second", 1), ("active", 0)] + let hasData = ["active": true, "second": true, "import": true] + XCTAssertEqual(shortCircuit(reversed, hasData), "active") + } + + /// TIED priorities, which two non-active straps have: both are priority 1. The answer must be the + /// FIRST of them in list order, deterministically, on both platforms. + /// + /// This is the case the short-circuit could have got wrong. Swift's `sorted` is not stable by + /// contract, so ordering on priority alone left a tie free to come back either way, while Kotlin's + /// `sortedBy` is stable and its resolver takes the first minimum. The probe therefore sorts on + /// (priority, original index), which is a total order and agrees with Kotlin by construction. + func testTiedPrioritiesResolveToTheFirstInListOrder() { + let tied: [(id: String, priority: Int)] = [("strapA", 1), ("strapB", 1)] + XCTAssertEqual(shortCircuit(tied, ["strapA": true, "strapB": true]), "strapA") + // And when the first of the tie has NO data, the second wins rather than nothing. + XCTAssertEqual(shortCircuit(tied, ["strapA": false, "strapB": true]), "strapB") + } + + /// Nobody with data yields nobody, so the caller falls back to its own id rather than guessing. + func testNoCandidateWithDataResolvesToNothing() { + XCTAssertNil(shortCircuit([("active", 0), ("second", 1)], + ["active": false, "second": false])) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DaytimeBaselinesTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DaytimeBaselinesTests.swift index f89bcb8834..97f57a99f8 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DaytimeBaselinesTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DaytimeBaselinesTests.swift @@ -87,6 +87,41 @@ final class DaytimeBaselinesTests: XCTestCase { // MARK: - Fold trailing history + /// #2107: reducing each day as it is read must produce EXACTLY the list fold's answer. + /// + /// The caller used to hold thirty days of raw streams to compute sixty numbers, which exhausted the + /// heap. It now reduces per day and keeps only the aggregates. That is only safe if the two routes + /// agree, so this pins them together rather than trusting that one delegates to the other today. + func testReducingPerDayMatchesFoldingTheWholeList() { + let days = (0..<12).map { + DaytimeStress.DaytimeDayStreams(hr: flatDayHR($0, bpm: 65 + ($0 % 3)), rr: [], + tzOffsetSeconds: 0) + } + let viaList = DaytimeStress.foldDaytimeBaselines(days: days) + let viaAggregates = DaytimeStress.foldAggregates(days.map { + DaytimeStress.dayDaytimeAggregate(hr: $0.hr, rr: $0.rr, tzOffsetSeconds: $0.tzOffsetSeconds) + }) + XCTAssertEqual(viaList.hr.baseline, viaAggregates.hr.baseline, accuracy: 0) + XCTAssertEqual(viaList.hr.usable, viaAggregates.hr.usable) + XCTAssertEqual(viaList.rmssd?.baseline, viaAggregates.rmssd?.baseline) + + // And the same for the mode the screen actually asks for. + let modeViaList = DaytimeStress.scoringMode(history: days) + let modeViaAggregates = DaytimeStress.scoringModeFromAggregates(days.map { + DaytimeStress.dayDaytimeAggregate(hr: $0.hr, rr: $0.rr, tzOffsetSeconds: $0.tzOffsetSeconds) + }) + XCTAssertEqual(modeViaList, modeViaAggregates) + } + + /// An unworn day contributes no aggregate either way. Pinned because the caller SKIPS such days + /// before reducing (it never reads their R-R), so the two routes must still line up when the day + /// list and the aggregate list have different lengths in the caller's loop. + func testAnEmptyDayReducesToNothingOnBothRoutes() { + let empty = DaytimeStress.dayDaytimeAggregate(hr: [], rr: [], tzOffsetSeconds: 0) + XCTAssertNil(empty.hr) + XCTAssertNil(empty.rmssd) + } + func testFoldConvergesHRBaselineToThePersonalDaytimeFloor() { // Twelve identical days whose P10 daytime HR is exactly 65 → the EWMA center converges to 65 and // the baseline is usable (12 ≥ minNightsSeed). No R-R anywhere → RMSSD baseline is nil. diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DaytimeStressTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DaytimeStressTests.swift index 790958eed1..5aa95fa18e 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DaytimeStressTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DaytimeStressTests.swift @@ -10,6 +10,105 @@ final class DaytimeStressTests: XCTestCase { return (0.. ([HRSample], [RRInterval]) { + var hr: [HRSample] = [] + var rr: [RRInterval] = [] + for h in 7...11 { + hr += hourHR(h, bpm: 60 + (h - 7) * 4) + rr += hourRRVariable(h, rrMs: 900, jitter: 20) + } + return (hr, rr) + } + + func testTimelineKeepsEveryHourlyPointExactlyAsScored() { + let (hr, rr) = wornMorning() + let res = DaytimeStress.analyze(hr: hr, rr: rr, includeTimeline: true) + // The sliding read must not restate the hours it slides between: a point on the hour has to + // carry the same level it carried before this existed, or the curve would disagree with every + // other surface that reads `hours`. + let byStart = Dictionary(uniqueKeysWithValues: res.timeline.map { ($0.startTs, $0) }) + XCTAssertFalse(res.hours.isEmpty) + for h in res.hours { + XCTAssertEqual(byStart[h.startTs]?.level, h.level, "hour \(h.startTs) restated") + XCTAssertEqual(byStart[h.startTs]?.maskedForActivity, h.maskedForActivity) + } + } + + func testTimelineAddsTheStraddlingMidpointsAndNothingElse() { + let (hr, rr) = wornMorning() + let res = DaytimeStress.analyze(hr: hr, rr: rr, includeTimeline: true) + XCTAssertGreaterThan(res.timeline.count, res.hours.count) + let hourly = Set(res.hours.map(\.startTs)) + let extras = res.timeline.filter { !hourly.contains($0.startTs) } + XCTAssertFalse(extras.isEmpty) + // Every added point sits exactly half a window off the hour, which is what "slid" means. A + // point anywhere else would mean the grid, not the phase, had moved. + for e in extras { + let offset = ((e.startTs % DaytimeStress.bucketSeconds) + DaytimeStress.bucketSeconds) + % DaytimeStress.bucketSeconds + XCTAssertEqual(offset, DaytimeStress.timelineStepSeconds) + } + XCTAssertEqual(res.timeline.map(\.startTs), res.timeline.map(\.startTs).sorted()) + } + + func testHourCountingIgnoresTheSlidingRead() { + let (hr, rr) = wornMorning() + let res = DaytimeStress.analyze(hr: hr, rr: rr, includeTimeline: true) + // Overlapping windows would count the same minute twice, so the minute total stays on the + // non-overlapping hours. This is the assertion that fails first if someone later points + // `highStressMinutes` at the denser series. + let highHours = res.hours.filter { ($0.level ?? 0) >= DaytimeStress.highBandFloor }.count + XCTAssertEqual(res.highStressMinutes, highHours * 60) + XCTAssertEqual(res.activityMaskedHours, res.hours.filter(\.maskedForActivity).count) + } + + func testASteadyDayScoresItsMidpointsLikeItsHours() { + // Same HR every hour: with one shared reference the midpoints must land on the same level as + // the hours they straddle. If the sliding pass ever derived its OWN calm reference, this is + // where it would show up, as a curve that zigzags between two scales rather than tracking one. + var hr: [HRSample] = [] + var rr: [RRInterval] = [] + for h in 8...12 { hr += hourHR(h, bpm: 66); rr += hourRRVariable(h, rrMs: 900, jitter: 20) } + let res = DaytimeStress.analyze(hr: hr, rr: rr, includeTimeline: true) + let levels = Set(res.timeline.compactMap { $0.level.map { String(format: "%.6f", $0) } }) + XCTAssertLessThanOrEqual(levels.count, 1, "a flat day should not zigzag, got \(levels)") + } + + func testTimelineMatchesTheKotlinTwinValueForValue() { + // The other half of the oracle. The Kotlin `DaytimeStressTest` asserts these same literals for + // this same scenario, so a change landing on ONE platform moves one of the two and fails here + // or there. An oracle only guards the direction it is written in. + let (hr, rr) = wornMorning() + let res = DaytimeStress.analyze(hr: hr, rr: rr, includeTimeline: true) + func render(_ points: [DaytimeStress.HourPoint]) -> String { + points.map { "\($0.startTs):" + ($0.level.map { String(format: "%.6f", $0) } ?? "nil") } + .joined(separator: " ") + } + XCTAssertEqual(render(res.hours), + "25200:0.990715 28800:1.500000 32400:2.009285 36000:2.413289 39600:2.678875") + XCTAssertEqual(render(res.timeline), + "23400:0.990715 25200:0.990715 27000:1.500000 28800:1.500000 30600:2.009285 " + + "32400:2.009285 34200:2.413289 36000:2.413289 37800:2.678875 39600:2.678875") + XCTAssertEqual(res.highStressMinutes, 180) + XCTAssertTrue(res.sustainedHigh) + XCTAssertEqual(res.sustainedRun, 3) + XCTAssertEqual(res.peak?.startTs, 39600) + } + func testEmptyWhenNoHR() { XCTAssertEqual(DaytimeStress.analyze(hr: [], rr: []), .empty) } 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.. HRVAnalyzer.RrCoverageVerdict { + let ts = rows.map(\.ts), ms = rows.map { Double($0.rrMs) } + return HRVAnalyzer.classifyCoverage(coverage: HRVAnalyzer.rrCoverage(tsSec: ts, rrMs: ms), + collapsed: HRVAnalyzer.collapsedCoverage(tsSec: ts, rrMs: ms)) + } + + /// Night: two beats every second (refused). Nap: one per second (clean). + private func dayResult() -> (AnalyticsEngine.DayResult, [RRInterval]) { + let night = (nightStart.. 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") + } + } + + /// #2128: the verdict the nightly trace prints must be the one the value gate actually acted on. + /// + /// Asserted as an AGREEMENT rather than against a fixed label, so the trace deriving its reason from + /// existing calls cannot let the explanation and the decision drift apart. A bare `reported=nil` + /// beside real window means reads as a value that went missing; it is usually this gate refusing an + /// over-counted night on purpose, and the trace can only say so if it asks the same helper. + func testTheTracedVerdictAgreesWithTheGate() { + let start = 1_000, end = 1_600 + var overCounted: [RRInterval] = [] + var plausible: [RRInterval] = [] + for i in 0..<600 { + overCounted.append(RRInterval(ts: start + i, rrMs: 880)) + overCounted.append(RRInterval(ts: start + i, rrMs: 960)) + plausible.append(RRInterval(ts: start + i, rrMs: 900)) + } + for beats in [overCounted, plausible] { + XCTAssertTrue(windowsYieldRMSSD(start, end, beats), "precondition: these beats DO yield an RMSSD") + // The trace derives "the gate refused" from exactly this pair, so pin the pair rather than + // a label: windows that DID yield an RMSSD, beside a nil value, can only be the gate. + let cov = HRVAnalyzer.rrCoverage(tsSec: beats.map { $0.ts }, rrMs: beats.map { Double($0.rrMs) }) + let verdict = HRVAnalyzer.classifyCoverage(coverage: cov, collapsed: cov) + XCTAssertEqual(SleepStager.sessionAvgHRV(start: start, end: end, rr: beats) == nil, + !HRVAnalyzer.successiveDiffIsTrustworthy(verdict), + "a withheld night must be exactly the night whose own beats were refused") + } + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LiftMetricsStoreAgreementTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LiftMetricsStoreAgreementTests.swift new file mode 100644 index 0000000000..a96c7861ea --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LiftMetricsStoreAgreementTests.swift @@ -0,0 +1,136 @@ +import XCTest +import GRDB +import WhoopStore +@testable import StrandAnalytics + +/// The per-muscle set count is computed TWICE, by two different pieces of code, and both are shown +/// to the user: +/// +/// * `WhoopStore.liftSetCounts` — SQL, feeds the Lift Log hub's "last 7 days" card; +/// * `LiftMetrics.muscleCounts` — in memory, feeds a finished session's detail sheet. +/// +/// It is the load-bearing figure of the whole feature — the one the reference doses are compared +/// against — so the two disagreeing would mean two screens reporting different numbers for the same +/// sets, with nothing to notice. They are pinned against each other here rather than each being +/// pinned to its own expectation, because agreeing with a literal is not the same as agreeing with +/// each other. +/// +/// This test exists because they DID differ: the in-memory version excluded a muscle listed both as +/// primary and secondary, and the SQL version did not. Nothing showed it, because the write path +/// strips the primary on the way in — so the divergence was invisible until some future writer +/// forgot to. The malformed row below is written with raw SQL precisely because the public API +/// cannot produce one. +final class LiftMetricsStoreAgreementTests: XCTestCase { + + private let day = 1_700_000_000 + + /// Insert sets through raw SQL so the row shapes are exactly what is asked for, including the + /// one the public API would clean up. + private func store(_ rows: [(primary: String?, secondary: String, warmup: Int)], + reps: [Int] = []) async throws -> WhoopStore { + let store = try await WhoopStore.inMemory() + let writer = store.registryWriter + try await writer.write { db in + try db.execute(sql: """ + INSERT INTO liftSession (id, deviceId, startTs, endTs, sport, programId, programName, + sessionRpe, note) + VALUES ('s1', 'dev', ?, ?, 'Strength Training', NULL, NULL, NULL, NULL) + """, arguments: [self.day, self.day + 3600]) + for (i, r) in rows.enumerated() { + try db.execute(sql: """ + INSERT INTO liftSet (id, deviceId, sessionId, ord, exercise, primaryMuscle, + secondaryMuscles, setIndex, weightKg, reps, rpe, isWarmup, + startTs, endTs, restSec, note) + VALUES (?, 'dev', 's1', ?, 'Exercise', ?, ?, 1, 60, ?, NULL, ?, NULL, NULL, NULL, NULL) + """, arguments: ["set-\(i)", i, r.primary, r.secondary, + reps.indices.contains(i) ? reps[i] : 10, r.warmup]) + } + } + return store + } + + /// The same rows, as `LiftMetrics` would receive them from the store. + private func rows(_ store: WhoopStore) async throws -> [LiftSetRow] { + try await store.liftSets(sessionId: "s1") + } + + private func assertAgree(_ store: WhoopStore, + file: StaticString = #filePath, line: UInt = #line) async throws { + let sql = try await store.liftSetCounts(deviceId: "dev", fromTs: day - 1, toTs: day + 1) + let memory = LiftMetrics.muscleCounts(try await rows(store)) + XCTAssertEqual(sql.direct, memory.direct, "direct counts diverged", file: file, line: line) + XCTAssertEqual(sql.indirect, memory.indirect, "indirect counts diverged", file: file, line: line) + XCTAssertEqual(sql.fractional, memory.fractional, "fractional counts diverged", file: file, line: line) + } + + /// The ordinary shape: a primary and two distinct secondaries. + func testBothImplementationsAgreeOnAWellFormedSet() async throws { + let store = try await store([(primary: "chest", secondary: "frontDelts,triceps", warmup: 0)]) + try await assertAgree(store) + + let counts = try await store.liftSetCounts(deviceId: "dev", fromTs: day - 1, toTs: day + 1) + XCTAssertEqual(counts.fractional[.chest], LiftMuscle.directSetCredit) + XCTAssertEqual(counts.fractional[.triceps], LiftMuscle.indirectSetCredit) + } + + /// THE ONE THAT WAS BROKEN. A row that lists its own primary among the secondaries must be + /// credited once, as direct, by BOTH — not 1.0 by one screen and 1.5 by the other. + func testBothImplementationsAgreeWhenARowListsItsPrimaryAsASecondary() async throws { + let store = try await store([(primary: "chest", secondary: "chest,triceps", warmup: 0)]) + try await assertAgree(store) + + let counts = try await store.liftSetCounts(deviceId: "dev", fromTs: day - 1, toTs: day + 1) + XCTAssertEqual(counts.direct[.chest], 1) + XCTAssertNil(counts.indirect[.chest], "the primary must not also be counted as indirect") + XCTAssertEqual(counts.fractional[.chest], LiftMuscle.directSetCredit, + "1.0, never 1.5 — the same muscle cannot be worked twice by one set") + } + + /// Warm-ups are excluded on both sides. They are excluded from volume and the per-muscle counts + /// by design, so a difference here would inflate the figure the doses are compared against. + func testBothImplementationsAgreeThatWarmUpsDoNotCount() async throws { + let store = try await store([ + (primary: "quads", secondary: "glutes", warmup: 1), + (primary: "quads", secondary: "glutes", warmup: 0), + ]) + try await assertAgree(store) + + let counts = try await store.liftSetCounts(deviceId: "dev", fromTs: day - 1, toTs: day + 1) + XCTAssertEqual(counts.direct[.quads], 1, "only the working set counts") + } + + /// An unclassified set contributes nothing rather than defaulting into a bucket. + func testBothImplementationsAgreeOnAnUnclassifiedSet() async throws { + let store = try await store([(primary: nil, secondary: "", warmup: 0)]) + try await assertAgree(store) + + let counts = try await store.liftSetCounts(deviceId: "dev", fromTs: day - 1, toTs: day + 1) + XCTAssertTrue(counts.fractional.isEmpty, "no muscle was named, so no muscle is credited") + } + + /// A token no longer in the vocabulary is ignored by both rather than crashing or counting. + func testBothImplementationsAgreeOnAnUnknownToken() async throws { + let store = try await store([(primary: "shoulders", secondary: "pecs,triceps", warmup: 0)]) + try await assertAgree(store) + + let counts = try await store.liftSetCounts(deviceId: "dev", fromTs: day - 1, toTs: day + 1) + XCTAssertEqual(counts.indirect[.triceps], 1, "the recognisable half still counts") + XCTAssertTrue(counts.direct.isEmpty) + } + + /// A set saved at zero reps was never performed, and neither implementation counts it. The rule is + /// exactly `reps != 0` on both sides, so a nonsensical negative count still counts in both rather + /// than in one: an SQL `reps > 0` would drop it where `LiftMetrics.isPerformed` keeps it. + func testBothImplementationsAgreeThatASetWithZeroRepsDoesNotCount() async throws { + let store = try await store([ + (primary: "chest", secondary: "triceps", warmup: 0), + (primary: "chest", secondary: "triceps", warmup: 0), + (primary: "chest", secondary: "triceps", warmup: 0), + ], reps: [0, 10, -1]) + try await assertAgree(store) + + let counts = try await store.liftSetCounts(deviceId: "dev", fromTs: day - 1, toTs: day + 1) + XCTAssertEqual(counts.direct[.chest], 2, "the zero-rep set is the only one left out") + XCTAssertEqual(counts.indirect[.triceps], 2) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LiftMetricsTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LiftMetricsTests.swift new file mode 100644 index 0000000000..e2ea19c28a --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LiftMetricsTests.swift @@ -0,0 +1,218 @@ +import XCTest +@testable import StrandAnalytics +import WhoopStore + +/// The Lift Log's training metrics. Pure arithmetic, so every figure the user is shown can be +/// checked here against a number worked out by hand. +final class LiftMetricsTests: XCTestCase { + + private var ord = 0 + + /// A working set. Defaults chosen so a test only states what it is actually about. + private func set(_ exercise: String = "Bench press", + weight: Double? = 100, reps: Int? = 5, rpe: Double? = nil, + warmup: Bool = false, + start: Int? = nil, end: Int? = nil, rest: Int? = nil, + primary: LiftMuscle? = .chest, + secondary: [LiftMuscle] = []) -> LiftSetRow { + ord += 1 + return LiftSetRow(id: UUID().uuidString, deviceId: "dev", sessionId: "s", + ord: ord, exercise: exercise, + primaryMuscle: primary, secondaryMuscles: secondary, + setIndex: ord, weightKg: weight, reps: reps, rpe: rpe, + isWarmup: warmup, startTs: start, endTs: end, restSec: rest, note: nil) + } + + // MARK: - Volume load + + func testVolumeIsWeightTimesRepsSummed() { + let v = LiftMetrics.volumeLoadKg([set(weight: 100, reps: 5), set(weight: 80, reps: 10)]) + XCTAssertEqual(v!, 100 * 5 + 80 * 10, accuracy: 0.001) // 1300 + } + + func testWarmUpsAreExcludedFromVolume() { + let v = LiftMetrics.volumeLoadKg([set(weight: 100, reps: 5), + set(weight: 40, reps: 10, warmup: true)]) + XCTAssertEqual(v!, 500, accuracy: 0.001, + "the literature counts working sets; a warm-up would flatter every session") + } + + func testASetMissingWeightOrRepsContributesNothingRatherThanAGuess() { + XCTAssertNil(LiftMetrics.volumeLoadKg([set(weight: nil, reps: 5)])) + XCTAssertNil(LiftMetrics.volumeLoadKg([set(weight: 100, reps: nil)])) + XCTAssertNil(LiftMetrics.volumeLoadKg([])) + } + + func testBodyweightWorkLoggedWithNoWeightIsNotCountedAsVolume() { + // Pull-ups logged as reps only: real training, but no tonnage figure is honest for it. + XCTAssertNil(LiftMetrics.volumeLoadKg([set("Pull-up", weight: nil, reps: 12)])) + } + + // MARK: - Session load (Foster sRPE-TL) + + func testSessionLoadIsRpeTimesMinutes() { + XCTAssertEqual(LiftMetrics.sessionLoad(sessionRpe: 7, durationSec: 3600)!, 420, accuracy: 0.001) + XCTAssertEqual(LiftMetrics.sessionLoad(sessionRpe: 8, durationSec: 1800)!, 240, accuracy: 0.001) + } + + func testAnUnratedSessionHasNoLoadRatherThanZero() { + XCTAssertNil(LiftMetrics.sessionLoad(sessionRpe: nil, durationSec: 3600), + "a skipped rating must never read as an effortless session") + XCTAssertNil(LiftMetrics.sessionLoad(sessionRpe: 7, durationSec: 0)) + } + + // MARK: - Estimated 1RM (Epley) + + func testEpleyMatchesTheFormula() { + // 100 × (1 + 5/30) = 116.67 + XCTAssertEqual(LiftMetrics.estimatedOneRepMaxKg(weightKg: 100, reps: 5)!, 116.6667, accuracy: 0.001) + } + + func testASingleReturnsTheWeightItself() { + XCTAssertEqual(LiftMetrics.estimatedOneRepMaxKg(weightKg: 140, reps: 1)!, 140, accuracy: 0.001, + "the formula's +3.3% at one rep is an artefact of the fit, not a heavier single") + } + + func testHighRepSetsGetNoEstimate() { + XCTAssertNil(LiftMetrics.estimatedOneRepMaxKg(weightKg: 60, reps: 20), + "a 20-rep set describes endurance, not a maximum") + XCTAssertNotNil(LiftMetrics.estimatedOneRepMaxKg(weightKg: 60, reps: 12), "12 is the ceiling") + } + + func testNoEstimateWithoutBothNumbers() { + XCTAssertNil(LiftMetrics.estimatedOneRepMaxKg(weightKg: nil, reps: 5)) + XCTAssertNil(LiftMetrics.estimatedOneRepMaxKg(weightKg: 100, reps: nil)) + XCTAssertNil(LiftMetrics.estimatedOneRepMaxKg(weightKg: 0, reps: 5)) + } + + // MARK: - Per exercise + + func testExercisesComeBackInTheOrderTheyWerePerformed() { + let s = [set("Squat"), set("Bench press"), set("Squat")] + XCTAssertEqual(LiftMetrics.perExercise(s).map(\.exercise), ["Squat", "Bench press"], + "a session reads back in the order it happened, not alphabetically") + } + + func testBestSetIsRankedByEstimatedOneRepMaxNotRawWeight() { + // 100×5 → 116.7 ; 90×10 → 120.0. The lighter set is the better one. + let summaries = LiftMetrics.perExercise([set(weight: 100, reps: 5), set(weight: 90, reps: 10)]) + XCTAssertEqual(summaries.count, 1) + XCTAssertEqual(summaries[0].bestWeightKg!, 90, accuracy: 0.001) + XCTAssertEqual(summaries[0].bestReps, 10) + XCTAssertEqual(summaries[0].bestEstimatedOneRepMaxKg!, 120, accuracy: 0.001) + } + + func testBestSetFallsBackToHeaviestWhenNoSetSupportsAnEstimate() { + let summaries = LiftMetrics.perExercise([set(weight: 60, reps: 20), set(weight: 70, reps: 25)]) + XCTAssertEqual(summaries[0].bestWeightKg!, 70, accuracy: 0.001) + XCTAssertNil(summaries[0].bestEstimatedOneRepMaxKg, "still no 1RM claim from a 25-rep set") + } + + func testWorkingAndWarmUpSetsAreCountedSeparately() { + let summaries = LiftMetrics.perExercise([ + set(warmup: true), set(warmup: true), set(), set(), set(), + ]) + XCTAssertEqual(summaries[0].workingSets, 3) + XCTAssertEqual(summaries[0].warmupSets, 2) + } + + // MARK: - RPE profile + + func testRpeProfileAveragesOnlyRatedWorkingSets() { + let p = LiftMetrics.rpeProfile([set(rpe: 7), set(rpe: 9), set(rpe: nil)]) + XCTAssertEqual(p.mean!, 8.0, accuracy: 0.001) + XCTAssertEqual(p.ratedSets, 2) + XCTAssertEqual(p.unratedSets, 1, "an unrated set is surfaced, so a thin mean is visibly thin") + } + + func testHardSetsAreCountedAtOrAboveTheThreshold() { + let p = LiftMetrics.rpeProfile([set(rpe: 7.5), set(rpe: 8), set(rpe: 9.5)]) + XCTAssertEqual(p.setsAtOrAboveThreshold, 2, "at the threshold counts, not just above it") + } + + func testAWarmUpNeverEntersTheRpeProfile() { + let p = LiftMetrics.rpeProfile([set(rpe: 3, warmup: true), set(rpe: 9)]) + XCTAssertEqual(p.mean!, 9.0, accuracy: 0.001) + XCTAssertEqual(p.ratedSets, 1) + } + + func testNothingRatedMeansNoMean() { + XCTAssertNil(LiftMetrics.rpeProfile([set(rpe: nil), set(rpe: nil)]).mean) + } + + // MARK: - Sets per muscle + + func testDirectSetsCountOnceAndIndirectSetsCountHalf() { + let s = [set(primary: .chest, secondary: [.triceps, .frontDelts])] + let c = LiftMetrics.muscleCounts(s) + XCTAssertEqual(c.fractional[.chest]!, 1.0, accuracy: 0.001) + XCTAssertEqual(c.fractional[.triceps]!, 0.5, accuracy: 0.001) + XCTAssertEqual(c.fractional[.frontDelts]!, 0.5, accuracy: 0.001) + XCTAssertEqual(c.direct[.chest], 1) + XCTAssertEqual(c.indirect[.triceps], 1) + } + + func testTheCreditsMatchThePublishedMethod() { + // Pinned deliberately: these constants and the reference doses were derived under the SAME + // operationalisation, so changing one without the other silently invalidates the comparison. + XCTAssertEqual(LiftMuscle.directSetCredit, 1.0) + XCTAssertEqual(LiftMuscle.indirectSetCredit, 0.5) + } + + func testAMuscleIsNeverCreditedTwiceForOneSet() { + // A malformed row listing the primary among its secondaries must not double-count. + let s = [set(primary: .chest, secondary: [.chest, .triceps])] + let c = LiftMetrics.muscleCounts(s) + XCTAssertEqual(c.fractional[.chest]!, 1.0, accuracy: 0.001) + XCTAssertNil(c.indirect[.chest]) + } + + func testWarmUpsDoNotCountTowardAnyMuscle() { + let c = LiftMetrics.muscleCounts([set(warmup: true, primary: .chest, secondary: [.triceps])]) + XCTAssertTrue(c.fractional.isEmpty) + } + + func testSetCountsAreNotFilteredByRpe() { + // Load-bearing: the reference doses come from UNFILTERED working-set counts. Filtering to + // "hard" sets would compare a smaller number against a scale built from a larger one. + let c = LiftMetrics.muscleCounts([set(rpe: 5), set(rpe: nil), set(rpe: 10)]) + XCTAssertEqual(c.fractional[.chest]!, 3.0, accuracy: 0.001, + "an easy set and an unrated set still count toward the dose") + } + + func testAnUnclassifiedExerciseClaimsNoMuscle() { + let s = [set(primary: nil, secondary: [])] + XCTAssertTrue(LiftMetrics.muscleCounts(s).fractional.isEmpty) + XCTAssertNotNil(LiftMetrics.volumeLoadKg(s), "but it still counts toward volume") + } + + // MARK: - The reference band + + func testTheReferenceDosesAreTheOnesTheCreditsWereDerivedUnder() { + XCTAssertEqual(LiftMetrics.ReferenceDose.hypertrophyMinimumSetsPerWeek, 4.0) + XCTAssertEqual(LiftMetrics.ReferenceDose.strengthMinimumSetsPerWeek, 1.0) + XCTAssertEqual(LiftMetrics.ReferenceDose.strengthPlateauSetsPerWeek, 4.0) + } + + // MARK: - Sets never performed + + /// A set saved at zero reps was discarded or skipped, and counts nowhere: not as a working set, not + /// in the RPE profile, not toward a muscle. An exercise with only such sets does not appear. + func testASetWithZeroRepsCountsNowhere() { + let sets = [set(weight: 100, reps: 5, rpe: 8, primary: .chest), + set(weight: 0, reps: 0, primary: .chest), + set("Row", weight: 0, reps: 0, primary: .lats)] + XCTAssertEqual(LiftMetrics.perExercise(sets).map(\.exercise), ["Bench press"]) + XCTAssertEqual(LiftMetrics.perExercise(sets).first?.workingSets, 1) + XCTAssertEqual(LiftMetrics.rpeProfile(sets).unratedSets, 0, "a discarded set is not an unrated one") + XCTAssertEqual(LiftMetrics.muscleCounts(sets).direct[.chest], 1) + XCTAssertNil(LiftMetrics.muscleCounts(sets).direct[.lats]) + } + + /// No rep count at all is different: the set was done, the number just was not typed. + func testASetWithNoRepCountStillCounts() { + let sets = [set(weight: nil, reps: nil, primary: .chest)] + XCTAssertTrue(LiftMetrics.isPerformed(reps: sets[0].reps)) + XCTAssertEqual(LiftMetrics.muscleCounts(sets).direct[.chest], 1) + } +} 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..68d23621d2 --- /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(day: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(day: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..deaf960304 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/LocalDayWindowsTests.swift @@ -0,0 +1,463 @@ +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")) + } + + /// Edge coverage for the day-number to `YYYY-MM-DD` conversion, held here because issue #71 retired + /// `TrainingLoadEngine`'s private duplicate of it in favour of this helper. Leap years (including + /// the century rules), negative epoch days, and the round trip back to the day number are exactly + /// what the duplicate covered, so they must keep being covered somewhere. + /// Kotlin twin: `LocalDayWindowsTest.dayNumberKeysCoverLeapYearsNegativeEpochsAndRoundTrips`. + func testDayNumberKeysCoverLeapYearsNegativeEpochsAndRoundTrips() { + let expected: [(Int, String)] = [ + (0, "1970-01-01"), + (-1, "1969-12-31"), + (11_016, "2000-02-29"), // 400-divisible century IS a leap year + (19_782, "2024-02-29"), + (-25_509, "1900-02-28"), // 100-divisible non-400 century is NOT + (-25_508, "1900-03-01"), + (47_540, "2100-02-28"), + (-135_081, "1600-02-29"), // negative epoch day, leap century + (-719_162, "0001-01-01"), // the floor the engine's day parser accepts + (2_932_896, "9999-12-31"), + ] + for (ordinal, key) in expected { + XCTAssertEqual(LocalCalendarDate(daysSinceEpoch: ordinal).key, key, "ordinal \(ordinal)") + } + // Round trip over both signs, stepping by a prime so the sweep does not land on month starts. + for ordinal in stride(from: -719_162, through: 2_932_896, by: 9_973) { + let date = LocalCalendarDate(daysSinceEpoch: ordinal) + XCTAssertEqual(date.daysSinceEpoch, ordinal, "ordinal \(ordinal)") + XCTAssertEqual(self.date(date.key).key, date.key, "ordinal \(ordinal)") + } + } +} 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.. BaselineState { + BaselineState(baseline: mean, spread: sigma / 1.253, nValid: nValid, + nightsSinceUpdate: status == .stale ? 20 : 0, status: status) + } + + private var hrvBase: BaselineState { state(55, 12, .trusted, nValid: 20) } + private var usableRhr: BaselineState { state(52, 3, .provisional, nValid: 5) } + /// What an empty or all-implausible history folds to: the config midpoint, never banked. + private var syntheticRhr: BaselineState { state(75, 6, .calibrating, nValid: 0) } + private var staleRhr: BaselineState { state(52, 3, .stale, nValid: 20) } + + private func score(_ rhrBaseline: BaselineState?) -> Double? { + RecoveryScorer.recovery(hrv: 55, rhr: 62, resp: nil, + hrvBaseline: hrvBase, rhrBaseline: rhrBaseline, + respBaseline: nil, sleepPerf: 0.85) + } + + private func rows(_ rhrBaseline: BaselineState?) -> [ChargeDriver] { + RecoveryScorer.chargeDrivers(hrv: 55, rhr: 62, resp: nil, + hrvBaseline: hrvBase, rhrBaseline: rhrBaseline, + respBaseline: nil, sleepPerf: 0.85) + } + + func testASyntheticRhrBaselineScoresLikeAnAbsentOne() { + XCTAssertEqual(score(syntheticRhr)!, score(nil)!, accuracy: 1e-12) + } + + func testAStaleRhrBaselineScoresLikeAnAbsentOne() { + // `usable` is provisional-or-trusted, so a real personal baseline that has gone stale is dropped + // too. That is the same reading of "usable" the rest of the codebase uses. + XCTAssertEqual(score(staleRhr)!, score(nil)!, accuracy: 1e-12) + } + + func testAUsableRhrBaselineStillContributes() { + // The control: the gate is not a blanket off. A resting HR well above a usable baseline must pull + // the score away from the HRV-only number. + XCTAssertNotEqual(score(usableRhr)!, score(nil)!, accuracy: 1e-9) + } + + func testASyntheticRhrBaselineProducesNoRhrDriverRow() { + let labels = rows(syntheticRhr).map(\.label) + XCTAssertTrue(labels.contains("Heart rate variability"), + "the HRV row must still be present: \(labels)") + XCTAssertFalse(labels.contains("Resting heart rate"), + "no usable resting-HR baseline, so no RHR row: \(labels)") + } + + func testAUsableRhrBaselineProducesItsRow() { + XCTAssertTrue(rows(usableRhr).map(\.label).contains("Resting heart rate")) + } + + /// THE invariant this change exists to keep. `chargeDrivers` documents that its rows are scored + /// "against the identical inputs as the headline number", so the two gates must agree: passing an + /// unusable baseline has to be indistinguishable from passing none, row for row. + func testTheDriverRowsAndTheHeadlineApplyTheSameGate() { + XCTAssertEqual(rows(syntheticRhr), rows(nil)) + XCTAssertEqual(rows(staleRhr), rows(nil)) + } + + /// The trace is the THIRD place that reads this baseline directly, for its own + /// `charge baseline rhr` line, its rhrZ and the saturation guard. Its own contract is that the score + /// it reports is the dashboard's "verbatim, so the trace cannot diverge from it", so an unusable + /// baseline has to drop the rhr TERM there too. Without the gate the trace would name a term the + /// score never used, which is the one thing a trace must never do. + func testTheTraceDropsTheRhrTermForAnUnusableBaseline() { + let (traced, lines) = RecoveryScorer.recoveryTrace( + hrv: 55, rhr: 62, resp: nil, + hrvBaseline: hrvBase, rhrBaseline: syntheticRhr, respBaseline: nil, sleepPerf: 0.85) + XCTAssertEqual(traced!, score(syntheticRhr)!, accuracy: 1e-12) + XCTAssertFalse(lines.contains { $0.contains("charge term rhr ") }, + "the trace must not name an rhr term the score did not use: \(lines)") + XCTAssertTrue(lines.contains { $0.contains("nilTerm dropped=") && $0.contains("rhr") }, + "rhr must be reported as a dropped term: \(lines)") + XCTAssertFalse(lines.contains { $0.contains("charge baseline rhr ") }, + "no baseline line for a baseline that was not used: \(lines)") + } + + /// Control: a usable baseline still produces the trace's rhr term and its baseline line. + func testTheTraceKeepsTheRhrTermForAUsableBaseline() { + let (_, lines) = RecoveryScorer.recoveryTrace( + hrv: 55, rhr: 62, resp: nil, + hrvBaseline: hrvBase, rhrBaseline: usableRhr, respBaseline: nil, sleepPerf: 0.85) + XCTAssertTrue(lines.contains { $0.contains("charge term rhr ") }) + XCTAssertTrue(lines.contains { $0.contains("charge baseline rhr ") }) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/RecoveryScorerTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/RecoveryScorerTests.swift index 91fe6882ef..c92264f3c4 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/RecoveryScorerTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/RecoveryScorerTests.swift @@ -162,128 +162,6 @@ final class RecoveryScorerTests: XCTestCase { XCTAssertEqual(RecoveryScorer.band(90), "green") } - func testRestingHRLowestRollingMean() { - // Two 5-min blocks: first averages 60, second averages 50 → resting = 50. - var hr: [HRSample] = [] - let start = 1000 - for i in 0..<300 { hr.append(HRSample(ts: start + i, bpm: 60)) } // 0..299 s - for i in 0..<300 { hr.append(HRSample(ts: start + 300 + i, bpm: 50)) } // 300..599 s - let r = RecoveryScorer.restingHR(hr, start: start, end: start + 600) - XCTAssertEqual(r, 50) - } - - func testRestingHRNilWhenNoSamples() { - XCTAssertNil(RecoveryScorer.restingHR([], start: 0, end: 1000)) - } - - // MARK: - #686: artifact hardening of the resting-HR floor - - func testRestingHRRejectsSingleSampleArtifactBin() { - // A dense, well-populated bin at 55 bpm, then a SECOND 5-min bin holding exactly ONE - // artifact beat at 30 bpm. The old min-of-bin-means took the lone-sample bin (30) as the - // floor; with #686 a single-sample bin can't WIN, so the floor is the real 55. - var hr: [HRSample] = [] - let start = 1000 - for i in 0..<300 { hr.append(HRSample(ts: start + i, bpm: 55)) } // bin 0: 300 samples @55 - hr.append(HRSample(ts: start + 300, bpm: 30)) // bin 1: ONE sample @30 - let r = RecoveryScorer.restingHR(hr, start: start, end: start + 600) - XCTAssertEqual(r, 55, "a single-sample artifact bin must not win the resting floor") - } - - func testRestingHRRejectsSubPhysiologicalDropoutBin() { - // Two FULLY-populated bins: a real 52 bpm bin and a dropout bin whose 300 samples all read - // an implausible 10 bpm (decode-zero / dropout run). It clears the sample-count bar but is - // sub-physiological, so #686 bars it from the floor → resting reads the real 52. - var hr: [HRSample] = [] - let start = 2000 - for i in 0..<300 { hr.append(HRSample(ts: start + i, bpm: 52)) } // real bin - for i in 0..<300 { hr.append(HRSample(ts: start + 300 + i, bpm: 10)) } // dropout bin - let r = RecoveryScorer.restingHR(hr, start: start, end: start + 600) - XCTAssertEqual(r, 52, "a sub-physiological dropout bin must not win the resting floor") - } - - func testRestingHRKeepsGenuineLowFloor() { - // A REAL sustained dip (a full 5-min bin at 45 bpm) is plausible AND well-populated, so it - // still wins — the hardening must not flatten genuine athletic resting HRs. - var hr: [HRSample] = [] - let start = 3000 - 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: 45)) } - let r = RecoveryScorer.restingHR(hr, start: start, end: start + 600) - XCTAssertEqual(r, 45, "a genuine sustained low bin must still win the floor") - } - - func testRestingHRFallsBackWhenNoBinQualifies() { - // A wholly sparse window: every bin holds a single sample (none clears the count bar). - // Rather than return nil on data present, fall back to the legacy lowest-bin-mean (here 48). - let start = 4000 - let hr = [HRSample(ts: start + 10, bpm: 58), - HRSample(ts: start + 320, bpm: 48)] // two bins, one sample each - let r = RecoveryScorer.restingHR(hr, start: start, end: start + 600) - XCTAssertEqual(r, 48, "with no qualifying bin, fall back to the lowest bin mean (never nil on data)") - } - - // MARK: - Recovery Index: recoveryIndexSlope(_:start:end:) - - /// Build a synthetic in-bed HR series with a constant slope (bpm/hour) from `startBpm`, - /// sampled every 30 s (a low-cadence strap) over `hours`. Returns the series plus its - /// [start, end] window. - private func slopeSeries(startBpm: Double, slopePerHour: Double, hours: Double, - originTs: Int = 10_000) -> (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() { @@ -397,4 +275,70 @@ final class RecoveryScorerTests: XCTestCase { XCTAssertEqual(neither, baselineOnly, accuracy: 1e-9, "a baseline with no value must drop the term") XCTAssertNotEqual(both, neither, "supplying BOTH must actually change the score") } + + // MARK: - Required HRV baseline on the raw DriverBaseline overload (#40) + + func testRawOverloadRefusesToScoreWithoutHRVBaseline() { + // The raw overload documents hrvBaseline as REQUIRED, but it is Optional and the + // cold-start gate only consults hrvBaselineUsable (default true). Without this guard any + // other optional term alone produced a Charge score carrying NO HRV term at all. Each + // case below supplies exactly one optional driver with the HRV baseline absent. + let rhrB = RecoveryScorer.DriverBaseline(mean: 55, spread: 3 / 1.253) + let respB = RecoveryScorer.DriverBaseline(mean: 14.5, spread: 1 / 1.253) + let effortB = RecoveryScorer.DriverBaseline(mean: 40, spread: 15 / 1.253) + + // The issue's minimal reproduction: sleepPerf as the only weighted term. + XCTAssertNil(RecoveryScorer.recovery( + hrv: 50, rhr: 60, resp: nil, + hrvBaseline: nil, rhrBaseline: nil, respBaseline: nil, + sleepPerf: 0.85)) + // RHR term alone. + XCTAssertNil(RecoveryScorer.recovery( + hrv: 50, rhr: 60, resp: nil, + hrvBaseline: nil, rhrBaseline: rhrB, respBaseline: nil, + sleepPerf: nil)) + // Resp term alone. + XCTAssertNil(RecoveryScorer.recovery( + hrv: 50, rhr: 60, resp: 14.0, + hrvBaseline: nil, rhrBaseline: nil, respBaseline: respB, + sleepPerf: nil)) + // Skin-temp term alone. + XCTAssertNil(RecoveryScorer.recovery( + hrv: 50, rhr: 60, resp: nil, + hrvBaseline: nil, rhrBaseline: nil, respBaseline: nil, + sleepPerf: nil, skinTempDev: 0.4)) + // Recovery-Index term alone. + XCTAssertNil(RecoveryScorer.recovery( + hrv: 50, rhr: 60, resp: nil, + hrvBaseline: nil, rhrBaseline: nil, respBaseline: nil, + sleepPerf: nil, recoveryIndexSlope: -1.0)) + // Activity-Balance term alone. + XCTAssertNil(RecoveryScorer.recovery( + hrv: 50, rhr: 60, resp: nil, + hrvBaseline: nil, rhrBaseline: nil, respBaseline: nil, + sleepPerf: nil, effortBaseline: effortB, priorDayEffort: 80.0)) + // Every optional driver at once still cannot substitute for the required HRV baseline. + XCTAssertNil(RecoveryScorer.recovery( + hrv: 50, rhr: 60, resp: 14.0, + hrvBaseline: nil, rhrBaseline: rhrB, respBaseline: respB, + sleepPerf: 0.85, skinTempDev: 0.4, recoveryIndexSlope: -1.0, + effortBaseline: effortB, priorDayEffort: 80.0)) + } + + func testRawOverloadPreservesColdStartAndValidScores() { + let hrvB = RecoveryScorer.DriverBaseline(mean: 50, spread: 6 / 1.253) + // Cold start unchanged: baseline PRESENT but not usable → nil, as before. + XCTAssertNil(RecoveryScorer.recovery( + hrv: 50, rhr: 60, resp: nil, + hrvBaseline: hrvB, rhrBaseline: nil, respBaseline: nil, + sleepPerf: 0.85, hrvBaselineUsable: false)) + // A present, usable baseline scores exactly as before. Expected literal from the + // standalone Swift oracle whose source is kept in the Kotlin twin's comment + // (RecoveryRequiredHrvBaselineTest.kt) — the same literal both sides pin. + let scored = RecoveryScorer.recovery( + hrv: 50, rhr: 60, resp: nil, + hrvBaseline: hrvB, rhrBaseline: nil, respBaseline: nil, + sleepPerf: 0.85) + XCTAssertEqual(scored!, 57.932425214874954, accuracy: 1e-12) + } } diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/RecoveryScorerTraceTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/RecoveryScorerTraceTests.swift index 12e0a304db..9f389d55f8 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/RecoveryScorerTraceTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/RecoveryScorerTraceTests.swift @@ -151,4 +151,61 @@ final class RecoveryScorerTraceTests: XCTestCase { "charge term skinTempDev z=-0.12 w=0.05 (dev=0.12C penalty=-|dev|/1.0)" ) } + + /// #47: the two-decimal trace-rounding contract itself, pinned as RAW BITS rather than as text. + /// Text would hide what this pins: Swift and Kotlin print the same Double differently ("1e+20" vs + /// "1.0E20"), and -0.0 vs 0.0 is a one-bit difference. This side is the oracle — the Kotlin twin + /// carries these same bit patterns as literals, so a change here without a change there fails over + /// there. Twin: `traceRound2MatchesTheSwiftContract`. + func testTraceRound2MatchesTheSwiftContract() { + let cases: [(String, Double, String)] = [ + ("-0.004", -0.004, "8000000000000000"), // -0.0: sign survives rounding to zero + ("0.004", 0.004, "0"), + ("1e20", 1e20, "4415af1d78b58c40"), // large finite value, not a signed-64 ceiling + ("-1e20", -1e20, "c415af1d78b58c40"), + ("0.0", 0.0, "0"), + ("-0.0", -0.0, "8000000000000000"), + ("0.125", 0.125, "3fc0a3d70a3d70a4"), // positive half-tie -> away from zero (0.13) + ("-0.125", -0.125, "bfc0a3d70a3d70a4"), // negative half-tie -> -0.13 + ("1.2349", 1.2349, "3ff3ae147ae147ae"), // ordinary non-ties + ("-1.2349", -1.2349, "bff3ae147ae147ae"), + // Just below the half-tie: a floor(m + 0.5) implementation would round this UP. + ("0.0049999999999999994", 0.0049999999999999994, "0"), + ("-0.0049999999999999994", -0.0049999999999999994, "8000000000000000"), + ("1e306", 1e306, "7f76c8e5ca239029"), // largest magnitudes whose *100 is still finite + ("-1e306", -1e306, "ff76c8e5ca239029"), + // Finite input whose intermediate x * 100 overflows: both platforms yield an infinity. + ("1e307", 1e307, "7ff0000000000000"), + ("-1e307", -1e307, "fff0000000000000"), + ] + for (label, input, bits) in cases { + XCTAssertEqual(String(RecoveryScorer.traceRound2(input).bitPattern, radix: 16), bits, + "traceRound2(\(label))") + } + } + + /// #47: the exact differential fixture. Every line of one full-term night, pinned verbatim, so a + /// rounding change on either platform shows up as a text diff rather than as two field logs that + /// quietly disagree. The skin-temp deviation is 0.004, the issue's near-zero negative: it must + /// render `z=-0.0` and `dev=0.0C`. Twin: `traceFixtureIsByteIdenticalAcrossPlatforms`. + func testTraceFixtureIsByteIdenticalAcrossPlatforms() { + let (_, lines) = RecoveryScorer.recoveryTrace( + hrv: 62, rhr: 51, resp: 15, + hrvBaseline: baseline(mean: 50, sigma: 6), rhrBaseline: baseline(mean: 55, sigma: 3), + respBaseline: baseline(mean: 16, sigma: 2), + sleepPerf: 0.9, skinTempDev: 0.004) + XCTAssertEqual(lines, [ + "charge baseline hrv mean=50.0 spread=4.79 nValid=14 status=trusted", + "charge baseline rhr mean=55.0 spread=2.39 nValid=14 status=trusted", + "charge baseline resp mean=16.0 spread=1.6 nValid=14 status=trusted", + "charge term hrv z=2.0 w=0.55 (higher HRV is better)", + "charge term rhr z=1.33 w=0.2 (lower RHR is better)", + "charge term resp z=0.5 w=0.05 (lower resp is better)", + "charge term sleepPerf z=0.42 w=0.15 (rest=0.9 center=0.85)", + "charge term skinTempDev z=-0.0 w=0.05 (dev=0.0C penalty=-|dev|/1.0)", + "charge nilTerm dropped=[] (each dropped term renormalizes the remaining weights)", + "charge renorm totalWeight=1.0 compositeZ=1.45 (z = sum(z*w)/sum(w))", + "charge score=93.38 band=green (logistic k=1.6 z0=-0.2)", + ]) + } } 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/SleepFragmentedNightTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepFragmentedNightTests.swift new file mode 100644 index 0000000000..0f28c9b4af --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepFragmentedNightTests.swift @@ -0,0 +1,143 @@ +import XCTest +@testable import StrandAnalytics +import WhoopProtocol + +/// Swift twin of `SleepFragmentedNightTest`. +/// +/// #1937: a night whose sleep runs are ALL under `minSleepMin` produced no session at all, however much +/// sleep they added up to. The capture that found it: `sleepRuns=5 droppedMinSleep=5 kept=0 +/// detectedSpanMin=298 survivingSpanMin=0 sparse=false` — five hours of detected sleep, five runs +/// averaging 59.6 minutes, every one a minute short, and the night vanished. The rescue that exists for +/// exactly this was gated on the night being SPARSE, and the night was dense. +final class SleepFragmentedNightTests: XCTestCase { + + private let minSleepS = 60 * 60 + + private func mins(_ m: Int...) -> [Int] { m.map { $0 * 60 } } + + /// The reported shape: 298 minutes of detected sleep across five runs, none of them clearing the + /// floor. Note this night CANNOT be written in whole minutes: five runs each at most 59 min sum to + /// 295. Runs are second-granular, and 298/5 = 59.6 min, so each one falls 24 seconds short. + func testTheReportedFiveFragmentNightQualifies() { + let runs = [Int](repeating: 3576, count: 5) // 59.6 min each + XCTAssertEqual(runs.reduce(0, +) / 60, 298) // the reported detectedSpanMin + XCTAssertTrue(runs.allSatisfy { $0 < minSleepS }) // and every one is dropped by the floor + XCTAssertTrue(SleepStager.isFragmentedToNothing(runs, minSleepS: minSleepS)) + } + + /// THE safety property. A night where any single run already clears the floor keeps at least one + /// session today, so the predicate must decline and leave the sparse rule to decide exactly as + /// before. Without this the change could alter a night that currently scores. + func testANightWithOneQualifyingRunIsNeverTouched() { + XCTAssertFalse(SleepStager.isFragmentedToNothing(mins(90, 20, 15), minSleepS: minSleepS)) + XCTAssertFalse(SleepStager.isFragmentedToNothing(mins(60, 20, 15), minSleepS: minSleepS)) + XCTAssertFalse(SleepStager.isFragmentedToNothing(mins(20, 15, 90), minSleepS: minSleepS)) + } + + /// A handful of brief stirs is not a night. The SUM has to clear the floor too. + func testBriefStirsDoNotBecomeANight() { + XCTAssertFalse(SleepStager.isFragmentedToNothing(mins(5, 8, 12), minSleepS: minSleepS)) + XCTAssertFalse(SleepStager.isFragmentedToNothing(mins(30, 29), minSleepS: minSleepS)) + XCTAssertTrue(SleepStager.isFragmentedToNothing(mins(30, 30), minSleepS: minSleepS)) + } + + /// One fragment is not a fragmented night; there is nothing to bridge it to. + func testASingleShortRunIsNotAFragmentedNight() { + XCTAssertFalse(SleepStager.isFragmentedToNothing(mins(59), minSleepS: minSleepS)) + XCTAssertFalse(SleepStager.isFragmentedToNothing([], minSleepS: minSleepS)) + } + + /// The predicate only ever opens the bridge; the bridge's own rules still decide. Stated here + /// because the predicate's name suggests more authority than it has. + func testThePredicateEnablesAnAttemptRatherThanGrantingIt() { + XCTAssertTrue(SleepStager.isFragmentedToNothing(mins(40, 40), minSleepS: minSleepS)) + let far = SleepStager.bridgeSparseSleepTraced( + [SleepStager.Period(stage: "sleep", start: 0, end: 40 * 60), + SleepStager.Period(stage: "sleep", start: 40 * 60 + 10 * 3600, + end: 40 * 60 + 10 * 3600 + 40 * 60)], + sparse: true, hr: [], baseline: nil) + XCTAssertEqual(far.0.filter { $0.stage == "sleep" }.count, 2, + "a gap of hours must not be bridged") + } + + // MARK: - end-to-end: the reported night through detectSleep + + private let refMidnight = 1_749_513_600 // 2025-06-10 00:00:00 UTC + private func at(_ min: Int) -> Int { refMidnight + min * 60 } + + /// Still gravity at 1/min: continuous, so the night reads DENSE, which is the point of #1937. + private func stillG(_ fromMin: Int, _ toMin: Int) -> [GravitySample] { + stride(from: at(fromMin), to: at(toMin), by: 60).map { GravitySample(ts: $0, x: 0, y: 0, z: 1.0) } + } + + /// Moving gravity at 1/min: orientation swings every sample, so this reads as an active run. + private func movingG(_ fromMin: Int, _ toMin: Int) -> [GravitySample] { + stride(from: at(fromMin), to: at(toMin), 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(_ fromMin: Int, _ toMin: Int, _ bpm: Int) -> [HRSample] { + (at(fromMin) ..< at(toMin)).map { HRSample(ts: $0, bpm: bpm) } + } + + /// The reported night end to end: five sleep runs of 59 minutes, each separated by a 20-minute + /// stir, gravity continuous throughout so the night is DENSE. Every run is under the 60-minute + /// floor, so before this change the night scored nothing at all. + /// + /// Five runs means the bridge has to chain FOUR consecutive absorptions, which is the part the pure + /// predicate tests cannot reach. + /// + /// The stir length is load-bearing at BOTH ends and a first attempt at 3 minutes made this test pass + /// for the wrong reason. `mergePeriods` absorbs any run under `mergeMin` (15) and rejoins the + /// same-stage neighbours, so short stirs never fragmented the night: it arrived as ONE 306-minute + /// run that always scored, and the rescue under test never ran. The stir must therefore be at least + /// mergeMin to survive the merge, and at most sparseBridgeActiveMaxInBandMin (60, since HR stays in + /// the sleep band) for the bridge to absorb it. + private func fragmentedDenseNight() -> ([HRSample], [GravitySample]) { + var g: [GravitySample] = [] + var t = 0 + for i in 0 ..< 5 { + g += stillG(t, t + 59) + t += 59 + if i < 4 { g += movingG(t, t + 20); t += 20 } + } + return (hr1Hz(0, t, 50), g) + } + + func testTheFragmentedDenseNightIsRescuedEndToEnd() { + let (hr, grav) = fragmentedDenseNight() + XCTAssertFalse(SleepStager.isGravitySparse(grav, hr: hr), + "the fixture must be DENSE, or the old sparse gate would already rescue it") + + var lines: [String] = [] + let sessions = SleepStager.detectSleep(hr: hr, gravity: grav, traceSink: { lines.append($0) }) + + XCTAssertFalse(sessions.isEmpty, + "a night of five 59-minute runs must now produce a session, got none.\n" + + lines.joined(separator: "\n")) + let spanMin = sessions.reduce(0) { $0 + ($1.end - $1.start) / 60 } + XCTAssertGreaterThanOrEqual(spanMin, 240, "expected most of the 295 sleeping minutes, got \(spanMin)") + // Anti-vacuity: the session must exist BECAUSE the rescue fired. Without this the test passes + // when the fixture is not actually fragmented, which is exactly what a 3-minute stir did. + XCTAssertTrue(lines.contains { $0.contains("gate=sparseBridge ") && $0.contains("fragmentedToNothing=true") }, + "the session must come from the #1937 rescue, not an unfragmented fixture.\n" + + lines.joined(separator: "\n")) + } + + /// The trace must name BOTH gates, so a dense rescue cannot contradict the summary line beside it. + /// + /// The contiguous substring pins key ORDER and spacing, not just presence. Nothing in the tree + /// compares the two languages' trace output automatically, so a format change on one side would + /// otherwise diverge in silence; the Kotlin twin asserts this identical fragment. + func testTheTraceNamesBothGates() { + let (hr, grav) = fragmentedDenseNight() + var lines: [String] = [] + _ = SleepStager.detectSleep(hr: hr, gravity: grav, traceSink: { lines.append($0) }) + let bridge = lines.first { $0.contains("gate=sparseBridge ") } + XCTAssertNotNil(bridge, "expected a sparseBridge line, got:\n" + lines.joined(separator: "\n")) + XCTAssertTrue(bridge!.contains("sparse=false fragmentedToNothing=true gapMin="), + "unexpected sparseBridge detail format: \(bridge!)") + } +} 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..8c3d2685dd --- /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(day: "2026-09-23", 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(day: "2026-09-23", 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(day: "2026-09-23", 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..002b8651e2 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerHrOnlyTraceTests.swift @@ -0,0 +1,80 @@ +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(day: "2026-09-23", 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 day=2026-09-23 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(day: "2026-09-23", 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(day: "2026-09-23", 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])") + } + + /// #2397: the night this line is about. A re-score emits one per night, twenty-one inside two + /// seconds in the field log that prompted this, and every other number here describes a night that + /// the line itself could not name. + func testTheLineNamesItsNight() { + var lines: [String] = [] + let hr = (0..<600).map { HRSample(ts: 1_000_000 + $0 * 10, bpm: 70) } + _ = SleepStager.hrOnlySessions(day: "2026-09-23", hr: hr, rr: [], resp: [], + traceSink: { lines.append($0) }) + XCTAssertEqual(lines.count, 1) + XCTAssertTrue(lines[0].hasPrefix("[sleep] hr-only spine day=2026-09-23 "), "got: \(lines[0])") + } + + /// The gate line travels with the spine line, so it carries the same attribution. + func testTheGateLineNamesItsNightToo() { + XCTAssertEqual( + SleepStager.GateTrace.hrOnlyGateLine(day: "2026-09-23", attempted: true, + reason: "no-motion-no-hypnogram", + gravRows: 0, storedNights: 0), + "[sleep] hr-only gate day=2026-09-23 attempted=true reason=no-motion-no-hypnogram " + + "grav=0 stored=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(day: "2026-09-23", 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..92b6865010 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerTests.swift @@ -859,6 +859,182 @@ 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 25 beats exactly on an aligned end = 600 (enough to clear + // the `minBeats` floor). The second window is the final one, so it closes on `end` and holds them; + // 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..<25).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, 25], + "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..<25).map { _ in RRInterval(ts: 1000, rrMs: 900) } + XCTAssertEqual(SleepStager.sessionAvgHRV(start: 1000, end: 1000, rr: rr), 0.0) + } + + func testSessionHrvWindowNeedsMinBeatsCleanIntervals() { + // A window emits an RMSSD only with `HRVAnalyzer.minBeats` (20) clean intervals: 19 is nil, 20 is + // a value. Before the floor a window needed just 2, and weighed as much as a full one in the mean. + func window(_ n: Int) -> Double? { + let rr = (0.. [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)) + } + + func testDisabledReuseSelectsSourceAcrossTheWholeRequestedWindow() async { + for historySecond in [250, 50] { + let direct: (Int, Int) -> [Int] = { from, to in + if (from...to).contains(historySecond) { return [historySecond] } + return Array(from...to).filter { $0 % 10 == 0 } + } + let w = SlidingStreamWindow(tsOf: { $0 }, limit: 1000) { _, f, t in direct(f, t) } + var expectedRowsRead = 0 + for (from, to) in [(100, 300), (0, 200), (0, 300)] { + let expected = direct(from, to) + let actual = await w.rows(owner: "whoop5", from: from, to: to, allowReuse: false) + XCTAssertEqual(actual, expected) + expectedRowsRead += expected.count + } + XCTAssertEqual(w.rowsRead, expectedRowsRead) + XCTAssertEqual(w.rowsServed, 0) + } + } +} 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/Spo2RawNightlyTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2RawNightlyTests.swift new file mode 100644 index 0000000000..4534934419 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2RawNightlyTests.swift @@ -0,0 +1,73 @@ +import XCTest +import WhoopProtocol +import WhoopStore +@testable import StrandAnalytics + +/// `nightlySpo2RawMeans` — the WHOOP 4.0 raw red/IR ADC means behind the Health "Raw SpO₂" tile (#93). +/// Byte-parity twin of the Kotlin `NightlySpo2RawTest`. +/// +/// The same `spo2Sample` table carries the Oura ring's single-channel rows (`red` = the ring's reading, +/// `ir = 0`). Those used to be averaged in, so a ring night reported an IR mean of 0 beside a red mean of +/// ~97 and the tile's `(red + ir) / 2` showed a ~97 % reading as "~49 ADC". Only two-channel rows count. +final class Spo2RawNightlyTests: XCTestCase { + + private func session(_ start: Int, _ end: Int) -> SleepSession { + SleepSession(start: start, end: end, efficiency: 0.9, stages: [], restingHR: 55, avgHRV: 60) + } + + private func whoop(_ ts: Int, red: Int, ir: Int) -> SpO2Sample { + SpO2Sample(ts: ts, red: red, ir: ir, unit: "raw_adc") + } + + /// An Oura row exactly as `OuraStreamMapping` writes it. + private func ring(_ ts: Int, _ value: Int) -> SpO2Sample { SpO2Sample(ts: ts, red: value, ir: 0, unit: "raw") } + + func testInWindowTwoChannelSamplesAverageRedAndIrSeparately() { + let r = AnalyticsEngine.nightlySpo2RawMeans( + [session(1000, 2000)], + spo2: [whoop(1100, red: 30000, ir: 20000), whoop(1500, red: 32000, ir: 24000)]) + XCTAssertEqual(r?.red, 31000) + XCTAssertEqual(r?.ir, 22000) + } + + func testRingOnlyNightHasNoRawMeans() { + let r = AnalyticsEngine.nightlySpo2RawMeans( + [session(1000, 2000)], spo2: [ring(1100, 97), ring(1200, 98), ring(1300, 96)]) + XCTAssertNil(r, "a ring has no red/IR ADC pair, so there is no raw mean to report") + } + + /// A legacy pre-#2152 `dc_raw` row (stored with ir = 0) is single-channel too, so it cannot inflate + /// the mean either. + func testLegacyPerfusionRowsAreExcluded() { + XCTAssertNil(AnalyticsEngine.nightlySpo2RawMeans([session(1000, 2000)], spo2: [ring(1100, 11_709_098)])) + } + + func testSingleChannelRowsDoNotDiluteTwoChannelMeans() { + let r = AnalyticsEngine.nightlySpo2RawMeans( + [session(1000, 2000)], + spo2: [whoop(1100, red: 100, ir: 200), ring(1200, 97), whoop(1300, red: 300, ir: 400)]) + XCTAssertEqual(r?.red, 200, "(100 + 300) / 2 — the ring row must not count") + XCTAssertEqual(r?.ir, 300) + } + + /// The ring's SpO2 is not lost by this: the same rows still feed its own nightly path. + func testRingRowsStillReachTheCeilingMean() { + let rows = [ring(1100, 97), ring(1200, 99)] + XCTAssertNil(AnalyticsEngine.nightlySpo2RawMeans([session(1000, 2000)], spo2: rows)) + XCTAssertEqual(AnalyticsEngine.nightlySpo2CeilingMean([session(1000, 2000)], spo2: rows)?.mean, 98) + } + + func testBoundariesInclusiveAndOutOfWindowDropped() { + let r = AnalyticsEngine.nightlySpo2RawMeans( + [session(1000, 2000)], + spo2: [whoop(999, red: 9, ir: 9), whoop(1000, red: 100, ir: 200), + whoop(2000, red: 300, ir: 400), whoop(2001, red: 9, ir: 9)]) + XCTAssertEqual(r?.red, 200) + XCTAssertEqual(r?.ir, 300) + } + + func testEmptyInputsReturnNil() { + XCTAssertNil(AnalyticsEngine.nightlySpo2RawMeans([], spo2: [whoop(100, red: 1, ir: 1)])) + XCTAssertNil(AnalyticsEngine.nightlySpo2RawMeans([session(0, 1000)], spo2: [])) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2ReTraceTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2ReTraceTests.swift index 9606c0cefd..a4f4bfec20 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2ReTraceTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/Spo2ReTraceTests.swift @@ -31,7 +31,20 @@ final class Spo2ReTraceTests: XCTestCase { XCTAssertTrue(line.contains("v=null"), line) } - func testSampleCapBoundedAtEight() { - XCTAssertEqual(Spo2ReTrace.maxSamples, 8) + func testSampleCapBounded() { + XCTAssertEqual(Spo2ReTrace.maxSamples, 12) + } + + /// The per-layout cap is what stops one dominant layout spending the whole session budget, and it + /// only does that if it is strictly smaller than the session cap. + func testPerVersionCapIsBoundedAndSmallerThanTheSessionCap() { + XCTAssertEqual(Spo2ReTrace.maxPerVersion, 3) + XCTAssertLessThan(Spo2ReTrace.maxPerVersion, Spo2ReTrace.maxSamples) + } + + /// The session cap has to leave room for more than one layout, or stratifying changes nothing: + /// a 5/MG emits v18 alongside v20/v21/v26 and every one of them needs samples. + func testSessionCapCoversSeveralDistinctLayouts() { + XCTAssertGreaterThanOrEqual(Spo2ReTrace.maxSamples / Spo2ReTrace.maxPerVersion, 4) } } 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/StandardHRHostReceivedTraceTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StandardHRHostReceivedTraceTests.swift new file mode 100644 index 0000000000..d0d2576eac --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StandardHRHostReceivedTraceTests.swift @@ -0,0 +1,165 @@ +import XCTest +@testable import StrandAnalytics + +/// What a strap log keeps about the standard-HR transport when no Test Centre mode is on. +/// +/// The per-sample line was 52.8% of one gym session's log (3,009 of 5,704 lines on 22 Sep 2026), which is what +/// decides how much history the on-disk log (#2386) can hold. These tests pin what survives that: every refusal +/// at the moment it happens, and one summary a window carrying the counts, the span and the widest gap. +final class StandardHRHostReceivedTraceTests: XCTestCase { + + private typealias Trace = LivePersistTrace.StandardHRHostReceivedTrace + + /// A routine sample: one HR row, two R-R rows, nothing refused. + private func routine(at second: Int, pendingHR: Int = 1, pendingRR: Int = 2) -> Trace.Sample { + Trace.Sample(hostUnixSeconds: second, acceptedHRRows: 1, acceptedRRRows: 2, + rejectedHRRows: 0, rejectedRRRows: 0, + pendingHRRows: pendingHR, pendingRRRows: pendingRR) + } + + /// THE ONE THAT MATTERS: a streaming strap writes one line a minute, not one a second, and that line + /// carries what the sixty said. + func testAQuietMinuteBecomesOneLineThatCarriesTheMinute() { + var trace = Trace() + var lines: [String] = [] + for second in 0...60 { lines += trace.record(routine(at: 1_790_000_000 + second), detailed: false) } + XCTAssertEqual(lines, [ + "standard-hr transport host-received summary windowSec=59 samples=60 gapMaxSec=1" + + " acceptedHRRows=60 acceptedRRRows=120 rejectedHRRows=0 rejectedRRRows=0" + + " pendingHRRows=1 pendingRRRows=2", + ]) + } + + /// The reason the per-sample line was added: a refusal is written the moment it happens, mode or no mode, + /// and is still counted in the window. + func testARefusedSampleIsWrittenAtOnceAndCounted() { + var trace = Trace() + var lines: [String] = [] + for second in 0..<30 { lines += trace.record(routine(at: second), detailed: false) } + lines += trace.record(Trace.Sample(hostUnixSeconds: 30, acceptedHRRows: 0, acceptedRRRows: 1, + rejectedHRRows: 1, rejectedRRRows: 2, + pendingHRRows: 3, pendingRRRows: 4), detailed: false) + XCTAssertEqual(lines, [ + "standard-hr transport host-received hostUnixSec=30 acceptedHRRows=0 acceptedRRRows=1" + + " rejectedHRRows=1 rejectedRRRows=2 pendingHRRows=3 pendingRRRows=4", + ]) + XCTAssertEqual(trace.close(), [ + "standard-hr transport host-received summary windowSec=30 samples=31 gapMaxSec=1" + + " acceptedHRRows=30 acceptedRRRows=61 rejectedHRRows=1 rejectedRRRows=2" + + " pendingHRRows=3 pendingRRRows=4", + ]) + } + + /// A gap is the stall a reader used to have to find by eye through sixty timestamps. + func testTheWidestGapIsReported() { + var trace = Trace() + for second in [0, 1, 2, 14, 15] { _ = trace.record(routine(at: second), detailed: false) } + XCTAssertEqual(trace.close(), [ + "standard-hr transport host-received summary windowSec=15 samples=5 gapMaxSec=12" + + " acceptedHRRows=5 acceptedRRRows=10 rejectedHRRows=0 rejectedRRRows=0" + + " pendingHRRows=1 pendingRRRows=2", + ]) + } + + /// With a Test Centre mode on, the per-sample readout is back in full — and the window still lands, so the + /// two modes describe the same stream. + func testDetailWritesEverySampleAndStillSummarises() { + var trace = Trace() + var lines: [String] = [] + for second in 0...60 { lines += trace.record(routine(at: second), detailed: true) } + XCTAssertEqual(lines.filter { $0.contains("hostUnixSec=") }.count, 61) + XCTAssertEqual(lines.filter { $0.contains("summary") }.count, 1) + } + + /// Closing is what a disconnect, a background flush or a termination does: the last, partial window is the + /// part a report is usually taken for. + func testClosingWritesThePartialWindowOnceAndThenNothing() { + var trace = Trace() + for second in 0..<5 { _ = trace.record(routine(at: second), detailed: false) } + XCTAssertEqual(trace.close().count, 1) + XCTAssertEqual(trace.close(), []) + } + + /// A span is only honest within one clock: a host clock that jumps backwards ends the window instead of + /// rendering a negative one. + func testAClockGoingBackwardsEndsTheWindow() { + var trace = Trace() + _ = trace.record(routine(at: 1_000), detailed: false) + let lines = trace.record(routine(at: 900), detailed: false) + XCTAssertEqual(lines.count, 1) + XCTAssertTrue(lines[0].hasPrefix("standard-hr transport host-received summary windowSec=0 samples=1"), lines[0]) + XCTAssertEqual(trace.close().first?.contains("samples=1"), true) + } + + /// The size of the thing: an hour of a streaming strap. + func testAnHourOfStreamingIsSixtyLines() { + var trace = Trace() + var lines = 0 + for second in 0..<3_600 { lines += trace.record(routine(at: second), detailed: false).count } + lines += trace.close().count + XCTAssertEqual(lines, 60, "3,600 per-sample lines an hour become one a minute") + } + + /// The exact pair of lines the Kotlin `ORACLE_QUIET` claims this trace renders. + /// + /// #2405: that oracle is Swift's output pasted into a Kotlin test, and nothing on this side asserted + /// it, so the two could drift without a failure anywhere. The second line is the one that moved when + /// the boundary gap started being carried: a window that opens one second after the last sample of + /// the previous one reports exactly that. + func testTheQuietMinutePairMatchesTheKotlinOracle() { + var trace = Trace() + var lines: [String] = [] + for second in 0...60 { lines += trace.record(routine(at: second), detailed: false) } + lines += trace.close() + XCTAssertEqual(lines, [ + "standard-hr transport host-received summary windowSec=59 samples=60 gapMaxSec=1" + + " acceptedHRRows=60 acceptedRRRows=120 rejectedHRRows=0 rejectedRRRows=0" + + " pendingHRRows=1 pendingRRRows=2", + "standard-hr transport host-received summary windowSec=0 samples=1 gapMaxSec=1" + + " acceptedHRRows=1 acceptedRRRows=2 rejectedHRRows=0 rejectedRRRows=0" + + " pendingHRRows=1 pendingRRRows=2", + ]) + } + + /// #2405: the stall that crosses a window boundary is the one a reader is looking for. + /// + /// A gap of a minute or more forces the roll on the very next sample, so before this it fell between + /// two windows and was counted in neither: `gapMaxSec` could only ever describe stalls SHORTER than + /// the window. Here the stream goes quiet for two minutes mid-session. + func testAStallLongerThanTheWindowIsReportedByTheWindowItOpens() { + var trace = Trace() + var lines: [String] = [] + for t in 0..<30 { lines += trace.record(routine(at: 1_000 + t), detailed: false) } + // Two minutes of silence, then the stream comes back. + lines += trace.record(routine(at: 1_000 + 29 + 120), detailed: false) + lines += trace.close() + + XCTAssertEqual(lines.count, 2, "the rolled window, then the closing one: \(lines)") + XCTAssertTrue(lines[0].contains("windowSec=29 samples=30 gapMaxSec=1"), lines[0]) + XCTAssertTrue(lines[1].contains("samples=1 gapMaxSec=120"), + "the stall belongs to the window it opens: \(lines[1])") + } + + /// A clock that steps backwards across the boundary has no honest span, so none is claimed. + func testABackwardsClockAcrossTheBoundarySeedsNoGap() { + var trace = Trace() + var lines: [String] = [] + for t in 0..<5 { lines += trace.record(routine(at: 1_000 + t), detailed: false) } + lines += trace.record(routine(at: 900), detailed: false) // before this window began + lines += trace.close() + + XCTAssertTrue(lines.last?.contains("gapMaxSec=0") == true, "got: \(lines)") + } + + /// `close()` is a disconnect, a background flush or a termination, each of which accounts for the + /// silence on its own. A stall measured across one would be reported twice. + func testAGapAcrossACloseIsNotSeeded() { + var trace = Trace() + _ = trace.record(routine(at: 1_000), detailed: false) + _ = trace.close() + var lines = trace.record(routine(at: 1_000 + 600), detailed: false) + lines += trace.close() + + XCTAssertTrue(lines.last?.contains("gapMaxSec=0") == true, "got: \(lines)") + } +} 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/StepsDetailDensityTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StepsDetailDensityTests.swift new file mode 100644 index 0000000000..d404651075 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StepsDetailDensityTests.swift @@ -0,0 +1,71 @@ +import Foundation +import XCTest +@testable import StrandAnalytics + +final class StepsDetailDensityTests: XCTestCase { + private let projector: ([StepsDetailReading], StepsDetailRange, String?) -> [StepsDetailBucket] = + StepsDetailDensity.project + + private func loadOracle() throws -> [[String: Any]] { + let relative = "android/app/src/test/resources/steps_detail_density_oracle.json" + var directory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + for _ in 0..<8 { + let candidate = directory.appendingPathComponent(relative) + if FileManager.default.fileExists(atPath: candidate.path) { + let object = try JSONSerialization.jsonObject(with: Data(contentsOf: candidate)) + let root = try XCTUnwrap(object as? [String: Any]) + XCTAssertEqual(root["schemaVersion"] as? Int, 1) + XCTAssertFalse(try XCTUnwrap(root["note"] as? String).isEmpty) + return try XCTUnwrap(root["cases"] as? [[String: Any]]) + } + directory = directory.deletingLastPathComponent() + } + XCTFail("committed oracle \(relative) not found above \(#filePath)") + throw CocoaError(.fileNoSuchFile) + } + + func testSwiftProjectorAssertsTheSharedAndroidFixture() throws { + let cases = try loadOracle() + XCTAssertEqual(cases.count, 11) + XCTAssertEqual(Set(cases.compactMap { $0["range"] as? String }), + Set(["W", "2W", "3W", "M", "3M", "6M", "1Y", "ALL"])) + + for fixture in cases { + let id = try XCTUnwrap(fixture["id"] as? String) + let range = try XCTUnwrap(StepsDetailRange(rawValue: try XCTUnwrap(fixture["range"] as? String))) + let readings = try XCTUnwrap(fixture["readings"] as? [[String: Any]]).map { + StepsDetailReading(day: try XCTUnwrap($0["day"] as? String), + value: try XCTUnwrap($0["value"] as? NSNumber).doubleValue) + } + let expected = try XCTUnwrap(fixture["expected"] as? [[String: Any]]) + let actual = projector(readings, range, try XCTUnwrap(fixture["anchorDay"] as? String)) + + XCTAssertEqual(actual.count, expected.count, id) + for (bucket, want) in zip(actual, expected) { + XCTAssertEqual(bucket.key, want["key"] as? String, id) + XCTAssertEqual(bucket.displayDay, want["displayDay"] as? String, id) + XCTAssertEqual(bucket.sum, try XCTUnwrap(want["sum"] as? NSNumber).doubleValue, + accuracy: 0.000_000_1, id) + XCTAssertEqual(bucket.observedDayCount, want["observedDayCount"] as? Int, id) + XCTAssertEqual(bucket.mean, want["mean"] as? Int, id) + } + } + } + + func testInvalidExplicitAnchorProducesNoBuckets() { + let readings = [StepsDetailReading(day: "2024-02-29", value: 100)] + XCTAssertEqual(projector(readings, .week, "2023-02-29"), []) + } + + func testLatestValidReadingIsTheDefaultAnchor() { + let readings = [ + StepsDetailReading(day: "2024-01-01", value: 1), + StepsDetailReading(day: "2024-01-20", value: 2), + StepsDetailReading(day: "invalid", value: 999), + ] + XCTAssertEqual(projector(readings, .week, nil), [ + StepsDetailBucket(key: "2024-01-20", displayDay: "2024-01-20", + sum: 2, observedDayCount: 1, mean: 2), + ]) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StepsMotionCacheTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StepsMotionCacheTests.swift new file mode 100644 index 0000000000..22aa3dc320 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StepsMotionCacheTests.swift @@ -0,0 +1,159 @@ +import XCTest +@testable import StrandAnalytics + +/// The steps-calibration motion cache's key and readout. Mirrored by the Kotlin +/// `StepsMotionCacheTest`, which pins the SAME literals — the key is a cross-platform contract only in +/// the sense that both sides must invalidate on the same facts, and pinning the rendered strings is how +/// a one-sided change to either rule is caught. +final class StepsMotionCacheTests: XCTestCase { + + func testKeyIsStableForUnchangedInputs() { + let a = StepsMotionCache.cacheKey(owner: "my-whoop", gravityCount: 8640, gravityMaxTs: 1_757_000_000) + let b = StepsMotionCache.cacheKey(owner: "my-whoop", gravityCount: 8640, gravityMaxTs: 1_757_000_000) + XCTAssertEqual(a, b) + XCTAssertEqual(a, "my-whoop|8640|1757000000") + } + + /// The three facts that MUST invalidate a fold, one at a time. A row added moves the count; a row + /// replacing another at a newer timestamp moves the max; a day changing hands moves the owner. + func testEveryInputInvalidates() { + let base = StepsMotionCache.cacheKey(owner: "my-whoop", gravityCount: 8640, gravityMaxTs: 1_757_000_000) + XCTAssertNotEqual(base, StepsMotionCache.cacheKey(owner: "my-whoop", gravityCount: 8641, + gravityMaxTs: 1_757_000_000)) + XCTAssertNotEqual(base, StepsMotionCache.cacheKey(owner: "my-whoop", gravityCount: 8640, + gravityMaxTs: 1_757_000_001)) + XCTAssertNotEqual(base, StepsMotionCache.cacheKey(owner: "whoop-5mg", gravityCount: 8640, + gravityMaxTs: 1_757_000_000)) + } + + /// An empty day is a real, cacheable answer — the key for it is well-formed and distinct from a day + /// that has rows. Caching it is what stops an unworn gap re-reading its whole stream every pass. + func testEmptyDayHasItsOwnKey() { + let empty = StepsMotionCache.cacheKey(owner: "my-whoop", gravityCount: 0, gravityMaxTs: 0) + XCTAssertEqual(empty, "my-whoop|0|0") + XCTAssertNotEqual(empty, StepsMotionCache.cacheKey(owner: "my-whoop", gravityCount: 1, + gravityMaxTs: 0)) + } + + /// The owner is the FIRST field, so two devices cannot collide by arranging their counts: the + /// separator makes `a|1|2` and `a|1|2` the only way to match. + func testOwnerBoundaryCannotBeForgedByCounts() { + XCTAssertNotEqual(StepsMotionCache.cacheKey(owner: "a", gravityCount: 1, gravityMaxTs: 2), + StepsMotionCache.cacheKey(owner: "a|1", gravityCount: 2, gravityMaxTs: 0)) + } + + func testLogLineReportsTheRatioAndSize() { + XCTAssertEqual(StepsMotionCache.logLine(reused: 58, folded: 2, size: 60), + "analyzeRecent stepsMotion reused=58/60 size=60") + // A cold process: everything folded, nothing reused. This is the line a FIRST pass prints, and + // seeing it on every pass is the symptom that the key is moving when it should not. + XCTAssertEqual(StepsMotionCache.logLine(reused: 0, folded: 60, size: 60), + "analyzeRecent stepsMotion reused=0/60 size=60") + } + + // MARK: - Persistence + + /// The exact payload the cache renders, pinned as a literal. This is the cross-platform contract that + /// matters most now the cache is stored: the Kotlin twin pins the SAME string, so a one-sided change to + /// the header, the separators or the number rendering is caught here rather than by a user whose folds + /// silently stopped being reused after an update. + static let vector = """ + stepsMotion v1 + 2026-09-01\tmy-whoop|8640|1757000000\t4659742922898407424 + 2026-09-02\tmy-whoop|0|0\t0 + """ + + static let vectorEntries: [String: (key: String, motion: Double)] = [ + "2026-09-01": (key: "my-whoop|8640|1757000000", motion: 3421.75), + "2026-09-02": (key: "my-whoop|0|0", motion: 0), + ] + + func testSerializeRendersThePinnedPayload() { + XCTAssertEqual(StepsMotionCache.serialize(Self.vectorEntries), Self.vector) + } + + func testRoundTripPreservesKeysAndVolumes() { + let back = StepsMotionCache.deserialize(StepsMotionCache.serialize(Self.vectorEntries)) + XCTAssertEqual(back.count, 2) + for (day, want) in Self.vectorEntries { + XCTAssertEqual(back[day]?.key, want.key) + XCTAssertEqual(back[day]?.motion, want.motion) + } + } + + /// A ZERO fold is a cached VALUE, not a missing one — the engine caches a zero from a read that + /// succeeded so unworn gaps stop re-reading their whole stream every pass. A round trip that dropped it + /// would quietly reintroduce exactly the cost the cache exists to remove, on the sparse libraries it is + /// worst for, so the zero day is asserted present rather than merely equal. + func testZeroFoldSurvivesTheRoundTrip() { + let back = StepsMotionCache.deserialize(StepsMotionCache.serialize(Self.vectorEntries)) + XCTAssertNotNil(back["2026-09-02"]) + XCTAssertEqual(back["2026-09-02"]?.motion, 0) + } + + /// Rendering is sorted, so an unchanged cache re-renders byte-identically and the store write coalesces + /// instead of churning a 60-entry payload on every pass. + func testRenderingIsStableAcrossDictionaryOrder() { + var shuffled: [String: (key: String, motion: Double)] = [:] + for (day, e) in Self.vectorEntries.sorted(by: { $0.key > $1.key }) { shuffled[day] = e } + XCTAssertEqual(StepsMotionCache.serialize(shuffled), StepsMotionCache.serialize(Self.vectorEntries)) + } + + /// An older fold's payload must be DISCARDED, not read. `cacheKey` witnesses the inputs only, so a day + /// whose gravity has not moved keys identically across an app update — without the header check the + /// pre-update volume would be served until that day's stream happened to change. + func testOlderFoldVersionIsDiscarded() { + let stale = Self.vector.replacingOccurrences(of: "stepsMotion v1", with: "stepsMotion v0") + XCTAssertTrue(StepsMotionCache.deserialize(stale).isEmpty) + } + + /// Anything that is not a payload this cache wrote yields an empty cache and one re-fold, never a + /// partially-trusted one. + func testUnreadablePayloadsYieldNothing() { + for raw in ["", "stepsMotion", "garbage", "\n", "v1\n2026-09-01\tk\t0"] { + XCTAssertTrue(StepsMotionCache.deserialize(raw).isEmpty, "expected empty for \(raw.debugDescription)") + } + } + + /// A malformed line is skipped and its neighbours survive: a truncated write should cost the days it + /// truncated, not the whole window. + func testMalformedLinesAreSkippedIndividually() { + let raw = """ + stepsMotion v1 + 2026-09-01\tmy-whoop|8640|1757000000\t4659742922898407424 + 2026-09-02\tmissing-a-field + 2026-09-03\tmy-whoop|1|2\tnot-a-number + \tempty-day\t0 + 2026-09-05\t\t0 + 2026-09-06\tmy-whoop|3|4\t4623226492472524800 + """ + let back = StepsMotionCache.deserialize(raw) + XCTAssertEqual(Set(back.keys), ["2026-09-01", "2026-09-06"]) + XCTAssertEqual(back["2026-09-06"]?.motion, 12.5) + } + + /// The writer prunes to the calibration window every pass, so a payload far above it did not come from + /// this cache. Rejecting it whole keeps a hand-edited or corrupt store from being parsed at length. + func testImplausiblyLargePayloadIsRejected() { + var raw = "stepsMotion v1" + for i in 0...513 { raw += "\nday-\(i)\tmy-whoop|1|2\t0" } + XCTAssertTrue(StepsMotionCache.deserialize(raw).isEmpty) + } + + /// Rendering is a FIXPOINT over a payload this build wrote. The engine skips the store write when the + /// rendered cache equals what is stored, so a pass that reused every day must produce the string it + /// read: if this ever stopped holding, every pass of an offload storm would write ~4 KB to say nothing. + func testRenderingIsAFixpoint() { + let once = StepsMotionCache.serialize(Self.vectorEntries) + XCTAssertEqual(StepsMotionCache.serialize(StepsMotionCache.deserialize(once)), once) + } + + /// The other half of that guard: a payload carrying a line this build DROPS must not re-render to + /// itself, so the cleaned version is written back once rather than being re-parsed every launch. + func testDroppedLinesReRenderDifferentlyAndAreRewritten() { + let dirty = Self.vector + "\n2026-09-03\tmy-whoop|1|2\tnot-a-number" + let cleaned = StepsMotionCache.serialize(StepsMotionCache.deserialize(dirty)) + XCTAssertNotEqual(cleaned, dirty) + XCTAssertEqual(cleaned, Self.vector) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StoreProbeTallyTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StoreProbeTallyTests.swift new file mode 100644 index 0000000000..91cf3c2c8b --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StoreProbeTallyTests.swift @@ -0,0 +1,60 @@ +import XCTest +@testable import StrandAnalytics + +/// The per-day probe readout. Mirrored by the Kotlin `StoreProbeTallyTest`, which pins the SAME literals: +/// the line is read off one shared strap log, so a one-sided change to the format would make two platforms' +/// logs incomparable exactly when they are being compared. +final class StoreProbeTallyTests: XCTestCase { + + /// A warm two-strap pass: the scoring loop's 21 owner probes plus the steps loop's 60, and one gravity + /// witness per steps day. This is the shape the line exists to make readable. + func testRendersCallsAndMillisInOrder() { + let line = StoreProbeTally.logLine([ + (name: "ownerHr", calls: 81, seconds: 1.24), + (name: "gravityFp", calls: 60, seconds: 1.89), + ]) + XCTAssertEqual(line, "analyzeRecent storeProbes total=3130ms ownerHr=81/1240ms gravityFp=60/1890ms") + } + + /// A default SINGLE-strap install skips the owner probe entirely (#970), so zero calls must render as + /// zero rather than being omitted: "not measured" and "measured, and it was free" are different + /// findings, and only one of them means the batching idea is pointless for that user. + func testZeroCallsStillRender() { + let line = StoreProbeTally.logLine([ + (name: "ownerHr", calls: 0, seconds: 0), + (name: "gravityFp", calls: 60, seconds: 1.89), + ]) + XCTAssertEqual(line, "analyzeRecent storeProbes total=1890ms ownerHr=0/0ms gravityFp=60/1890ms") + } + + func testNoProbesSaysSoRatherThanRenderingAnEmptyTail() { + XCTAssertEqual(StoreProbeTally.logLine([]), "analyzeRecent storeProbes total=0ms (no probes)") + } + + /// The renderer clamps, and that is a contract about the RENDERER rather than a claim about the probes: + /// both sides now count monotonic nanoseconds, so neither can hand it a negative. It is pinned anyway + /// because a negative or non-finite seconds value is the one input where Swift's round-half-away-from- + /// zero and Kotlin's round-half-up disagree, at exactly -0.5 ms, and the two platforms render into one + /// shared strap log. A probe cannot take less than no time, so nothing true is lost by flooring it. + func testNegativeAndNonFiniteSecondsClampToZeroOnBothSides() { + let line = StoreProbeTally.logLine([ + (name: "ownerHr", calls: 3, seconds: -0.0005), + (name: "gravityFp", calls: 1, seconds: .nan), + ]) + XCTAssertEqual(line, "analyzeRecent storeProbes total=0ms ownerHr=3/0ms gravityFp=1/0ms") + } + + /// The three-probe line as the engine actually renders it, pinned against the Kotlin twin's literal in + /// `StoreProbeTallyTest.dayOwnerAndOwnerHrAreCountedSeparately`. The shape matters as much as the + /// numbers: the LOOKUP and the PROBE are separate columns because collapsing them is what hid the + /// lookup, and `gravityFp` renders even at zero so a pass that never reached the steps loop says so. + func testThreeProbeLineMatchesTheKotlinTwin() { + let line = StoreProbeTally.logLine([ + (name: "dayOwner", calls: 81, seconds: 1.62), + (name: "ownerHr", calls: 132, seconds: 0.132), + (name: "gravityFp", calls: 0, seconds: 0), + ]) + XCTAssertEqual(line, + "analyzeRecent storeProbes total=1752ms dayOwner=81/1620ms ownerHr=132/132ms gravityFp=0/0ms") + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StrainBanisterDenominatorTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StrainBanisterDenominatorTests.swift index c2de5166cb..f5e1707284 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StrainBanisterDenominatorTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StrainBanisterDenominatorTests.swift @@ -26,12 +26,19 @@ final class StrainBanisterDenominatorTests: XCTestCase { 1440.0 * 0.64 * exp(1.67), accuracy: d) } - /// The denominator is ceiling + 1, exactly as `strainDenominator` was derived from 7200. - func testTheDenominatorIsCeilingPlusOne() { + /// The denominator is (ceiling MINUS a full day of sedentary baseline) + 1. + /// + /// #1545 derived it as ceiling + 1, mirroring how `strainDenominator` came from 7200. #1624 subtracts + /// the same baseline from the denominator that the scorer subtracts from each day, so both ends of the + /// axis line up with Edwards: no exertion maps to 0 AND a maximum day still maps to 100. Anchoring one + /// end only would trade one mismatch for the other. + func testTheDenominatorIsCeilingMinusBaselinePlusOne() { XCTAssertEqual(StrainScorer.logMapDenominator(method: .banister, sex: "male"), - StrainScorer.banisterDailyCeiling(b: StrainScorer.banisterBMen) + 1.0, accuracy: d) + StrainScorer.banisterDailyCeiling(b: StrainScorer.banisterBMen) - + StrainScorer.banisterBaseline(minutes: 24.0 * 60.0, b: StrainScorer.banisterBMen) + 1.0, accuracy: d) XCTAssertEqual(StrainScorer.logMapDenominator(method: .banister, sex: "female"), - StrainScorer.banisterDailyCeiling(b: StrainScorer.banisterBWomen) + 1.0, accuracy: d) + StrainScorer.banisterDailyCeiling(b: StrainScorer.banisterBWomen) - + StrainScorer.banisterBaseline(minutes: 24.0 * 60.0, b: StrainScorer.banisterBWomen) + 1.0, accuracy: d) } /// The property the whole derivation exists for: a theoretical maximum day maps to exactly 100 under @@ -43,7 +50,11 @@ final class StrainBanisterDenominatorTests: XCTestCase { for sex in ["male", "female"] { let b = sex == "female" ? StrainScorer.banisterBWomen : StrainScorer.banisterBMen - let ceiling = StrainScorer.banisterDailyCeiling(b: b) + // #1624: the quantity that maps to the top is a maximum day's EXCESS over the sedentary + // baseline, not its raw TRIMP — that is what the scorer now feeds the log map. Feeding the raw + // ceiling overshoots (100.21), which is the arithmetic working, not breaking. + let ceiling = StrainScorer.banisterDailyCeiling(b: b) - + StrainScorer.banisterBaseline(minutes: 24.0 * 60.0, b: b) let mapped = StrainScorer.trimpToStrain( ceiling, denominator: StrainScorer.logMapDenominator(method: .banister, sex: sex)) XCTAssertEqual(mapped, 100.0, accuracy: 1e-6, "\(sex) ceiling should map to the top of the scale") diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StrainDenominatorDomainTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StrainDenominatorDomainTests.swift new file mode 100644 index 0000000000..dc021102f1 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StrainDenominatorDomainTests.swift @@ -0,0 +1,50 @@ +import XCTest +@testable import StrandAnalytics + +/// Log-map denominator behavior outside the map's domain. +/// +/// D ≤ 1 has no ln-based score: ln(1) = 0 divides to ±∞, ln(D) < 0 below 1 flips the sign, and ln(D) +/// is NaN at or below 0. Before the guard, Swift returned `+Inf` for D = 1 while Kotlin's +/// `roundToLong()` saturated to 9.223372036854776E16 — the same input, two different answers. +/// The Kotlin twin is `StrainScorerDenominatorDomainTest`; its expected literals are the stdout of +/// this file's formula compiled standalone. +final class StrainDenominatorDomainTests: XCTestCase { + + func testDenominatorAtOrBelowOneScoresZero() { + for denominator in [1.0, 0.5, 0.0, -3.0, Double.nan] { + let s = StrainScorer.trimpToStrain(1, denominator: denominator) + XCTAssertTrue(s.isFinite, "D = \(denominator) produced a non-finite Effort") + XCTAssertEqual(s, 0.0, accuracy: 0, "D = \(denominator)") + } + } + + func testValidDenominatorsAreUnchanged() { + XCTAssertEqual(StrainScorer.trimpToStrain(1, denominator: 7201), 7.8, accuracy: 1e-9) + XCTAssertEqual(StrainScorer.trimpToStrain(100, denominator: 7201), 51.96, accuracy: 1e-9) + XCTAssertEqual(StrainScorer.trimpToStrain(7200, denominator: 7201), 100.0, accuracy: 1e-9) + XCTAssertEqual(StrainScorer.trimpToStrain(1, denominator: 2), 100.0, accuracy: 1e-9) + } + + /// Just above the boundary the score is enormous but finite, and it must stay a Double on both + /// platforms — this is the value Kotlin's `roundToLong()` clipped to Long.MAX / 100. + /// + /// Loosely toleranced on purpose: ln(1 + 2⁻⁵²) is ~2.2e-16, so a 1-ulp difference between + /// Darwin's and glibc's `log` moves the quotient by ~±30 absolute. Linux/glibc observes exactly + /// 3.1216573840826803e+17; the assertion checks the magnitude, not the last bits. + func testDenominatorJustAboveOneStaysFinite() { + let s = StrainScorer.trimpToStrain(1, denominator: 1.0000000000000002) + XCTAssertTrue(s.isFinite) + XCTAssertEqual(s, 3.1216573840826803e+17, accuracy: 1e4) + } + + /// Non-finite TRIMP propagates rather than being caught by the domain guard — the guard is about + /// D, not TRIMP. Both platforms agree here only because Kotlin no longer rounds through Long + /// (`roundToLong()` mapped +∞ to Long.MAX), so pin the non-finite propagation behavior. + func testNonFiniteTrimpPropagates() { + let inf = StrainScorer.trimpToStrain(.infinity, denominator: 7201) + XCTAssertFalse(inf.isFinite) + XCTAssertFalse(inf.isNaN) + XCTAssertGreaterThan(inf, 0) + XCTAssertTrue(StrainScorer.trimpToStrain(.nan, denominator: 7201).isNaN) + } +} 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/TrainingLoadEngineTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/TrainingLoadEngineTests.swift index abcb401acc..5d5dc41ab3 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/TrainingLoadEngineTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/TrainingLoadEngineTests.swift @@ -152,4 +152,22 @@ final class TrainingLoadEngineTests: XCTestCase { XCTAssertEqual(result.ctl!, 30, accuracy: 1e-12) XCTAssertEqual(result.atl!, 30, accuracy: 1e-12) } + + /// Pins the synthetic day labels `evaluateDense` hands out, which issue #71 moved off a private + /// Gregorian formatter onto the shared `LocalCalendarDate`. The literals are the verbatim stdout of + /// the pre-move formatter compiled standalone (`swiftc -O`), so a label that shifts by a day around + /// the leap day or the year boundary fails here instead of surfacing as two disagreeing platforms. + /// Kotlin twin: `TrainingLoadEngineTest.denseDayLabelsCrossLeapDayAndYearBoundary`. + func testDenseDayLabelsCrossLeapDayAndYearBoundary() { + let result = TrainingLoadEngine.evaluateDense(Array(repeating: 30, count: 367)) + XCTAssertEqual(result.startDay, "2000-01-01") + XCTAssertEqual(result.endDay, "2001-01-01") + // Points begin at the last priming day (offset 6), so offset N is points[N - 6]. + XCTAssertEqual(result.points.first?.day, "2000-01-07") + XCTAssertEqual(result.points[52].day, "2000-02-28") + XCTAssertEqual(result.points[53].day, "2000-02-29") + XCTAssertEqual(result.points[54].day, "2000-03-01") + XCTAssertEqual(result.points[359].day, "2000-12-31") + XCTAssertEqual(result.points.last?.day, "2001-01-01") + } } diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/UserProfileCacheKeyTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/UserProfileCacheKeyTests.swift new file mode 100644 index 0000000000..e6bb68c515 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/UserProfileCacheKeyTests.swift @@ -0,0 +1,18 @@ +import XCTest +@testable import StrandAnalytics + +/// `UserProfile.cacheKey` must move with every stored field, or the per-cycle load cache serves stale Effort +/// and calories after a profile edit. Twin of Kotlin `RescoreUnchangedInputsTest.everyProfileFieldMovesTheLoadKey`. +final class UserProfileCacheKeyTests: XCTestCase { + func testEveryFieldMovesTheKey() { + let base = UserProfile() + var edits: [UserProfile] = [] + var p = base; p.weightKg = 71; edits.append(p) + p = base; p.heightCm = 171; edits.append(p) + p = base; p.age = 31; edits.append(p) + p = base; p.sex = "male"; edits.append(p) + p = base; p.stepTicksPerStep = 2; edits.append(p) + for edit in edits { XCTAssertNotEqual(edit.cacheKey, base.cacheKey) } + XCTAssertEqual(UserProfile().cacheKey, base.cacheKey) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/WatchRecoveryTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/WatchRecoveryTests.swift index c7f1f221f6..cc168199f4 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/WatchRecoveryTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/WatchRecoveryTests.swift @@ -93,6 +93,89 @@ final class WatchRecoveryTests: XCTestCase { XCTAssertLessThanOrEqual(out.recovery!, 70) } + // MARK: - Week gate counts ACCEPTED nights, not raw entries (fork issue #62) + + // Seven RAW history entries of which only four are physiologically valid must NOT clear the + // week gate: `minBaselineNights` means nights the baseline ACCEPTED (`nValid`), so the rejected + // -1 / 0 / 999 readings can't buy a score a week early. + func testSevenRawNightsWithFourValidCalibrates() { + let out = WatchRecovery.compute(todaySDNN: 45.0, todayRHR: nil, + sdnnHistory: [45, 46, 47, 48, -1, 0, 999], rhrHistory: []) + XCTAssertNil(out.recovery) + XCTAssertEqual(out.confidence, .calibrating) + } + + // Seven raw entries of which only three are valid: below the baseline's own seed gate too. + func testSevenRawNightsWithThreeValidCalibrates() { + let out = WatchRecovery.compute(todaySDNN: 45.0, todayRHR: nil, + sdnnHistory: [45, 46, 47, -1, 0, 999, 1000], rhrHistory: []) + XCTAssertNil(out.recovery) + XCTAssertEqual(out.confidence, .calibrating) + } + + // Six raw entries, all six valid → still one accepted night short of the gate. + func testSixRawNightsWithSixValidCalibrates() { + let out = WatchRecovery.compute(todaySDNN: 45.0, todayRHR: nil, + sdnnHistory: [45, 46, 47, 48, 45, 46], rhrHistory: []) + XCTAssertNil(out.recovery) + XCTAssertEqual(out.confidence, .calibrating) + } + + // Nine raw entries of which exactly seven are valid → the gate is met by accepted nights. + func testNineRawNightsWithSevenValidScores() { + let out = WatchRecovery.compute(todaySDNN: 45.0, todayRHR: nil, + sdnnHistory: [45, 46, 47, 48, 45, 46, 47, -1, 999], + rhrHistory: []) + XCTAssertNotNil(out.recovery) + XCTAssertNotEqual(out.confidence, .calibrating) + } + + // MARK: - RHR term needs a USABLE RHR baseline (fork issue #61) + + // An empty RHR history yields `foldHistory`'s synthetic midpoint baseline (75 bpm), which must + // never score today's reading: with no usable RHR baseline the result is the documented + // HRV-only path, exactly as if today's RHR were missing. + func testEmptyRHRHistoryScoresLikeMissingRHR() { + let hist = Array(repeating: 45.0, count: 7) + let withRHR = WatchRecovery.compute(todaySDNN: 45.0, todayRHR: 52, + sdnnHistory: hist, rhrHistory: []) + let hrvOnly = WatchRecovery.compute(todaySDNN: 45.0, todayRHR: nil, + sdnnHistory: hist, rhrHistory: []) + XCTAssertNotNil(withRHR.recovery) + XCTAssertNotNil(hrvOnly.recovery) + XCTAssertEqual(withRHR.recovery!, hrvOnly.recovery!, accuracy: 1e-12) + // The same literal the Kotlin twin pins, so the oracle guards BOTH directions: a Swift-side + // drift would break here rather than silently diverging from Android. + XCTAssertEqual(withRHR.recovery!, 57.932425214874954, accuracy: 1e-12) + } + + // An RHR history that is entirely out of physiological range accepts no night at all, so its + // baseline is the same synthetic midpoint — likewise dropped. + func testUnusableRHRHistoryScoresLikeMissingRHR() { + let hist = Array(repeating: 45.0, count: 7) + let junk = Array(repeating: 300.0, count: 7) // above restingHRCfg.maxVal (120) + let withRHR = WatchRecovery.compute(todaySDNN: 45.0, todayRHR: 52, + sdnnHistory: hist, rhrHistory: junk) + let hrvOnly = WatchRecovery.compute(todaySDNN: 45.0, todayRHR: nil, + sdnnHistory: hist, rhrHistory: junk) + XCTAssertNotNil(withRHR.recovery) + XCTAssertEqual(withRHR.recovery!, hrvOnly.recovery!, accuracy: 1e-12) + } + + // Once the RHR baseline IS usable (≥ Baselines.minNightsSeed accepted nights) the term returns: + // a resting HR above baseline must pull the score below the HRV-only number. + func testUsableRHRHistoryStillContributes() { + let hist = Array(repeating: 45.0, count: 7) + let rhrHist = Array(repeating: 52.0, count: 4) // exactly the seed gate → provisional + let withRHR = WatchRecovery.compute(todaySDNN: 45.0, todayRHR: 62, + sdnnHistory: hist, rhrHistory: rhrHist) + let hrvOnly = WatchRecovery.compute(todaySDNN: 45.0, todayRHR: nil, + sdnnHistory: hist, rhrHistory: rhrHist) + XCTAssertNotNil(withRHR.recovery) + XCTAssertNotNil(hrvOnly.recovery) + XCTAssertLessThan(withRHR.recovery!, hrvOnly.recovery! - 1.0) + } + // Watch recovery is on the SAME scale as strap recovery: feeding identical at-baseline inputs // to RecoveryScorer directly (HRV + RHR terms only) reproduces WatchRecovery's number. func testSameScaleAsStrapRecovery() { diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/WindowedStreamPlanTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/WindowedStreamPlanTests.swift new file mode 100644 index 0000000000..88f40d0623 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/WindowedStreamPlanTests.swift @@ -0,0 +1,100 @@ +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 ownerFlips=0 reuseOff=0] " + + "rr[read=250 served=750 truncated=3 ownerFlips=0 reuseOff=0]") + XCTAssertEqual( + WindowedStreamPlan.logLine(hrRead: 0, hrServed: 0, hrTruncated: 0, + rrRead: 0, rrServed: 0, rrTruncated: 0), + "analyzeRecent windows hr[read=0 served=0 truncated=0 ownerFlips=0 reuseOff=0] " + + "rr[read=0 served=0 truncated=0 ownerFlips=0 reuseOff=0]") + // #2073: the shape the field log actually had, and the one a reader would have got WRONG. + // A WHOOP 5 R-R window is told not to reuse at all, so served=0 is by design; ownerFlips alone + // reads 0 there and would have suggested a gap, which is the opposite of true. + XCTAssertEqual( + WindowedStreamPlan.logLine(hrRead: 1_752_302, hrServed: 1_926_517, hrTruncated: 0, + rrRead: 9_044, rrServed: 0, rrTruncated: 0, + hrOwnerFlips: 0, rrOwnerFlips: 0, + hrReuseOff: 0, rrReuseOff: 21), + "analyzeRecent windows hr[read=1752302 served=1926517 truncated=0 ownerFlips=0 reuseOff=0] " + + "rr[read=9044 served=0 truncated=0 ownerFlips=0 reuseOff=21]") + } +} diff --git a/Packages/StrandDesign/Sources/StrandDesign/Appearance.swift b/Packages/StrandDesign/Sources/StrandDesign/Appearance.swift index a9ae147ce0..acd509fe4e 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/Appearance.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/Appearance.swift @@ -243,10 +243,18 @@ public enum AppearanceMode: String, CaseIterable, Identifiable, Sendable { public static let storageKey = "theme.appearance" /// Human label for the Settings control. + /// + /// `.light` carries an EXPLICIT key rather than using its English text as the key, because + /// "Light" is also the name of a SLEEP STAGE (`Palette.swift`, Awake / Light / Deep / REM) and a + /// catalogue key IS its English string, so both shared one entry and one translation. Every + /// locale then had to pick a meaning, and each picked the sleep one: French offered "Léger" + /// (lightweight) as an appearance, Portuguese and Polish offered "Luz" and "Światło" + /// (illumination), and Chinese offered 浅睡, "shallow sleep", as a theme. No translation could + /// fix that while one key served both. `.dark` and `.system` are unambiguous and keep theirs. public var label: String { switch self { case .system: return String(localized: "System", bundle: .module) - case .light: return String(localized: "Light", bundle: .module) + case .light: return String(localized: "appearance.light", defaultValue: "Light", bundle: .module) case .dark: return String(localized: "Dark", bundle: .module) } } diff --git a/Packages/StrandDesign/Sources/StrandDesign/ChartHover.swift b/Packages/StrandDesign/Sources/StrandDesign/ChartHover.swift index 422e5444b1..efc6f507ac 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/ChartHover.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/ChartHover.swift @@ -128,12 +128,20 @@ public enum ChartHoverMath { /// A thin vertical crosshair line drawn at a given x with a hairline-strong /// stroke. Shared by TrendChart / Sparkline so the rule reads identically. -struct CrosshairRule: View { - var x: CGFloat - var height: CGFloat - var color: Color = StrandPalette.hairlineStrong +/// `public`: app-target chart types outside this package (WorkoutRecoveryTrendChart, TrainingLoadCard, +/// LiveTimeChart) reuse this instead of hand-rolling their own crosshair. +public struct CrosshairRule: View { + public var x: CGFloat + public var height: CGFloat + public var color: Color + + public init(x: CGFloat, height: CGFloat, color: Color = StrandPalette.hairlineStrong) { + self.x = x + self.height = height + self.color = color + } - var body: some View { + public var body: some View { Path { p in p.move(to: CGPoint(x: x, y: 0)) p.addLine(to: CGPoint(x: x, y: height)) @@ -149,11 +157,17 @@ struct CrosshairRule: View { // MARK: - Highlighted point dot /// A small accented dot used to mark the highlighted sample on a line. -struct HighlightDot: View { - var color: Color - var diameter: CGFloat = 9 +/// `public`: shared with app-target chart hover overlays — see `CrosshairRule`. +public struct HighlightDot: View { + public var color: Color + public var diameter: CGFloat + + public init(color: Color, diameter: CGFloat = 9) { + self.color = color + self.diameter = diameter + } - var body: some View { + public var body: some View { // Design Reset (WHOOP): a crisp solid dot with a clean surface ring, no blurred bloom halo. ZStack { Circle() @@ -189,14 +203,21 @@ struct NowCapDot: View { /// Wraps a tooltip so its measured size feeds back into placement. Fades in /// with StrandMotion and positions itself within `container` near `anchor`. -struct PositionedTooltip: View { - var anchor: CGPoint - var container: CGSize - var tooltip: ChartTooltip +/// `public`: shared with app-target chart hover overlays — see `CrosshairRule`. +public struct PositionedTooltip: View { + public var anchor: CGPoint + public var container: CGSize + public var tooltip: ChartTooltip @State private var measured: CGSize = .zero - var body: some View { + public init(anchor: CGPoint, container: CGSize, tooltip: ChartTooltip) { + self.anchor = anchor + self.container = container + self.tooltip = tooltip + } + + public var body: some View { tooltip .background( GeometryReader { g in diff --git a/Packages/StrandDesign/Sources/StrandDesign/Hypnogram.swift b/Packages/StrandDesign/Sources/StrandDesign/Hypnogram.swift index dbb5a6e533..daf3d8cec4 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/Hypnogram.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/Hypnogram.swift @@ -154,14 +154,26 @@ public struct Hypnogram: View { let f = DateFormatter(); f.locale = Locale.current; f.setLocalizedDateFormatFromTemplate("jmm"); return f }() - /// Format a seconds-from-origin offset either as wall-clock (if nightStart - /// is set) or as elapsed H:MM from the start of the night. - private func timeLabel(_ secondsFromOrigin: TimeInterval) -> String { + /// Format a seconds-from-night-start offset (per `SleepInterval`'s own doc: `start`/`end` are + /// already "seconds from the start of the night", not from `origin`) either as wall-clock (if + /// `nightStart` is set) or as elapsed H:MM from the start of the night. + /// + /// Deliberately does NOT subtract `origin` here. `origin` (`intervals.first?.start`) exists so + /// `bandRect`/`risers`/`intervalIndex` can lay bars out to fill the plot's width starting from + /// whatever the first VISIBLE interval is — a layout concern. Reusing it here to compute a clock + /// time would silently assume the first visible interval always sits at the night's true onset. That + /// holds for an ordinary night (the first stage code lands a few seconds after onset, so `origin` is + /// negligible), but not for a holed/partial timeline, where the surviving stages can start hours into + /// the night — subtracting `origin` there forced the leading axis label to read as `nightStart` + /// verbatim regardless, mislabeling e.g. a night's last 52 minutes as its first 52. + /// `internal`, not `private`: exposed so `StrandDesignTests` can pin this exact math without a live + /// SwiftUI render. + func timeLabel(_ secondsFromNightStart: TimeInterval) -> String { if let nightStart { - let d = nightStart.addingTimeInterval(secondsFromOrigin - origin) + let d = nightStart.addingTimeInterval(secondsFromNightStart) return Hypnogram.clockFormatter.string(from: d) } - let total = Int((secondsFromOrigin - origin).rounded()) + let total = Int(secondsFromNightStart.rounded()) let h = total / 3600 let m = (total % 3600) / 60 return String(format: "%d:%02d", h, m) diff --git a/Packages/StrandDesign/Sources/StrandDesign/MotionTrace.swift b/Packages/StrandDesign/Sources/StrandDesign/MotionTrace.swift index 0cd8839c1f..e41234f3ee 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/MotionTrace.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/MotionTrace.swift @@ -33,12 +33,27 @@ public struct MotionTrace: View { /// The peak magnitude used to normalise the fill height. A non-positive peak (all-zero / empty) maps /// everything to the baseline so the strip is flat rather than dividing by zero. - private var peak: Double { max(epochs.max() ?? 0, 0) } + /// + /// Computed ONCE where it is needed and threaded down, never read inside a per-epoch loop. As a + /// computed property it rescanned every epoch on each read, and it was read from inside the `map` in + /// `points(in:)` and the `filter` in `accessibilitySummary`, so a night cost a scan per epoch. With + /// 30-second epochs an 8-hour night is ~960 of them, about 1.8 million comparisons every time the + /// strip is laid out, and SwiftUI re-runs `body` on hover, animation and the 1 Hz HR tick (#2283). + /// + /// Android already hoists the same value (`SleepScreen.kt`), so this removes a divergence rather than + /// creating one. + static func peak(of epochs: [Double]) -> Double { max(epochs.max() ?? 0, 0) } public var body: some View { GeometryReader { geo in let w = geo.size.width let h = geo.size.height + // ONE scan, threaded into everything below, rather than a scan per epoch. See `peak(of:)`. + // + // Sits inside the GeometryReader, so it is recomputed per LAYOUT pass rather than per body + // evaluation. That distinction does not matter here (both are O(n) against the O(n^2) this + // replaces) and keeping it here avoids restructuring `body` around the ViewBuilder. + let peak = Self.peak(of: epochs) ZStack { // Faint baseline so the strip reads as a grounded trace even on a calm night. Path { p in @@ -49,7 +64,7 @@ public struct MotionTrace: View { // Filled area under the per-epoch magnitude, normalised to the night's own peak. if epochs.count >= 2, peak > 0 { - let pts = points(in: geo.size) + let pts = Self.points(in: geo.size, epochs: epochs, peak: peak) Path { p in p.move(to: CGPoint(x: 0, y: h)) for pt in pts { p.addLine(to: pt) } @@ -77,14 +92,14 @@ public struct MotionTrace: View { } .accessibilityElement() .accessibilityLabel(Text("Movement during sleep")) - .accessibilityValue(Text(accessibilitySummary)) + .accessibilityValue(Text(Self.accessibilitySummary(epochs: epochs, peak: peak))) } .frame(height: height) } /// One screen point per epoch: x spread evenly across the width (matching the hypnogram's left→right /// time mapping), y the magnitude normalised to the night's peak (0 at the baseline, full at the top). - private func points(in size: CGSize) -> [CGPoint] { + static func points(in size: CGSize, epochs: [Double], peak: Double) -> [CGPoint] { let n = epochs.count guard n >= 2, peak > 0 else { return [] } let h = size.height @@ -98,7 +113,7 @@ public struct MotionTrace: View { /// A coarse VoiceOver summary — the share of epochs with above-half-peak movement — since a per-epoch /// trace can't be voiced point by point. "Calm" when nothing crosses the threshold. - private var accessibilitySummary: String { + static func accessibilitySummary(epochs: [Double], peak: Double) -> String { guard peak > 0, !epochs.isEmpty else { return "no movement data" } let restless = epochs.filter { $0 >= peak * 0.5 }.count if restless == 0 { return "calm throughout" } diff --git a/Packages/StrandDesign/Sources/StrandDesign/NoopMotion.swift b/Packages/StrandDesign/Sources/StrandDesign/NoopMotion.swift index 37abbf550c..77b08fd638 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/NoopMotion.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/NoopMotion.swift @@ -1,4 +1,10 @@ import SwiftUI +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +// For the #2393 window-visibility gate. SwiftUI already makes AppKit types reachable on macOS (the +// palette's NSColor path relies on it), but the dependency is explicit here because this file names +// NSApplication and NSWindow directly rather than a type SwiftUI itself vends. +import AppKit +#endif // MARK: - NoopMotion — the "Design Reset" motion set (WHOOP design language, 2026-06-22) // @@ -106,6 +112,21 @@ public final class NoopMotionState: ObservableObject { /// toggling the setting takes effect without a relaunch. @Published public private(set) var isLowPower: Bool + /// #2393, macOS only: every window of this app is hidden, minimised or fully covered, so nothing + /// a frame loop draws can be seen. + /// + /// A decorative `TimelineView(.animation…)` keeps running when the app is hidden. A reporter + /// measured NOOP at a third to half a core permanently on an M1 Pro, the largest single process on + /// their machine, ahead of `WindowServer` — and Cmd+H did not reduce it, it RAISED it (28.8% to + /// 49.3% of a core in one run, 34.2% to 41.8% in another, returning to baseline exactly on + /// re-show). Their hypothesis is that the display link paces the timeline while the window is on + /// screen and the schedule free-runs once it is not. That is unproven and does not need to be true: + /// drawing frames nobody can see is not worth doing either way. + /// + /// iOS and watchOS never set this. The system already stops rendering a backgrounded app there, and + /// `scenePhase` covers what it does not; this is the gap AppKit leaves. + @Published public private(set) var windowObscured: Bool = false + /// The in-app "Reduce motion in NOOP" preference. Kept in step with `UserDefaults` so a /// non-SwiftUI reader (the motion sensor) and the `@AppStorage` toggle never disagree. @Published public private(set) var quietMotion: Bool @@ -130,6 +151,84 @@ public final class NoopMotionState: ObservableObject { if self?.quietMotion != now { self?.quietMotion = now } } } + #if canImport(AppKit) && !targetEnvironment(macCatalyst) + // #2393: hide/unhide covers Cmd+H, occlusion covers "another window is over it" and, with the + // miniaturise pair, the Dock. They all land on one recompute rather than each setting the flag + // its own way: the states overlap (hiding an app also occludes its windows) and a flag written + // from five places would disagree with itself the moment two of them arrived out of order. + for name in [NSApplication.didHideNotification, + NSApplication.didUnhideNotification, + NSWindow.didChangeOcclusionStateNotification, + NSWindow.didMiniaturizeNotification, + NSWindow.didDeminiaturizeNotification] { + NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { self?.refreshWindowObscured() } + } + } + #endif + } + + #if canImport(AppKit) && !targetEnvironment(macCatalyst) + /// Recompute [windowObscured] from AppKit's current window list. Cheap: a handful of windows, and + /// it runs on notifications a user generates by hand, not on a clock. + private func refreshWindowObscured() { + // `.titled` is what separates a real app window from the `MenuBarExtra`'s status-item window, + // which lives in `NSApplication.shared.windows` for the whole life of the process and would + // otherwise report something on screen forever. + // + // #2397: this was `canBecomeMain`, and that was wrong in the one case the gate exists for. + // `canBecomeMain` describes a window's CURRENT STATE, not its kind: a hidden or miniaturised + // window answers false, so the real window left the filtered list at exactly the moment the gate + // should have closed, the list went empty, and the empty-list rule below then read that as "not + // obscured". Two individually reasonable decisions cancelling out. @nichtlegacy measured it: + // + // visible all=3 cbm=1 titled=2 (one on screen) obscured=N + // hidden all=4 cbm=0 titled=2 (none on screen) obscured=N, should be Y + // minimised all=4 cbm=0 titled=2 (none on screen) obscured=N, should be Y + // + // `NSStatusBarWindow` reported `titled=N` in every state they logged, and the app window kept + // `titled=Y` in all three, which is the property this needs and `canBecomeMain` never had. + let windows = NSApplication.shared.windows + .filter { $0.styleMask.contains(.titled) } + .map { WindowVisibility(onScreen: $0.isVisible && $0.occlusionState.contains(.visible)) } + let now = NoopMotionState.obscured(windows) + if windowObscured != now { windowObscured = now } + } + #endif + + /// One app window's contribution to the gate, as the pure decision sees it. + /// + /// #2397: a struct rather than two loose Bools because the decision is about a LIST of windows, and + /// the bug it replaces was invisible to a test that could only be handed a summary. Given + /// `hasWindows`/`anyWindowOnScreen` there was no way to express "the app window is hidden while the + /// status-item window is on screen", which is the exact state that was misread. + struct WindowVisibility: Equatable { + /// Visible AND not fully occluded: what `isVisible && occlusionState.contains(.visible)` answers. + let onScreen: Bool + } + + /// Pure half of the recompute, so the rule can be tested without AppKit. + /// + /// Takes the already-filtered app windows: every entry is a titled window, the status-item window + /// having been dropped by the caller. Obscured means "there are app windows and none of them is on + /// screen". + /// + /// NO WINDOWS is deliberately NOT obscured, and the case is more common than it sounds: before the + /// first window exists during launch, and again when someone closes the window and leaves NOOP + /// running as a menu-bar app. Treating an empty list as "nothing on screen" would pose every surface + /// still until the next occlusion notification arrived — a first frame of static gauges on the way + /// to a live screen, caused by the optimisation. Nothing is lost by the other reading: with no + /// window there is no view hierarchy, so there is no frame loop to stop. + /// + /// That rule is only safe while the filter is state-INDEPENDENT. Under #2394's `canBecomeMain` the + /// list emptied whenever the window was hidden, so this clause silently answered the live question + /// instead of the launch one. `.titled` keeps a hidden window in the list, which is what returns this + /// rule to the case it was written for. + /// + /// `nonisolated` because the enclosing class is `@MainActor` and this decides nothing that needs the + /// main actor — without it a test could not call it off the main actor at all. + nonisolated static func obscured(_ windows: [WindowVisibility]) -> Bool { + !windows.isEmpty && !windows.contains { $0.onScreen } } /// The gate. `reduceMotion` comes from `@Environment(\.accessibilityReduceMotion)` at the call @@ -143,13 +242,13 @@ public final class NoopMotionState: ObservableObject { /// ``` @inline(__always) public func poseStill(_ reduceMotion: Bool) -> Bool { - reduceMotion || isLowPower || quietMotion + reduceMotion || isLowPower || quietMotion || windowObscured } - /// The two non-environment signals on their own, for an imperative (non-View) reader that - /// supplies its own Reduce Motion read — e.g. the decorative motion sensor deciding whether to - /// start at all. Views must use `poseStill(_:)` instead so they invalidate correctly. - public var poseStillIgnoringReduceMotion: Bool { isLowPower || quietMotion } + /// The non-environment signals on their own, for an imperative (non-View) reader that supplies its + /// own Reduce Motion read — e.g. the decorative motion sensor deciding whether to start at all. + /// Views must use `poseStill(_:)` instead so they invalidate correctly. + public var poseStillIgnoringReduceMotion: Bool { isLowPower || quietMotion || windowObscured } } // MARK: - CountUpText diff --git a/Packages/StrandDesign/Sources/StrandDesign/Palette.swift b/Packages/StrandDesign/Sources/StrandDesign/Palette.swift index 0e258d579b..b48f585b12 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/Palette.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/Palette.swift @@ -31,23 +31,42 @@ public extension Color { /// at every one of its call sites when the colour scheme flips — no per-view environment plumbing. /// This is the whole light-theme strategy: only the token definitions change, never the call sites. init(light: String, dark: String) { + // #2393: parse BOTH hexes ONCE, here, and let the provider pick between two ready tuples. + // + // The provider closure is not called once per token — it is called once per RESOLUTION, and the + // liquid layer resolves on every frame: `Color.liquidComponents()` asks for + // `NSColor(self).usingColorSpace(.sRGB)` (`LiquidCore.swift`), which re-invokes this closure, + // which used to run `trimmingCharacters` + `Scanner.scanHexInt64` over a string. A reporter + // profiling NOOP at a third to half a CPU core on macOS found `Color.sRGBComponents(hex:)` and + // `closure #1 in Color.init(light:dark:)` among the hot leaves (#2393). + // + // A cache would also have removed the cost, and a cache is the wrong shape for it: it needs a + // key, a lock (a dynamic provider can resolve off the main thread) and an eviction story, to + // re-derive per frame a value that cannot change after this line. The tokens are `static let` + // (`NoopVisualStyle`, `StrandPalette`), so this runs once per token for the life of the process. + // One declaration rather than two `let`s: watchOS resolves straight to the dark hex, so a separate + // `lightComponents` would be unused on that platform and warn. A pair that is always read as a + // whole has no such half. + let components = (light: Color.sRGBComponents(hex: light), dark: Color.sRGBComponents(hex: dark)) #if os(watchOS) // watchOS has no UITraitCollection / dynamic-provider UIColor, and our watch app is effectively // always dark, so a token resolves straight to its dark hex. No per-scheme plumbing on the wrist. - self.init(hex: dark) + self.init(.sRGB, red: components.dark.r, green: components.dark.g, + blue: components.dark.b, opacity: components.dark.a) #elseif canImport(UIKit) self.init(UIColor { trait in - let c = Color.sRGBComponents(hex: trait.userInterfaceStyle == .dark ? dark : light) + let c = trait.userInterfaceStyle == .dark ? components.dark : components.light return UIColor(red: CGFloat(c.r), green: CGFloat(c.g), blue: CGFloat(c.b), alpha: CGFloat(c.a)) }) #elseif canImport(AppKit) self.init(nsColor: NSColor(name: nil) { appearance in let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua - let c = Color.sRGBComponents(hex: isDark ? dark : light) + let c = isDark ? components.dark : components.light return NSColor(srgbRed: CGFloat(c.r), green: CGFloat(c.g), blue: CGFloat(c.b), alpha: CGFloat(c.a)) }) #else - self.init(hex: dark) + self.init(.sRGB, red: components.dark.r, green: components.dark.g, + blue: components.dark.b, opacity: components.dark.a) #endif } } @@ -276,6 +295,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..5cf167fb37 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/Resources/Localizable.xcstrings +++ b/Packages/StrandDesign/Sources/StrandDesign/Resources/Localizable.xcstrings @@ -36,7 +36,7 @@ "fr": { "stringUnit": { "state": "translated", - "value": "%1$lld il y a des jours" + "value": "il y a %lld jours" } }, "pl": { @@ -44,6 +44,18 @@ "state": "translated", "value": "%lld kilka dni temu" } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%lld 天前" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%lld 天前" + } } } }, @@ -154,6 +166,18 @@ "state": "translated", "value": "%lld minut" } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%lld 分钟" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%lld 分鐘" + } } } }, @@ -192,7 +216,7 @@ "fr": { "stringUnit": { "state": "translated", - "value": "Points %1$lld, moyenne %2$@, plage %3$@ à %4$@" + "value": "%1$lld points, moyenne %2$@, plage %3$@ à %4$@" } }, "pl": { @@ -200,6 +224,18 @@ "state": "translated", "value": "%lld punkty, średnia %@, zakres %@ do %@" } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%lld 个数据点,平均 %@,范围 %@ 至 %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%lld 個資料點,平均 %@,範圍 %@ 至 %@" + } } } }, @@ -238,7 +274,7 @@ "fr": { "stringUnit": { "state": "translated", - "value": "Lectures %1$lld" + "value": "%lld relevés" } }, "pl": { @@ -246,6 +282,18 @@ "state": "translated", "value": "Odczyty %lld" } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%lld 次读数" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%lld 次讀數" + } } } }, @@ -292,6 +340,18 @@ "state": "translated", "value": "Treningi %lld" } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%lld 次锻炼" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%lld 次鍛鍊" + } } } }, @@ -330,7 +390,7 @@ "fr": { "stringUnit": { "state": "translated", - "value": "%1$lldd il y a" + "value": "il y a %lld j" } }, "pl": { @@ -338,6 +398,18 @@ "state": "translated", "value": "%lldd temu" } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%lld天前" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%lld天前" + } } } }, @@ -394,7 +466,7 @@ "fr": { "stringUnit": { "state": "translated", - "value": "il y a %lldh" + "value": "il y a %lld h" } }, "pl": { @@ -440,7 +512,7 @@ "fr": { "stringUnit": { "state": "translated", - "value": "%1$lldm il y a" + "value": "il y a %lld min" } }, "pl": { @@ -448,6 +520,18 @@ "state": "translated", "value": "%lldm temu" } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%lld分钟前" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%lld分鐘前" + } } } }, @@ -558,6 +642,18 @@ "state": "translated", "value": "1 minuta" } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "1 分钟" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "1 分鐘" + } } } }, @@ -604,6 +700,18 @@ "state": "translated", "value": "1 trening" } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "1 次锻炼" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "1 次鍛鍊" + } } } }, @@ -642,7 +750,7 @@ "fr": { "stringUnit": { "state": "translated", - "value": "TOUT" + "value": "MAXIMAL" } }, "pl": { @@ -650,6 +758,18 @@ "state": "translated", "value": "CAŁOŚĆ" } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "全力" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "全力" + } } } }, @@ -760,6 +880,18 @@ "state": "translated", "value": "Klasyczny" } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "经典" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "經典" + } } } }, @@ -798,7 +930,7 @@ "fr": { "stringUnit": { "state": "translated", - "value": "DÉPLACÉ" + "value": "ÉPUISÉ" } }, "pl": { @@ -806,6 +938,18 @@ "state": "translated", "value": "WYKOŃCZONY" } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已耗尽" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已耗盡" + } } } }, @@ -832,7 +976,7 @@ "fr": { "stringUnit": { "state": "translated", - "value": "Noir" + "value": "Sombre" } }, "pt-PT": { @@ -858,6 +1002,12 @@ "state": "translated", "value": "Ciemny" } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "深色" + } } } }, @@ -1088,7 +1238,7 @@ "fr": { "stringUnit": { "state": "translated", - "value": "LUMIÈRE" + "value": "LÉGER" } }, "pl": { @@ -1096,6 +1246,18 @@ "state": "translated", "value": "ŚWIATŁO" } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "轻度" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "輕度" + } } } }, @@ -1262,7 +1424,7 @@ "fr": { "stringUnit": { "state": "translated", - "value": "MODÉRATE" + "value": "MODÉRÉ" } }, "pl": { @@ -1270,6 +1432,18 @@ "state": "translated", "value": "UMIARKOWANY" } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "中等" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "中等" + } } } }, @@ -1380,6 +1554,18 @@ "state": "translated", "value": "Brak danych dotyczących tętna" } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无心率数据" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無心率資料" + } } } }, @@ -1482,7 +1668,7 @@ "fr": { "stringUnit": { "state": "translated", - "value": "PRIMAIRE" + "value": "PRÉPARÉ" } }, "pl": { @@ -1490,6 +1676,18 @@ "state": "translated", "value": "GRUNTOWANY" } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已就绪" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "就緒" + } } } }, @@ -1528,7 +1726,7 @@ "fr": { "stringUnit": { "state": "translated", - "value": "Calendrier de récupération, %1$lld jours, moyenne %2$lld, faible %3$lld, haute %4$lld" + "value": "Calendrier de récupération, %1$lld jours, moyenne %2$lld, faible %3$lld, haut %4$lld" } }, "pl": { @@ -1536,6 +1734,18 @@ "state": "translated", "value": "Regeneracja kalendarz, %lld dni, średnia %lld, niska %lld, wysoka %lld" } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "恢复日历,%lld 天,平均 %lld,最低 %lld,最高 %lld" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "恢復日曆,%lld 天,平均 %lld,最低 %lld,最高 %lld" + } } } }, @@ -1582,6 +1792,18 @@ "state": "translated", "value": "Kalendarz Regeneracja, brak danych" } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "恢复日历,无数据" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "恢復日曆,無資料" + } } } }, @@ -1620,7 +1842,7 @@ "fr": { "stringUnit": { "state": "translated", - "value": "STRÈNE" + "value": "SOUTENU" } }, "pl": { @@ -1628,6 +1850,18 @@ "state": "translated", "value": "INTENSYWNY" } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "吃力" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "吃力" + } } } }, @@ -1674,6 +1908,18 @@ "state": "translated", "value": "Etapy snu, %@" } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "睡眠阶段,%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "睡眠階段,%@" + } } } }, @@ -1720,6 +1966,18 @@ "state": "translated", "value": "Fazy ​​snu, brak danych" } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "睡眠阶段,无数据" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "睡眠階段,無資料" + } } } }, @@ -1830,6 +2088,18 @@ "state": "translated", "value": "Systemowy" } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "跟随系统" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "跟隨系統" + } } } }, @@ -1932,7 +2202,7 @@ "fr": { "stringUnit": { "state": "translated", - "value": "Tendance, points %1$lld, dernier %2$@, faible %3$@, haut %4$@" + "value": "Tendance, %1$lld points, dernier %2$@, faible %3$@, haut %4$@" } }, "pl": { @@ -1940,6 +2210,18 @@ "state": "translated", "value": "Trend, punkty %lld, najnowsze %@, niskie %@, wysokie %@" } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "趋势,%lld 个数据点,最新 %@,最低 %@,最高 %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "趨勢,%lld 個資料點,最新 %@,最低 %@,最高 %@" + } } } }, @@ -2042,7 +2324,7 @@ "fr": { "stringUnit": { "state": "translated", - "value": "sommeil %1$@" + "value": "%1$@ de sommeil" } }, "pl": { @@ -2050,6 +2332,18 @@ "state": "translated", "value": "śpi %@" } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "睡眠 %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "睡眠 %@" + } } } }, @@ -2096,15 +2390,27 @@ "state": "translated", "value": "średnia %@ bpm" } - } - } - }, - "just now": { - "localizations": { - "de": { + }, + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "gerade eben" + "value": "平均 %@ bpm" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "平均 %@ bpm" + } + } + } + }, + "just now": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "gerade eben" } }, "en": { @@ -2198,7 +2504,7 @@ "fr": { "stringUnit": { "state": "translated", - "value": "gamme %1$@ à %2$@" + "value": "plage %1$@ à %2$@" } }, "pl": { @@ -2206,6 +2512,18 @@ "state": "translated", "value": "zakres od %@ do %@" } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "范围 %@ 至 %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "範圍 %@ 至 %@" + } } } }, @@ -2240,10 +2558,23 @@ "state": "translated", "value": "%lld" } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%lld" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%lld" + } } } }, "0xAA 41 00 1c crc32=f3a1 mono 13": { + "comment": "Xcode #Preview specimen: this literal exists only inside an `#if DEBUG` preview block, so no release build can reach it and it is not user-facing copy. Translating it produced nonsense in the locales held to zero tolerance (fr rendered \"Bold\" as \"Or\", four languages read the animation \"lift\" as an elevator), which is what this flag prevents.", "localizations": { "de": { "stringUnit": { @@ -2275,9 +2606,11 @@ "value": "0xAA 41 00 1c crc32=f3a1 mono 13" } } - } + }, + "shouldTranslate": false }, "51 bpm": { + "comment": "Xcode #Preview specimen: this literal exists only inside an `#if DEBUG` preview block, so no release build can reach it and it is not user-facing copy. Translating it produced nonsense in the locales held to zero tolerance (fr rendered \"Bold\" as \"Or\", four languages read the animation \"lift\" as an elevator), which is what this flag prevents.", "localizations": { "de": { "stringUnit": { @@ -2308,10 +2641,18 @@ "state": "translated", "value": "51 bpm" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "51 уд/мин" + } } - } + }, + "shouldTranslate": false }, "7h 42m asleep · 92% efficiency": { + "comment": "Xcode #Preview specimen: this literal exists only inside an `#if DEBUG` preview block, so no release build can reach it and it is not user-facing copy. Translating it produced nonsense in the locales held to zero tolerance (fr rendered \"Bold\" as \"Or\", four languages read the animation \"lift\" as an elevator), which is what this flag prevents.", "localizations": { "de": { "stringUnit": { @@ -2342,10 +2683,18 @@ "state": "translated", "value": "7h 42m śpi · 92% ewydajność" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "7 ч 42 мин сна · эффективность 92%" + } } - } + }, + "shouldTranslate": false }, "87% · streaming": { + "comment": "Xcode #Preview specimen: this literal exists only inside an `#if DEBUG` preview block, so no release build can reach it and it is not user-facing copy. Translating it produced nonsense in the locales held to zero tolerance (fr rendered \"Bold\" as \"Or\", four languages read the animation \"lift\" as an elevator), which is what this flag prevents.", "localizations": { "de": { "stringUnit": { @@ -2376,10 +2725,18 @@ "state": "translated", "value": "87% · streaming" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "87% · передача" + } } - } + }, + "shouldTranslate": false }, "Add": { + "comment": "Xcode #Preview specimen: this literal exists only inside an `#if DEBUG` preview block, so no release build can reach it and it is not user-facing copy. Translating it produced nonsense in the locales held to zero tolerance (fr rendered \"Bold\" as \"Or\", four languages read the animation \"lift\" as an elevator), which is what this flag prevents.", "localizations": { "de": { "stringUnit": { @@ -2399,6 +2756,12 @@ "value": "Ajouter" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Добавить" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -2417,9 +2780,11 @@ "value": "Dodać" } } - } + }, + "shouldTranslate": false }, "Adopted via NoopButtonStyle": { + "comment": "Xcode #Preview specimen: this literal exists only inside an `#if DEBUG` preview block, so no release build can reach it and it is not user-facing copy. Translating it produced nonsense in the locales held to zero tolerance (fr rendered \"Bold\" as \"Or\", four languages read the animation \"lift\" as an elevator), which is what this flag prevents.", "localizations": { "de": { "stringUnit": { @@ -2450,10 +2815,18 @@ "state": "translated", "value": "Przyjęte przez NoopButtonStyle" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Применяется через NoopButtonStyle" + } } - } + }, + "shouldTranslate": false }, "Bar only": { + "comment": "Xcode #Preview specimen: this literal exists only inside an `#if DEBUG` preview block, so no release build can reach it and it is not user-facing copy. Translating it produced nonsense in the locales held to zero tolerance (fr rendered \"Bold\" as \"Or\", four languages read the animation \"lift\" as an elevator), which is what this flag prevents.", "localizations": { "de": { "stringUnit": { @@ -2484,10 +2857,18 @@ "state": "translated", "value": "Tylko bar" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Только полоса" + } } - } + }, + "shouldTranslate": false }, "Body / Regular 15 — the thread of you, read in full.": { + "comment": "Xcode #Preview specimen: this literal exists only inside an `#if DEBUG` preview block, so no release build can reach it and it is not user-facing copy. Translating it produced nonsense in the locales held to zero tolerance (fr rendered \"Bold\" as \"Or\", four languages read the animation \"lift\" as an elevator), which is what this flag prevents.", "localizations": { "de": { "stringUnit": { @@ -2518,10 +2899,18 @@ "state": "translated", "value": "Body / Regular 15 — wątek o Tobie, przeczytaj w całości." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Body / Regular 15 — нить о вас, прочитанная целиком." + } } - } + }, + "shouldTranslate": false }, "COUNT-UP SCORE": { + "comment": "Xcode #Preview specimen: this literal exists only inside an `#if DEBUG` preview block, so no release build can reach it and it is not user-facing copy. Translating it produced nonsense in the locales held to zero tolerance (fr rendered \"Bold\" as \"Or\", four languages read the animation \"lift\" as an elevator), which is what this flag prevents.", "localizations": { "de": { "stringUnit": { @@ -2552,10 +2941,18 @@ "state": "translated", "value": "PODLICZANIE WYNIKÓW" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "ОЦЕНКА С ОТСЧЁТОМ" + } } - } + }, + "shouldTranslate": false }, "Calm night": { + "comment": "Xcode #Preview specimen: this literal exists only inside an `#if DEBUG` preview block, so no release build can reach it and it is not user-facing copy. Translating it produced nonsense in the locales held to zero tolerance (fr rendered \"Bold\" as \"Or\", four languages read the animation \"lift\" as an elevator), which is what this flag prevents.", "localizations": { "de": { "stringUnit": { @@ -2586,10 +2983,18 @@ "state": "translated", "value": "Spokojna noc" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Спокойная ночь" + } } - } + }, + "shouldTranslate": false }, "Caption 12": { + "comment": "Xcode #Preview specimen: this literal exists only inside an `#if DEBUG` preview block, so no release build can reach it and it is not user-facing copy. Translating it produced nonsense in the locales held to zero tolerance (fr rendered \"Bold\" as \"Or\", four languages read the animation \"lift\" as an elevator), which is what this flag prevents.", "localizations": { "de": { "stringUnit": { @@ -2621,9 +3026,11 @@ "value": "Podpis 12" } } - } + }, + "shouldTranslate": false }, "Card %lld": { + "comment": "Xcode #Preview specimen: this literal exists only inside an `#if DEBUG` preview block, so no release build can reach it and it is not user-facing copy. Translating it produced nonsense in the locales held to zero tolerance (fr rendered \"Bold\" as \"Or\", four languages read the animation \"lift\" as an elevator), which is what this flag prevents.", "localizations": { "de": { "stringUnit": { @@ -2654,8 +3061,15 @@ "state": "translated", "value": "Karta %lld" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Карточка %lld" + } } - } + }, + "shouldTranslate": false }, "Done": { "localizations": { @@ -2677,6 +3091,12 @@ "value": "Terminé" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Готово" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -2694,10 +3114,17 @@ "state": "translated", "value": "Gotowe" } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "完成" + } } } }, "Footnote 11": { + "comment": "Xcode #Preview specimen: this literal exists only inside an `#if DEBUG` preview block, so no release build can reach it and it is not user-facing copy. Translating it produced nonsense in the locales held to zero tolerance (fr rendered \"Bold\" as \"Or\", four languages read the animation \"lift\" as an elevator), which is what this flag prevents.", "localizations": { "de": { "stringUnit": { @@ -2729,9 +3156,11 @@ "value": "Przypis 11" } } - } + }, + "shouldTranslate": false }, "HRV (ms) — 30 days": { + "comment": "Xcode #Preview specimen: this literal exists only inside an `#if DEBUG` preview block, so no release build can reach it and it is not user-facing copy. Translating it produced nonsense in the locales held to zero tolerance (fr rendered \"Bold\" as \"Or\", four languages read the animation \"lift\" as an elevator), which is what this flag prevents.", "localizations": { "de": { "stringUnit": { @@ -2762,10 +3191,18 @@ "state": "translated", "value": "HRV (ms) — 30 dni" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "ВСР (мс) — 30 дней" + } } - } + }, + "shouldTranslate": false }, "Headline / Semibold 17": { + "comment": "Xcode #Preview specimen: this literal exists only inside an `#if DEBUG` preview block, so no release build can reach it and it is not user-facing copy. Translating it produced nonsense in the locales held to zero tolerance (fr rendered \"Bold\" as \"Or\", four languages read the animation \"lift\" as an elevator), which is what this flag prevents.", "localizations": { "de": { "stringUnit": { @@ -2797,7 +3234,8 @@ "value": "Nagłówek / Półpogrubiony 17" } } - } + }, + "shouldTranslate": false }, "Heart rate, 24 hours": { "localizations": { @@ -2830,10 +3268,29 @@ "state": "translated", "value": "Tętno, 24 godziny" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пульс, 24 часа" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "心率,24 小时" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "心率,24 小時" + } } } }, "Hover a band: stage name, clock start–end and duration.": { + "comment": "Xcode #Preview specimen: this literal exists only inside an `#if DEBUG` preview block, so no release build can reach it and it is not user-facing copy. Translating it produced nonsense in the locales held to zero tolerance (fr rendered \"Bold\" as \"Or\", four languages read the animation \"lift\" as an elevator), which is what this flag prevents.", "localizations": { "de": { "stringUnit": { @@ -2864,10 +3321,18 @@ "state": "translated", "value": "Najedź kursorem na zespół: nazwa sceny, początek i koniec zegara oraz czas trwania." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Наведите на полосу: название стадии, время начала и конца и длительность." + } } - } + }, + "shouldTranslate": false }, "Hover a cell: ring + date, score and recovery-state tooltip.": { + "comment": "Xcode #Preview specimen: this literal exists only inside an `#if DEBUG` preview block, so no release build can reach it and it is not user-facing copy. Translating it produced nonsense in the locales held to zero tolerance (fr rendered \"Bold\" as \"Or\", four languages read the animation \"lift\" as an elevator), which is what this flag prevents.", "localizations": { "de": { "stringUnit": { @@ -2898,10 +3363,18 @@ "state": "translated", "value": "Najedź kursorem na komórkę: pierścień + data, wynik i podpowiedź dotycząca stanu odzyskiwania." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Наведите на ячейку: кольцо и дата, оценка и подсказка о состоянии восстановления." + } } - } + }, + "shouldTranslate": false }, "Hover a gauge for a strain + load-word tooltip.": { + "comment": "Xcode #Preview specimen: this literal exists only inside an `#if DEBUG` preview block, so no release build can reach it and it is not user-facing copy. Translating it produced nonsense in the locales held to zero tolerance (fr rendered \"Bold\" as \"Or\", four languages read the animation \"lift\" as an elevator), which is what this flag prevents.", "localizations": { "de": { "stringUnit": { @@ -2932,10 +3405,18 @@ "state": "translated", "value": "Najedź kursorem na wskaźnik obciążenia i podpowiedzi dotyczącego słowa ładowania." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Наведите на индикатор, чтобы увидеть подсказку с нагрузкой и её словесной оценкой." + } } - } + }, + "shouldTranslate": false }, "Hover a ring for a recovery + state-word tooltip.": { + "comment": "Xcode #Preview specimen: this literal exists only inside an `#if DEBUG` preview block, so no release build can reach it and it is not user-facing copy. Translating it produced nonsense in the locales held to zero tolerance (fr rendered \"Bold\" as \"Or\", four languages read the animation \"lift\" as an elevator), which is what this flag prevents.", "localizations": { "de": { "stringUnit": { @@ -2966,10 +3447,18 @@ "state": "translated", "value": "Najedź kursorem na pierścień, aby odzyskać + podpowiedź ze słowem stanu." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Наведите на кольцо, чтобы увидеть подсказку с восстановлением и его словесной оценкой." + } } - } + }, + "shouldTranslate": false }, "Hover any sparkline to read the exact sample under the cursor.": { + "comment": "Xcode #Preview specimen: this literal exists only inside an `#if DEBUG` preview block, so no release build can reach it and it is not user-facing copy. Translating it produced nonsense in the locales held to zero tolerance (fr rendered \"Bold\" as \"Or\", four languages read the animation \"lift\" as an elevator), which is what this flag prevents.", "localizations": { "de": { "stringUnit": { @@ -3000,10 +3489,18 @@ "state": "translated", "value": "Najedź kursorem na dowolną linię przebiegu, aby odczytać dokładną próbkę pod kursorem." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Наведите на любой мини-график, чтобы прочитать точное значение под курсором." + } } - } + }, + "shouldTranslate": false }, "Hover the cards to see the lift.": { + "comment": "Xcode #Preview specimen: this literal exists only inside an `#if DEBUG` preview block, so no release build can reach it and it is not user-facing copy. Translating it produced nonsense in the locales held to zero tolerance (fr rendered \"Bold\" as \"Or\", four languages read the animation \"lift\" as an elevator), which is what this flag prevents.", "localizations": { "de": { "stringUnit": { @@ -3034,10 +3531,18 @@ "state": "translated", "value": "Najedź kursorem na karty, aby zobaczyć windę." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Наведите на карточки, чтобы увидеть подъём." + } } - } + }, + "shouldTranslate": false }, "Hover the line: crosshair + dot + date/value tooltip.": { + "comment": "Xcode #Preview specimen: this literal exists only inside an `#if DEBUG` preview block, so no release build can reach it and it is not user-facing copy. Translating it produced nonsense in the locales held to zero tolerance (fr rendered \"Bold\" as \"Or\", four languages read the animation \"lift\" as an elevator), which is what this flag prevents.", "localizations": { "de": { "stringUnit": { @@ -3068,10 +3573,18 @@ "state": "translated", "value": "Najedź kursorem na linię: celownik + kropka + podpowiedź z datą/wartością." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Наведите на линию: перекрестие, точка и подсказка с датой и значением." + } } - } + }, + "shouldTranslate": false }, "Hover to read each day's HRV in ms.": { + "comment": "Xcode #Preview specimen: this literal exists only inside an `#if DEBUG` preview block, so no release build can reach it and it is not user-facing copy. Translating it produced nonsense in the locales held to zero tolerance (fr rendered \"Bold\" as \"Or\", four languages read the animation \"lift\" as an elevator), which is what this flag prevents.", "localizations": { "de": { "stringUnit": { @@ -3102,10 +3615,18 @@ "state": "translated", "value": "Najedź kursorem, aby przeczytać HRV każdego dnia w ms." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Наведите, чтобы прочитать ВСР каждого дня в мс." + } } - } + }, + "shouldTranslate": false }, "In a card": { + "comment": "Xcode #Preview specimen: this literal exists only inside an `#if DEBUG` preview block, so no release build can reach it and it is not user-facing copy. Translating it produced nonsense in the locales held to zero tolerance (fr rendered \"Bold\" as \"Or\", four languages read the animation \"lift\" as an elevator), which is what this flag prevents.", "localizations": { "de": { "stringUnit": { @@ -3136,10 +3657,18 @@ "state": "translated", "value": "W karcie" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "В карточке" + } } - } + }, + "shouldTranslate": false }, "Last night": { + "comment": "Xcode #Preview specimen: this literal exists only inside an `#if DEBUG` preview block, so no release build can reach it and it is not user-facing copy. Translating it produced nonsense in the locales held to zero tolerance (fr rendered \"Bold\" as \"Or\", four languages read the animation \"lift\" as an elevator), which is what this flag prevents.", "localizations": { "de": { "stringUnit": { @@ -3170,8 +3699,15 @@ "state": "translated", "value": "Ubiegłej nocy" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Последняя ночь" + } } - } + }, + "shouldTranslate": false }, "Movement during sleep": { "localizations": { @@ -3204,10 +3740,29 @@ "state": "translated", "value": "Ruch podczas snu" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Движение во сне" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "睡眠期间的身体活动" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "睡眠期間的身體活動" + } } } }, "Overline": { + "comment": "Xcode #Preview specimen: this literal exists only inside an `#if DEBUG` preview block, so no release build can reach it and it is not user-facing copy. Translating it produced nonsense in the locales held to zero tolerance (fr rendered \"Bold\" as \"Or\", four languages read the animation \"lift\" as an elevator), which is what this flag prevents.", "localizations": { "de": { "stringUnit": { @@ -3239,9 +3794,11 @@ "value": "Nadkreśl" } } - } + }, + "shouldTranslate": false }, "Overview HR": { + "comment": "Xcode #Preview specimen: this literal exists only inside a preview block, so no release build can reach it and it is not user-facing copy. Unlike its neighbours this preview is not wrapped in `#if DEBUG`, which is why the first pass over this catalogue read it as shipped copy and translated it.", "localizations": { "de": { "stringUnit": { @@ -3272,10 +3829,30 @@ "state": "translated", "value": "Przegląd HR" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пульс, обзор" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "概览心率" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "概覽心率" + } } - } + }, + "shouldTranslate": false }, "RECOVERY GRADIENT": { + "comment": "Xcode #Preview specimen: this literal exists only inside an `#if DEBUG` preview block, so no release build can reach it and it is not user-facing copy. Translating it produced nonsense in the locales held to zero tolerance (fr rendered \"Bold\" as \"Or\", four languages read the animation \"lift\" as an elevator), which is what this flag prevents.", "localizations": { "de": { "stringUnit": { @@ -3306,10 +3883,18 @@ "state": "translated", "value": "GRADIDENT ODZYSKU" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "ГРАДИЕНТ ВОССТАНОВЛЕНИЯ" + } } - } + }, + "shouldTranslate": false }, "Recovery — 30 days": { + "comment": "Xcode #Preview specimen: this literal exists only inside an `#if DEBUG` preview block, so no release build can reach it and it is not user-facing copy. Translating it produced nonsense in the locales held to zero tolerance (fr rendered \"Bold\" as \"Or\", four languages read the animation \"lift\" as an elevator), which is what this flag prevents.", "localizations": { "de": { "stringUnit": { @@ -3340,10 +3925,18 @@ "state": "translated", "value": "Regeneracja — 30 dni" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Восстановление — 30 дней" + } } - } + }, + "shouldTranslate": false }, "Recovery — past year": { + "comment": "Xcode #Preview specimen: this literal exists only inside an `#if DEBUG` preview block, so no release build can reach it and it is not user-facing copy. Translating it produced nonsense in the locales held to zero tolerance (fr rendered \"Bold\" as \"Or\", four languages read the animation \"lift\" as an elevator), which is what this flag prevents.", "localizations": { "de": { "stringUnit": { @@ -3374,10 +3967,18 @@ "state": "translated", "value": "Regeneracja — ubiegły rok" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Восстановление — за прошлый год" + } } - } + }, + "shouldTranslate": false }, "Reduce Motion renders static at the final frame.": { + "comment": "Xcode #Preview specimen: this literal exists only inside an `#if DEBUG` preview block, so no release build can reach it and it is not user-facing copy. Translating it produced nonsense in the locales held to zero tolerance (fr rendered \"Bold\" as \"Or\", four languages read the animation \"lift\" as an elevator), which is what this flag prevents.", "localizations": { "de": { "stringUnit": { @@ -3408,10 +4009,18 @@ "state": "translated", "value": "Zmniejsz ruch renderuje statycznie w ostatniej klatce." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "При включённом «Уменьшении движения» отображается статичный последний кадр." + } } - } + }, + "shouldTranslate": false }, "Remove": { + "comment": "Xcode #Preview specimen: this literal exists only inside an `#if DEBUG` preview block, so no release build can reach it and it is not user-facing copy. Translating it produced nonsense in the locales held to zero tolerance (fr rendered \"Bold\" as \"Or\", four languages read the animation \"lift\" as an elevator), which is what this flag prevents.", "localizations": { "de": { "stringUnit": { @@ -3431,6 +4040,12 @@ "value": "Supprimer" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удалить" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -3449,9 +4064,11 @@ "value": "Usunąć" } } - } + }, + "shouldTranslate": false }, "Replay reveal": { + "comment": "Xcode #Preview specimen: this literal exists only inside an `#if DEBUG` preview block, so no release build can reach it and it is not user-facing copy. Translating it produced nonsense in the locales held to zero tolerance (fr rendered \"Bold\" as \"Or\", four languages read the animation \"lift\" as an elevator), which is what this flag prevents.", "localizations": { "de": { "stringUnit": { @@ -3482,10 +4099,18 @@ "state": "translated", "value": "Odtwórz ponownie" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Повторить появление" + } } - } + }, + "shouldTranslate": false }, "Resting HR": { + "comment": "Xcode #Preview specimen: this literal exists only inside an `#if DEBUG` preview block, so no release build can reach it and it is not user-facing copy. Translating it produced nonsense in the locales held to zero tolerance (fr rendered \"Bold\" as \"Or\", four languages read the animation \"lift\" as an elevator), which is what this flag prevents.", "localizations": { "de": { "stringUnit": { @@ -3505,6 +4130,12 @@ "value": "FC au repos" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пульс в покое" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -3523,9 +4154,11 @@ "value": "Odpoczynkowe tętno" } } - } + }, + "shouldTranslate": false }, "Restless night": { + "comment": "Xcode #Preview specimen: this literal exists only inside an `#if DEBUG` preview block, so no release build can reach it and it is not user-facing copy. Translating it produced nonsense in the locales held to zero tolerance (fr rendered \"Bold\" as \"Or\", four languages read the animation \"lift\" as an elevator), which is what this flag prevents.", "localizations": { "de": { "stringUnit": { @@ -3556,10 +4189,18 @@ "state": "translated", "value": "Niespokojna noc" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Беспокойная ночь" + } } - } + }, + "shouldTranslate": false }, "Roll the number": { + "comment": "Xcode #Preview specimen: this literal exists only inside an `#if DEBUG` preview block, so no release build can reach it and it is not user-facing copy. Translating it produced nonsense in the locales held to zero tolerance (fr rendered \"Bold\" as \"Or\", four languages read the animation \"lift\" as an elevator), which is what this flag prevents.", "localizations": { "de": { "stringUnit": { @@ -3590,10 +4231,18 @@ "state": "translated", "value": "Rzuć numerem" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Прокрутить число" + } } - } + }, + "shouldTranslate": false }, "SOFT CARD TRANSITION": { + "comment": "Xcode #Preview specimen: this literal exists only inside an `#if DEBUG` preview block, so no release build can reach it and it is not user-facing copy. Translating it produced nonsense in the locales held to zero tolerance (fr rendered \"Bold\" as \"Or\", four languages read the animation \"lift\" as an elevator), which is what this flag prevents.", "localizations": { "de": { "stringUnit": { @@ -3624,10 +4273,18 @@ "state": "translated", "value": "MIĘKKIE PRZEJŚCIE KARTY" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "МЯГКИЙ ПЕРЕХОД КАРТОЧКИ" + } } - } + }, + "shouldTranslate": false }, "STAGGERED APPEAR": { + "comment": "Xcode #Preview specimen: this literal exists only inside an `#if DEBUG` preview block, so no release build can reach it and it is not user-facing copy. Translating it produced nonsense in the locales held to zero tolerance (fr rendered \"Bold\" as \"Or\", four languages read the animation \"lift\" as an elevator), which is what this flag prevents.", "localizations": { "de": { "stringUnit": { @@ -3658,10 +4315,18 @@ "state": "translated", "value": "WYGLĄDAJĄ ZNACZNIE" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "ПОСЛЕДОВАТЕЛЬНОЕ ПОЯВЛЕНИЕ" + } } - } + }, + "shouldTranslate": false }, "STRAIN RAMP": { + "comment": "Xcode #Preview specimen: this literal exists only inside an `#if DEBUG` preview block, so no release build can reach it and it is not user-facing copy. Translating it produced nonsense in the locales held to zero tolerance (fr rendered \"Bold\" as \"Or\", four languages read the animation \"lift\" as an elevator), which is what this flag prevents.", "localizations": { "de": { "stringUnit": { @@ -3692,8 +4357,15 @@ "state": "translated", "value": "RAMPA OBCIĄŻENIA" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "НАРАСТАНИЕ НАГРУЗКИ" + } } - } + }, + "shouldTranslate": false }, "Save": { "localizations": { @@ -3715,6 +4387,12 @@ "value": "Enregistrer" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сохранить" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -3732,10 +4410,17 @@ "state": "translated", "value": "Zapisz" } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "儲存" + } } } }, "Segment counts & heights": { + "comment": "Xcode #Preview specimen: this literal exists only inside an `#if DEBUG` preview block, so no release build can reach it and it is not user-facing copy. Translating it produced nonsense in the locales held to zero tolerance (fr rendered \"Bold\" as \"Or\", four languages read the animation \"lift\" as an elevator), which is what this flag prevents.", "localizations": { "de": { "stringUnit": { @@ -3766,10 +4451,18 @@ "state": "translated", "value": "Liczba segmentów i wysokości" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Количество и высота сегментов" + } } - } + }, + "shouldTranslate": false }, "Sleep performance": { + "comment": "Xcode #Preview specimen: this literal exists only inside an `#if DEBUG` preview block, so no release build can reach it and it is not user-facing copy. Translating it produced nonsense in the locales held to zero tolerance (fr rendered \"Bold\" as \"Or\", four languages read the animation \"lift\" as an elevator), which is what this flag prevents.", "localizations": { "de": { "stringUnit": { @@ -3800,10 +4493,18 @@ "state": "translated", "value": "Wydajność snu" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Качество сна" + } } - } + }, + "shouldTranslate": false }, "Sleep stages": { + "comment": "Xcode #Preview specimen: this literal exists only inside an `#if DEBUG` preview block, so no release build can reach it and it is not user-facing copy. Translating it produced nonsense in the locales held to zero tolerance (fr rendered \"Bold\" as \"Or\", four languages read the animation \"lift\" as an elevator), which is what this flag prevents.", "localizations": { "de": { "stringUnit": { @@ -3834,10 +4535,18 @@ "state": "translated", "value": "Etapy snu" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Стадии сна" + } } - } + }, + "shouldTranslate": false }, "Subhead 13": { + "comment": "Xcode #Preview specimen: this literal exists only inside an `#if DEBUG` preview block, so no release build can reach it and it is not user-facing copy. Translating it produced nonsense in the locales held to zero tolerance (fr rendered \"Bold\" as \"Or\", four languages read the animation \"lift\" as an elevator), which is what this flag prevents.", "localizations": { "de": { "stringUnit": { @@ -3869,9 +4578,11 @@ "value": "Podtytuł 13" } } - } + }, + "shouldTranslate": false }, "Title 1 / Bold 28": { + "comment": "Xcode #Preview specimen: this literal exists only inside an `#if DEBUG` preview block, so no release build can reach it and it is not user-facing copy. Translating it produced nonsense in the locales held to zero tolerance (fr rendered \"Bold\" as \"Or\", four languages read the animation \"lift\" as an elevator), which is what this flag prevents.", "localizations": { "de": { "stringUnit": { @@ -3903,9 +4614,11 @@ "value": "Tytuł 1 / Pogrubienie 28" } } - } + }, + "shouldTranslate": false }, "Title 2 / Semibold 22": { + "comment": "Xcode #Preview specimen: this literal exists only inside an `#if DEBUG` preview block, so no release build can reach it and it is not user-facing copy. Translating it produced nonsense in the locales held to zero tolerance (fr rendered \"Bold\" as \"Or\", four languages read the animation \"lift\" as an elevator), which is what this flag prevents.", "localizations": { "de": { "stringUnit": { @@ -3937,9 +4650,11 @@ "value": "Tytuł 2 / Półgruby 22" } } - } + }, + "shouldTranslate": false }, "Toggle gentle spring": { + "comment": "Xcode #Preview specimen: this literal exists only inside an `#if DEBUG` preview block, so no release build can reach it and it is not user-facing copy. Translating it produced nonsense in the locales held to zero tolerance (fr rendered \"Bold\" as \"Or\", four languages read the animation \"lift\" as an elevator), which is what this flag prevents.", "localizations": { "de": { "stringUnit": { @@ -3970,10 +4685,18 @@ "state": "translated", "value": "Przełącz delikatną wiosnę" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Переключить мягкую пружину" + } } - } + }, + "shouldTranslate": false }, "Values": { + "comment": "Xcode #Preview specimen: this literal exists only inside an `#if DEBUG` preview block, so no release build can reach it and it is not user-facing copy. Translating it produced nonsense in the locales held to zero tolerance (fr rendered \"Bold\" as \"Or\", four languages read the animation \"lift\" as an elevator), which is what this flag prevents.", "localizations": { "de": { "stringUnit": { @@ -4004,10 +4727,18 @@ "state": "translated", "value": "Wartości" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Значения" + } } - } + }, + "shouldTranslate": false }, "Whoop 4.0": { + "comment": "Xcode #Preview specimen: this literal exists only inside an `#if DEBUG` preview block, so no release build can reach it and it is not user-facing copy. Translating it produced nonsense in the locales held to zero tolerance (fr rendered \"Bold\" as \"Or\", four languages read the animation \"lift\" as an elevator), which is what this flag prevents.", "localizations": { "de": { "stringUnit": { @@ -4039,9 +4770,11 @@ "value": "Whoop 4.0" } } - } + }, + "shouldTranslate": false }, "hour %lld": { + "comment": "Xcode #Preview specimen: this literal exists only inside an `#if DEBUG` preview block, so no release build can reach it and it is not user-facing copy. Translating it produced nonsense in the locales held to zero tolerance (fr rendered \"Bold\" as \"Or\", four languages read the animation \"lift\" as an elevator), which is what this flag prevents.", "localizations": { "de": { "stringUnit": { @@ -4072,8 +4805,15 @@ "state": "translated", "value": "godzina %lld" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "час %lld" + } } - } + }, + "shouldTranslate": false }, "Clear search": { "localizations": { @@ -4638,6 +5378,71 @@ } } } + }, + "appearance.light": { + "comment": "The APPEARANCE mode, the light half of System / Light / Dark. It carries an explicit key because \"Light\" is also a sleep stage, and a catalogue key is its English string, so one entry served both and every locale translated the sleep one: Léger, Luz, Światło, 浅睡. Translate this as the opposite of Dark, never as a weight or a lamp.", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Hell" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Light" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Claro" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Clair" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Chiaro" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Jasny" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Claro" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Светлая" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "浅色" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "淺色" + } + } + } } }, "version": "1.0" diff --git a/Packages/StrandDesign/Sources/StrandDesign/SportIcon.swift b/Packages/StrandDesign/Sources/StrandDesign/SportIcon.swift index 56e3e21c96..2ecb3548a1 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/SportIcon.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/SportIcon.swift @@ -15,6 +15,23 @@ import AppKit public enum KnownWorkoutType: String, CaseIterable, Sendable { case running = "Running" case walking = "Walking" + case nordicWalking = "Nordic walking" + case ballet = "Ballet" + case billiards = "Billiards" + case breakdancing = "Breakdancing" + case cheerleading = "Cheerleading" + case darts = "Darts" + case discGolf = "Disc golf" + case hurlingCamogie = "Hurling/Camogie" + case jiuJitsu = "Jiu jitsu" + case judo = "Judo" + case kiteboarding = "Kiteboarding" + case motocross = "Motocross" + case muayThai = "Muay Thai" + case paintball = "Paintball" + case parkour = "Parkour" + case polo = "Polo" + case skydiving = "Skydiving" case hiking = "Hiking" case cycling = "Cycling" case openWaterSwim = "Open-water swim" @@ -154,8 +171,12 @@ public enum KnownWorkoutType: String, CaseIterable, Sendable { case s.contains("baseball"): return .baseball case s.contains("volley"): return .volleyball case s.contains("martial") || s.contains("jiu") || s.contains("judo") - || s.contains("karate") || s.contains("mma"): return .martialArts - case s.contains("dance"): return .dancing + || s.contains("karate") || s.contains("mma") || s.contains("muay"): + return .martialArts + // "danc" rather than "dance": the common inflection is "dancing", which does not contain + // "dance", so the exact catalogue name was the only thing matching. Breakdancing and any + // free-typed "dancing" fell through to the generic icon. + case s.contains("danc") || s.contains("ballet"): return .dancing case s.contains("golf"): return .golf case s.contains("climb"): return .climbing case s.contains("stretch") || s.contains("mobility") || s.contains("flex"): @@ -206,6 +227,49 @@ public enum WorkoutTypeIconography { return .system("figure.run") case .walking: return .system("figure.walk") + // The WHOOP-parity sports. Each needs a glyph no other sport uses: the live-workout + // control draws them side by side, and testCatalogueSportsHaveUniqueWorkoutTypeIcons + // enforces it. Several of these are approximations rather than true depictions, marked + // below, and each carries a fallback for an OS without the preferred symbol. + // Nordic walking: poles make it a different gait from a plain walk. + case .nordicWalking: + return systemOrCustom("figure.walk.motion", fallbackSystem: "figure.walk") + case .ballet: + return systemOrCustom("figure.barre", fallbackSystem: "figure.dance") + // Billiards: the eight ball, for want of a cue-sport symbol. + case .billiards: + return systemOrCustom("8.circle.fill", fallbackSystem: "circle.fill") + case .breakdancing: + return systemOrCustom("figure.socialdance", fallbackSystem: "figure.dance") + case .cheerleading: + return systemOrCustom("megaphone.fill", fallbackSystem: "star.fill") + case .darts: + return systemOrCustom("target", fallbackSystem: "circle.circle") + // Disc golf: the basket flag; figure.disc.sports belongs to Frisbee. + case .discGolf: + return systemOrCustom("flag.fill", fallbackSystem: "figure.disc.sports") + case .hurlingCamogie: + return systemOrCustom("sportscourt.fill", fallbackSystem: "figure.hockey") + case .jiuJitsu: + return systemOrCustom("figure.wrestling", fallbackSystem: "figure.martial.arts") + case .judo: + return systemOrCustom("figure.taichi", fallbackSystem: "figure.martial.arts") + case .kiteboarding: + return systemOrCustom("wind", fallbackSystem: "figure.surfing") + case .motocross: + return systemOrCustom("fuelpump.fill", fallbackSystem: "steeringwheel") + // Muay Thai: a strike; the three obvious martial symbols are taken. + case .muayThai: + return systemOrCustom("hand.raised.fill", fallbackSystem: "figure.martial.arts") + case .paintball: + return systemOrCustom("scope", fallbackSystem: "target") + case .parkour: + return systemOrCustom("figure.play", fallbackSystem: "figure.run") + // Polo: figure.equestrian.sports belongs to Horseback riding. + case .polo: + return systemOrCustom("flag.2.crossed.fill", fallbackSystem: "figure.equestrian.sports") + case .skydiving: + return systemOrCustom("airplane.departure", fallbackSystem: "wind") case .hiking: return systemOrCustom("figure.hiking", .hikingStick) case .cycling: @@ -335,6 +399,23 @@ public enum WorkoutTypeIconography { switch type { case .running: return "system:figure.run" case .walking: return "system:figure.walk" + case .nordicWalking: return "system:figure.walk.motion" + case .ballet: return "system:figure.barre" + case .billiards: return "system:8.circle.fill" + case .breakdancing: return "system:figure.socialdance" + case .cheerleading: return "system:megaphone.fill" + case .darts: return "system:target" + case .discGolf: return "system:flag.fill" + case .hurlingCamogie: return "system:sportscourt.fill" + case .jiuJitsu: return "system:figure.wrestling" + case .judo: return "system:figure.taichi" + case .kiteboarding: return "system:wind" + case .motocross: return "system:fuelpump.fill" + case .muayThai: return "system:hand.raised.fill" + case .paintball: return "system:scope" + case .parkour: return "system:figure.play" + case .polo: return "system:flag.2.crossed.fill" + case .skydiving: return "system:airplane.departure" case .hiking: return "system:figure.hiking" case .cycling: return "system:figure.outdoor.cycle" case .openWaterSwim: return "system:figure.open.water.swim" diff --git a/Packages/StrandDesign/Sources/StrandDesign/TrendChart.swift b/Packages/StrandDesign/Sources/StrandDesign/TrendChart.swift index a78a255a91..bca7b1ab29 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/TrendChart.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/TrendChart.swift @@ -12,6 +12,46 @@ import Charts // score), but any gradient + value-range can be supplied — pass the blue sleep // ramp for sleep, the teal HRV scale for HRV, the amber strain ramp for strain. +/// The index runs that `hrGapSegments` implies: one range per unbroken stretch, in order. +/// +/// Charts can hand a segment id to the plotting library and let it split the line. A hand-drawn sparkline +/// cannot, so it needs the runs themselves to know where to lift the pen. Same rule, same source of truth, +/// rather than a second walk that could disagree with the first (#2082). +/// +/// An empty input yields no runs. A run of one is still a run: a lone bucket between two gaps is real data +/// and a caller that drops it would be hiding a reading rather than a gap. +public func hrGapRuns(segments: [String]) -> [ClosedRange] { + guard !segments.isEmpty else { return [] } + var runs: [ClosedRange] = [] + var start = 0 + for i in 1.. [String] { + var segment = 0 + return bucketTs.enumerated().map { i, ts in + if i > 0, ts - bucketTs[i - 1] > bucketSeconds { segment += 1 } + return String(segment) + } +} + /// One point on a trend line. public struct TrendPoint: Identifiable, Sendable { public var date: Date @@ -34,6 +74,47 @@ public struct TrendPoint: Identifiable, Sendable { public struct TrendChart: View { + /// The days the x-axis puts a mark on: day-aligned, distinct, newest always among them. + /// + /// The axis used to ask Swift Charts for a COUNT (`.automatic(desiredCount: 5)`) and let it choose the + /// stride. Over a short window the stride it chooses is sub-day, so more than one mark lands inside a + /// single calendar day, every one of them formats to the same date, and they print on top of each + /// other. The reported screenshot is that duplication, not crowding: a wider card would have spread + /// the duplicates apart and left them just as wrong. + /// + /// Explicit dates rather than `.stride(by: .day, count:)`, for two reasons. A stride steps from the + /// domain's LOWER bound, so on a 30-day window it marks days 0/6/12/18/24 and leaves the newest day, + /// the one a trend is usually read for, unlabelled. And `.stride` carries `roundLowerBound` / + /// `roundUpperBound`, which can widen an inferred domain and put dead space at the chart edges. Naming + /// the days outright settles both, and cannot alter the domain at all. + /// + /// Walks BACK from the newest day so that one is always present, then reverses into ascending order, + /// which is what `AxisMarks(values:)` expects. + static func xAxisDays(spanning dates: [Date], + targetLabels: Int = 5, + calendar: Calendar = .current) -> [Date] { + guard targetLabels > 0, let first = dates.min(), let last = dates.max() else { return [] } + let lo = calendar.startOfDay(for: first) + let hi = calendar.startOfDay(for: last) + let span = calendar.dateComponents([.day], from: lo, to: hi).day ?? 0 + guard span > 0 else { return [hi] } + // Days INCLUSIVE of both ends: a 4-day span carries 5 days, which is 5 labels a day apart. + let stride = max(1, Int((Double(span + 1) / Double(targetLabels)).rounded(.up))) + var days: [Date] = [] + var day = hi + while day >= lo { + days.append(day) + guard let previous = calendar.date(byAdding: .day, value: -stride, to: day) else { break } + // Re-anchored every step, because a day-add preserves the TIME of day and in a zone that + // shifts its clocks at midnight the 00:00 it aims for does not exist on the transition date: + // Foundation hands back 01:00 instead. Left alone, that one stepped mark is off the day + // boundary and every mark after it inherits the 01:00, so the tail of the axis drifts. In + // America/Santiago a sweep of two years' windows put 52 marks at 01:00 before this line. + day = calendar.startOfDay(for: previous) + } + return days.reversed() + } + public var points: [TrendPoint] /// The gradient the line/area is stroked with (defaults to the recovery scale). public var gradient: Gradient @@ -45,6 +126,18 @@ public struct TrendChart: View { /// filled `BarMark` per (down-sampled) sample. Display-only — the plotted series is identical; only /// the mark geometry changes. Default false (the classic line). `showsArea` is ignored in bar mode. public var showsBars: Bool + public var yAxisStep: Double? + public var showsBarValues: Bool + public var largeSelection: Bool + @State private var selectedPoint: TrendPoint? + @State private var holdingBar = false + + /// Optional personal-baseline reference, drawn as a dashed rule UNDER the series. + /// + /// A reference the readings are judged against, not a second series, so it is dashed and faint. Nil + /// (the default) draws nothing, and the rule rides the chart's own y domain, so a value outside the + /// plotted range simply falls off it rather than being clamped to an edge it does not sit on. + public var baselineValue: Double? public var height: CGFloat /// Whether hovering reveals a crosshair + tooltip for the nearest point. public var showsHover: Bool @@ -79,13 +172,17 @@ public struct TrendChart: View { valueRange: ClosedRange = 0...100, showsArea: Bool = true, showsBars: Bool = false, + baselineValue: Double? = nil, height: CGFloat = 220, showsHover: Bool = true, valueFormat: @escaping (Double) -> String = { String(Int($0.rounded())) }, dateFormat: @escaping (Date) -> String = { TrendChart.defaultDateString($0) }, accessibilityLabel: String? = nil, nowCapColor: Color? = nil, - yDomain: ClosedRange? = nil + yDomain: ClosedRange? = nil, + yAxisStep: Double? = nil, + showsBarValues: Bool = false, + largeSelection: Bool = false ) { let sorted = points.sorted { $0.date < $1.date } self.points = sorted @@ -93,6 +190,7 @@ public struct TrendChart: View { self.valueRange = valueRange self.showsArea = showsArea self.showsBars = showsBars + self.baselineValue = baselineValue self.height = height self.showsHover = showsHover self.valueFormat = valueFormat @@ -100,6 +198,9 @@ public struct TrendChart: View { self.accessibilityLabel = accessibilityLabel self.nowCapColor = nowCapColor self.yDomain = yDomain + self.yAxisStep = yAxisStep + self.showsBarValues = showsBarValues + self.largeSelection = largeSelection let avg = sorted.isEmpty ? valueRange.lowerBound : sorted.map(\.value).reduce(0, +) / Double(sorted.count) @@ -177,11 +278,30 @@ public struct TrendChart: View { /// unaffected. Exposed internally alongside `resolvedYDomain` for the same test-without-rendering /// reason. var plotYDomain: ClosedRange { - showsBars ? min(0, resolvedYDomain.lowerBound)...resolvedYDomain.upperBound : resolvedYDomain + if let step = yAxisStep, step > 0 { + return 0...max(step, ceil((points.map(\.value).max() ?? 0) / step) * step) + } + return showsBars ? min(0, resolvedYDomain.lowerBound)...resolvedYDomain.upperBound : resolvedYDomain } public var body: some View { + // Resolve against current data so the marker and readout never refer to a removed date. + let currentSelection = selectedPoint.flatMap { selected in points.first { $0.date == selected.date } } + VStack(alignment: .leading, spacing: 8) { + if largeSelection { + let point = currentSelection ?? points.last + VStack(alignment: .leading, spacing: 3) { + Text(point.map { dateFormat($0.date) } ?? "—").font(.headline) + Text(point.map { valueFormat($0.value) } ?? "—").font(.title2.bold()).monospacedDigit() + } + .foregroundStyle(StrandPalette.textPrimary) + } Chart { + if let baselineValue { + RuleMark(y: .value("Baseline", baselineValue)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 4])) + .foregroundStyle(.secondary.opacity(0.45)) + } if showsBars { // Bar mode: one value-ramp-filled BarMark per (down-sampled) sample, from the baseline. // The line, area and point marks are all replaced. The same `displayPoints` feed it, so a @@ -193,6 +313,20 @@ public struct TrendChart: View { y: .value("Value", p.value) ) .foregroundStyle(valueGradient) + .cornerRadius(min(2, max(0, CGFloat(p.value / max(1, plotYDomain.upperBound)) * height * 0.2))) + .opacity(holdingBar && currentSelection != nil && currentSelection?.date != p.date ? 0.3 : 1) + .annotation(position: .top, spacing: 3) { + if showsBarValues { + Text(p.value.formatted(.number.precision(.fractionLength(0)))) + .font(.system(size: 9, weight: .medium)).monospacedDigit() + .foregroundStyle(StrandPalette.textSecondary) + } + } + } + if showsHover, let selectedPoint = currentSelection { + RuleMark(x: .value("Date", selectedPoint.date)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 4])) + .foregroundStyle(StrandPalette.textSecondary) } } else { if showsArea { @@ -254,20 +388,48 @@ public struct TrendChart: View { // on sharp turns, and the AreaMark gradient is drawn UNCLIPPED — so on a spiky HR curve the // rose fill bled down the page behind the cards below the chart. Clipping the plot area bounds // every mark (line, area, points, overshoot) to the chart rectangle. - .chartPlotStyle { plotArea in plotArea.clipped() } + .chartPlotStyle { plotArea in + if showsBarValues { plotArea.padding(.top, 18) } else { plotArea.clipped() } + } + // Marks are pinned to WHOLE DAYS, not asked for by count (#2431-style label smear on Trends). + // + // `.automatic(desiredCount: 5)` is free to choose the stride that best fits the count, and over a + // short window the best fit is sub-day: several marks then land inside one calendar day, the label + // formats each to a date, and the axis prints "Sep 21" twice over itself. What the screenshot + // shows is not crowding but DUPLICATION, which is why more room would not have helped. + // + // Naming the days outright makes a duplicate structurally impossible: the marks are distinct + // start-of-day instants, so no two can format to the same date, whatever the window. The explicit + // day-only format keeps a mark from ever printing a time as well. .chartXAxis { - AxisMarks(values: .automatic(desiredCount: 5)) { _ in + AxisMarks(values: Self.xAxisDays(spanning: points.map(\.date))) { _ in AxisGridLine().foregroundStyle(StrandPalette.hairline.opacity(0.4)) - AxisValueLabel().foregroundStyle(StrandPalette.textTertiary) + AxisValueLabel(format: .dateTime.month(.abbreviated).day()) + .foregroundStyle(StrandPalette.textTertiary) .font(StrandFont.footnote) } } .chartYAxis { + if let step = yAxisStep, step > 0 { + AxisMarks(position: .leading, values: Array(stride(from: 0.0, through: plotYDomain.upperBound, by: step))) { value in + if let number = value.as(Double.self), number > 0, number < plotYDomain.upperBound { + AxisGridLine(stroke: StrokeStyle(lineWidth: 1.5, dash: [4, 4])) + .foregroundStyle(StrandPalette.textSecondary.opacity(0.45)) + } + AxisValueLabel { + if let number = value.as(Double.self) { + Text(number.formatted(.number.precision(.fractionLength(0)))) + .font(StrandFont.footnote).foregroundStyle(StrandPalette.textTertiary) + } + } + } + } else { AxisMarks(position: .leading, values: .automatic(desiredCount: 4)) { _ in AxisGridLine().foregroundStyle(StrandPalette.hairline.opacity(0.4)) AxisValueLabel().foregroundStyle(StrandPalette.textTertiary) .font(StrandFont.footnote) } + } } .chartOverlay { proxy in GeometryReader { geo in @@ -313,7 +475,18 @@ public struct TrendChart: View { } } .animation(StrandMotion.fade, value: hoverX) + .frame(width: geo.size.width, height: geo.size.height, alignment: .topLeading) .contentShape(Rectangle()) + .gesture(DragGesture(minimumDistance: 0) + .onChanged { value in + guard showsHover, showsBars else { return } + let x = min(max(value.location.x, plot.minX), plot.maxX) + selectedPoint = nearestPoint(toX: x, proxy: proxy, plot: plot) + holdingBar = true + hoverX = largeSelection ? nil : x + } + .onEnded { _ in holdingBar = false; hoverX = nil }, + including: showsHover && showsBars ? .all : .none) .onContinuousHover(coordinateSpace: .local) { phase in guard showsHover else { return } // Update the hover position in a NON-animating transaction. Otherwise entering or @@ -347,6 +520,12 @@ public struct TrendChart: View { .accessibilityLabel(accessibilityLabel.map(Text.init) ?? Text("Trend", bundle: .module)) .accessibilityValue(Text(a11ySummary)) .accessibilityHidden(!showsHover && accessibilityLabel == nil) + } + .onChange(of: points.map(\.date)) { _ in + selectedPoint = nil + holdingBar = false + hoverX = nil + } } } @@ -358,16 +537,35 @@ public struct TrendChart: View { // survives and the rendered envelope is identical at normal chart widths. First and last points are // always kept so the line spans the full domain. Pure + deterministic — same input → same output. -enum ChartDownsample { +public enum ChartDownsample { /// Above this many points we downsample; at or below it the series is passed through untouched (so /// the common 7/30/90-day trends and the ≤60-point dotted series are byte-for-byte unchanged). - static let markThreshold = 120 + public static let markThreshold = 120 /// Target drawn-vertex budget — a touch above a typical ~360pt plot so the line stays crisp. - static let targetVertices = 400 + public static let targetVertices = 400 /// Min/max-bucketed copy of `points` when it exceeds `threshold`, else `points` unchanged. /// Assumes `points` is already sorted by date (both chart callers sort in their init). - static func minMaxBucketed(_ points: [TrendPoint], threshold: Int, targetCount: Int) -> [TrendPoint] { + public static func minMaxBucketed(_ points: [TrendPoint], threshold: Int, targetCount: Int) -> [TrendPoint] { + minMaxBucketed(points, threshold: threshold, targetCount: targetCount, + date: { $0.date }, value: { $0.value }) + } + + /// Generic form of the same algorithm, keyed by caller-supplied `date`/`value` accessors instead of + /// `TrendPoint`'s own properties. `ChartDownsample` used to be internal-by-default and `TrendPoint`-only, + /// so a chart type living outside this package (an app-target screen) couldn't call it and instead + /// hand-duplicated the algorithm for its own point type (see `CompareView.Model.minMaxBucketed`, which + /// predates this generic form and documents the mirroring). This overload is `public` and generic so + /// new app-target chart types can share the ONE implementation instead of adding a third copy. + /// Pure + deterministic; identical behaviour to the `TrendPoint` overload when `date`/`value` project + /// the same fields it does. + public static func minMaxBucketed( + _ points: [Point], + threshold: Int, + targetCount: Int, + date: (Point) -> Date, + value: (Point) -> Double + ) -> [Point] { let n = points.count guard n > threshold, n > 2, targetCount >= 4 else { return points } @@ -379,11 +577,11 @@ enum ChartDownsample { let bucketCount = max(1, (targetCount - 2) / 2) guard bucketCount < interior else { return points } - var out: [TrendPoint] = [] + var out: [Point] = [] out.reserveCapacity(targetCount) out.append(first) - var lastEmittedDate = first.date + var lastEmittedDate = date(first) for b in 0.. points[maxIdx].value { maxIdx = i } + if value(points[i]) < value(points[minIdx]) { minIdx = i } + if value(points[i]) > value(points[maxIdx]) { maxIdx = i } i += 1 } @@ -406,14 +604,15 @@ enum ChartDownsample { let bIdx = lowFirst ? maxIdx : minIdx for idx in [aIdx, bIdx] { let p = points[idx] - if p.date > lastEmittedDate { + let d = date(p) + if d > lastEmittedDate { out.append(p) - lastEmittedDate = p.date + lastEmittedDate = d } } } - if last.date > lastEmittedDate { out.append(last) } + if date(last) > lastEmittedDate { out.append(last) } return out } } diff --git a/Packages/StrandDesign/Tests/StrandDesignTests/ChartDownsampleTests.swift b/Packages/StrandDesign/Tests/StrandDesignTests/ChartDownsampleTests.swift new file mode 100644 index 0000000000..48ee7bc73a --- /dev/null +++ b/Packages/StrandDesign/Tests/StrandDesignTests/ChartDownsampleTests.swift @@ -0,0 +1,84 @@ +import XCTest +@testable import StrandDesign + +/// PERF (chart-invalidation-and-tooltips): `ChartDownsample.minMaxBucketed` gained a generic +/// date/value-keypath overload so app-target chart types outside this package (WorkoutRecoveryTrendChart, +/// TrainingLoadCard) can share the SAME implementation `TrendChart`/`OverviewHRChart` already use, instead +/// of hand-duplicating the algorithm the way `CompareView.Model.minMaxBucketed` had to. These pin the +/// generic overload's behaviour directly (a bare struct, not `TrendPoint`) and confirm the original +/// `TrendPoint` overload — now implemented by delegating to the generic form — is unchanged. +final class ChartDownsampleTests: XCTestCase { + private struct Point { + let date: Date + let value: Double + } + + private func day(_ n: Int) -> Date { Date(timeIntervalSince1970: TimeInterval(n) * 86_400) } + + private func points(_ n: Int, value: (Int) -> Double) -> [Point] { + (0.. String { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // StrandDesignTests + .deletingLastPathComponent() // Tests + .deletingLastPathComponent() // StrandDesign + .deletingLastPathComponent() // Packages + .deletingLastPathComponent() // repo root + let src = try String(contentsOf: root.appendingPathComponent( + "Packages/StrandDesign/Sources/StrandDesign/Palette.swift"), encoding: .utf8) + let start = try XCTUnwrap(src.range(of: "init(light: String, dark: String) {"), + "the dynamic token initialiser moved or was renamed") + // The initialiser is the last member of the extension, so its body runs to the closing brace + // pair that ends the extension. Cutting at the NEXT top-level `// MARK:` is stable against + // formatting and does not depend on counting braces. + let rest = src[start.upperBound...] + let end = rest.range(of: "// MARK:")?.lowerBound ?? rest.endIndex + return String(rest[.. [CGPoint] { + let peak = max(epochs.max() ?? 0, 0) + let n = epochs.count + guard n >= 2, peak > 0 else { return [] } + let h = size.height + let usable = h - 2 + return epochs.enumerated().map { i, v in + let x = CGFloat(i) / CGFloat(n - 1) * size.width + let frac = CGFloat(max(0, min(v / peak, 1))) + return CGPoint(x: x, y: h - frac * usable) + } + } + + private func referenceSummary(_ epochs: [Double]) -> String { + let peak = max(epochs.max() ?? 0, 0) + guard peak > 0, !epochs.isEmpty else { return "no movement data" } + let restless = epochs.filter { $0 >= peak * 0.5 }.count + if restless == 0 { return "calm throughout" } + let pct = Int((Double(restless) / Double(epochs.count) * 100).rounded()) + return "\(pct)% of the night had elevated movement" + } + + private func assertMatches(_ epochs: [Double], _ label: String, + file: StaticString = #filePath, line: UInt = #line) { + let peak = MotionTrace.peak(of: epochs) + XCTAssertEqual(MotionTrace.points(in: size, epochs: epochs, peak: peak), + referencePoints(epochs), label, file: file, line: line) + XCTAssertEqual(MotionTrace.accessibilitySummary(epochs: epochs, peak: peak), + referenceSummary(epochs), label, file: file, line: line) + } + + func testAnOrdinaryNightIsUnchanged() { + // 30-second epochs across eight hours, the real shape this draws. + let epochs = (0..<960).map { i in Double((i * 37) % 100) / 10.0 } + assertMatches(epochs, "ordinary night") + } + + func testDegenerateNightsAreUnchanged() { + // The cases where a careless hoist changes behaviour: dividing by a zero peak, or losing the + // guard that keeps an empty strip flat rather than crashing. + assertMatches([], "empty") + assertMatches([0], "single zero epoch") + assertMatches([4.2], "single non-zero epoch") + assertMatches([0, 0, 0, 0], "all zero") + assertMatches([-1, -2], "negative magnitudes clamp to a flat strip") + } + + func testHalfPeakThresholdIsUnchanged() { + // `accessibilitySummary` counts epochs at or above half the peak, so a value exactly on the + // boundary is the one that would move if the peak were computed differently. + assertMatches([10, 5, 4.999, 0], "values straddling half peak") + assertMatches([1, 1, 1], "every epoch at the peak") + } + + func testTheStripsOwnHelpersAgreeOnTheSamePeak() { + // The gap these tests cannot close: nothing here checks that `body` passes the peak it computed + // into both helpers. Pin the next best thing, that the helpers agree when handed the peak the + // hoisted accessor produces, so a caller threading a DIFFERENT value is the only way to break it. + let epochs: [Double] = [0, 3, 9, 4.5, 4.4, 0] + let peak = MotionTrace.peak(of: epochs) + XCTAssertEqual(peak, 9) + let pts = MotionTrace.points(in: size, epochs: epochs, peak: peak) + XCTAssertEqual(pts.count, epochs.count) + // 9 is the peak, so it must land at the very top of the usable band, and 0 at the baseline. + XCTAssertEqual(pts[2].y, size.height - (size.height - 2), accuracy: 0.0001) + XCTAssertEqual(pts[0].y, size.height, accuracy: 0.0001) + // 4.5 is exactly half the peak and counts as restless; 4.4 does not. Two of six is 33%. + XCTAssertEqual(MotionTrace.accessibilitySummary(epochs: epochs, peak: peak), + "33% of the night had elevated movement") + } + + func testPeakIgnoresNegativesAndEmpties() { + XCTAssertEqual(MotionTrace.peak(of: []), 0) + XCTAssertEqual(MotionTrace.peak(of: [-5, -1]), 0, "a negative peak clamps to zero") + XCTAssertEqual(MotionTrace.peak(of: [1, 9, 3]), 9) + } +} diff --git a/Packages/StrandDesign/Tests/StrandDesignTests/QuietMotionCoverageTests.swift b/Packages/StrandDesign/Tests/StrandDesignTests/QuietMotionCoverageTests.swift index e3c13539ca..a847110004 100644 --- a/Packages/StrandDesign/Tests/StrandDesignTests/QuietMotionCoverageTests.swift +++ b/Packages/StrandDesign/Tests/StrandDesignTests/QuietMotionCoverageTests.swift @@ -184,13 +184,46 @@ final class QuietMotionCoverageTests: XCTestCase { // MARK: - The gate itself /// Losing one signal is invisible in the app — the screen looks right in whichever mode still - /// works — so pin that all three are read, and that the OS flag stays live. - func testGateReadsAllThreeSignalsAndStaysLive() throws { + /// works — so pin that all four are read, and that the OS flags stay live. + func testGateReadsAllFourSignalsAndStaysLive() throws { let root = try repoRoot() let src = try String(contentsOf: root.appendingPathComponent( "Packages/StrandDesign/Sources/StrandDesign/NoopMotion.swift"), encoding: .utf8) - XCTAssertTrue(src.contains("reduceMotion || isLowPower || quietMotion"), - "poseStill must OR all three signals") + XCTAssertTrue(src.contains("reduceMotion || isLowPower || quietMotion || windowObscured"), + "poseStill must OR all four signals") + // #2393: the window-visibility term is worthless if nothing ever sets it, and the three + // notification families answer different questions — hide/unhide is the app, occlusion is the + // window, miniaturise is the Dock. Which of them AppKit posts for any given user action is not + // established here (it was not measured, and the reporter's numbers cover Cmd+H only), which is + // the reason to observe all three rather than pick the one that looks sufficient. + for name in ["didHideNotification", "didUnhideNotification", + "didChangeOcclusionStateNotification", + "didMiniaturizeNotification", "didDeminiaturizeNotification"] { + XCTAssertTrue(src.contains(name), + "windowObscured must stay live on \(name)") + } + // The window list MUST be filtered to real app windows. NOOP ships a MenuBarExtra whose + // status-item window lives in `NSApplication.shared.windows` forever, so dropping this filter + // leaves the gate permanently open and the fix silently inert. + // + // #2397: this assertion previously pinned `canBecomeMain`, and it passed for a week while the + // gate never once closed — a predicate that is correct for a visible window and wrong for a + // hidden one is exactly what a source census cannot see. It is kept, narrowed to the predicate + // that actually holds, and paired with the REGRESSION half below, because what a census IS good + // for is noticing that a known-bad predicate came back. The behaviour is pinned where it belongs, + // over window states, in `WindowObscuredGateTests`. + // + // Comment lines are dropped first: the source EXPLAINS why `canBecomeMain` was wrong, and a + // census that counted its own explanation would fail on an accurate account of the code it + // guards. Same treatment as `DynamicColorParseOnceTests`. + let code = src.split(separator: "\n", omittingEmptySubsequences: false) + .filter { !$0.trimmingCharacters(in: .whitespaces).hasPrefix("//") } + .joined(separator: "\n") + XCTAssertTrue(code.contains("styleMask.contains(.titled)"), + "the MenuBarExtra's status-item window must not hold the gate open") + XCTAssertFalse(code.contains("canBecomeMain"), + "canBecomeMain answers false for a HIDDEN window, which empties the list at the " + + "moment the gate should close (#2397)") XCTAssertTrue(src.contains("isLowPowerModeEnabled"), "must read Low Power Mode") XCTAssertTrue(src.contains("NSProcessInfoPowerStateDidChange"), "Low Power Mode must stay live without a relaunch") diff --git a/Packages/StrandDesign/Tests/StrandDesignTests/StepsBarAxisTests.swift b/Packages/StrandDesign/Tests/StrandDesignTests/StepsBarAxisTests.swift new file mode 100644 index 0000000000..8793410d77 --- /dev/null +++ b/Packages/StrandDesign/Tests/StrandDesignTests/StepsBarAxisTests.swift @@ -0,0 +1,21 @@ +#if !os(watchOS) +import XCTest +@testable import StrandDesign + +final class StepsBarAxisTests: XCTestCase { + func testStepAxisIncludesOccupiedBlockAndKeepsZero() { + for (maximum, upper) in [(0.0, 5000.0), (4999, 5000), (5000, 5000), + (5001, 10000), (19000, 20000), (20001, 25000)] { + let chart = TrendChart(points: [.init(date: Date(), value: maximum)], + showsBars: true, yAxisStep: 5000) + XCTAssertEqual(chart.plotYDomain, 0...upper) + } + } + + func testExistingChartDomainUnchangedWithoutStepConfiguration() { + let chart = TrendChart(points: [], valueRange: 40...80, showsBars: true) + XCTAssertEqual(chart.plotYDomain, 0...80) + XCTAssertEqual(TrendChart(points: [], valueRange: 40...80).plotYDomain, 40...80) + } +} +#endif diff --git a/Packages/StrandDesign/Tests/StrandDesignTests/TrendChartAxisDaysTests.swift b/Packages/StrandDesign/Tests/StrandDesignTests/TrendChartAxisDaysTests.swift new file mode 100644 index 0000000000..5c362b9a03 --- /dev/null +++ b/Packages/StrandDesign/Tests/StrandDesignTests/TrendChartAxisDaysTests.swift @@ -0,0 +1,148 @@ +import XCTest +@testable import StrandDesign + +/// The Trends x-axis marks are distinct calendar days, so a date label can never print twice over itself. +/// +/// The axis asked Swift Charts for a COUNT (`.automatic(desiredCount: 5)`) and let it choose the stride. +/// Over a short window the stride it chooses is sub-day, so several marks land inside one calendar day, +/// each formats to the same date, and the axis draws "Sep 21" on top of "Sep 21". The reported screenshot +/// is that duplication rather than crowding, which is why a wider card would not have helped: the same day +/// would still have been labelled more than once. +/// +/// These assert the invariant itself, not a proxy for it. `xAxisDays` is a pure function over the series +/// dates, so the whole contract is checkable without Swift Charts; what no test on this side reaches is +/// Charts' own rendering. +final class TrendChartAxisDaysTests: XCTestCase { + + private var calendar: Calendar = { + var c = Calendar(identifier: .gregorian) + c.timeZone = TimeZone(identifier: "UTC")! + return c + }() + + private var day0: Date { calendar.startOfDay(for: Date(timeIntervalSince1970: 1_758_000_000)) } + + /// `days` calendar days, `perDay` samples inside each. + private func series(days: Int, perDay: Int = 1) -> [Date] { + (0.. String { + let c = calendar.dateComponents([.year, .month, .day], from: date) + return "\(c.year!)-\(c.month!)-\(c.day!)" + } + + /// The defect, stated directly: two marks must never format to the same date. Swept over every window + /// the Trends ranges can ask for, because the sub-day stride only appeared at some widths. + func testNoTwoMarksEverFallOnTheSameDay() { + for days in 1...400 { + let marks = TrendChart.xAxisDays(spanning: series(days: days), calendar: calendar) + XCTAssertEqual(Set(marks.map(dayKey)).count, marks.count, + "two marks share a day in a \(days)-day window, which is what printed twice") + } + } + + /// The reported shape: a window whose samples all sit inside one day. This input used to produce five + /// marks all carrying the same date. + func testASingleDayOfSamplesGetsExactlyOneMark() { + let marks = TrendChart.xAxisDays(spanning: series(days: 1, perDay: 24), calendar: calendar) + XCTAssertEqual(marks, [day0]) + } + + /// Every mark sits at a day boundary, so none can render a time of day. + func testEveryMarkIsDayAligned() { + for days in [1, 2, 7, 30, 90, 365] { + let marks = TrendChart.xAxisDays(spanning: series(days: days), calendar: calendar) + for mark in marks { + XCTAssertEqual(mark, calendar.startOfDay(for: mark), "\(days)-day window has an off-day mark") + } + } + } + + /// The newest day is what a trend is usually read for, so it always carries a label. A stride stepping + /// up from the lower bound does NOT give this: on a 30-day window it marks days 0/6/12/18/24 and + /// leaves day 29 bare, which is why the marks are named outright. + func testTheNewestDayIsAlwaysLabelled() { + for days in 1...400 { + let dates = series(days: days) + let marks = TrendChart.xAxisDays(spanning: dates, calendar: calendar) + XCTAssertEqual(marks.last, calendar.startOfDay(for: dates.max()!), + "newest day unlabelled in a \(days)-day window") + } + } + + /// Ascending, which is the order `AxisMarks(values:)` expects, and never more than the target, so the + /// axis cannot become the smear it was by a different route. + func testMarksAreAscendingAndBounded() { + for days in 1...400 { + let marks = TrendChart.xAxisDays(spanning: series(days: days), calendar: calendar) + XCTAssertEqual(marks, marks.sorted(), "\(days)-day window is out of order") + XCTAssertLessThanOrEqual(marks.count, 5, "\(days)-day window wants \(marks.count) marks") + XCTAssertFalse(marks.isEmpty, "\(days)-day window has no marks at all") + } + } + + /// No mark may sit before the data starts, which would stretch the axis past the series. + func testNoMarkPrecedesTheSeries() { + for days in [2, 7, 30, 90, 365] { + let dates = series(days: days) + let marks = TrendChart.xAxisDays(spanning: dates, calendar: calendar) + XCTAssertGreaterThanOrEqual(marks.first!, calendar.startOfDay(for: dates.min()!), + "\(days)-day window marks before its first sample") + } + } + + /// Degenerate input must not produce marks the axis cannot use. + func testEmptyAndSinglePointSeries() { + XCTAssertTrue(TrendChart.xAxisDays(spanning: [], calendar: calendar).isEmpty) + XCTAssertEqual(TrendChart.xAxisDays(spanning: [day0], calendar: calendar), [day0]) + XCTAssertEqual(TrendChart.xAxisDays(spanning: [day0], targetLabels: 0, calendar: calendar), [], + "a nonsensical label target must not loop or divide by zero") + } + + /// A zone that shifts its clocks AT MIDNIGHT does not break the day alignment. + /// + /// A day-add preserves the time of day, and on Chile's spring-forward date 00:00 does not exist, so + /// stepping back from a start-of-day lands on 01:00. Left alone that mark is off the day boundary and + /// every mark after it inherits the 01:00, so the tail of the axis drifts. Sweeping two years of + /// windows in America/Santiago put 52 marks at 01:00 before each step was re-anchored. + /// + /// The rest of the suite runs in UTC, which has no transitions and so cannot see this at all. + func testAZoneThatShiftsAtMidnightKeepsEveryMarkOnItsDay() { + guard let santiago = TimeZone(identifier: "America/Santiago") else { + return XCTFail("America/Santiago is required: it is the zone that shifts at midnight") + } + var chile = Calendar(identifier: .gregorian) + chile.timeZone = santiago + + let start = chile.startOfDay(for: Date(timeIntervalSince1970: 1_690_000_000)) + for dayOffset in 0..<730 { + let lo = chile.date(byAdding: .day, value: dayOffset, to: start)! + for span in [6, 29, 89] { + let hi = chile.date(byAdding: .day, value: span, to: lo)! + let marks = TrendChart.xAxisDays(spanning: [lo, hi], calendar: chile) + for mark in marks { + XCTAssertEqual(mark, chile.startOfDay(for: mark), + "off-day mark in a \(span)-day window starting +\(dayOffset)") + } + let days = marks.map { mark -> String in + let c = chile.dateComponents([.year, .month, .day], from: mark) + return "\(c.year!)-\(c.month!)-\(c.day!)" + } + XCTAssertEqual(Set(days).count, marks.count, + "two marks share a day in a \(span)-day window starting +\(dayOffset)") + } + } + } + + /// The series is spanned by its extremes, not by its first and last element. + func testAnUnorderedSeriesSpansByItsExtremes() { + let ordered = series(days: 30) + XCTAssertEqual(TrendChart.xAxisDays(spanning: ordered.shuffled(), calendar: calendar), + TrendChart.xAxisDays(spanning: ordered, calendar: calendar)) + } +} diff --git a/Packages/StrandDesign/Tests/StrandDesignTests/WindowObscuredGateTests.swift b/Packages/StrandDesign/Tests/StrandDesignTests/WindowObscuredGateTests.swift new file mode 100644 index 0000000000..e74810d218 --- /dev/null +++ b/Packages/StrandDesign/Tests/StrandDesignTests/WindowObscuredGateTests.swift @@ -0,0 +1,68 @@ +import XCTest +@testable import StrandDesign + +/// #2394 / #2397: the macOS window-visibility gate, tested as the LIST decision it actually is. +/// +/// The first version of this suite could only hand the decision two summary Bools, `hasWindows` and +/// `anyWindowOnScreen`, and it passed while the gate never once closed. The bug was in the filter that +/// produced those summaries: `canBecomeMain` answers false for a hidden or miniaturised window, so the +/// app window left the list at exactly the moment the gate should have closed, the list went empty, and +/// the empty-list rule read that as "not obscured". No test over a summary can see that, because the +/// summary is where the information was already lost. +/// +/// So the cases below are the window states @nichtlegacy measured on the shipped build, expressed the +/// way `refreshWindowObscured` now expresses them: +/// +/// visible titled=2 (one on screen) obscured=N +/// hidden titled=2 (none on screen) obscured=Y +/// minimised titled=2 (none on screen) obscured=Y +/// +/// The hidden and minimised cases FAIL against the old predicate, which is the property the source +/// census in `QuietMotionCoverageTests` could not provide and the reason this file exists. +final class WindowObscuredGateTests: XCTestCase { + + private func window(onScreen: Bool) -> NoopMotionState.WindowVisibility { + NoopMotionState.WindowVisibility(onScreen: onScreen) + } + + func testAWindowOnScreenIsNotObscured() { + XCTAssertFalse(NoopMotionState.obscured([window(onScreen: true)])) + } + + /// Cmd+H. The window is still in the list (that is what `.titled` buys) and simply not on screen. + func testAHiddenAppIsObscured() { + XCTAssertTrue(NoopMotionState.obscured([window(onScreen: false)])) + } + + /// Sent to the Dock. Indistinguishable from hidden at this layer, and should be. + func testAMiniaturisedWindowIsObscured() { + XCTAssertTrue(NoopMotionState.obscured([window(onScreen: false)])) + } + + /// The reporter's `titled=2` counts, both states. A second titled window (a sheet, or Settings) + /// must not hold the gate open on its own, and must not close it while the main window is up. + func testASecondTitledWindowFollowsTheSameRule() { + XCTAssertFalse(NoopMotionState.obscured([window(onScreen: true), window(onScreen: false)])) + XCTAssertTrue(NoopMotionState.obscured([window(onScreen: false), window(onScreen: false)])) + } + + /// The case that decides the empty-list rule rather than following from it. + /// + /// During launch AppKit has no windows yet, and someone can close the window and leave NOOP running + /// as a menu-bar app. Reading that as "nothing on screen" would pose every surface still until the + /// next occlusion notification arrived, so the app's first frame would show static gauges on the way + /// to a live screen. With no window there is no view hierarchy and so no frame loop to stop. + /// + /// This rule is only safe while the filter is state-INDEPENDENT, which is precisely what #2397 broke: + /// under `canBecomeMain` a hidden window emptied the list and this clause answered the live question. + func testNoWindowsIsNotObscured() { + XCTAssertFalse(NoopMotionState.obscured([])) + } + + /// The status-item window never reaches here (the caller filters on `.titled`), but if the filter is + /// ever loosened, an always-on-screen window would hold the gate open forever, which is the other way + /// this fix can be made inert. Pinned as the shape that must not appear. + func testAnAlwaysOnScreenWindowWouldHoldTheGateOpen() { + XCTAssertFalse(NoopMotionState.obscured([window(onScreen: false), window(onScreen: true)])) + } +} diff --git a/Packages/StrandDesign/Tests/StrandDesignTests/WorkoutTypeIconTests.swift b/Packages/StrandDesign/Tests/StrandDesignTests/WorkoutTypeIconTests.swift index a8ed9bdf11..24efc9791e 100644 --- a/Packages/StrandDesign/Tests/StrandDesignTests/WorkoutTypeIconTests.swift +++ b/Packages/StrandDesign/Tests/StrandDesignTests/WorkoutTypeIconTests.swift @@ -3,6 +3,29 @@ import XCTest final class WorkoutTypeIconTests: XCTestCase { + /// The WHOOP-parity sports (#2260). Resolution is token-based, so a new catalogue name silently + /// falls to the generic icon unless a token already covers it; these are the ones that should not. + func testWhoopParitySportsResolveToARealIcon() { + XCTAssertEqual(KnownWorkoutType.resolving("Nordic walking"), .nordicWalking) + XCTAssertEqual(KnownWorkoutType.resolving("Jiu jitsu"), .jiuJitsu) + XCTAssertEqual(KnownWorkoutType.resolving("Judo"), .judo) + // "muay" was missing from a bucket that already listed karate and mma. + XCTAssertEqual(KnownWorkoutType.resolving("Muay Thai"), .muayThai) + XCTAssertEqual(KnownWorkoutType.resolving("Ballet"), .ballet) + XCTAssertEqual(KnownWorkoutType.resolving("Breakdancing"), .breakdancing) + XCTAssertEqual(KnownWorkoutType.resolving("Disc golf"), .discGolf) + } + + /// The token was "dance", which the common inflection "dancing" does not contain, so only the exact + /// catalogue name matched and any free-typed variant fell through. Pinned so it cannot regress. + func testFreeTypedDancingResolves() { + // NOT "dancing" on its own: exact(matching:) is case-insensitive, so that hits the catalogue's + // own "Dancing" and never reaches the token chain. It passed before the fix too, which makes it + // useless as a regression test. These have no exact match and must go through the token. + XCTAssertEqual(KnownWorkoutType.resolving("Salsa dancing"), .dancing) + XCTAssertEqual(KnownWorkoutType.resolving("evening dancing class"), .dancing) + } + func testPreferredIdentitiesAreUnique() { var seen = Set() for type in KnownWorkoutType.allCases { 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..fd8d5d0d3e 100644 --- a/Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift +++ b/Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift @@ -154,4 +154,97 @@ 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) + } + + /// How close to the newest heart rate a night may end and still be read as unfinished. + public static let openNightMarginSeconds = 20 * 60 + + /// How long after its end a night is final even with no newer heart rate, so a strap taken off at + /// wake (charging, say) does not hold the night back indefinitely. + public static let openNightMaxHoldSeconds = 2 * 3_600 + + /// Whether a detected night may still be growing: it ends where the synced heart rate ends. + /// + /// A night is detected from whatever has synced so far, so a pass during the night ends it at the + /// newest sample. That truncated night used to reach Apple Health like a finished one, and a reader + /// took its end as the wake: a field night slept to 09:36 was in Health as ending 06:53, with vitals + /// scored from the first six hours, until a later write-back replaced it. Holding a night whose end + /// sits within `openNightMarginSeconds` of the newest heart rate keeps it out until the strap has + /// seen the wearer awake; after `openNightMaxHoldSeconds` it is written regardless. + public static func nightIsStillOpen(endTs: Int, newestHeartRateTs: Int, now: Int) -> Bool { + newestHeartRateTs - endTs < openNightMarginSeconds && now - endTs < openNightMaxHoldSeconds + } + + /// The sleep key: `noop:sleep:`. + public static func appleHealthSleepKey(startTs: Int) -> String { + appleHealthExternalUUID(kind: "sleep", identity: "\(startTs)") + } + + /// The prefix every workout key carries, without the identity. + /// + /// Exists so a reader can recognise one of our workouts in Health without reconstructing a key it + /// does not know the timestamp for: the orphan reconciliation in `HealthKitBridge` matches on this + /// to tell a workout WE wrote from one another app wrote, before deciding anything about it. Kept + /// beside the key builder so the two cannot drift. (#2210) + public static let appleHealthWorkoutKeyPrefix = "noop:workout:" + + /// 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/HevyAPI.swift b/Packages/StrandImport/Sources/StrandImport/HevyAPI.swift new file mode 100644 index 0000000000..5b358ad946 --- /dev/null +++ b/Packages/StrandImport/Sources/StrandImport/HevyAPI.swift @@ -0,0 +1,284 @@ +import Foundation + +/// Parsers for the Hevy Web API endpoints beyond `GET /v1/workouts`, which `LiftingImporter` already +/// folds into Strength sessions. +/// +/// Verified against Hevy's published OpenAPI spec (api.hevyapp.com/docs). Every endpoint takes the +/// user's own `api-key` header, and the paginated ones take `page` / `pageSize` — note the defaults +/// are SMALL (5, or 10 for body measurements), so a caller that reads one page has almost certainly +/// not read the account. +/// +/// Parsing only, deliberately: there is no client, and the transport question (whether an +/// offline-first app should hold an API key at all) is unanswered. These are pure so the shape can be +/// pinned by tests now and the decision taken later. Tolerant throughout, because this runs over +/// another server's data: a malformed element is skipped and counted, never fatal. +public enum HevyAPI { + + // MARK: - Muscle groups + + /// Hevy's published muscle-group vocabulary, attached to an exercise TEMPLATE rather than to a + /// logged set. + /// + /// This matters more than it looks: an exercise carries `exercise_template_id`, so this is + /// authoritative attribution for the exercise a set was performed on, from the people who own the + /// catalogue. Anything NOOP derives by matching exercise TITLES is guessing at data that is + /// published here. + /// + /// An unrecognised value parses to nil rather than to `.other`, because Hevy defines `other` as a + /// real category and folding a newly-added group into it would silently mislabel it. + public enum MuscleGroup: String, Sendable, CaseIterable { + case abdominals + case shoulders + case biceps + case triceps + case forearms + case quadriceps + case hamstrings + case calves + case glutes + case abductors + case adductors + case lats + case upperBack = "upper_back" + case traps + case lowerBack = "lower_back" + case chest + case cardio + case neck + case fullBody = "full_body" + case other + } + + // MARK: - Models + + /// One page of a paginated Hevy response. `pageCount` is the total number of pages, so a caller + /// keeps going while `page < pageCount`. + public struct Page: Sendable { + public let items: [Element] + public let page: Int + public let pageCount: Int + /// Elements that were present but unusable. Non-zero means the response was understood and + /// something in it was not, which is different from an empty page. + public let skipped: Int + + public var hasMore: Bool { page < pageCount } + } + + /// An entry in Hevy's exercise catalogue. `equipment` and `type` stay raw strings: nothing in + /// NOOP consumes them yet, and an enum would only add a drift risk when Hevy adds a value. + public struct ExerciseTemplate: Sendable, Equatable { + public let id: String + public let title: String + public let type: String? + public let primaryMuscleGroup: MuscleGroup? + public let secondaryMuscleGroups: [MuscleGroup] + public let equipment: String? + public let isCustom: Bool + } + + /// A body-composition entry. + /// + /// The date is kept as Hevy writes it (a bare `yyyy-MM-dd`, no time and no zone) rather than + /// resolved to an instant here. Choosing a moment inside that day is a real decision with a real + /// failure mode -- NOOP already re-buckets nights when an offset changes -- and it belongs to + /// whatever stores the sample, not to the parser. + /// + /// Hevy also returns fourteen circumference fields (neck, chest, each bicep, each thigh, and so + /// on). They are deliberately not modelled: nothing in NOOP consumes them, and an unused field is + /// a schema commitment with no payer. + public struct BodyMeasurement: Sendable, Equatable { + public let date: String + public let weightKg: Double? + public let fatPercent: Double? + public let leanMassKg: Double? + + /// True when the entry carries none of the three figures NOOP could use, which is the case + /// for an entry that only recorded circumferences. + public var isEmpty: Bool { weightKg == nil && fatPercent == nil && leanMassKg == nil } + } + + /// Whose account a key belongs to. Worth a call before an import, so a wrong key fails with + /// "that key isn't yours" rather than with an empty history. + public struct UserInfo: Sendable, Equatable { + public let id: String + public let name: String? + public let url: String? + } + + /// A change to the account since a given moment: either a workout in its current form, or the id + /// of one that is gone. + /// + /// This is the endpoint that would make an API import worth having over the CSV export, because + /// it is the only one that reports DELETIONS. It is also the one that exposes what NOOP does not + /// yet store: a workout row is keyed by `(deviceId, startTs)`, and neither event can be applied + /// through that key. A delete names a Hevy id NOOP never kept, and an edit that moved the start + /// time would land as a second session rather than replacing the first. Persisting Hevy's id + /// alongside the row is the missing piece, and it is a schema change on both platforms. + public enum WorkoutEvent: Sendable { + case updated(id: String, session: LiftingSession) + case deleted(id: String, deletedAt: Date?) + + /// The Hevy workout id an event refers to, which is the key NOOP would have to have stored. + public var workoutID: String { + switch self { + case let .updated(id, _): return id + case let .deleted(id, _): return id + } + } + } + + // MARK: - GET /v1/workouts/events + + /// Parse a workout-events page (`since` defaults to the epoch, so an unqualified call replays the + /// whole account). + /// + /// An `updated` event carries the same `Workout` shape a page does and is folded by the same code + /// path, so an edit cannot arrive with different arithmetic from the workout it edits. An update + /// whose sets all fold away -- every set deleted, or every set turned into a warm-up -- yields no + /// session and is counted as skipped; it is NOT a deletion, and treating it as one would drop a + /// workout the user still has. + public static func parseWorkoutEvents(data: Data, zone: TimeZone = .current) -> Page { + // The closure is annotated because `Element` is inferred FROM it: leading-dot `.updated` + // has nothing to resolve against until the return type is spelled out. + parsePage(data, key: "events") { (dict: [String: Any]) -> WorkoutEvent? in + switch (dict["type"] as? String)?.lowercased() { + case "updated": + guard let w = dict["workout"] as? [String: Any], + let id = w["id"] as? String, !id.isEmpty, + let acc = LiftingImporter.hevyAccumulator(from: w, zone: zone), + let session = acc.session else { return nil } + return .updated(id: id, session: session) + case "deleted": + guard let id = dict["id"] as? String, !id.isEmpty else { return nil } + return .deleted(id: id, + deletedAt: (dict["deleted_at"] as? String) + .flatMap { LiftingImporter.parseDate($0, zone: zone) }) + default: + return nil + } + } + } + + // MARK: - GET /v1/exercise_templates + + /// Parse an exercise-template page. A template with no id or no title is unusable as a join + /// target, so it is skipped rather than stored half-formed. + public static func parseExerciseTemplates(data: Data) -> Page { + parsePage(data, key: "exercise_templates") { dict in + guard let id = dict["id"] as? String, !id.isEmpty, + let title = dict["title"] as? String, !title.isEmpty else { return nil } + let secondary = ((dict["secondary_muscle_groups"] as? [Any]) ?? []) + .compactMap { $0 as? String } + .compactMap { MuscleGroup(rawValue: $0.lowercased()) } + return ExerciseTemplate( + id: id, + title: title, + type: dict["type"] as? String, + primaryMuscleGroup: (dict["primary_muscle_group"] as? String) + .flatMap { MuscleGroup(rawValue: $0.lowercased()) }, + secondaryMuscleGroups: secondary, + equipment: dict["equipment"] as? String, + isCustom: (dict["is_custom"] as? Bool) ?? false + ) + } + } + + // MARK: - GET /v1/body_measurements + + /// Parse a body-measurement page. An entry with no date cannot be filed, and one carrying only + /// circumferences has nothing NOOP can store, so both are skipped and counted. + public static func parseBodyMeasurements(data: Data) -> Page { + parsePage(data, key: "body_measurements") { dict in + guard let date = (dict["date"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), + !date.isEmpty else { return nil } + let entry = BodyMeasurement( + date: date, + weightKg: positive(dict["weight_kg"]), + fatPercent: LiftingImporter.jsonDouble(dict["fat_percent"]), + leanMassKg: positive(dict["lean_mass_kg"]) + ) + return entry.isEmpty ? nil : entry + } + } + + // MARK: - GET /v1/workouts/count and /v1/user/info + + /// Parse `{ "workout_count": 42 }`. A cheap way to ask whether anything has changed before + /// paging the whole history at a default page size of five. + public static func parseWorkoutCount(data: Data) -> Int? { + guard let root = object(data), let n = LiftingImporter.jsonDouble(root["workout_count"]), + n >= 0, n < 1e9 else { return nil } + return Int(n) + } + + /// Parse `{ "data": { "id": …, "name": …, "url": … } }`. + public static func parseUserInfo(data: Data) -> UserInfo? { + guard let root = object(data) else { return nil } + // The payload nests under `data`; tolerate a bare object too, since a caller that has already + // unwrapped one should not get a silent nil. + let dict = (root["data"] as? [String: Any]) ?? root + guard let id = dict["id"] as? String, !id.isEmpty else { return nil } + return UserInfo(id: id, name: dict["name"] as? String, url: dict["url"] as? String) + } + + // MARK: - Shared + + /// The `page` / `page_count` pair off any paginated Hevy body, for a caller that wants to know + /// how far it has to go before it has parsed the elements. + public static func parsePagination(data: Data) -> (page: Int, pageCount: Int)? { + guard let root = object(data) else { return nil } + return (pageNumber(root["page"], fallback: 1), pageNumber(root["page_count"], fallback: 1)) + } + + private static func object(_ data: Data) -> [String: Any]? { + // JSONSerialization rejects a leading UTF-8 BOM, so strip it (the shared CSV helper). + try? JSONSerialization.jsonObject(with: BOM.stripUTF8(data)) as? [String: Any] + } + + /// Fold a `{ page, page_count, : [...] }` envelope. A bare array is accepted as well, so a + /// caller that already unwrapped a page is not forced back into the envelope. + private static func parsePage( + _ data: Data, + key: String, + element: ([String: Any]) -> Element? + ) -> Page { + let root = try? JSONSerialization.jsonObject(with: BOM.stripUTF8(data)) + let raw: [Any] + var page = 1 + var pageCount = 1 + if let dict = root as? [String: Any] { + raw = (dict[key] as? [Any]) ?? [] + page = pageNumber(dict["page"], fallback: 1) + pageCount = pageNumber(dict["page_count"], fallback: 1) + } else if let list = root as? [Any] { + raw = list + } else { + raw = [] + } + + var items: [Element] = [] + var skipped = 0 + for entry in raw { + guard let dict = entry as? [String: Any], let parsed = element(dict) else { + skipped += 1 + continue + } + items.append(parsed) + } + return Page(items: items, page: page, pageCount: pageCount, skipped: skipped) + } + + /// A page number, bounded. Hevy sends integers, but this runs over another server's data and the + /// value ends up in a loop condition. + private static func pageNumber(_ any: Any?, fallback: Int) -> Int { + guard let d = LiftingImporter.jsonDouble(any), d >= 1, d < 1e7 else { return fallback } + return Int(d) + } + + /// A measurement that must be positive to mean anything. A zero body weight is a cleared field, + /// not a reading, and storing it would drag an average down. + private static func positive(_ any: Any?) -> Double? { + guard let d = LiftingImporter.jsonDouble(any), d > 0 else { return nil } + return d + } +} diff --git a/Packages/StrandImport/Sources/StrandImport/LabMarkerCsvImport.swift b/Packages/StrandImport/Sources/StrandImport/LabMarkerCsvImport.swift index f172a64545..795d1476b3 100644 --- a/Packages/StrandImport/Sources/StrandImport/LabMarkerCsvImport.swift +++ b/Packages/StrandImport/Sources/StrandImport/LabMarkerCsvImport.swift @@ -343,15 +343,31 @@ public enum LabMarkerCsvImport { /// The `custom_` key for an unrecognised marker name. MUST stay byte-identical /// to the manual editor's `MarkerUnits.slug` (Strand/Screens/MarkerEditorView.swift) - /// so a CSV custom marker folds onto a hand-added one. Returns "" for a name with no - /// usable characters. - static func customKey(_ name: String) -> String { - let lowered = name.precomposedStringWithCanonicalMapping - .trimmingCharacters(in: .whitespaces).lowercased() - let mapped = lowered.map { ch -> Character in - (ch.isLetter || ch.isNumber) ? ch : "_" + /// so a CSV custom marker folds onto a hand-added one (the editor delegates here). Returns + /// "" for a name with no usable characters. + /// + /// "%" becomes its own `pct` token: a lab export lists a white-cell differential as both a + /// count and a percentage ("LYMPH" 1.9 and "LYMPH %" 31.2), and mapping "%" to "_" like any + /// other punctuation gave both `custom_lymph`, so the second row of the day overwrote the + /// first. The token is joined to a letter/digit neighbour by one "_" (a non-alphanumeric + /// neighbour already maps to "_"), so "LYMPH %", "LYMPH%" and "Lymph (%)" all give + /// `custom_lymph_pct`. Names without "%" keep the keys they always had. Twin: `customKey` + /// in android/app/src/main/java/com/noop/ingest/LabMarkerCsvImport.kt. + public static func customKey(_ name: String) -> String { + let lowered = Array(name.precomposedStringWithCanonicalMapping + .trimmingCharacters(in: .whitespaces).lowercased()) + func isWordChar(_ ch: Character) -> Bool { ch.isLetter || ch.isNumber } + var mapped = "" + for (i, ch) in lowered.enumerated() { + if ch == "%" { + if i > 0, isWordChar(lowered[i - 1]) { mapped += "_" } + mapped += "pct" + if i + 1 < lowered.count, isWordChar(lowered[i + 1]) { mapped += "_" } + } else { + mapped.append(isWordChar(ch) ? ch : "_") + } } - let collapsed = String(mapped).replacingOccurrences(of: "__", with: "_") + let collapsed = mapped.replacingOccurrences(of: "__", with: "_") let trimmed = collapsed.trimmingCharacters(in: CharacterSet(charactersIn: "_")) guard !trimmed.isEmpty else { return "" } return "custom_" + trimmed diff --git a/Packages/StrandImport/Sources/StrandImport/LiftProgramSheetImporter.swift b/Packages/StrandImport/Sources/StrandImport/LiftProgramSheetImporter.swift new file mode 100644 index 0000000000..af57e6d02f --- /dev/null +++ b/Packages/StrandImport/Sources/StrandImport/LiftProgramSheetImporter.swift @@ -0,0 +1,344 @@ +import Foundation +import WhoopStore +import ZIPFoundation + +// Build a Lift Log PROGRAM from a spreadsheet filled in on a computer. +// +// WHY THIS EXISTS. A program is a name plus an ordered list of exercise lines, and every line carries +// an exercise, a muscle classification, sets, reps, a weight, a max RPE, a rest period and a technique note. +// Typing all of that on a phone, for a dozen exercises, is the single most tedious thing in the +// feature — and it is exactly the kind of work a spreadsheet on a real keyboard is good at. +// +// This reads the filled-in sheet and produces programs. It never writes: the caller decides what to +// do with the result, which keeps this parser pure and testable with no store and no app. +// +// TWO FORMATS, ONE PARSER. `.xlsx` is what the shipped template is, because a spreadsheet can lock +// its own structure and offer the muscle vocabulary as a dropdown — the user cannot mistype a muscle +// name that has to match a closed token set. CSV is accepted too, because every spreadsheet program +// on every platform can save it, and because a user who rebuilds the sheet by hand should not be +// blocked. Detection is by ZIP magic bytes, the same way `DataBackup` tells a zipped backup from a +// bare SQLite file. + +/// One exercise line read from the sheet — the TARGETS, mirroring `liftProgramItem`. +public struct ImportedProgramLine: Sendable, Equatable { + public var exercise: String + public var primaryMuscle: LiftMuscle? + public var secondaryMuscles: [LiftMuscle] + public var targetSets: Int? + public var targetReps: Int? + public var targetWeightKg: Double? + /// The line's max RPE, 1-10: a ceiling to stay under, stored as `liftProgramItem.targetRpe`. + public var targetMaxRpe: Double? + public var restSec: Int? + public var note: String? + + public init(exercise: String, primaryMuscle: LiftMuscle?, secondaryMuscles: [LiftMuscle], + targetSets: Int?, targetReps: Int?, targetWeightKg: Double?, targetMaxRpe: Double?, + restSec: Int?, note: String?) { + self.exercise = exercise + self.primaryMuscle = primaryMuscle + self.secondaryMuscles = secondaryMuscles + self.targetSets = targetSets + self.targetReps = targetReps + self.targetWeightKg = targetWeightKg + self.targetMaxRpe = targetMaxRpe + self.restSec = restSec + self.note = note + } +} + +/// One program: a name, an optional note, and its lines IN SHEET ORDER. +public struct ImportedProgram: Sendable, Equatable { + public var name: String + public var note: String? + public var lines: [ImportedProgramLine] + + public init(name: String, note: String?, lines: [ImportedProgramLine]) { + self.name = name + self.note = note + self.lines = lines + } +} + +/// What a sheet produced, plus everything that was wrong with it. +/// +/// Warnings are per-row and NON-fatal by design. A sheet with one misspelled muscle should import +/// eleven good lines and tell the user about the twelfth, not refuse the file — the alternative is a +/// user fixing one typo at a time through twelve round trips. +public struct LiftProgramImportResult: Sendable, Equatable { + public var programs: [ImportedProgram] + public var warnings: [String] + + public init(programs: [ImportedProgram], warnings: [String]) { + self.programs = programs + self.warnings = warnings + } +} + +public enum LiftProgramSheetImporter { + + public enum ImportError: Error, Equatable { + /// Not a spreadsheet this can read at all. + case unreadable + /// Readable, but without the columns that make it a program sheet. + case missingColumns([String]) + /// Read fine, but there was nothing in it. + case empty + /// Too big to be a program sheet — see `maxFileBytes`. + case tooLarge + } + + // MARK: - Bounds + // + // A program is a few dozen rows. Everything below is far above any real sheet and far below + // anything that could hurt: this feature is a convenience, and it must never be the reason the + // app is slow, runs out of memory, or writes a database nobody wants. + + /// A filled template is ~9 KB; a hand-built sheet with a year of programs is still well under a + /// megabyte. 8 MB is generous enough that no honest file is refused, and small enough that + /// reading it whole into memory on a phone is nothing. + public static let maxFileBytes = 8 * 1024 * 1024 + + /// Rows considered from a sheet. A spreadsheet can carry a million empty rows, and some writers + /// emit them; parsing them all is wasted work and the result would be unusable anyway. + static let maxRows = 5_000 + + /// Exercise lines kept per program. A program with more lines than this is not a program. + static let maxLinesPerProgram = 200 + + /// Programs created from one file. + static let maxPrograms = 50 + + /// Warnings reported. A pathological sheet could otherwise produce thousands, which helps nobody + /// and makes the preview unscrollable; the count is still reported honestly (see `warnings`). + static let maxWarnings = 50 + + /// Column keys, after `HeaderNorm.normalize`. Several spellings map to the same field so a user + /// who retypes the header — or translates it — is not punished for it. + private static let exerciseKeys = ["exercise", "movement", "lift"] + private static let programKeys = ["program", "programme", "workout", "routine", "day"] + private static let programNoteKeys = ["program_note", "programme_note", "workout_note", "routine_note"] + private static let primaryKeys = ["primary_muscle", "primary", "muscle"] + private static let secondaryKeys = ["secondary_muscles", "secondary_muscle", "secondary"] + private static let setsKeys = ["sets", "working_sets", "target_sets"] + private static let repsKeys = ["reps", "rep", "target_reps", "repetitions"] + private static let weightKeys = ["weight_kg", "weight", "kg", "load", "load_kg"] + private static let restKeys = ["rest_sec", "rest_seconds", "rest", "rest_s"] + private static let noteKeys = ["note", "technique_note", "notes", "cue"] + + /// Parse a filled-in template. Detects `.xlsx` by its ZIP magic bytes, else treats it as CSV. + public static func parse(data: Data) throws -> LiftProgramImportResult { + // Refuse before doing any work. An .xlsx is a ZIP, so a hostile one can be small on disk and + // enormous expanded; `XlsxSheet` bounds the expansion separately. This bound is on what the + // caller handed us. + guard data.count <= maxFileBytes else { throw ImportError.tooLarge } + + let candidates: [XlsxSheet.Sheet] + if isZip(data) { + // Every sheet, in tab order — the workbook may carry instructions, notes or the user's + // own scratch alongside the program, and only this parser can tell which is which. + candidates = try XlsxSheet.sheets(from: data) + } else { + let table = CSVTable(data: data) + guard !table.headers.isEmpty else { throw ImportError.unreadable } + candidates = [XlsxSheet.Sheet(headerKeys: Set(table.normalizedHeaders.filter { !$0.isEmpty }), + rows: table.rows)] + } + + // An exercise column is the one thing a program sheet cannot do without, so it is also how a + // program sheet is recognised among several. First match in tab order wins. + // + // Matched on HEADERS, not on rows, so the three failures stay distinguishable: a file that is + // not a program sheet at all, and a correct template nobody has filled in yet, are different + // mistakes and deserve different advice. + guard let sheet = candidates.first(where: { + !$0.headerKeys.isDisjoint(with: exerciseKeys) + }) else { + throw ImportError.missingColumns(["exercise"]) + } + let rows = sheet.rows + guard !rows.isEmpty else { throw ImportError.empty } + + var programs: [ImportedProgram] = [] + var indexByName: [String: Int] = [:] + var warnings: [String] = [] + + var truncated = false + for (i, row) in rows.prefix(maxRows).enumerated() { + // Spreadsheets are full of trailing blank rows; they are not an error. + let exercise = value(row, exerciseKeys)?.trimmed ?? "" + if exercise.isEmpty { + if row.values.contains(where: { !$0.trimmed.isEmpty }), warnings.count < maxWarnings { + warnings.append(rowMessage(i, "no exercise name, so the row was skipped")) + } + continue + } + + let programName = value(row, programKeys)?.trimmed.nilIfEmpty ?? "Imported program" + + var primary: LiftMuscle? + if let raw = value(row, primaryKeys)?.trimmed.nilIfEmpty { + primary = LiftMuscle(sheetName: raw) + if primary == nil, warnings.count < maxWarnings { + warnings.append(rowMessage(i, "\"\(raw)\" is not a muscle group, so \"\(exercise)\" was left unclassified")) + } + } + + var secondary: [LiftMuscle] = [] + if let raw = value(row, secondaryKeys)?.trimmed.nilIfEmpty { + for part in raw.split(whereSeparator: { $0 == "," || $0 == ";" || $0 == "/" || $0 == "|" }) { + let token = String(part).trimmed + guard !token.isEmpty else { continue } + if let m = LiftMuscle(sheetName: token) { + // The store excludes the primary from the secondary list, so do it here too + // rather than leaving a row that says "chest, chest". + if m != primary, !secondary.contains(m) { secondary.append(m) } + } else if warnings.count < maxWarnings { + warnings.append(rowMessage(i, "\"\(token)\" is not a muscle group and was ignored")) + } + } + } + + // A max RPE outside the scale is refused with a warning rather than clamped: 12 is a typo, + // and guessing whether it meant 10 or 1.2 would put a ceiling in the plan nobody chose. + var maxRpe = doubleValue(row, ["target_max_rpe", "max_rpe", "rpe_max", "target_rpe", "rpe"]) + if let rpe = maxRpe, !(1...10).contains(rpe) { + maxRpe = nil + if warnings.count < maxWarnings { + let shown = rpe == rpe.rounded() ? String(Int(rpe)) : String(rpe) + warnings.append(rowMessage(i, "max RPE \(shown) is not between 1 and 10, so \"\(exercise)\" has none")) + } + } + + // Notes are capped to the same lengths the in-app editors enforce. A spreadsheet cell + // holds far more than a phone can show, and a note that arrives longer than the editor + // would ever let you type is a note you can never fully see or edit afterwards. + let line = ImportedProgramLine( + exercise: exercise, + primaryMuscle: primary, + secondaryMuscles: secondary, + targetSets: intValue(row, setsKeys), + targetReps: intValue(row, repsKeys), + targetWeightKg: doubleValue(row, weightKeys), + targetMaxRpe: maxRpe, + restSec: intValue(row, restKeys), + note: value(row, noteKeys)?.trimmed.nilIfEmpty + .map { String($0.prefix(WhoopStore.maxExerciseNoteLength)) }) + + if let idx = indexByName[programName.lowercased()] { + guard programs[idx].lines.count < maxLinesPerProgram else { truncated = true; continue } + programs[idx].lines.append(line) + if programs[idx].note == nil { + programs[idx].note = programNote(row) + } + } else { + guard programs.count < maxPrograms else { truncated = true; continue } + indexByName[programName.lowercased()] = programs.count + programs.append(ImportedProgram( + name: programName, + note: programNote(row), + lines: [line])) + } + } + + // Say so rather than silently importing a subset: a user whose sheet was cut off must be able + // to see that it was, and the preview is where they would notice. + if rows.count > maxRows || truncated { + warnings.append("The sheet is larger than a program can be, so only the first " + + "\(maxPrograms) programs and \(maxLinesPerProgram) exercises each were read.") + } + + guard !programs.isEmpty else { throw ImportError.empty } + return LiftProgramImportResult(programs: programs, warnings: warnings) + } + + // MARK: - Helpers + + private static func isZip(_ data: Data) -> Bool { + data.count >= 4 && data[data.startIndex] == 0x50 && data[data.startIndex + 1] == 0x4B + } + + /// Warnings name the SHEET's own row number so the user can go straight to it: the header is + /// row 1, so the first data row is row 2. + private static func rowMessage(_ i: Int, _ text: String) -> String { + "Row \(i + 2): \(text)" + } + + private static func programNote(_ row: [String: String]) -> String? { + value(row, programNoteKeys)?.trimmed.nilIfEmpty + .map { String($0.prefix(WhoopStore.maxProgramNoteLength)) } + } + + private static func value(_ row: [String: String], _ keys: [String]) -> String? { + for k in keys { + if let v = row[k], !v.trimmed.isEmpty { return v } + } + return nil + } + + private static func intValue(_ row: [String: String], _ keys: [String]) -> Int? { + guard let raw = value(row, keys) else { return nil } + return doubleFrom(raw).map { Int($0.rounded()) } + } + + private static func doubleValue(_ row: [String: String], _ keys: [String]) -> Double? { + guard let raw = value(row, keys) else { return nil } + return doubleFrom(raw) + } + + /// Numbers as spreadsheets actually write them: "60", "60.5", "60,5" (comma decimal in most of + /// Europe), "60 kg", "2 min". Anything with no digits at all is nil rather than 0 — a blank cell + /// means "not planned", and 0 would be a planned zero. + static func doubleFrom(_ raw: String) -> Double? { + var cleaned = "" + var seenSeparator = false + for ch in raw { + if ch.isNumber { cleaned.append(ch) } + else if (ch == "." || ch == ",") && !seenSeparator && !cleaned.isEmpty { + cleaned.append(".") + seenSeparator = true + } else if ch == "-" && cleaned.isEmpty { + cleaned.append(ch) + } + } + guard cleaned.contains(where: { $0.isNumber }) else { return nil } + return Double(cleaned) + } +} + +extension LiftMuscle { + /// Resolve a muscle written by a person in a spreadsheet. + /// + /// Accepts the stored token (`frontDelts`) and the English display name ("Front delts"), and is + /// insensitive to case, spaces, hyphens and underscores — so "front delts", "Front-Delts" and + /// "FRONTDELTS" all land on the same case. Deliberately NOT fuzzy beyond that: the muscle + /// vocabulary is a stored-data contract, and guessing that "shoulders" means front delts would + /// silently put sets in a bucket the user did not choose. + init?(sheetName raw: String) { + let key = raw.folding(options: .diacriticInsensitive, locale: Locale(identifier: "en_US_POSIX")) + .lowercased() + .filter { $0.isLetter || $0.isNumber } + guard !key.isEmpty else { return nil } + for m in LiftMuscle.allCases where m.rawValue.lowercased() == key { + self = m + return + } + // The English display names, which is what the shipped template's dropdown offers. + let byName: [String: LiftMuscle] = [ + "chest": .chest, "frontdelts": .frontDelts, "sidedelts": .sideDelts, + "reardelts": .rearDelts, "triceps": .triceps, "lats": .lats, + "upperback": .upperBack, "traps": .traps, "biceps": .biceps, + "forearms": .forearms, "quads": .quads, "hamstrings": .hamstrings, + "glutes": .glutes, "adductors": .adductors, "abductors": .abductors, + "calves": .calves, "abs": .abs, "obliques": .obliques, + "lowerback": .lowerBack, "neck": .neck, + ] + guard let m = byName[key] else { return nil } + self = m + } +} + +private extension String { + var trimmed: String { trimmingCharacters(in: .whitespacesAndNewlines) } + var nilIfEmpty: String? { isEmpty ? nil : self } +} diff --git a/Packages/StrandImport/Sources/StrandImport/LiftingImporter.swift b/Packages/StrandImport/Sources/StrandImport/LiftingImporter.swift index 7a0e4f5513..93a96a3ffb 100644 --- a/Packages/StrandImport/Sources/StrandImport/LiftingImporter.swift +++ b/Packages/StrandImport/Sources/StrandImport/LiftingImporter.swift @@ -160,11 +160,13 @@ public enum LiftingImporter { let setType = (row.cell("set_type", "type") ?? "").lowercased() // Weight: prefer kg; fall back to a lb column (convert). Bodyweight sets have no weight. - let weightKg: Double? = row.double("weight_kg", "weight", "weight_kgs") - ?? row.double("weight_lb", "weight_lbs", "weight_lbf").map { $0 * lbToKg } - // Crafted-import-crash guard: Int($0) traps on non-finite/out-of-range - // Doubles from a hostile CSV; bound reps to a sane finite range. - let reps = row.double("reps", "rep_count").flatMap { $0.isFinite && $0 >= 0 && $0 < 1e6 ? Int($0) : nil } + // Non-finite is rejected here for the same reason reps are bounded: `Double("1e9999")` is + // infinity, and an infinite top set would ride all the way out to the session note while + // poisoning the volume total. Kotlin's twin already dropped it at this point. + let weightKg: Double? = (row.double("weight_kg", "weight", "weight_kgs") + ?? row.double("weight_lb", "weight_lbs", "weight_lbf").map { $0 * lbToKg }) + .flatMap { $0.isFinite ? $0 : nil } + let reps = boundedReps(row.double("reps", "rep_count")) let key = "\(title ?? "")|\(startRaw)" if byKey[key] == nil { @@ -178,8 +180,40 @@ public enum LiftingImporter { return finish(order.compactMap { byKey[$0] }, skipped: skipped) } + /// Fold ONE workout object from the Hevy API into an accumulator, or nil when it carries no + /// usable start (no start means no window to attach a session to). + /// + /// Shared by `parseHevyAPI` and the workout-events lane, which receives the SAME `Workout` shape + /// wrapped in an `updated` event. Factored out so an edit arriving over events cannot fold + /// differently from the same workout arriving over a page. + static func hevyAccumulator(from w: [String: Any], zone: TimeZone) -> HevyAccumulator? { + guard let startStr = w["start_time"] as? String, + let start = parseDate(startStr, zone: zone) else { return nil } + let rawTitle = (w["title"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let acc = HevyAccumulator(start: start, + title: (rawTitle?.isEmpty == false) ? rawTitle : nil, + zone: zone) + // The accessor resolves this through the same `parseDate`, so the API and CSV paths cannot + // disagree about an end time. + acc.endRaw = w["end_time"] as? String + for e in (w["exercises"] as? [Any]) ?? [] { + guard let ex = e as? [String: Any] else { continue } + let title = (ex["title"] as? String) ?? "" + for st in (ex["sets"] as? [Any]) ?? [] { + guard let set = st as? [String: Any] else { continue } + acc.add( + exercise: title, + setType: ((set["type"] as? String) ?? "normal").lowercased(), + weightKg: jsonDouble(set["weight_kg"]), + reps: boundedReps(jsonDouble(set["reps"])) + ) + } + } + return acc + } + /// Mutable per-session tally while folding Hevy set rows. - private final class HevyAccumulator { + final class HevyAccumulator { let start: Date let title: String? /// Device timezone used to resolve the zoneless Hevy end-time, matching the start (#649). @@ -230,6 +264,61 @@ public enum LiftingImporter { // MARK: - Liftosaur JSON /// Parse a Liftosaur JSON export into one session per history record. + /// Parse a Hevy **API** workouts page into the same sessions the CSV export yields. + /// + /// A third sibling of [parseHevy] and [parseLiftosaur], deliberately not a lane of its own: the + /// API describes the same workouts the CSV export does, so it must produce the same + /// `LiftingSession`, through the same accumulator, with the same warm-up exclusion and volume-load + /// arithmetic and the same `sourceId`. A parallel model would import one session twice under two + /// provenances, and would let the two drift about what a set is worth. + /// + /// Dates go through [parseDate] for the same reason. It honours an embedded ISO offset first and + /// only falls back to zoneless wall-clock, so the API's stamped offsets are respected and the + /// #649 device-zone interpretation stays available if the API ever omits one. `zone` is carried + /// through rather than assumed. + /// + /// The envelope is VERIFIED against Hevy's published OpenAPI spec (api.hevyapp.com/docs): + /// `GET /v1/workouts` returns `{ page, page_count, workouts[] }`; a workout carries `id`, `title`, + /// `description`, `start_time`, `end_time`; an exercise carries `title` and `sets[]`; a set + /// carries `type`, `weight_kg`, `reps` (and `distance_meters`, `duration_seconds`, `rpe`, which + /// this lane has nowhere to put). + /// + /// `type` is one of `normal`, `warmup`, `dropset`, `failure`. Only `warmup` is excluded from the + /// volume load — a dropset and a set taken to failure are working sets, and the shared + /// accumulator already treats them that way, which is why this path needs no rule of its own. + public static func parseHevyAPI(data: Data, zone: TimeZone = .current) -> LiftingImportResult { + let empty = LiftingImportResult(sessions: [], skipped: 0, earliest: nil, latest: nil) + guard let root = try? JSONSerialization.jsonObject(with: BOM.stripUTF8(data)) else { return empty } + let raw: [Any] + if let obj = root as? [String: Any], let list = obj["workouts"] as? [Any] { + raw = list + } else if let list = root as? [Any] { + raw = list + } else { + return empty + } + + var accumulators: [HevyAccumulator] = [] + var skipped = 0 + for element in raw { + guard let w = element as? [String: Any], + let acc = hevyAccumulator(from: w, zone: zone) else { skipped += 1; continue } + accumulators.append(acc) + } + // The shared tail, so a workout whose sets all folded away counts as skipped and the reported + // range is derived exactly as the CSV lane derives it. + return finish(accumulators, skipped: skipped) + } + + /// Bound a rep count before `Int(_:)`, which TRAPS on a non-finite or out-of-range Double rather + /// than saturating. Both Hevy lanes route through this so they cannot drift apart, and so a + /// `{"reps": 1e9999}` in a response body is skipped rather than crashing the app — a network + /// payload deserves the guard more than a file the user picked does. + static func boundedReps(_ d: Double?) -> Int? { + guard let d, d.isFinite, d >= 0, d < 1e6 else { return nil } + return Int(d) + } + public static func parseLiftosaur(data: Data) -> LiftingImportResult { // JSONSerialization rejects a leading UTF-8 BOM, so strip it (the shared CSV helper). guard let obj = try? JSONSerialization.jsonObject(with: BOM.stripUTF8(data)) else { @@ -305,21 +394,30 @@ public enum LiftingImporter { private static func liftosaurWeightKg(_ set: [String: Any], entryUnit: String?) -> Double? { let raw = set["weight"] ?? set["weightValue"] if let obj = raw as? [String: Any] { - guard let v = liftosaurDouble(obj["value"]) else { return nil } + guard let v = jsonDouble(obj["value"]) else { return nil } let unit = (obj["unit"] as? String)?.lowercased() ?? entryUnit return unit == "lb" || unit == "lbs" ? v * lbToKg : v } - guard let v = liftosaurDouble(raw) else { return nil } + guard let v = jsonDouble(raw) else { return nil } return entryUnit == "lb" || entryUnit == "lbs" ? v * lbToKg : v } // MARK: - JSON scalar coercion - private static func liftosaurDouble(_ any: Any?) -> Double? { - if let d = any as? Double { return d } - if let n = any as? NSNumber { return n.doubleValue } - if let s = any as? String { return Double(s) } - return nil + /// A JSON number arrives as a `Double`, an `NSNumber` or a quoted string depending on the + /// encoder. Accept all three and reject anything else, so a nulled weight stays absent rather + /// than becoming zero volume. Shared by the Liftosaur, Hevy CSV and Hevy API lanes. + static func jsonDouble(_ any: Any?) -> Double? { + let raw: Double? + if let d = any as? Double { raw = d } + else if let n = any as? NSNumber { raw = n.doubleValue } + else if let s = any as? String { raw = Double(s) } + else { raw = nil } + // Reject a non-finite result rather than carrying it into the volume load: `Double("1e9999")` + // is `+infinity`, and an infinite volume would print as "0 kg" via the safeInt fallback while + // poisoning the session total. Kotlin's twin rejects at the same point. + guard let raw, raw.isFinite else { return nil } + return raw } private static func liftosaurInt(_ any: Any?) -> Int? { @@ -342,7 +440,7 @@ public enum LiftingImporter { /// Liftosaur timestamps are epoch milliseconds (number or numeric string). A plain ISO string is /// tolerated as a fallback. private static func liftosaurDate(_ any: Any?) -> Date? { - if let ms = liftosaurDouble(any), ms > 0 { + if let ms = jsonDouble(any), ms > 0 { // Heuristic: a 13-digit value is ms; a 10-digit value is seconds. return ms > 1_000_000_000_000 ? Date(timeIntervalSince1970: ms / 1000) : Date(timeIntervalSince1970: ms) diff --git a/Packages/StrandImport/Sources/StrandImport/MuscleGroups.swift b/Packages/StrandImport/Sources/StrandImport/MuscleGroups.swift new file mode 100644 index 0000000000..b0fbeff4fa --- /dev/null +++ b/Packages/StrandImport/Sources/StrandImport/MuscleGroups.swift @@ -0,0 +1,175 @@ +import Foundation + +// MARK: - Exercise → muscle attribution +// +// Groundwork for a per-muscle strength view. NOOP already imports lifting sessions (Hevy CSV, +// Liftosaur JSON) but aggregates them to a set count and a volume figure, so nothing downstream can +// say WHICH muscles did the work. This maps an exercise name to the muscles that move it. +// +// Keyword matching on a normalised name, NOT an exhaustive catalogue. Lifting trackers let people +// type anything, so a closed list would silently mis-attribute everything it did not recognise. The +// rule here is the project's usual one: an exercise this cannot place returns NOTHING, and the +// caller shows no attribution rather than a guess. A wrong muscle is worse than a blank one, because +// a blank invites the user to look while a wrong one does not. +// +// PRIMARY movers only. A barbell squat loads glutes and hamstrings too, and a bench press loads +// triceps and shoulders — but attributing every synergist would make every session light up the +// whole body and stop distinguishing a push day from a leg day. The screen this feeds is about where +// the work went, not about what was innervated. + +/// The muscle groups a strength view can attribute work to. +/// +/// Deliberately coarse. These are the granularity a lifting log can actually support: an exercise +/// name can tell you "lats", it cannot honestly tell you which head of which muscle. +public enum MuscleGroup: String, CaseIterable, Sendable, Equatable { + case chest + case upperBack + case lats + case shoulders + case biceps + case triceps + case forearms + case abs + case lowerBack + case glutes + case quadriceps + case hamstrings + case calves +} + +public enum MuscleAttribution { + + /// Normalise a logged exercise name for matching. + /// + /// Trackers append equipment in parentheses ("Squat (Barbell)"), use hyphens and en-dashes + /// inconsistently ("Seated Cable Row - V Grip"), and vary in case. The parenthetical is KEPT + /// rather than stripped, because it can carry the distinguishing word: a "Row (Machine)" and a + /// "Row (Dumbbell)" are the same muscles, but "Curl (Barbell)" and "Leg Curl" are not. + public static func normalise(_ raw: String) -> String { + let lowered = raw.lowercased() + var out = "" + var lastWasSpace = false + for ch in lowered { + if ch.isLetter || ch.isNumber { + out.append(ch) + lastWasSpace = false + } else if !lastWasSpace { + out.append(" ") + lastWasSpace = true + } + } + return out.trimmingCharacters(in: .whitespaces) + } + + /// Real exercises this vocabulary has no group for, which must attribute NOTHING rather than + /// fall through to a generic rule that would be wrong. + /// + /// "Neck Curl" contains "curl" and came out as BICEPS. These thirteen groups have no neck, so a + /// blank is the only honest answer, and a blank is what the whole table is supposed to prefer: it + /// invites a look, where a wrong muscle does not. + /// + /// Kept separate from `rules` because a rule must name at least one group. An entry here is the + /// deliberate absence of one, which is a different statement from "not recognised". + static let unattributable: [String] = ["neck"] + + /// The primary movers for a logged exercise name, or an empty array when it cannot be placed. + /// + /// Order matters: the first rule that matches wins, so the more specific phrase has to be tested + /// before the word it contains. "leg curl" is hamstrings and must be decided before "curl" sends + /// it to biceps; "front raise" is shoulders and must beat "raise"; "calf raise" likewise. + public static func muscles(for exercise: String) -> [MuscleGroup] { + let n = normalise(exercise) + guard !n.isEmpty else { return [] } + guard !unattributable.contains(where: { n.contains($0) }) else { return [] } + for (needle, groups) in rules where n.contains(needle) { + return groups + } + return [] + } + + /// Longest-phrase-first, so a specific lift is decided before the generic word inside it. Held as + /// an ordered array rather than a dictionary precisely because that order is the logic. + static let rules: [(String, [MuscleGroup])] = [ + // legs — the specific curls and raises must precede the generic ones + ("leg curl", [.hamstrings]), + ("romanian deadlift", [.hamstrings, .glutes]), + ("stiff leg deadlift", [.hamstrings, .glutes]), + ("good morning", [.hamstrings, .lowerBack]), + ("leg extension", [.quadriceps]), + ("hack squat", [.quadriceps]), + ("front squat", [.quadriceps]), + ("bulgarian", [.quadriceps, .glutes]), + ("lunge", [.quadriceps, .glutes]), + ("leg press", [.quadriceps, .glutes]), + ("squat", [.quadriceps, .glutes]), + ("hip thrust", [.glutes]), + ("glute bridge", [.glutes]), + ("calf raise", [.calves]), + ("calf press", [.calves]), + // hinge / back + ("deadlift", [.lowerBack, .glutes, .hamstrings]), + ("back extension", [.lowerBack]), + ("hyperextension", [.lowerBack]), + ("pull up", [.lats, .biceps]), + ("pullup", [.lats, .biceps]), + ("chin up", [.lats, .biceps]), + ("chinup", [.lats, .biceps]), + ("lat pulldown", [.lats]), + ("pulldown", [.lats]), + ("pullover", [.lats]), + ("face pull", [.upperBack, .shoulders]), + ("shrug", [.upperBack]), + // Before the generic row: an upright row is a shoulder movement, and every + // upright row contains "row", so the generic rule would swallow it. + ("upright row", [.shoulders, .upperBack]), + ("row", [.upperBack, .lats]), + // push + ("bench press", [.chest, .triceps]), + ("chest press", [.chest, .triceps]), + ("chest fly", [.chest]), + ("pec deck", [.chest]), + // Before the generic fly: a reverse fly and a rear-delt fly are REAR movements, and both + // contain "fly", so the generic rule would file the opposite side of the body. Order is the + // whole contract here, not a stylistic choice. + ("rear delt", [.shoulders, .upperBack]), + ("reverse fly", [.shoulders, .upperBack]), + ("fly", [.chest]), + ("push up", [.chest, .triceps]), + ("pushup", [.chest, .triceps]), + ("dip", [.chest, .triceps]), + ("overhead press", [.shoulders, .triceps]), + ("shoulder press", [.shoulders, .triceps]), + ("military press", [.shoulders, .triceps]), + ("arnold press", [.shoulders]), + ("lateral raise", [.shoulders]), + ("front raise", [.shoulders]), + // arms + // Hevy writes it as one word; the two-word rule missed the spelling the catalogue uses. + ("skullcrusher", [.triceps]), + ("skull crusher", [.triceps]), + ("tricep", [.triceps]), + ("pushdown", [.triceps]), + ("kickback", [.triceps]), + ("hammer curl", [.biceps, .forearms]), + ("preacher curl", [.biceps]), + ("bicep", [.biceps]), + // Before the generic curl: a nordic curl is a hamstring movement and a jefferson curl is a + // spinal one. Both contain "curl", so the generic rule would call them biceps. + ("nordic curl", [.hamstrings]), + ("jefferson curl", [.lowerBack, .hamstrings]), + // Before the generic curl: a wrist curl is forearms, and "Wrist Curl" is how the exercise is + // normally written, so the rule below was unreachable for the title it exists to catch. + ("wrist", [.forearms]), + ("curl", [.biceps]), + ("farmer", [.forearms]), + // trunk + ("plank", [.abs]), + ("crunch", [.abs]), + ("sit up", [.abs]), + ("situp", [.abs]), + ("leg raise", [.abs]), + ("hanging raise", [.abs]), + ("ab wheel", [.abs]), + ("russian twist", [.abs]), + ] +} 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/Sources/StrandImport/XlsxSheet.swift b/Packages/StrandImport/Sources/StrandImport/XlsxSheet.swift new file mode 100644 index 0000000000..eb1bd11baf --- /dev/null +++ b/Packages/StrandImport/Sources/StrandImport/XlsxSheet.swift @@ -0,0 +1,286 @@ +import Foundation +import ZIPFoundation + +// A deliberately small `.xlsx` reader: enough to read a filled-in template, and nothing else. +// +// An .xlsx is a ZIP of XML. Reading one properly — styles, number formats, dates, formulas, +// streaming — is a library's worth of work. This reads each worksheet as text, in tab order, which is +// all a program sheet needs, and is honest about that: no formula evaluation (a cell's last cached +// value is used, which is what Excel wrote), no date coercion, no styling. +// +// Only ZIPFoundation and Foundation's own XMLParser are used, both already in the package. Nothing +// new enters the dependency graph for this. +enum XlsxSheet { + + /// One worksheet, reduced to what the caller needs to recognise and read it. + /// + /// Headers are kept SEPARATE from rows so an empty sheet is still identifiable. A downloaded + /// template that nobody has filled in has the right columns and no data, and that has to be + /// reported as "no exercises yet" rather than "wrong file" — the user is holding the right file. + struct Sheet { + var headerKeys: Set + var rows: [[String: String]] + } + + /// Every worksheet, in workbook (tab) order. + /// + /// Every sheet rather than the first, because the caller is looking for a particular KIND of + /// sheet and only it can recognise one. A user who reorders tabs, keeps the instructions page in + /// front, or pastes the data into their own workbook still gets an import; picking by position + /// would refuse all three. + static func sheets(from data: Data) throws -> [Sheet] { + try grids(from: data).map(headerKeyed) + } + + /// One grid's header row applied to the rows beneath it. + private static func headerKeyed(_ grid: [[String]]) -> Sheet { + guard let headerRow = grid.first else { return Sheet(headerKeys: [], rows: []) } + let keys = headerRow.map { HeaderNorm.normalize($0) } + var out: [[String: String]] = [] + for cells in grid.dropFirst() { + // Blank rows are ordinary in a spreadsheet — a user leaves space at the bottom. + if cells.allSatisfy({ $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) { continue } + var dict: [String: String] = [:] + for (i, key) in keys.enumerated() where !key.isEmpty { + let v = i < cells.count ? cells[i] : "" + if dict[key] == nil || dict[key]!.isEmpty { dict[key] = v } + } + out.append(dict) + } + return Sheet(headerKeys: Set(keys.filter { !$0.isEmpty }), rows: out) + } + + /// Every worksheet as a grid of strings, in workbook (tab) order. + static func grids(from data: Data) throws -> [[[String]]] { + guard let archive = try? Archive(data: data, accessMode: .read) else { + throw LiftProgramSheetImporter.ImportError.unreadable + } + // Shared strings are optional: a sheet written with inline strings has no such part. + var shared: [String] = [] + if let pool = try? entryData(archive, "xl/sharedStrings.xml") { + shared = SharedStrings.parse(pool) + } + let parts = worksheetPaths(archive) + guard !parts.isEmpty else { throw LiftProgramSheetImporter.ImportError.unreadable } + return parts.compactMap { path in + (try? entryData(archive, path)).map { SheetParser.parse($0, shared: shared) } + } + } + + /// Worksheet part paths in TAB order, resolved through the workbook. + /// + /// `xl/worksheets/sheet1.xml` is a stable id, not a position: a user who drags the instructions + /// tab in front of the data tab, or a writer that numbers parts differently, leaves `sheet1.xml` + /// sitting behind another sheet. So take `` order from `xl/workbook.xml` — that IS tab + /// order — and map each `r:id` through `xl/_rels/workbook.xml.rels`. + /// + /// Falls back to every worksheet part sorted by name, for a file whose workbook part is missing + /// or unreadable. Order matters less there than not losing the data entirely. + private static func worksheetPaths(_ archive: Archive) -> [String] { + if let workbook = try? entryData(archive, "xl/workbook.xml"), + let rels = try? entryData(archive, "xl/_rels/workbook.xml.rels") { + let ids = WorkbookOrder.sheetRelationshipIds(workbook) + let targets = WorkbookOrder.targets(in: rels) + let paths = ids.compactMap { targets[$0] }.map { target -> String in + // Targets are written relative to the workbook part, which lives in `xl/`. + target.hasPrefix("/") ? String(target.dropFirst()) : "xl/" + target + } + if !paths.isEmpty { return paths } + } + return archive.map(\.path) + .filter { $0.hasPrefix("xl/worksheets/") && $0.hasSuffix(".xml") } + .sorted() + } + + /// One part's raw bytes, by path. Internal, for tests that need to assert on the XML itself — + /// the shipped template's sheet protection is not visible through the parsed grid. + static func rawPart(_ data: Data, path: String) -> Data? { + guard let archive = try? Archive(data: data, accessMode: .read) else { return nil } + return try? entryData(archive, path) + } + + /// Ceiling on ONE decompressed part. + /// + /// Measured on the expanded stream, not the archive, because that is the only bound that means + /// anything: an .xlsx is a ZIP, and a zip bomb is by definition tiny compressed and enormous + /// expanded, so a limit on the file's own size is trivially defeated. `DataBackup` guards its + /// restore the same way and for the same reason (#1807). 64 MB is orders of magnitude above any + /// real worksheet and still bounded. + static let maxPartBytes = 64 * 1024 * 1024 + + private static func entryData(_ archive: Archive, _ path: String) throws -> Data { + guard let entry = archive[path] else { + throw LiftProgramSheetImporter.ImportError.unreadable + } + var out = Data() + out.reserveCapacity(min(Int(entry.uncompressedSize), 1 << 20)) + _ = try archive.extract(entry, bufferSize: 64 * 1024, skipCRC32: true) { chunk in + guard out.count + chunk.count <= maxPartBytes else { + throw LiftProgramSheetImporter.ImportError.tooLarge + } + out.append(chunk) + } + return out + } + + /// Reads just enough of `workbook.xml` and its rels to put the worksheets in tab order. + final class WorkbookOrder: NSObject, XMLParserDelegate { + private var ids: [String] = [] + private var targets: [String: String] = [:] + private var collectingRels = false + + /// `r:id` of every ``, in document order — which is tab order. + static func sheetRelationshipIds(_ data: Data) -> [String] { + let d = WorkbookOrder() + let p = XMLParser(data: data) + p.delegate = d + p.parse() + return d.ids + } + + /// Relationship id -> target path. + static func targets(in rels: Data) -> [String: String] { + let d = WorkbookOrder() + d.collectingRels = true + let p = XMLParser(data: rels) + p.delegate = d + p.parse() + return d.targets + } + + func parser(_ parser: XMLParser, didStartElement name: String, namespaceURI: String?, + qualifiedName: String?, attributes: [String: String] = [:]) { + if collectingRels { + if name == "Relationship", let id = attributes["Id"], let target = attributes["Target"] { + targets[id] = target + } + return + } + // The r:id attribute arrives qualified or not depending on the writer. + if name == "sheet", let rid = attributes["r:id"] ?? attributes["id"] { + ids.append(rid) + } + } + } + + /// `xl/sharedStrings.xml` — the string pool most cells point into. + private final class SharedStrings: NSObject, XMLParserDelegate { + private var strings: [String] = [] + private var current: String? + private var collecting = false + + static func parse(_ data: Data) -> [String] { + let d = SharedStrings() + let p = XMLParser(data: data) + p.delegate = d + p.parse() + return d.strings + } + + func parser(_ parser: XMLParser, didStartElement name: String, namespaceURI: String?, + qualifiedName: String?, attributes: [String: String] = [:]) { + if name == "si" { current = "" } + // A rich-text run splits one logical string across several elements; they concatenate. + if name == "t" { collecting = true } + } + + func parser(_ parser: XMLParser, foundCharacters string: String) { + if collecting { current = (current ?? "") + string } + } + + func parser(_ parser: XMLParser, didEndElement name: String, namespaceURI: String?, + qualifiedName: String?) { + if name == "t" { collecting = false } + if name == "si" { + strings.append(current ?? "") + current = nil + } + } + } + + /// A worksheet part: rows of cells, placed by their `r` reference so gaps stay gaps. + private final class SheetParser: NSObject, XMLParserDelegate { + private var shared: [String] = [] + private var grid: [[String]] = [] + private var row: [String] = [] + private var cellRef = "" + private var cellType = "" + private var text: String? + private var collecting = false + + static func parse(_ data: Data, shared: [String]) -> [[String]] { + let d = SheetParser() + d.shared = shared + let p = XMLParser(data: data) + p.delegate = d + p.parse() + return d.grid + } + + func parser(_ parser: XMLParser, didStartElement name: String, namespaceURI: String?, + qualifiedName: String?, attributes: [String: String] = [:]) { + switch name { + case "row": + row = [] + case "c": + cellRef = attributes["r"] ?? "" + cellType = attributes["t"] ?? "" + text = nil + case "v", "t": + collecting = true + text = text ?? "" + default: + break + } + } + + func parser(_ parser: XMLParser, foundCharacters string: String) { + if collecting { text = (text ?? "") + string } + } + + func parser(_ parser: XMLParser, didEndElement name: String, namespaceURI: String?, + qualifiedName: String?) { + switch name { + case "v", "t": + collecting = false + case "c": + // t="s" means the value is an index into the shared-string pool; anything else is + // the literal text (inline strings arrive already resolved through ). + var value = text ?? "" + if cellType == "s", let i = Int(value), i >= 0, i < shared.count { + value = shared[i] + } + let column = SheetParser.columnIndex(cellRef) + if column >= 0 { + while row.count <= column { row.append("") } + row[column] = value + } else { + row.append(value) + } + text = nil + case "row": + grid.append(row) + row = [] + default: + break + } + } + + /// "C7" -> 2. Zero-based, so a skipped column stays an empty cell rather than shifting every + /// value after it one place left — which would silently move reps into the weight column. + static func columnIndex(_ ref: String) -> Int { + var n = 0 + var any = false + for ch in ref.uppercased() { + guard let ascii = ch.asciiValue else { break } + if ascii >= 65, ascii <= 90 { + n = n * 26 + Int(ascii - 64) + any = true + } else { + break + } + } + return any ? n - 1 : -1 + } + } +} 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..8366fed45f 100644 --- a/Packages/StrandImport/Tests/StrandImportTests/HealthWritebackTests.swift +++ b/Packages/StrandImport/Tests/StrandImportTests/HealthWritebackTests.swift @@ -183,4 +183,175 @@ 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)") + } + + /// #2210: the orphan reconciliation recognises one of our workouts in Health by this prefix, on a + /// record whose timestamp it does not know and therefore cannot rebuild a key for. If the prefix and + /// the builder ever drift, that match silently stops finding anything and orphaned workouts come + /// back, with nothing failing to say so. Pinned against a real key rather than against itself. + func testWorkoutKeyPrefixMatchesTheKeysItIsUsedToRecognise() { + XCTAssertTrue(HealthWriteback.appleHealthWorkoutKey(startTs: 1_700_000_000) + .hasPrefix(HealthWriteback.appleHealthWorkoutKeyPrefix)) + XCTAssertTrue(HealthWriteback.appleHealthWorkoutKey(startTs: 0) + .hasPrefix(HealthWriteback.appleHealthWorkoutKeyPrefix)) + XCTAssertEqual(HealthWriteback.appleHealthWorkoutKeyPrefix, "noop:workout:") + } + + /// The prefix must NOT match a sleep or vitals key, or the reconciliation would treat those as + /// workouts of ours and consider deleting them. + func testWorkoutKeyPrefixDoesNotMatchOtherKinds() { + XCTAssertFalse(HealthWriteback.appleHealthSleepKey(startTs: 1_700_000_000) + .hasPrefix(HealthWriteback.appleHealthWorkoutKeyPrefix)) + XCTAssertFalse(HealthWriteback.appleHealthVitalKey(metricId: "heartRateVariabilitySDNN", day: "2026-01-01") + .hasPrefix(HealthWriteback.appleHealthWorkoutKeyPrefix)) + } + + /// 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"]) + } + + func testANightEndingAtTheNewestHeartRateIsStillOpen() { + // Synced to 06:54 while the wearer slept on: the detected night ends at 06:53. + XCTAssertTrue(HealthWriteback.nightIsStillOpen(endTs: 1_000, newestHeartRateTs: 1_060, now: 1_600)) + } + + func testANightWithHeartRateWellPastItsEndIsClosed() { + XCTAssertFalse(HealthWriteback.nightIsStillOpen(endTs: 1_000, newestHeartRateTs: 1_000 + 20 * 60, now: 3_000)) + } + + func testANightIsWrittenAfterTheMaximumHoldEvenWithNoNewerHeartRate() { + // Strap taken off to charge at wake: no newer heart rate arrives. + XCTAssertTrue(HealthWriteback.nightIsStillOpen(endTs: 1_000, newestHeartRateTs: 1_000, now: 1_000 + 2 * 3_600 - 1)) + XCTAssertFalse(HealthWriteback.nightIsStillOpen(endTs: 1_000, newestHeartRateTs: 1_000, now: 1_000 + 2 * 3_600)) + } } diff --git a/Packages/StrandImport/Tests/StrandImportTests/HevyAPITests.swift b/Packages/StrandImport/Tests/StrandImportTests/HevyAPITests.swift new file mode 100644 index 0000000000..9f1d24cf7b --- /dev/null +++ b/Packages/StrandImport/Tests/StrandImportTests/HevyAPITests.swift @@ -0,0 +1,169 @@ +import XCTest +@testable import StrandImport + +/// Pins the Hevy Web API parsers beyond the workouts page: exercise templates (the authoritative +/// muscle attribution), body measurements, workout events (the only endpoint that reports deletions), +/// and the two trivial ones. Kotlin twin: HevyApiTest. +final class HevyAPITests: XCTestCase { + + private func data(_ s: String) -> Data { Data(s.utf8) } + + // MARK: - Exercise templates + + func testExerciseTemplatesParseMuscleGroupsAndPaging() { + let page = HevyAPI.parseExerciseTemplates(data: data(""" + {"page":2,"page_count":7,"exercise_templates":[ + {"id":"t1","title":"Bench Press (Barbell)","type":"weight_reps", + "primary_muscle_group":"chest","secondary_muscle_groups":["triceps","shoulders"], + "equipment":"barbell","is_custom":false}]} + """)) + XCTAssertEqual(page.page, 2) + XCTAssertEqual(page.pageCount, 7) + XCTAssertTrue(page.hasMore, "page 2 of 7 has more") + XCTAssertEqual(page.items.count, 1) + let t = page.items[0] + XCTAssertEqual(t.id, "t1") + XCTAssertEqual(t.primaryMuscleGroup, .chest) + XCTAssertEqual(t.secondaryMuscleGroups, [.triceps, .shoulders]) + XCTAssertEqual(t.equipment, "barbell") + XCTAssertFalse(t.isCustom) + } + + /// The underscored wire values are the ones a hand-written mapping is most likely to get wrong. + func testMuscleGroupWireValuesRoundTrip() { + XCTAssertEqual(HevyAPI.MuscleGroup(rawValue: "upper_back"), .upperBack) + XCTAssertEqual(HevyAPI.MuscleGroup(rawValue: "lower_back"), .lowerBack) + XCTAssertEqual(HevyAPI.MuscleGroup(rawValue: "full_body"), .fullBody) + XCTAssertEqual(HevyAPI.MuscleGroup.allCases.count, 20) + } + + /// A group Hevy adds later must NOT collapse into `.other`, which is a real category of its own: + /// silently filing an unknown group as "other" would look like data rather than like a gap. + func testUnknownMuscleGroupIsNilRatherThanOther() { + let page = HevyAPI.parseExerciseTemplates(data: data(""" + {"exercise_templates":[{"id":"t2","title":"X","primary_muscle_group":"rotator_cuff", + "secondary_muscle_groups":["chest","hypothetical"]}]} + """)) + XCTAssertNil(page.items[0].primaryMuscleGroup) + XCTAssertEqual(page.items[0].secondaryMuscleGroups, [.chest], "unknown dropped, known kept") + } + + func testExerciseTemplateWithoutIdOrTitleIsSkipped() { + let page = HevyAPI.parseExerciseTemplates(data: data(""" + {"exercise_templates":[{"title":"No id"},{"id":"t3"},{"id":"t4","title":"Good"}]} + """)) + XCTAssertEqual(page.items.map(\.id), ["t4"]) + XCTAssertEqual(page.skipped, 2) + } + + // MARK: - Body measurements + + func testBodyMeasurementsKeepTheDateStringAndDropEmptyEntries() { + let page = HevyAPI.parseBodyMeasurements(data: data(""" + {"page":1,"page_count":1,"body_measurements":[ + {"date":"2024-08-14","weight_kg":82.5,"fat_percent":16.2,"lean_mass_kg":69.1}, + {"date":"2024-08-15","waist":80,"left_calf":38}]} + """)) + XCTAssertEqual(page.items.count, 1, "an entry with only circumferences has nothing to store") + XCTAssertEqual(page.skipped, 1) + XCTAssertEqual(page.items[0].date, "2024-08-14", "the bare calendar date is kept verbatim") + XCTAssertEqual(page.items[0].weightKg, 82.5) + XCTAssertEqual(page.items[0].leanMassKg, 69.1) + XCTAssertFalse(page.hasMore) + } + + /// A zero weight is a cleared field, not a reading of zero kilograms. + func testBodyMeasurementZeroWeightIsNotAReading() { + let page = HevyAPI.parseBodyMeasurements(data: data(""" + {"body_measurements":[{"date":"2024-08-16","weight_kg":0,"fat_percent":15}]} + """)) + XCTAssertNil(page.items[0].weightKg) + XCTAssertEqual(page.items[0].fatPercent, 15) + } + + // MARK: - Workout events + + func testWorkoutEventsFoldUpdatesThroughTheSameArithmeticAsAPage() { + let page = HevyAPI.parseWorkoutEvents(data: data(""" + {"page":1,"page_count":3,"events":[ + {"type":"updated","workout":{"id":"w1","title":"Push","start_time":"2026-09-07T10:00:00Z", + "end_time":"2026-09-07T11:00:00Z","exercises":[{"title":"Bench Press","sets":[ + {"type":"warmup","weight_kg":40,"reps":10},{"type":"normal","weight_kg":60,"reps":8}]}]}}, + {"type":"deleted","id":"w2","deleted_at":"2026-09-07T12:00:00Z"}]} + """)) + XCTAssertEqual(page.items.count, 2) + XCTAssertEqual(page.pageCount, 3) + guard case let .updated(id, session) = page.items[0] else { return XCTFail("expected updated") } + XCTAssertEqual(id, "w1") + XCTAssertEqual(session.volumeLoadKg, 480, accuracy: 0.001) // 60×8; the warm-up excluded + XCTAssertEqual(session.setCount, 1) + guard case let .deleted(deletedID, at) = page.items[1] else { return XCTFail("expected deleted") } + XCTAssertEqual(deletedID, "w2") + XCTAssertNotNil(at) + XCTAssertEqual(page.items[1].workoutID, "w2") + } + + /// An update whose sets all fold away is NOT a deletion. Reporting it as one would drop a workout + /// the user still has, which is the worst thing a sync can do. + func testAnUpdateThatFoldsToNothingIsSkippedNotDeleted() { + let page = HevyAPI.parseWorkoutEvents(data: data(""" + {"events":[{"type":"updated","workout":{"id":"w3","start_time":"2026-09-07T10:00:00Z", + "exercises":[{"title":"Bench","sets":[{"type":"warmup","weight_kg":40,"reps":10}]}]}}]} + """)) + XCTAssertTrue(page.items.isEmpty) + XCTAssertEqual(page.skipped, 1) + } + + func testUnknownEventTypeIsSkipped() { + let page = HevyAPI.parseWorkoutEvents(data: data(""" + {"events":[{"type":"archived","id":"w4"},{"type":"deleted","id":"w5"}]} + """)) + XCTAssertEqual(page.items.count, 1) + XCTAssertEqual(page.skipped, 1) + guard case let .deleted(_, at) = page.items[0] else { return XCTFail("expected deleted") } + XCTAssertNil(at, "deleted_at is optional in the spec") + } + + // MARK: - Count, user info, pagination + + func testWorkoutCountAndUserInfo() { + XCTAssertEqual(HevyAPI.parseWorkoutCount(data: data("{\"workout_count\":42}")), 42) + XCTAssertNil(HevyAPI.parseWorkoutCount(data: data("{}"))) + let user = HevyAPI.parseUserInfo(data: data("{\"data\":{\"id\":\"u1\",\"name\":\"Sam\"}}")) + XCTAssertEqual(user?.id, "u1") + XCTAssertEqual(user?.name, "Sam") + XCTAssertNil(user?.url) + XCTAssertEqual(HevyAPI.parseUserInfo(data: data("{\"id\":\"u2\"}"))?.id, "u2", "bare object tolerated") + } + + /// A missing or nonsense page number falls back to 1 rather than to zero: this value ends up in a + /// `page < pageCount` loop condition, and a zero there would read as "nothing to fetch". + func testPaginationFallsBackRatherThanReturningZero() { + XCTAssertEqual(HevyAPI.parsePagination(data: data("{\"page\":3,\"page_count\":9}"))?.page, 3) + let odd = HevyAPI.parsePagination(data: data("{\"page\":0,\"page_count\":\"nope\"}")) + XCTAssertEqual(odd?.page, 1) + XCTAssertEqual(odd?.pageCount, 1) + XCTAssertNil(HevyAPI.parsePagination(data: data("not json"))) + } + + // MARK: - Hostile input + + /// Every one of these runs over another server's data, so nothing here may throw. + func testMalformedInputYieldsEmptyPagesRatherThanThrowing() { + for bad in ["", "not json", "[]", "{}", "{\"events\":\"nope\"}", "null"] { + XCTAssertTrue(HevyAPI.parseWorkoutEvents(data: data(bad)).items.isEmpty) + XCTAssertTrue(HevyAPI.parseExerciseTemplates(data: data(bad)).items.isEmpty) + XCTAssertTrue(HevyAPI.parseBodyMeasurements(data: data(bad)).items.isEmpty) + } + } + + /// A bare array is what a saved response pasted out of a browser looks like. + func testBareArrayIsAcceptedForEachPagedEndpoint() { + let templates = HevyAPI.parseExerciseTemplates(data: data(""" + [{"id":"t9","title":"Squat","primary_muscle_group":"quadriceps"}] + """)) + XCTAssertEqual(templates.items.count, 1) + XCTAssertEqual(templates.page, 1) + XCTAssertFalse(templates.hasMore, "an unwrapped page cannot claim more") + } +} diff --git a/Packages/StrandImport/Tests/StrandImportTests/LabMarkerCsvImportTests.swift b/Packages/StrandImport/Tests/StrandImportTests/LabMarkerCsvImportTests.swift index ac93a89df0..1634055eaf 100644 --- a/Packages/StrandImport/Tests/StrandImportTests/LabMarkerCsvImportTests.swift +++ b/Packages/StrandImport/Tests/StrandImportTests/LabMarkerCsvImportTests.swift @@ -307,4 +307,34 @@ final class LabMarkerCsvImportTests: XCTestCase { XCTAssertEqual(LabMarkerCsvImport.customKey("Apo B"), "custom_apo_b") XCTAssertEqual(LabMarkerCsvImport.customKey(" ??? "), "") } + + /// A white-cell differential lists each cell type as a count AND a percentage. Both used to + /// slug to `custom_lymph`, so on the same day the "%" row overwrote the count row. + func testPercentRowDoesNotOverwriteCountRow() { + let csv = """ + date,marker,value,unit + 2026-05-01,LYMPH,1.9,10^9/L + 2026-05-01,LYMPH %,31.2,% + 2026-05-01,BASO,0.03,10^9/L + 2026-05-01,BASO %,0.5,% + """ + let result = LabMarkerCsvImport.parse(text: csv) + XCTAssertEqual(result.importedReadings, 4) + XCTAssertEqual(result.customMarkerKeys, + ["custom_baso", "custom_baso_pct", "custom_lymph", "custom_lymph_pct"]) + XCTAssertEqual(result.rows.first { $0.markerKey == "custom_lymph" }?.value, 1.9) + XCTAssertEqual(result.rows.first { $0.markerKey == "custom_lymph_pct" }?.value, 31.2) + } + + /// Pinned by oracle: the same literals are asserted by the Android twin + /// (LabMarkerCsvImportTest.customKeyIsPinned). Names without "%" keep their pre-"pct" keys. + func testCustomKeyIsPinned() { + let names = [ + "LYMPH", "LYMPH %", "LYMPH%", "Lymph (%)", "BASO", "BASO %", "% Neutrophils", "NEUT %", "a%%b", "%", "Apo B", "Magnesium", " ??? ", "A - B", "Vitamin D (25-OH)", "Caf\u{e9} Marker", "Cafe\u{301} Marker", "MCHC g/dL", "HbA1c (IFCC)", + ] + let expected = [ + "custom_lymph", "custom_lymph_pct", "custom_lymph_pct", "custom_lymph_pct", "custom_baso", "custom_baso_pct", "custom_pct_neutrophils", "custom_neut_pct", "custom_a_pctpct_b", "custom_pct", "custom_apo_b", "custom_magnesium", "", "custom_a__b", "custom_vitamin_d_25_oh", "custom_café_marker", "custom_café_marker", "custom_mchc_g_dl", "custom_hba1c_ifcc", + ] + XCTAssertEqual(names.map(LabMarkerCsvImport.customKey), expected) + } } diff --git a/Packages/StrandImport/Tests/StrandImportTests/LiftProgramSheetImporterTests.swift b/Packages/StrandImport/Tests/StrandImportTests/LiftProgramSheetImporterTests.swift new file mode 100644 index 0000000000..9cd8387378 --- /dev/null +++ b/Packages/StrandImport/Tests/StrandImportTests/LiftProgramSheetImporterTests.swift @@ -0,0 +1,290 @@ +import XCTest +import WhoopStore +@testable import StrandImport + +/// Building a Lift Log program from a spreadsheet filled in on a computer. +/// +/// Both fixtures carry the SAME content in the two accepted formats, so one set of expectations +/// covers the `.xlsx` reader and the CSV path and neither can drift from the other. +final class LiftProgramSheetImporterTests: XCTestCase { + + private func parse(_ fixture: String) throws -> LiftProgramImportResult { + try LiftProgramSheetImporter.parse(data: Fixtures.data(fixture)) + } + + // MARK: - The two formats agree + + func testXlsxAndCsvProduceTheSameProgramsFromTheSameContent() throws { + let x = try parse("lift_program_filled.xlsx") + let c = try parse("lift_program_filled.csv") + XCTAssertEqual(x.programs, c.programs, + "one parser, two containers — the container must not change the result") + } + + // MARK: - Grouping + + func testRowsAreGroupedIntoProgramsInSheetOrder() throws { + let r = try parse("lift_program_filled.xlsx") + XCTAssertEqual(r.programs.map(\.name), ["Lower A", "Upper A"]) + XCTAssertEqual(r.programs[0].lines.map(\.exercise), + ["Leg Press midfoot", "Lying Leg Curl", "Leg Extension"]) + XCTAssertEqual(r.programs[1].lines.map(\.exercise), + ["Incline dumbbell press", "Cable fly", "Lat pulldown"], + "a blank row in the middle separates nothing — it is just an empty row") + } + + func testTheProgramNoteIsTakenFromWhicheverRowCarriesIt() throws { + let r = try parse("lift_program_filled.xlsx") + XCTAssertEqual(r.programs[0].note, "Belt from set 3") + XCTAssertNil(r.programs[1].note) + } + + // MARK: - Targets + + func testTargetsAreReadIncludingEuropeanDecimalsAndUnitSuffixes() throws { + let r = try parse("lift_program_filled.xlsx") + let press = r.programs[0].lines[0] + XCTAssertEqual(press.targetSets, 3) + XCTAssertEqual(press.targetReps, 10) + XCTAssertEqual(press.targetWeightKg, 50) + XCTAssertEqual(press.restSec, 90) + XCTAssertEqual(press.note, "Slow eccentric") + + // "40,5 kg" — a comma decimal and a unit, which is what a European spreadsheet writes. + XCTAssertEqual(r.programs[0].lines[2].targetWeightKg, 40.5) + } + + /// Max RPE is a ceiling on the 1-10 scale. The template's header, a decimal comma and the shorter + /// spellings a hand-built sheet uses all read; a blank stays nil. + func testMaxRpeIsReadFromTheTemplateHeaderAndItsShorterSpellings() throws { + let csv = "Exercise,Target max RPE\nBack squat,\"8,5\"\nBench press,\n" + let r = try LiftProgramSheetImporter.parse(data: Data(csv.utf8)) + XCTAssertEqual(r.programs[0].lines[0].targetMaxRpe, 8.5) + XCTAssertNil(r.programs[0].lines[1].targetMaxRpe, "a blank cell means no ceiling") + for header in ["Max RPE", "RPE"] { + let short = try LiftProgramSheetImporter.parse(data: Data("Exercise,\(header)\nRow,7\n".utf8)) + XCTAssertEqual(short.programs[0].lines[0].targetMaxRpe, 7, "failed on \(header)") + } + } + + /// Outside 1-10 is a typo, not a ceiling: the line imports without one and the row is named. + func testAMaxRpeOutsideTheScaleWarnsAndIsLeftBlank() throws { + let csv = "Exercise,Target max RPE\nBack squat,12\nBench press,0\n" + let r = try LiftProgramSheetImporter.parse(data: Data(csv.utf8)) + XCTAssertEqual(r.programs[0].lines.map(\.exercise), ["Back squat", "Bench press"]) + XCTAssertNil(r.programs[0].lines[0].targetMaxRpe) + XCTAssertNil(r.programs[0].lines[1].targetMaxRpe) + XCTAssertTrue(r.warnings.contains { $0.hasPrefix("Row 2:") && $0.contains("12") && $0.contains("Back squat") }, + "the warning names the row, the value and the exercise: \(r.warnings)") + XCTAssertTrue(r.warnings.contains { $0.hasPrefix("Row 3:") }, "0 is outside the scale too") + } + + func testABlankTargetStaysNilRatherThanBecomingZero() throws { + let r = try parse("lift_program_filled.xlsx") + let fly = r.programs[1].lines[1] + XCTAssertNil(fly.targetWeightKg, "a blank cell means not planned; 0 would be a planned zero") + XCTAssertNil(fly.restSec) + XCTAssertNil(fly.note) + } + + // MARK: - Muscles + + func testMusclesResolveAndSecondariesSplitOnCommas() throws { + let r = try parse("lift_program_filled.xlsx") + let incline = r.programs[1].lines[0] + XCTAssertEqual(incline.primaryMuscle, .chest) + XCTAssertEqual(incline.secondaryMuscles, [.frontDelts, .triceps]) + } + + /// An unrecognised muscle warns and leaves the line unclassified — it never guesses, and it never + /// throws away the row. The vocabulary is a stored-data contract; deciding that "Shoulders" means + /// front delts would put sets in a bucket the user did not choose. + func testAnUnknownMuscleWarnsButKeepsTheExercise() throws { + let r = try parse("lift_program_filled.xlsx") + let fly = r.programs[1].lines[1] + XCTAssertEqual(fly.exercise, "Cable fly") + XCTAssertNil(fly.primaryMuscle) + XCTAssertTrue(r.warnings.contains { $0.contains("Shoulders") && $0.contains("Cable fly") }, + "the warning must name the value AND the exercise: \(r.warnings)") + } + + func testAWarningNamesTheSheetsOwnRowNumber() throws { + let r = try parse("lift_program_filled.xlsx") + XCTAssertTrue(r.warnings.contains { $0.hasPrefix("Row 6:") }, + "Cable fly is the 5th data row, which is row 6 in the sheet: \(r.warnings)") + } + + func testMuscleNamesAreCaseAndSpacingInsensitiveAndAcceptStoredTokens() { + for spelling in ["Front delts", "front delts", "FRONT-DELTS", "frontdelts", "frontDelts"] { + XCTAssertEqual(LiftMuscle(sheetName: spelling), .frontDelts, "failed on \(spelling)") + } + XCTAssertNil(LiftMuscle(sheetName: "shoulders"), "no guessing beyond spelling") + XCTAssertNil(LiftMuscle(sheetName: " ")) + } + + func testEveryMuscleInTheVocabularyIsReachableByItsEnglishName() { + // The shipped template's dropdown offers these; if a case were added to LiftMuscle without a + // name here, it would be offered and then rejected on import. + for m in LiftMuscle.allCases { + XCTAssertEqual(LiftMuscle(sheetName: m.rawValue), m) + } + } + + // MARK: - The shipped template itself + + /// The empty template in `docs/` is the file users actually download. Parsing it must not throw + /// on its headers, and it must contain no programs — an unfilled template imports nothing. + func testTheShippedTemplateHasTheHeadersTheImporterExpects() throws { + let data = try templateData() + + // The header row is what the importer matches on, so it is what must not drift. (`rows` + // is empty for an unfilled template by design — every data row is blank.) + let grid = try XlsxSheet.grids(from: data).first ?? [] + let headers = grid.first.map { $0.map { HeaderNorm.normalize($0) } } ?? [] + for expected in ["program", "program_note", "exercise", "primary_muscle", + "secondary_muscles", "sets", "reps", "weight_kg", "target_max_rpe", "rest_sec", "note"] { + XCTAssertTrue(headers.contains(expected), + "the shipped template lost the \"\(expected)\" column: \(headers)") + } + + // And an UNFILLED template is empty, not a program of blank exercises. + XCTAssertThrowsError(try LiftProgramSheetImporter.parse(data: data)) { error in + XCTAssertEqual(error as? LiftProgramSheetImporter.ImportError, .empty) + } + } + + /// `xl/worksheets/sheet1.xml` is a stable id, not a position. This fixture puts the instructions + /// sheet FIRST in tab order with the program data still in sheet1.xml — reading by filename would + /// import the instructions page as a program, or find no exercise column and refuse the file. + func testTheFirstTabIsFoundThroughTheWorkbookNotTheFilename() throws { + let r = try parse("lift_program_tabs_reordered.xlsx") + XCTAssertEqual(r.programs.count, 1, "the instructions tab is first; the data must still be found") + XCTAssertEqual(r.programs[0].lines.map(\.exercise), ["Back squat"]) + XCTAssertEqual(r.programs[0].lines[0].targetWeightKg, 100) + } + + /// The template must not permit the one edit that breaks the import. In OOXML these + /// `sheetProtection` flags answer "is this PREVENTED" and default to true, so an attribute set to + /// "0" ALLOWS the thing it names — an earlier template shipped `insertColumns="0"`, permitting + /// exactly the change that shifts reps into the weight column. + func testTheShippedTemplateLocksColumnEditsAndAllowsRowEdits() throws { + let xml = try templateSheetXml() + XCTAssertTrue(xml.contains("sheet=\"1\""), "the sheet must actually be protected") + XCTAssertFalse(xml.contains("insertColumns=\"0\""), "inserting columns must stay prevented") + XCTAssertFalse(xml.contains("deleteColumns=\"0\""), "deleting columns must stay prevented") + XCTAssertTrue(xml.contains("insertRows=\"0\""), "a long routine needs more rows") + XCTAssertTrue(xml.contains("selectUnlockedCells=\"0\""), "the data cells must be typable") + } + + /// The max RPE column only accepts the scale, so a typo is caught while filling the sheet rather + /// than surfacing as an import warning later. + func testTheShippedTemplateLimitsMaxRpeToTheScale() throws { + let xml = try templateSheetXml() + XCTAssertTrue(xml.contains("type=\"decimal\" operator=\"between\""), "max RPE must be validated") + XCTAssertTrue(xml.contains("110"), "and bounded to 1-10") + } + + // MARK: - Refusals + + func testAFileWithNoExerciseColumnIsRefusedWithThatReason() { + let csv = "Program,Sets,Reps\nLower A,3,10\n" + XCTAssertThrowsError(try LiftProgramSheetImporter.parse(data: Data(csv.utf8))) { error in + XCTAssertEqual(error as? LiftProgramSheetImporter.ImportError, .missingColumns(["exercise"])) + } + } + + func testNonsenseIsRefusedRatherThanImportedAsOneStrangeProgram() { + XCTAssertThrowsError(try LiftProgramSheetImporter.parse(data: Data([0x00, 0x01, 0x02]))) + } + + /// Only the exercise is required; a sheet with nothing else still makes a usable program that can + /// be finished in the app. + func testAnExerciseOnlySheetImports() throws { + let csv = "Exercise\nBack squat\nBench press\n" + let r = try LiftProgramSheetImporter.parse(data: Data(csv.utf8)) + XCTAssertEqual(r.programs.count, 1) + XCTAssertEqual(r.programs[0].lines.map(\.exercise), ["Back squat", "Bench press"]) + XCTAssertNil(r.programs[0].lines[0].targetSets) + } + + /// Excel in most of Europe writes CSV with semicolons. `CSVTable` sniffs the delimiter, and this + /// pins that the lift path benefits from it. + func testASemicolonDelimitedCsvIsRead() throws { + let csv = "Program;Exercise;Primary muscle;Sets;Reps\nLower A;Back squat;Quads;5;5\n" + let r = try LiftProgramSheetImporter.parse(data: Data(csv.utf8)) + XCTAssertEqual(r.programs[0].lines[0].exercise, "Back squat") + XCTAssertEqual(r.programs[0].lines[0].primaryMuscle, .quads) + XCTAssertEqual(r.programs[0].lines[0].targetSets, 5) + } + + // MARK: - Bounds + // + // This feature is a convenience. It must never be the reason the app is slow, runs out of + // memory, or writes a database nobody wants — so every input it accepts is bounded, and the + // bounds are pinned here. + + func testAnAbsurdlyLargeFileIsRefusedBeforeAnyParsing() { + let big = Data(count: LiftProgramSheetImporter.maxFileBytes + 1) + XCTAssertThrowsError(try LiftProgramSheetImporter.parse(data: big)) { error in + XCTAssertEqual(error as? LiftProgramSheetImporter.ImportError, .tooLarge) + } + } + + func testTooManyProgramsAreTruncatedAndSaidSo() throws { + var csv = "Program,Exercise\n" + for i in 0..<(LiftProgramSheetImporter.maxPrograms + 10) { + csv += "Program \(i),Exercise \(i)\n" + } + let r = try LiftProgramSheetImporter.parse(data: Data(csv.utf8)) + XCTAssertEqual(r.programs.count, LiftProgramSheetImporter.maxPrograms) + XCTAssertTrue(r.warnings.contains { $0.contains("larger than a program can be") }, + "a truncated import must say it was truncated: \(r.warnings)") + } + + func testTooManyLinesInOneProgramAreTruncated() throws { + var csv = "Program,Exercise\n" + for i in 0..<(LiftProgramSheetImporter.maxLinesPerProgram + 10) { + csv += "One,Exercise \(i)\n" + } + let r = try LiftProgramSheetImporter.parse(data: Data(csv.utf8)) + XCTAssertEqual(r.programs.count, 1) + XCTAssertEqual(r.programs[0].lines.count, LiftProgramSheetImporter.maxLinesPerProgram) + } + + /// A sheet full of bad muscle names must not produce thousands of warnings — the preview would + /// be unscrollable and the user no better informed. + func testWarningsAreCapped() throws { + // Deliberately under `maxLinesPerProgram`, so this isolates the WARNING cap rather than + // tripping the line cap and testing two things at once. + let rows = LiftProgramSheetImporter.maxLinesPerProgram - 10 + var csv = "Exercise,Primary muscle\n" + for i in 0.. Data { + let url = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent().deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + .appendingPathComponent("docs/lift-log-program-template.xlsx") + return try Data(contentsOf: url) + } + + private func templateSheetXml() throws -> String { + let data = try templateData() + guard let part = XlsxSheet.rawPart(data, path: "xl/worksheets/sheet1.xml"), + let xml = String(data: part, encoding: .utf8) else { + XCTFail("the template has no first worksheet part") + return "" + } + return xml + } +} + diff --git a/Packages/StrandImport/Tests/StrandImportTests/LiftingImporterTests.swift b/Packages/StrandImport/Tests/StrandImportTests/LiftingImporterTests.swift index 40e7d6080c..73e53fd935 100644 --- a/Packages/StrandImport/Tests/StrandImportTests/LiftingImporterTests.swift +++ b/Packages/StrandImport/Tests/StrandImportTests/LiftingImporterTests.swift @@ -224,4 +224,143 @@ final class LiftingImporterTests: XCTestCase { XCTAssertEqual(session(title: "").volumeLoadNote(), body) XCTAssertEqual(session(title: "Leg Day").volumeLoadNote(), "Leg Day: \(body)") } + + + // MARK: - Hevy API (draft) + + /// A weight is narrowed nowhere, so nothing traps — but `Double("1e9999")` is infinity, and an + /// infinite top set rides out to the session note while poisoning the volume total. Kotlin always + /// dropped it at the parse; Swift did not, which meant the same hostile CSV imported differently + /// depending on the phone. The set still counts as work done, it just carries no weight. + func testHevyCSVRejectsANonFiniteWeight() { + let csv = """ + title,start_time,exercise_title,set_type,weight_kg,reps + H,2026-06-01 18:00:00,Bench Press,normal,1e9999,5 + """ + let s = LiftingImporter.parseHevy(text: csv).sessions[0] + XCTAssertEqual(s.setCount, 1) + XCTAssertNil(s.topSetKg, "an infinite top set must not survive the parse") + XCTAssertEqual(s.volumeLoadKg, 0, accuracy: 0.001) + } + + private func apiPage(_ workouts: String) -> Data { + Data("{\"page\":1,\"page_count\":1,\"workouts\":[\(workouts)]}".utf8) + } + + private let apiWorkout = """ + {"id":"w1","title":"Push Day","start_time":"2026-09-07T10:00:00Z","end_time":"2026-09-07T11:02:00Z", + "exercises":[{"title":"Bench Press","sets":[{"type":"warmup","weight_kg":40,"reps":10}, + {"type":"normal","weight_kg":60,"reps":8}, + {"type":"normal","weight_kg":60,"reps":8}]}, + {"title":"Overhead Press","sets":[{"type":"normal","weight_kg":40,"reps":10}]}]} + """ + + /// The API must land on the SAME arithmetic as the CSV lane: warm-ups excluded from volume, sets + /// counted, top set tracked. 60x8 + 60x8 + 40x10 = 1360 kg, and the 40 kg warm-up adds nothing. + func testHevyAPIUsesTheSameVolumeArithmeticAsTheCSVLane() { + let out = LiftingImporter.parseHevyAPI(data: apiPage(apiWorkout)) + XCTAssertEqual(out.sessions.count, 1) + let s = out.sessions[0] + XCTAssertEqual(s.volumeLoadKg, 1360, accuracy: 0.001) + XCTAssertEqual(s.setCount, 3, "the warm-up must not be counted as a working set") + XCTAssertEqual(s.exerciseCount, 2) + XCTAssertEqual(s.totalReps, 26) + XCTAssertEqual(s.topSetKg, 60) + XCTAssertEqual(s.title, "Push Day") + XCTAssertEqual(s.end.timeIntervalSince(s.start), 62 * 60, accuracy: 1) + } + + /// A workout with no countable set is skipped, not stored as an empty session — the same rule the + /// CSV lane applies, and the reason `skipped` exists. + func testHevyAPISkipsAWorkoutWithNoCountableSet() { + let empty = """ + {"id":"w2","title":"Rest","start_time":"2026-09-07T10:00:00Z","end_time":"2026-09-07T10:05:00Z", + "exercises":[{"title":"Bench Press","sets":[{"type":"warmup","weight_kg":40,"reps":10}]}]} + """ + let out = LiftingImporter.parseHevyAPI(data: apiPage(empty)) + XCTAssertTrue(out.sessions.isEmpty) + XCTAssertEqual(out.skipped, 1) + } + + /// No start means no window to attach the session to, so it is skipped rather than defaulted. + func testHevyAPISkipsAWorkoutWithNoStart() { + let noStart = """ + {"id":"w3","title":"x","end_time":"2026-09-07T11:00:00Z","exercises":[]} + """ + let out = LiftingImporter.parseHevyAPI(data: apiPage(noStart)) + XCTAssertTrue(out.sessions.isEmpty) + XCTAssertEqual(out.skipped, 1) + } + + /// This runs over another server's data: malformed input yields nothing rather than throwing. + func testHevyAPIMalformedInputYieldsNothing() { + XCTAssertTrue(LiftingImporter.parseHevyAPI(data: Data("not json".utf8)).sessions.isEmpty) + XCTAssertTrue(LiftingImporter.parseHevyAPI(data: Data()).sessions.isEmpty) + XCTAssertTrue(LiftingImporter.parseHevyAPI(data: Data("{\"workouts\":\"nope\"}".utf8)).sessions.isEmpty) + } + + /// A bare array is what a saved response pasted out of a browser looks like. + func testHevyAPIAcceptsABareArray() { + XCTAssertEqual(LiftingImporter.parseHevyAPI(data: Data("[\(apiWorkout)]".utf8)).sessions.count, 1) + } + + /// Weights and reps may arrive quoted depending on the encoder. A nulled weight must not become + /// zero volume silently — the set still counts, it just adds nothing. + func testHevyAPIAcceptsQuotedNumbersAndToleratesANullWeight() { + let quoted = """ + {"id":"w4","title":"Q","start_time":"2026-09-07T10:00:00Z","end_time":"2026-09-07T10:30:00Z", + "exercises":[{"title":"Row","sets":[{"type":"normal","weight_kg":"50","reps":"5"}, + {"type":"normal","reps":12}]}]} + """ + let s = LiftingImporter.parseHevyAPI(data: apiPage(quoted)).sessions[0] + XCTAssertEqual(s.volumeLoadKg, 250, accuracy: 0.001) + XCTAssertEqual(s.setCount, 2, "a weightless set still counts as work done") + XCTAssertEqual(s.totalReps, 17) + } + + /// The API stamps an offset, which `parseDate` honours over the device zone — so the same payload + /// parses identically whatever zone the phone is in. That is the API's advantage over the CSV + /// lane, where a zoneless wall clock has to be interpreted (#649). + func testHevyAPIHonoursTheStampedOffsetRatherThanTheDeviceZone() { + let utc = LiftingImporter.parseHevyAPI(data: apiPage(apiWorkout), + zone: TimeZone(identifier: "UTC")!) + let tokyo = LiftingImporter.parseHevyAPI(data: apiPage(apiWorkout), + zone: TimeZone(identifier: "Asia/Tokyo")!) + XCTAssertEqual(utc.sessions[0].start, tokyo.sessions[0].start) + } + + /// The rep count is narrowed to Int, and `Int(_:)` TRAPS on a non-finite Double rather than + /// saturating — `Double("1e9999")` is infinity. A response body is less trustworthy than a file + /// the user picked, so the bound has to hold here: the set still counts as work done, it just + /// contributes no reps and no volume. + func testHevyAPISurvivesAHostileRepCount() { + let hostile = """ + {"id":"w6","title":"H","start_time":"2026-09-07T10:00:00Z","end_time":"2026-09-07T10:30:00Z", + "exercises":[{"title":"Row","sets":[{"type":"normal","weight_kg":50,"reps":"1e9999"}]}]} + """ + let s = LiftingImporter.parseHevyAPI(data: apiPage(hostile)).sessions[0] + XCTAssertEqual(s.setCount, 1) + XCTAssertEqual(s.totalReps, 0) + XCTAssertEqual(s.volumeLoadKg, 0, accuracy: 0.001) + } + + /// Hevy's set `type` is one of normal, warmup, dropset, failure (published spec). Only warmup is + /// excluded from volume: a dropset and a set taken to failure are work, and counting them as + /// warm-ups would under-report a hard session — the opposite of the error the exclusion prevents. + func testHevyAPICountsDropsetAndFailureSetsAsWork() { + let mixed = """ + {"id":"w5","title":"Arms","start_time":"2026-09-07T10:00:00Z","end_time":"2026-09-07T10:40:00Z", + "exercises":[{"title":"Bicep Curl (Dumbbell)", + "sets":[{"type":"warmup","weight_kg":10,"reps":10}, + {"type":"normal","weight_kg":20,"reps":10}, + {"type":"dropset","weight_kg":15,"reps":8}, + {"type":"failure","weight_kg":12,"reps":6}]}]} + """ + let s = LiftingImporter.parseHevyAPI(data: apiPage(mixed)).sessions[0] + XCTAssertEqual(s.setCount, 3, "warmup excluded; dropset and failure are working sets") + // 20×10 + 15×8 + 12×6 = 200 + 120 + 72; pre-summed because the literal arithmetic blows the + // Swift type-check budget. Kotlin's twin computes it inline, which is what caught this sum. + XCTAssertEqual(s.volumeLoadKg, 392, accuracy: 0.001) + XCTAssertEqual(s.totalReps, 24) + } } diff --git a/Packages/StrandImport/Tests/StrandImportTests/MuscleGroupsTests.swift b/Packages/StrandImport/Tests/StrandImportTests/MuscleGroupsTests.swift new file mode 100644 index 0000000000..e01b2dce41 --- /dev/null +++ b/Packages/StrandImport/Tests/StrandImportTests/MuscleGroupsTests.swift @@ -0,0 +1,124 @@ +import XCTest +@testable import StrandImport + +/// Exercise → muscle attribution. +/// +/// The ordering cases are the ones that matter. The rules are matched first-hit-wins on a normalised +/// name, so every generic word ("curl", "raise", "row", "press") has specific phrases that contain it +/// and must be decided first. A regression there does not crash or blank — it silently attributes leg +/// work to biceps, which is exactly the kind of confident wrong answer this project treats as worse +/// than nothing. +final class MuscleGroupsTests: XCTestCase { + + func testTheLiftsFromARealLogAttributeCorrectly() { + XCTAssertEqual(MuscleAttribution.muscles(for: "Romanian Deadlift (Barbell)"), + [.hamstrings, .glutes]) + XCTAssertEqual(MuscleAttribution.muscles(for: "Squat (Barbell)"), [.quadriceps, .glutes]) + XCTAssertEqual(MuscleAttribution.muscles(for: "Seated Cable Row - V Grip"), + [.upperBack, .lats]) + XCTAssertEqual(MuscleAttribution.muscles(for: "Bicep Curl (Dumbbell)"), [.biceps]) + } + + /// "leg curl" contains "curl"; "romanian deadlift" contains "deadlift". If the generic rule won, + /// hamstring work would be filed under biceps and lower back. + func testSpecificPhrasesBeatTheGenericWordTheyContain() { + XCTAssertEqual(MuscleAttribution.muscles(for: "Lying Leg Curl"), [.hamstrings], + "leg curl must not fall through to the biceps 'curl' rule") + XCTAssertEqual(MuscleAttribution.muscles(for: "Romanian Deadlift"), [.hamstrings, .glutes], + "must not fall through to the generic deadlift rule") + XCTAssertEqual(MuscleAttribution.muscles(for: "Calf Raise (Machine)"), [.calves], + "calf raise must not fall through to a shoulder 'raise'") + XCTAssertEqual(MuscleAttribution.muscles(for: "Front Raise"), [.shoulders]) + XCTAssertEqual(MuscleAttribution.muscles(for: "Hanging Leg Raise"), [.abs], + "a hanging leg raise is trunk work, not quads") + XCTAssertEqual(MuscleAttribution.muscles(for: "Upright Row"), [.shoulders, .upperBack], + "upright row must not fall through to the back 'row' rule") + XCTAssertEqual(MuscleAttribution.muscles(for: "Hammer Curl"), [.biceps, .forearms]) + } + + /// Titles that match TWO rules whose needles do not contain each other, so the structural + /// shadowing test cannot see them: "Rear Delt Fly" contains both "rear delt" and "fly", and + /// whichever sits first in the table wins. Every one of these was wrong when first written. + func testCompoundTitlesResolveToTheRearOrLegMovementNotTheGenericOne() { + XCTAssertEqual(MuscleAttribution.muscles(for: "Rear Delt Fly"), [.shoulders, .upperBack], + "the generic chest 'fly' rule would file the opposite side of the body") + XCTAssertEqual(MuscleAttribution.muscles(for: "Reverse Fly (Dumbbell)"), [.shoulders, .upperBack]) + XCTAssertEqual(MuscleAttribution.muscles(for: "Nordic Curl"), [.hamstrings], + "a nordic curl is hamstrings, not the biceps 'curl' rule") + XCTAssertEqual(MuscleAttribution.muscles(for: "Jefferson Curl"), [.lowerBack, .hamstrings]) + XCTAssertEqual(MuscleAttribution.muscles(for: "Cable Fly"), [.chest], "a plain fly is still chest") + XCTAssertEqual(MuscleAttribution.muscles(for: "Chest Fly"), [.chest]) + } + + /// A wrist curl is forearms. The rule for it existed but sat BELOW the generic biceps "curl", + /// so it could never match the spelling the exercise is normally written with. + func testWristCurlIsForearmsNotBiceps() { + XCTAssertEqual(MuscleAttribution.muscles(for: "Wrist Curl"), [.forearms]) + XCTAssertEqual(MuscleAttribution.muscles(for: "Reverse Wrist Curl (Barbell)"), [.forearms]) + } + + /// These thirteen groups have no neck, and "Neck Curl" contains "curl", so it came out as biceps. + /// A blank is the only honest answer: the table prefers a blank to a wrong muscle everywhere else, + /// and an exercise the vocabulary cannot express is exactly where that has to hold. + func testAnExerciseTheVocabularyCannotExpressAttributesNothing() { + XCTAssertTrue(MuscleAttribution.muscles(for: "Neck Curl").isEmpty) + XCTAssertTrue(MuscleAttribution.muscles(for: "Neck Extension").isEmpty) + XCTAssertTrue(MuscleAttribution.muscles(for: "Weighted Neck Harness").isEmpty) + XCTAssertEqual(MuscleAttribution.muscles(for: "Bicep Curl"), [.biceps], + "the guard must not swallow an ordinary curl") + } + + /// Hevy writes it as one word. A rule that only matches the spaced spelling silently attributes + /// nothing for the spelling the catalogue actually uses, which reads as an unknown lift. + func testSkullcrusherMatchesBothSpellings() { + XCTAssertEqual(MuscleAttribution.muscles(for: "Skullcrusher (Barbell)"), [.triceps]) + XCTAssertEqual(MuscleAttribution.muscles(for: "Skull Crusher"), [.triceps]) + } + + /// Equipment parentheses, hyphens and case must not change the answer. + func testNormalisationIgnoresEquipmentPunctuationAndCase() { + let expected: [MuscleGroup] = [.chest, .triceps] + for spelling in ["Bench Press", "bench press", "Bench Press (Barbell)", + "BENCH-PRESS", "Bench Press (Smith Machine)"] { + XCTAssertEqual(MuscleAttribution.muscles(for: spelling), expected, spelling) + } + } + + /// An unrecognised lift attributes NOTHING. A wrong muscle is worse than a blank one: the blank + /// invites a look, the wrong one does not. + func testAnUnknownExerciseAttributesNothing() { + XCTAssertTrue(MuscleAttribution.muscles(for: "Kettlebell Flow").isEmpty) + XCTAssertTrue(MuscleAttribution.muscles(for: "").isEmpty) + XCTAssertTrue(MuscleAttribution.muscles(for: " ").isEmpty) + XCTAssertTrue(MuscleAttribution.muscles(for: "???").isEmpty) + } + + /// Every rule must map to at least one group, and every group named must be a real case — a typo + /// in the table would otherwise sit there attributing nothing and look like an unknown lift. + func testEveryRuleIsWellFormed() { + XCTAssertFalse(MuscleAttribution.rules.isEmpty) + for (needle, groups) in MuscleAttribution.rules { + XCTAssertFalse(needle.isEmpty) + XCTAssertEqual(needle, MuscleAttribution.normalise(needle), + "rule '\(needle)' must already be in normalised form or it can never match") + XCTAssertFalse(groups.isEmpty, "rule '\(needle)' attributes nothing") + } + } + + /// Guards the ordering property itself rather than individual pairs: if a rule's needle contains + /// an earlier rule's needle, the earlier one wins and the later is dead. This catches a new rule + /// appended in the wrong place, which no example-based test would. + /// + /// What it does NOT catch: two rules whose needles do not contain each other, where a real title + /// contains BOTH. "rear delt" and "fly" are disjoint, yet "Rear Delt Fly" matches whichever comes + /// first — and it came out as chest until an example test was written for it. The example test + /// above is the guard for that class; this one cannot be. + func testNoRuleIsShadowedByAnEarlierOne() { + let rules = MuscleAttribution.rules + for (i, later) in rules.enumerated() { + for earlier in rules[..= 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/DeviceConfigReadProbe.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceConfigReadProbe.swift index a238e2a82c..b6b148d0a0 100644 --- a/Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceConfigReadProbe.swift +++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceConfigReadProbe.swift @@ -115,6 +115,25 @@ public enum DeviceConfigReadProbe { /// about the key. public static let deviceConfigDiscoveryKey = "whoop_live_hr_in_adv_ind_pkt" + /// Names to read, independent of the flags NOOP writes. The four extra WHOOP 5 keys were + /// returned by a complete 117/118 exchange on firmware 50.41.1.0. Their effects and accepted + /// values are unknown; observing a name does not authorize adding it to the enable sequence. + /// This macOS hardware investigation intentionally leaves mobile probe plans and decoding unchanged: + /// the requested scope and the available transport evidence are macOS plus this WHOOP 5 firmware. + /// Hardware check, 2026-09-10: all four GET_FF_VALUE replies were CRC-valid SUCCESS with ASCII + /// '2'. The full probe received 29 replies (21 successful, eight rejected guessed keys), with + /// no timeout or reconnect. + public static func knownFlagKeys(for family: DeviceFamily) -> [String] { + let existing = Whoop5Config.enableR22Sequence.map(\.name) + guard family == .whoop5 else { return existing } + return existing + [ + "enable_r22_v9_packets", + "enable_frizzle_burst_mode", + "ir_1x_enable", + "enable_rocky2", + ] + } + /// **GUESSES.** Candidate oxygen-related key names to try against the device-config namespace. None of /// these has been observed on a wire, in a capture, or in any protocol table — they are constructed /// from the naming conventions the *known* keys follow (`enable_…`, `…_enable`, snake_case, and the @@ -453,7 +472,16 @@ public struct DeviceConfigReadProbeReport: Equatable, Sendable { /// Record one decoded reply. public mutating func noteReply(_ r: DeviceConfigReadProbe.ValueResponse, for step: Step) { setStatus(r.isUnsupported ? .unsupported : .answered, for: step.opcode) - let value = r.value(for: step.key) + // The hardware run that found this was on macOS, but the fault is not: a FAILURE reply echoes + // the requested key back with zero padding, so taking `value(for:)` regardless of the result + // code renders a rejected read as a stored 0, indistinguishable from a key that holds 0. This + // file has no platform gate and builds for iOS too, so scoping the guard left the bug live + // there, with the test that catches it compiled out by the same condition. A report that + // fabricates a value is wrong wherever it runs. (#2193) + // + // The low-level cross-platform decoder is untouched: `value(for:)` still answers what the + // bytes say. This is the report declining to claim it. + let value = r.resultCode == nil || r.resultCode == 1 ? r.value(for: step.key) : nil readings.append(Reading(group: step.group, opcode: step.opcode, key: step.key, value: value, resultCode: r.resultCode, recordHex: r.recordHex)) var line = "\(DeviceConfigReadProbeReport.opcodeLabel(step.opcode)) key=\"\(step.key)\"" @@ -541,7 +569,14 @@ public struct DeviceConfigReadProbeReport: Equatable, Sendable { } let named = readings.filter { $0.value != nil }.count if named == 0 { - return "\(answered) of 2 read verbs answered, but no reply echoed its key so no value is claimed" + // Same reasoning as the guard above: the verdict has to distinguish "every reply was + // rejected" from "replies succeeded but none carried a verified pair", on every platform. + // The old `#else` said only the second, which is the wrong sentence for a run where the + // strap refused every read. (#2193) + if readings.allSatisfy({ $0.resultCode != nil && $0.resultCode != 1 }) { + return "\(answered) of 2 read verbs answered, but no reply reported success; no value is claimed" + } + return "\(answered) of 2 read verbs answered, but no successful reply carried a verified key/value pair; no value is claimed" } return "\(answered) of 2 read verbs answered; read \(named) config value(s)" } @@ -563,8 +598,12 @@ public struct DeviceConfigReadProbeReport: Equatable, Sendable { sb += section(.discovery, title: "Discovery — one round-trip per verb against a key it should know", empty: "(none — no reply was decoded)") + let writeNames = Set(Whoop5Config.enableR22Sequence.map(\.name)) + let flagTitle = knownFlagKeys.allSatisfy(writeNames.contains) + ? "Known feature-flag values (names NOOP already writes; values never read before)" + : "Known feature-flag values (includes names observed in strap enumeration)" sb += section(.knownFlag, - title: "Known feature-flag values (names NOOP already writes; values never read before)", + title: flagTitle, empty: "(none — the verb that would carry them did not answer)") sb += section(.candidate, title: "Candidate oxygen keys — GUESSES, never observed on a wire or in any table", diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceFamily.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceFamily.swift index f23af995f1..11323f72d7 100644 --- a/Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceFamily.swift +++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceFamily.swift @@ -135,6 +135,17 @@ public func whoopGattScanDecision( } public extension DeviceFamily { + /// Positive registry evidence for unit/source decisions. Legacy "WHOOP" identifies no generation. + /// Unlike `forRegistryModel`, this must never infer WHOOP 5 from an unspecified model. + static func confirmedRegistryFamily(model: String?, brand: String?) -> DeviceFamily? { + if let brand, !brand.isEmpty, brand.caseInsensitiveCompare("WHOOP") != .orderedSame { return nil } + switch model?.lowercased() { + case "4.0", "whoop 4.0": return .whoop4 + case "5.0", "5.0 mg", "whoop 5.0", "whoop 5.0 / mg", "mg", "whoop5": return .whoop5 + default: return nil + } + } + /// Resolve a device-registry `model` label to the strap family that wrote its rows (#171). /// /// The registry holds several historical spellings for the same hardware: the Add-Device wizard diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/FirmwareGateDiagnostic.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/FirmwareGateDiagnostic.swift new file mode 100644 index 0000000000..8c338ff9c4 --- /dev/null +++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/FirmwareGateDiagnostic.swift @@ -0,0 +1,35 @@ +import Foundation + +/// Why a WHOOP 5/MG hello carried no firmware version, in enough detail to re-derive the offset. +/// +/// The version is read as four bytes at `pay[93]`, guarded by `pay[93] == 50` ("5.0 generation"). Both +/// the offset and the guard are anchored to a SINGLE capture at firmware 50.38.1.0, and the decoder's own +/// comment says so: the name+token region ahead of the version is fixed-width *on that firmware*, and +/// re-verifying the offset across firmwares is explicitly left as future work. The guards fail closed, so +/// a strap that does not match simply reports nothing. +/// +/// That silence has two very different causes and no way to tell them apart from a log: +/// - the byte at 93 is not 50 — a different generation marker, offset probably still right; +/// - the name+token region is a different width, so byte 93 is not the version at all and the offset has +/// MOVED. This is the one the decoder comment warns about, and the one a user cannot diagnose. +/// +/// So the line carries what distinguishes them: the payload length, the byte actually at 93, where the +/// printable-ASCII device name ended (the run whose width shifts everything after it), and a hex window +/// spanning the region the version should sit in. A capture from an undecoded strap then locates the real +/// offset without another round trip. +/// +/// Deliberately does NOT loosen the guard. Widening it blindly would render unrelated bytes as a version, +/// which is worse than "unknown" because it looks authoritative. +/// +/// Pure, and the window is clamped to the payload, so a short or malformed frame cannot trap. Kotlin +/// twin: `com.noop.protocol.firmwareGateDiagnostic`. +public func firmwareGateDiagnostic(payload: [UInt8], nameEndIndex: Int) -> String { + let at93 = payload.count > 93 ? String(payload[93]) : "n/a" + let lo = min(88, payload.count) + let hi = min(101, payload.count) + let window = lo < hi + ? payload[lo.. 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 + } + + /// Fold a reassembler's `headerChecksumDrops` into the `headerChecksumMismatch` bucket. + /// + /// Same reasoning as `absorbReassemblerDrops` next door, for the other thing the reassembler now + /// drops before any parser sees it. A false start-of-frame rejected by its own header checksum + /// reaches neither a parser nor the evidence-preserving reader, so without this fold the only + /// record that a link was resyncing at all would be a counter nothing reads. Monotonic total; + /// only the growth since the last fold is added, so calling it per notification is idempotent. + /// + /// Kotlin twin: `FrameRejectTally.absorbReassemblerHeaderDrops`. It lives under `com/noop/ble`, + /// outside the ledger's Kotlin inventory roots, so the pair is declared here rather than inferred. + public mutating func absorbReassemblerHeaderDrops(_ monotonicTotal: Int) { + guard monotonicTotal > absorbedReassemblerHeaderDrops else { return } + counts[.headerChecksumMismatch, default: 0] += monotonicTotal - absorbedReassemblerHeaderDrops + absorbedReassemblerHeaderDrops = 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..03e9bcdb62 100644 --- a/Packages/WhoopProtocol/Sources/WhoopProtocol/Framing.swift +++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/Framing.swift @@ -82,19 +82,79 @@ 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. Structural failures are decided +/// before the payload CRC is required, so every frame that reaches the payload-integrity decision +/// has a computable CRC32 by construction. +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 family 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] + [crc32 u32]` = 11 bytes. The zero-payload +/// metadata frames at this bound are valid and intentional; the minimum preserves the old `length >= 7` rule. +/// WHOOP 5.0/MG: `[SOF][fmt][declLen u16][hdr u16][crc16 u16] + >=1 payload byte + [crc32 u32]` = 13. +/// Unlike the 4.0 bound, 13 is an empirical acceptance policy, not an envelope necessity: Goose's +/// `v5Payload` accepts a 12-byte, zero-payload frame (`declaredLength == 4`). NOOP deliberately +/// requires the inner type byte. Real fixtures include 20-byte command responses plus 24- and +/// 32-byte frames, but no captured 12-byte zero-payload frame; those observations do not prove the +/// boundary. Keep this assumption explicit until hardware evidence changes it. +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 two checksum outcomes into ONE integrity reason after the caller has already established +/// the structural bounds. Taking a non-optional payload result makes the evaluation order explicit: +/// an uncomputable CRC is represented by the earlier structural reason, not a dead checksum case. +@inline(__always) +private func integrityRejectReason(headerCRCOK: Bool, payloadCRCOK: Bool) -> FrameRejectReason { + if !headerCRCOK { return .headerChecksumMismatch } + return payloadCRCOK ? .none : .payloadCRCMismatch +} + @inline(__always) private func u16le(_ bytes: [UInt8], _ off: Int) -> Int { Int(bytes[off]) | (Int(bytes[off + 1]) << 8) @@ -106,23 +166,43 @@ 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 { + // Below the smallest real 4.0 inner record (type + sequence + command): no field 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 { - // inner record = frame[4..= 7 and leave a complete four-byte trailer in bounds. + let crc32OK = crc32(frame, 4, length) == u32le(frame, length) + let reason = integrityRejectReason(headerCRCOK: crc8OK, payloadCRCOK: crc32OK) + return FrameCheck(ok: reason == .none, length: length, crc8OK: crc8OK, crc32OK: crc32OK, + reason: reason) } /// Family-aware frame validation. @@ -151,15 +231,16 @@ public func verifyFrame(_ frame: [UInt8], family: DeviceFamily) -> 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) + // NOOP's empirical 5/MG floor: envelope + at least the inner type byte + CRC32. The Goose + // reference parser permits a 12-byte empty payload, but no such hardware frame is known here. + 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. @@ -167,28 +248,43 @@ private func verifyFrameWhoop5(_ frame: [UInt8]) -> FrameCheck { let gotHeaderCRC = UInt16(frame[6]) | (UInt16(frame[7]) << 8) 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. - let payloadEnd = total - 4 - // payload = frame[8..= 4 && total <= frame.count + ? crc32(frame, 8, total - 4) == u32le(frame, total - 4) + : nil + return FrameCheck(ok: false, length: declaredLength, crc8OK: headerCRCOK, + crc32OK: diagnosticCRC32OK, reason: .belowMinimumLength) } - - let ok = headerCRCOK && (crc32OK ?? false) + if total != frame.count { + // Preserve a CRC result for a surplus tail; truncation leaves it unavailable. + let diagnosticCRC32OK: Bool? = total <= frame.count + ? crc32(frame, 8, total - 4) == u32le(frame, total - 4) + : nil + return FrameCheck(ok: false, length: declaredLength, crc8OK: headerCRCOK, + crc32OK: diagnosticCRC32OK, reason: .lengthMismatch) + } + // Exact size plus the configured 13-byte floor proves at least one byte before the CRC trailer. + let payloadEnd = total - 4 + let crc32OK = crc32(frame, 8, payloadEnd) == u32le(frame, payloadEnd) + let reason = integrityRejectReason(headerCRCOK: headerCRCOK, payloadCRCOK: 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 +324,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 +344,19 @@ 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 + + /// How many start-of-frame bytes were dropped because the header checksum over their own length + /// field did not match. That is a 0xAA which is not a frame start: payload data, or the tail of a + /// frame the stream desynced inside. Counted separately from [belowMinimumLengthDrops] because the + /// two say different things about a link — a floor drop is a malformed frame, this is a misplaced + /// read cursor. Monotonic for the lifetime of the reassembler. + public private(set) var headerChecksumDrops = 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,12 +392,52 @@ 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 configured family floor is not accepted: emitting it + // would hand the parser a byte run whose "inner fields" are its own checksum trailer. + // Drop this 0xAA, count it, and resync — 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. head += 1 continue } + // An in-range length is not the same as a real one. `indexOfSOF` matches a bare 0xAA, which + // occurs inside payload as readily as at a frame start, so a stream that has desynced (a + // subscribe landing mid-frame, or one earlier mis-parse) can put `head` on a payload byte + // whose next bytes read as a PLAUSIBLE length: past the family floor, under the 8 KB ceiling, + // and therefore accepted by both guards above. feed() then waited for that many bytes and + // emitted them as one frame — swallowing whatever valid frames fell inside it. Those frames + // were consumed, and nothing downstream can hand them back: the CRC32 check that rejects the + // bad frame runs after `head` has already advanced past them. + // + // The header checksum decides this without new protocol knowledge, because it already covers + // the length field on both families: CRC-8 over the two length bytes on WHOOP 4.0 + // (`crc8(frame, 1, 3) == frame[3]`), CRC-16-Modbus over the first six bytes on 5/MG. A real + // frame always passes. A false SOF passes with probability 1/256 on 4.0 and 1/65536 on 5/MG, + // so this converts a swallowed-frame stall into a one-byte resync in almost every case. + // + // Ordered AFTER the floor and ceiling checks so those keep their existing counters and the + // tests that pin them are untouched: this gate only sees lengths the old code accepted. + let headerBytes = family == .whoop4 ? 4 : 8 + if avail < headerBytes { break } + let headerOK: Bool + switch family { + case .whoop4: + headerOK = crc8(buf, head + 1, head + 3) == buf[head + 3] + case .whoop5: + let got = UInt16(buf[head + 6]) | (UInt16(buf[head + 7]) << 8) + headerOK = crc16Modbus(buf, head, head + 6) == got + } + if !headerOK { + headerChecksumDrops += 1 + head += 1 + continue + } if avail < total { break } diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/Hex.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/Hex.swift new file mode 100644 index 0000000000..0c66df3504 --- /dev/null +++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/Hex.swift @@ -0,0 +1,34 @@ +import Foundation + +/* + * Hex.swift - the hex encoder the per-frame diagnostic paths use. Twin of the Kotlin `toHexLower`. + * + * `map { String(format: "%02x", $0) }.joined()` reads well and is fine for a one-off line, but it runs + * `String(format:)` ONCE PER BYTE, and that bridges to NSString formatting and parses the format string + * every time, plus a String per byte for `joined()` to concatenate. The captures write a line per frame + * for a whole offload and a deep-buffer frame is 2140 bytes. + * + * A nibble lookup into one preallocated buffer does the same job with a single allocation. + */ + +private let hexDigits: [UInt8] = Array("0123456789abcdef".utf8) + +public extension Array where Element == UInt8 { + + /// Lowercase, two digits per byte, no separator. Byte-for-byte identical to the `%02x` join it + /// replaces, which matters because these dumps are read by existing decode tooling. + /// + /// Named to match the Kotlin `toHexLower` rather than `hexString`: the two produce the same bytes and + /// a reader comparing the platforms should not have to check whether two differently-named helpers + /// agree on case or separator. + var hexLower: String { + var out = [UInt8](repeating: 0, count: count * 2) + var i = 0 + for b in self { + out[i] = hexDigits[Int(b >> 4)] + out[i + 1] = hexDigits[Int(b & 0x0F)] + i += 2 + } + return String(decoding: out, as: UTF8.self) + } +} diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/HistoricalLayoutSupport.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/HistoricalLayoutSupport.swift new file mode 100644 index 0000000000..31cdd37700 --- /dev/null +++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/HistoricalLayoutSupport.swift @@ -0,0 +1,52 @@ +import Foundation + +/// How well this platform handles a historical record's layout version, for the strap-log line that tells +/// a user why their nights are not staging. +/// +/// The two negative cases are kept apart because they are different facts with different remedies, and the +/// single line that used to cover both stated one of them wrongly. See `historicalLayoutSupport`. +public enum HistoricalLayoutSupport: Equatable, Sendable { + /// The layout decodes and the record carries a named signal NOOP scores from. + case supported + /// The layout decodes, but this record carries no per-second heart rate and no motion, so a night made + /// only of these cannot be staged. What is missing is the meaning of the channels, not the layout: the + /// 5/MG optical record (v20) and raw IMU record (v21) both decode into raw arrays that no engine reads. + case decodesWithoutNamedSignal + /// This platform has no field map for the layout at all; the record does not decode. + case unmapped +} + +/// Classify a historical record's layout version. +/// +/// #1992. This used to be one question — "did the record decode any of `heart_rate`, `gravity_x` or +/// `ppg_waveform`?" — and one message, which said NOOP could not decode the layout. That is a list which +/// has to be extended by hand every time NOOP learns a layout, and twice it silently was not: #156 was v25 +/// and v26 being reported as undecodable after they had been decoding for releases, and v20 has been +/// reported the same way ever since it was mapped, because the 5/MG optical record decodes to block counts +/// and per-block headers rather than to any of the three names. +/// +/// Suppressing the line for v20 would have been the wrong correction, though, because the SECOND half of +/// what it said is true: those records really do carry no heart rate or motion, and a night made only of +/// them really cannot be staged. So the question splits. On WHOOP 5.0/MG, whether the layout decodes is +/// asked of `mappedWhoop5HistoricalVersions`, the set `decodeWhoop5Historical` itself dispatches on, which +/// cannot drift from what NOOP decodes. Whether the record carries anything scoreable stays the field test, +/// which is what it was always actually measuring. +/// +/// WHOOP 4.0 has no equivalent dispatch set, and every layout it maps emits one of the three names, so a +/// record carrying none of them is genuinely unmapped there. If a 4.0 layout is ever mapped that emits +/// none of them, this is the function that has to learn about it, and `HistoricalLayoutSupportTests` is +/// where that shows up as a failure rather than as a wrong line in somebody's strap log. +public func historicalLayoutSupport(version: Int, + family: DeviceFamily, + hasHeartRate: Bool, + hasGravity: Bool, + hasPpgWaveform: Bool) -> HistoricalLayoutSupport { + let carriesNamedSignal = hasHeartRate || hasGravity || hasPpgWaveform + switch family { + case .whoop5: + if !mappedWhoop5HistoricalVersions.contains(version) { return .unmapped } + return carriesNamedSignal ? .supported : .decodesWithoutNamedSignal + case .whoop4: + return carriesNamedSignal ? .supported : .unmapped + } +} 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..d96b387ad7 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 { @@ -258,13 +290,15 @@ public func extractHistoricalStreams(_ parsed: [ParsedFrame], if let samples = p["ppg_waveform"]?.intArrayValue, !samples.isEmpty { ppgRecords.append((ts: ts, samples: samples)) out.ppgWaveform.append(PpgWaveformSample(ts: ts, samples: samples, - burstIndex: p["burst_index"]?.intValue)) + burstIndex: p["burst_index"]?.intValue, + baseCode: p["ppg_base_code"]?.intValue)) } if let bpm = p["heart_rate"]?.intValue, bpm != 0 { // skip startup hr=0 out.hr.append(HRSample(ts: ts, bpm: bpm)) } if let rrs = p["rr_intervals"]?.intArrayValue { - for rr in rrs { out.rr.append(RRInterval(ts: ts, rrMs: rr)) } + let source = p["rr_source_channel"]?.intValue.flatMap(RRSourceChannel.init(rawValue:)) + for rr in rrs { out.rr.append(RRInterval(ts: ts, rrMs: rr, srcChannel: source)) } } if let red = p["spo2_red"]?.intValue { out.spo2.append(SpO2Sample(ts: ts, red: red, ir: p["spo2_ir"]?.intValue ?? 0)) @@ -433,3 +467,66 @@ public func extractHistoricalStreams(_ parsed: [ParsedFrame], out.droppedImplausibleNewestTs = droppedNewest return out } + +/// Reconstruct a WHOOP 5/MG v26 optical window from its stored parts. #2019. +/// +/// The strap sends a 25-sample window as one absolute ADC code plus 24 deltas, so this is the only way to +/// get back the signal the strap measured: `sample[0] = baseCode`, `sample[i+1] = sample[i] + delta[i]`. +/// NOOP stores the two as they arrive rather than folding them together, because the delta blob is +/// little-endian i16 and a real code (about 378,000 on the captured fixture) does not fit in one. +/// +/// nil when `baseCode` is nil, which is the honest answer for a row written before the base was read: a +/// delta series cannot be inverted without its starting point, and returning the deltas, or a window +/// built from a fabricated zero, would present a signal nobody measured. +/// +/// CAVEAT: the deltas are SATURATED, clamped at the i16 bounds by the encoder, so a window containing a +/// clamped delta reconstructs only approximately. Nothing here can detect that after the fact; a delta at +/// exactly ±32,768 is the signal to distrust, and the captured fixture's largest magnitude is 1,833. +/// +/// Mirror EXACTLY in Kotlin (`ppgWaveformAbsolute`). +public func ppgWaveformAbsolute(baseCode: Int?, deltas: [Int]) -> [Int]? { + guard let baseCode else { return nil } + var out: [Int] = [baseCode] + out.reserveCapacity(deltas.count + 1) + var acc = baseCode + for d in deltas { + acc += d + out.append(acc) + } + return out +} + +/// A delta at either i16 rail: the encoder clamped it, so the window reconstructs only approximately. +public func isSaturatedPpgDelta(_ delta: Int) -> Bool { + delta == Int(Int16.min) || delta == Int(Int16.max) +} + +/// The per-session v26 optical census, or nil when the session carried no v26 windows (a 4.0, or a 5/MG +/// that banked none) so a log with nothing to say stays quiet. #2019. +/// +/// Three things a strap log could not previously answer, all of which decide whether the banked windows +/// are usable for the channel-mapping work this stream exists for: +/// +/// - how many windows arrived at all; +/// - how many carried the absolute base. A window without one cannot be reconstructed, ever. On a +/// well-formed record the base is always readable, so `withBase` below the window count means +/// TRUNCATED records or a firmware that does not carry it at frame-abs 23, and either is worth knowing +/// rather than silently banking un-reconstructable windows; +/// - how many windows hold a SATURATED delta. Those reconstruct only approximately, and the caveat is +/// worthless without a way to see whether it ever fires. +/// +/// The base range is carried because it is the DC level over the session, which is the quantity the whole +/// stream is banked for and the one that used to be discarded entirely. +/// +/// Mirror EXACTLY in Kotlin (`ppgWaveformCensusLine`). +public func ppgWaveformCensusLine(windows: Int, withBase: Int, saturatedWindows: Int, + baseMin: Int?, baseMax: Int?) -> String? { + guard windows > 0 else { return nil } + let range: String = { + guard let baseMin, let baseMax else { return " base n/a" } + return " base \(baseMin)..\(baseMax)" + }() + let note = saturatedWindows > 0 ? " (a saturated window reconstructs only approximately)" : "" + return "Backfill: v26 optical census: \(windows) window(s), \(withBase) with a base, " + + "\(saturatedWindows) saturated,\(range)\(note)" +} diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/Interpreter.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/Interpreter.swift index 1c6294ff2f..09d0b92357 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) +// +// `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) -> Int? { - off + 1 <= f.count ? Int(f[off]) : nil +@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,53 @@ 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] = [] + var rawTicks: [Int] = [] for i in 0.. 0 { - fb.add(off, 2, "rr[\(i)]", "rr", value: .int(v), note: "ms") - rrs.append(v) + guard off + 2 <= limit else { break } + if let v = readDType(frame, off, "u16", limit), v > 0 { + let ms = Whoop5RR.milliseconds(ticks: UInt16(v)) + fb.add(off, 2, "rr[\(i)]", "rr", value: .int(ms), note: "ms from 1/1024 s ticks") + rawTicks.append(v) + rrs.append(ms) } } fb.parsed["rr_intervals"] = .intArray(rrs) + fb.parsed["rr_raw_ticks"] = .intArray(rawTicks) + fb.parsed["rr_source_channel"] = .int(RRSourceChannel.whoop5Realtime.rawValue) } 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 +405,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 +447,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,31 +475,37 @@ 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] = [] + var rawTicks: [Int] = [] for i in 0.. 0 { - fb.add(off, 2, "rr[\(i)]", "rr", value: .int(v), note: "ms") - rrs.append(v) + guard off + 2 <= limit else { break } + if let v = readDType(frame, off, "u16", limit), v > 0 { + let ms = Whoop5RR.milliseconds(ticks: UInt16(v)) + fb.add(off, 2, "rr[\(i)]", "rr", value: .int(ms), note: "ms from 1/1024 s ticks") + rawTicks.append(v) + rrs.append(ms) } } fb.parsed["rr_intervals"] = .intArray(rrs) + fb.parsed["rr_raw_ticks"] = .intArray(rawTicks) + fb.parsed["rr_source_channel"] = .int(RRSourceChannel.whoop5Historical.rawValue) // 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 +516,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 +588,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 +619,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 +659,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 +676,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,42 +714,69 @@ 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 { - fb.add(21, 1, "burst_index", "ppg", value: .int(bi), - note: "per-burst counter (raw); NOT a channel id") + // Sixteen bits, not eight. The field is a per-burst COUNTER and a u8 wraps the moment it passes 255; + // @21 has already been observed at 65 (see above), so wrapping is reachable rather than hypothetical, + // and a wrapped counter is indistinguishable from a genuine low one. + // + // The evidence for the width is the LAYOUT, not our fixtures: 21..23 counter, 23..27 the absolute + // base (#2019), 27..75 the 24 deltas, which accounts for every byte between the record header and the + // samples with nothing left over. An independent decode of this record reads the same two bytes as one + // u16 LE. Every v26 frame held here carries byte 22 = 0, so our own captures CANNOT discriminate a u16 + // from a u8 beside a constant zero, and that is recorded as the weaker half of the case rather than + // left implied. Reading it wide is the safe direction: under 256 the two readings agree exactly, and + // above it only the wide one is right. + if let bi = readDType(frame, 21, "u16", limit), bi > 0 { + fb.add(21, 2, "burst_index", "ppg", value: .int(bi), + note: "per-burst counter (raw, u16 LE); 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") } + // #2019: the ABSOLUTE optical code the 24 values below are deltas FROM. The window is 25 samples, + // not 24: sample 0 is this code and delta i produces sample i+1. Reading only the deltas and calling + // them the waveform stored a derivative as if it were a signal, and discarded the DC level, which is + // the half an SpO2 ratio-of-ratios needs. Verified on the captured frame in the waveform tests: + // 378,307 here, a valid 20-bit code, against 24 deltas that are every one NEGATIVE, which no + // absolute optical reading can be. + if let base = readDType(frame, 23, "u32", limit) { + fb.add(23, 4, "ppg_base_code", "ppg", value: .int(base), + note: "absolute optical ADC code; ppg_waveform holds the deltas from it") + } 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 { fb.add(27, samples.count * 2, "ppg_waveform", "ppg", value: .intArray(samples), - note: "optical PPG @24 Hz, LE-i16 ADC counts") + note: "optical PPG DELTAS, LE-i16; absolute sample i+1 = base + running sum") fb.parsed["ppg_sample_count"] = .int(samples.count) } // PR#563: the remaining per-record v26 bytes, surfaced as RAW NEUTRAL fields — read off the real // fixtures but with NO invented semantics (deliberately not named segment_id / signal_quality / etc.). - // Each is gated only to "present in range"; meaning is unpinned. The header @19/@23/@25 frame the - // 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), + // Each is gated only to "present in range"; meaning is unpinned. @19 sits ahead of the waveform + // block and @75/@79/@81/@82 trail it. (`@27…@75` is the proven waveform handled above.) + // + // @23 and @25 USED to be here, described as framing the block. They are not: #2019 identified + // @23…@27 as the window's absolute optical base, which `ppg_base_code` above now carries as one u32. + // Leaving them would describe the same two bytes twice, once as an identified field and once as a + // byte whose "meaning is not pinned", and the second reading is simply no longer true. A field + // viewer showing both would invite someone to re-derive what has already been worked out. + for (name, off) in [("raw_u8_19", 19), ("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 +803,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 +829,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 +885,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 +908,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..= 97, pay[93] == 50 { fb.parsed["fw_version"] = .string("\(pay[93]).\(pay[94]).\(pay[95]).\(pay[96])") + } else { + // The guards fail closed by design, which left a strap reporting no firmware with no way to + // say WHY - a different generation byte and a MOVED offset look identical from a log. Carry + // the evidence instead; see `firmwareGateDiagnostic`. + fb.parsed["fw_gate"] = .string(firmwareGateDiagnostic(payload: pay, nameEndIndex: i)) } } } @@ -856,39 +980,51 @@ private func decodeWhoop5CommandResponse(_ frame: [UInt8], fb: FieldBuilder, sch /// borrowed from another enum (`CommandNumber` 123 is `SELECT_WRIST`) or invented. Other events' /// payloads (EXTENDED_BATTERY_INFORMATION, STRAP_CONDITION_REPORT) lack on-device 5.0 ground truth and /// are intentionally left raw rather than ported from 4.0 on faith. -private func decodeWhoop5Event(_ frame: [UInt8], fb: FieldBuilder, schema: Schema) { - guard let evVal = readDType(frame, 10, "u8") else { return } +private func decodeWhoop5Event(_ frame: [UInt8], fb: FieldBuilder, schema: Schema, limit: Int) { + guard let evVal = readDType(frame, 10, "u8", limit) else { return } guard schema.enums["EventNumber"]?[String(evVal)] == "BATTERY_LEVEL" else { return } - if let raw = readDType(frame, 21, "u16"), raw <= 1100 { + if let raw = readDType(frame, 21, "u16", limit), raw <= 1100 { fb.add(21, 2, "battery_pct", "battery", value: .double(Double(raw) / 10), note: "%") } - if let mv = readDType(frame, 25, "u16"), (3000...4300).contains(mv) { + if let mv = readDType(frame, 25, "u16", limit), (3000...4300).contains(mv) { fb.add(25, 2, "battery_mV", "battery", value: .int(mv), note: "mV") } - if let ch = readDType(frame, 30, "u8"), ch <= 1 { + if let ch = readDType(frame, 30, "u8", limit), ch <= 1 { fb.add(30, 1, "battery_charging", "battery", value: .int(ch & 1)) } } /// Decode a WHOOP 5.0 CONSOLE_LOGS (type 50) frame — the strap firmware's own plaintext diagnostics /// channel. The console is one continuous text stream chunked into fixed-size pieces, so a log line -/// routinely splits mid-sentence across frames; consumers reassemble by `record_index` order before -/// reading. Lines look like `19, 146552119: BLE: History burst success. Trim: …` (boot-count, +/// routinely splits mid-sentence across frames. Consumers retain arrival order within one link and +/// characteristic and check the wrapping `console_sequence` before joining text. Lines look like +/// `19, 146552119: BLE: History burst success. Trim: …` (boot-count, /// firmware tick ms, tag, message) and narrate the history sync and the sensor pipeline /// ("SENSORS: AFE configuration changed", "SIGPROC: generated a valid SPO2 during sleep") — primary /// raw material for the deep-data work (#103). /// -/// Record header, verified across 3 257 real frames from two nights (all one shape: 76-byte frame, -/// chunk_len 52, channel 1): `record_index` u16@9 (monotonic per-chunk counter — the frame's u8 seq -/// slot is its low byte), `unix` u32@12 + `subsec` u16@16 (batch write time), chunk_len u16@18, -/// channel u8@20, text bytes @21 up to the CRC32 trailer with NUL padding. The Kotlin twin is -/// `Framing.decodeConsoleLogsWhoop5` (text key "console"), same offsets. -private func decodeWhoop5ConsoleLogs(_ frame: [UInt8], fb: FieldBuilder, payloadEnd: Int?) { - if let idx = readDType(frame, 9, "u16") { - fb.add(9, 2, "record_index", "meta", value: .int(idx), note: "per-chunk counter") - } - if let unix = readDType(frame, 12, "u32") { fb.add(12, 4, "unix", "time", value: .int(unix)) } - if let ss = readDType(frame, 16, "u16") { fb.add(16, 2, "subsec", "time", value: .int(ss)) } +/// Header: sequence u8@9, raw header byte @10, `unix` u32@12 + `subsec` u16@16 (batch write time), +/// chunk_len u16@18, channel u8@20, text bytes @21 up to the CRC32 trailer with NUL padding. +/// On firmware 50.41.1.0, 2,978 CRC-valid night records keep @10 = 2, including nine captured +/// 255 -> 0 sequence wraps. Reading @9 as a monotonic u16 was wrong: it jumps 767 -> 512 at each +/// wrap. Captured EVENT records can occupy sequence positions between console fragments; other +/// record kinds have their own sequence semantics. Never sort these chunks by a global index or +/// join across an unexplained gap. See docs/WHOOP5_DEEP_DATA.md for the evidence boundary. +/// The text key is "log" — matching the Python +/// reference decoder that `golden.json` is generated from, which `ParityTests` pins. Kotlin used +/// "console" for the same field, and the mismatch is why a reader ported from that side got nil. +private func decodeWhoop5ConsoleLogs(_ frame: [UInt8], fb: FieldBuilder, payloadEnd: Int?, + limit: Int) { + if let sequence = readDType(frame, 9, "u8", limit) { + fb.add(9, 1, "console_sequence", "meta", value: .int(sequence), + note: "wrapping u8 console/event sequence; not a historical record index") + } + if let headerByte = readDType(frame, 10, "u8", limit) { + fb.add(10, 1, "console_header_byte_10", "raw", value: .int(headerByte), + note: "raw header byte; remains 2 across observed sequence wraps") + } + if let unix = readDType(frame, 12, "u32", limit) { fb.add(12, 4, "unix", "time", value: .int(unix)) } + if let ss = readDType(frame, 16, "u16", limit) { fb.add(16, 2, "subsec", "time", value: .int(ss)) } guard let payloadEnd = payloadEnd, 21 < payloadEnd, payloadEnd <= frame.count else { return } var textBytes = Array(frame[21.. 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..acf0a3493c 100644 --- a/Packages/WhoopProtocol/Sources/WhoopProtocol/Streams.swift +++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/Streams.swift @@ -10,10 +10,35 @@ public struct HRSample: Equatable, Codable { public init(ts: Int, bpm: Int) { self.ts = ts; self.bpm = bpm } } -/// WHICH sensor channel produced an R-R interval (#1071). +/// Sensor-contact state carried by the standard BLE Heart Rate Measurement flags. /// -/// A WHOOP strap has ONE beat source, so its rows carry no channel (nil) and nothing here changes for -/// them. An Oura ring has more than one: the green-quality tag (0x80) and the SpO2 tag (0x6E) both +/// 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 + } + } +} + +/// The sensor channel or transport that produced an R-R interval. +/// +/// WHOOP 5 exposes one beat train over multiple transports; codes 5–7 distinguish those observations. +/// WHOOP 4 and unlabelled legacy rows keep nil. An Oura ring has more than one optical channel: +/// the green-quality tag (0x80) and the SpO2 tag (0x6E) both /// decode to R-R and both were stored, so the table held roughly TWO complete copies of every night — /// not duplicate rows to de-duplicate, but the SAME heartbeats measured twice. Labelling the channel is /// what lets scoring read one copy while both stay on disk as each other's cross-check. @@ -39,13 +64,21 @@ public enum RRSourceChannel: Int, Equatable, Codable, Sendable, CaseIterable { /// is the question the channel choice for scoring rests on, and no stored night could answer it. /// Labelling only — both are read exactly as before. case ibiBare = 4 + /// WHOOP 5 v18 history, converted from wire ticks to milliseconds. + case whoop5Historical = 5 + /// WHOOP 5 type-40 live transport, converted from wire ticks to milliseconds. + case whoop5Realtime = 6 + /// WHOOP 5 standard BLE 0x2A37 live transport, already converted to milliseconds. + case whoop5Standard = 7 + + public var isWhoop5Transport: Bool { (5...7).contains(rawValue) } } public struct RRInterval: Equatable, Codable { public let ts: Int // wall-clock unix seconds public let rrMs: Int /// The sensor channel this beat came from, or nil when the source does not distinguish one (every - /// WHOOP row, and every row written before the column existed). See `RRSourceChannel`. + /// WHOOP 4 row, and legacy unlabelled rows). See `RRSourceChannel`. public let srcChannel: RRSourceChannel? /// #1008 diagnostics: this beat's EMISSION ORDER within the batch that delivered it, as stored in /// `rrInterval.ord`. nil on a decode (nothing has been stored yet) and on rows written before the @@ -54,11 +87,33 @@ public struct RRInterval: Equatable, Codable { /// It is the one field that separates the two remaining explanations for a second carrying seven /// beats: contiguous ords mean ONE record's array carried them all, so the over-count is in the /// record's contents; repeated or non-monotonic ords mean SEPARATE deliveries each contributed to - /// 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. + /// that second, so the over-count is accumulation across offloads. WHOOP 5 transport labels + /// separate live/history origins, while `ord` preserves the array order within the selected origin. 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) } } } @@ -327,12 +382,20 @@ public struct SleepStateSample: Equatable, Codable { /// `decodeWhoop5HistoricalV26`); a truncated frame can yield fewer than 24. public struct PpgWaveformSample: Equatable, Codable, Sendable { public let ts: Int // wall-clock unix seconds (one record per second) - public let samples: [Int] // raw i16 ADC counts @24 Hz, verbatim from `ppg_waveform` (usually 24) + /// The window's 24 i16 DELTAS, verbatim from `ppg_waveform`. #2019: these are NOT absolute samples. + /// The strap sends a 25-sample window as one absolute code plus 24 deltas; reconstruct with + /// `ppgWaveformAbsolute(baseCode:deltas:)`. + public let samples: [Int] public let burstIndex: Int? // raw per-burst counter @21; nil for legacy archives - public init(ts: Int, samples: [Int], burstIndex: Int? = nil) { + /// #2019: the absolute optical ADC code `samples` are deltas from, at frame-abs 23. nil on a row + /// written before it was read, and that nil is TRUE rather than merely missing: a delta series + /// cannot be inverted without its starting point, so those windows have no recoverable level. + public let baseCode: Int? + public init(ts: Int, samples: [Int], burstIndex: Int? = nil, baseCode: Int? = nil) { self.ts = ts self.samples = samples self.burstIndex = burstIndex + self.baseCode = baseCode } } @@ -754,11 +817,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 { @@ -769,7 +837,8 @@ public func extractStreams(_ parsed: [ParsedFrame], } // Unlike Python, drop RR rows when timestamp is absent (a ts-less RR row is unstorable). if let ts = ts, let rrs = p["rr_intervals"]?.intArrayValue { - for rr in rrs { out.rr.append(RRInterval(ts: ts, rrMs: rr)) } + let source = p["rr_source_channel"]?.intValue.flatMap(RRSourceChannel.init(rawValue:)) + for rr in rrs { out.rr.append(RRInterval(ts: ts, rrMs: rr, srcChannel: source)) } } case "EVENT": // EVENT timestamps are real RTC unix seconds — already wall-clock, NOT offset. 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/Whoop5RR.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5RR.swift new file mode 100644 index 0000000000..771e208799 --- /dev/null +++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5RR.swift @@ -0,0 +1,63 @@ +import Foundation + +/// WHOOP 5 type-40 and v18 interval words are already milliseconds. +/// Proven from 71 464 CRC-verified frames across 19 captures (firmware 50.41.1.0) by four +/// independent internal estimators — elapsed-time slope, phase feasibility, longest +/// self-consistent run, and HR-byte cross-check — all converging near 1000 words/second. +/// Transport parity with the standard BLE 0x2A37 characteristic (same raw words on both +/// channels) means WHOOP 5 sends millisecond values there too, non-compliant with the BLE +/// spec's 1/1024-second unit. +/// Keep this separate from WHOOP 4 decoding, whose existing millisecond contract is unchanged. +public enum Whoop5RR { + public static func milliseconds(ticks: UInt16) -> Int { + Int(ticks) + } + + /// Labelled wire observations resolve an unknown registry entry, but cannot override another family. + public static func usesCanonicalSource(model: String?, brand: String?, hasTaggedIntervals: Bool) -> Bool { + if let brand, !brand.isEmpty, brand.caseInsensitiveCompare("WHOOP") != .orderedSame { return false } + switch DeviceFamily.confirmedRegistryFamily(model: model, brand: brand) { + case .whoop4: return false + case .whoop5: return true + case nil: return hasTaggedIntervals + } + } + + /// Whether a stored night is one this unit policy has to leave unscored, rather than one that simply + /// has no data. + /// + /// The policy reads a window through a SINGLE labelled transport. Rows recorded before the label + /// existed carry no transport, so they mix millisecond and tick units with nothing on disk to tell + /// them apart, and a window holding only those rows yields no beats at all. HRV is nil, and Charge, + /// which needs a nightly HRV, is nil behind it. That is indistinguishable on screen from a night the + /// strap was not worn, which is why this exists: it is the one case where the cause is known. + /// + /// Deliberately CONSERVATIVE, per the rule that a diagnostic may only assert what it can attribute. + /// The claim is bounded on BOTH sides, to the window where this strap was recording and nothing was + /// yet labelled: + /// - `strictWhoop5` gates it, so a WHOOP 4 or another brand can never see this explanation. + /// - `firstRecordedDay` is required and bounds it below. Without that bound, a wearer who imported + /// years of history before ever owning the strap would be told those nights lost their beats to a + /// labelling change that had not been invented yet, which is a fabricated cause. A device that has + /// banked no beats at all never qualifies for the same reason. + /// - `firstScorableDay` bounds it above: a night on or after it is NOT claimed, even though a + /// particular window there can still come up empty. Past that day the app is labelling, so the + /// honest general statement stops. Nil means nothing labelled has been banked yet, which for a + /// confirmed WHOOP 5 is exactly the wearer who has not synced since the units were corrected. + /// - `totalSleepMin` must be positive. A night that staged is a night the wearer was wearing it; + /// without that, an empty HRV is far more likely a strap left on the charger. + /// + /// One gap is left un-narrowed and is stated rather than hidden: inside those bounds, a night staged + /// purely from an imported source while the strap itself banked nothing would also be claimed. Ruling + /// that out needs a per-night beat count, and the statement stays true of the wearer's situation + /// either way. Day keys are `yyyy-MM-dd`, so comparing them as strings orders them by date. + public static func legacyUnscorableNight(strictWhoop5: Bool, day: String, firstRecordedDay: String?, + firstScorableDay: String?, + avgHrv: Double?, totalSleepMin: Double?) -> Bool { + guard strictWhoop5, avgHrv == nil, (totalSleepMin ?? 0) > 0 else { return false } + guard let firstRecordedDay, !firstRecordedDay.isEmpty, day >= firstRecordedDay else { return false } + if let firstScorableDay, !firstScorableDay.isEmpty, day >= firstScorableDay { return false } + return true + } + +} 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/WhoopProtocol/Whoop5Variant.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5Variant.swift index 254d315c86..e02877bda2 100644 --- a/Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5Variant.swift +++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5Variant.swift @@ -63,7 +63,25 @@ public enum Whoop5Variant: String, Sendable, CaseIterable { /// 2. Hardware revision carrying the known 5.0 token → `.fiveZero` (device-attested). /// 3. Serial prefix → `.mg` / `.fiveZero`. /// 4. Anything else → `.unknown`. Never infer from a digit elsewhere in the name (#772). - public static func from(serial: String?, hardwareRevision: String? = nil) -> Whoop5Variant { + /// DIS 0x2A24 Model Number String, as a real WHOOP MG reports it. Read unbonded off the standard + /// profile, so it is available on a strap that never pairs — exactly the case the prefix heuristics + /// were failing on. + static let modelNumberMG = "MG" + + public static func from( + serial: String?, + hardwareRevision: String? = nil, + modelNumber: String? = nil + ) -> Whoop5Variant { + // The strap SAYING what it is beats inferring it from a prefix. A field capture showed serial + // prefix "MGB" with hardware revision "WS50_r03" — matching neither `mgSerialPrefix` nor + // `fiveZeroHardwareIdToken` — on a strap whose own model number said MG outright (#520). + // + // Only MG is claimed. No 5.0 has been observed reporting its model number, so inventing a token + // for one would be the guess this function already refuses to make elsewhere. + if (modelNumber ?? "").trimmingCharacters(in: .whitespacesAndNewlines).uppercased() == modelNumberMG { + return .mg + } let hw = (hardwareRevision ?? "").uppercased() var s = (serial ?? "").uppercased().trimmingCharacters(in: .whitespacesAndNewlines) if s.hasPrefix("WHOOP ") { s = String(s.dropFirst("WHOOP ".count)) } diff --git a/Packages/WhoopProtocol/Sources/whoop-decode/main.swift b/Packages/WhoopProtocol/Sources/whoop-decode/main.swift index 6a5910717d..121f4af8dc 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: structural + // failures remain structural reasons even when their payload CRC32 could not be computed. + 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..7a13b6cff4 100644 --- a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DeviceConfigReadProbeTests.swift +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DeviceConfigReadProbeTests.swift @@ -51,6 +51,24 @@ final class DeviceConfigReadProbeTests: XCTestCase { private var flagKeys: [String] { Whoop5Config.enableR22Sequence.map(\.name) } + func testObservedWhoop5FlagsAreReadWithoutExtendingTheWriteSequence() { + let keys = DeviceConfigReadProbe.knownFlagKeys(for: .whoop5) + var report = DeviceConfigReadProbeReport(family: .whoop5, knownFlagKeys: keys, + candidateKeys: []) + while let step = report.nextStep() { + XCTAssertTrue(DeviceConfigReadProbe.isReadOnlyOpcode(step.opcode)) + report.noteReply(.init(resultCode: 1, record: echoRecord(step.key, value: 0x31)), for: step) + } + let flagReads = report.readings.filter { $0.opcode == DeviceConfigReadProbe.getFeatureFlagValueCmd } + XCTAssertEqual(flagReads.count, 20, "the complete hardware enumeration contained twenty names") + XCTAssertEqual(Set(flagReads.map(\.key)), Set(keys)) + XCTAssertEqual(report.steps, 21, "one read per flag plus the existing device-config discovery") + XCTAssertEqual(flagKeys.count, 16, "the write sequence must not inherit read-only discoveries") + XCTAssertEqual(DeviceConfigReadProbe.knownFlagKeys(for: .whoop4), flagKeys) + XCTAssertTrue(report.render().contains("includes names observed in strap enumeration")) + XCTAssertFalse(report.render().contains("names NOOP already writes; values never read before")) + } + // MARK: - The read-only allowlist (the hard safety constraint) func testAllowlistAdmitsOnlyTheTwoReadVerbs() { @@ -141,6 +159,73 @@ final class DeviceConfigReadProbeTests: XCTestCase { XCTAssertNil(r.value(for: "whatever"), "an UNSUPPORTED reply must never yield a value") } + // These were gated to macOS because that is where the hardware run happened. The behaviour they + // pin is not macOS-specific, and gating them meant the guard-only test could not compile on the + // platform where the guard was missing: `Executed 0 tests` off macOS, against a probe that builds + // for iOS. A test that only runs where the bug is already fixed cannot catch the bug. (#2193) + func testEchoedFailureBytesAreNotReportedAsStoredValues() { + // The live WHOOP 5 oxygen-key reads returned FAILURE with the requested key and zeroes. + for result in [UInt8(0), 2, 3] { + let frame = whoop5Response(cmd: 121, + payload: payload(result: result, + record: echoRecord("enable_spo2", value: 0))) + guard case .success(let reply) = DeviceConfigReadProbe.parse(frame: frame, family: .whoop5, + expecting: 121) else { + return XCTFail("the response is valid framing even when the read failed") + } + var report = DeviceConfigReadProbeReport(family: .whoop5, knownFlagKeys: [], + candidateKeys: []) + report.noteReply(reply, for: .init(opcode: 121, key: "enable_spo2", group: .candidate)) + XCTAssertEqual(reply.value(for: "enable_spo2"), 0, "the shared byte decoder remains unchanged") + XCTAssertNil(report.readings.first?.value) + if result == 3 { + XCTAssertEqual(report.verdict, + "no read verb answered — GET_FF_VALUE(128) not asked; GET_DEVICE_CONFIG_VALUE(121) refused by firmware (UNSUPPORTED)") + } else { + XCTAssertEqual(report.verdict, + "1 of 2 read verbs answered, but no reply reported success; no value is claimed") + } + XCTAssertFalse(report.render().contains("value=0x00")) + } + } + + func testSuccessfulReplyWithoutAKeyValueHasADistinctVerdict() { + var report = DeviceConfigReadProbeReport(family: .whoop5, knownFlagKeys: [], candidateKeys: []) + report.noteReply(.init(resultCode: 1, record: [1, 0]), + for: .init(opcode: 128, key: "enable_r22_packets", group: .discovery)) + XCTAssertEqual(report.verdict, + "1 of 2 read verbs answered, but no successful reply carried a verified key/value pair; no value is claimed") + } + + func testReportDoesNotPresentAFailedReadAsAStoredValue() { + // Guard-only: uses nothing this change added, so dropping the result-code check fails an + // assertion here rather than the build. The FAILURE and SUCCESS frames carry identical records. + let record = echoRecord("enable_rocky2", value: 0) + func report(result: UInt8) -> DeviceConfigReadProbeReport { + let frame = whoop5Response(cmd: 128, payload: payload(result: result, record: record)) + guard case .success(let reply) = DeviceConfigReadProbe.parse(frame: frame, family: .whoop5, + expecting: 128) else { + XCTFail("the response is valid framing even when the read failed") + return DeviceConfigReadProbeReport(family: .whoop5, knownFlagKeys: [], candidateKeys: []) + } + XCTAssertEqual(reply.value(for: "enable_rocky2"), 0, + "the fixture must be the dangerous shape: key echoed, zero byte after the field") + var report = DeviceConfigReadProbeReport(family: .whoop5, knownFlagKeys: [], candidateKeys: []) + report.noteReply(reply, for: .init(opcode: 128, key: "enable_rocky2", group: .knownFlag)) + return report + } + + let failed = report(result: 0) + XCTAssertEqual(failed.readings.count, 1, "the rejected read is still recorded") + XCTAssertEqual(failed.readings.first?.resultCode, 0) + XCTAssertNil(failed.readings.first?.value, "a FAILURE reply must not be reported as a stored 0") + XCTAssertFalse(failed.render().contains("value=")) + + let succeeded = report(result: 1) + XCTAssertEqual(succeeded.readings.first?.value, 0, "a SUCCESS reply holding 0 is still a real 0") + XCTAssertTrue(succeeded.render().contains("value=0x00")) + } + func testNoValueIsClaimedWhenTheReplyDoesNotEchoTheKey() { // A plausible-looking record that simply isn't the key we asked for. let frame = whoop5Response(cmd: 128, payload: payload(result: 1, record: echoRecord("some_other_key", value: 0x32))) @@ -505,4 +590,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/FirmwareGateDiagnosticTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/FirmwareGateDiagnosticTests.swift new file mode 100644 index 0000000000..657bd66577 --- /dev/null +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/FirmwareGateDiagnosticTests.swift @@ -0,0 +1,49 @@ +import XCTest +@testable import WhoopProtocol + +/// Pins the 5/MG firmware-gate diagnostic. Kotlin twin: `FirmwareGateDiagnosticTest`. +/// +/// The decoder reads the version at pay[93] behind a `pay[93] == 50` guard, both anchored to a single +/// 50.38.1.0 capture. The guards fail closed, so a strap that does not match reports nothing — and a +/// different generation byte is indistinguishable from a MOVED offset unless the line says which. +final class FirmwareGateDiagnosticTests: XCTestCase { + + private func payload(count: Int, at93: UInt8) -> [UInt8] { + var p = (0.. 93 { p[93] = at93 } + return p + } + + func testReportsTheByteItActuallySawAndTheExpectedOne() { + let line = firmwareGateDiagnostic(payload: payload(count: 128, at93: 51), nameEndIndex: 27) + XCTAssertTrue(line.contains("at93=51 expected=50"), line) + XCTAssertTrue(line.contains("len=128"), line) + } + + func testCarriesTheNameEndBecauseThatIsWhatMovesTheOffset() { + // The version sits after the name+token region, so where the printable-ASCII name run ended is + // the number that lets a reader re-derive a shifted offset. + let line = firmwareGateDiagnostic(payload: payload(count: 128, at93: 51), nameEndIndex: 31) + XCTAssertTrue(line.contains("nameEnd=31"), line) + } + + func testTheHexWindowSpansTheRegionTheVersionShouldOccupy() { + let line = firmwareGateDiagnostic(payload: payload(count: 128, at93: 51), nameEndIndex: 27) + XCTAssertTrue(line.contains("hex[88..<101]="), line) + // 13 bytes, two hex chars each. + let hex = line.components(separatedBy: "hex[88..<101]=").last ?? "" + XCTAssertEqual(hex.count, 26, hex) + } + + func testAShortPayloadCannotTrapAndSaysSo() { + // A malformed/truncated hello must not crash the decoder; the window clamps and at93 reports n/a. + let line = firmwareGateDiagnostic(payload: [1, 2, 3], nameEndIndex: 2) + XCTAssertTrue(line.contains("at93=n/a"), line) + XCTAssertTrue(line.contains("len=3"), line) + } + + func testAPayloadEndingExactlyAtTheWindowStartYieldsAnEmptyWindow() { + let line = firmwareGateDiagnostic(payload: Array(repeating: 0, count: 88), nameEndIndex: 20) + XCTAssertTrue(line.hasSuffix("hex[88..<88]="), line) + } +} diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/FrameDiagnosticsTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/FrameDiagnosticsTests.swift new file mode 100644 index 0000000000..80ae061815 --- /dev/null +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/FrameDiagnosticsTests.swift @@ -0,0 +1,219 @@ +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)") + } + + /// A structural rejection whose declared payload is unavailable keeps the CRC column at "—" + /// rather than claiming the checksum was computed and disagreed. + func testUnavailablePayloadCRCDiagnosticIsNotReportedAsAMismatch() { + 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 testTallyReportsOnlyAComputedPayloadMismatchAsPayloadCRCMismatch() { + var tally = FrameRejectTally() + // Payload byte flipped: the CRC32 was computed and disagreed. + var wrongPayload = hex(FrameIntegrityTests.w4Valid) + wrongPayload[10] ^= 0xFF + tally.note(parseFrame(wrongPayload)) + tally.note(parseFrame(hex(FrameIntegrityTests.w4Total8))) + XCTAssertEqual(tally.count(.payloadCRCMismatch), 1) + XCTAssertEqual(tally.count(.belowMinimumLength), 1, + "an unavailable CRC stays classified by the structural failure") + } + + /// 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 unavailable: 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 a structural failure with no CRC result 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..a67023efd7 --- /dev/null +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/FrameIntegrityOracleTests.swift @@ -0,0 +1,197 @@ +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 committed fixture is the cross-platform expectation. No generator is retained in this +/// repository, so maintenance must update both byte-identical copies deliberately and validate the +/// result with both suites rather than claiming regeneration from a missing tool. +/// +/// 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. +/// +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") + } + } + + /// Render the classification in the fixture's compact form, 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 declared reason is reachable and therefore pinned by the shared oracle. + for reason in FrameRejectReason.allCases { + XCTAssertGreaterThan(reasons[reason.rawValue] ?? 0, 0, + "reason \(reason.rawValue) is not covered by any oracle case") + } + // 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..e7f7aa0b0b --- /dev/null +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/FrameIntegrityTests.swift @@ -0,0 +1,398 @@ +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 testBelowMinimumLengthOwnsTheReasonWhenPayloadCRCIsUnavailable() { + // Too short for a CRC32 over any payload byte at all: the structural rule decides it. + let parsed = parseFrame(Self.hex(Self.w4Total8)) + XCTAssertFalse(parsed.ok) + XCTAssertNil(parsed.crcOK, "the diagnostic stays honest: no CRC32 was computed") + XCTAssertEqual(parsed.rejectReason, .belowMinimumLength) + } + + 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") + XCTAssertEqual(FrameRejectReason.allCases.count, 6) + } + + 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/HexTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/HexTests.swift new file mode 100644 index 0000000000..0a7d7213ba --- /dev/null +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/HexTests.swift @@ -0,0 +1,38 @@ +import XCTest +@testable import WhoopProtocol + +/// The fast encoder has to be byte-for-byte identical to the `%02x` join it replaces: its output goes +/// into capture files and reject archives that existing decode tooling reads. Twin of the Kotlin +/// `HexTest`, using the OLD implementation as the oracle rather than a hand-written expectation. +final class HexTests: XCTestCase { + + private func slow(_ b: [UInt8]) -> String { b.map { String(format: "%02x", $0) }.joined() } + + func testMatchesTheFormatBasedEncoderAcrossTheWholeByteRange() { + let all = (0...255).map { UInt8($0) } + XCTAssertEqual(slow(all), all.hexLower) + } + + /// The high half of the range is where a nibble-shifting encoder usually goes wrong. + func testHighBytesAreZeroPadded() { + XCTAssertEqual([0x00].hexLower, "00") + XCTAssertEqual([0x0f].hexLower, "0f") + XCTAssertEqual([0x80].hexLower, "80") + XCTAssertEqual([0xff].hexLower, "ff") + } + + func testEmptyInEmptyOut() { + XCTAssertEqual([UInt8]().hexLower, "") + } + + /// Lowercase, no separator: the shape every existing consumer of these dumps expects. + func testOutputIsLowercaseAndUnseparated() { + XCTAssertEqual([0xab, 0xcd, 0xef].hexLower, "abcdef") + } + + /// A realistic deep-buffer frame, since that is the size this exists for. + func testLongFrameMatchesTheOracle() { + let frame = (0..<2140).map { UInt8($0 % 256) } + XCTAssertEqual(slow(frame), frame.hexLower) + } +} diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/HistoricalLayoutSupportTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/HistoricalLayoutSupportTests.swift new file mode 100644 index 0000000000..2625b65928 --- /dev/null +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/HistoricalLayoutSupportTests.swift @@ -0,0 +1,66 @@ +import XCTest +@testable import WhoopProtocol + +final class HistoricalLayoutSupportTests: XCTestCase { + + /// The regression guard the old form did not have. No layout the 5/MG decoder dispatches on may be + /// called UNMAPPED, whatever field names its decode happens to produce. This is what #156 needed and + /// did not get, so v25/v26 kept warning; and what v20 needed and did not get. Driven off the dispatch + /// set itself, so a layout added there without a signature field cannot reintroduce the bug. + func testNoMappedWhoop5LayoutIsEverCalledUnmapped() { + for v in mappedWhoop5HistoricalVersions { + XCTAssertNotEqual( + historicalLayoutSupport(version: v, family: .whoop5, hasHeartRate: false, + hasGravity: false, hasPpgWaveform: false), + .unmapped, + "layout v\(v) is in mappedWhoop5HistoricalVersions but reports as unmapped") + } + } + + /// v20 by name. It decodes, and it still cannot stage a night, and those are different sentences. The + /// first version of this change suppressed the line entirely for v20, which would have dropped the + /// true half and left a user with unstaged nights and nothing in the log saying why. + func testTheOpticalLayoutDecodesButStillCannotStageANight() { + XCTAssertTrue(mappedWhoop5HistoricalVersions.contains(20)) + XCTAssertEqual( + historicalLayoutSupport(version: 20, family: .whoop5, hasHeartRate: false, + hasGravity: false, hasPpgWaveform: false), + .decodesWithoutNamedSignal) + } + + /// A mapped layout that DOES carry a named signal is simply fine, and says nothing at all. + func testAMappedLayoutCarryingASignalIsSupported() { + for (hr, grav, ppg) in [(true, false, false), (false, true, false), (false, false, true)] { + XCTAssertEqual( + historicalLayoutSupport(version: 18, family: .whoop5, hasHeartRate: hr, + hasGravity: grav, hasPpgWaveform: ppg), + .supported) + } + } + + /// A version nothing dispatches on is unmapped, and a decoded field does not rescue it: on the 5/MG + /// side the dispatch set is the authority, so a plausible-looking value under an unmapped version is + /// exactly the "it holds by accident" case that set exists to refuse. + func testAnUnknownWhoop5LayoutIsUnmappedWhateverItDecoded() { + let unknown = (1...255).first { !mappedWhoop5HistoricalVersions.contains($0) }! + XCTAssertEqual( + historicalLayoutSupport(version: unknown, family: .whoop5, hasHeartRate: false, + hasGravity: false, hasPpgWaveform: false), .unmapped) + XCTAssertEqual( + historicalLayoutSupport(version: unknown, family: .whoop5, hasHeartRate: true, + hasGravity: true, hasPpgWaveform: true), .unmapped) + } + + /// WHOOP 4.0 is judged by what it decoded, exactly as before: it has no dispatch set to ask, and every + /// layout it maps emits one of the three names. + func testWhoop4IsStillJudgedByWhatItDecoded() { + XCTAssertEqual( + historicalLayoutSupport(version: 19, family: .whoop4, hasHeartRate: false, + hasGravity: false, hasPpgWaveform: false), .unmapped) + for (hr, grav, ppg) in [(true, false, false), (false, true, false), (false, false, true)] { + XCTAssertEqual( + historicalLayoutSupport(version: 25, family: .whoop4, hasHeartRate: hr, + hasGravity: grav, hasPpgWaveform: ppg), .supported) + } + } +} 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/ReassemblerTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/ReassemblerTests.swift index 31e17426b1..cd312a9d8f 100644 --- a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/ReassemblerTests.swift +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/ReassemblerTests.swift @@ -82,4 +82,46 @@ final class ReassemblerTests: XCTestCase { let out = r.feed([0xAA, 0xFF, 0xFF, 0x00] + valid) XCTAssertEqual(out, [valid], "a garbage oversized SOF must not wedge the stream") } + + /// A false start-of-frame whose declared length is PLAUSIBLE, which the floor and ceiling guards + /// both accept. Before the header-checksum gate, feed() waited for that many bytes and emitted them + /// as a single frame, consuming the valid frames that fell inside it — they never reached a parser + /// and nothing downstream could put them back, because the CRC32 that rejects the bad frame runs + /// after `head` has advanced past them. + /// + /// Built to be exactly the case the range guards miss: length 0x0064 gives total 104, well past the + /// WHOOP 4.0 floor and far under the 8 KB ceiling. The byte at offset 3 is a deliberately wrong + /// header CRC-8, which is what a payload byte read as a frame start looks like. + func testFalseSOFWithAnInRangeLengthDoesNotSwallowTheFramesBehindIt() { + let first = frameFromPayload([0x01, 0x02, 0x03], type: 40) + let second = frameFromPayload([0x04, 0x05], type: 41) + // 0xAA, length 100 (in range), then a crc8 that cannot be right for those length bytes. + let wrongCRC = crc8([0xAA, 0x64, 0x00], 1, 3) ^ 0xFF + let falseSOF: [UInt8] = [0xAA, 0x64, 0x00, wrongCRC] + let r = Reassembler() + let out = r.feed(falseSOF + first + second) + XCTAssertEqual(out, [first, second], + "a false SOF must resync by one byte, not eat the frames behind it") + XCTAssertEqual(r.headerChecksumDrops, 1, "and the drop must be counted, not silent") + } + + /// The gate must not cost a real frame. Every valid frame carries a correct header checksum, so + /// this is the half that would break loudly if the CRC span or byte order were wrong. + func testAValidFrameStillPassesTheHeaderGate() { + let frame = frameFromPayload([0x09, 0x08, 0x07], type: 40) + let r = Reassembler() + XCTAssertEqual(r.feed(frame), [frame]) + XCTAssertEqual(r.headerChecksumDrops, 0, "a real frame must never be counted as a false SOF") + } + + /// A 5/MG false SOF is rejected by its CRC-16 header for the same reason, on the family whose + /// length lives at a different offset — so a gate written for one family cannot silently pass here. + func testFalseSOFIsRejectedOnWhoop5Too() { + let r = Reassembler(family: .whoop5) + // 0xAA, fmt, declared length 0x0040 (total 72: in range), header bytes, then a bad CRC16. + let falseSOF: [UInt8] = [0xAA, 0x01, 0x40, 0x00, 0x00, 0x00, 0xFF, 0xFF] + let out = r.feed(falseSOF) + XCTAssertEqual(out, [], "nothing to emit") + XCTAssertEqual(r.headerChecksumDrops, 1, "the bad 5/MG header must be counted and resynced") + } } 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/decoder_oracle.json b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Resources/decoder_oracle.json index 2ac5e1297e..1be6bf627c 100644 --- a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Resources/decoder_oracle.json +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Resources/decoder_oracle.json @@ -58,7 +58,7 @@ "skin_temp_raw": 5, "sleep_state": 2, "spo2_ir": 1, "spo2_red": 1, "status_word": 1, "status_word_1": 2, "status_word_2": 2, "step_cadence": 1, "step_motion_counter": 1, "temp_aux_1_raw": 2, "temp_aux_2_raw": 2, "unix": 15, "unknown_f32_113": 1, - "wake_quality": 2 + "wake_quality": 2, "rr_raw_ticks": 4, "rr_source_channel": 4 }, "derived_fields": ["gravity_mag"], "frames": [ @@ -166,7 +166,10 @@ "unix": 1780916150, "heart_rate": 102, "rr_count": 2, - "rr_intervals": [602, 613], + "rr_intervals": [ + 602, + 613 + ], "skin_temp_raw": 3057, "step_motion_counter": 50, "motion_wear_quality": 0, @@ -187,7 +190,12 @@ "temp_aux_2_raw": 265, "unknown_f32_113": -5.2307, "gravity_mag": 1.0, - "dynamic_acceleration": 0.00916 + "dynamic_acceleration": 0.00916, + "rr_raw_ticks": [ + 602, + 613 + ], + "rr_source_channel": 5 } }, { @@ -200,8 +208,14 @@ "unix": 1780916152, "heart_rate": 101, "rr_count": 1, - "rr_intervals": [595], - "dynamic_acceleration": 0.010708 + "rr_intervals": [ + 595 + ], + "dynamic_acceleration": 0.010708, + "rr_raw_ticks": [ + 595 + ], + "rr_source_channel": 5 } }, { @@ -304,7 +318,15 @@ "unix": 1780916150, "heart_rate": 102, "rr_count": 2, - "rr_intervals": [602, 613] + "rr_intervals": [ + 602, + 613 + ], + "rr_raw_ticks": [ + 602, + 613 + ], + "rr_source_channel": 5 } }, { @@ -375,7 +397,15 @@ "record_index": 25443699, "heart_rate": 102, "rr_count": 2, - "rr_intervals": [602, 613] + "rr_intervals": [ + 602, + 613 + ], + "rr_raw_ticks": [ + 602, + 613 + ], + "rr_source_channel": 5 } } ], 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..0429b1c3eb --- /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).", + "Committed parity fixture; no generator is retained in this repository.", + "Update both copies together and validate them with both platform suites.", + "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/Resources/whoop5_rr_oracle.json b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Resources/whoop5_rr_oracle.json new file mode 100644 index 0000000000..d468c96f9f --- /dev/null +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Resources/whoop5_rr_oracle.json @@ -0,0 +1,426 @@ +{ + "cases": [ + { + "milliseconds": 0, + "ticks": 0 + }, + { + "milliseconds": 1, + "ticks": 1 + }, + { + "milliseconds": 64, + "ticks": 64 + }, + { + "milliseconds": 128, + "ticks": 128 + }, + { + "milliseconds": 192, + "ticks": 192 + }, + { + "milliseconds": 256, + "ticks": 256 + }, + { + "milliseconds": 512, + "ticks": 512 + }, + { + "milliseconds": 595, + "ticks": 595 + }, + { + "milliseconds": 602, + "ticks": 602 + }, + { + "milliseconds": 603, + "ticks": 603 + }, + { + "milliseconds": 613, + "ticks": 613 + }, + { + "milliseconds": 1000, + "ticks": 1000 + }, + { + "milliseconds": 1020, + "ticks": 1020 + }, + { + "milliseconds": 1024, + "ticks": 1024 + }, + { + "milliseconds": 2048, + "ticks": 2048 + }, + { + "milliseconds": 3072, + "ticks": 3072 + }, + { + "milliseconds": 65535, + "ticks": 65535 + } + ], + "milliseconds_u16le_fnv1a64": "ba36b3170979df25", + "policy_cases": [ + { + "brand": "WHOOP", + "model": "4.0", + "strict": false, + "tagged": false + }, + { + "brand": "WHOOP", + "model": "4.0", + "strict": false, + "tagged": true + }, + { + "brand": null, + "model": "WHOOP 4.0", + "strict": false, + "tagged": false + }, + { + "brand": null, + "model": "WHOOP 4.0", + "strict": false, + "tagged": true + }, + { + "brand": "WHOOP", + "model": "5.0 MG", + "strict": true, + "tagged": false + }, + { + "brand": "WHOOP", + "model": "5.0 MG", + "strict": true, + "tagged": true + }, + { + "brand": "whoop", + "model": "WHOOP 5.0 / MG", + "strict": true, + "tagged": false + }, + { + "brand": "whoop", + "model": "WHOOP 5.0 / MG", + "strict": true, + "tagged": true + }, + { + "brand": "WHOOP", + "model": "WHOOP", + "strict": false, + "tagged": false + }, + { + "brand": "WHOOP", + "model": "WHOOP", + "strict": true, + "tagged": true + }, + { + "brand": null, + "model": null, + "strict": false, + "tagged": false + }, + { + "brand": null, + "model": null, + "strict": true, + "tagged": true + }, + { + "brand": "WHOOP", + "model": "future model", + "strict": false, + "tagged": false + }, + { + "brand": "WHOOP", + "model": "future model", + "strict": true, + "tagged": true + }, + { + "brand": "Oura", + "model": "5.0 MG", + "strict": false, + "tagged": false + }, + { + "brand": "Oura", + "model": "5.0 MG", + "strict": false, + "tagged": true + }, + { + "brand": "Apple", + "model": "4.0", + "strict": false, + "tagged": false + }, + { + "brand": "Apple", + "model": "4.0", + "strict": false, + "tagged": true + }, + { + "brand": "WHOOP", + "model": "5.0", + "strict": true, + "tagged": false + }, + { + "brand": "Oura", + "model": "5.0", + "strict": false, + "tagged": false + }, + { + "brand": "WHOOP", + "model": "WHOOP 5.0", + "strict": true, + "tagged": false + }, + { + "brand": "Oura", + "model": "WHOOP 5.0", + "strict": false, + "tagged": false + }, + { + "brand": "WHOOP", + "model": "WHOOP 5.0 / MG", + "strict": true, + "tagged": false + }, + { + "brand": "Oura", + "model": "WHOOP 5.0 / MG", + "strict": false, + "tagged": false + }, + { + "brand": "WHOOP", + "model": "MG", + "strict": true, + "tagged": false + }, + { + "brand": "Oura", + "model": "MG", + "strict": false, + "tagged": false + }, + { + "brand": "WHOOP", + "model": "whoop5", + "strict": true, + "tagged": false + }, + { + "brand": "Oura", + "model": "whoop5", + "strict": false, + "tagged": false + }, + { + "brand": "WHOOP", + "model": "Whoop5", + "strict": true, + "tagged": false + }, + { + "brand": "Oura", + "model": "Whoop5", + "strict": false, + "tagged": false + }, + { + "brand": "WHOOP", + "model": "mg", + "strict": true, + "tagged": false + }, + { + "brand": "Oura", + "model": "mg", + "strict": false, + "tagged": false + }, + { + "brand": "WHOOP", + "model": "unknown", + "strict": false, + "tagged": false + }, + { + "brand": "Oura", + "model": "unknown", + "strict": false, + "tagged": false + }, + { + "brand": "Oura", + "model": "WHOOP", + "strict": false, + "tagged": false + } + ], + "provenance": "Identity conversion (word IS milliseconds); FNV over all 65536 u16 inputs.", + "wire_cases": [ + { + "name": "realtime_normal", + "hex": "aa01120001002139280000f1536500003c0200040002630363c3", + "raw": [ + 1024, + 512 + ], + "ms": [ + 1024, + 512 + ], + "channel": 6 + }, + { + "name": "realtime_zero", + "hex": "aa011400010021b1280000f1536500003c03000400000002b406fddd", + "raw": [ + 1024, + 512 + ], + "ms": [ + 1024, + 512 + ], + "channel": 6 + }, + { + "name": "realtime_empty", + "hex": "aa010e00010026a9280000f1536500003c00a4e132d5", + "raw": [], + "ms": [], + "channel": 6 + }, + { + "name": "realtime_truncated", + "hex": "aa0111000100217d280000f1536500003cff0004ff2e93e939", + "raw": [ + 1024 + ], + "ms": [ + 1024 + ], + "channel": 6 + }, + { + "name": "realtime_no_words", + "hex": "aa010f0001002755280000f1536500003cffff62a84abd", + "raw": [], + "ms": [], + "channel": 6 + }, + { + "name": "realtime_max_four", + "hex": "aa011800010022e1280000f1536500003c0500040004000400040004cf3c777d", + "raw": [ + 1024, + 1024, + 1024, + 1024, + 1024 + ], + "ms": [ + 1024, + 1024, + 1024, + 1024, + 1024 + ], + "channel": 6 + }, + { + "name": "history_normal", + "hex": "aa011800010022e12f12000000000000f153650000003c0200040002700d85e7", + "raw": [ + 1024, + 512 + ], + "ms": [ + 1024, + 512 + ], + "channel": 5 + }, + { + "name": "history_zero", + "hex": "aa011a00010023592f12000000000000f153650000003c03000400000002c74eaa5b", + "raw": [ + 1024, + 512 + ], + "ms": [ + 1024, + 512 + ], + "channel": 5 + }, + { + "name": "history_empty", + "hex": "aa011400010021b12f12000000000000f153650000003c002479bc93", + "raw": [], + "ms": [], + "channel": 5 + }, + { + "name": "history_truncated", + "hex": "aa011700010021f52f12000000000000f153650000003cff0004ffdd26446b", + "raw": [ + 1024 + ], + "ms": [ + 1024 + ], + "channel": 5 + }, + { + "name": "history_no_words", + "hex": "aa0115000100204d2f12000000000000f153650000003cffffdaa5b450", + "raw": [], + "ms": [], + "channel": 5 + }, + { + "name": "history_max_four", + "hex": "aa011e00010022692f12000000000000f153650000003c05000400040004000400044302558b", + "raw": [ + 1024, + 1024, + 1024, + 1024 + ], + "ms": [ + 1024, + 1024, + 1024, + 1024 + ], + "channel": 5 + } + ] +} 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/Whoop5ConsoleLogsTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Whoop5ConsoleLogsTests.swift index 050bf67307..5287eef21f 100644 --- a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Whoop5ConsoleLogsTests.swift +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Whoop5ConsoleLogsTests.swift @@ -3,11 +3,11 @@ import XCTest /// WHOOP 5.0 ("puffin") CONSOLE_LOGS (type 50) decode, verified against real captured frames. /// -/// The strap streams its firmware console as fixed-size text chunks: record header (`record_index` -/// u16@9, `unix` u32@12, `subsec` u16@16, chunk_len u16@18, channel u8@20), then the raw text at @21 +/// The strap streams its firmware console as fixed-size text chunks: record header (`console_sequence` +/// u8@9, raw byte @10, `unix` u32@12, `subsec` u16@16, chunk_len u16@18, channel u8@20), then the raw text at @21 /// with NUL padding up to the CRC32 trailer. One log line routinely spans several frames — the two /// consecutive fixtures below split "…start response a" / "ck, start burst" mid-word — so consumers -/// reassemble by `record_index` before reading. All fixtures are real frames from a 2026-07-12 +/// retain arrival order and check wrapping sequences before reading. Existing fixtures are from a 2026-07-12 /// history sync (the strap narrating its own transfer); they carry no device name / serial / token. final class Whoop5ConsoleLogsTests: XCTestCase { @@ -29,7 +29,9 @@ final class Whoop5ConsoleLogsTests: XCTestCase { let f = parseFrame(bytes(sendHistoricalHex), family: .whoop5) XCTAssertEqual(f.typeName, "CONSOLE_LOGS") XCTAssertEqual(f.crcOK, true) - XCTAssertEqual(f.parsed["record_index"]?.intValue, 684) + XCTAssertEqual(f.parsed["console_sequence"]?.intValue, 172) + XCTAssertEqual(f.parsed["console_header_byte_10"]?.intValue, 2) + XCTAssertNil(f.parsed["record_index"]) XCTAssertEqual(f.parsed["unix"]?.intValue, 1783805010) XCTAssertEqual(f.parsed["subsec"]?.intValue, 29491) XCTAssertEqual(f.parsed["log"]?.stringValue, @@ -37,7 +39,7 @@ final class Whoop5ConsoleLogsTests: XCTestCase { } /// The next two chunks of the same stream: one log line split mid-word ("…response a" | "ck, - /// start burst…") across consecutive record_index values — the reassembly contract. + /// start burst…") across consecutive sequence values. private let splitLineAHex = "aa014400010030b132ad020052b4526a337334000131392c203134363535323131393a20424c453a2068" + "697374207472616e7366657220737461727420726573706f6e7365206100324a7906" @@ -45,19 +47,45 @@ final class Whoop5ConsoleLogsTests: XCTestCase { "aa014400010030b132ae020052b4526a3373340001636b2c2073746172742062757273740a2031392c20" + "3134363535343633303a20424c453a20486973746f727920627572737400e67d611f" - func testConsecutiveChunksCarryContiguousIndices() { + func testConsecutiveChunksCarryContiguousSequences() { let a = parseFrame(bytes(splitLineAHex), family: .whoop5) let b = parseFrame(bytes(splitLineBHex), family: .whoop5) XCTAssertEqual(a.crcOK, true) XCTAssertEqual(b.crcOK, true) - XCTAssertEqual(a.parsed["record_index"]?.intValue, 685) - XCTAssertEqual(b.parsed["record_index"]?.intValue, 686) + XCTAssertEqual(a.parsed["console_sequence"]?.intValue, 173) + XCTAssertEqual(b.parsed["console_sequence"]?.intValue, 174) XCTAssertEqual(a.parsed["log"]?.stringValue, "19, 146552119: BLE: hist transfer start response a") XCTAssertEqual(b.parsed["log"]?.stringValue, "ck, start burst\n 19, 146554630: BLE: History burst") } + /// Synthetic headers reproduce the independently captured 255 -> 0 wrap while @10 stays 2. + /// No raw user capture is embedded. A u16 read would report 767 -> 512 instead. + func testConsoleSequenceWrapDoesNotCarryIntoHeaderByte() { + for sequence: UInt8 in [254, 255, 0, 1] { + var frame = consoleFrame(textBytes: Array("fragment".utf8)) + frame[9] = sequence + frame[10] = 2 + let parsed = parseFrame(frame, family: .whoop5).parsed + XCTAssertEqual(parsed["console_sequence"]?.intValue, Int(sequence)) + XCTAssertEqual(parsed["console_header_byte_10"]?.intValue, 2) + XCTAssertNil(parsed["record_index"], "console chunks have no monotonic historical index") + XCTAssertEqual(parsed["log"]?.stringValue, "fragment") + } + } + + func testConsoleHeaderByteDoesNotChangeSequence() { + for headerByte: UInt8 in [0, 2, 255] { + var frame = consoleFrame(textBytes: Array("fragment".utf8)) + frame[9] = 7 + frame[10] = headerByte + let parsed = parseFrame(frame, family: .whoop5).parsed + XCTAssertEqual(parsed["console_sequence"]?.intValue, 7) + XCTAssertEqual(parsed["console_header_byte_10"]?.intValue, Int(headerByte)) + } + } + /// A truncated frame (header only, no text region) must not decode a log — and must not crash. func testHeaderOnlyFrameYieldsNoLog() { let full = bytes(sendHistoricalHex) 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..= 0 && $0 < (1 << 20) }, + "every sample must sit in the 20-bit ADC domain") + // A legacy row, whose base was never stored, is honestly unreconstructable rather than silently + // reconstructed from a fabricated zero. + XCTAssertNil(ppgWaveformAbsolute(baseCode: nil, deltas: row.samples)) + } + func testExtractHistoricalStreamsCarriesEachBurstIndexWithItsWaveform() { let parsed = [v26Hex, v26HexChannel46].map { parseFrame(bytes($0), family: .whoop5) } let streams = extractHistoricalStreams(parsed, @@ -146,3 +171,117 @@ final class Whoop5PpgWaveformTests: XCTestCase { XCTAssertEqual(round.ppgWaveform, withBurst.ppgWaveform) } } + +/// #2019: the per-session optical census. Mirrored by the Kotlin `PpgWaveformCensusTest` against the +/// SAME literals, so the two platforms cannot report the same offload differently. +final class PpgWaveformCensusTests: XCTestCase { + + /// A session with no v26 windows says nothing at all: a 4.0, or a 5/MG that banked none, must not + /// print a census of zero. + func testNoWindowsIsSilent() { + XCTAssertNil(ppgWaveformCensusLine(windows: 0, withBase: 0, saturatedWindows: 0, + baseMin: nil, baseMax: nil)) + } + + /// The ordinary healthy session: every window carried a base, none saturated. + func testEveryWindowReconstructable() { + XCTAssertEqual( + ppgWaveformCensusLine(windows: 412, withBase: 412, saturatedWindows: 0, + baseMin: 361_204, baseMax: 379_881), + "Backfill: v26 optical census: 412 window(s), 412 with a base, 0 saturated, base 361204..379881") + } + + /// `withBase` below the window count is the signal that matters: those windows can never be + /// reconstructed, and before this line existed they were banked with nothing said about it. + func testWindowsMissingABaseAreVisible() { + XCTAssertEqual( + ppgWaveformCensusLine(windows: 10, withBase: 7, saturatedWindows: 0, + baseMin: 100, baseMax: 200), + "Backfill: v26 optical census: 10 window(s), 7 with a base, 0 saturated, base 100..200") + } + + /// A saturated window earns the caveat inline, because the caveat is the reason to distrust the + /// reconstruction and it is worthless if it only lives in a doc comment. + func testSaturationCarriesItsCaveat() { + let line = ppgWaveformCensusLine(windows: 5, withBase: 5, saturatedWindows: 2, + baseMin: 1, baseMax: 2) + XCTAssertEqual(line, "Backfill: v26 optical census: 5 window(s), 5 with a base, 2 saturated, " + + "base 1..2 (a saturated window reconstructs only approximately)") + } + + /// An absent range reads as absent rather than as a fabricated zero. + func testAbsentBaseRangeSaysSo() { + XCTAssertEqual( + ppgWaveformCensusLine(windows: 3, withBase: 0, saturatedWindows: 0, + baseMin: nil, baseMax: nil), + "Backfill: v26 optical census: 3 window(s), 0 with a base, 0 saturated, base n/a") + } + + /// Both i16 rails count, and an ordinary delta does not. + func testSaturationIsBothRails() { + XCTAssertTrue(isSaturatedPpgDelta(-32_768)) + XCTAssertTrue(isSaturatedPpgDelta(32_767)) + XCTAssertFalse(isSaturatedPpgDelta(-1_833)) + XCTAssertFalse(isSaturatedPpgDelta(0)) + } +} + +/// #2019 follow-up: the per-burst counter is a u16, not a u8. Mirrored by the Kotlin +/// `Whoop5BurstIndexWidthTest` against the same synthetic frames. +final class Whoop5BurstIndexWidthTests: XCTestCase { + + /// A counter past 255 is the whole reason for the width. Read as a u8 this frame reports 0, which is + /// the sentinel meaning "absent", so a wrapped counter would not merely be wrong, it would vanish. + func testACounterPastAByteSurvives() { + let f = v26Frame(burstLow: 0x00, burstHigh: 0x01) // 256 + let p = parseFrame(f, family: .whoop5) + XCTAssertEqual(p.parsed["burst_index"]?.intValue, 256) + } + + /// And the two readings agree exactly below 256, which is why every fixture we hold is unmoved: our + /// captures carry byte 22 = 0, so they cannot tell a u16 from a u8 beside a constant zero. + func testTheTwoReadingsAgreeBelow256() { + for low in [1, 2, 65, 255] { + let p = parseFrame(v26Frame(burstLow: UInt8(low), burstHigh: 0), family: .whoop5) + XCTAssertEqual(p.parsed["burst_index"]?.intValue, low, "index \(low) must be unchanged") + } + } + + /// Zero stays the absent sentinel across both bytes, so a widened read cannot invent a burst. + func testZeroIsStillAbsent() { + let p = parseFrame(v26Frame(burstLow: 0, burstHigh: 0), family: .whoop5) + XCTAssertNil(p.parsed["burst_index"]) + } + + /// The case where the two readings DISAGREE, pinned so the choice is deliberate rather than + /// incidental. A low byte of 0 with a high byte set reads as absent under a u8 and as 1280 under a + /// u16. If the high byte really is the counter's, 1280 is right and the u8 lost the burst entirely. + /// If it is a separate field, this is where a fabricated index would come from, which is why the + /// falsifiable prediction is a persisted index jumping by a multiple of 256. + func testTheDivergentCaseIsPinned() { + let p = parseFrame(v26Frame(burstLow: 0, burstHigh: 5), family: .whoop5) + XCTAssertEqual(p.parsed["burst_index"]?.intValue, 1280) + } + + /// A v26 frame with the counter bytes planted, sealed exactly as a strap seals one. + private func v26Frame(burstLow: UInt8, burstHigh: UInt8) -> [UInt8] { + var f = bytes(v26Hex) + f[21] = burstLow + f[22] = burstHigh + let payloadEnd = f.count - 4 + let c = crc32(f, 8, payloadEnd) + for b in 0..<4 { f[payloadEnd + b] = UInt8((c >> (8 * UInt32(b))) & 0xFF) } + return f + } + + private let v26Hex = + "aa015000010035412f1a80ad418401f0a3266aae470100c3c5050068faccfa8dfb46fc8bfd4c" + + "febafedafe6dff56ffd5fffbff37ff6afce5f9d7f8dffa5efc98fddbfe5afe84fe15ff5cff40" + + "5fb33c50080101006cb67c17" + + private func bytes(_ s: String) -> [UInt8] { + stride(from: 0, to: s.count, by: 2).map { + UInt8(s[s.index(s.startIndex, offsetBy: $0)...s.index(s.startIndex, offsetBy: $0 + 1)], radix: 16)! + } + } +} diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Whoop5RRTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Whoop5RRTests.swift new file mode 100644 index 0000000000..3a2f695481 --- /dev/null +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Whoop5RRTests.swift @@ -0,0 +1,96 @@ +import XCTest +@testable import WhoopProtocol + +final class Whoop5RRTests: XCTestCase { + private struct Oracle: Decodable { + struct Case: Decodable { let ticks: UInt16; let milliseconds: Int } + struct Policy: Decodable { let model: String?; let brand: String?; let tagged: Bool; let strict: Bool } + struct Wire: Decodable { let name: String; let hex: String; let raw: [Int]; let ms: [Int]; let channel: Int } + let cases: [Case] + let policy_cases: [Policy] + let wire_cases: [Wire] + let milliseconds_u16le_fnv1a64: String + } + private func oracle() throws -> Oracle { + let url = try XCTUnwrap(Bundle.module.url(forResource: "whoop5_rr_oracle", withExtension: "json")) + return try JSONDecoder().decode(Oracle.self, from: Data(contentsOf: url)) + } + + func testAllUnsignedWordsMatchExecutedSwiftOracle() throws { + let expected = try oracle() + var hash: UInt64 = 14695981039346656037 + for ticks in UInt16.min...UInt16.max { + let ms = Whoop5RR.milliseconds(ticks: ticks) + for byte in [ms & 255, ms >> 8] { hash = (hash ^ UInt64(byte)) &* 1099511628211 } + } + XCTAssertEqual(String(format: "%016llx", hash), expected.milliseconds_u16le_fnv1a64) + for c in expected.cases { XCTAssertEqual(Whoop5RR.milliseconds(ticks: c.ticks), c.milliseconds) } + for c in expected.policy_cases { + XCTAssertEqual(Whoop5RR.usesCanonicalSource(model: c.model, brand: c.brand, + hasTaggedIntervals: c.tagged), c.strict) + } + } + + func testWireBoundsRawUnitsAndExtractedProvenance() throws { + for c in try oracle().wire_cases { + let chars = Array(c.hex) + let bytes = stride(from: 0, to: chars.count, by: 2).map { UInt8(String(chars[$0...$0+1]), radix: 16)! } + let f = parseFrame(bytes, family: .whoop5) + XCTAssertTrue(f.ok, c.name) + XCTAssertEqual(f.crcOK, true, c.name) + XCTAssertEqual(f.parsed["rr_raw_ticks"]?.intArrayValue, c.raw, c.name) + XCTAssertEqual(f.parsed["rr_intervals"]?.intArrayValue, c.ms, c.name) + XCTAssertEqual(f.parsed["rr_source_channel"]?.intValue, c.channel, c.name) + let streams = c.channel == 5 + ? extractHistoricalStreams([f], deviceClockRef: 0, wallClockRef: 0) + : extractStreams([f], deviceClockRef: 0, wallClockRef: 0) + XCTAssertEqual(streams.rr.map(\.rrMs), c.ms, c.name) + XCTAssertEqual(streams.rr.compactMap { $0.srcChannel?.rawValue }, c.ms.map { _ in c.channel }, c.name) + } + } + + func testTransportCodes() { + XCTAssertEqual(RRSourceChannel.allCases.map(\.rawValue), Array(1...7)) + } + + /// The table below is the parity contract for the "this night cannot be scored" explanation. The + /// Kotlin `legacyUnscorableNight` test carries the SAME rows, so the two platforms cannot start + /// explaining a different set of nights to the wearer. + func testLegacyUnscorableNight() { + let cases: [(String, Bool, String, String?, String?, Double?, Double?, Bool)] = [ + // (strictWhoop5, day, firstRecordedDay, firstScorableDay, avgHrv, totalSleepMin) -> claimed + // A confirmed WHOOP 5 recording since the 1st, labelled era starting on the 10th: the 9th is the + // case this explains. + ("W5, staged night inside the unlabelled era", true, "2026-08-09", "2026-08-01", "2026-08-10", nil, 431.0, true), + // The same night once it scored: there is nothing to explain. + ("W5, that night scored", true, "2026-08-09", "2026-08-01", "2026-08-10", 48.0, 431.0, false), + // Nothing staged, so an empty HRV is far more likely an unworn strap. Never blame the units. + ("W5, no night staged", true, "2026-08-09", "2026-08-01", "2026-08-10", nil, 0.0, false), + ("W5, sleep unknown", true, "2026-08-09", "2026-08-01", "2026-08-10", nil, nil, false), + // On and after the first scorable day the general statement stops being true. + ("W5, the labelled day itself", true, "2026-08-10", "2026-08-01", "2026-08-10", nil, 431.0, false), + ("W5, inside the labelled era", true, "2026-08-11", "2026-08-01", "2026-08-10", nil, 431.0, false), + // BEFORE the strap ever recorded: imported history, which never had beats to lose. + ("W5, imported night predating the strap", true, "2026-07-30", "2026-08-01", "2026-08-10", nil, 431.0, false), + ("W5, the first recorded day itself", true, "2026-08-01", "2026-08-01", "2026-08-10", nil, 431.0, true), + // A device that has banked no beats at all can never have lost any to the units. + ("W5, nothing recorded", true, "2026-08-09", nil, nil, nil, 431.0, false), + ("W5, nothing recorded, empty key", true, "2026-08-09", "", nil, nil, 431.0, false), + // Recording, but never synced since the units were corrected: every staged night in the era. + ("W5, nothing labelled banked", true, "2026-08-09", "2026-08-01", nil, nil, 431.0, true), + ("W5, nothing labelled, empty key", true, "2026-08-09", "2026-08-01", "", nil, 431.0, true), + // The policy is not applied to this device, so the explanation would be a lie. + ("WHOOP 4 night", false, "2026-08-09", "2026-08-01", "2026-08-10", nil, 431.0, false), + // Day keys are yyyy-MM-dd, so string order is date order across a month and a year boundary. + ("W5, previous month", true, "2026-07-31", "2026-07-01", "2026-08-01", nil, 400.0, true), + ("W5, next year", true, "2027-01-01", "2026-01-01", "2026-12-31", nil, 400.0, false), + ] + for (name, strict, day, recorded, scorable, hrv, sleep, want) in cases { + XCTAssertEqual( + Whoop5RR.legacyUnscorableNight(strictWhoop5: strict, day: day, firstRecordedDay: recorded, + firstScorableDay: scorable, + avgHrv: hrv, totalSleepMin: sleep), + want, name) + } + } +} diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Whoop5RawOpticalTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Whoop5RawOpticalTests.swift index f12e34af88..1f537d7309 100644 --- a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Whoop5RawOpticalTests.swift +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Whoop5RawOpticalTests.swift @@ -426,4 +426,29 @@ final class Whoop5RawOpticalTests: XCTestCase { out[end + 3] = UInt8((payloadCRC >> 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/WhoopProtocol/Tests/WhoopProtocolTests/Whoop5VariantTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Whoop5VariantTests.swift index 8961d0391a..3ec73fb17d 100644 --- a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Whoop5VariantTests.swift +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Whoop5VariantTests.swift @@ -51,4 +51,31 @@ final class Whoop5VariantTests: XCTestCase { XCTAssertEqual(Whoop5Variant.fiveZero.label, "5.0") XCTAssertEqual(Whoop5Variant.unknown.label, "—") } + + /// The field capture that exposed the gap: serial prefix "MGB", hardware revision "WS50_r03" — neither + /// matches `mgSerialPrefix` ("5AM") nor `fiveZeroHardwareIdToken` ("WG50"), so every heuristic returned + /// unknown for a strap whose own model number said MG. + func testRealMGResolvesFromItsModelNumberWhenPrefixesDoNot() { + XCTAssertEqual(Whoop5Variant.from(serial: "MGB0779473", hardwareRevision: "WS50_r03"), .unknown) + XCTAssertEqual( + Whoop5Variant.from(serial: "MGB0779473", hardwareRevision: "WS50_r03", modelNumber: "MG"), .mg) + } + + func testModelNumberIsTrimmedAndCaseInsensitive() { + XCTAssertEqual(Whoop5Variant.from(serial: nil, modelNumber: " mg "), .mg) + } + + /// Only MG is claimed. No 5.0 has been observed reporting a model number, so an unrecognised one must + /// fall through to the existing heuristics rather than inventing a verdict. + func testAnUnknownModelNumberDoesNotOverrideTheHeuristics() { + XCTAssertEqual(Whoop5Variant.from(serial: "5AG12345678", modelNumber: "WHOOP5"), .fiveZero) + XCTAssertEqual(Whoop5Variant.from(serial: nil, modelNumber: "WHOOP5"), .unknown) + } + + /// The model number outranks the contradiction guard: two heuristics disagreeing is a reason not to + /// guess, but it is not a reason to ignore the strap stating its own model. + func testModelNumberBeatsTheContradictionGuard() { + XCTAssertEqual( + Whoop5Variant.from(serial: "5AM12345678", hardwareRevision: "WG50_r52", modelNumber: "MG"), .mg) + } } 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 1ac4d04088..b03a34e0c2 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() @@ -875,6 +875,222 @@ extension WhoopStore { t.add(column: "burstIndex", .integer) } } + // v40 (#1636): keep the nightly ABSOLUTE skin temperature beside the deviation derived from it. + // The engine computed this mean on every pass and discarded it the moment `skinTempDevC` was + // taken, so the app could show "+0.5 Δ°C" with no way to learn what it moved from — and a febrile + // night reads as a small delta where the absolute reads as a fever. Additive and nullable: old + // rows stay nil, and nothing reads it as a gate. Existing nights refill on the next scoring pass + // because the value is re-derived from raw `skinTempSample` rows that are still on disk — no + // separate backfill, and therefore no second implementation that could disagree with the live one. + migrator.registerMigration("v40-daily-skin-temp-absolute") { db in + try db.alter(table: "dailyMetric") { t in + 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. + } + // #2019: carry the v26 optical window's ABSOLUTE base code beside its deltas. + // + // The 25-sample window is one absolute ADC code plus 24 deltas, and only the deltas were read, so + // the stored `samples` blob is a derivative and the DC level was thrown away. Nullable and + // additive: an existing row keeps its deltas and gets a null base, which is the true statement + // about it. A delta series cannot be inverted without the base, so those windows have no + // recoverable absolute level and no backfill can invent one. Twin of Room's MIGRATION_37_38. + migrator.registerMigration("v44-ppg-waveform-base-code") { db in + try db.alter(table: "ppgWaveformSample") { t in + t.add(column: "baseCode", .integer) + } + } + // Source promotions change scoring without adding rows. Cover their cache witnesses so + // legacy/non-WHOOP installs do not scan the entire R-R table on every analysis tick. + migrator.registerMigration("v45-rr-source-index") { db in + try db.create(index: "rrInterval_source_suspect", on: "rrInterval", columns: ["srcChannel", "tsSuspect"]) + } + // v46-lift-log: the in-app strength log — saved programs and the sessions run from them. + // + // NOOP can already IMPORT a lifting history (Hevy CSV / Liftosaur JSON via LiftingImporter), but + // that path collapses each workout to a session summary — volume load, set count, top set — + // because there has never been anywhere to put an individual set. These five tables are that + // place. A logged session still lands in `workout` like any other (so Workouts / Today / Effort + // are unchanged); these rows hang beside it and carry the detail the workout row cannot. + // + // Deliberately NO load/strain column anywhere here: `workout.strain` stays the HR-measured + // number the analytics engine computes, and lifting volume is derived on read from the sets + // themselves. Nothing in this migration feeds a score. + // + // Shape notes: + // • Five flat, deviceId-keyed tables joined manually by id — this schema has no foreign keys + // anywhere and does not start here. Every table carries `deviceId` so `deleteAllData` + // (DeviceRegistryStore.deviceScopedTables) clears the whole feature; a child table keyed + // only by its parent's id would silently survive a delete. + // • `id` is a client-generated TEXT identifier so a row can be edited/deleted by id and a + // backup round-trips — the labMarker (v17) idiom. + // • `liftSession` is keyed to its workout row by the same natural key the workout table uses, + // (deviceId, startTs, sport), enforced UNIQUE. One session per workout row, no orphan pairs. + // • Sets are ROWS, not a JSON blob on the session. "What did I lift for this exercise last + // time" is the read the whole feature exists for, and it must be answerable by an index + // rather than by decoding every session ever recorded. + // • Timestamps are unix seconds (Int) like every other table; booleans are `.integer` 0/1, + // never `.boolean` (GRDB declares that BOOLEAN → NUMERIC affinity, which diverges from + // Room's INTEGER — see `grdb-boolean-affinity` in schema_oracle.json). + // • Every create here is `ifNotExists` — tables AND indexes, consistently — so the + // migration is a no-op against a database that already carries the schema (the v38 + // idiom). GRDB keys applied migrations by identifier, so a fork carrying these tables + // under a different one converges instead of failing the migrator. + // + // Pinned in schema_oracle.json as `ios_only` with a stated reason: the Room twin is a follow-up, + // not part of this change. + migrator.registerMigration("v46-lift-log") { db in + // The user's own exercise vocabulary. NOOP ships NO exercise catalogue: an exercise is + // whatever the user typed, and it is remembered here the first time they use it so it can + // be offered back later with the muscle group they gave it. That consistency is what makes + // a per-muscle-group rollup honest — the same name always resolves to the same group, + // rather than to whatever was typed on the day. + try db.create(table: "liftExercise", options: [.ifNotExists]) { t in + t.column("id", .text).primaryKey() + t.column("deviceId", .text).notNull() + t.column("name", .text).notNull() + // Canonical LiftMuscle tokens, never free text — a rollup only means something if + // the same muscle always lands in the same bucket. Nullable so an exercise can be + // recorded before it has been classified. + t.column("primaryMuscle", .text) + // Comma-joined LiftMuscle tokens, or NULL. Short, never queried alone, trivially + // mirrorable in Room — a join table would be three tables of ceremony for a list of + // two or three. + t.column("secondaryMuscles", .text) + t.column("createdAt", .integer).notNull() // unix seconds + t.column("lastUsedTs", .integer) // unix seconds; recency for the picker + } + // One entry per name per device, so recording a name twice updates rather than duplicates. + try db.create(index: "idx_liftExercise_natural", on: "liftExercise", + columns: ["deviceId", "name"], options: [.unique, .ifNotExists]) + + // A saved program: "Upper A", "Lower A". Held separately from the sessions run from it so + // editing a program never rewrites history — a session snapshots the name it ran under. + try db.create(table: "liftProgram", options: [.ifNotExists]) { t in + t.column("id", .text).primaryKey() + t.column("deviceId", .text).notNull() + t.column("name", .text).notNull() + t.column("note", .text) + t.column("createdAt", .integer).notNull() // unix seconds + t.column("updatedAt", .integer).notNull() // unix seconds; drives most-recent-first + t.column("archived", .integer).notNull().defaults(to: 0) // 0/1, hidden not deleted + } + // Programs list most-recently-touched first. + try db.create(index: "idx_liftProgram_device_updatedAt", + on: "liftProgram", columns: ["deviceId", "updatedAt"], options: [.ifNotExists]) + + // One exercise line inside a program: the TARGETS (what you intend to do). The session + // records what actually happened. `ord` is the position in the program; deliberately NOT + // unique with programId, because reordering two lines would collide mid-swap on a unique + // index and the id primary key already guarantees row identity. + try db.create(table: "liftProgramItem", options: [.ifNotExists]) { t in + t.column("id", .text).primaryKey() + t.column("deviceId", .text).notNull() + t.column("programId", .text).notNull() + t.column("ord", .integer).notNull() + t.column("exercise", .text).notNull() + // No muscle column here on purpose: `liftExercise` owns an exercise's classification, + // and duplicating it on the program line is a second place for it to drift. + t.column("targetSets", .integer) + t.column("targetRepsLow", .integer) // rep range low end, e.g. 8 of "8-10" + t.column("targetRepsHigh", .integer) // rep range high end + t.column("targetRpe", .double) // 1-10, the user's own scale + // A program line plans a WEIGHT, not just a rep range — it is the number actually + // written on a program. Kilograms, like every stored weight; display converts. + t.column("targetWeightKg", .double) + t.column("restSec", .integer) // intended rest after each set + t.column("note", .text) // the user's technique cue, verbatim + } + try db.create(index: "idx_liftProgramItem_device", on: "liftProgramItem", + columns: ["deviceId"], options: [.ifNotExists]) + try db.create(index: "idx_liftProgramItem_program_ord", on: "liftProgramItem", + columns: ["programId", "ord"], options: [.ifNotExists]) + + // One gym session. (deviceId, startTs, sport) is the workout table's natural key, so this + // row and its `workout` row identify each other without a foreign key. `programId` is + // nullable: a session can be logged freehand with no program behind it. + try db.create(table: "liftSession", options: [.ifNotExists]) { t in + t.column("id", .text).primaryKey() + t.column("deviceId", .text).notNull() + t.column("startTs", .integer).notNull() // unix seconds; matches workout.startTs + t.column("endTs", .integer) // nil while the session is still running + t.column("sport", .text).notNull() // matches workout.sport + t.column("programId", .text) // nil for a freehand session + t.column("programName", .text) // snapshot: renaming a program never rewrites history + // 0-10 Borg CR10, as rated by the user. Foster's session load is sRPE x duration, so + // the rating has to be a number in its own column or the metric cannot be derived at + // all. Nullable: a session whose rating was skipped simply has no session load, and a + // 0 would read as "effortless" rather than "unrated". + t.column("sessionRpe", .double) + t.column("note", .text) + } + // One lift session per workout row, and the index that serves date-ordered history reads. + try db.create(index: "idx_liftSession_natural", on: "liftSession", + columns: ["deviceId", "startTs", "sport"], options: [.unique, .ifNotExists]) + + // One set. `ord` is the position within the whole session (so the tap-through order is + // reconstructible); `setIndex` is 1-based within its exercise (so "set 3 of 4" survives). + // `exercise` is denormalised rather than pointing at a program item, because a session must + // stay readable after its program is edited or deleted. + try db.create(table: "liftSet", options: [.ifNotExists]) { t in + t.column("id", .text).primaryKey() + t.column("deviceId", .text).notNull() + t.column("sessionId", .text).notNull() + t.column("ord", .integer).notNull() // order within the session + t.column("exercise", .text).notNull() + // The classification AS IT WAS when the set was logged, snapshotted like `exercise` + // itself. Reclassifying an exercise later is an explicit bulk action, not a silent + // rewrite of what past weeks were counted as. + t.column("primaryMuscle", .text) + t.column("secondaryMuscles", .text) + t.column("setIndex", .integer).notNull() // 1-based within the exercise + t.column("weightKg", .double) // kilograms; display units convert + t.column("reps", .integer) + t.column("rpe", .double) // 1-10 as rated by the user + t.column("isWarmup", .integer).notNull().defaults(to: 0) // 0/1; warmups excluded from volume + t.column("startTs", .integer) // when the set began (unix seconds) + t.column("endTs", .integer) // when it ended + t.column("restSec", .integer) // rest ACTUALLY taken after this set + t.column("note", .text) + } + // "What did I lift for this exercise last time" — the read the feature exists for. + try db.create(index: "idx_liftSet_device_exercise", on: "liftSet", + columns: ["deviceId", "exercise"], options: [.ifNotExists]) + // Replaying one session in order. + try db.create(index: "idx_liftSet_session_ord", on: "liftSet", + columns: ["sessionId", "ord"], options: [.ifNotExists]) + } return migrator } } diff --git a/Packages/WhoopStore/Sources/WhoopStore/DatabaseIntegrity.swift b/Packages/WhoopStore/Sources/WhoopStore/DatabaseIntegrity.swift index 63b5ea9a1c..ee8a2c0899 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/DatabaseIntegrity.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/DatabaseIntegrity.swift @@ -60,4 +60,38 @@ public enum DatabaseIntegrity { if rows.count == 1, rows[0].lowercased() == "ok" { return nil } return rows.first { $0.lowercased() != "ok" } ?? "quick_check returned no verdict" } + + /// SQLite's banner line, which names the database being reported on and says nothing about what is + /// wrong with it. quick_check emits it ahead of the real diagnosis, joined into the SAME row. + private static func isBanner(_ line: String) -> Bool { + line.hasPrefix("*** in database") && line.hasSuffix("***") + } + + /// The part of a `verdict` worth showing a person, as one line. + /// + /// `verdict` is kept VERBATIM on purpose: it is the forensic value, and a reporter pasting it into + /// an issue should paste what SQLite actually said rather than a summary of it. (It reaches no log + /// today: the only copy is the one shown.) What SQLite says begins with a banner and a newline: + /// + /// *** in database main *** + /// Page 5 is never used + /// + /// Rendered into a sentence, that spends the whole line on "*** in database main ***" and pushes the + /// only informative half ("Page 5 is never used") out of sight. This drops the banner, joins what is + /// left onto one line, and caps the length, so the detail a person sees is the diagnosis. + /// + /// Falls back to the raw verdict, newlines flattened, whenever stripping would leave nothing: a + /// verdict made ENTIRELY of banner is still better shown than shown as an empty parenthesis. + /// Mirrored by Android's `DataBackup.readableComplaint` on the same golden vectors. + public static func readableComplaint(_ verdict: String, limit: Int = 140) -> String { + let kept = verdict.split(separator: "\n", omittingEmptySubsequences: false) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty && !isBanner($0) } + let joined = kept.isEmpty + ? verdict.replacingOccurrences(of: "\n", with: " ").trimmingCharacters(in: .whitespaces) + : kept.joined(separator: "; ") + let cap = max(1, limit) + guard joined.count > cap else { return joined } + return String(joined.prefix(cap - 1)).trimmingCharacters(in: .whitespaces) + "\u{2026}" + } } diff --git a/Packages/WhoopStore/Sources/WhoopStore/DeviceRegistryStore.swift b/Packages/WhoopStore/Sources/WhoopStore/DeviceRegistryStore.swift index 7788dc3642..63996012c3 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/DeviceRegistryStore.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/DeviceRegistryStore.swift @@ -23,9 +23,23 @@ public struct DeviceRegistryStore: Sendable { } } + /// The one query that answers "which strap is active". + /// + /// Shared rather than retyped because the answer is load-bearing beyond this accessor: the WHOOP 5 + /// R-R policy resolves a legacy `my-whoop` alias through it, so this decides whose unit rules a + /// wearer's older history is judged by. Two copies of that could drift into disagreeing about which + /// strap a night belongs to, and the Android twin already keeps it in exactly one place + /// (`DeviceRegistryDao`). + /// + /// `LIMIT 1` with no `ORDER BY` is deliberate but only safe because single-active is an invariant + /// the writers maintain (demote every active row, then promote one, in that order). If two rows were + /// ever active at once the pick would be arbitrary, so the invariant is what makes this total, not + /// the query. + static let activeDeviceIdSQL = "SELECT id FROM pairedDevice WHERE status = 'active' LIMIT 1" + public func activeDeviceId() throws -> String? { try dbQueue.read { db in - try String.fetchOne(db, sql: "SELECT id FROM pairedDevice WHERE status = 'active' LIMIT 1") + try String.fetchOne(db, sql: Self.activeDeviceIdSQL) } } @@ -108,6 +122,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 +153,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", @@ -142,6 +160,13 @@ public struct DeviceRegistryStore: Sendable { // so forgetting that source must clear them — otherwise an imported phone's hour-by-hour step // history survives the delete (the same privacy defect this list exists to close). "appleStepHour", + // v46-lift-log: every table in the strength log is deviceId-keyed, including the child rows + // (liftProgramItem, liftSet). They are joined to their parents by id, not by a foreign key, so + // if the children were left off this list a "delete all of this device's data" would strip the + // programs and sessions and leave every exercise line and every logged set behind — the exact + // privacy defect this list exists to close, and one the deviceId-column guard test could not + // catch for a child table keyed only by its parent. + "liftExercise", "liftProgram", "liftProgramItem", "liftSession", "liftSet", ] /// Permanently delete every recorded sample/derived row belonging to one device, across all @@ -199,9 +224,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/JournalWorkoutAppleCache.swift b/Packages/WhoopStore/Sources/WhoopStore/JournalWorkoutAppleCache.swift index dbe4d57c1b..f4ec3dece9 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/JournalWorkoutAppleCache.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/JournalWorkoutAppleCache.swift @@ -179,9 +179,9 @@ extension WhoopStore { } } - /// Delete one source's workouts of a given sport whose startTs is in [from, to] - /// (makes detected-workout re-derivation idempotent). Returns rows deleted. - /// Port of Android WhoopDao.deleteWorkoutsBySport (#78). + /// Delete one source's workouts of a given sport whose startTs is in [from, to]. Automatic + /// detected-workout reconciliation no longer calls this; explicit edit/dismiss paths still do. + /// Returns rows deleted. Port of Android WhoopDao.deleteWorkoutsBySport (#78/#2187). @discardableResult public func deleteWorkouts(deviceId: String, sport: String, from: Int, to: Int) async throws -> Int { try syncWrite { db in diff --git a/Packages/WhoopStore/Sources/WhoopStore/LiftLogStore.swift b/Packages/WhoopStore/Sources/WhoopStore/LiftLogStore.swift new file mode 100644 index 0000000000..268b5e33de --- /dev/null +++ b/Packages/WhoopStore/Sources/WhoopStore/LiftLogStore.swift @@ -0,0 +1,749 @@ +import Foundation +import GRDB + +// MARK: - v45 store: the in-app strength log (programs, sessions, sets) +// +// Mirrors the established LabMarkerStore / AppleStepHourStore idiom precisely: plain Codable row +// structs, raw `Row` fetch + manual decode, idempotent upserts keyed by the natural key, all GRDB +// work through the actor's `syncWrite` / `syncRead` helpers. +// +// The four tables are flat and deviceId-keyed, joined manually by id — this schema carries no +// foreign keys. A logged session also exists as an ordinary `workout` row; the two find each other +// through the workout table's natural key (deviceId, startTs, sport), which `liftSession` pins with +// a UNIQUE index. +// +// Nothing here computes or stores a load score. `workout.strain` remains the HR-measured number the +// analytics engine produces; volume is derived on read from the sets, where the arithmetic is +// visible (`LiftSetRow.volumeKg`). + +// MARK: - Rows + +/// One exercise in the user's own vocabulary. NOOP ships no catalogue: an exercise is whatever the +/// user typed, remembered here the first time it is used so it can be offered back with the muscle +/// group they gave it. Keeping the name→group mapping in one place is what makes a per-muscle-group +/// rollup mean the same thing from one session to the next. +public struct LiftExerciseRow: Equatable, Codable, Sendable { + public var id: String + public var deviceId: String + /// Exactly as the user typed it. The natural key is (deviceId, name), so case and spacing are + /// preserved rather than normalised — their vocabulary, shown back verbatim. + public var name: String + /// Canonical classification. Nil until the user has classified this exercise. + public var primaryMuscle: LiftMuscle? + /// Also-worked muscles, in the order the user listed them. Never contains `primaryMuscle`. + public var secondaryMuscles: [LiftMuscle] + /// Unix seconds. + public var createdAt: Int + /// Unix seconds; most-recently-used floats to the top of the picker. Nil until first used. + public var lastUsedTs: Int? + + public init( + id: String, + deviceId: String, + name: String, + primaryMuscle: LiftMuscle?, + secondaryMuscles: [LiftMuscle] = [], + createdAt: Int, + lastUsedTs: Int? + ) { + self.id = id + self.deviceId = deviceId + self.name = name + self.primaryMuscle = primaryMuscle + self.secondaryMuscles = LiftMuscle.decodeList( + LiftMuscle.encodeList(secondaryMuscles, excluding: primaryMuscle)) + self.createdAt = createdAt + self.lastUsedTs = lastUsedTs + } + + static func decode(_ row: Row) -> LiftExerciseRow { + LiftExerciseRow( + id: row["id"], + deviceId: row["deviceId"], + name: row["name"], + primaryMuscle: LiftMuscle(rawValue: row["primaryMuscle"] ?? ""), + secondaryMuscles: LiftMuscle.decodeList(row["secondaryMuscles"]), + createdAt: row["createdAt"], + lastUsedTs: row["lastUsedTs"] + ) + } +} + +/// A saved program — the reusable plan ("Upper A"), not a session run from it. +public struct LiftProgramRow: Equatable, Codable, Sendable { + public var id: String + public var deviceId: String + public var name: String + public var note: String? + /// Unix seconds. + public var createdAt: Int + /// Unix seconds. Drives most-recently-touched-first ordering in the programs list. + public var updatedAt: Int + /// Hidden from the picker but kept, so old sessions still resolve their program. + public var archived: Bool + + public init( + id: String, + deviceId: String, + name: String, + note: String?, + createdAt: Int, + updatedAt: Int, + archived: Bool + ) { + self.id = id + self.deviceId = deviceId + self.name = name + self.note = note + self.createdAt = createdAt + self.updatedAt = updatedAt + self.archived = archived + } + + static func decode(_ row: Row) -> LiftProgramRow { + LiftProgramRow( + id: row["id"], + deviceId: row["deviceId"], + name: row["name"], + note: row["note"], + createdAt: row["createdAt"], + updatedAt: row["updatedAt"], + archived: row["archived"] + ) + } +} + +/// One exercise line in a program: the TARGETS. What actually happened lives in `LiftSetRow`. +public struct LiftProgramItemRow: Equatable, Codable, Sendable { + public var id: String + public var deviceId: String + public var programId: String + /// Position within the program, 0-based. + public var ord: Int + /// The exercise NAME. Its muscle classification lives in `liftExercise`, resolved by name — + /// one owner, so a program line and the vocabulary can never disagree. + public var exercise: String + public var targetSets: Int? + /// Rep range low end — the 8 of "8-10". Nil when the line has no rep target. + public var targetRepsLow: Int? + public var targetRepsHigh: Int? + /// Max RPE on the user's own 1-10 scale: the ceiling the session shows grey, and what a set left + /// unrated records as its rating. + public var targetRpe: Double? + /// Planned working weight in kilograms (v41). A program line plans a weight, not only reps. + public var targetWeightKg: Double? + /// Intended rest after each set, seconds. + public var restSec: Int? + /// The user's own technique cue, stored and shown back verbatim. + public var note: String? + + public init( + id: String, + deviceId: String, + programId: String, + ord: Int, + exercise: String, + targetSets: Int?, + targetRepsLow: Int?, + targetRepsHigh: Int?, + targetRpe: Double?, + targetWeightKg: Double?, + restSec: Int?, + note: String? + ) { + self.id = id + self.deviceId = deviceId + self.programId = programId + self.ord = ord + self.exercise = exercise + self.targetSets = targetSets + self.targetRepsLow = targetRepsLow + self.targetRepsHigh = targetRepsHigh + self.targetRpe = targetRpe + self.targetWeightKg = targetWeightKg + self.restSec = restSec + self.note = note + } + + static func decode(_ row: Row) -> LiftProgramItemRow { + LiftProgramItemRow( + id: row["id"], + deviceId: row["deviceId"], + programId: row["programId"], + ord: row["ord"], + exercise: row["exercise"], + targetSets: row["targetSets"], + targetRepsLow: row["targetRepsLow"], + targetRepsHigh: row["targetRepsHigh"], + targetRpe: row["targetRpe"], + targetWeightKg: row["targetWeightKg"], + restSec: row["restSec"], + note: row["note"] + ) + } +} + +/// One gym session. Pairs 1:1 with a `workout` row through (deviceId, startTs, sport). +public struct LiftSessionRow: Equatable, Codable, Sendable { + public var id: String + public var deviceId: String + /// Unix seconds; the same instant as the paired `workout.startTs`. + public var startTs: Int + /// Nil while the session is still running. + public var endTs: Int? + /// The same string as the paired `workout.sport`. + public var sport: String + /// Nil for a freehand session with no program behind it. + public var programId: String? + /// The program's name AS IT WAS when the session ran, so a later rename never rewrites history. + public var programName: String? + /// Session RPE, 0-10 Borg CR10 (v41). A NUMBER, not a note: Foster's session load is sRPE x + /// duration, so the rating has to be computable. Nil when the user skipped rating the session. + public var sessionRpe: Double? + public var note: String? + + public init( + id: String, + deviceId: String, + startTs: Int, + endTs: Int?, + sport: String, + programId: String?, + programName: String?, + sessionRpe: Double?, + note: String? + ) { + self.id = id + self.deviceId = deviceId + self.startTs = startTs + self.endTs = endTs + self.sport = sport + self.programId = programId + self.programName = programName + self.sessionRpe = sessionRpe + self.note = note + } + + static func decode(_ row: Row) -> LiftSessionRow { + LiftSessionRow( + id: row["id"], + deviceId: row["deviceId"], + startTs: row["startTs"], + endTs: row["endTs"], + sport: row["sport"], + programId: row["programId"], + programName: row["programName"], + sessionRpe: row["sessionRpe"], + note: row["note"] + ) + } +} + +/// One set. Stored as its own row rather than folded into the session, because per-exercise history +/// ("what did I lift for this last time") has to be answerable from an index. +public struct LiftSetRow: Equatable, Codable, Sendable { + public var id: String + public var deviceId: String + public var sessionId: String + /// Order within the whole session, 0-based — reconstructs the order the sets were performed in. + public var ord: Int + /// Denormalised deliberately: a session stays readable after its program is edited or deleted. + public var exercise: String + /// The classification this set was counted under, snapshotted at log time. + public var primaryMuscle: LiftMuscle? + public var secondaryMuscles: [LiftMuscle] + /// 1-based within this exercise, so "set 3 of 4" survives. + public var setIndex: Int + /// Kilograms. Display units convert at the edge; storage is always kg. + public var weightKg: Double? + public var reps: Int? + /// RPE on the user's own 1-10 scale. + public var rpe: Double? + /// Warmup sets are recorded but excluded from volume. + public var isWarmup: Bool + public var startTs: Int? + public var endTs: Int? + /// Rest ACTUALLY taken after this set, seconds — not the target from the program. + public var restSec: Int? + public var note: String? + + public init( + id: String, + deviceId: String, + sessionId: String, + ord: Int, + exercise: String, + primaryMuscle: LiftMuscle?, + secondaryMuscles: [LiftMuscle] = [], + setIndex: Int, + weightKg: Double?, + reps: Int?, + rpe: Double?, + isWarmup: Bool, + startTs: Int?, + endTs: Int?, + restSec: Int?, + note: String? + ) { + self.id = id + self.deviceId = deviceId + self.sessionId = sessionId + self.ord = ord + self.exercise = exercise + self.primaryMuscle = primaryMuscle + self.secondaryMuscles = LiftMuscle.decodeList( + LiftMuscle.encodeList(secondaryMuscles, excluding: primaryMuscle)) + self.setIndex = setIndex + self.weightKg = weightKg + self.reps = reps + self.rpe = rpe + self.isWarmup = isWarmup + self.startTs = startTs + self.endTs = endTs + self.restSec = restSec + self.note = note + } + + /// Volume for this set: weight x reps, in kilograms. Nil unless BOTH are present, and zero for a + /// warmup — a transparent arithmetic figure, never a physiological claim. + public var volumeKg: Double? { + guard !isWarmup, let weightKg, let reps else { return nil } + return weightKg * Double(reps) + } + + static func decode(_ row: Row) -> LiftSetRow { + LiftSetRow( + id: row["id"], + deviceId: row["deviceId"], + sessionId: row["sessionId"], + ord: row["ord"], + exercise: row["exercise"], + primaryMuscle: LiftMuscle(rawValue: row["primaryMuscle"] ?? ""), + secondaryMuscles: LiftMuscle.decodeList(row["secondaryMuscles"]), + setIndex: row["setIndex"], + weightKg: row["weightKg"], + reps: row["reps"], + rpe: row["rpe"], + isWarmup: row["isWarmup"], + startTs: row["startTs"], + endTs: row["endTs"], + restSec: row["restSec"], + note: row["note"] + ) + } +} + +// MARK: - Store + +extension WhoopStore { + + // MARK: Exercises (the user's own vocabulary) + + /// Remember exercises. Keyed on the natural key (deviceId, name), so recording the same name + /// twice updates its classification and recency instead of duplicating it. A muscle field is + /// only overwritten when the caller supplies one, so merely using an exercise never erases the + /// classification the user set for it. + /// How many exercises one device remembers. + /// + /// A cap exists because the vocabulary is typo-accumulating by design: every misspelling becomes + /// a permanent picker entry ("Chest supported row3"). It is set high enough that no real + /// training history reaches it — a broad lifter's whole vocabulary is well under a hundred — so + /// hitting it means something has gone wrong, and the honest response is to say so rather than + /// to silently drop what the user typed or to evict something they still use. + /// + /// An EXISTING name always updates, cap or no cap: only genuinely NEW names are refused. + public static let maxRememberedExercises = 500 + + /// How long a PROGRAM note may be. + /// + /// 120 because that is what can actually be SEEN: the hub renders it under the program name at + /// `lineLimit(3)` in caption type, in a column narrowed by the Start button — roughly three + /// lines on a phone. A longer note is not + /// stored-and-shown, it is stored-and-silently-truncated, and typing into a field that quietly + /// discards the end is worse than a field that stops. + public static let maxProgramNoteLength = 120 + + /// How long an EXERCISE (technique) note may be. + /// + /// 200, about four lines. Longer than a program note because it is a cue you read BETWEEN sets + /// — "slow eccentric, pause at the bottom, don't let the elbows flare" — and shorter than + /// unlimited because it renders directly above the set rows and every line pushes them down the + /// screen. Both are a MAXIMUM, not a target — a one-line note is usually the better note. + public static let maxExerciseNoteLength = 200 + + /// Thrown when the vocabulary is full and the name is a new one. + public struct LiftExerciseVocabularyFull: Error, Equatable { + public let limit: Int + public init(limit: Int) { self.limit = limit } + } + + @discardableResult + public func upsertLiftExercises(_ rows: [LiftExerciseRow]) async throws -> Int { + guard !rows.isEmpty else { return 0 } + return try syncWrite { db in + var n = 0 + for r in rows { + // Only a NEW name can push the vocabulary over: re-saving one that already exists is + // an update and must always be allowed, or a user at the cap could no longer correct + // the classification of an exercise they use every week. + let known = try Int.fetchOne(db, sql: """ + SELECT COUNT(*) FROM liftExercise WHERE deviceId = ? AND name = ? + """, arguments: [r.deviceId, r.name]) ?? 0 + if known == 0 { + let total = try Int.fetchOne(db, sql: """ + SELECT COUNT(*) FROM liftExercise WHERE deviceId = ? + """, arguments: [r.deviceId]) ?? 0 + if total >= WhoopStore.maxRememberedExercises { + throw LiftExerciseVocabularyFull(limit: WhoopStore.maxRememberedExercises) + } + } + try db.execute(sql: """ + INSERT INTO liftExercise + (id, deviceId, name, primaryMuscle, secondaryMuscles, createdAt, lastUsedTs) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(deviceId, name) DO UPDATE SET + primaryMuscle = COALESCE(excluded.primaryMuscle, primaryMuscle), + secondaryMuscles = COALESCE(excluded.secondaryMuscles, secondaryMuscles), + lastUsedTs = MAX(COALESCE(excluded.lastUsedTs, 0), COALESCE(lastUsedTs, 0)) + """, arguments: [ + r.id, r.deviceId, r.name, + r.primaryMuscle?.rawValue, + LiftMuscle.encodeList(r.secondaryMuscles, excluding: r.primaryMuscle), + r.createdAt, r.lastUsedTs, + ]) + n += db.changesCount + } + return n + } + } + + /// The user's exercises, most recently used first and never-used ones after, alphabetical within + /// each. This is the picker's list — built entirely from what they have typed. + public func liftExercises(deviceId: String) async throws -> [LiftExerciseRow] { + try syncRead { db in + try Row.fetchAll(db, sql: """ + SELECT * FROM liftExercise + WHERE deviceId = ? + ORDER BY COALESCE(lastUsedTs, 0) DESC, name ASC + """, arguments: [deviceId]).map(LiftExerciseRow.decode) + } + } + + /// Forget one exercise from the vocabulary. Sets already logged under that name are untouched — + /// they carry their own copy of the name and muscle group, so history never loses meaning. + @discardableResult + public func deleteLiftExercise(id: String) async throws -> Bool { + try syncWrite { db in + try db.execute(sql: "DELETE FROM liftExercise WHERE id = ?", arguments: [id]) + return db.changesCount > 0 + } + } + + // MARK: Programs + + /// Upsert programs by `id`. Returns rows written. + @discardableResult + public func upsertLiftPrograms(_ rows: [LiftProgramRow]) async throws -> Int { + guard !rows.isEmpty else { return 0 } + return try syncWrite { db in + var n = 0 + for r in rows { + try db.execute(sql: """ + INSERT INTO liftProgram + (id, deviceId, name, note, createdAt, updatedAt, archived) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + note = excluded.note, + updatedAt = excluded.updatedAt, + archived = excluded.archived + """, arguments: [ + r.id, r.deviceId, r.name, r.note, r.createdAt, r.updatedAt, r.archived, + ]) + n += db.changesCount + } + return n + } + } + + /// Programs for a device, most recently touched first. `includeArchived` defaults false so the + /// picker shows only live programs. + public func liftPrograms(deviceId: String, includeArchived: Bool = false) async throws -> [LiftProgramRow] { + try syncRead { db in + let sql = includeArchived + ? """ + SELECT * FROM liftProgram + WHERE deviceId = ? + ORDER BY updatedAt DESC + """ + : """ + SELECT * FROM liftProgram + WHERE deviceId = ? AND archived = 0 + ORDER BY updatedAt DESC + """ + return try Row.fetchAll(db, sql: sql, arguments: [deviceId]).map(LiftProgramRow.decode) + } + } + + /// Delete a program and its item lines. Sessions already run from it are NOT touched — they + /// carry their own `programName` snapshot, so history survives the program's deletion. + /// Returns true if a program row was removed. + @discardableResult + public func deleteLiftProgram(id: String) async throws -> Bool { + try syncWrite { db in + try db.execute(sql: "DELETE FROM liftProgramItem WHERE programId = ?", arguments: [id]) + try db.execute(sql: "DELETE FROM liftProgram WHERE id = ?", arguments: [id]) + return db.changesCount > 0 + } + } + + // MARK: Program items + + /// Replace a program's item lines wholesale. The editor hands back the whole list, and deleting + /// then reinserting inside one transaction avoids reconciling removals and reorders row by row. + @discardableResult + public func replaceLiftProgramItems(programId: String, items: [LiftProgramItemRow]) async throws -> Int { + try syncWrite { db in + try db.execute(sql: "DELETE FROM liftProgramItem WHERE programId = ?", arguments: [programId]) + var n = 0 + for r in items { + try db.execute(sql: """ + INSERT INTO liftProgramItem + (id, deviceId, programId, ord, exercise, targetSets, + targetRepsLow, targetRepsHigh, targetRpe, targetWeightKg, restSec, note) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, arguments: [ + r.id, r.deviceId, r.programId, r.ord, r.exercise, r.targetSets, + r.targetRepsLow, r.targetRepsHigh, r.targetRpe, r.targetWeightKg, + r.restSec, r.note, + ]) + n += db.changesCount + } + return n + } + } + + /// A program's exercise lines in program order. + public func liftProgramItems(programId: String) async throws -> [LiftProgramItemRow] { + try syncRead { db in + try Row.fetchAll(db, sql: """ + SELECT * FROM liftProgramItem + WHERE programId = ? + ORDER BY ord ASC + """, arguments: [programId]).map(LiftProgramItemRow.decode) + } + } + + // MARK: Sessions + + /// Upsert sessions. Keyed on the NATURAL key (deviceId, startTs, sport) rather than the `id` PK, + /// so re-saving the same session updates it in place even if the caller minted a fresh id — the + /// labMarker rule, and what keeps this row paired 1:1 with its `workout` row. + @discardableResult + public func upsertLiftSessions(_ rows: [LiftSessionRow]) async throws -> Int { + guard !rows.isEmpty else { return 0 } + return try syncWrite { db in + var n = 0 + for r in rows { + try db.execute(sql: """ + INSERT INTO liftSession + (id, deviceId, startTs, endTs, sport, programId, programName, + sessionRpe, note) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(deviceId, startTs, sport) DO UPDATE SET + endTs = excluded.endTs, + programId = excluded.programId, + programName = excluded.programName, + sessionRpe = excluded.sessionRpe, + note = excluded.note + """, arguments: [ + r.id, r.deviceId, r.startTs, r.endTs, r.sport, + r.programId, r.programName, r.sessionRpe, r.note, + ]) + n += db.changesCount + } + return n + } + } + + /// Sessions for a device that started within `[fromTs, toTs]` inclusive, most recent first. + public func liftSessions(deviceId: String, fromTs: Int, toTs: Int) async throws -> [LiftSessionRow] { + try syncRead { db in + try Row.fetchAll(db, sql: """ + SELECT * FROM liftSession + WHERE deviceId = ? AND startTs >= ? AND startTs <= ? + ORDER BY startTs DESC + """, arguments: [deviceId, fromTs, toTs]).map(LiftSessionRow.decode) + } + } + + /// Delete a session and every set in it. Returns true if a session row was removed. + @discardableResult + public func deleteLiftSession(id: String) async throws -> Bool { + try syncWrite { db in + try db.execute(sql: "DELETE FROM liftSet WHERE sessionId = ?", arguments: [id]) + try db.execute(sql: "DELETE FROM liftSession WHERE id = ?", arguments: [id]) + return db.changesCount > 0 + } + } + + /// Delete individual sets, as editing a finished session does when a set is removed. The session + /// itself stays. Returns how many rows went. + /// + /// The Kotlin twin is `DeviceRegistryDao.deleteLiftSets`. + @discardableResult + public func deleteLiftSets(ids: [String]) async throws -> Int { + guard !ids.isEmpty else { return 0 } + return try syncWrite { db in + var n = 0 + for id in ids { + try db.execute(sql: "DELETE FROM liftSet WHERE id = ?", arguments: [id]) + n += db.changesCount + } + return n + } + } + + // MARK: Sets + + /// Upsert sets by `id`. Called as each set is logged, so a session in progress is durable set by + /// set rather than only on finish. + @discardableResult + public func upsertLiftSets(_ rows: [LiftSetRow]) async throws -> Int { + guard !rows.isEmpty else { return 0 } + return try syncWrite { db in + var n = 0 + for r in rows { + try db.execute(sql: """ + INSERT INTO liftSet + (id, deviceId, sessionId, ord, exercise, primaryMuscle, secondaryMuscles, + setIndex, weightKg, reps, rpe, isWarmup, startTs, endTs, restSec, note) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + ord = excluded.ord, + exercise = excluded.exercise, + primaryMuscle = excluded.primaryMuscle, + secondaryMuscles = excluded.secondaryMuscles, + setIndex = excluded.setIndex, + weightKg = excluded.weightKg, + reps = excluded.reps, + rpe = excluded.rpe, + isWarmup = excluded.isWarmup, + startTs = excluded.startTs, + endTs = excluded.endTs, + restSec = excluded.restSec, + note = excluded.note + """, arguments: [ + r.id, r.deviceId, r.sessionId, r.ord, r.exercise, + r.primaryMuscle?.rawValue, + LiftMuscle.encodeList(r.secondaryMuscles, excluding: r.primaryMuscle), + r.setIndex, r.weightKg, r.reps, r.rpe, r.isWarmup, + r.startTs, r.endTs, r.restSec, r.note, + ]) + n += db.changesCount + } + return n + } + } + + /// Every set in a session, in the order they were performed. + public func liftSets(sessionId: String) async throws -> [LiftSetRow] { + try syncRead { db in + try Row.fetchAll(db, sql: """ + SELECT * FROM liftSet + WHERE sessionId = ? + ORDER BY ord ASC + """, arguments: [sessionId]).map(LiftSetRow.decode) + } + } + + /// The sets from the most recent session that contained `exercise`, in performed order. + /// + /// This is the read the whole feature exists for: it pre-fills the next session with what you + /// actually did last time, which the user then confirms or overrides. Empty when the exercise + /// has never been logged. `before` excludes the session currently in progress (pass its + /// `startTs`) so a running session never pre-fills from itself. Sets with zero reps were not + /// performed (`LiftMetrics.isPerformed`) and are skipped, so a discarded set never becomes the next + /// session's grey numbers, and a session holding only such sets is not "last time" for the exercise. + public func lastLiftSets(deviceId: String, exercise: String, before: Int? = nil) async throws -> [LiftSetRow] { + try syncRead { db in + // Two steps rather than a correlated subquery: find the latest qualifying session, then + // read its sets in order. Served by idx_liftSet_device_exercise + idx_liftSession_natural. + let cutoff = before ?? Int.max + guard let sessionId = try String.fetchOne(db, sql: """ + SELECT s.sessionId FROM liftSet s + JOIN liftSession sess ON sess.id = s.sessionId + WHERE s.deviceId = ? AND s.exercise = ? AND sess.startTs < ? + AND (s.reps IS NULL OR s.reps <> 0) + ORDER BY sess.startTs DESC + LIMIT 1 + """, arguments: [deviceId, exercise, cutoff]) else { return [] } + return try Row.fetchAll(db, sql: """ + SELECT * FROM liftSet + WHERE sessionId = ? AND exercise = ? AND (reps IS NULL OR reps <> 0) + ORDER BY ord ASC + """, arguments: [sessionId, exercise]).map(LiftSetRow.decode) + } + } + + /// Per-muscle working-set counts over `[fromTs, toTs]`, from the classification each set was + /// logged under. + /// + /// `fractional` is the headline figure: direct sets count 1, indirect sets count 0.5. That is + /// not a house convention — the 2025 dose-response meta-regression tested exactly this choice + /// against counting indirect sets as 1 and as 0, and the evidence was strongest for 0.5, which + /// its primary models then used. The reference doses NOOP displays come from those models, so + /// the count and the reference must stay on the same method. + /// + /// `direct` and `indirect` are returned alongside so the arithmetic is inspectable rather than + /// asserted. + /// + /// **Warm-ups and sets with zero reps (never performed, `LiftMetrics.isPerformed`) are excluded; + /// nothing else is.** In particular this does NOT filter by RPE, even + /// though a hard set is the thing that drives adaptation — because the reference doses were + /// derived from unfiltered working-set counts, and filtering here would quietly compare a + /// smaller number against a scale built from a larger one. Proximity to failure is reported + /// separately by `LiftMetrics.rpeProfile` instead, where it can inform without corrupting the count. + public func liftSetCounts( + deviceId: String, + fromTs: Int, + toTs: Int + ) async throws -> (fractional: [LiftMuscle: Double], direct: [LiftMuscle: Int], indirect: [LiftMuscle: Int]) { + try syncRead { db in + let rows = try Row.fetchAll(db, sql: """ + SELECT s.primaryMuscle AS primaryMuscle, s.secondaryMuscles AS secondaryMuscles + FROM liftSet s + JOIN liftSession sess ON sess.id = s.sessionId + WHERE s.deviceId = ? + AND sess.startTs >= ? AND sess.startTs <= ? + AND s.isWarmup = 0 + AND (s.reps IS NULL OR s.reps <> 0) + """, arguments: [deviceId, fromTs, toTs]) + + var direct: [LiftMuscle: Int] = [:] + var indirect: [LiftMuscle: Int] = [:] + for row in rows { + if let token: String = row["primaryMuscle"], let m = LiftMuscle(rawValue: token) { + direct[m, default: 0] += 1 + } + // A muscle listed BOTH as primary and as secondary is credited once, as direct. + // `LiftMuscle.encodeList(_:excluding:)` already strips the primary on the way in, so + // today no stored row needs this — but "today no row needs it" is not a guarantee, + // and without the guard this aggregation and `LiftMetrics.muscleCounts` (which has + // always had it) would report DIFFERENT numbers for the same set: the hub's weekly + // card and the session detail, disagreeing, with nothing to catch it. The two are + // pinned against each other by + // `LiftMetricsStoreAgreementTests.testBothImplementationsAgree…`. + let primary: LiftMuscle? = (row["primaryMuscle"] as String?).flatMap(LiftMuscle.init(rawValue:)) + for m in LiftMuscle.decodeList(row["secondaryMuscles"]) where m != primary { + indirect[m, default: 0] += 1 + } + } + var fractional: [LiftMuscle: Double] = [:] + for (m, n) in direct { fractional[m, default: 0] += Double(n) * LiftMuscle.directSetCredit } + for (m, n) in indirect { fractional[m, default: 0] += Double(n) * LiftMuscle.indirectSetCredit } + return (fractional, direct, indirect) + } + } + +} diff --git a/Packages/WhoopStore/Sources/WhoopStore/LiftMuscle.swift b/Packages/WhoopStore/Sources/WhoopStore/LiftMuscle.swift new file mode 100644 index 0000000000..68d6f6e4d9 --- /dev/null +++ b/Packages/WhoopStore/Sources/WhoopStore/LiftMuscle.swift @@ -0,0 +1,126 @@ +import Foundation + +// MARK: - The muscle-group vocabulary (v45) +// +// Exercise NAMES are free text — whatever the user types, kept verbatim. Muscle GROUPS are not: +// they are a closed, fixed set of canonical tokens, because a per-muscle rollup is only meaningful +// if the same muscle always lands in the same bucket. Free-text groups would scatter "Quads", +// "quads" and "Legs" across three counts and quietly make the weekly view a lie. +// +// These raw values are a STORED-DATA CONTRACT. They are written into `liftExercise.primaryMuscle`, +// `liftExercise.secondaryMuscles`, `liftSet.primaryMuscle` and `liftSet.secondaryMuscles`, and an +// Android twin must use byte-identical tokens. So: +// • NEVER rename or remove a case — stored rows would stop resolving. +// • Adding a case is safe and additive. +// • Tokens are deliberately locale-independent; the display name is localized at the app layer, +// never here (WhoopStore holds no UI strings). +// +// Granularity matches the resolution the volume literature measures at — the muscles hypertrophy +// trials actually image (pectoralis, latissimus, the deltoid heads, biceps, triceps, quadriceps, +// hamstrings, glutes, erectors, gastrocnemius…), plus the few that are trained in practice but +// rarely studied (adductors, abductors, forearms, neck). Coarser ("Legs") would hide hamstrings +// that never got trained; finer (individual heads of each muscle) would make every bucket look +// starved and turn logging into taxonomy homework. +// +// There is deliberately NO "full body" and NO "other". Both are escape hatches that contribute to +// no muscle's count while looking like they did — a clean is quads-direct with upper back, traps, +// glutes and hamstrings indirect, and saying so is both more accurate and more useful. An exercise +// the user has not classified simply has a nil primary: it still counts toward volume and session +// load, it just does not claim a muscle it was never assigned. + +/// A muscle group a set can be attributed to. Raw values are stored; do not rename them. +public enum LiftMuscle: String, CaseIterable, Codable, Sendable { + + // Push + case chest + case frontDelts + case sideDelts + case rearDelts + case triceps + + // Pull + case lats + case upperBack + case traps + case biceps + case forearms + + // Legs + case quads + case hamstrings + case glutes + case adductors + case abductors + case calves + + // Trunk + case abs + case obliques + case lowerBack + case neck + + /// Coarse section, used only to group the picker. Not stored, not counted — purely presentation + /// scaffolding, so changing it is free. + public enum Region: String, CaseIterable, Sendable { + case push, pull, legs, trunk + } + + public var region: Region { + switch self { + case .chest, .frontDelts, .sideDelts, .rearDelts, .triceps: + return .push + case .lats, .upperBack, .traps, .biceps, .forearms: + return .pull + case .quads, .hamstrings, .glutes, .adductors, .abductors, .calves: + return .legs + case .abs, .obliques, .lowerBack, .neck: + return .trunk + } + } + + /// Cases in picker order: by region, in the order declared above. + public static var ordered: [LiftMuscle] { allCases } + + /// Cases in one region, in declaration order. + public static func inRegion(_ region: Region) -> [LiftMuscle] { + allCases.filter { $0.region == region } + } + + /// What one set contributes to this muscle's weekly count. + /// + /// The 2025 Sports Medicine dose-response meta-regression compared three ways of counting a set + /// for a muscle that was only an indirect mover — "total" (count it as 1), "fractional" (count + /// it as 0.5) and "direct" (count it as 0) — and found the evidence strongest for FRACTIONAL, + /// which is what its primary models use. So 0.5 here is not a house convention; it is the + /// operationalisation with the best empirical support, and the reference doses NOOP shows are + /// derived under it. Change one and you must change the other. + public static let directSetCredit: Double = 1.0 + public static let indirectSetCredit: Double = 0.5 + + // MARK: - The secondary-muscle list, as stored + // + // A comma-joined token list in one TEXT column rather than a join table: the list is short, + // never queried on its own, and one column is trivially mirrorable in Room. Order is preserved + // as the user set it; duplicates and the primary itself are stripped on encode so a set can + // never be counted twice for one muscle. + + /// Encode a secondary list for storage. Returns nil for an empty list so the column stays NULL + /// rather than holding an empty string (two spellings of "none" is a bug waiting to happen). + public static func encodeList(_ muscles: [LiftMuscle], excluding primary: LiftMuscle? = nil) -> String? { + var seen = Set() + if let primary { seen.insert(primary) } + var kept: [LiftMuscle] = [] + for m in muscles where !seen.contains(m) { + seen.insert(m) + kept.append(m) + } + return kept.isEmpty ? nil : kept.map(\.rawValue).joined(separator: ",") + } + + /// Decode a stored secondary list. Unknown tokens are skipped rather than failing the read: a + /// database written by a newer build must stay readable by an older one. + public static func decodeList(_ stored: String?) -> [LiftMuscle] { + guard let stored, !stored.isEmpty else { return [] } + return stored.split(separator: ",").compactMap { LiftMuscle(rawValue: String($0)) } + } +} diff --git a/Packages/WhoopStore/Sources/WhoopStore/MetricsCache.swift b/Packages/WhoopStore/Sources/WhoopStore/MetricsCache.swift index 1e3b2623c0..ab4970a0d2 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/MetricsCache.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/MetricsCache.swift @@ -35,14 +35,33 @@ public struct CachedSleepSession: Equatable, Codable { /// of presenting the short total as fact. nil for imported nights and pre-migration rows (unknown, not /// flagged). Set per session to the DAY's value; only NOOP-computed nights populate it. Byte-parity twin. public let stagingSparse: Bool? + /// Which device's row this is, when the read that produced it knew. + /// + /// The Kotlin `SleepSession` entity has carried `deviceId` all along; this type never did, so a caller + /// holding a block had no way to say where it came from and had to ask the store which device owned it, + /// once per block. That probe is the expensive half of the Sleep screen's load. + /// + /// Optional, and defaulted, for two reasons. Seventy-six construction sites build these by hand, and a + /// required field would churn every one of them to state something most of them do not know. And the + /// honest value for a hand-built session IS absent: claiming a device it was never read from would be + /// worse than saying nothing. Readers treat nil as "ask", so provenance is a shortcut where the store + /// supplied it and never a silent wrong answer where it did not. `Codable` decodes a missing key to nil, + /// so persisted payloads written before this field stay readable. + /// + /// The WRITE never reads it. `upsertSleepSessions` takes its device as a separate argument, so + /// setting this on a session being stored cannot route the row into another device's namespace, + /// and a row always reads back stamped with the id it was actually written under. + public let deviceId: String? public init(startTs: Int, endTs: Int, efficiency: Double?, restingHr: Int?, avgHrv: Double?, stagesJSON: String?, userEdited: Bool = false, - startTsAdjusted: Int? = nil, stagingSparse: Bool? = nil) { + startTsAdjusted: Int? = nil, stagingSparse: Bool? = nil, + deviceId: String? = nil) { self.startTs = startTs; self.endTs = endTs self.efficiency = efficiency; self.restingHr = restingHr self.avgHrv = avgHrv; self.stagesJSON = stagesJSON self.userEdited = userEdited self.startTsAdjusted = startTsAdjusted + self.deviceId = deviceId self.stagingSparse = stagingSparse } @@ -52,7 +71,10 @@ public struct CachedSleepSession: Equatable, Codable { public func withStartTs(_ newStartTs: Int) -> CachedSleepSession { CachedSleepSession(startTs: newStartTs, endTs: endTs, efficiency: efficiency, restingHr: restingHr, avgHrv: avgHrv, stagesJSON: stagesJSON, userEdited: userEdited, - startTsAdjusted: startTsAdjusted, stagingSparse: stagingSparse) + startTsAdjusted: startTsAdjusted, stagingSparse: stagingSparse, + // Carried: a re-keyed session is the same row from the same device, and + // dropping it here would turn a known provenance back into a store probe. + deviceId: deviceId) } } @@ -93,12 +115,28 @@ public struct DailyMetric: Equatable, Codable { /// compute it from the night's R-R (`HRVAnalyzer.sdnnIndex`); Apple rows mirror their own SDNN reading; /// Oura/other imports carry no SDNN so it stays nil. public let avgSdnn: Double? + /// Nightly ABSOLUTE skin temperature (°C) — the wear-gated mean over the night's detected sleep, the + /// value `skinTempDevC` is derived FROM (#1636). + /// + /// The engine already computed this on every scoring pass and threw it away once the deviation was + /// taken, so a wearer could see "+0.5 Δ°C" with no way to learn what it moved from — and a febrile + /// night reads as a small delta while the absolute reads as a fever. v40 column, nullable: nights + /// scored before it shipped stay nil until a re-score re-derives them from the same raw samples. + /// + /// Distinct from `skinTempDevC`, which is bimodal — CSV/Apple imports write an ABSOLUTE wrist °C into + /// 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) { + spo2Red: Int? = nil, spo2Ir: Int? = nil, avgSdnn: 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 @@ -106,6 +144,8 @@ public struct DailyMetric: Equatable, Codable { self.spo2Pct = spo2Pct; self.skinTempDevC = skinTempDevC; self.respRateBpm = respRateBpm 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 / @@ -138,6 +178,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 { @@ -145,11 +220,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, @@ -178,6 +271,19 @@ 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"], + deviceId: deviceId) + } + /// 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 @@ -323,6 +429,28 @@ extension WhoopStore { } } + /// One device's session BOUNDS in a window, as `startTs -> endTs`, in a single lean read. + /// + /// Deliberately not `sleepSessions(deviceId:from:to:limit:)`, which selects `stagesJSON` among other + /// columns: a caller that only needs to know which blocks a device owns would haul every night's + /// staging blob, once per candidate device, to read two integers from each. On a browsable history + /// that is the same rows re-read several times over. + /// + /// `(deviceId, startTs)` is the primary key, so a start maps to exactly one end and the dictionary + /// loses nothing. Unpaged on purpose: the window bounds the result, and a page limit sized from a + /// caller's own list would silently drop rows for a caller that passed a sparse subset of a wide span. + public func sleepSessionBounds(deviceId: String, from: Int, to: Int) async throws -> [Int: Int] { + try syncRead { db in + var out: [Int: Int] = [:] + let rows = try Row.fetchAll(db, sql: """ + SELECT startTs, endTs FROM sleepSession + WHERE deviceId = ? AND startTs >= ? AND startTs <= ? + """, arguments: [deviceId, from, to]) + for row in rows { out[row["startTs"]] = row["endTs"] } + return out + } + } + /// Batched twin of `sessionMotion` for a SET of session starts: the persisted per-epoch motion series /// for each of `sessionStarts` that HAS one, in a SINGLE query, keyed by startTs. Same contract as the /// single-key accessor — a start whose column is NULL/absent (or an empty series) is simply omitted from @@ -416,8 +544,8 @@ extension WhoopStore { (deviceId, day, totalSleepMin, efficiency, deepMin, remMin, lightMin, disturbances, restingHr, avgHrv, recovery, strain, exerciseCount, spo2Pct, skinTempDevC, respRateBpm, steps, activeKcalEst, - spo2Red, spo2Ir, avgSdnn) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + spo2Red, spo2Ir, avgSdnn, skinTempC, sleepHrOnly) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(deviceId, day) DO UPDATE SET totalSleepMin = excluded.totalSleepMin, efficiency = excluded.efficiency, @@ -437,13 +565,15 @@ extension WhoopStore { activeKcalEst = excluded.activeKcalEst, spo2Red = excluded.spo2Red, spo2Ir = excluded.spo2Ir, - avgSdnn = excluded.avgSdnn + avgSdnn = excluded.avgSdnn, + 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.spo2Red, d.spo2Ir, d.avgSdnn, d.skinTempC, d.sleepHrOnly]) n += db.changesCount } return n @@ -466,6 +596,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. @@ -482,7 +629,10 @@ extension WhoopStore { efficiency: $0["efficiency"], restingHr: $0["restingHr"], avgHrv: $0["avgHrv"], stagesJSON: $0["stagesJSON"], userEdited: $0["userEdited"], startTsAdjusted: $0["startTsAdjusted"], - stagingSparse: $0["stagingSparse"]) + stagingSparse: $0["stagingSparse"], + // The read knows the device it queried, so every block it hands + // back carries it and no caller has to ask the store again. + deviceId: deviceId) } } } @@ -494,7 +644,7 @@ extension WhoopStore { SELECT day, totalSleepMin, efficiency, deepMin, remMin, lightMin, disturbances, restingHr, avgHrv, recovery, strain, exerciseCount, spo2Pct, skinTempDevC, respRateBpm, steps, activeKcalEst, - spo2Red, spo2Ir, avgSdnn FROM dailyMetric + spo2Red, spo2Ir, avgSdnn, skinTempC, sleepHrOnly FROM dailyMetric WHERE deviceId = ? AND day >= ? AND day <= ? ORDER BY day ASC """, arguments: [deviceId, from, to]) @@ -508,7 +658,9 @@ extension WhoopStore { spo2Pct: $0["spo2Pct"], skinTempDevC: $0["skinTempDevC"], respRateBpm: $0["respRateBpm"], steps: $0["steps"], activeKcalEst: $0["activeKcalEst"], - spo2Red: $0["spo2Red"], spo2Ir: $0["spo2Ir"], avgSdnn: $0["avgSdnn"]) + spo2Red: $0["spo2Red"], spo2Ir: $0["spo2Ir"], avgSdnn: $0["avgSdnn"], + skinTempC: $0["skinTempC"], + sleepHrOnly: $0["sleepHrOnly"]) } } } diff --git a/Packages/WhoopStore/Sources/WhoopStore/OuraRingEpochScan.swift b/Packages/WhoopStore/Sources/WhoopStore/OuraRingEpochScan.swift new file mode 100644 index 0000000000..5e1d62c50f --- /dev/null +++ b/Packages/WhoopStore/Sources/WhoopStore/OuraRingEpochScan.swift @@ -0,0 +1,135 @@ +import Foundation + +/// Group Oura sidecar rows by the ring epoch each one implies, so a session filed under the wrong +/// anchor is visible without needing a trusted anchor to compare against. +/// +/// #2252: the ticks×10 defect (#2239) filed whole sessions in the past, and the rows it wrote carry no +/// ring-time in the store, so nothing there can say which they were. The decoded sidecars do carry both +/// axes, and they are written on every live-feeding connection rather than behind a Test Centre toggle, +/// so the evidence is already on the affected devices. +/// +/// Every row implies `epoch = utc - ringTs / 10`: the UTC at which the ring's tick counter read zero. +/// Rows anchored correctly against one ring boot all imply the SAME epoch. A session whose anchor was +/// adopted as seconds×10 implies one `0.9 × anchorTicks` seconds earlier, which for the Ring 5 in #2239 +/// was 41.7 days. The shift is CONSTANT across that session, because it comes from the anchor rather +/// than from each record: +/// +/// stored = T + (rt - 10R)/10, true = T + (rt - R)/10, true - stored = 0.9R +/// +/// This DESCRIBES, it does not classify. A ring that genuinely restarted also starts a new epoch, and +/// telling a restart from a mis-anchored session is a judgement the caller makes with the registration +/// date and the ring's own history in hand. Reporting the clusters is what makes that judgement +/// possible; guessing it here would be the same confident wrongness the defect itself was. +public enum OuraRingEpochScan { + + /// One run of rows agreeing on where the ring's clock started. + public struct Cluster: Equatable, Sendable { + /// Implied UTC of ring tick 0, taken as the median of the run so one outlying row cannot move it. + public let epochUnix: Int + public let rows: Int + /// The stored-UTC span of this run, which is what a reader sees on a calendar. + public let firstStoredUtc: Int + public let lastStoredUtc: Int + + public init(epochUnix: Int, rows: Int, firstStoredUtc: Int, lastStoredUtc: Int) { + self.epochUnix = epochUnix + self.rows = rows + self.firstStoredUtc = firstStoredUtc + self.lastStoredUtc = lastStoredUtc + } + } + + /// Six hours, the default gap that separates one epoch from the next. + /// + /// Not tight, deliberately. The ring's clock runs at 9.94 to 10.25 ticks per second rather than + /// exactly 10 (#2239 measured 58 pairs), so dividing by 10 accumulates error WITHIN a correctly + /// anchored session: across a fortnight of banked history a 2.5% rate error is about eight hours of + /// implied-epoch drift. A tolerance under that would split one honest boot into several clusters and + /// report drift as corruption. The defect this exists to surface moves the epoch by DAYS, so six + /// hours separates the two cases without inventing a precision the tick rate does not support. + public static let defaultToleranceSeconds = 21_600 + + /// One report line when the rows disagree about where the ring's clock started, or nil when they do + /// not. Nil is the healthy answer and the common one: a ring that has never restarted implies a + /// single epoch, and an absent line keeps a healthy report byte-unchanged by this existing. + /// + /// Deliberately states the gap in days rather than naming a cause. `0.9 x anchorTicks` for the #2239 + /// ring was 41.7 days, so a gap near that is the ticks×10 signature; a gap of a few days is more + /// likely a genuine restart. Both look identical from here, and the reader has the registration date. + /// + /// The Kotlin twin is `OuraRingEpochScan.summaryLine`. + public static func summaryLine(_ clusters: [Cluster]) -> String? { + guard clusters.count > 1, let newest = clusters.first else { return nil } + var line = "ouraRingEpoch clusters=\(clusters.count) newest=\(isoDay(newest.epochUnix)) rows=\(newest.rows)" + for older in clusters.dropFirst() { + let gapDays = Double(newest.epochUnix - older.epochUnix) / 86_400 + line += " | epoch=\(isoDay(older.epochUnix)) rows=\(older.rows)" + + " gapDays=\(String(format: "%.1f", gapDays))" + + " stored=\(isoDay(older.firstStoredUtc))..\(isoDay(older.lastStoredUtc))" + } + return line + } + + /// UTC calendar day for a unix second, as the report prints dates elsewhere. + /// + /// The Kotlin twin is `OuraRingEpochScan.isoDay`. + static func isoDay(_ unix: Int) -> String { + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.timeZone = TimeZone(identifier: "UTC") + f.dateFormat = "yyyy-MM-dd" + return f.string(from: Date(timeIntervalSince1970: TimeInterval(unix))) + } + + /// Cluster `rows` by implied ring epoch, newest epoch first. + /// + /// The Kotlin twin is `OuraRingEpochScan.cluster`. + /// + /// `rows` may arrive in any order and may mix sidecars; only `ringTs` and the stored `utc` matter. + /// A row whose `ringTs` is 0 is dropped: an unanchored record has no time axis to imply an epoch + /// from, and including it would invent one at `utc`. + public static func cluster(_ rows: [(ringTs: UInt32, utc: Int)], + toleranceSeconds: Int = defaultToleranceSeconds) -> [Cluster] { + let points = rows + .filter { $0.ringTs > 0 } + .map { (epoch: $0.utc - Int($0.ringTs) / 10, utc: $0.utc) } + .sorted { $0.epoch < $1.epoch } + guard !points.isEmpty else { return [] } + + // Runs are collected first and turned into clusters after, rather than closed by a local helper: + // a nested function is a DECLARATION the parity ledger sees on each side and cannot pair, so the + // twin would owe two one-sided entries for a private detail neither platform exposes. + var runs: [[(epoch: Int, utc: Int)]] = [] + var run: [(epoch: Int, utc: Int)] = [points[0]] + + for point in points.dropFirst() { + // Against the PREVIOUS row, not the run's first: a long session drifts steadily, and measuring + // from the start would eventually exceed any tolerance and split one boot in two. + if point.epoch - run[run.count - 1].epoch <= toleranceSeconds { + run.append(point) + } else { + runs.append(run) + run = [point] + } + } + runs.append(run) + + let clusters = runs.map { r -> Cluster in + let epochs = r.map(\.epoch).sorted() + let utcs = r.map(\.utc) + return Cluster(epochUnix: epochs[epochs.count / 2], + rows: r.count, + firstStoredUtc: utcs.min() ?? 0, + lastStoredUtc: utcs.max() ?? 0) + } + // Tie-broken, not just ordered by epoch: Swift's `sorted(by:)` is NOT stable while Kotlin's + // `sortedByDescending` is, so two clusters sharing a median epoch could come back in a different + // order on each platform and `summaryLine` would name a different one "newest". Ordering by row + // count and then by stored span makes the result a function of the values on both. + return clusters.sorted { + if $0.epochUnix != $1.epochUnix { return $0.epochUnix > $1.epochUnix } + if $0.rows != $1.rows { return $0.rows > $1.rows } + return $0.firstStoredUtc > $1.firstStoredUtc + } + } +} diff --git a/Packages/WhoopStore/Sources/WhoopStore/OuraSerialIdentity.swift b/Packages/WhoopStore/Sources/WhoopStore/OuraSerialIdentity.swift new file mode 100644 index 0000000000..d134ce00ce --- /dev/null +++ b/Packages/WhoopStore/Sources/WhoopStore/OuraSerialIdentity.swift @@ -0,0 +1,24 @@ +import Foundation + +/// What may be written to a SHAREABLE strap log for an Oura ring's serial (#2092). +/// +/// Mirrors `WhoopSerialIdentity.logSafe`'s shape exactly (same 3-character prefix + `…`) so a masked +/// WHOOP serial and a masked Oura serial read identically in a shared log — the reader should not need +/// to know which brand a masked id came from to trust it is masked. +/// +/// Kept as its OWN type rather than a shared call into `WhoopSerialIdentity`: Oura's serial identity has +/// its own home (`ExperimentalBrand.oura`'s `idPrefix`, `SourceCoordinator.adoptOuraSerial`) and does not +/// share WHOOP's `adoptedId`/`mayAdopt`/`isAlreadyAdopted` machinery — only the LOG-SAFETY shape is +/// common, not the identity model. Kotlin twin: `OuraSerialIdentity`. +public enum OuraSerialIdentity { + /// The one place the Oura id namespace is spelled, matching `ExperimentalBrand.oura.idPrefix` and + /// `DeviceBrandCatalog`'s `"oura"` entry — kept here too so `LiveState.redactPii`'s Oura rule and this + /// masking function agree on the prefix without importing the brand catalog into a pure package. + public static let idPrefix = "oura" + + /// Never log `"\(idPrefix)-\(serial)"` (or the bare serial) directly — only this. + 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/OuraSidecarGenerations.swift b/Packages/WhoopStore/Sources/WhoopStore/OuraSidecarGenerations.swift new file mode 100644 index 0000000000..d7376d5446 --- /dev/null +++ b/Packages/WhoopStore/Sources/WhoopStore/OuraSidecarGenerations.swift @@ -0,0 +1,117 @@ +import Foundation + +/// Pure, deterministic selection rule for WHICH Oura diagnostics files a Test Centre export ships. +/// +/// The dumps (`Strand/BLE/Oura*Dump.swift`) keep a per-session generation ring: the live file +/// `oura--.jsonl` is the CURRENT session, and finished sessions roll into +/// `…jsonl.1` (newest retained) … `…jsonl.N` (oldest). Several files therefore map to one bundle +/// entry name, and the assembler must decide what to do about that. +/// +/// WHY THIS EXISTS — the rule it replaces was `LARGEST WINS`, and largest is the wrong criterion. +/// The wake drain flushes the whole night's bank in one burst, so on an ordinary morning the biggest +/// generation is whichever session happened to drain the most — routinely NOT the one holding the +/// night. Measured on `noop-master-iOS-v11.1.1-260905-0714.zip`: the shipped `oura-raw.jsonl` spanned +/// `2026-09-04 10:58:22 → 12:03:47 UTC` — the previous day's post-reinstall backfill, which was +/// simply the fattest file on disk. That morning's own 07:08 drain, the one carrying the night's +/// banked records (including the single `0x5c` of the day and the hypnogram), was in a different +/// generation and never left the device. Over the whole `Sleep Nights` corpus this cost **11 of 31 +/// nights**, which is the single biggest limit on the hypnogram ledger. +/// +/// THE RULE: do not pick at all — **concatenate the generations, oldest → newest**, and let the +/// bundle cap keep the tail. The cap already trims from the FRONT and keeps the most-recent bytes, +/// so merging in chronological order makes "the newest capture survives" a property of the two rules +/// composed, rather than a guess made blind at selection time. The morning drain is by construction +/// the newest data, so it can no longer be the thing that gets dropped. +/// +/// WHY NOT ship every generation as its own entry (the other candidate in the issue draft): each +/// extra entry competes for the same 20 MB cap under max-min fair allocation, so N generations of a +/// bulk sidecar would shrink every other stream's share — starving exactly the file this exists to +/// preserve. Merging costs one entry, keeps the bundle listing stable, and needs no new names. +/// +/// SAFE TO MERGE: generations are disjoint app sessions, and the raw capture deliberately keeps no +/// dedup high-water (a re-served record is itself evidence the ring re-sent it). Every line carries +/// its own `utc`/`iso`, and the offline reframer collapses duplicates by `(tag, ring-time)`, so a +/// concatenation reads exactly like the longer single capture it stands in for. +public enum OuraSidecarGenerations { + + /// Bundle entry name for a sidecar kind — the ring id is dropped, because bundle redaction scrubs + /// file CONTENT and never file NAMES. + public static func entryName(kind: String) -> String { "oura-\(kind).jsonl" } + + /// Classify a diagnostics filename as `(kind, generation)`, or nil when it is not one of `kinds`. + /// + /// `generation` is 0 for the live file and `n` for `…jsonl.n`, matching the dumps' own ring where + /// **1 is the NEWEST retained** session and higher indices are older. So a smaller generation is + /// always the more recent capture, and 0 (live) is the most recent of all — that single ordering + /// fact is what `mergePlan` sorts on. + /// + /// Rejects anything that is not exactly the ring's shape: no suffix at all beyond `.jsonl`, or a + /// dot followed by digits only. `oura-raw.txt`, `oura-raw-.jsonl.bak` and a bare + /// `oura-raw.jsonl` (no ring id) are all nil. + public static func classify(filename: String, kinds: [String]) -> (kind: String, generation: Int)? { + for kind in kinds where filename.hasPrefix("oura-\(kind)-") { + guard let range = filename.range(of: ".jsonl") else { continue } + let tail = filename[range.upperBound...] // "" for the live file, ".3" for a generation + if tail.isEmpty { return (kind, 0) } + guard tail.hasPrefix("."), tail.count > 1 else { continue } + let digits = tail.dropFirst() + guard digits.allSatisfy(\.isNumber), let n = Int(digits) else { continue } + return (kind, n) + } + return nil + } + + /// One bundle entry and the files that make it up, already in concatenation order. + public struct Plan: Equatable { + /// Normalized bundle entry name, e.g. `oura-raw.jsonl`. + public let entryName: String + /// Filenames to concatenate, **OLDEST → NEWEST**. Never empty. + public let files: [String] + public init(entryName: String, files: [String]) { + self.entryName = entryName + self.files = files + } + } + + /// Plan the merge for every sidecar kind present in `files`. + /// + /// Files are grouped by kind, ordered newest-first (live, then generation 1, 2, …), and taken + /// until their cumulative size reaches `ceilingBytes`; the file that crosses the ceiling is + /// INCLUDED (so the boundary never silently drops the run that spans it) and everything older is + /// left on the device. The kept run is then returned oldest → newest, ready to concatenate. + /// + /// `ceilingBytes` is a READ bound, not a budget: the bundle cap is the thing that decides what + /// finally ships, and it can never keep more than the whole cap for one entry — so reading beyond + /// that is pure wasted memory. Pass the bundle's cap. A non-positive ceiling still yields the + /// single newest file per kind, because shipping nothing would be worse than shipping the tail. + /// + /// Deterministic: the result does not depend on the order of `files`, and ties between two files + /// of the same kind and generation (impossible from one ring, possible if two rings wrote the same + /// kind) break on the filename so the bundle listing stays stable. + /// + /// Returns one `Plan` per kind, in `kinds` order, so the bundle listing is deterministic too. + public static func mergePlan(files: [(name: String, bytes: Int)], + kinds: [String], + ceilingBytes: Int) -> [Plan] { + var byKind: [String: [(generation: Int, name: String, bytes: Int)]] = [:] + for file in files { + guard let hit = classify(filename: file.name, kinds: kinds) else { continue } + byKind[hit.kind, default: []].append((hit.generation, file.name, max(0, file.bytes))) + } + return kinds.compactMap { kind -> Plan? in + guard let group = byKind[kind], !group.isEmpty else { return nil } + // Newest first: generation 0 (live), then 1 (newest retained), … N (oldest). + let newestFirst = group.sorted { + $0.generation != $1.generation ? $0.generation < $1.generation : $0.name < $1.name + } + var kept: [String] = [] + var accumulated = 0 + for file in newestFirst { + kept.append(file.name) + accumulated += file.bytes + if accumulated >= ceilingBytes { break } + } + return Plan(entryName: entryName(kind: kind), files: kept.reversed()) + } + } +} diff --git a/Packages/WhoopStore/Sources/WhoopStore/OuraStreamMapping.swift b/Packages/WhoopStore/Sources/WhoopStore/OuraStreamMapping.swift index f7e7f06320..5cfcd4476a 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/OuraStreamMapping.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/OuraStreamMapping.swift @@ -50,10 +50,13 @@ public enum OuraStreamMapping { /// - `.hr` (0x55 live-HR push) → `hr:[HRSample]` /// - `.ibi` (0x44/0x60 IBI) → `rr:[RRInterval]` /// - `.hrv` (0x5D HRV tag, u8 hr/rmssd pairs) → `events:[WhoopEvent(kind: OURA_HRV)]` (one row per 5-min bucket) - /// - `.spo2` (0x6F/0x70/0x77) → `spo2:[SpO2Sample]`, carrying the decoder's own - /// `unit` tag. NOT all one quantity: 0x6F/0x70 are firmware-computed PERCENTAGES (tagged `"raw"`, + /// - `.spo2` (0x6F ONLY) → `spo2:[SpO2Sample]`, carrying the decoder's own + /// `unit` tag. NOT all one quantity: 0x6F is a firmware-computed PERCENTAGE (tagged `"raw"`, /// a legacy channel label — see `OuraDecoders.decodeSpO2PerSample`), while 0x77 is a genuine raw - /// DC channel tagged `"dc_raw"`. Both land in `SpO2Sample.red`; neither is written to `spo2Pct`. + /// DC channel tagged `"dc_raw"` on a wholly different scale. **0x77 is decoded but NOT persisted** + /// — it is filtered at the guard below, because blending two scales into one `red` column corrupts + /// any consumer that averages the stream without a unit filter. 0x7B (`"stable_raw"`, a u16 of + /// unpinned scale) is dropped by the same guard. Nothing is written to `spo2Pct`. /// - `.temp` (0x46/0x75) → `skinTemp:[SkinTempSample(raw_adc)]` /// - `.sleepPhase` (0x4E/0x5A 2-bit codes) → `events:[WhoopEvent(kind: OURA_SLEEP_PHASE)]` /// - `.battery` → `battery:[BatterySample]` @@ -124,11 +127,22 @@ public enum OuraStreamMapping { ])) case .spo2(let v): - // Oura reports a single SpO2 channel; `SpO2Sample` is the WHOOP-shaped two-channel raw row, - // so we record the decoded value on `red` and leave `ir` at 0 (no second channel). `unit` - // carries the decoder's own scale tag ("raw"/"dc_raw") so downstream never assumes a %. + // Only persist the 0x6F channel (`unit == "raw"`; 0x70 is Tier B and never reaches `.spo2`, + // and 0x7B tags itself `stable_raw` because its scale is unpinned): a real overnight capture + // (2026-07-30/31, 22516 samples) clusters tightly at 95-105, matching a genuine %SpO2 + // reading. The 0x77 DC channel (`unit == "dc_raw"`) is a wildly different-scale raw + // PPG/perfusion signal (-9K to +11.7M in the same capture) - not SpO2 at all, so blending + // it into `red` corrupts any consumer that averages the stream with no unit filter + // (`AnalyticsEngine.nightlySpo2RawMeans` still does; the ceiling-mean path only survives + // because it range-gates at 50...110). Still decoded, still reachable from + // `oura-raw.jsonl`; just never durably persisted. `SpO2Sample` is the WHOOP-shaped + // two-channel raw row, so we record the decoded value on `red` and leave `ir` at 0. + guard v.unit == "raw" else { continue } + + // `unit` still travels onto the row (always "raw" past the guard) so downstream never + // assumes a %. // - // Each sample gets its OWN second. `spo2Sample` is keyed (deviceId, ts), so the 13 samples + // Each surviving sample gets its OWN second. `spo2Sample` is keyed (deviceId, ts), so the 13 samples // of one 0x6F record written at the record's single `ts` collided and only the first // survived — 92% of an overnight silently discarded, and unrecoverable because the ring // trims its banked history once the offload is acked (#1070). The samples are one per 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..065ac728d2 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/Reads.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/Reads.swift @@ -1,3 +1,4 @@ +import Dispatch import Foundation import GRDB import WhoopProtocol @@ -8,13 +9,31 @@ import WhoopProtocol public struct HRBucket: Sendable, Equatable { public let ts: Int public let bpm: Double + /// The lowest and highest sample IN the bucket, not the bucket's mean. + /// + /// A chart plots `bpm`, which is what makes a day read as a curve rather than a spike field, but a + /// Min/Max readout taken from that series describes the calmest and busiest FIVE MINUTES rather than + /// the day. A forty-second interval is averaged against the four minutes around it before the reader + /// sees it, which is why a workout's max could exceed the day's (#2032). Same scan and grouping as + /// the mean, so carrying them costs nothing. + public let minBpm: Double + public let maxBpm: Double /// The WEAKEST signal confidence contributing to this bucket: 1.0 for measured `hrSample` /// rows, the stored autocorrelation `conf` for PPG-derived fallback rows. Lets a chart render /// a weak-optical stretch distinctly instead of identically to a clean measured beat. Defaults /// to 1.0 so existing constructors/tests are unchanged. (adopted from ryanAtriumAi #988 — /// purely additive surfacing; the acceptance floor itself is unchanged.) public let conf: Double - public init(ts: Int, bpm: Double, conf: Double = 1.0) { self.ts = ts; self.bpm = bpm; self.conf = conf } + /// `minBpm` and `maxBpm` are required rather than defaulted. A default would let a caller build a + /// bucket whose extremes silently equal its mean, which is precisely the shape of the bug (#2032): + /// a number that looks like a reading and describes something else. + public init(ts: Int, bpm: Double, minBpm: Double, maxBpm: Double, conf: Double = 1.0) { + self.ts = ts + self.bpm = bpm + self.minBpm = minBpm + self.maxBpm = maxBpm + self.conf = conf + } } /// Aggregate HR over a time window: sample count + mean/peak bpm. Result of [WhoopStore.hrWindowStats], @@ -60,6 +79,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 @@ -80,6 +116,58 @@ extension WhoopStore { } } + /// Whether `deviceId` has ANY heart rate in the window, as a scalar EXISTS rather than a row. + /// + /// The day-owner resolver asks this once per candidate per day, so a 60-day steps-calibration window + /// on a two-strap install asks it 120 times per pass. It used to be answered by fetching a `LIMIT 1` + /// ROW from `hrSamples` and testing the array for emptiness, which materialises a row and an + /// `HRSample` for a question whose answer is one bit. + /// + /// BOTH tables, because `hrSamples` is a UNION and "has heart rate" has always meant either of them. + /// A WHOOP 4.0 v25 record stores no per-second HR at all — it is PPG-derived and lands in + /// `ppgHrSample` — so checking `hrSample` alone would have quietly stopped those days from owning + /// themselves. The union's `NOT EXISTS` de-dupe does not affect PRESENCE: a ppgHr row suppressed + /// because an hrSample shares its ts implies hrSample is non-empty, so "union non-empty" is exactly + /// "hrSample non-empty OR ppgHrSample non-empty". OR short-circuits, so the common case is one probe. + /// Twin of Kotlin's `WhoopDao.hasHrInWindow`. + public func hasHrInWindow(deviceId: String, from: Int, to: Int) async throws -> Bool { + // Counted HERE rather than at the call site, mirroring the Kotlin repository: the resolver this + // serves is `nonisolated static`, so it has nowhere to accumulate, and the steps loop that drives + // most of these runs inside a detached task. One call site each makes the count unambiguous. + // See `StoreProbeTally` (StrandAnalytics). Instrumentation only. + let probeStarted = DispatchTime.now().uptimeNanoseconds + defer { StoreProbeRecorder.record(.ownerHr, nanos: DispatchTime.now().uptimeNanoseconds &- probeStarted) } + return try syncRead { db in + try Bool.fetchOne(db, sql: """ + SELECT EXISTS(SELECT 1 FROM hrSample WHERE deviceId = ? AND ts >= ? AND ts <= ?) + OR EXISTS(SELECT 1 FROM ppgHrSample WHERE deviceId = ? AND ts >= ? AND ts <= ?) + """, arguments: [deviceId, from, to, deviceId, from, to]) ?? false + } + } + + /// Per-day GRAVITY fingerprint: `(count, maxTs)` over one device's `gravitySample` rows in a window. + /// + /// The witness the steps-calibration motion cache reuses a day's fold against. `dayStreamFingerprint` + /// already computes this pair, but it computes it alongside eight other streams, so a new HR row would + /// invalidate a motion volume that cannot have changed. `StepsEstimateEngine.dayMotionIntensity` is a + /// pure fold over one day's gravity stream and nothing else, so its cache key must move when that + /// stream moves and at no other time. Same COUNT/COALESCE(MAX) shape and the same `(deviceId, ts)` + /// index as `hrFingerprint(deviceId:from:to:)` above, and never a row fetch. + public func gravityFingerprint(deviceId: String, from: Int, to: Int) async throws -> (count: Int, maxTs: Int) { + // Counted for the same reason as `hasHrInWindow` above; see `StoreProbeTally`. + let probeStarted = DispatchTime.now().uptimeNanoseconds + defer { StoreProbeRecorder.record(.gravityFp, nanos: DispatchTime.now().uptimeNanoseconds &- probeStarted) } + return try syncRead { db in + guard let row = try Row.fetchOne(db, sql: """ + SELECT COUNT(*) AS c, COALESCE(MAX(ts), 0) AS m FROM gravitySample + WHERE deviceId = ? AND ts >= ? AND ts <= ? + """, arguments: [deviceId, from, to]) else { return (0, 0) } + let c: Int = row["c"] + let m: Int = row["m"] + return (c, m) + } + } + /// Cross-device raw-HR fingerprint: `(count, maxTs)` over EVERY `hrSample` row, no `deviceId` filter. /// The `analyzeRecent` re-score gate (#1392) only needs to answer "did the raw stream change AT ALL", /// so it must see HR that lands under ANY id — an Oura ring, an Apple Watch, or a WHOOP re-added under a @@ -99,6 +187,128 @@ 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. + /// `v3` also witnesses authoritative WHOOP 5 RR promotions and registry-only source-policy changes. + /// The version changes the persisted watermark once so the normal recent window is recomputed. + /// Older persisted scores remain until explicitly rescored; raw legacy intervals stay on disk. + 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 COUNT(*) FROM rrInterval WHERE srcChannel = 5 + AND (tsSuspect IS NULL OR tsSuspect <> 1)) AS w5, + (SELECT COUNT(*) FROM rrInterval WHERE srcChannel = 7 + AND (tsSuspect IS NULL OR tsSuspect <> 1)) AS w7, + (SELECT COUNT(*) FROM rrInterval WHERE srcChannel IN (5, 6, 7)) AS w5tagged, + (SELECT COALESCE(GROUP_CONCAT(identity, ';'), '') FROM + (SELECT QUOTE(id) || ':' || QUOTE(brand) || ':' || QUOTE(model) || ':' || QUOTE(status) AS identity + FROM pairedDevice ORDER BY id)) AS registry, + (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) + } + let historyCount: Int = row["w5"] + let registry: String = row["registry"] + return "v3|h\(hc):\(hm)|" + tails.joined(separator: "|") + + "|w5\(historyCount)|w7\(row["w7"] as Int)|tagged\(row["w5tagged"] as Int)|registry\(registry)" + } + } + + /// 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`, which is reserved for 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 rrInterval WHERE deviceId = :d AND ts >= :f AND ts <= :t + AND srcChannel = 5 AND (tsSuspect IS NULL OR tsSuspect <> 1)) AS w5, + (SELECT COUNT(*) FROM rrInterval WHERE deviceId = :d AND ts >= :f AND ts <= :t + AND srcChannel = 7 AND (tsSuspect IS NULL OR tsSuspect <> 1)) AS w7, + EXISTS(SELECT 1 FROM rrInterval WHERE deviceId = :d AND srcChannel IN (5, 6, 7)) AS w5owner, + COALESCE((SELECT QUOTE(brand) || ':' || QUOTE(model) FROM pairedDevice + WHERE id = :d), 'absent') AS registry, + (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)" + } + let historyCount: Int = row["w5"] + let registry: String = row["registry"] + let strictRR = try Self.isWhoop5RRSource(db: db, deviceId: deviceId) + return "s2|" + parts.joined(separator: "|") + "|w5\(historyCount)|w7\(row["w7"] as Int)|ownerTagged\(row["w5owner"] as Int)|registry\(registry)|rr5=\(strictRR)" + } + } + /// 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. /// @@ -166,7 +376,8 @@ extension WhoopStore { // (conservative), and a purely-measured bucket stays 1.0. Purely additive projection: // the bpm aggregate and the anti-join semantics are byte-identical. (ryanAtriumAi #988) try Row.fetchAll(db, sql: """ - SELECT (ts / ?) * ? AS bucket, AVG(bpm) AS avgBpm, MIN(conf) AS minConf FROM ( + SELECT (ts / ?) * ? AS bucket, AVG(bpm) AS avgBpm, MIN(conf) AS minConf, + MIN(bpm) AS minBpm, MAX(bpm) AS maxBpm FROM ( SELECT ts, bpm, 1.0 AS conf FROM hrSample WHERE deviceId = ? AND ts >= ? AND ts <= ? UNION ALL @@ -182,7 +393,9 @@ extension WhoopStore { deviceId, from, to, deviceId, from, to, bucket]) - .map { HRBucket(ts: $0["bucket"], bpm: $0["avgBpm"], conf: $0["minConf"] ?? 1.0) } + .map { HRBucket(ts: $0["bucket"], bpm: $0["avgBpm"], + minBpm: $0["minBpm"], maxBpm: $0["maxBpm"], + conf: $0["minConf"] ?? 1.0) } } } @@ -190,7 +403,8 @@ extension WhoopStore { /// second's beats sorted by VALUE, which makes successive beats similar by construction and biases /// RMSSD — all successive differences — downward. Pre-v30 rows have `ord` NULL and SQLite sorts NULL /// first in ASC, so an all-NULL second ties and falls through to the old (rrMs, seq) order unchanged. - /// Byte-parity twin of Kotlin `WhoopDao.rrIntervals`; both are SQLite, so NULL ordering matches. + /// WHOOP 5 reads one verified transport; its unlabelled legacy rows remain stored but unscored. + /// Kotlin's repository routes to equivalent SQLite queries, including the same NULL ordering. /// /// ONE optical channel (#1071). An Oura ring measures the same heartbeats on more than one tag, and /// every one of them is stored, so an unfiltered read returned roughly TWO complete copies of a night @@ -200,8 +414,7 @@ extension WhoopStore { /// /// The predicate EXCLUDES the one channel proven redundant (`spo2Ibi`, 0x6E) rather than whitelisting /// the one preferred (`greenQuality`, 0x80), which matters for what it does NOT drop: - /// - NULL is kept. Every WHOOP row is NULL by construction (one beat source), as is every row - /// written before v32. A whitelist would delete every WHOOP night from scoring. + /// - Outside strict WHOOP 5 policy, NULL is kept for WHOOP 4 and unlabelled legacy rows. /// - `ibiAmplitude` (0x60/0x44) is kept. It does not fire on the Gen-3 hardware this was measured /// on, so there is no evidence it duplicates green — and dropping a ring's ONLY beat source on an /// untested assumption is the more expensive mistake. If a capture ever shows 0x60 and 0x80 firing @@ -214,18 +427,38 @@ extension WhoopStore { /// Every R-R consumer reads through this one function, so the `hrv diag` trace moves with the scores /// rather than reporting a coverage nobody can reproduce. public func rrIntervals(deviceId: String, from: Int, to: Int, limit: Int) async throws -> [RRInterval] { + try await rrIntervals(deviceId: deviceId, from: from, to: to, limit: limit, + unlabelledAliasOfWhoop5: false) + } + + /// A canonical alias of a WHOOP 5 may hold old mixed-unit rows. Apply its unit guard when the + /// alias has no confirmed identity; a known WHOOP 4 or another brand retains its own read policy. + public func rrIntervals(deviceId: String, from: Int, to: Int, limit: Int, + unlabelledAliasOfWhoop5: Bool) async throws -> [RRInterval] { try syncRead { db in - try Row.fetchAll(db, sql: """ - SELECT ts, rrMs, srcChannel, ord FROM rrInterval - WHERE deviceId = ? AND ts >= ? AND ts <= ? - AND (srcChannel IS NULL OR srcChannel <> ?) + let strictWhoop5 = try Self.isWhoop5RRSource(db: db, deviceId: deviceId, + unlabelledAliasOfWhoop5: unlabelledAliasOfWhoop5) + // One transport for the complete requested interval. Legacy WHOOP 5 rows mix units and + // origins, so they remain stored but cannot be converted or spliced into a scored beat train. + // This subquery uses the SAME time/suspect predicates as the outer read, before LIMIT. + let sourcePredicate = strictWhoop5 ? """ + srcChannel = (SELECT MIN(srcChannel) FROM rrInterval + WHERE deviceId = :d AND ts >= :f AND ts <= :t AND srcChannel IN \(Self.scorableWhoop5Channels) + AND (tsSuspect IS NULL OR tsSuspect <> 1)) + """ : "1" + return try Row.fetchAll(db, sql: """ + SELECT ts, rrMs, srcChannel, ord, seq FROM rrInterval + WHERE deviceId = :d AND ts >= :f AND ts <= :t + AND (srcChannel IS NULL OR srcChannel <> :rrx) + AND \(sourcePredicate) AND (tsSuspect IS NULL OR tsSuspect <> 1) -- #1073: exclude future-stamped beats - ORDER BY ts ASC, ord ASC, rrMs ASC, seq ASC LIMIT ? - """, arguments: [deviceId, from, to, RRSourceChannel.spo2Ibi.rawValue, limit]) + ORDER BY ts ASC, ord ASC, rrMs ASC, seq ASC LIMIT :lim + """, arguments: ["d": deviceId, "f": from, "t": to, + "rrx": RRSourceChannel.spo2Ibi.rawValue, "lim": limit]) .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 +530,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 +625,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 +721,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/SleepSessionDedup.swift b/Packages/WhoopStore/Sources/WhoopStore/SleepSessionDedup.swift index 9d9083660d..9123ed83d8 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/SleepSessionDedup.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/SleepSessionDedup.swift @@ -120,6 +120,19 @@ public enum SleepSessionDedup { dropped.sorted { $0.startTs < $1.startTs }) } + /// The bank-recency witness the post-upsert heal may hand `dedupe` for ONE device id's rows. + /// + /// `keptStarts` are the `startTs` of the sessions the analyze pass just banked under `computedId`. They + /// witness recency ONLY there: on a day scored from a device-provided hypnogram (an Oura ring) the pass's + /// sessions are that device's own stored rows with `startTs` copied verbatim, so the same keys name the + /// row the pass READ in the device's own table — the stalest row of the night by the time the heal runs, + /// not the freshest. Handing them to that sweep ranked the read row above every fuller re-serve banked + /// while the pass was in flight, and the heal deleted the full night. Every id but `computedId` therefore + /// gets no witness and falls back to longest-wins, the read-side default. Twin of Kotlin's `healWitness`. + public static func healWitness(for healId: String, computedId: String, keptStarts: Set) -> Set { + healId == computedId ? keptStarts : [] + } + // MARK: - #1284 residual 3: generation-side 0x49-onset keying (at-persist, no schema migration) /// The grid (seconds) the 0x49 onset is rounded to before it becomes a session's `startTs`. The ring @@ -139,6 +152,28 @@ public enum SleepSessionDedup { return ((onsetUnixSeconds + g / 2) / g) * g } + /// Guards the onset rekey against `OuraHypnogramBurst.codesWithTimes`'s own safety net: that + /// assembler silently falls back to the UNCLIPPED lay when clipping to the ring's `0x49` onset + /// would empty the burst entirely (a mis-paired window's onset landing after every real code) — + /// "so a mis-paired window can never empty the night." The caller has no signal that fallback + /// fired, and blindly re-keying `startTs` to that onset anyway wrote a NEGATIVE-DURATION session + /// (item 22, 2026-09-12: `startTs` 16 min after its own `endTs` on a 2-minute nap, because the + /// matched `0x49` window's onset was ~16 min later than the only two codes the ring actually wrote). + /// + /// A clip that genuinely bound leaves `mapped.startTs >= onset` by construction — the surviving + /// codes are exactly those with `ts >= onset`, so the first one can only sit at or after it. A clip + /// the fallback ignored leaves `mapped.startTs < onset` just as reliably, since EVERY written code + /// was earlier than onset (that is precisely why clipping to it would have emptied the burst). + /// Also rejects the rarer case where 30 s grid-rounding pushes a nearly-adjacent onset to or past + /// `endTs`, which would otherwise mint a zero/negative-duration session on its own. + /// + /// Returns the keyed start to bank, or `nil` to keep `mapped.startTs` unchanged (no rekey). + public static func safeKeyedStart(onset: Int, mapped: CachedSleepSession) -> Int? { + guard onset <= mapped.startTs else { return nil } + let keyed = keyedStart(onsetUnixSeconds: onset) + return keyed < mapped.endTs ? keyed : nil + } + /// Decide, at persist time, whether a freshly reconstructed `candidate` night should be banked and /// which already-stored rows it supersedes — the generation-side twin of the `dedupe` heal, so a /// duplicate is suppressed BEFORE it is banked (closing the window where the wrong night shows until 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/StoreProbeCounts.swift b/Packages/WhoopStore/Sources/WhoopStore/StoreProbeCounts.swift new file mode 100644 index 0000000000..0b824ef546 --- /dev/null +++ b/Packages/WhoopStore/Sources/WhoopStore/StoreProbeCounts.swift @@ -0,0 +1,104 @@ +import Foundation + +/// Per-pass call/time counters for the per-day lookups `analyzeRecent` makes, rendered by +/// `StrandAnalytics.StoreProbeTally`. Twin of the Kotlin `StoreProbeTally` counters. +/// +/// Counted one level down from the call sites, which is what makes the counts attribute themselves: +/// counting at the call sites would mean threading an accumulator through a `nonisolated static` resolver +/// and a detached task, and on Android through a method with no ratchet margin to spend. +/// +/// Drained per pass, which is exact for `gravityFingerprint` (the steps loop is its only caller) but NOT +/// quite for the other two: `IntelligenceEngine.resolveDayOwner` has a second Swift caller in +/// `SkinTempBackfillWalker`, a manual Test Centre action. Run it while a scoring pass is in flight and its +/// lookups and presence probes land in that pass's line. The Kotlin twin's resolver is private to the +/// engine, so this is one-sided as well as imprecise. +/// +/// Left as-is deliberately: suppressing it would have to reach through the resolver into the store's own +/// probe, and a global "stop counting" switch would UNDERCOUNT a genuine concurrent pass, which is the +/// worse failure. The CALL COUNT is the tell instead, which is why it prints beside the time. A clean pass +/// over a 21-day scoring window and a 60-day steps window resolves 81 owners; a line reporting many more +/// than that had company, and should be read as contaminated rather than as a slow lookup. +/// +/// Instrumentation only: nothing reads these but the diagnostic line. +public struct StoreProbeCounts: Sendable, Equatable { + /// One probe's calls and accumulated time. + public struct Probe: Sendable, Equatable { + public private(set) var calls = 0 + + /// Accumulated INTEGER nanoseconds, divided to seconds once when read rather than per call. + /// + /// The Kotlin twin sums `System.nanoTime()` deltas into an `AtomicLong` and divides once at render. + /// Converting each call to `Double` seconds instead would spend sixty to eighty roundings where the + /// twin spends none, and the two would then disagree in the last bits — which survives into the + /// rendered line whenever the true total sits near a half-millisecond boundary, the exact boundary + /// `StoreProbeTallyTests` pins. Integer accumulation makes the two provably the same arithmetic. + public private(set) var nanos: UInt64 = 0 + + /// Accumulated seconds, for the renderer. One division, matching the twin. + public var seconds: Double { Double(nanos) / 1_000_000_000 } + + /// Add one call of `nanos` monotonic nanoseconds. + /// + /// Nanoseconds from a MONOTONIC source, not a `Date()` difference, and that is the whole point of + /// the type. These counters exist to decide whether the per-day round trips are worth batching, so + /// a number that a clock correction can move is worse than no number: `Date()` steps in BOTH + /// directions, and while a backwards step is clamped away, a forward NTP jump mid-probe would + /// silently add itself to that probe's time and inflate the very total the decision reads. The + /// Kotlin twin counts `System.nanoTime()` for the same reason, so the two measure the same thing. + mutating func record(nanos elapsed: UInt64) { + calls += 1 + nanos &+= elapsed + } + } + + /// `DeviceRegistryStore.dayOwner` — the resolver's LOCKED-override lookup, which runs on every call + /// before any presence probe. Measured because the first cut of this instrumentation counted the probe + /// and not the lookup in front of it, so it reported the cheap half of owner resolution while a warm + /// pass still had seconds unaccounted for. + public var dayOwner = Probe() + /// `hasHrInWindow` — the day-owner resolver's per-candidate presence probe, in BOTH the scoring loop + /// and the sixty-day steps loop. Skipped entirely on a default single-strap install (#970). + public var ownerHr = Probe() + /// `gravityFingerprint` — the steps loop's per-day motion witness. Steps-only. + public var gravityFp = Probe() + + public init() {} +} + +/// Process-wide recorder for `StoreProbeCounts`. +/// +/// Nonisolated and lock-guarded rather than actor state, because the three call sites cannot share one +/// isolation: `gravityFingerprint` and `hasHrInWindow` are `WhoopStore` actor methods, while the day-owner +/// lookup runs inside `IntelligenceEngine.resolveDayOwner`, which is `nonisolated static` precisely so the +/// day loop does not hop back to the main actor on every iteration. A recorder only the actor could reach +/// would have measured two of the three and left the third invisible, which is the exact gap this round of +/// instrumentation exists to close. It also makes the shape identical to the Kotlin twin's `object` of +/// atomics rather than merely equivalent. +public enum StoreProbeRecorder { + private static let lock = NSLock() + nonisolated(unsafe) private static var counts = StoreProbeCounts() + + /// Which probe a measurement belongs to. + public enum Probe: Sendable { case dayOwner, ownerHr, gravityFp } + + /// Record one call. `elapsed` must come from a monotonic source. + public static func record(_ probe: Probe, nanos elapsed: UInt64) { + lock.lock() + defer { lock.unlock() } + switch probe { + case .dayOwner: counts.dayOwner.record(nanos: elapsed) + case .ownerHr: counts.ownerHr.record(nanos: elapsed) + case .gravityFp: counts.gravityFp.record(nanos: elapsed) + } + } + + /// Read the counters and zero them, so a line describes ONE pass and never accumulates across the + /// back-to-back passes an offload storm is made of. + public static func take() -> StoreProbeCounts { + lock.lock() + defer { lock.unlock() } + let taken = counts + counts = StoreProbeCounts() + return taken + } +} diff --git a/Packages/WhoopStore/Sources/WhoopStore/StreamStore.swift b/Packages/WhoopStore/Sources/WhoopStore/StreamStore.swift index 43bf0b7fe4..59c5386b84 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/StreamStore.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/StreamStore.swift @@ -2,6 +2,11 @@ import Foundation import GRDB import WhoopProtocol +private struct RRBatchSecond: Hashable { + let ts: Int + let transport: Int +} + extension WhoopStore { /// Deterministic JSON for an event payload (sorted keys so the same payload always /// serializes byte-identically, important for the natural-key dedupe and parity). @@ -58,14 +63,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 +149,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 +161,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 +171,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 @@ -187,24 +223,41 @@ extension WhoopStore { // DO NOTHING keeps the first row. The historical path delivers a second atomically. // Twin of Kotlin assignRrSeq. // - // v32 (#1071): `srcChannel` is the sensor channel that measured the beat, carried from the - // decoder that produced it. NULL for every WHOOP row (one beat source — there is no channel - // to name, and that is honest rather than a placeholder) and for any source that does not - // report one. Like `ord` it is OUTSIDE the key: two channels measuring the same beat can + // `srcChannel` carries Oura optical channels or WHOOP 5 transport provenance. WHOOP 4 + // and legacy rows stay NULL. Like `ord` it is OUTSIDE the key: two observations of a beat can // yield the same (ts, rrMs), and keying on the label would store both — which is precisely - // the double-count this fixes. `DO NOTHING` therefore keeps whichever arrived first and the - // second channel's copy of THAT exact beat is dropped at insert; the read filter is what - // separates the streams in general. - var seqByTsRr: [Int: [Int: Int]] = [:] - var ordByTs: [Int: Int] = [:] + // the double-count this fixes. A collision never inserts another beat. A newly observed + // canonical WHOOP 5 transport can promote the existing source and order below; the read + // filter separates sources across the full requested interval. + let promote = try db.cachedStatement(sql: """ + UPDATE rrInterval SET srcChannel = :source, ord = :ord + WHERE deviceId = :device AND ts = :ts AND rrMs = :rr AND seq = :seq + AND ((:source = 5 AND (srcChannel IS NULL OR srcChannel IN (6, 7))) + OR (:source = 7 AND (srcChannel IS NULL OR srcChannel = 6))) + """) + var seqByTsRr: [RRBatchSecond: [Int: Int]] = [:] + var ordByTs: [RRBatchSecond: Int] = [:] for r in streams.rr { - let seq = seqByTsRr[r.ts]?[r.rrMs] ?? 0 - seqByTsRr[r.ts, default: [:]][r.rrMs] = seq + 1 - let ord = ordByTs[r.ts] ?? 0 - ordByTs[r.ts] = ord + 1 + // A second's native historical array is atomic. A standard packet in the same + // batch must not change its order or the occurrence number of an equal interval. + let key = RRBatchSecond(ts: r.ts, + transport: r.srcChannel?.isWhoop5Transport == true ? r.srcChannel!.rawValue : 0) + let seq = seqByTsRr[key]?[r.rrMs] ?? 0 + seqByTsRr[key, default: [:]][r.rrMs] = seq + 1 + let ord = ordByTs[key] ?? 0 + ordByTs[key] = ord + 1 try stmt.execute(arguments: [deviceId, r.ts, r.rrMs, seq, ord, r.srcChannel?.rawValue]) - rr += db.changesCount + let inserted = db.changesCount + rr += inserted + if inserted == 0, let source = r.srcChannel, + source == .whoop5Historical || source == .whoop5Standard { + // Canonical precedence is history > standard > native/legacy. The winning + // observation supplies its order; values/keys and Oura labels remain intact. + // Cache fingerprints witness both canonical-source counts independently of inserts. + try promote.execute(arguments: ["source": source.rawValue, "ord": ord, + "device": deviceId, "ts": r.ts, "rr": r.rrMs, "seq": seq]) + } } } if !streams.events.isEmpty { @@ -284,9 +337,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 @@ -323,12 +382,14 @@ extension WhoopStore { // `packPpgSamples`) rather than 24 scalar rows, so this insert is O(records), not O(samples). if !streams.ppgWaveform.isEmpty { let stmt = try db.cachedStatement(sql: """ - INSERT INTO ppgWaveformSample (deviceId, ts, samples, burstIndex) VALUES (?, ?, ?, ?) + INSERT INTO ppgWaveformSample (deviceId, ts, samples, burstIndex, baseCode) + VALUES (?, ?, ?, ?, ?) ON CONFLICT(deviceId, ts) DO NOTHING """) for s in streams.ppgWaveform { try stmt.execute(arguments: [deviceId, s.ts, WhoopStore.packPpgSamples(s.samples), - s.burstIndex]) + s.burstIndex, s.baseCode]) + ppgWaveformWritten += 1 } } // Every remaining v18 slot (v31), one compact blob per strap-second. Persist-only, same as @@ -350,9 +411,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 +434,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 } @@ -634,13 +713,18 @@ extension WhoopStore { -> [PpgWaveformSample] { try syncRead { db in try Row.fetchAll(db, sql: """ - SELECT ts, samples, burstIndex FROM ppgWaveformSample + SELECT ts, samples, burstIndex, baseCode FROM ppgWaveformSample WHERE deviceId = ? AND ts >= ? AND ts <= ? ORDER BY ts LIMIT ? """, arguments: [deviceId, from, to, limit]) + // #2019: baseCode is SELECTed explicitly. This projection names its columns, so a new one + // is invisible to it until it is listed — the write would have banked the base and every + // read would have handed back nil, which is the same answer a legacy row gives and would + // have looked like the column doing nothing. .map { PpgWaveformSample(ts: $0["ts"], samples: WhoopStore.unpackPpgSamples($0["samples"]), - burstIndex: $0["burstIndex"]) } + burstIndex: $0["burstIndex"], + baseCode: $0["baseCode"]) } } } 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/Whoop5RRReadPolicy.swift b/Packages/WhoopStore/Sources/WhoopStore/Whoop5RRReadPolicy.swift new file mode 100644 index 0000000000..e135b30450 --- /dev/null +++ b/Packages/WhoopStore/Sources/WhoopStore/Whoop5RRReadPolicy.swift @@ -0,0 +1,102 @@ +import GRDB +import WhoopProtocol + +extension WhoopStore { + /// The transports a WHOOP 5 window may be SCORED through, as a SQL list. + /// + /// One constant rather than a literal per query. `rrIntervals` pins a window to the lowest of these + /// present, and `firstScorableWhoop5RRTimestamp` reports when the first of them was banked, so the + /// day the app tells a wearer its scoring begins is derived from the same set the scoring uses. Two + /// literals would let those drift apart silently, and the drift would show as an explanation that + /// names the wrong date. Type-40 live (6) is deliberately absent: it is a labelling channel that + /// standard BLE (7) already covers beat for beat. + static let scorableWhoop5Channels = "(5, 7)" + + /// The earliest beat this device has banked that the unit policy can actually score, or nil when it + /// has none at all. + /// + /// Cheap and device-level, not per-day: one indexed MIN over the beats already on disk. Carries the + /// same suspect-timestamp exclusion as the scoring read (#1073), so it cannot name a day that + /// scoring would then refuse. The caller turns it into a local day key; the calendar is the app's + /// policy, not the store's. + public func firstScorableWhoop5RRTimestamp(deviceId: String) async throws -> Int? { + try syncRead { db in + try Int.fetchOne(db, sql: """ + SELECT MIN(ts) FROM rrInterval + WHERE deviceId = ? AND srcChannel IN \(Self.scorableWhoop5Channels) + AND (tsSuspect IS NULL OR tsSuspect <> 1) + """, arguments: [deviceId]) + } + } + + /// The earliest beat this device has banked AT ALL, labelled or not, or nil when it has none. + /// + /// The lower bound on the "cannot be scored" explanation: it is what separates history this strap + /// actually recorded from history imported from somewhere else, and only the former can have lost + /// anything to a labelling change. Same shape and same suspect exclusion as the scorable read above. + public func firstRecordedRRTimestamp(deviceId: String) async throws -> Int? { + try syncRead { db in + try Int.fetchOne(db, sql: """ + SELECT MIN(ts) FROM rrInterval + WHERE deviceId = ? AND (tsSuspect IS NULL OR tsSuspect <> 1) + """, arguments: [deviceId]) + } + } + + /// Whether the strict WHOOP 5 read policy withheld this exact window solely because it contains + /// unlabelled, non-quarantined legacy rows and no verified scoring transport. This is deliberately + /// narrower than "the R-R read was empty": an unworn night, WHOOP 4, another brand, and a labelled + /// but insufficient transport all return false and therefore remain ordinary current-score outcomes. + public func legacyWhoop5RRWithheld(deviceId: String, from: Int, to: Int, + unlabelledAliasOfWhoop5: Bool = false) async throws -> Bool { + try syncRead { db in + guard try Self.isWhoop5RRSource(db: db, deviceId: deviceId, + unlabelledAliasOfWhoop5: unlabelledAliasOfWhoop5) else { + return false + } + return try Bool.fetchOne(db, sql: """ + SELECT + EXISTS(SELECT 1 FROM rrInterval + WHERE deviceId = :d AND ts >= :f AND ts <= :t + AND srcChannel IS NULL + AND (tsSuspect IS NULL OR tsSuspect <> 1)) + AND NOT EXISTS(SELECT 1 FROM rrInterval + WHERE deviceId = :d AND ts >= :f AND ts <= :t + AND srcChannel IN \(Self.scorableWhoop5Channels) + AND (tsSuspect IS NULL OR tsSuspect <> 1)) + """, arguments: ["d": deviceId, "f": from, "t": to]) ?? false + } + } + + /// Shared by RR reads and consumers whose cached/union reads must obey the same owner policy. + public func isWhoop5RRSource(deviceId: String, unlabelledAliasOfWhoop5: Bool = false) async throws -> Bool { + try syncRead { try Self.isWhoop5RRSource(db: $0, deviceId: deviceId, + unlabelledAliasOfWhoop5: unlabelledAliasOfWhoop5) } + } + + static func isWhoop5RRSource(db: Database, deviceId: String, + unlabelledAliasOfWhoop5: Bool = false) throws -> Bool { + let row = try Row.fetchOne(db, sql: "SELECT model, brand FROM pairedDevice WHERE id = ?", + arguments: [deviceId]) + let model: String? = row?["model"] + let brand: String? = row?["brand"] + // Only unknown identity needs wire evidence. Avoid scanning tagged rows for a known family. + let knownFamily = DeviceFamily.confirmedRegistryFamily(model: model, brand: brand) + let nonWhoop = brand.map { !$0.isEmpty && $0.lowercased() != "whoop" } ?? false + var tagged = false + if knownFamily == nil && !nonWhoop { + tagged = try Bool.fetchOne(db, sql: """ + SELECT EXISTS(SELECT 1 FROM rrInterval WHERE deviceId = ? AND srcChannel IN (5, 6, 7)) + """, arguments: [deviceId]) ?? false + // Re-pairing can leave legacy rows under the canonical alias while callers still hold + // that old ID. Resolve its active strap here so sleep edits and ordinary reads agree. + // Physical owners and confirmed WHOOP 4 history never inherit another strap's policy. + if !tagged && !unlabelledAliasOfWhoop5 && deviceId == "my-whoop", + let active = try String.fetchOne(db, sql: DeviceRegistryStore.activeDeviceIdSQL), + active != deviceId { + tagged = try isWhoop5RRSource(db: db, deviceId: active) + } + } + return Whoop5RR.usesCanonicalSource(model: model, brand: brand, hasTaggedIntervals: tagged || unlabelledAliasOfWhoop5) + } +} 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 new file mode 100644 index 0000000000..f6d9f884f3 --- /dev/null +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/DailySkinTempAbsoluteTests.swift @@ -0,0 +1,127 @@ +import XCTest +import GRDB +@testable import WhoopStore + +/// v40 (#1636): the nightly ABSOLUTE skin temperature, kept beside the deviation derived from it. +/// +/// The engine computed this mean on every pass and discarded it once `skinTempDevC` was taken, so a +/// wearer saw "+0.5 Δ°C" with no way to learn what it moved from. Twin of the Kotlin +/// `DailySkinTempAbsoluteMigrationTest` (v33 → v34). +final class DailySkinTempAbsoluteTests: XCTestCase { + + func testV40SkinTempAbsoluteColumnPresent() async throws { + let store = try await WhoopStore.inMemory() + let cols = try await store.columnNamesForTest(table: "dailyMetric") + XCTAssertTrue(cols.contains("skinTempC"), "dailyMetric missing v40 skinTempC column") + // The deviation column is untouched: every existing gate (illness, Charge) still reads it. + XCTAssertTrue(cols.contains("skinTempDevC"), "v40 must not disturb skinTempDevC") + } + + func testV40RoundTripKeepsAbsoluteAndDeviationDistinct() async throws { + let store = try await WhoopStore.inMemory() + // A febrile night from the report: 34.6 °C absolute, a deviation that reads as barely anything. + 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: 34.6) + try await store.upsertDailyMetrics([d], deviceId: "devA") + + let rows = try await store.dailyMetrics(deviceId: "devA", from: "2026-08-01", to: "2026-08-31") + XCTAssertEqual(rows.count, 1) + let row = try XCTUnwrap(rows.first) + XCTAssertEqual(try XCTUnwrap(row.skinTempC), 34.6, accuracy: 0.001, + "the absolute must survive the round trip") + XCTAssertEqual(try XCTUnwrap(row.skinTempDevC), 0.52, accuracy: 0.001, + "the deviation must persist independently of the absolute") + } + + func testOmittingTheAbsoluteKeepsItNil() async throws { + let store = try await WhoopStore.inMemory() + // Every pre-#1636 call site omits it (defaulted init), and a night scored before v40 has no + // absolute to report. Nil, never a fabricated 0.0 — which would read as a real temperature. + let bare = DailyMetric(day: "2026-08-15", totalSleepMin: nil, efficiency: nil, + deepMin: nil, remMin: nil, lightMin: nil, disturbances: nil, + restingHr: nil, avgHrv: 55.0, recovery: nil, strain: nil, + exerciseCount: nil, skinTempDevC: 0.1) + try await store.upsertDailyMetrics([bare], deviceId: "devA") + let rows = try await store.dailyMetrics(deviceId: "devA", from: "2026-08-15", to: "2026-08-15") + let row = try XCTUnwrap(rows.first) + XCTAssertNil(row.skinTempC, "an unscored night must not claim a temperature") + XCTAssertEqual(try XCTUnwrap(row.skinTempDevC), 0.1, accuracy: 0.001) + } + + /// An upsert over an existing day must not blank a previously-stored absolute by omission — the + /// DO UPDATE list has to carry the column or a later cache write silently erases it. + func testUpsertOverAnExistingDayUpdatesTheAbsolute() async throws { + let store = try await WhoopStore.inMemory() + let first = DailyMetric(day: "2026-08-20", totalSleepMin: 400, efficiency: 0.9, + deepMin: 80, remMin: 100, lightMin: 220, disturbances: 1, + restingHr: 51, avgHrv: 60.0, recovery: 0.7, strain: 9.0, + exerciseCount: 0, skinTempC: 33.9) + try await store.upsertDailyMetrics([first], deviceId: "devA") + let second = DailyMetric(day: "2026-08-20", totalSleepMin: 400, efficiency: 0.9, + deepMin: 80, remMin: 100, lightMin: 220, disturbances: 1, + restingHr: 51, avgHrv: 60.0, recovery: 0.7, strain: 9.0, + exerciseCount: 0, skinTempC: 34.4) + try await store.upsertDailyMetrics([second], deviceId: "devA") + let rows = try await store.dailyMetrics(deviceId: "devA", from: "2026-08-20", to: "2026-08-20") + XCTAssertEqual(rows.count, 1, "same (deviceId, day) must not duplicate") + 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/DatabaseIntegrityTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/DatabaseIntegrityTests.swift index 165ff56b57..01d3244be1 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/DatabaseIntegrityTests.swift +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/DatabaseIntegrityTests.swift @@ -106,4 +106,49 @@ final class DatabaseIntegrityTests: XCTestCase { XCTAssertEqual(DatabaseIntegrity.verdict(fromRows: ["ok", "Page 9 is never used"]), "Page 9 is never used") } + + /// Swift twin of the Kotlin `DataBackupIntegrityTest` readableComplaint cases: the verdict stays + /// forensic and verbatim, this is the half a person is shown. + func testReadableComplaintDropsTheBannerAndKeepsTheDiagnosis() { + // The reported shape: ONE row carrying the banner, a newline, then the only informative half. + XCTAssertEqual( + DatabaseIntegrity.readableComplaint("*** in database main ***\nPage 5 is never used"), + "Page 5 is never used" + ) + // Captured from a REAL corrupted SQLite file (pages scribbled over, header left intact) rather + // than invented: quick_check(1) answers with ONE row holding the banner, a newline, and the + // diagnosis. The exact shape the reported Toast was truncating. + XCTAssertEqual( + DatabaseIntegrity.readableComplaint("*** in database main ***\nTree 2 page 7: btreeInitPage() returns error code 11"), + "Tree 2 page 7: btreeInitPage() returns error code 11" + ) + // An attached database names itself, so the match cannot be on "main" alone. + XCTAssertEqual( + DatabaseIntegrity.readableComplaint("*** in database temp ***\nPage 9 is never used"), + "Page 9 is never used" + ) + // Banner and nothing else: an empty parenthesis would be worse, so the raw verdict survives. + XCTAssertEqual( + DatabaseIntegrity.readableComplaint("*** in database main ***"), + "*** in database main ***" + ) + // No banner at all: already the diagnosis, passes through untouched. + XCTAssertEqual( + DatabaseIntegrity.readableComplaint("row 12 missing from index sleepIdx"), + "row 12 missing from index sleepIdx" + ) + XCTAssertEqual( + DatabaseIntegrity.readableComplaint("*** in database main ***\nPage 5 is never used\nPage 9 is never used"), + "Page 5 is never used; Page 9 is never used" + ) + // A nonsense limit must not trap: prefix(-1) is a precondition failure, and a display helper is + // the last place that should be able to crash a failure path. + XCTAssertEqual(DatabaseIntegrity.readableComplaint("*** in database main ***\nPage 5", limit: 0).count, 1) + XCTAssertEqual(DatabaseIntegrity.readableComplaint("*** in database main ***\nPage 5", limit: -5).count, 1) + // Capped, because this rides inside a sentence that has to stay readable. + let long = DatabaseIntegrity.readableComplaint("*** in database main ***\n" + String(repeating: "x", count: 500), + limit: 40) + XCTAssertEqual(long.count, 40) + XCTAssertTrue(long.hasSuffix("\u{2026}")) + } } 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/GravityWitnessTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/GravityWitnessTests.swift new file mode 100644 index 0000000000..20c38a4262 --- /dev/null +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/GravityWitnessTests.swift @@ -0,0 +1,146 @@ +import XCTest +import WhoopProtocol +@testable import WhoopStore + +/// The contract the steps-calibration motion cache rests on. +/// +/// `StepsEstimateEngine.dayMotionIntensity` sums the distance between CONSECUTIVE gravity samples, so it +/// depends on the set of x/y/z values in a day AND on their order. The cache re-folds a day only when +/// `gravityFingerprint` moves, which is sound only while a day's gravity cannot change underneath an +/// unchanged `(count, maxTs)`. These pin the parts of that a test can reach; the rest is structural (the +/// reads are `ORDER BY ts ASC`, and nothing anywhere rewrites a `ts` or a vector in place). +final class GravityWitnessTests: XCTestCase { + + private func store() async throws -> WhoopStore { + let s = try await WhoopStore.inMemory() + try await s.upsertDevice(id: "dev1", mac: nil, name: nil) + try await s.upsertDevice(id: "other", mac: nil, name: nil) + return s + } + + private func grav(_ ts: Int, _ v: Double) -> GravitySample { + GravitySample(ts: ts, x: v, y: v, z: v, dynAccel: nil) + } + + /// The load-bearing one. Re-offloading a second that is already banked is absorbed by + /// `ON CONFLICT(deviceId, ts) DO NOTHING`: the stored vector is the FIRST one, not the newest. That is + /// what makes an unchanged witness mean an unchanged fold. Were this ever flipped to a REPLACE, the + /// values under a day would change while its count and newest timestamp held still, and the cache would + /// serve a motion volume for data it no longer describes — silently, with no failing read. + func testReinsertingAnExistingSecondChangesNeitherTheWitnessNorTheValues() async throws { + let s = try await store() + _ = try await s.insert(Streams(gravity: [grav(100, 1), grav(200, 2)]), deviceId: "dev1") + let before = try await s.gravityFingerprint(deviceId: "dev1", from: 0, to: 1000) + + _ = try await s.insert(Streams(gravity: [grav(100, 99), grav(200, 99)]), deviceId: "dev1") + let after = try await s.gravityFingerprint(deviceId: "dev1", from: 0, to: 1000) + XCTAssertEqual(before.count, after.count) + XCTAssertEqual(before.maxTs, after.maxTs) + + let rows = try await s.gravitySamples(deviceId: "dev1", from: 0, to: 1000, limit: 100) + XCTAssertEqual(rows.map { $0.x }, [1, 2], "conflict-ignore must keep the first vector, not the newest") + } + + /// A new second moves both halves, so the day re-folds. Both are asserted: a witness that moved only + /// its count would miss an append that replaced nothing, and one that moved only its newest timestamp + /// would miss a backfilled second older than the day's last. + func testAnAppendMovesTheWitnessAndABackfillMovesTheCount() async throws { + let s = try await store() + _ = try await s.insert(Streams(gravity: [grav(200, 1)]), deviceId: "dev1") + let one = try await s.gravityFingerprint(deviceId: "dev1", from: 0, to: 1000) + XCTAssertEqual(one.count, 1) + XCTAssertEqual(one.maxTs, 200) + + _ = try await s.insert(Streams(gravity: [grav(300, 1)]), deviceId: "dev1") + let appended = try await s.gravityFingerprint(deviceId: "dev1", from: 0, to: 1000) + XCTAssertEqual(appended.count, 2) + XCTAssertEqual(appended.maxTs, 300) + + // A second that lands BEFORE the day's newest: the timestamp holds, the count is the only witness + // that moves. An offload does not commit its channels in order, so this is the ordinary case. + _ = try await s.insert(Streams(gravity: [grav(100, 1)]), deviceId: "dev1") + let backfilled = try await s.gravityFingerprint(deviceId: "dev1", from: 0, to: 1000) + XCTAssertEqual(backfilled.count, 3) + XCTAssertEqual(backfilled.maxTs, 300, "a backfill leaves the newest timestamp alone") + } + + /// Scoped to one device and one window, because the cache key pairs the witness with a resolved owner + /// and a day. Another strap's gravity, or the same strap's gravity on another day, must not move it. + func testTheWitnessIsScopedToOneDeviceAndOneWindow() async throws { + let s = try await store() + _ = try await s.insert(Streams(gravity: [grav(100, 1), grav(200, 1)]), deviceId: "dev1") + let base = try await s.gravityFingerprint(deviceId: "dev1", from: 0, to: 250) + + _ = try await s.insert(Streams(gravity: [grav(150, 1)]), deviceId: "other") + _ = try await s.insert(Streams(gravity: [grav(900, 1)]), deviceId: "dev1") + let after = try await s.gravityFingerprint(deviceId: "dev1", from: 0, to: 250) + XCTAssertEqual(base.count, after.count) + XCTAssertEqual(base.maxTs, after.maxTs) + } + + /// An empty window is a real answer, not a missing one: `(0, 0)`. The cache stores zero folds under it + /// so an unworn gap stops re-reading its whole stream to rediscover that it is empty. + func testAnEmptyWindowReportsZeroRatherThanFailing() async throws { + let s = try await store() + let fp = try await s.gravityFingerprint(deviceId: "dev1", from: 0, to: 1000) + XCTAssertEqual(fp.count, 0) + XCTAssertEqual(fp.maxTs, 0) + } +} + +/// `hasHrInWindow` is the day-owner resolver's presence probe. It replaced a `LIMIT 1` fetch from +/// `hrSamples`, which is a UNION of `hrSample` and `ppgHrSample`, so it has to answer for BOTH tables or +/// it silently narrows which days a device can own. +final class HasHrInWindowTests: XCTestCase { + + private func store() async throws -> WhoopStore { + let s = try await WhoopStore.inMemory() + try await s.upsertDevice(id: "dev1", mac: nil, name: nil) + return s + } + + /// The case the probe exists to get right, and the one a `hrSample`-only check would have broken: a + /// WHOOP 4.0 v25 record stores no per-second heart rate at all, only the PPG-derived value, so a day + /// of them lives entirely in `ppgHrSample`. Under a narrower probe that device would have stopped + /// being a candidate to own its own day. + func testPpgDerivedHeartRateAloneCountsAsPresent() async throws { + let s = try await store() + _ = try await s.insert(Streams(ppgHr: [PpgHrSample(ts: 500, bpm: 61, conf: 0.9)]), deviceId: "dev1") + let viaUnion = try await s.hrSamples(deviceId: "dev1", from: 0, to: 1000, limit: 1) + XCTAssertFalse(viaUnion.isEmpty, "the union read must see it, or this test proves nothing") + let present = try await s.hasHrInWindow(deviceId: "dev1", from: 0, to: 1000) + XCTAssertTrue(present, "a day with only PPG-derived HR must still count as present") + } + + /// Ordinary strap HR, the common case. + func testStrapHeartRateCountsAsPresent() async throws { + let s = try await store() + _ = try await s.insert(Streams(hr: [HRSample(ts: 500, bpm: 60)]), deviceId: "dev1") + let present = try await s.hasHrInWindow(deviceId: "dev1", from: 0, to: 1000) + XCTAssertTrue(present) + } + + /// Absent means absent: outside the window, and on another device, both read false rather than + /// letting a neighbouring day or a second strap claim ownership. + func testAbsenceIsScopedToTheDeviceAndTheWindow() async throws { + let s = try await store() + try await s.upsertDevice(id: "dev2", mac: nil, name: nil) + _ = try await s.insert(Streams(hr: [HRSample(ts: 5_000, bpm: 60)]), deviceId: "dev1") + let outsideWindow = try await s.hasHrInWindow(deviceId: "dev1", from: 0, to: 1000) + XCTAssertFalse(outsideWindow) + let otherDevice = try await s.hasHrInWindow(deviceId: "dev2", from: 0, to: 10_000) + XCTAssertFalse(otherDevice) + } + + /// And it agrees with the read it replaced, which is the property that makes the swap safe. + func testItAgreesWithTheUnionReadItReplaced() async throws { + let s = try await store() + _ = try await s.insert(Streams(hr: [HRSample(ts: 100, bpm: 60)], + ppgHr: [PpgHrSample(ts: 900, bpm: 62, conf: 0.9)]), deviceId: "dev1") + for (from, to) in [(0, 50), (0, 150), (500, 1000), (0, 1000), (2_000, 3_000)] { + let viaUnion = !(try await s.hrSamples(deviceId: "dev1", from: from, to: to, limit: 1)).isEmpty + let viaExists = try await s.hasHrInWindow(deviceId: "dev1", from: from, to: to) + XCTAssertEqual(viaExists, viaUnion, "window \(from)...\(to)") + } + } +} 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/LiftLogStoreTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/LiftLogStoreTests.swift new file mode 100644 index 0000000000..7d701cc052 --- /dev/null +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/LiftLogStoreTests.swift @@ -0,0 +1,629 @@ +import XCTest +import GRDB +@testable import WhoopStore + +final class LiftLogStoreTests: XCTestCase { + + // MARK: - v46 migration (additive: five new tables + indexes, nothing dropped) + + func testV46CreatesLiftLogTables() async throws { + let store = try await WhoopStore.inMemory() + let tables = try await store.tableNames() + for t in ["liftExercise", "liftProgram", "liftProgramItem", "liftSession", "liftSet"] { + XCTAssertTrue(tables.contains(t), "missing table \(t)") + let pk = try await store.primaryKeyColumns(t) + XCTAssertEqual(pk, ["id"], "\(t) primary key should be id") + } + } + + func testV46ColumnsArePresent() async throws { + let store = try await WhoopStore.inMemory() + + let exercise = try await store.columnNamesForTest(table: "liftExercise") + for c in ["id", "deviceId", "name", "primaryMuscle", "secondaryMuscles", + "createdAt", "lastUsedTs"] { + XCTAssertTrue(exercise.contains(c), "liftExercise missing column \(c)") + } + + let program = try await store.columnNamesForTest(table: "liftProgram") + for c in ["id", "deviceId", "name", "note", "createdAt", "updatedAt", "archived"] { + XCTAssertTrue(program.contains(c), "liftProgram missing column \(c)") + } + + let item = try await store.columnNamesForTest(table: "liftProgramItem") + for c in ["id", "deviceId", "programId", "ord", "exercise", "targetSets", + "targetRepsLow", "targetRepsHigh", "targetRpe", "targetWeightKg", + "restSec", "note"] { + XCTAssertTrue(item.contains(c), "liftProgramItem missing column \(c)") + } + + let session = try await store.columnNamesForTest(table: "liftSession") + for c in ["id", "deviceId", "startTs", "endTs", "sport", "programId", "programName", + "sessionRpe", "note"] { + XCTAssertTrue(session.contains(c), "liftSession missing column \(c)") + } + + let set = try await store.columnNamesForTest(table: "liftSet") + for c in ["id", "deviceId", "sessionId", "ord", "exercise", "primaryMuscle", + "secondaryMuscles", "setIndex", "weightKg", "reps", "rpe", "isWarmup", + "startTs", "endTs", "restSec", "note"] { + XCTAssertTrue(set.contains(c), "liftSet missing column \(c)") + } + } + + func testV46CreatesIndexes() async throws { + let store = try await WhoopStore.inMemory() + let exercise = try await store.indexNamesForTest(table: "liftExercise") + XCTAssertTrue(exercise.contains("idx_liftExercise_natural")) + + let program = try await store.indexNamesForTest(table: "liftProgram") + XCTAssertTrue(program.contains("idx_liftProgram_device_updatedAt")) + + let item = try await store.indexNamesForTest(table: "liftProgramItem") + XCTAssertTrue(item.contains("idx_liftProgramItem_device")) + XCTAssertTrue(item.contains("idx_liftProgramItem_program_ord")) + + let session = try await store.indexNamesForTest(table: "liftSession") + XCTAssertTrue(session.contains("idx_liftSession_natural")) + + let set = try await store.indexNamesForTest(table: "liftSet") + XCTAssertTrue(set.contains("idx_liftSet_device_exercise")) + XCTAssertTrue(set.contains("idx_liftSet_session_ord")) + } + + /// Additive: v46 must not drop any table that existed before it. + func testV46IsAdditive() async throws { + let store = try await WhoopStore.inMemory() + let tables = try await store.tableNames() + for t in ["device", "hrSample", "rrInterval", "event", "battery", "rawBatch", + "sleepSession", "dailyMetric", "journal", "workout", "appleDaily", + "metricSeries", "pairedDevice", "dayOwnership", "labMarker", "appleStepHour"] { + XCTAssertTrue(tables.contains(t), "v46 must not drop \(t)") + } + } + + // MARK: - Targets and the session rating, stored as numbers + + func testTargetWeightRoundTripsOnAProgramLine() async throws { + let store = try await WhoopStore.inMemory() + let programId = UUID().uuidString + let item = LiftProgramItemRow( + id: UUID().uuidString, deviceId: "dev", programId: programId, ord: 0, + exercise: "Back squat", targetSets: 5, targetRepsLow: 5, targetRepsHigh: nil, + targetRpe: nil, targetWeightKg: 102.5, restSec: 180, note: nil) + _ = try await store.replaceLiftProgramItems(programId: programId, items: [item]) + + let back = try await store.liftProgramItems(programId: programId) + XCTAssertEqual(back.count, 1) + XCTAssertEqual(back[0].targetWeightKg, 102.5) + XCTAssertEqual(back[0].targetRepsLow, 5) + } + + func testSessionRpeRoundTripsAsANumber() async throws { + let store = try await WhoopStore.inMemory() + let row = LiftSessionRow( + id: UUID().uuidString, deviceId: "dev", startTs: 1_700_000_000, endTs: 1_700_003_600, + sport: "Strength Training", programId: nil, programName: "Upper A", + sessionRpe: 7.5, note: nil) + _ = try await store.upsertLiftSessions([row]) + + let back = try await store.liftSessions(deviceId: "dev", fromTs: 1_700_000_000, + toTs: 1_700_000_000).first + XCTAssertEqual(back?.sessionRpe, 7.5) + } + + func testSessionRpeIsOptionalSoASkippedRatingIsNotAZero() async throws { + let store = try await WhoopStore.inMemory() + let row = LiftSessionRow( + id: UUID().uuidString, deviceId: "dev", startTs: 1_700_000_500, endTs: nil, + sport: "Strength Training", programId: nil, programName: nil, + sessionRpe: nil, note: nil) + _ = try await store.upsertLiftSessions([row]) + + let back = try await store.liftSessions(deviceId: "dev", fromTs: 1_700_000_500, + toTs: 1_700_000_500).first + XCTAssertNil(back?.sessionRpe, + "a skipped rating must stay nil — a 0 would read as 'effortless' and corrupt the load") + } + + // MARK: - The vocabulary cap + + func testAnExistingExerciseAlwaysUpdatesEvenAtTheCap() async throws { + let store = try await WhoopStore.inMemory() + let row = LiftExerciseRow(id: UUID().uuidString, deviceId: "dev", name: "Bench press", + primaryMuscle: .chest, secondaryMuscles: [], + createdAt: 1_700_000_000, lastUsedTs: nil) + _ = try await store.upsertLiftExercises([row]) + // Re-saving a KNOWN name is an update, not a new entry — it must never be refused, or a user + // at the cap could no longer fix the classification of something they train weekly. + var reclassified = row + reclassified.primaryMuscle = .triceps + _ = try await store.upsertLiftExercises([reclassified]) + + let back = try await store.liftExercises(deviceId: "dev") + XCTAssertEqual(back.count, 1) + XCTAssertEqual(back[0].primaryMuscle, .triceps) + } + + func testANewExerciseIsRefusedOnceTheVocabularyIsFull() async throws { + let store = try await WhoopStore.inMemory() + let limit = WhoopStore.maxRememberedExercises + let rows = (0.. LiftProgramRow { + LiftProgramRow(id: id, deviceId: dev, name: name, note: nil, + createdAt: 1, updatedAt: updatedAt, archived: archived) + } + + private func mkItem(id: String, ord: Int, exercise: String = "Leg Press", + programId: String = "p1") -> LiftProgramItemRow { + LiftProgramItemRow(id: id, deviceId: dev, programId: programId, ord: ord, + exercise: exercise, targetSets: 3, + targetRepsLow: 8, targetRepsHigh: 10, targetRpe: 7.5, + targetWeightKg: nil, + restSec: 180, note: "Lower slowly.") + } + + private func mkSession(id: String, startTs: Int, endTs: Int? = nil, + programId: String? = "p1") -> LiftSessionRow { + LiftSessionRow(id: id, deviceId: dev, startTs: startTs, endTs: endTs, sport: sport, + programId: programId, programName: "Upper A", sessionRpe: nil, note: nil) + } + + private func mkSet(id: String, sessionId: String, ord: Int, setIndex: Int, + exercise: String = "Leg Press", weightKg: Double? = 60, + reps: Int? = 10, primary: LiftMuscle? = .quads, + secondary: [LiftMuscle] = [.glutes], rpe: Double? = 8, + isWarmup: Bool = false) -> LiftSetRow { + LiftSetRow(id: id, deviceId: dev, sessionId: sessionId, ord: ord, exercise: exercise, + primaryMuscle: primary, secondaryMuscles: secondary, setIndex: setIndex, + weightKg: weightKg, reps: reps, rpe: rpe, isWarmup: isWarmup, + startTs: nil, endTs: nil, restSec: nil, note: nil) + } +} diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/MetricsCacheTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/MetricsCacheTests.swift index f6cb7d549a..3ac96a6763 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/MetricsCacheTests.swift +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/MetricsCacheTests.swift @@ -21,6 +21,17 @@ final class MetricsCacheTests: XCTestCase { // MARK: - sleep sessions + /// The same session as it reads back from `deviceId`. `sleepSessions` stamps the device it read the + /// row from, and a hand-built session carries no provenance, so a whole-value expectation has to + /// state it. The write takes its device from the `upsertSleepSessions` argument, never from this + /// field, so a session's `deviceId` is an output of the read and not an input to the write. + private func readBack(_ s: CachedSleepSession, from deviceId: String) -> CachedSleepSession { + CachedSleepSession(startTs: s.startTs, endTs: s.endTs, efficiency: s.efficiency, + restingHr: s.restingHr, avgHrv: s.avgHrv, stagesJSON: s.stagesJSON, + userEdited: s.userEdited, startTsAdjusted: s.startTsAdjusted, + stagingSparse: s.stagingSparse, deviceId: deviceId) + } + func testSleepSessionUpsertReadAndIdempotency() async throws { let store = try await WhoopStore.inMemory() let s = CachedSleepSession(startTs: 1000, endTs: 5000, efficiency: 0.92, @@ -30,17 +41,91 @@ final class MetricsCacheTests: XCTestCase { var rows = try await store.sleepSessions(deviceId: "devA", from: 0, to: 100_000, limit: 100) XCTAssertEqual(rows.count, 1) - XCTAssertEqual(rows[0], s) + XCTAssertEqual(rows[0], readBack(s, from: "devA")) - // 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], readBack(full, from: "oura-ring"), + "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], readBack(full, from: "oura-ring"), + "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/OuraRingEpochScanTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/OuraRingEpochScanTests.swift new file mode 100644 index 0000000000..f3a53a1d14 --- /dev/null +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/OuraRingEpochScanTests.swift @@ -0,0 +1,137 @@ +import XCTest +@testable import WhoopStore + +/// #2252: the ticks×10 defect (#2239) filed whole sessions in the past, and the store keeps no +/// ring-time, so nothing there can say which rows they were. The decoded sidecars keep both axes, so +/// the epoch each row implies (`utc - ringTs/10`) separates a mis-anchored session from an honest one. +/// +/// Kotlin twin: `OuraRingEpochScanTest.kt`. +final class OuraRingEpochScanTests: XCTestCase { + + /// The ring really runs at 9.94 to 10.25 ticks per second, so a correctly anchored session's implied + /// epoch DRIFTS as `ringTs/10` accumulates error. 2.5% over a fortnight is about eight hours. + private func session(bootUnix: Int, startTicks: UInt32, count: Int, + tickRate: Double = 10.25, shiftSeconds: Int = 0) -> [(ringTs: UInt32, utc: Int)] { + (0.. 1 { + XCTAssertGreaterThanOrEqual(once[0].rows, once[1].rows, + "an epoch tie breaks on row count, identically on both platforms") + } + } + + func testEmptyInputYieldsNoClusters() { + XCTAssertTrue(OuraRingEpochScan.cluster([]).isEmpty) + } + + /// Order in, order out: the sidecars are appended per connection and may be concatenated in any + /// order, so the result must depend on the values rather than on how they arrived. + func testResultDoesNotDependOnInputOrder() { + let boot = 1_789_000_000 + let rows = session(bootUnix: boot, startTicks: 3_000, count: 40) + + session(bootUnix: boot, startTicks: 4_006_498, count: 20, shiftSeconds: 3_605_848) + XCTAssertEqual(OuraRingEpochScan.cluster(rows), OuraRingEpochScan.cluster(rows.reversed())) + } + + /// The gap that closes a cluster is measured against the PREVIOUS row, not the run's first, so a long + /// steady drift stays one cluster while a genuine jump splits. + func testASteadyDriftPastToleranceStillClustersAsOne() { + // Each step is an hour of implied-epoch drift: well inside the six-hour gap, far past it in total. + // Hoisted rather than written inline: the one-line form exceeds the type-checker's budget. + var rows: [(ringTs: UInt32, utc: Int)] = [] + let boot = 1_789_000_000 + for i in 0..<12 { + let ticks: Int = 3_000 + i * 3_000 + let driftSeconds: Int = i * 3_600 + let utc: Int = boot + ticks / 10 + driftSeconds + rows.append((UInt32(ticks), utc)) + } + XCTAssertEqual(OuraRingEpochScan.cluster(rows).count, 1) + } +} diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/OuraSerialIdentityTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/OuraSerialIdentityTests.swift new file mode 100644 index 0000000000..dbad703941 --- /dev/null +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/OuraSerialIdentityTests.swift @@ -0,0 +1,20 @@ +import XCTest +@testable import WhoopStore + +final class OuraSerialIdentityTests: XCTestCase { + func testLogSafeNeverLeaksTheFullSerial() { + XCTAssertEqual(OuraSerialIdentity.logSafe(serial: "2H3B2405003655"), "2H3…") + XCTAssertEqual(OuraSerialIdentity.logSafe(serial: "2038082631034041"), "203…") + XCTAssertEqual(OuraSerialIdentity.logSafe(serial: nil), "?") + XCTAssertEqual(OuraSerialIdentity.logSafe(serial: " "), "?") + XCTAssertFalse(OuraSerialIdentity.logSafe(serial: "2H3B2405003655").contains("2405003655")) + } + + func testLogSafeUppercasesAndTrims() { + XCTAssertEqual(OuraSerialIdentity.logSafe(serial: " 2h3b2405003655 "), "2H3…") + } + + func testIdPrefixMatchesTheBrandCatalog() { + XCTAssertEqual(OuraSerialIdentity.idPrefix, "oura") + } +} diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/OuraSidecarGenerationsTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/OuraSidecarGenerationsTests.swift new file mode 100644 index 0000000000..7ea6ff4727 --- /dev/null +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/OuraSidecarGenerationsTests.swift @@ -0,0 +1,140 @@ +import XCTest +@testable import WhoopStore + +/// Pins the export's generation-selection rule. These run under `swift test` with no app, no strap and +/// no CoreBluetooth, which matters because the caller (`TestBundleAssembler`) is app-target Swift that +/// no default CI job compiles — this is the only automated cover the rule has. +final class OuraSidecarGenerationsTests: XCTestCase { + + private let kinds = ["raw", "cva-ppg", "motion", "activity"] + + // MARK: - classify + + func testClassifyLiveFileIsGenerationZero() { + let hit = OuraSidecarGenerations.classify(filename: "oura-raw-AABB.jsonl", kinds: kinds) + XCTAssertEqual(hit?.kind, "raw") + XCTAssertEqual(hit?.generation, 0) + } + + func testClassifyRolledGenerations() { + for n in 1...8 { + let hit = OuraSidecarGenerations.classify(filename: "oura-raw-AABB.jsonl.\(n)", kinds: kinds) + XCTAssertEqual(hit?.kind, "raw") + XCTAssertEqual(hit?.generation, n, "generation \(n)") + } + // Multi-digit, so the ring is not silently capped at nine. + XCTAssertEqual(OuraSidecarGenerations.classify(filename: "oura-raw-AABB.jsonl.12", + kinds: kinds)?.generation, 12) + } + + func testClassifyRejectsNonRingShapes() { + // Not a JSONL sidecar, a non-numeric suffix, a trailing dot, and no ring id at all. + for bad in ["oura-raw.txt", "oura-raw-AABB.jsonl.bak", "oura-raw-AABB.jsonl.", + "oura-raw-AABB.jsonl.1a", "raw-capture.jsonl", "whoop.sqlite", "oura-raw.jsonl"] { + XCTAssertNil(OuraSidecarGenerations.classify(filename: bad, kinds: kinds), bad) + } + // A kind we do not collect. + XCTAssertNil(OuraSidecarGenerations.classify(filename: "oura-unknown-AABB.jsonl", kinds: kinds)) + } + + func testEntryNameDropsRingId() { + XCTAssertEqual(OuraSidecarGenerations.entryName(kind: "raw"), "oura-raw.jsonl") + } + + // MARK: - mergePlan ordering (the actual defect) + + /// THE REGRESSION TEST. Under the old largest-wins rule this exact input shipped `gen 2` alone — + /// the fattest file — and the newest capture never left the device. The plan must now END with the + /// live file, because the cap keeps the TAIL. + func testMergePlanEndsWithTheNewestFileEvenWhenAnOlderGenerationIsFarLarger() { + let plan = OuraSidecarGenerations.mergePlan( + files: [("oura-raw-AABB.jsonl", 100), // live: this morning's drain, small + ("oura-raw-AABB.jsonl.1", 500), + ("oura-raw-AABB.jsonl.2", 9_000)], // yesterday's backfill, the old winner + kinds: kinds, ceilingBytes: 1_000_000) + XCTAssertEqual(plan.count, 1) + XCTAssertEqual(plan[0].entryName, "oura-raw.jsonl") + XCTAssertEqual(plan[0].files, ["oura-raw-AABB.jsonl.2", + "oura-raw-AABB.jsonl.1", + "oura-raw-AABB.jsonl"]) + XCTAssertEqual(plan[0].files.last, "oura-raw-AABB.jsonl", "newest must be last") + } + + func testMergePlanIsIndependentOfInputOrder() { + let forward: [(name: String, bytes: Int)] = [("oura-raw-A.jsonl", 1), ("oura-raw-A.jsonl.1", 2), + ("oura-raw-A.jsonl.2", 3)] + let a = OuraSidecarGenerations.mergePlan(files: forward, kinds: kinds, ceilingBytes: 1_000) + let b = OuraSidecarGenerations.mergePlan(files: forward.reversed(), kinds: kinds, ceilingBytes: 1_000) + XCTAssertEqual(a, b) + } + + func testMergePlanOrdersEntriesByKindsNotByDiscovery() { + let plan = OuraSidecarGenerations.mergePlan( + files: [("oura-motion-A.jsonl", 1), ("oura-raw-A.jsonl", 1), ("oura-cva-ppg-A.jsonl", 1)], + kinds: kinds, ceilingBytes: 1_000) + XCTAssertEqual(plan.map(\.entryName), ["oura-raw.jsonl", "oura-cva-ppg.jsonl", "oura-motion.jsonl"]) + } + + func testMergePlanSkipsKindsWithNoFiles() { + let plan = OuraSidecarGenerations.mergePlan(files: [("oura-raw-A.jsonl", 1)], + kinds: kinds, ceilingBytes: 1_000) + XCTAssertEqual(plan.map(\.entryName), ["oura-raw.jsonl"]) + } + + // MARK: - mergePlan ceiling + + /// The ceiling is a READ bound. The file that crosses it is kept, so the run spanning the boundary + /// is never half-read; everything strictly older is left on the device. + func testMergePlanStopsAtCeilingButIncludesTheCrossingFile() { + let plan = OuraSidecarGenerations.mergePlan( + files: [("oura-raw-A.jsonl", 60), // newest + ("oura-raw-A.jsonl.1", 60), // crosses the 100 ceiling -> included + ("oura-raw-A.jsonl.2", 60)], // older -> dropped + kinds: kinds, ceilingBytes: 100) + XCTAssertEqual(plan[0].files, ["oura-raw-A.jsonl.1", "oura-raw-A.jsonl"]) + } + + /// A single file at or over the ceiling reads exactly as it did before this change — one file, the + /// newest — so the memory profile never regresses against largest-wins. + func testMergePlanKeepsOnlyTheNewestWhenItAlreadyFillsTheCeiling() { + let plan = OuraSidecarGenerations.mergePlan( + files: [("oura-raw-A.jsonl", 500), ("oura-raw-A.jsonl.1", 500)], + kinds: kinds, ceilingBytes: 100) + XCTAssertEqual(plan[0].files, ["oura-raw-A.jsonl"]) + } + + /// Shipping nothing would be strictly worse than shipping the newest tail, so a degenerate ceiling + /// still yields one file rather than an empty plan. + func testMergePlanWithNonPositiveCeilingStillShipsTheNewestFile() { + for ceiling in [0, -1] { + let plan = OuraSidecarGenerations.mergePlan( + files: [("oura-raw-A.jsonl", 10), ("oura-raw-A.jsonl.1", 10)], + kinds: kinds, ceilingBytes: ceiling) + XCTAssertEqual(plan[0].files, ["oura-raw-A.jsonl"], "ceiling \(ceiling)") + } + } + + func testMergePlanNeverReturnsAnEmptyFileList() { + let plan = OuraSidecarGenerations.mergePlan( + files: [("oura-raw-A.jsonl", 0)], kinds: kinds, ceilingBytes: 1_000) + XCTAssertEqual(plan[0].files, ["oura-raw-A.jsonl"]) + } + + func testMergePlanIgnoresUnrelatedFiles() { + let plan = OuraSidecarGenerations.mergePlan( + files: [("raw-capture.jsonl", 10), ("whoop.sqlite", 10), ("oura-raw-A.jsonl", 10)], + kinds: kinds, ceilingBytes: 1_000) + XCTAssertEqual(plan.count, 1) + XCTAssertEqual(plan[0].files, ["oura-raw-A.jsonl"]) + } + + /// Two rings that wrote the same kind collide on generation. The tie must break deterministically + /// rather than by directory order, or the bundle listing changes between exports. + func testMergePlanBreaksSameGenerationTiesOnFilename() { + let plan = OuraSidecarGenerations.mergePlan( + files: [("oura-raw-BBBB.jsonl", 10), ("oura-raw-AAAA.jsonl", 10)], + kinds: kinds, ceilingBytes: 1_000) + // Newest-first sorts A before B; reversed for concatenation, B lands last. + XCTAssertEqual(plan[0].files, ["oura-raw-BBBB.jsonl", "oura-raw-AAAA.jsonl"]) + } +} diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/OuraStreamMappingTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/OuraStreamMappingTests.swift index 2a489f7294..b7b4474f5f 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/OuraStreamMappingTests.swift +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/OuraStreamMappingTests.swift @@ -129,11 +129,30 @@ final class OuraStreamMappingTests: XCTestCase { .spo2(OuraSpO2(ringTimestamp: 100, value: 970, unit: "raw")), .spo2(OuraSpO2(ringTimestamp: 101, value: 12345, unit: "dc_raw")), ], at: ts) - XCTAssertEqual(s.spo2.map { $0.red }, [970, 12345]) - XCTAssertEqual(s.spo2.map { $0.ir }, [0, 0]) - XCTAssertEqual(s.spo2.map { $0.unit }, ["raw", "dc_raw"]) + // Only the "raw" 0x6F channel persists: a real capture shows it clustering at 95-105 + // (genuine %SpO2), while "dc_raw" (0x77) is a wildly different-scale raw PPG/perfusion signal + // (-9K to +11.7M) that would corrupt a blind mean over `red` if it were mixed in. + XCTAssertEqual(s.spo2.map { $0.red }, [970]) + XCTAssertEqual(s.spo2.map { $0.ir }, [0]) + XCTAssertEqual(s.spo2.map { $0.unit }, ["raw"]) // Single-sample records (count == 1) keep the record's own second, exactly as before #1070. - XCTAssertEqual(s.spo2.map { $0.ts }, [ts, ts]) + XCTAssertEqual(s.spo2.map { $0.ts }, [ts]) + } + + func testSpO2DcRawChannelIsDroppedNotPersisted() { + let s = OuraStreamMapping.streams(from: [ + .spo2(OuraSpO2(ringTimestamp: 100, value: 11_709_098, unit: "dc_raw")), + ], at: ts) + XCTAssertTrue(s.spo2.isEmpty) + } + + /// 0x7B's scale is unpinned, so its decoded sample must not reach the SpO2 % column. Fed through the + /// real decoder (not a hand-set unit) so a decoder that regresses to the 0x6F tag fails here. + func testSpO2StableChannelIsDroppedNotPersisted() throws { + let rec = OuraRecord(type: 0x7B, ringTimestamp: 100, payload: [0x03, 0xCA]) + let stable = try XCTUnwrap(OuraDecoders.decodeSpO2Stable(rec)) + let s = OuraStreamMapping.streams(from: [.spo2(stable)], at: ts) + XCTAssertTrue(s.spo2.isEmpty) } // #1070: `spo2Sample` is keyed (deviceId, ts). A 0x6F record's 13 per-second samples used to be diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/PpgWaveformSampleTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/PpgWaveformSampleTests.swift index d3217e97b0..97972c9379 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/PpgWaveformSampleTests.swift +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/PpgWaveformSampleTests.swift @@ -31,19 +31,27 @@ final class PpgWaveformSampleTests: XCTestCase { func testPpgWaveformTableShape() async throws { let store = try await WhoopStore.inMemory() let cols = try await store.columnNamesForTest(table: "ppgWaveformSample") - XCTAssertEqual(Set(cols), ["deviceId", "ts", "samples", "burstIndex"]) + // #2019: `baseCode` is the absolute optical code `samples` are deltas from. Nullable and + // additive, so an existing row keeps its deltas and reads null, which is the true statement + // about it: a delta series cannot be inverted without its starting point. + XCTAssertEqual(Set(cols), ["deviceId", "ts", "samples", "burstIndex", "baseCode"]) } func testPpgWaveformInsertRoundTripAndDedup() async throws { let store = try await WhoopStore.inMemory() - let streams = Streams(ppgWaveform: [PpgWaveformSample(ts: 1_780_917_232, - samples: realSamples, burstIndex: 7)]) + // #2019: with a baseCode, so the round trip proves the column is WRITTEN and READ. The read is a + // named projection, so a new column is invisible to it until it is listed, and a base that banked + // but never came back would read as nil, which is exactly what a legacy row reads as. + let streams = Streams(ppgWaveform: [PpgWaveformSample(ts: 1_780_917_232, samples: realSamples, + burstIndex: 7, baseCode: 378_307)]) _ = try await store.insert(streams, deviceId: "my-whoop") let n1 = try await store.ppgWaveformCountForTest() XCTAssertEqual(n1, 1) let read = try await store.ppgWaveformSamples(deviceId: "my-whoop", from: 1_780_917_232, to: 1_780_917_232) - XCTAssertEqual(read, [PpgWaveformSample(ts: 1_780_917_232, samples: realSamples, burstIndex: 7)]) + XCTAssertEqual(read, [PpgWaveformSample(ts: 1_780_917_232, samples: realSamples, burstIndex: 7, + baseCode: 378_307)]) + XCTAssertEqual(read.first?.baseCode, 378_307, "the absolute base must survive the round trip") // Re-inserting the same (deviceId, ts) is idempotent, ON CONFLICT DO NOTHING (mirrors every // other per-second stream's dedupe rule). _ = try await store.insert(streams, deviceId: "my-whoop") @@ -108,4 +116,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..71e59ddfdb 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/ReadTests.swift +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/ReadTests.swift @@ -68,16 +68,91 @@ 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): // ts100 → bucket 0 → mean 60 // ts200, ts300 → bucket 200 → mean (61+62)/2 = 61.5 let buckets = try await store.hrBuckets(deviceId: "dev1", from: 0, to: 1000, bucketSeconds: 200) - XCTAssertEqual(buckets, [HRBucket(ts: 0, bpm: 60), HRBucket(ts: 200, bpm: 61.5)]) + // #2032: bucket 200 is the case the readout used to get wrong. Its mean is 61.5 and its highest + // sample is 62, so a Max taken off the means understates the busiest moment. The extremes are + // asserted beside the mean here so a query that stopped carrying them fails rather than silently + // handing the card the mean again. + XCTAssertEqual(buckets, [HRBucket(ts: 0, bpm: 60, minBpm: 60, maxBpm: 60), + HRBucket(ts: 200, bpm: 61.5, minBpm: 61, maxBpm: 62)]) // The decoy on "other" (ts200, bpm99) must never bleed into dev1's bucket. let other = try await store.hrBuckets(deviceId: "other", from: 0, to: 1000, bucketSeconds: 200) - XCTAssertEqual(other, [HRBucket(ts: 200, bpm: 99)]) + XCTAssertEqual(other, [HRBucket(ts: 200, bpm: 99, minBpm: 99, maxBpm: 99)]) } /// Both same-second beats survive the v24 key. Since #823 the read order is EMISSION order, and @@ -275,20 +350,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 4a19cda30f..4ab143d2b4 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": 33, + "roomVersion": 40, "grdbMigrations": [ "v1", "v2", @@ -40,7 +40,14 @@ "v36-whoop-caps-no-spo2", "v37-daily-avg-sdnn", "v38-apple-step-hour", - "v39-ppg-burst-index" + "v39-ppg-burst-index", + "v40-daily-skin-temp-absolute", + "v41-drop-raw-imu-sample", + "v42-daily-sleep-hr-only", + "v43-coach-messages", + "v44-ppg-waveform-base-code", + "v45-rr-source-index", + "v46-lift-log" ], "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 \u2014 the per-row upload flag belongs to an upload path that does not exist there \u2014 so it is dead width, not divergent data. Removing it needs a Room table rebuild.", @@ -214,6 +221,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.", @@ -364,6 +420,22 @@ "affinity": "REAL", "notNull": false, "default": null + }, + { + "name": "skinTempC", + "affinity": "REAL", + "notNull": false, + "default": null + }, + { + "name": "sleepHrOnly", + "affinity": "NUMERIC", + "notNull": false, + "default": null, + "android": { + "affinity": "INTEGER" + }, + "divergence": "grdb-boolean-affinity" } ], "primaryKey": [ @@ -809,6 +881,438 @@ } ] }, + "liftExercise": { + "platform": "both", + "columns": [ + { + "name": "id", + "affinity": "TEXT", + "notNull": false, + "default": null, + "android": { + "notNull": true + }, + "divergence": "sqlite-text-pk-nullable" + }, + { + "name": "deviceId", + "affinity": "TEXT", + "notNull": true, + "default": null + }, + { + "name": "name", + "affinity": "TEXT", + "notNull": true, + "default": null + }, + { + "name": "primaryMuscle", + "affinity": "TEXT", + "notNull": false, + "default": null + }, + { + "name": "secondaryMuscles", + "affinity": "TEXT", + "notNull": false, + "default": null + }, + { + "name": "createdAt", + "affinity": "INTEGER", + "notNull": true, + "default": null + }, + { + "name": "lastUsedTs", + "affinity": "INTEGER", + "notNull": false, + "default": null + } + ], + "primaryKey": [ + "id" + ], + "indices": [ + { + "name": "idx_liftExercise_natural", + "unique": true, + "columns": [ + "deviceId", + "name" + ] + } + ] + }, + "liftProgram": { + "platform": "both", + "columns": [ + { + "name": "id", + "affinity": "TEXT", + "notNull": false, + "default": null, + "android": { + "notNull": true + }, + "divergence": "sqlite-text-pk-nullable" + }, + { + "name": "deviceId", + "affinity": "TEXT", + "notNull": true, + "default": null + }, + { + "name": "name", + "affinity": "TEXT", + "notNull": true, + "default": null + }, + { + "name": "note", + "affinity": "TEXT", + "notNull": false, + "default": null + }, + { + "name": "createdAt", + "affinity": "INTEGER", + "notNull": true, + "default": null + }, + { + "name": "updatedAt", + "affinity": "INTEGER", + "notNull": true, + "default": null + }, + { + "name": "archived", + "affinity": "INTEGER", + "notNull": true, + "default": "0" + } + ], + "primaryKey": [ + "id" + ], + "indices": [ + { + "name": "idx_liftProgram_device_updatedAt", + "unique": false, + "columns": [ + "deviceId", + "updatedAt" + ] + } + ] + }, + "liftProgramItem": { + "platform": "both", + "columns": [ + { + "name": "id", + "affinity": "TEXT", + "notNull": false, + "default": null, + "android": { + "notNull": true + }, + "divergence": "sqlite-text-pk-nullable" + }, + { + "name": "deviceId", + "affinity": "TEXT", + "notNull": true, + "default": null + }, + { + "name": "programId", + "affinity": "TEXT", + "notNull": true, + "default": null + }, + { + "name": "ord", + "affinity": "INTEGER", + "notNull": true, + "default": null + }, + { + "name": "exercise", + "affinity": "TEXT", + "notNull": true, + "default": null + }, + { + "name": "targetSets", + "affinity": "INTEGER", + "notNull": false, + "default": null + }, + { + "name": "targetRepsLow", + "affinity": "INTEGER", + "notNull": false, + "default": null + }, + { + "name": "targetRepsHigh", + "affinity": "INTEGER", + "notNull": false, + "default": null + }, + { + "name": "targetRpe", + "affinity": "REAL", + "notNull": false, + "default": null + }, + { + "name": "targetWeightKg", + "affinity": "REAL", + "notNull": false, + "default": null + }, + { + "name": "restSec", + "affinity": "INTEGER", + "notNull": false, + "default": null + }, + { + "name": "note", + "affinity": "TEXT", + "notNull": false, + "default": null + } + ], + "primaryKey": [ + "id" + ], + "indices": [ + { + "name": "idx_liftProgramItem_device", + "unique": false, + "columns": [ + "deviceId" + ] + }, + { + "name": "idx_liftProgramItem_program_ord", + "unique": false, + "columns": [ + "programId", + "ord" + ] + } + ] + }, + "liftSession": { + "platform": "both", + "columns": [ + { + "name": "id", + "affinity": "TEXT", + "notNull": false, + "default": null, + "android": { + "notNull": true + }, + "divergence": "sqlite-text-pk-nullable" + }, + { + "name": "deviceId", + "affinity": "TEXT", + "notNull": true, + "default": null + }, + { + "name": "startTs", + "affinity": "INTEGER", + "notNull": true, + "default": null + }, + { + "name": "endTs", + "affinity": "INTEGER", + "notNull": false, + "default": null + }, + { + "name": "sport", + "affinity": "TEXT", + "notNull": true, + "default": null + }, + { + "name": "programId", + "affinity": "TEXT", + "notNull": false, + "default": null + }, + { + "name": "programName", + "affinity": "TEXT", + "notNull": false, + "default": null + }, + { + "name": "sessionRpe", + "affinity": "REAL", + "notNull": false, + "default": null + }, + { + "name": "note", + "affinity": "TEXT", + "notNull": false, + "default": null + } + ], + "primaryKey": [ + "id" + ], + "indices": [ + { + "name": "idx_liftSession_natural", + "unique": true, + "columns": [ + "deviceId", + "startTs", + "sport" + ] + } + ] + }, + "liftSet": { + "platform": "both", + "columns": [ + { + "name": "id", + "affinity": "TEXT", + "notNull": false, + "default": null, + "android": { + "notNull": true + }, + "divergence": "sqlite-text-pk-nullable" + }, + { + "name": "deviceId", + "affinity": "TEXT", + "notNull": true, + "default": null + }, + { + "name": "sessionId", + "affinity": "TEXT", + "notNull": true, + "default": null + }, + { + "name": "ord", + "affinity": "INTEGER", + "notNull": true, + "default": null + }, + { + "name": "exercise", + "affinity": "TEXT", + "notNull": true, + "default": null + }, + { + "name": "primaryMuscle", + "affinity": "TEXT", + "notNull": false, + "default": null + }, + { + "name": "secondaryMuscles", + "affinity": "TEXT", + "notNull": false, + "default": null + }, + { + "name": "setIndex", + "affinity": "INTEGER", + "notNull": true, + "default": null + }, + { + "name": "weightKg", + "affinity": "REAL", + "notNull": false, + "default": null + }, + { + "name": "reps", + "affinity": "INTEGER", + "notNull": false, + "default": null + }, + { + "name": "rpe", + "affinity": "REAL", + "notNull": false, + "default": null + }, + { + "name": "isWarmup", + "affinity": "INTEGER", + "notNull": true, + "default": "0" + }, + { + "name": "startTs", + "affinity": "INTEGER", + "notNull": false, + "default": null + }, + { + "name": "endTs", + "affinity": "INTEGER", + "notNull": false, + "default": null + }, + { + "name": "restSec", + "affinity": "INTEGER", + "notNull": false, + "default": null + }, + { + "name": "note", + "affinity": "TEXT", + "notNull": false, + "default": null + } + ], + "primaryKey": [ + "id" + ], + "indices": [ + { + "name": "idx_liftSet_device_exercise", + "unique": false, + "columns": [ + "deviceId", + "exercise" + ] + }, + { + "name": "idx_liftSet_session_ord", + "unique": false, + "columns": [ + "sessionId", + "ord" + ] + } + ] + }, "liveSession": { "platform": "both", "columns": [ @@ -1168,6 +1672,12 @@ "affinity": "INTEGER", "notNull": false, "default": null + }, + { + "name": "baseCode", + "affinity": "INTEGER", + "notNull": false, + "default": null } ], "primaryKey": [ @@ -1252,34 +1762,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": [ @@ -1384,7 +1866,16 @@ "rrMs", "seq" ], - "indices": [] + "indices": [ + { + "name": "rrInterval_source_suspect", + "unique": false, + "columns": [ + "srcChannel", + "tsSuspect" + ] + } + ] }, "scoreInputProvenance": { "platform": "both", diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/RrSourceChannelTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/RrSourceChannelTests.swift index b934c2c702..f7be3d54f5 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/RrSourceChannelTests.swift +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/RrSourceChannelTests.swift @@ -54,7 +54,7 @@ final class RrSourceChannelTests: XCTestCase { /// The two enums are pinned to the same raw values on purpose: they are one durable storage code /// split across two packages only because `OuraProtocol` does not depend on `WhoopProtocol`. func testTheTwoChannelEnumsAgreeCaseForCaseAndCodeForCode() { - XCTAssertEqual(OuraIBIChannel.allCases.count, RRSourceChannel.allCases.count) + XCTAssertEqual(OuraIBIChannel.allCases.count, RRSourceChannel.allCases.filter { !$0.isWhoop5Transport }.count) for c in OuraIBIChannel.allCases { let mapped = OuraStreamMapping.rrChannel(c) XCTAssertEqual(mapped?.rawValue, c.rawValue, 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/SleepDeleteUndoStoreTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/SleepDeleteUndoStoreTests.swift index 37a2722ccb..90c65607f4 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/SleepDeleteUndoStoreTests.swift +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/SleepDeleteUndoStoreTests.swift @@ -12,6 +12,17 @@ final class SleepDeleteUndoStoreTests: XCTestCase { private let computed = "my-whoop-noop" private let imported = "my-whoop" + /// The same session as it reads back from `deviceId`. `sleepSessions` stamps the device it read the + /// row from, and a hand-built session carries no provenance, so a whole-value expectation has to + /// state it. The write takes its device from the `upsertSleepSessions` argument, never from this + /// field, so a session's `deviceId` is an output of the read and not an input to the write. + private func readBack(_ s: CachedSleepSession, from deviceId: String) -> CachedSleepSession { + CachedSleepSession(startTs: s.startTs, endTs: s.endTs, efficiency: s.efficiency, + restingHr: s.restingHr, avgHrv: s.avgHrv, stagesJSON: s.stagesJSON, + userEdited: s.userEdited, startTsAdjusted: s.startTsAdjusted, + stagingSparse: s.stagingSparse, deviceId: deviceId) + } + private func session(start: Int, end: Int, edited: Bool = false, stages: String? = "[]", startAdjusted: Int? = nil) -> CachedSleepSession { CachedSleepSession(startTs: start, endTs: end, efficiency: 0.9, restingHr: 50, @@ -35,7 +46,7 @@ final class SleepDeleteUndoStoreTests: XCTestCase { let computedRows = try await store.sleepSessions(deviceId: computed, from: 0, to: 100_000, limit: 100) let importedRows = try await store.sleepSessions(deviceId: imported, from: 0, to: 100_000, limit: 100) - XCTAssertEqual(computedRows, [row], "restored verbatim into computed") + XCTAssertEqual(computedRows, [readBack(row, from: computed)], "restored verbatim into computed") XCTAssertTrue(importedRows.isEmpty, "undo never leaks into the imported namespace") } @@ -55,7 +66,7 @@ final class SleepDeleteUndoStoreTests: XCTestCase { let computedRows = try await store.sleepSessions(deviceId: computed, from: 0, to: 100_000, limit: 100) let importedRows = try await store.sleepSessions(deviceId: imported, from: 0, to: 100_000, limit: 100) - XCTAssertEqual(importedRows, [row], "restored verbatim into imported") + XCTAssertEqual(importedRows, [readBack(row, from: imported)], "restored verbatim into imported") XCTAssertTrue(computedRows.isEmpty, "an imported night must never resurrect as a computed twin") } 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/SleepMotionStateTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/SleepMotionStateTests.swift index 2b45a859b5..8de52a3e73 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/SleepMotionStateTests.swift +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/SleepMotionStateTests.swift @@ -170,4 +170,65 @@ final class SleepMotionStateTests: XCTestCase { let states = try await store.sessionSleepState(deviceId: dev, sessionStart: start) XCTAssertEqual(states, [1, 2]) } + + // MARK: - session bounds + + /// The bounds read is device-scoped, window-scoped, and returns EVERY row in the window. + /// + /// It exists so a caller resolving which device owns a block does not have to pull `stagesJSON` for + /// every night to compare two integers. The failure it can have is a partial map, which reads to the + /// caller as "that device does not own this block" and silently sends it to the wrong motion source, + /// so the count is asserted alongside the values. + func testSleepSessionBoundsIsDeviceAndWindowScoped() async throws { + let store = try await WhoopStore.inMemory() + let starts = [start, start + 86_400, start + 2 * 86_400] + try await store.upsertSleepSessions(starts.map { + CachedSleepSession(startTs: $0, endTs: $0 + 7 * 3_600, efficiency: 0.9, + restingHr: 52, avgHrv: 70, stagesJSON: "[]") + }, deviceId: dev) + // A decoy on another device at the SAME starts: bounds must never blend the two. + try await store.upsertSleepSessions(starts.map { + CachedSleepSession(startTs: $0, endTs: $0 + 99, efficiency: 0.5, + restingHr: 60, avgHrv: 40, stagesJSON: "[]") + }, deviceId: "other") + + let bounds = try await store.sleepSessionBounds(deviceId: dev, from: start, + to: start + 2 * 86_400) + XCTAssertEqual(bounds.count, starts.count, "a dropped row reads as an unowned block") + for s in starts { + XCTAssertEqual(bounds[s], s + 7 * 3_600, "start \(s) got the other device's end") + } + + // Window-scoped: a start past `to` is absent rather than clamped in. + let narrow = try await store.sleepSessionBounds(deviceId: dev, from: start, to: start) + XCTAssertEqual(narrow, [start: start + 7 * 3_600]) + } + + /// An empty window is an empty map, not a fabricated entry. + func testSleepSessionBoundsOnAnEmptyWindowIsEmpty() async throws { + let store = try await storeWithSession() + let bounds = try await store.sleepSessionBounds(deviceId: dev, from: start + 10_000, + to: start + 20_000) + XCTAssertTrue(bounds.isEmpty) + } + + /// The read stamps each block with the device it queried, and a re-keyed copy keeps it. + /// + /// This is what lets a caller skip asking the store which device owns a block. `withStartTs` is + /// included because it is the one place a session is rebuilt: dropping the field there would turn a + /// known provenance silently back into a probe, which is a performance fault with no visible symptom. + func testSleepSessionsCarryTheDeviceTheyWereReadFrom() async throws { + let store = try await storeWithSession() + let rows = try await store.sleepSessions(deviceId: dev, from: start - 1, to: start + 1, limit: 8) + XCTAssertEqual(rows.count, 1) + XCTAssertEqual(rows.first?.deviceId, dev) + XCTAssertEqual(rows.first?.withStartTs(start + 60).deviceId, dev, "a re-keyed copy forgot its device") + } + + /// A hand-built session says nothing rather than claiming a device it was never read from. + func testAHandBuiltSessionHasNoProvenance() { + let s = CachedSleepSession(startTs: start, endTs: start + 3_600, efficiency: nil, + restingHr: nil, avgHrv: nil, stagesJSON: nil) + XCTAssertNil(s.deviceId, "absent is the honest value; a default would be a wrong answer") + } } diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/SleepSessionDedupTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/SleepSessionDedupTests.swift index c6716a4f67..eeb5859f1b 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/SleepSessionDedupTests.swift +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/SleepSessionDedupTests.swift @@ -96,6 +96,37 @@ final class SleepSessionDedupTests: XCTestCase { XCTAssertEqual(result.kept.map(\.startTs), [long.startTs]) } + // MARK: - Heal witness: the computed bank-recency witness must not reach a provided device's rows + + func testHealWitnessIsHandedOnlyToTheComputedId() { + let kept: Set = [midnight - 2 * 3600, midnight + 15 * 3600] + XCTAssertEqual(SleepSessionDedup.healWitness(for: "my-whoop-noop", computedId: "my-whoop-noop", + keptStarts: kept), kept) + XCTAssertEqual(SleepSessionDedup.healWitness(for: "oura-Y12", computedId: "my-whoop-noop", + keptStarts: kept), []) + XCTAssertEqual(SleepSessionDedup.healWitness(for: "oura-Y12", computedId: "my-whoop-noop", + keptStarts: []), []) + } + + func testRingSweepKeepsTheFullerReserveBankedWhileThePassWasInFlight() { + // 09-19/20 (iOS 11.8.0): the pass READ the ring's 22:20 → 03:57 row, iOS suspended it, and by the + // time its heal ran the ring had re-served the night out to 08:21. The read row's startTs is in + // `keptStarts` (the pass banked it verbatim under computedId). Handed to the ring's own sweep as the + // witness, it outranked the full night and the heal deleted 598 min in favour of 337 — the wake + // time the user saw was 04:48. With `healWitness` the ring id gets no witness: longest wins. + let read = session(start: midnight - 2 * 3600 + 53, end: midnight + 3 * 3600 + 57 * 60) // 337 min + let full = session(start: midnight - 2 * 3600 + 231, end: midnight + 8 * 3600 + 21 * 60) // 598 min + let keptStarts: Set = [read.startTs] + let ringWitness = SleepSessionDedup.healWitness(for: "oura-Y12", computedId: "my-whoop-noop", + keptStarts: keptStarts) + let healed = SleepSessionDedup.dedupe([read, full], freshStarts: ringWitness) + XCTAssertEqual(healed.kept.map(\.startTs), [full.startTs], "the fuller re-serve survives the heal") + XCTAssertEqual(healed.dropped.map(\.startTs), [read.startTs]) + // The regression, pinned so it cannot creep back: the leaked witness keeps the stale read row. + let leaked = SleepSessionDedup.dedupe([read, full], freshStarts: keptStarts) + XCTAssertEqual(leaked.kept.map(\.startTs), [read.startTs]) + } + func testUserEditedSessionIsNeverDropped() { // A hand-corrected night outranks everything, including a fresh re-detection. let edited = session(start: midnight - 8 * 3600, end: midnight, edited: true) @@ -272,6 +303,42 @@ final class SleepSessionDedupTests: XCTestCase { XCTAssertEqual(SleepSessionDedup.keyedStart(onsetUnixSeconds: 1234, gridSeconds: 0), 1234) // grid clamp ≥1 = identity } + /// Item 22, 2026-09-12: a real captured negative-duration nap. Two written codes span + /// 14:24:17→14:26:17 (`mapped`), but the closest-matched `0x49` window's onset landed at + /// ~14:41:xx — ~16 min AFTER `mapped.endTs` — because `codesWithTimes`'s own clip-would-empty + /// fallback had silently returned the unclipped burst. The unguarded `keyedStart` call rounded + /// that onset to exactly 14:42:00 and wrote it as `startTs`, 943 s after `endTs`. `safeKeyedStart` + /// must refuse the rekey here and return nil (keep `mapped.startTs` unchanged). + func testSafeKeyedStartRefusesTheItem22NegativeDurationRegression() { + let mappedStart = 1_789_215_857 // 14:24:17 local — matches the captured stagesJSON + let mappedEnd = 1_789_215_977 // 14:26:17 local + let mismatchedOnset = 1_789_216_910 // ~14:41:50 — the mis-paired window's onset, after endTs + let mapped = session(start: mappedStart, end: mappedEnd) + XCTAssertNil(SleepSessionDedup.safeKeyedStart(onset: mismatchedOnset, mapped: mapped)) + } + + /// The ordinary, intended case: the assembler's clip genuinely bound, so the onset sits at or + /// just before the first surviving code (a few epochs, per `persistHypnogramBurst`'s own doc + /// comment) — `safeKeyedStart` should key `startTs` to the rounded onset as designed. + func testSafeKeyedStartAppliesTheRekeyWhenTheClipGenuinelyBound() { + let onset = 1_789_215_800 // precedes mapped.startTs, as a bound clip guarantees + let mapped = session(start: 1_789_215_857, end: 1_789_215_977) + let keyed = SleepSessionDedup.safeKeyedStart(onset: onset, mapped: mapped) + XCTAssertEqual(keyed, SleepSessionDedup.keyedStart(onsetUnixSeconds: onset)) + XCTAssertLessThan(keyed!, mapped.endTs) + } + + /// A second, smaller-magnitude failure mode `safeKeyedStart` also has to catch: the onset passes + /// the first guard (`onset <= mapped.startTs`, so the clip genuinely bound), but 30 s grid-rounding + /// pushes it to or past `endTs` on a very short (20 s) session — refuse rather than mint a + /// zero/negative-duration session. + func testSafeKeyedStartRefusesWhenRoundingWouldReachOrPassEndTs() { + let mapped = session(start: 1_000_000, end: 1_000_020) // 20 s session + let onset = 999_995 // <= mapped.startTs (clip bound), but keyedStart(999_995, 60) = 1_000_020 == endTs + XCTAssertEqual(SleepSessionDedup.keyedStart(onsetUnixSeconds: onset), mapped.endTs) + XCTAssertNil(SleepSessionDedup.safeKeyedStart(onset: onset, mapped: mapped)) + } + func testPlanBankSameBucketFullerStoredRowSuppressesAPartialReserve() { // F1 regression: a partial re-drain keyed to the SAME bucket (same PK) as a fuller banked night must // be SUPPRESSED — the upsert would otherwise replace the fuller night's stages by PK. `existing` is 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/StoreProbeCountsTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/StoreProbeCountsTests.swift new file mode 100644 index 0000000000..7417fa8e8e --- /dev/null +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/StoreProbeCountsTests.swift @@ -0,0 +1,58 @@ +import XCTest +@testable import WhoopStore + +/// The per-day probe counters' ARITHMETIC, pinned against the Kotlin twin's. +/// +/// The twin sums `System.nanoTime()` deltas into an `AtomicLong` and divides once at render. This side has +/// to do the same thing, not merely a similar thing: dividing each call to `Double` seconds and summing +/// those would spend sixty to eighty roundings where the twin spends none, and the difference survives into +/// the rendered millisecond whenever the true total sits near a half-millisecond boundary. Both platforms +/// render into ONE shared strap log, so a divergence there is read as two devices behaving differently. +final class StoreProbeCountsTests: XCTestCase { + + /// Sixty calls of exactly 20 ms. Integer accumulation makes this exactly 1.2 s, so it renders 1200ms; + /// per-call `Double` division would land a hair either side. The literal matches the Kotlin + /// `StoreProbeTallyTest.recordAccumulatesIntegerNanosLikeTheSwiftTwin`. + func testAccumulatesIntegerNanosRatherThanSummingDoubles() { + var probe = StoreProbeCounts.Probe() + for _ in 0..<60 { probe.record(nanos: 20_000_000) } + XCTAssertEqual(probe.calls, 60) + XCTAssertEqual(probe.nanos, 1_200_000_000) + XCTAssertEqual(probe.seconds, 1.2, accuracy: 0) + } + + /// A probe that was never called contributes nothing and says so, which is what makes `ownerHr=0/0ms` + /// on a single-strap install (#970) a finding rather than a missing measurement. + func testUncalledProbeIsZeroNotAbsent() { + let probe = StoreProbeCounts.Probe() + XCTAssertEqual(probe.calls, 0) + XCTAssertEqual(probe.seconds, 0) + } + + /// `StoreProbeRecorder.take()` DRAINS. A line has to describe one pass, and the passes this runs under are the + /// back-to-back ones an offload storm is made of, so a read that left the counters standing would make + /// every pass after the first report its predecessors' work as its own. + func testTakeDrainsSoAPassCannotInheritTheLastOne() async throws { + _ = StoreProbeRecorder.take() + let store = try await WhoopStore.inMemory() + _ = try? await store.hasHrInWindow(deviceId: "d", from: 0, to: 1) + let first = StoreProbeRecorder.take() + XCTAssertEqual(first.ownerHr.calls, 1) + let second = StoreProbeRecorder.take() + XCTAssertEqual(second.ownerHr.calls, 0) + XCTAssertEqual(second.ownerHr.seconds, 0) + } + + /// The day-owner LOOKUP is counted separately from the presence probe. They are different queries with + /// different costs, and collapsing them is what hid the lookup in the first place. + func testDayOwnerAndOwnerHrAreCountedSeparately() { + _ = StoreProbeRecorder.take() + StoreProbeRecorder.record(.dayOwner, nanos: 3_000_000) + StoreProbeRecorder.record(.ownerHr, nanos: 1_000_000) + let counts = StoreProbeRecorder.take() + XCTAssertEqual(counts.dayOwner.calls, 1) + XCTAssertEqual(counts.dayOwner.nanos, 3_000_000) + XCTAssertEqual(counts.ownerHr.calls, 1) + XCTAssertEqual(counts.gravityFp.calls, 0) + } +} diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/Whoop5RRStoreTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/Whoop5RRStoreTests.swift new file mode 100644 index 0000000000..c5ff1f53cd --- /dev/null +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/Whoop5RRStoreTests.swift @@ -0,0 +1,326 @@ +import XCTest +import GRDB +import WhoopProtocol +@testable import WhoopStore + +final class Whoop5RRStoreTests: XCTestCase { + private let id = "my-whoop" + private func registry(_ store: WhoopStore, model: String, brand: String = "WHOOP") throws { + try store.registryWriter.write { db in + try db.execute(sql: "UPDATE pairedDevice SET model = ?, brand = ? WHERE id = ?", + arguments: [model, brand, id]) + } + } + private func read(_ store: WhoopStore, from: Int = 0, to: Int = 1000, limit: Int = 100) async throws -> [RRInterval] { + try await store.rrIntervals(deviceId: id, from: from, to: to, limit: limit) + } + + /// The date the "this night cannot be scored" explanation names comes from this read, so it has to + /// agree with what scoring actually accepts: labelled scoring transports only, suspect stamps + /// excluded, and nil rather than a fabricated epoch when nothing scorable has been banked yet. + /// Same assertions as the Kotlin `firstScorableTimestampMatchesWhatScoringAccepts`. + func testFirstScorableTimestampMatchesWhatScoringAccepts() async throws { + let store = try await WhoopStore.inMemory() + try registry(store, model: "5.0 MG") + // Legacy unlabelled beats only: nothing here can be scored, so there is no first scorable day. + _ = try await store.insert(Streams(rr: (100..<110).map { RRInterval(ts: $0, rrMs: 1000) }), deviceId: id) + var first = try await store.firstScorableWhoop5RRTimestamp(deviceId: id) + XCTAssertNil(first) + // The lower bound sees those same rows: they WERE recorded, they just cannot be read. + var recorded = try await store.firstRecordedRRTimestamp(deviceId: id) + XCTAssertEqual(recorded, 100) + // A type-40 live beat (6) is labelled but is NOT a scoring transport, so it must not count. + _ = try await store.insert(Streams(rr: [RRInterval(ts: 200, rrMs: 1000, srcChannel: .whoop5Realtime)]), + deviceId: id) + first = try await store.firstScorableWhoop5RRTimestamp(deviceId: id) + XCTAssertNil(first) + // A future-stamped beat is excluded from scoring (#1073), so it cannot name the day either. + let device = id + try await store.dbWriter.write { db in + try db.execute(sql: """ + INSERT OR REPLACE INTO rrInterval(deviceId, ts, rrMs, seq, synced, ord, srcChannel, tsSuspect) + VALUES (?, 300, 1000, 0, 0, 0, 7, 1) + """, arguments: [device]) + } + first = try await store.firstScorableWhoop5RRTimestamp(deviceId: id) + XCTAssertNil(first) + // The first genuinely scorable beat, and then an earlier one, which must win. + _ = try await store.insert(Streams(rr: [RRInterval(ts: 900, rrMs: 1000, srcChannel: .whoop5Standard)]), + deviceId: id) + first = try await store.firstScorableWhoop5RRTimestamp(deviceId: id) + XCTAssertEqual(first, 900) + _ = try await store.insert(Streams(rr: [RRInterval(ts: 400, rrMs: 1000, srcChannel: .whoop5Historical)]), + deviceId: id) + first = try await store.firstScorableWhoop5RRTimestamp(deviceId: id) + XCTAssertEqual(first, 400) + // The lower bound ignores the channel entirely and still refuses the suspect stamp. + recorded = try await store.firstRecordedRRTimestamp(deviceId: id) + XCTAssertEqual(recorded, 100) + // Another device's beats never leak into either answer. + let other = try await store.firstScorableWhoop5RRTimestamp(deviceId: "someone-else") + XCTAssertNil(other) + let otherRecorded = try await store.firstRecordedRRTimestamp(deviceId: "someone-else") + XCTAssertNil(otherRecorded) + } + + func testHistoricalCanonicalOwnerAfterRePairingUsesActiveWhoop5Policy() async throws { + let store = try await WhoopStore.inMemory() + try registry(store, model: "WHOOP") + _ = try await store.insert(Streams(rr: [RRInterval(ts: 100, rrMs: 1024)]), deviceId: "my-whoop") + let strict = try await store.isWhoop5RRSource(deviceId: "my-whoop", unlabelledAliasOfWhoop5: true) + XCTAssertTrue(strict) + let guarded = try await store.rrIntervals(deviceId: "my-whoop", from: 0, to: 1000, limit: 100, + unlabelledAliasOfWhoop5: true) + XCTAssertTrue(guarded.isEmpty) + let withheld = try await store.legacyWhoop5RRWithheld( + deviceId: "my-whoop", from: 0, to: 1000, unlabelledAliasOfWhoop5: true) + XCTAssertTrue(withheld, "the canonical alias must expose the same strict-window status as its read") + try registry(store, model: "4.0") + let confirmedFour = try await store.isWhoop5RRSource(deviceId: "my-whoop", unlabelledAliasOfWhoop5: true) + XCTAssertFalse(confirmedFour) + let legacy = try await store.rrIntervals(deviceId: "my-whoop", from: 0, to: 1000, limit: 100, + unlabelledAliasOfWhoop5: true) + XCTAssertEqual(legacy.map(\.rrMs), [1024]) + } + + func testOrdinaryReadsAndFingerprintsFollowActiveAliasPolicy() async throws { + let store = try await WhoopStore.inMemory() + try registry(store, model: "WHOOP") + let registry = DeviceRegistryStore(dbQueue: store.registryWriter) + for (owner, model) in [("old-four", "4.0"), ("new-five", "5.0 MG")] { + try registry.add(PairedDevice(id: owner, brand: "WHOOP", model: model, + sourceKind: .liveBLE, capabilities: [.hr, .hrv], status: .paired, addedAt: 1, lastSeenAt: 1)) + } + _ = try await store.insert(Streams(rr: [RRInterval(ts: 100, rrMs: 1000)]), deviceId: id) + try registry.setActive("old-four") + let global = try await store.analysisFingerprint() + let day = try await store.dayStreamFingerprint(deviceId: id, from: 0, to: 1000) + let legacy = try await read(store) + XCTAssertEqual(legacy.map(\.rrMs), [1000]) + try registry.setActive("new-five") + let guarded = try await read(store) + XCTAssertTrue(guarded.isEmpty, "ordinary callers must resolve the alias from the active registry") + let changedGlobal = try await store.analysisFingerprint() + let changedDay = try await store.dayStreamFingerprint(deviceId: id, from: 0, to: 1000) + XCTAssertNotEqual(global, changedGlobal) + XCTAssertNotEqual(day, changedDay) + try self.registry(store, model: "4.0") + let confirmedFour = try await read(store) + XCTAssertEqual(confirmedFour.map(\.rrMs), [1000]) + } + + func testOwnerPolicyExcludesMixedLegacyWithoutChangingStoredRows() async throws { + let s = try await WhoopStore.inMemory() + _ = try await s.insert(Streams(rr: [RRInterval(ts: 100, rrMs: 1000)]), deviceId: id) + try registry(s, model: "5.0 MG") + var rows = try await read(s) + XCTAssertTrue(rows.isEmpty) + try registry(s, model: "4.0") + rows = try await read(s) + XCTAssertEqual(rows.map(\.rrMs), [1000]) + try registry(s, model: "WHOOP") + rows = try await read(s) + XCTAssertEqual(rows.map(\.rrMs), [1000]) + _ = try await s.insert(Streams(rr: [RRInterval(ts: 101, rrMs: 977, srcChannel: .whoop5Standard)]), deviceId: id) + rows = try await read(s) + XCTAssertEqual(rows.map(\.rrMs), [977], "wire evidence resolves an unknown registry") + try registry(s, model: "5.0 MG", brand: "Oura") + rows = try await read(s) + XCTAssertEqual(rows.map(\.rrMs), [1000, 977], "stray tags cannot override a different device brand") + let stored = try await s.rrRowsWithChannelForTest(deviceId: id) + XCTAssertEqual(stored.count, 2, "source policy never rewrites or removes legacy intervals") + } + + func testLegacyWithheldStatusUsesTheExactScoringWindowAndSourcePolicy() async throws { + let s = try await WhoopStore.inMemory() + try registry(s, model: "5.0 MG") + _ = try await s.insert(Streams(rr: [RRInterval(ts: 100, rrMs: 1000)]), deviceId: id) + let withheld = try await s.legacyWhoop5RRWithheld(deviceId: id, from: 100, to: 200) + XCTAssertTrue(withheld) + let outside = try await s.legacyWhoop5RRWithheld(deviceId: id, from: 101, to: 200) + XCTAssertFalse(outside, + "legacy rows outside the exact read window cannot protect a score") + try await s.dbWriter.write { db in + try db.execute(sql: "UPDATE rrInterval SET tsSuspect = 1 WHERE deviceId = ? AND ts = 100", + arguments: [self.id]) + } + let quarantined = try await s.legacyWhoop5RRWithheld(deviceId: id, from: 100, to: 200) + XCTAssertFalse(quarantined, "quarantined rows are excluded exactly like the scoring read") + try await s.dbWriter.write { db in + try db.execute(sql: "UPDATE rrInterval SET tsSuspect = NULL WHERE deviceId = ? AND ts = 100", + arguments: [self.id]) + } + + _ = try await s.insert(Streams(rr: [RRInterval(ts: 150, rrMs: 990, + srcChannel: .whoop5Standard)]), deviceId: id) + let labelled = try await s.legacyWhoop5RRWithheld(deviceId: id, from: 100, to: 200) + XCTAssertFalse(labelled, + "any scorable labelled transport ends legacy protection") + + try registry(s, model: "4.0") + let whoop4 = try await s.legacyWhoop5RRWithheld(deviceId: id, from: 100, to: 149) + XCTAssertFalse(whoop4) + try registry(s, model: "5.0 MG", brand: "Oura") + let otherBrand = try await s.legacyWhoop5RRWithheld(deviceId: id, from: 100, to: 149) + XCTAssertFalse(otherBrand) + } + + func testSourceSelectionPrecedesLimitAndSharesBoundsAndQuarantine() async throws { + let s = try await WhoopStore.inMemory() + try registry(s, model: "5.0 MG") + let rows = (100..<200).map { RRInterval(ts: $0, rrMs: 1000, srcChannel: .whoop5Standard) } + + [RRInterval(ts: 200, rrMs: 900, srcChannel: .whoop5Historical), + RRInterval(ts: 201, rrMs: 800, srcChannel: .whoop5Realtime)] + _ = try await s.insert(Streams(rr: rows), deviceId: id) + var selected = try await read(s, limit: 1) + XCTAssertEqual(selected.map(\.rrMs), [900], "historical source must be chosen before LIMIT") + selected = try await read(s, to: 199, limit: 1) + XCTAssertEqual(selected.map(\.rrMs), [1000], "history outside this interval cannot select its source") + try await s.registryWriter.write { db in try db.execute(sql: "UPDATE rrInterval SET tsSuspect = 1 WHERE ts = 200") } + selected = try await read(s, limit: 1) + XCTAssertEqual(selected.map(\.rrMs), [1000], "suspect-only history cannot suppress the valid fallback") + selected = try await read(s, from: 201) + XCTAssertTrue(selected.isEmpty, "native realtime is diagnostic, not the durable scoring fallback") + } + + func testZeroInsertPromotionRestoresHistoricalOrderAndInvalidatesBothCaches() async throws { + let s = try await WhoopStore.inMemory() + try registry(s, model: "5.0 MG") + let legacy = [700, 900, 900, 800].map { RRInterval(ts: 100, rrMs: $0) } + _ = try await s.insert(Streams(rr: legacy), deviceId: id) + let beforeGlobal = try await s.analysisFingerprint() + let beforeDay = try await s.dayStreamFingerprint(deviceId: id, from: 0, to: 1000) + let history = [900, 700, 900, 800].map { RRInterval(ts: 100, rrMs: $0, srcChannel: .whoop5Historical) } + let inserted = try await s.insert(Streams(rr: history), deviceId: id) + XCTAssertEqual(inserted.rr, 0) + let selected = try await read(s) + XCTAssertEqual(selected.map(\.rrMs), [900, 700, 900, 800]) + XCTAssertEqual(selected.map(\.ord), [0, 1, 2, 3]) + XCTAssertEqual(selected.map(\.seq), [0, 0, 1, 0]) + let afterGlobal = try await s.analysisFingerprint() + let afterDay = try await s.dayStreamFingerprint(deviceId: id, from: 0, to: 1000) + XCTAssertNotEqual(beforeGlobal, afterGlobal) + XCTAssertNotEqual(beforeDay, afterDay) + let replayed = try await s.insert(Streams(rr: history), deviceId: id) + XCTAssertEqual(replayed.rr, 0) + let replayGlobal = try await s.analysisFingerprint() + let replayDay = try await s.dayStreamFingerprint(deviceId: id, from: 0, to: 1000) + XCTAssertEqual(afterGlobal, replayGlobal) + XCTAssertEqual(afterDay, replayDay) + } + + func testInterleavedTransportsCannotChangeHistoricalSequenceOrOrder() async throws { + let s = try await WhoopStore.inMemory() + let pairs: [(Int, RRSourceChannel)] = [(700, .whoop5Standard), (900, .whoop5Historical), + (900, .whoop5Standard), (700, .whoop5Historical), (900, .whoop5Historical), (800, .whoop5Historical)] + let n = try await s.insert(Streams(rr: pairs.map { RRInterval(ts: 100, rrMs: $0.0, srcChannel: $0.1) }), deviceId: id) + XCTAssertEqual(n.rr, 4, "exact keys coalesce but history promotes their source and order") + let selected = try await read(s) + XCTAssertEqual(selected.map(\.rrMs), [900, 700, 900, 800]) + XCTAssertEqual(selected.map(\.ord), [0, 1, 2, 3]) + XCTAssertEqual(selected.map(\.seq), [0, 0, 1, 0]) + } + + func testStandardWinsNativeAndLegacyCollisionsInBothArrivalOrders() async throws { + for lowerSource: RRSourceChannel? in [nil, .whoop5Realtime] { + for standardFirst in [false, true] { + let s = try await WhoopStore.inMemory() + try registry(s, model: "5.0 MG") + let lower = [700, 900, 900, 800].map { RRInterval(ts: 100, rrMs: $0, srcChannel: lowerSource) } + let standard = [900, 700, 900, 800].map { RRInterval(ts: 100, rrMs: $0, srcChannel: .whoop5Standard) } + let first = try await s.insert(Streams(rr: standardFirst ? standard : lower), deviceId: id) + XCTAssertEqual(first.rr, 4) + let g0 = try await s.analysisFingerprint() + let d0 = try await s.dayStreamFingerprint(deviceId: id, from: 0, to: 1000) + let second = try await s.insert(Streams(rr: standardFirst ? lower : standard), deviceId: id) + XCTAssertEqual(second.rr, 0) + let selected = try await read(s) + XCTAssertEqual(selected.map(\.rrMs), [900, 700, 900, 800]) + XCTAssertEqual(selected.map(\.ord), [0, 1, 2, 3]) + XCTAssertEqual(selected.map(\.seq), [0, 0, 1, 0]) + XCTAssertEqual(selected.map(\.srcChannel), Array(repeating: .whoop5Standard, count: 4)) + let g1 = try await s.analysisFingerprint() + let d1 = try await s.dayStreamFingerprint(deviceId: id, from: 0, to: 1000) + XCTAssertEqual(g0 == g1, standardFirst, "zero-insert source promotion must invalidate global analysis") + XCTAssertEqual(d0 == d1, standardFirst, "zero-insert source promotion must invalidate the cached day") + for replay in [lower, standard] { + let n = try await s.insert(Streams(rr: replay), deviceId: id) + XCTAssertEqual(n.rr, 0) + } + let g2 = try await s.analysisFingerprint() + let d2 = try await s.dayStreamFingerprint(deviceId: id, from: 0, to: 1000) + XCTAssertEqual(g1, g2) + XCTAssertEqual(d1, d2) + let history = [800, 900, 700, 900].map { RRInterval(ts: 100, rrMs: $0, srcChannel: .whoop5Historical) } + let promoted = try await s.insert(Streams(rr: history), deviceId: id) + XCTAssertEqual(promoted.rr, 0) + let g3 = try await s.analysisFingerprint() + let d3 = try await s.dayStreamFingerprint(deviceId: id, from: 0, to: 1000) + XCTAssertNotEqual(g2, g3) + XCTAssertNotEqual(d2, d3) + for replay in [standard, lower, history] { + let n = try await s.insert(Streams(rr: replay), deviceId: id) + XCTAssertEqual(n.rr, 0) + } + let final = try await read(s) + XCTAssertEqual(final.map(\.rrMs), [800, 900, 700, 900]) + XCTAssertEqual(final.map(\.ord), [0, 1, 2, 3]) + XCTAssertEqual(final.map(\.srcChannel), Array(repeating: .whoop5Historical, count: 4)) + let g4 = try await s.analysisFingerprint() + let d4 = try await s.dayStreamFingerprint(deviceId: id, from: 0, to: 1000) + XCTAssertEqual(g3, g4) + XCTAssertEqual(d3, d4) + } + } + } + + func testPromotionDoesNotRelabelOuraAndRegistryOnlyChangesInvalidateCaches() async throws { + let s = try await WhoopStore.inMemory() + _ = try await s.insert(Streams(rr: [RRInterval(ts: 100, rrMs: 800, srcChannel: .greenQuality)]), deviceId: id) + let standard = try await s.insert(Streams(rr: [RRInterval(ts: 100, rrMs: 800, srcChannel: .whoop5Standard)]), deviceId: id) + XCTAssertEqual(standard.rr, 0) + let n = try await s.insert(Streams(rr: [RRInterval(ts: 100, rrMs: 800, srcChannel: .whoop5Historical)]), deviceId: id) + XCTAssertEqual(n.rr, 0) + let stored = try await s.rrRowsWithChannelForTest(deviceId: id) + XCTAssertEqual(stored.map(\.srcChannel), [1]) + let g0 = try await s.analysisFingerprint() + let d0 = try await s.dayStreamFingerprint(deviceId: id, from: 0, to: 1000) + try registry(s, model: "5.0 MG") + let g1 = try await s.analysisFingerprint() + let d1 = try await s.dayStreamFingerprint(deviceId: id, from: 0, to: 1000) + XCTAssertNotEqual(g0, g1) + XCTAssertNotEqual(d0, d1) + try await s.registryWriter.write { db in try db.execute(sql: "UPDATE pairedDevice SET lastSeenAt = 999") } + let g2 = try await s.analysisFingerprint() + let d2 = try await s.dayStreamFingerprint(deviceId: id, from: 0, to: 1000) + XCTAssertEqual(g1, g2, "routine last-seen writes do not force rescoring") + XCTAssertEqual(d1, d2) + } + + func testFirstTagOutsideDayAndSuspectPromotionInvalidateOwnerPolicyCaches() async throws { + for suspect in [false, true] { + let s = try await WhoopStore.inMemory() + try registry(s, model: "WHOOP") + _ = try await s.insert(Streams(rr: [RRInterval(ts: 100, rrMs: 800), + RRInterval(ts: 2000, rrMs: 900)]), deviceId: id) + if suspect { + try await s.registryWriter.write { db in + try db.execute(sql: "UPDATE rrInterval SET tsSuspect = 1 WHERE ts = 2000") + } + } + let g0 = try await s.analysisFingerprint() + let d0 = try await s.dayStreamFingerprint(deviceId: id, from: 0, to: 1000) + let before = try await read(s) + XCTAssertEqual(before.map(\.rrMs), [800]) + let n = try await s.insert(Streams(rr: [RRInterval(ts: 2000, rrMs: 900, + srcChannel: .whoop5Historical)]), deviceId: id) + XCTAssertEqual(n.rr, 0) + let after = try await read(s) + XCTAssertTrue(after.isEmpty) + let g1 = try await s.analysisFingerprint() + let d1 = try await s.dayStreamFingerprint(deviceId: id, from: 0, to: 1000) + XCTAssertNotEqual(g0, g1, "even a suspect tag changes the unknown owner's source policy") + XCTAssertNotEqual(d0, d1, "owner policy applies to cached days outside the tagged interval") + } + } +} 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..3f523247a5 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. --- @@ -105,8 +106,9 @@ own device in SQLite, imports your existing WHOOP and Apple Health history, and computes recovery, strain, HRV, and sleep **locally**, with no WHOOP account and no WHOOP cloud. -There is also **experimental** support for the **Oura Ring (Gen 3)** — real overnight data from a ring -you own, on iOS and Android, with real limits. See [Oura ring support](#oura-ring-support). +There is also **experimental** support for the **Oura Ring** (measured on a Gen 3; a Ring 5 is +reported working over the same path) — real overnight data from a ring you own, on iOS and Android, +with real limits. See [Oura ring support](#oura-ring-support). It is built on prior community interoperability work and exists for one reason: to let someone who owns a WHOOP strap read **their own biometric data** @@ -187,7 +189,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 @@ -244,7 +246,7 @@ NOOP is an independent, **experimental** project — capable, but a work in prog |---|---| | **WHOOP 4.0** | ✅ The tested, supported path. Live HR, recovery, strain, sleep, history offload — the full experience. (v1.95 also unlocked sleep + recovery on the newer "v25" 4.0 firmware layout that earlier versions could only read live HR from.) | | **WHOOP 5.0 / MG** | 🧪 **Live heart rate works** (confirmed on real hardware). Pick "WHOOP 5.0 / MG" before connecting — and see the pairing note below, because you can't just scan for it. Deeper 5/MG metrics (recovery, strain, sleep) are still being mapped; there's an opt-in **Settings → Experimental** toggle for 5/MG owners who want to help document the protocol. | -| **Oura Ring (Gen 3)** | 🧪 **Experimental, not a supported strap.** Pairs on **iOS / Android only** and reads real overnight data — heart rate, sleep stages, skin temperature, SpO₂, motion — but a ring-only day does **not** produce a recovery/strain score yet, and some metrics are permanently out of reach. See **Oura ring support** below before you expect anything from it. | +| **Oura Ring (Gen 3 measured; Ring 5 reported)** | 🧪 **Experimental, not a supported strap.** Pairs on **iOS / Android only** and reads real overnight data — heart rate, sleep stages, skin temperature, SpO₂, motion. The ring's night feeds the scorer, so a ring-only day gets a **Sleep** and a **Strain** score but **no Recovery** (it needs an HRV baseline the ring cannot provide), and some metrics are permanently out of reach. See **Oura ring support** below before you expect anything from it. | > ### WHOOP 5.0 / MG analysis limits > @@ -296,31 +298,42 @@ The app always tells you what's live now versus still building, both in onboardi ### Oura ring support -NOOP has **experimental, clean-room** support for the **Oura Ring (Gen 3)**. It is not a supported -strap and it is not on the same footing as a WHOOP 4.0: it lives behind the experimental-device path -in the pairing wizard, and parts of it are permanently limited by what the ring will hand over. It -reads real data from a ring you own, over Bluetooth, with no Oura account and no Oura cloud — the -same rules as everything else here. +NOOP has **experimental, clean-room** support for the **Oura Ring**. It is not a supported strap and +it is not on the same footing as a WHOOP 4.0: it lives behind the experimental-device path in the +pairing wizard, and parts of it are permanently limited by what the ring will hand over. It reads real +data from a ring you own, over Bluetooth, with no Oura account and no Oura cloud — the same rules as +everything else here. + +**Which ring.** Every row in the table below was measured on a **Gen 3**. The framing, the auth +handshake and the event-record dictionary are the same across Gen 3, Ring 4 and Ring 5 +([`docs/OURA_PROTOCOL.md` §7.2](docs/OURA_PROTOCOL.md)); what changes per generation is the MTU, which +characteristics are discovered and the live-HR enable command set — verified on Gen 3, expected the same +on 4/5. A **Ring 5** has been reported working over that path (#2075: pairing/auth, live HR and +inter-beat intervals, skin temperature, battery), which is marked per row as *Ring 5 ✓*. Nothing has +been run on a **Ring 4** yet. The two rows marked *not possible from this data* are about what the ring +banks, not which ring banks it, so they hold across the three until a newer ring is shown transmitting +true beat-to-beat intervals. > **Not affiliated with Oura.** Independent interoperability work with hardware you own. "Oura" is > used only to identify that hardware. NOOP does not use, decompile, or redistribute any Oura app > code, and does not bypass any login, paywall, or DRM. -**📱 iOS / Android only.** Pairing an Oura ring **does not work on macOS** — `connect()` is issued -cleanly and no CoreBluetooth callback ever arrives, so it hangs silently rather than failing. This is -reproducible and independent of the pairing method. Documented in +**⚠️ macOS pairing needs a factory-reset ring.** Pairing an Oura ring that is still Bluetooth-bonded +to a phone (i.e. its only prior bond is with the official Oura app) reproducibly hangs on macOS — +`connect()` is issued cleanly and no CoreBluetooth callback ever arrives. Factory-resetting the ring +from the official Oura app first, then pairing with NOOP on macOS, works. Documented in [`docs/OURA_PROTOCOL.md` §3.8](docs/OURA_PROTOCOL.md). | Input / output | Status | |---|---| -| **Overnight heart rate** | ✅ Works. Reconstructed from the ring's banked inter-beat intervals; validated against a WHOOP strap worn the same night. | +| **Overnight heart rate** | ✅ Works. Reconstructed from the ring's banked inter-beat intervals; validated against a WHOOP strap worn the same night. *Ring 5 ✓* (intervals stream). | | **Sleep stages & timeline** | ✅ Works. Uses the **ring's own** hypnogram rather than re-staging it, so the stages are Oura's, shown in NOOP's Sleep screen. | -| **Skin temperature** | ✅ Works. | +| **Skin temperature** | ✅ Works. *Ring 5 ✓* | | **Motion** | ✅ Stored. | | **Live wear status** | ✅ Works — NOOP can tell whether the ring is on your finger. | -| **Live heart rate** | 🟡 Partial. Only one of the ring's channels ever arrives near-live, and it is quality-filtered, so it does not tile continuously the way a chest-strap or WHOOP feed does. | -| **SpO₂** | 🟡 Raw channel captured and stored; a **calibrated percentage** is still open (~47% of decoded samples read above 100%, which no ground truth yet explains — so NOOP does **not** surface a Blood Oxygen number it can't stand behind). | -| **Recovery / strain score on a ring-only day** | 🚧 **Not yet.** The ring's night is stored and visible, but it does not currently feed the scorer, so a ring-only day shows no recovery or strain. This is the single biggest gap and it is actively being worked on. | +| **Live heart rate** | 🟡 Partial. Only one of the ring's channels ever arrives near-live, and it is quality-filtered, so it does not tile continuously the way a chest-strap or WHOOP feed does. *Ring 5 ✓* | +| **SpO₂** | 🟡 Mostly there. The ring's own overnight SpO₂ percentage is decoded, stored and drawn on the Deep Timeline, and NOOP can switch the ring's SpO₂ sensing on from its own Test Centre (iOS/macOS) — no Oura account needed. A nightly **Blood Oxygen** number (the per-sample ceiling-at-100 mean the wire's positive bias calls for) is available behind the SpO₂ candidate display toggle in Settings, default off; it has round-matched the value the Oura app displays on every paired night measured so far (4 of 4), which is why it is still labelled a candidate rather than promoted to a scored metric. | +| **Recovery / strain score on a ring-only day** | 🟡 **Sleep and Strain, not Recovery.** The ring's own hypnogram feeds the scorer (#1183), so a ring-only day gets a Sleep score and a Strain score (from the ring's banked and live HR — light on exercise HR, which stays server-gated, see the last row). **Recovery does not score:** it requires an HRV baseline, and the ring's banked intervals cannot give one (see the HRV row), so the Today screen shows no Recovery on a ring-only day. | | **Step count** | 🚧 Estimated only, and not shown as a step count. The ring does **not** transmit a step total NOOP can read; what exists is a research estimate derived from activity intensity, which over-reads badly on very active days. | | **HRV (RMSSD / SDNN) and the Rhythm screen** | ❌ **Not possible from this data.** The ring banks its intervals in records rather than sending true beat-to-beat values, which inflates HRV spread beyond anything physiological. NOOP **refuses** to show a number here rather than showing a plausible-looking wrong one. | | **Respiratory rate** | ❌ **Not possible from this data**, for the same reason — it is derived from beat timing, and the ring's banked intervals carry no breathing signal (shuffling them at random produces the identical answer). The Oura app shows respiration because it computes it from data the ring does not transmit. | @@ -521,9 +534,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..a1caa04f30 100644 --- a/Strand/AI/AICoach.swift +++ b/Strand/AI/AICoach.swift @@ -108,10 +108,20 @@ enum AIKeyStore { /// User-facing failure reasons mapped to clear, non-crashing messages. enum AICoachError: LocalizedError { + + /// Whether an HTTP status means the stored key itself was turned away, as opposed to the provider + /// being busy, broken, or asked for something it does not have. + /// + /// Named rather than left as two literals in two switches because it is the hinge the key-repair + /// affordance hangs on, and it decides what the wearer is told to go and do. Widen it and a rate + /// limit starts demanding a new key; narrow it and the trap this exists to remove comes straight + /// back. Byte-identical twin of the Kotlin `AiCoach.isKeyRejection`. + static func isKeyRejection(_ status: Int) -> Bool { status == 401 || status == 403 } + case noKey case emptyQuestion case badKey - case rateLimited + case rateLimited(String) case server(Int, String) case network(String) case decode @@ -131,8 +141,9 @@ enum AICoachError: LocalizedError { return "Type a question for the coach." case .badKey: return "That API key was rejected. Check the key and the provider you selected." - case .rateLimited: - return "The provider is rate-limiting requests right now. Wait a moment and try again." + case .rateLimited(let detail): + let extra = detail.isEmpty ? "" : " (\(detail))" + return "The provider is rate-limiting requests right now. Wait a moment and try again.\(extra)" case .server(let code, let detail): let extra = detail.isEmpty ? "" : " - \(detail)" return "The provider returned an error (\(code))\(extra)." @@ -156,8 +167,34 @@ 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? + + /// Whether the last failure was the provider turning the stored key away, as opposed to a rate + /// limit, a server fault or the network. + /// + /// It exists because the rejection message tells the wearer to check their key while the screen + /// offers no way to reach it: the coach shows the chat as soon as ANY key is stored, and a wrong + /// key is still a stored key, so the only route back was a Disconnect that also throws the + /// conversation away. This lets the error carry the field with it. + /// + /// It QUALIFIES `errorText` rather than standing on its own, and the view reads it only inside the + /// branch that renders one, so it cannot leave a key editor open under no error. Assigned on every + /// failure, so a rejection followed by a rate limit stops claiming to be a rejection. Twin of the + /// Kotlin `CoachViewModel.keyRejected`. + @Published var keyRejected = false + + /// #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 } @@ -168,6 +205,12 @@ final class AICoachEngine: ObservableObject { if !provider.modelOptions.contains(model) { model = provider.defaultModel } + // The message names a provider ("That API key was rejected", after a request only THIS + // provider saw), so it cannot survive switching to a different one. Harmless while only the + // chat rendered it; wrong now that the setup card does too, which is where switching + // happens. Twin of the Kotlin `selectProvider`. + errorText = nil + keyRejected = false } } @Published var model: String { @@ -202,6 +245,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 +261,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 +273,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 +331,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 +399,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 +449,19 @@ 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 + // The error belongs to the connection being retired, so it goes with it. Kotlin has cleared it + // here since the method existed and this side never did: harmless while only the chat rendered + // an error, and a visible defect the moment the setup card does too, because the card this + // returns to would open carrying "That API key was rejected" above an empty key field, reading + // as a verdict on the key about to be typed. + errorText = nil + keyRejected = false objectWillChange.send() } @@ -373,6 +474,10 @@ final class AICoachEngine: ObservableObject { return } errorText = nil + // A stored key is no longer the rejected one. Deliberately leaves the transcript alone: + // correcting a mistyped key is not a reason to lose the conversation, which is what routing + // this through `disconnect` used to cost. Twin of the Kotlin `saveKey`. + keyRejected = false objectWillChange.send() // `hasKey` is computed; nudge SwiftUI to re-read it. // #288: do NOT auto-fetch the provider's model list on key-save. For a cloud provider that GET // egresses to the provider the MOMENT a key is saved (IP + request timing + key-validity) — before @@ -384,6 +489,18 @@ 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 + // The error belongs to the connection being retired, so it goes with it. Kotlin has cleared it + // here since the method existed and this side never did: harmless while only the chat rendered + // an error, and a visible defect the moment the setup card does too, because the card this + // returns to would open carrying "That API key was rejected" above an empty key field, reading + // as a verdict on the key about to be typed. + errorText = nil + keyRejected = false objectWillChange.send() } @@ -409,12 +526,57 @@ final class AICoachEngine: ObservableObject { /// Best-effort: GET the chosen provider's models endpoint with the saved key and merge the /// returned ids into `availableModels`. Never crashes; failures land in `errorText` and leave /// the existing list intact. Requires a saved key. - func refreshModels() async { + /// When the live catalogue was last pulled for `provider`, keyed per provider so switching does + /// not hide one provider's stale list behind another's refresh. Kotlin twin: + /// `NoopPrefs.coachModelsRefreshedAt`. + static func modelsRefreshedKey(_ provider: AIProvider) -> String { + "ai.modelsRefreshed.\(provider.rawValue)" + } + + /// How long a pulled catalogue is trusted. Kotlin twin: `MODEL_REFRESH_INTERVAL_MS`. + static let modelRefreshInterval: TimeInterval = 7 * 24 * 60 * 60 + + /// Whether a catalogue last pulled at `last` is due another pull at `now`. + /// + /// Split out and `static` so the rule can be pinned without an engine: it decides how often the app + /// talks to a provider unasked. A never-pulled catalogue (0) is stale, so the first visit fetches. A + /// clock moved BACKWARDS gives a negative age and reads as fresh, keeping the cached list rather + /// than refetching every visit until the clock catches up. Kotlin twin: + /// `CoachViewModel.isCatalogueStale`. + static func isCatalogueStale(last: TimeInterval, now: TimeInterval) -> Bool { + now - last >= modelRefreshInterval + } + + /// Pull the live catalogue at most once a week, so the picker offers what the provider sells today + /// without this app shipping a build for every model release. + /// + /// Quiet about FAILURE: it passes `silent`, so `refreshModels` leaves the error surface untouched + /// in both directions rather than this restoring it afterwards. Restoring would have raced — there + /// is no re-entrancy guard here, so a manual Refresh tapped during the await would have had its + /// result stomped by a stale snapshot on resume. Not touching the state cannot race with anything. + /// + /// Requires a stored key, so it cannot fire during first-run setup where there is nothing to + /// authenticate with. Custom is excluded: `connectCustom()` already pulls its list, and its server + /// is the user's own machine rather than a vendor catalogue. + /// + /// Only the LIST moves. The selected model is never changed underneath the user. Kotlin twin: + /// `CoachViewModel.refreshModelsIfStale`. + func refreshModelsIfStale() async { + guard provider != .custom, hasKey else { return } + let last = UserDefaults.standard.double(forKey: Self.modelsRefreshedKey(provider)) + guard Self.isCatalogueStale(last: last, now: Date().timeIntervalSince1970) else { return } + await refreshModels(silent: true) + } + + /// `silent` leaves the error surface entirely alone, in both directions: an automatic refresh must + /// neither wipe a message the user is still reading nor raise one they never asked for. Kotlin twin: + /// the `silent` parameter on `CoachViewModel.refreshModels`. + func refreshModels(silent: Bool = false) async { guard let key = resolvedKey else { - errorText = AICoachError.noKey.errorDescription + if !silent { errorText = AICoachError.noKey.errorDescription } return } - errorText = nil + if !silent { errorText = nil } // Snapshot the provider BEFORE the await. The Picker isn't disabled during a refresh, so the // user can switch providers mid-flight (#873). We fetch this provider's ids, then re-check on @@ -439,7 +601,7 @@ final class AICoachEngine: ObservableObject { guard provider == capturedProvider else { return } guard !ids.isEmpty else { - errorText = AICoachError.decode.errorDescription + if !silent { errorText = AICoachError.decode.errorDescription } return } @@ -450,10 +612,25 @@ final class AICoachEngine: ObservableObject { var merged = builtin + discovered if !merged.contains(model) { merged.insert(model, at: 0) } availableModels = merged - } catch { + // Stamp only on a SUCCESSFUL pull, so a provider that is down does not buy itself a week + // of silence from `refreshModelsIfStale()`. + UserDefaults.standard.set(Date().timeIntervalSince1970, + forKey: Self.modelsRefreshedKey(capturedProvider)) + } catch let e as AICoachError { // A switch mid-flight makes any error moot for the old provider, so don't surface it. - guard provider == capturedProvider else { return } + guard provider == capturedProvider, !silent else { return } + // Typed first, because this used to report EVERY failure as a network problem, including a + // key the provider had just turned away. Refresh is one of the two places a wrong key shows + // itself, and it was the one that blamed the wrong thing: the wearer read "Network problem" + // and went looking at their connection. It now says what happened and, for a rejection, + // opens the field to fix it. + errorText = e.errorDescription + if case .badKey = e { keyRejected = true } else { keyRejected = false } + return + } catch { + guard provider == capturedProvider, !silent else { return } errorText = AICoachError.network(error.localizedDescription).errorDescription + keyRejected = false return } } @@ -474,67 +651,296 @@ 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 } + // Recover the day this transcript was last written on FROM THE ROWS. `conversationDay` lives in + // memory, so a process restart brought it back nil, and `isStaleConversation(nil, ...)` is false + // by design (nothing sent yet is never stale), which meant a restored conversation from any + // previous day was never retired, by `send` or by anything else (#2087). + let newest = rows.map(\.createdAt).max() ?? 0 + let lastDay = Self.localEpochDay(Date(timeIntervalSince1970: TimeInterval(newest))) + // Retire by NOT restoring. The next append replaces the stored rows wholesale, so nothing is + // deleted here and a transcript is never destroyed by merely opening the screen. + guard !Self.isStaleConversation(lastEpochDay: lastDay, todayEpochDay: Self.localEpochDay()) 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) } + conversationDay = lastDay + } + + /// 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`. func send(_ userText: String) async { let trimmed = userText.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { errorText = AICoachError.emptyQuestion.errorDescription; return } + // The master switch, checked at the EGRESS rather than only on the routes in. Every way into Coach + // is gated, but "gated everywhere I thought of" is what #2254 got wrong once: a revoked consent + // survived in memory because the conversation never re-read it. A wearer can be STANDING on this + // screen when the switch goes off, and that path passes no tab. Refusing here makes "the AI is off" + // true however the screen was reached. + guard CoachBriefScheduler.coachMasterEnabled else { 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 + // Typed, never text-matched: the message is localized and the case is not. + if case .badKey = e { keyRejected = true } else { keyRejected = false } } 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 + keyRejected = false } } /// 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 + // Typed, never text-matched: the message is localized and the case is not. + if case .badKey = e { keyRejected = true } else { keyRejected = false } } 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 + keyRejected = false } } + /// 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? { + // Same master-switch gate as `send`, because this entry has NO UI at all: it is what the scheduler + // calls, and a caller that skipped the scheduler's own gate would otherwise reach a provider with + // the AI switched off. + guard CoachBriefScheduler.coachMasterEnabled else { return nil } + 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 +959,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 +967,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 +987,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 +1044,119 @@ 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. Kotlin computes the same value with + /// `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 +1237,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 +1247,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..b37a29f2c5 100644 --- a/Strand/AI/AIProvider.swift +++ b/Strand/AI/AIProvider.swift @@ -1,4 +1,5 @@ import Foundation +import StrandAnalytics // MARK: - Provider enum @@ -21,7 +22,7 @@ enum AIProvider: String, CaseIterable, Identifiable { var defaultModel: String { switch self { - case .openAI: return "gpt-4o-mini" + case .openAI: return "gpt-5-mini" case .anthropic: return "claude-sonnet-4-6" case .gemini: return "gemini-flash-latest" // stable alias → current Flash, no version churn (#400) case .custom: return "" // the user picks the model their server serves @@ -33,7 +34,27 @@ enum AIProvider: String, CaseIterable, Identifiable { var modelOptions: [String] { switch self { case .openAI: - return ["gpt-4o", "gpt-4o-mini", "gpt-4.1", "gpt-4.1-mini", "gpt-4.1-nano"] + // Pinned ids, not aliases: OpenAI has no stable per-tier "-latest" alias the way Gemini + // does (#400), so this list is bumped by hand. `refreshModels()` merges the live /models + // catalogue, which stays the authority for anything released after this. + // + // The reasoning tiers (o3, o4-mini) and the GPT-5 family reject `temperature` and + // `max_tokens`. Nothing special is needed for them here: the request path sends the + // classic parameters, and on a 400 naming one of them retries with + // `max_completion_tokens` and no temperature (see AiCoach's modernParams leg). The cost + // is one extra round trip on the first message, not a per-model table to maintain. + return [ + "gpt-5", + "gpt-5-mini", + "gpt-5-nano", + "gpt-4.1", + "gpt-4.1-mini", + "gpt-4.1-nano", + "gpt-4o", + "gpt-4o-mini", + "o3", + "o4-mini" + ] case .anthropic: return [ "claude-opus-4-8", @@ -227,6 +248,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 @@ -253,10 +333,10 @@ func performRequest(_ req: URLRequest, session: URLSession) async throws -> [Str } return obj - case 401, 403: + case let status where AICoachError.isKeyRejection(status): throw AICoachError.badKey case 429: - throw AICoachError.rateLimited + throw AICoachError.rateLimited(providerErrorMessage(from: data)) default: throw AICoachError.server(http.statusCode, providerErrorMessage(from: data)) } @@ -283,3 +363,47 @@ 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 let status where AICoachError.isKeyRejection(status): + throw AICoachError.badKey + case 429: + var body = "" + for try await line in bytes.0.lines { body += line } + throw AICoachError.rateLimited(providerErrorMessage(from: Data(body.utf8))) + 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..21fdc37f34 100644 --- a/Strand/App/AppModel.swift +++ b/Strand/App/AppModel.swift @@ -4,6 +4,7 @@ import WhoopProtocol import WhoopStore import StrandAnalytics import StrandImport +import OuraProtocol #if os(iOS) import UserNotifications #endif @@ -32,6 +33,14 @@ final class AppModel: ObservableObject { let f = DateFormatter(); f.dateFormat = "HH:mm:ss"; return f }() + /// One of our own strap-log lines, stamped like the lines around it. NOOP's log takes each line's time from + /// whoever writes it, and the Lift Log's lines arrived without one: all 98 of them in Utku's 22 Sep session, + /// so the moment a tap or a step happened had to be inferred from the neighbouring lines. A diagnostic says + /// when it happened. + static func stamped(_ line: String) -> String { + "[\(logTimeFormatter.string(from: Date()))] \(line)" + } + /// The CANONICAL imported/computed id ("my-whoop"). The WHOOP-IMPORT target (`WhoopImporter`), the /// FusionSource `.whoopImport` mapping, and a manually-saved workout all land under THIS stable id, and /// the engine writes its computed scores under the matching `-noop` sibling. It must NOT follow the @@ -118,9 +127,35 @@ final class AppModel: ObservableObject { var isPaused: Bool { pausedAt != nil } + /// Adds a heart-rate sample unless this second already has one, and says whether it did. + /// + /// `captureWorkoutSample` runs from two `@Published` sinks (`heartRate` and `rr`), so a strap sends + /// it one call per R-R packet plus another whenever the rate itself moves, which during exercise is + /// most seconds: two samples with one `ts`. Effort credits each sample with the gap to the next and a + /// zero gap with a full second (`StrainScorer.sampleDurationsMinutes`), so every repeat counted as + /// another second of effort, live and in the saved workout. The stream is one reading a second. + /// + /// A refused reading still reaches `peakHr`: the sinks fire because the rate moved, so a repeat is often a + /// different bpm for that second, and a within-second high is part of the workout's peak. Only the last + /// sample is compared, so this drops a repeat of the current second, not an out-of-order arrival; the live + /// stream is monotonic. + mutating func recordSample(_ sample: HRSample) -> Bool { + if let last = samples.last, last.ts == sample.ts { + peakHr = max(peakHr, sample.bpm) + return false + } + samples.append(sample) + return true + } + + /// The maximum heart rate the workout is saved with: the highest sample, or a higher reading a repeated + /// second folded into `peakHr` (`recordSample`). Nil with no samples. + var savedPeak: Int? { samples.map(\.bpm).max().map { max($0, peakHr) } } + + /// 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 { @@ -159,6 +194,8 @@ final class AppModel: ObservableObject { // L3 stress-onset detector state: a rolling R-R buffer + the replay-safe detector state (persisted // via BiofeedbackPrefs so a relaunch can't re-fire), carried verbatim between evaluations. private var rrBuf: [Int] = [] + /// Which live R-R packet `rrBuf` last took, so each packet enters it once (`RRPacketCursor`). + private var stressPackets = RRPacketCursor() private var stressState = BiofeedbackPrefs.loadStressState() /// Import source currently writing to the local store, if any. @@ -245,8 +282,27 @@ final class AppModel: ObservableObject { } }.store(in: &hrCancellables) // Smooth HR centrally so it's solid everywhere it's shown. - live.$heartRate.sink { [weak self] _ in self?.ingestHR() }.store(in: &hrCancellables) - live.$rr.sink { [weak self] _ in self?.ingestHR() }.store(in: &hrCancellables) + // A `@Published` sink runs in willSet, before the value lands, so each hands `ingestHR` the value being + // written and reads the other from `live`, where it is current. Reading both from `live` meant clearing the + // heart rate (a disconnect, the strap off the wrist) found the old values still there and kept the median. + live.$heartRate.sink { [weak self] hr in + guard let self else { return } + self.ingestHR(heartRate: hr, rr: self.live.rr) + }.store(in: &hrCancellables) + live.$rr.sink { [weak self] rr in + guard let self else { return } + self.ingestHR(heartRate: self.live.heartRate, rr: rr) + }.store(in: &hrCancellables) + + // #2117: bank the device's R-R transport facts whenever a link comes up. Shell-independent on + // purpose: the classic Today already reads these three for its own note, but the Liquid shell is + // the iOS default, and the wearer this explains is the one whose HRV silently went blank there. + // Two indexed MINs plus one registry read, once per connect, so it is cheap enough not to gate. + live.$connected.sink { [weak self] isConnected in + // The disconnect path clears via `clearBiometrics`, so only a link coming UP refreshes. + guard isConnected, let self else { return } + Task { await self.refreshRRTransportFacts() } + }.store(in: &hrCancellables) // Physical-input + wear hooks (fired live by FrameRouter). live.onDoubleTap = { [weak self] in self?.handleDoubleTap() } @@ -502,6 +558,36 @@ final class AppModel: ObservableObject { ble.setPauseCaptureOnPowerSave(on && PuffinExperiment.pauseHrvOnPowerSaveEnabled, thresholdPct: PuffinExperiment.powerSavingBatteryPct) } + /// #2117: resolve what this device has banked versus what its unit policy can score, and hand the + /// facts to `LiveState` so every Test Centre export carries the universal `rrTransport` line. + /// + /// Facts only. The judgement is `UniversalTrace.rrTransportLine`, shared byte for byte with Android. + /// Silent on failure: a diagnostic that cannot read its inputs says nothing rather than guessing, and + /// the line is simply absent from the export. + private func refreshRRTransportFacts() async { + // No store to ask: drop whatever was banked rather than leaving a previous answer standing. A + // diagnostic may only assert what it can attribute, and stale facts would be attributed to now. + guard let store = await repo.storeHandle() else { + live.clearRRTransport() + return + } + let owner = repo.deviceId + let strict = (try? await store.isWhoop5RRSource(deviceId: owner)) ?? false + // The two MINs are only ever read by a line the formatter suppresses unless this is strict, so a + // device the policy does not govern stops after the one registry read. That case is not + // hypothetical: a 4.0 in a reconnect burst (#1120) runs this repeatedly, and the timestamps would + // be fetched from the store queue the backfill is writing through, to be discarded every time. + guard strict else { + live.setRRTransport(strictWhoop5: false, firstRecordedUnix: nil, firstScorableUnix: nil) + return + } + let firstRecorded = (try? await store.firstRecordedRRTimestamp(deviceId: owner)) ?? nil + let firstScorable = (try? await store.firstScorableWhoop5RRTimestamp(deviceId: owner)) ?? nil + // AppModel is @MainActor, so this resumes on the main actor: no hop needed. + live.setRRTransport(strictWhoop5: strict, firstRecordedUnix: firstRecorded, + firstScorableUnix: firstScorable) + } + /// Tiny and guarded: with no generic strap paired the active id is "my-whoop", so the coordinator /// observes WHOOP-active and stays a NO-OP , the existing `scan()`/`disconnect()` WHOOP flow is @@ -541,8 +627,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,7 +649,14 @@ 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 + bindOuraFeatureStatusMirror() // #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- // screen switch/remove/re-add calls `registry.setActive` DIRECTLY (NOT through `registerDevice`), so @@ -617,13 +714,31 @@ final class AppModel: ObservableObject { /// so that it cannot mark unscored data as scored — so gating on the fingerprint here would be asking /// a question whose answer is already known to be "yes, there is work". func runDeferredRescoreIfOwed() async { - guard RescoreBackgroundScheduler.isRescoreOwed else { return } - live.append(log: "re-score: resuming a pass an earlier attempt could not finish (#1538)") - await intelligence.analyzeRecent() + // A pass already running here holds the owed mark itself and settles it when it finishes; forcing + // another would only queue a second full pass behind it. + guard RescoreBackgroundScheduler.isRescoreOwed, !intelligence.computing else { return } + // #2238: force only when the debt is UNPROVEN — an interrupted pass, whose watermark was + // deliberately never advanced. A pass that COMPLETED and was merely outvoted by a token recorded + // mid-pass did advance it, so asking the fingerprint is a real question with a real answer, and a + // "nothing changed" answer is the only thing that lets this chain stop. + // + // Without it a ring draining every 5 minutes re-scores 21 nights continuously while the app is + // awake: each pass runs longer than the drain interval, so it always finishes owing a newer debt, + // and the resume forces the next one whether or not a single row moved. + let fromCompletedPass = RescoreBackgroundScheduler.isOwedAfterCompletedPass + live.append(log: "re-score: resuming a pass an earlier attempt could not finish (#1538)" + + (fromCompletedPass ? " — debt is from a completed pass, gating on the fingerprint (#2238)" : "")) + await intelligence.analyzeRecent(skipIfUnchanged: fromCompletedPass, + triggerLabel: fromCompletedPass ? "resume-gated" : "resume-forced") #if os(iOS) // The deferred pass is the one that finally produces today's score, and it runs with no UI // attached — so publish the snapshot here too, for the same reason the post-offload path does. await WidgetSnapshot.publish(from: self) + // Apple Health too. The post-offload write-back ran BEFORE this pass (the offload deferred its + // re-score here), so it published the store as it stood then: last night's sleep and vitals were + // not scored yet and only reached Health on some later foreground. This is the first moment they + // exist. The bridge coalesces a call that lands during an in-flight write-back. + await healthWriteBack?() #endif } @@ -634,7 +749,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. @@ -646,7 +761,8 @@ final class AppModel: ObservableObject { // the #1538 report while never producing a score. Decide first whether this pass can finish here, // and hand it to a background-processing task when it cannot. A no-op on macOS, and on iOS a // foreground pass is never deferred. - await RescoreBackgroundScheduler.run(log: { [live] line in live.append(log: line) }) { + await RescoreBackgroundScheduler.run(passInProgress: intelligence.computing, + log: { [live] line in live.append(log: line) }) { await intelligence.analyzeRecent(skipIfUnchanged: true) } await refreshV5Signals() @@ -669,11 +785,11 @@ final class AppModel: ObservableObject { /// Fold a fresh reading into the smoothing window and republish a stable bpm. /// Prefers the strap's reported HR; falls back to 60000/R-R. Clamps to a plausible /// 30–220 range (rejects 0 / garbage spikes) and publishes the window MEDIAN. - private func ingestHR() { + private func ingestHR(heartRate: Int?, rr: [Int]) { var inst: Double? - if let hr = live.heartRate, hr >= 30, hr <= 220 { + if let hr = heartRate, hr >= 30, hr <= 220 { inst = Double(hr) - } else if let rr = live.rr.last, rr > 0 { + } else if let rr = rr.last, rr > 0 { let v = 60_000.0 / Double(rr) if v >= 30, v <= 220 { inst = v } } @@ -682,7 +798,7 @@ final class AppModel: ObservableObject { // median so screens that now prefer `bpm` fall through to "," instead of freezing on the // last value. Mirrors Android (_bpm = null on disconnect). A transient out-of-range sample // with the link still up (heartRate or rr still present) keeps the last median. - if live.heartRate == nil && live.rr.isEmpty { resetSmoothing() } + if heartRate == nil && rr.isEmpty { resetSmoothing() } return } let now = Date() @@ -842,6 +958,18 @@ final class AppModel: ObservableObject { /// Finish the active workout: finalize the GPS route (#524), score the captured HR window, and save it /// as a `WorkoutRow`. A session with no HR window AND no real GPS route is discarded quietly (parity /// with Android) , but a GPS-only walk with HR not streaming still saves. Double-buzz confirms. + /// Shortest live session worth keeping. Below this a start/stop is an accident, not training (#2278). + static let minimumWorkoutSeconds: TimeInterval = 60 + + /// Whether a finished live session is too short to save. + /// + /// A named predicate rather than an inline comparison so the boundary is pinned by a test and so the + /// Android twin has one thing to mirror. Exactly `minimumWorkoutSeconds` is KEPT: a wearer who logs a + /// deliberate one-minute effort gets to keep it, and the discard is for what falls short of that. + nonisolated static func isTooShortToSave(elapsedSeconds: TimeInterval) -> Bool { + elapsedSeconds < minimumWorkoutSeconds + } + func endWorkout() { guard let w = activeWorkout else { return } activeWorkout = nil @@ -871,9 +999,29 @@ final class AppModel: ObservableObject { return } let end = Date() + // A session under a minute is a start/stop the wearer did not mean to keep, and it was the thing + // that made deletion feel broken: the list filled with 5-30 second entries (#2278). Discarded HERE, + // at save, rather than retained and pruned later, which is the whole difference between dropping + // something that never had training data in it and deleting a wearer's history. NOOP has no server + // and no cloud copy, so a later prune would be irreversible; this is not, because nothing with real + // data is ever removed. + // + // Sits after the sample/route gate above so that gate's meaning is unchanged: a 30-second session + // can easily carry two HR samples and would otherwise have been saved. + let elapsed = w.elapsed(at: end) + if Self.isTooShortToSave(elapsedSeconds: elapsed) { + emitWorkoutsTrace(WorkoutsTrace.sessionLine( + event: "discarded", sportKey: WorkoutSource.traceSportKey(w.sport), + hrSamples: samples.count, durationSec: Int(elapsed), + gpsPoints: wasGps ? gpsRecorder.pointCount : nil)) + // Drop the route too: keeping a polyline for a session that was never saved would orphan it in + // RouteStore under a natural key no row claims. + lastWorkout = nil + return + } let avg = samples.isEmpty ? nil : Int((Double(samples.map(\.bpm).reduce(0, +)) / Double(samples.count)).rounded()) - let peak = samples.map(\.bpm).max() + let peak = w.savedPeak // #983: score the SAVED workout with the wearer's measured resting HR, not the hardcoded // default of 60. %HRR is (bpm - resting) / (max - resting), so the default moves every zone // boundary — at 136 bpm with maxHR 190 it is the difference between zone 1 and zone 2. Today's @@ -933,7 +1081,13 @@ final class AppModel: ObservableObject { /// over the growing window each sample is cheap at the ~1 Hz live-HR cadence. private func captureWorkoutSample() { guard var w = activeWorkout, !w.isPaused, let hr = bpm else { return } - w.samples.append(HRSample(ts: Int(Date().timeIntervalSince1970), bpm: hr)) + // A second that already has its sample moves only the peak: publish that, and skip the rescore and the + // snapshot (the next second's sample carries the peak into the snapshot). + let peakBefore = w.peakHr + guard w.recordSample(HRSample(ts: Int(Date().timeIntervalSince1970), bpm: hr)) else { + if w.peakHr != peakBefore { activeWorkout = w } + return + } w.peakHr = max(w.peakHr, hr) w.avgHr = Int((Double(w.samples.map(\.bpm).reduce(0, +)) / Double(w.samples.count)).rounded()) w.liveStrain = StrainScorer.strain(w.samples, maxHR: Double(profile.hrMax), @@ -959,6 +1113,10 @@ final class AppModel: ObservableObject { /// baseline + rate limit), persisted via `BiofeedbackPrefs` so a relaunch can't re-fire. Honest / /// non-clinical: "stress" is an autonomic proxy vs the user's own baseline, never a diagnosis. private func evaluateStress() { + // Once per R-R packet. `ingestHR` runs from both the heart-rate and the R-R sink, so a packet reached + // this once or twice, its intervals entered `rrBuf` as often, and the detector's slow baseline + // advanced on every call rather than every packet. + guard stressPackets.isNew(live.rrSeq) else { return } let fresh = live.rr.filter { $0 > 300 && $0 < 2000 } // plausible R-R (30–200 bpm) guard !fresh.isEmpty else { return } rrBuf.append(contentsOf: fresh) @@ -1154,6 +1312,47 @@ final class AppModel: ObservableObject { /// published properties above. Re-bound whenever the active Oura source changes. private var ouraAdoptCancellables = Set() + /// The most recent `feature status` read-back per feature id (0x04 SpO2, 0x0b real-steps, 0x03 + /// exercise-HR, 0x0d CVA-PPG), mirrored off the live Oura source so Test Centre's enable/disable + /// rows can show the ring's own current state instead of being log-only. Bound once, right after + /// `sourceCoordinator` is set (below) — unlike `ouraAdoptPhase` above, this isn't scoped to the + /// adopt wizard, so it needs to be live for any paired ring, not just one mid-adopt. + @Published private(set) var ouraFeatureStatuses: [Int: OuraFeatureStatus] = [:] + private var ouraFeatureStatusCancellable: AnyCancellable? + + /// The live ring's link phase (`disconnected` / `connecting` / `authenticating` / `authenticated`), + /// mirrored off the live Oura source for the Live console's ring status and reconnect affordance + /// (#2305). `.disconnected` when no ring source is live. Bound beside the feature-status mirror. + @Published private(set) var ouraLinkPhase: OuraLiveSource.LinkPhase = .disconnected + private var ouraLinkPhaseCancellable: AnyCancellable? + + /// (Re)bind the feature-status and link-phase mirrors to whichever `OuraLiveSource` the coordinator + /// has live, and every later swap — same `flatMap`-over-`$ouraSource` shape as `bindOuraAdoptMirror` + /// below. + private func bindOuraFeatureStatusMirror() { + guard let coordinator = sourceCoordinator else { return } + ouraFeatureStatusCancellable = coordinator.$ouraSource + .flatMap { source -> AnyPublisher<[Int: OuraFeatureStatus], Never> in + source?.$featureStatuses.eraseToAnyPublisher() + ?? Just([:]).eraseToAnyPublisher() + } + .receive(on: DispatchQueue.main) + .sink { [weak self] in self?.ouraFeatureStatuses = $0 } + ouraLinkPhaseCancellable = coordinator.$ouraSource + .flatMap { source -> AnyPublisher in + source?.$linkPhase.eraseToAnyPublisher() + ?? Just(.disconnected).eraseToAnyPublisher() + } + .receive(on: DispatchQueue.main) + .sink { [weak self] in self?.ouraLinkPhase = $0 } + } + + /// Reconnect the active ring on the user's request from the Live console (#2305). Routed through the + /// coordinator so it can only ever reach the ring that is the live source. + func reconnectOuraRing() { + sourceCoordinator?.reconnectActiveRing() + } + /// Take over a factory-reset Oura ring: grant the coordinator explicit adopt consent for THIS ring (so /// its live session may run the one-time key install, s3.2), register it active (which starts that live /// session), then begin mirroring its adopt outcome for the wizard. The irreversible-consent gate has @@ -1357,7 +1556,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 +1578,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 +1589,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 +1660,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 +1691,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 +1701,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 } @@ -1510,11 +1759,29 @@ final class AppModel: ObservableObject { // MARK: - Physical inputs / wear automation + /// Set by a running Lift Log session to CLAIM the strap's double-tap for the duration of that + /// session, so a set can be logged without picking the phone up — the one cue that works with the + /// phone face-down on a bench. Cleared when the session ends, handing the gesture straight back to + /// whatever the user has configured; nothing about their setting is read or written. + /// + /// A double tap rather than a single one because a strap takes knocks against bars and benches all + /// session, and two deliberate taps are not something a rack does by accident. + var strapDoubleTapOverride: (@MainActor () -> Void)? + private func handleDoubleTap() { let now = Date() - guard now.timeIntervalSince(lastDoubleTapAt) > 1.2 else { return } // debounce repeats + let since = now.timeIntervalSince(lastDoubleTapAt) + guard since > 1.2 else { // debounce repeats + live.append(log: Self.stamped(String(format: "Double-tap ignored: %.1f s after the previous one (debounce 1.2 s)", since))) + return + } lastDoubleTapAt = now - live.append(log: "Double-tap → \(behavior.doubleTapAction.label)") + if let override = strapDoubleTapOverride { + live.append(log: Self.stamped("Double-tap → Lift Log: next")) + override() + return + } + live.append(log: Self.stamped("Double-tap → \(behavior.doubleTapAction.label)")) runMacAction(behavior.doubleTapAction, shortcut: behavior.doubleTapShortcut) } @@ -1671,6 +1938,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 +2019,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") @@ -1836,6 +2127,18 @@ final class AppModel: ObservableObject { set { UserDefaults.standard.set(newValue, forKey: Self.ouraOnsetKeyingKey) } } + /// Oura packed-notification A/B (EXPERIMENTAL, default OFF): send the official app's SetNotification + /// mask `1c 01 ff` at the next connect instead of NOOP's `3f`. The ring packs ~10 packets per + /// notification for the official app (9x the drain throughput) and NOOP's session never gets that + /// shape; the mask is the first candidate switch (OURA_PROTOCOL.md s2.3). Read once per connect, so + /// turning it off restores `3f` on the next session — nothing persists on the ring. Readout: the + /// `-> notify_all(ff)` line and the raw sidecar's notification-size histogram. Test Centre only. + static let ouraNotifyMaskFullKey = "noopOuraNotifyMaskFull" + var ouraNotifyMaskFull: Bool { + get { UserDefaults.standard.bool(forKey: Self.ouraNotifyMaskFullKey) } + set { UserDefaults.standard.set(newValue, forKey: Self.ouraNotifyMaskFullKey) } + } + /// Recompute the v5 skin-temp suite snapshots (cycle phase + body clock) from the current history. /// Called from the analytics pass and when the cycle opt-in flips. Honest-nil throughout: cycle is /// nil unless opted in; circadian is nil unless a usable activity profile exists. 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..55ec1a9604 100644 --- a/Strand/App/RootView.swift +++ b/Strand/App/RootView.swift @@ -202,6 +202,9 @@ struct RootView: View { @EnvironmentObject var router: NavRouter /// The liquid Today (default) vs the classic Today, same flag the iOS shell + Settings toggle read. @AppStorage("noop.liquidTodayEnabled") private var liquidTodayEnabled = true + /// The Coach master switch (`noop.coachEnabled`, shared by name with Android and iOS). Default ON. + @AppStorage("noop.coachEnabled") private var coachEnabled = true + @State private var selection: NavItem? = .today /// Which sidebar groups are expanded (S1, #805). Default = the group owning the launch selection /// (`.today`). The single-item Today/Sleep sections always read expanded so their one row shows; the @@ -328,6 +331,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 } @@ -378,9 +384,15 @@ struct RootView: View { /// user-search semantics (case-insensitive, diacritic-insensitive, locale-aware) in one call. /// ALL groups filter, including single-item Today/Sleep; a group with no hits disappears entirely. private func visibleItems(in group: NavGroup) -> [NavItem] { + // Coach is dropped here, at the RENDER site, rather than out of `NavGroup.all`. That catalogue is + // asserted complete -- every NavItem appears in it exactly once (the M5 routability test) -- so + // filtering the source would trade a hidden row for a failing invariant. Hiding it here keeps the + // catalogue honest and still leaves the destination routable, which matters because a saved + // `selection` or a deep link can still name `.coach` after the switch goes off. + let items = coachEnabled ? group.items : group.items.filter { $0 != .coach } let query = trimmedQuery - guard !query.isEmpty else { return group.items } - return group.items.filter { $0.localizedTitle.localizedStandardContains(query) } + guard !query.isEmpty else { return items } + return items.filter { $0.localizedTitle.localizedStandardContains(query) } } /// One selectable destination row (same Label styling the flat list used), tagged for selection. @@ -542,7 +554,13 @@ private struct SidebarStatus: View { Text(statusText) .font(StrandFont.rounded(12, weight: .medium)) .foregroundStyle(StrandPalette.textPrimary) - Text(live.batteryPct.map { String(localized: "Battery \(Int($0))%") } ?? String(localized: "Strap not connected")) + // #2208: gated on BOTH the link and whose device it is. This read had NO gate at all, so + // it showed the strap's last charge with nothing connected: `batteryPct` is never cleared, + // which made the honest `nil` branch below unreachable on any install that had paired a + // strap once. "Strap not connected" was dead text. + Text(live.connected && live.activeIsWhoop + ? live.batteryPct.map { String(localized: "Battery \(Int($0))%") } ?? String(localized: "Strap not connected") + : String(localized: "Strap not connected")) .font(StrandFont.rounded(11)) .foregroundStyle(StrandPalette.textTertiary) } 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/App/TabRoute.swift b/Strand/App/TabRoute.swift index 2bbdd42463..6f11257774 100644 --- a/Strand/App/TabRoute.swift +++ b/Strand/App/TabRoute.swift @@ -75,3 +75,37 @@ extension View { } } } + +/// The metric keys the hero rings and their Key-Metrics tiles route to, named rather than repeated as +/// literals. +/// +/// Both Apple Today shells render those rings and both used their own copies of the strings, so the +/// pairing lived in three places at once including the test that was meant to guard it. +/// +/// Not every ring uses one. A ring opens the RICHEST explanation its shell has, so Charge keeps its +/// breakdown sheet wherever that sheet exists (the classic Today, and Android) and takes a route only on +/// the Liquid Today, which has no breakdown to offer. Effort and Rest have no breakdown anywhere, so a +/// route is the richest thing they have and every surface uses one. `TabRoute.metric` +/// falls back to the Health screen on a key it does not recognise, which is the reason this is worth +/// pinning: a rename leaves the ring tappable, animating, and landing on the wrong screen with nothing +/// logged and nothing to notice. +/// +/// Twin of Android's `HERO_CHARGE_METRIC_KEY` and friends, with ONE deliberate difference: Rest routes on +/// `sleep_performance` here and on `rest` there, because the two platforms' detail screens resolve +/// different key spaces. Making them agree would break the Android side, not fix anything here. +/// +/// SCOPE, since the same three strings appear elsewhere meaning something else. These are CATALOG keys, +/// resolved through `MetricCatalog`. The Liquid Key-Metrics tiles take them too, because that parameter +/// resolves the tile's destination through the same catalog, which is the pairing the hero-ring test +/// exists to guard. What does NOT belong here is the series key space: `exploreSeries(key:)`, +/// `resolvedSeries(key:)`, `sparks[...]` and the classic hero's `provenanceKey` happen to spell two of +/// these the same way while asking a different question. Android's `rest` against `sleep_performance` is +/// the standing proof that two key spaces looking alike is not the same as being one. +enum HeroRingMetric { + static let charge = "recovery" + static let effort = "strain" + static let rest = "sleep_performance" + + /// Charge, Effort, Rest, in the order the hero row renders them. + static let all = [charge, effort, rest] +} diff --git a/Strand/BLE/BLEManager.swift b/Strand/BLE/BLEManager.swift index 45ae108155..475a3e423a 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,62 @@ 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 + + /// #2332: the most recent link RSSI and when it was read, both scoped to the CURRENT link. + /// + /// Apple read no link RSSI at all before this. `discoveredWhoops` carries a SCAN-time reading, taken + /// before the connection existed, so a link that degraded and died left nothing about range behind it + /// — for the end reason that names range as the suspect. + /// + /// Cleared with `linkUpSince`, and for the same reason: a reading carried over from the PREVIOUS link + /// would put a number on this link's drop that was never measured on it. The pair travels to + /// `ConnectionReadout.linkEpitaph` together so the age is always printed with the value. + /// + /// Diagnostic only — nothing reads these to make a decision. + /// #2397: how many RSSI readings this link produced, and their worst and total, so the epitaph can + /// report a SHAPE rather than a point. #2332 added the periodic read and kept only the latest value, + /// so a link that took 200 readings reported one of them. Last alone cannot separate "marginal all + /// along" from "walked out of range", which is the question a supervision timeout raises. + /// + /// Free: fed by readings the periodic read ALREADY takes. No extra radio work, no new timer. + private var rssiReads = 0 + private var rssiWorstDbm: Int? + private var rssiSumDbm = 0 + + private var lastRssiDbm: Int? + /// When `lastRssiDbm` was read. Monotonic, matching `linkUpSince`, so a wall-clock change mid-link + /// cannot make the printed age negative. Meaningless unless `lastRssiDbm` is non-nil. + private var lastRssiAt: DispatchTime? + /// Throttle for the keep-alive RSSI read. Apple cannot use Android's `keepAliveTick % 2` here: + /// `keepAliveTick` only advances past the WHOOP4-only guard in `keepAliveFire`, so on a 5/MG (the + /// family this diagnostic is for) it never increments. A wall-clock throttle reaches both families + /// and both bond states, and lands on the same ~60 s cadence as the Android twin. + private var lastRssiReadAt: Date? + /// ~60 s between link RSSI reads, matching the Android odd-tick cadence. + private static let rssiReadIntervalSeconds: TimeInterval = 60 + + /// 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 +926,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). @@ -871,6 +998,11 @@ public final class BLEManager: NSObject, ObservableObject { /// callback (the settle resolved); if it fires instead, the state never settled — the wedged-grant /// shape (#429) — and the #295 re-grant banner is shown after all. private var unauthorizedSettleWork: DispatchWorkItem? + /// When the 5/MG CLIENT_HELLO went out, or nil when none is outstanding. Consumed ONLY by a + /// completion on the hello's own characteristic (or a failed one, or the link dropping) — never by an + /// unrelated command's callback, which would otherwise either inherit the window or close it out from + /// under a genuine ack still in flight (#1635). Kotlin twin: `WhoopBleClient.clientHelloWriteAtMs`. + private var clientHelloWriteAt: Date? private var cmdCharacteristic: CBCharacteristic? /// #613: true when a command would ACTUALLY reach the strap right now — the same condition `send()` /// requires (connected peripheral + a discovered command characteristic). The alarm-arm log and the @@ -890,14 +1022,53 @@ 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. + private var gattTreeDumpedFor: UUID? + 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). @@ -905,6 +1076,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 @@ -1010,6 +1186,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. @@ -1094,6 +1271,10 @@ public final class BLEManager: NSObject, ObservableObject { /// pick so restoration/reconnect after a relaunch target the right strap. private var selectedModel: WhoopModel = .persisted private var lastStandardHRLogAt: Date? + /// Counts unreadable standard heart-rate samples in a row, so a run of them clears the shown heart rate. + private var heartRateReadability = LiveHeartRateReadability() + /// The skin-contact flag the last standard heart-rate sample carried, so a change is logged once. + private var lastLoggedHRContact: StandardHRContact? /// True when the selected/connected strap is a WHOOP 5/MG. Read-only window onto the private /// `selectedModel` so a view can tell whether the firmware-alarm path is the experimental 5/MG one @@ -1119,9 +1300,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? @@ -1146,7 +1358,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 @@ -1160,12 +1375,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 @@ -1195,9 +1445,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 @@ -1209,7 +1475,15 @@ 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 per-sample host-received readout belongs to the modes that exist for it; without one, the log + // carries the summary instead (`LivePersistTrace.StandardHRHostReceivedTrace`). + collector?.hostReceivedDetail = { TestCentre.active(.hrv) || TestCentre.active(.connection) } // 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() @@ -1217,6 +1491,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 @@ -1282,7 +1563,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) @@ -1294,12 +1578,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, @@ -1315,6 +1606,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) } @@ -1325,6 +1627,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) } @@ -1343,6 +1651,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 @@ -1403,9 +1712,31 @@ public final class BLEManager: NSObject, ObservableObject { central.connect(p, options: nil) return } + #if os(iOS) + // Off-screen, a scan is the wrong tool: iOS throttles background scanning so hard that a field log + // showed eight minutes of alternating 5.0/4.0 scans finding nothing, then the strap discovered eight + // seconds after the app came to the foreground — twice. A targeted connect to the last strap is what + // iOS honours in the background: it has no timeout and wakes the app when the strap advertises, the + // same call the pinned path above and the standing reconnect already make. Foreground behaviour is + // unchanged, so a scan still finds a strap the user has switched to. + if UIApplication.shared.applicationState != .active, + let last = Self.lastConnectedPeripheralUUID, + let p = central.retrievePeripherals(withIdentifiers: [last]).first { + log("Connecting to last strap \(last) — targeted (app not on screen; a background scan would not find it)") + preparePeripheral(p) + central.connect(p, options: nil) + return + } + #endif startScan(for: model, allowFallback: true) } + /// The identifier of the strap the last successful connect landed on, for the background path above. + static let lastConnectedPeripheralKey = "ble.lastConnectedPeripheralUUID" + private static var lastConnectedPeripheralUUID: UUID? { + UserDefaults.standard.string(forKey: lastConnectedPeripheralKey).flatMap(UUID.init(uuidString:)) + } + public func disconnect() { intentionalDisconnect = true cancelScanFallback() @@ -1437,6 +1768,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 @@ -1444,6 +1778,10 @@ public final class BLEManager: NSObject, ObservableObject { // so connect()/restoration can't re-target it. if target == nil || preferredPeripheralUUID == target { setPreferredPeripheral(nil) } if target == nil || restoredPeripheral?.identifier == target { restoredPeripheral = nil } + // The background targeted-connect must not re-grab a strap the user has released either. + if target == nil || Self.lastConnectedPeripheralUUID == target { + UserDefaults.standard.removeObject(forKey: Self.lastConnectedPeripheralKey) + } // Drop the live BLE link so the strap is free to enter pairing mode. if isCurrent, let p = peripheral { central.cancelPeripheralConnection(p) @@ -1457,6 +1795,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 @@ -1691,6 +2035,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 @@ -1746,8 +2168,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 } @@ -1755,7 +2183,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 @@ -1765,6 +2196,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. @@ -1847,6 +2322,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 @@ -2205,7 +2684,9 @@ public final class BLEManager: NSObject, ObservableObject { backfillTimeout?.cancel() backfillTimeout = nil backfillFrameQueue.removeAll() - log("Backfill: session ended — reason=\(reason)") + log("Backfill: session ended — reason=\(reason)" + + BLEManager.sessionEndedOutcome(reason: reason, + bankedRows: (backfiller?.sessionRowsPersisted ?? 0) > 0)) // 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. if reason == "HISTORY_COMPLETE" { maybeBuzzInactivity() } @@ -2330,8 +2811,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 { @@ -2346,8 +2846,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 @@ -2365,6 +2896,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 @@ -2455,6 +2996,38 @@ 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 { + let pending = + !BackfillContinuation.isFutureDatedNewest(n, wallNowUnix: wallNow) + && persistedSensorRows + && (n - f) > BackfillContinuation.defaultBehindGapSeconds + // #2012: say WHY, on the flip only. This half of the Rest "Pending sync" state used to + // change in total silence, so a report of it showing hours after waking was unanswerable. + if state.historyPendingSync != pending { + log(PendingSyncDiagnostic.line( + pending: pending, site: PendingSyncDiagnostic.sitePostOffload, + newestUnix: n, frontierUnix: f, + futureDated: BackfillContinuation.isFutureDatedNewest(n, wallNowUnix: wallNow), + persistedRows: persistedSensorRows, + thresholdSec: BackfillContinuation.defaultBehindGapSeconds)) + } + state.historyPendingSync = pending + } let stillConnected = state.connected && state.bonded guard BackfillContinuation.shouldAutoContinue( stillConnected: stillConnected, @@ -2568,6 +3141,40 @@ public final class BLEManager: NSObject, ObservableObject { chunks > 0 || rows > 0 || deepPackets > 0 } + /// #2384: the `HR notify:` line, so a strap log says whether the standard 0x2A37 profile is + /// delivering anything at all, and whether what it delivered was usable. + /// + /// Extracted from the emitter below it, unchanged, because Android had no twin of this line and is + /// getting one. A reporter's 5/MG banked `live hr=0 rr=0` on ten consecutive links while the + /// historical offload ran perfectly, and an Android log could not distinguish the strap never + /// notifying on 0x2A37 from it notifying with a value the 30...220 gate below drops without a word. + /// Those call for opposite fixes. Pinning the wording here means the answer reads the same whichever + /// platform the log came from. + /// + /// The range is the value gate's own: `ignored` means the reading reached neither the UI nor the + /// store. Twin of the Kotlin `WhoopBleClient.standardHrNotifyLine`. + nonisolated static func standardHrNotifyLine(hr: Int, rrCount: Int) -> String { + let plausibility = (30...220).contains(hr) ? "" : " ignored" + return "HR notify: \(hr) bpm\(plausibility), rr=\(rrCount)" + } + + /// #2387: the outcome token on a `session ended` line, so a timeout says whether it achieved anything. + /// + /// A WHOOP 4.0 routinely ends a PRODUCTIVE offload on the idle timeout, because that firmware finishes + /// without emitting HISTORY_COMPLETE. The line said `reason=timeout` either way, and the rows landed on + /// the NEXT line, so the alarming half read first and the outcome second. A reporter read six of these + /// as six interrupted syncs; so did I, reviewing their log, after reading the code that says otherwise. + /// Their session had banked 56,879, 183,266, 37,645 and 40,386 rows under four of those timeouts. + /// + /// Rows, not frames: a stalled session still receives frames, and rows is what the summary line beside + /// this one reports, so the two cannot disagree. Same test the notify path already applies + /// (`shouldNotifySuccessfulOffload` on Kotlin). Empty for any other reason, which keeps + /// HISTORY_COMPLETE and the disconnect paths byte-identical to before. + nonisolated static func sessionEndedOutcome(reason: String, bankedRows: Bool) -> String { + guard reason == "timeout" else { return "" } + return bankedRows ? " outcome=drained" : " outcome=nothing-banked" + } + /// #1466: the banner (if any) for an offload that ended on the idle TIMEOUT rather than /// HISTORY_COMPLETE, for a non-5/MG strap. Pure so the decision is unit-testable — the surrounding /// method is a long side-effecting BLE callback. @@ -3034,18 +3641,19 @@ public final class BLEManager: NSObject, ObservableObject { finishR22Disable() } - /// EXPERIMENTAL (#181): make a bonded WHOOP 5/MG advertise its heart rate as a standard BLE HR - /// sensor (0x180D + the live HR in its manufacturer data) by writing the device-config flag - /// `whoop_live_hr_in_adv_ind_pkt` = "1" (on) / "0" (off) via SET_DEVICE_CONFIG (0x77). With it on, a - /// Garmin (Edge/watch), Zwift or gym HR client can pair to the WHOOP directly during a workout. - /// Validated on real hardware (paired on a Garmin Edge 840). Opt-in, reversible; unlike R22 it is NOT - /// on-wrist gated. Re-applied on each 5/MG connection. iOS/Android only (macOS can't bond a 5/MG). + /// Make a bonded strap advertise as a standard BLE HR sensor. WHOOP 4 uses the reversible + /// TOGGLE_GENERIC_HR_PROFILE command; WHOOP 5/MG keeps the existing device-config path. public func setBroadcastHr(_ on: Bool) { - guard selectedModel.deviceFamily == .whoop5 else { - log("Broadcast HR: needs a WHOOP 5.0/MG strap selected — ignored."); return - } guard state.connected, state.bonded else { - log("Broadcast HR: connect and bond a 5/MG strap first — ignored."); return + log("Broadcast HR: connect and bond the strap first — ignored."); return + } + if selectedModel.deviceFamily == .whoop4 { + send(.toggleGenericHRProfile, payload: [on ? 0x01 : 0x00]) + log("Broadcast HR: WHOOP 4 \(on ? "enable" : "disable") command sent (14); effect not confirmed.") + return + } + guard selectedModel.deviceFamily == .whoop5 else { + log("Broadcast HR: strap family is not known yet — ignored."); return } // Mutually exclusive with the ECG gate: both verify over the SAME 121 read-back opcode, so if both // were in flight one strap reply would be consumed by both handlers and cross-contaminate the other's @@ -3145,7 +3753,8 @@ public final class BLEManager: NSObject, ObservableObject { return } // The full encrypted bond, not the live-HR-only link — a config write over the latter silently - // fails (#269). Matches the R22 write paths and the button's own `ecgGateReady` gate in Settings. + // fails (#269). Matches the R22 write paths; the Settings button that carried the same gate as + // `ecgGateReady` went with the WHOOP 5/MG research card in #2417, so this is the gate now. guard state.connected, state.encryptedBond else { log("ECG gate (#891): needs the full encrypted bond, not the live-HR-only link — close the official WHOOP app and pair the strap to NOOP first. Ignored."); return } @@ -3240,7 +3849,12 @@ public final class BLEManager: NSObject, ObservableObject { send(.setAdvertisingNameHarvard, payload: WhoopCommand.advertisingNamePayload(name), writeType: .withResponse) - log("Strap rename: wrote advertising name=\(name.debugDescription)") + // #2337: through `logSafeDeviceName`, never raw. This name is USER-CHOSEN, so it is the one + // string in the rename path that can carry a person's name, and strap logs get attached to public + // issues. The redactor masks MACs, WHOOP serials and hex dumps, none of which this is, so it would + // go out verbatim. The scan path already routes the very same value through the helper + // ("Discovered \(safeName)"), which made this the one place the same data was handled two ways. + log("Strap rename: wrote advertising name=\(LiveState.logSafeDeviceName(name))") // Re-read shortly after so the card reflects the change if the strap applies it without dropping // the link; if it reboots instead, the connect handshake re-reads the name on reconnect anyway. DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) { [weak self] in @@ -3516,10 +4130,17 @@ public final class BLEManager: NSObject, ObservableObject { log("Device-config read probe (#103) ignored — a probe is already walking its plan") return } + // Platform exception: this hardware investigation is explicitly scoped to macOS. + // Keep iOS and Android's established plans until the additional reads are verified there. + #if os(macOS) + let flagKeys = DeviceConfigReadProbe.knownFlagKeys(for: selectedModel.deviceFamily) + #else + let flagKeys = Whoop5Config.enableR22Sequence.map(\.name) + #endif deviceConfigReport = DeviceConfigReadProbeReport( family: selectedModel.deviceFamily, - // The flag names come from NOOP's own R22 sequence — never restated here. - knownFlagKeys: Whoop5Config.enableR22Sequence.map(\.name), + // Include names observed on the strap without changing the R22 write sequence. + knownFlagKeys: flagKeys, candidateKeys: DeviceConfigReadProbe.oxygenCandidateKeys) state.deviceConfigProbe = BLEManager.deviceConfigProbeWaiting log("Device-config read probe (#103): asking for config VALUES via GET_DEVICE_CONFIG_VALUE(121) + GET_FF_VALUE(128) on family=\(selectedModel.deviceFamily); read-only (SET_FF_VALUE/120 and SET_DEVICE_CONFIG_VALUE/119 are never sent from this path)") @@ -3716,6 +4337,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 } @@ -3824,10 +4459,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 @@ -3977,8 +4621,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 @@ -3995,6 +4649,44 @@ public final class BLEManager: NSObject, ObservableObject { if let p = peripheral { central.cancelPeripheralConnection(p) } return } + // #2332: re-read link RSSI on a ~60 s throttle, for BOTH families and BOTH bond states. It sits + // here, above the paragraph below, because that paragraph's subject is puffin work needing the + // bond and this is the opposite: `readRSSI()` needs no bond and no characteristic, so the + // unbonded #1635 strap gets it too. + // + // Apple read no link RSSI at all before this, so a link that degraded and died at the supervision + // timeout left nothing about range behind it — for the end reason that names range as the + // suspect. Repeating the read also gives the log a SLOPE rather than a point, which is what + // separates a link that was always marginal from one that walked out of range. + // + // The stash is written in `didReadRSSI`, not here: a request that never answers must leave the + // previous reading's AGE growing rather than stamping a fresh time on a stale value. + if let p = peripheral, + Date().timeIntervalSince(lastRssiReadAt ?? .distantPast) >= BLEManager.rssiReadIntervalSeconds { + lastRssiReadAt = Date() + p.readRSSI() + } + + // 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. @@ -4026,7 +4718,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: []) } } @@ -4096,6 +4814,48 @@ public final class BLEManager: NSObject, ObservableObject { requestSync(.manual) } + /// When the iOS "Sync Strap" shortcut asked for a sync while no strap link was ready. The connect + /// handshake's own on-connect kick consumes it (`connectSyncTrigger`), so a request made while NOOP was + /// still launching and reconnecting in the background runs as soon as the link can serve, at the + /// un-floored `.manual` tier the user's tap deserves, instead of being lost. Bounded by + /// `pendingManualSyncTTL` so a stale request cannot fire an offload long after anyone asked. + /// + /// Persisted, not in-memory: iOS may end the background process the shortcut launched and relaunch + /// NOOP later through CoreBluetooth state restoration when the strap reconnects. A request held only in + /// memory would not survive that, and the relaunch is exactly the path that completes the connect. + private var pendingManualSyncRequestedAt: Date? { + get { (UserDefaults.standard.object(forKey: Self.pendingManualSyncKey) as? Double).map(Date.init(timeIntervalSince1970:)) } + set { + if let newValue { UserDefaults.standard.set(newValue.timeIntervalSince1970, forKey: Self.pendingManualSyncKey) } + else { UserDefaults.standard.removeObject(forKey: Self.pendingManualSyncKey) } + } + } + static let pendingManualSyncKey = "sync.pendingManualRequestedAt" + static let pendingManualSyncTTL: TimeInterval = 600 // 10 min + + /// Record that a manual sync was asked for before the link was ready. + public func armPendingManualSync() { + pendingManualSyncRequestedAt = Date() + log("Sync now: requested before the strap link was ready — will run once the connect handshake settles.") + } + + /// Which trigger the on-connect kick should use. Pure so the TTL rule is unit-testable: a pending + /// request younger than the TTL upgrades the kick to `.manual` (always runs); anything else is the + /// ordinary `.connect` (90 s floor). Consumed either way. + nonisolated static func connectSyncTrigger(pendingManualRequestedAt: Date?, now: Date, + ttl: TimeInterval = pendingManualSyncTTL) -> BackfillTrigger { + guard let at = pendingManualRequestedAt, now.timeIntervalSince(at) < ttl, now >= at else { return .connect } + return .manual + } + + /// The on-connect offload kick, shared by both families' handshakes. Consumes any pending shortcut request. + private func requestConnectSync() { + let trigger = Self.connectSyncTrigger(pendingManualRequestedAt: pendingManualSyncRequestedAt, now: Date()) + pendingManualSyncRequestedAt = nil + if trigger == .manual { log("Sync now: running the sync requested before the link was ready.") } + requestSync(trigger) + } + // MARK: Helpers private static let logTimeFormatter: DateFormatter = { let f = DateFormatter(); f.dateFormat = "HH:mm:ss"; return f @@ -4141,9 +4901,11 @@ public final class BLEManager: NSObject, ObservableObject { batteryCharacteristic = nil disSerialCharacteristic = nil disHwRevCharacteristic = nil + disExtraCharacteristics = [] disRead = false disSerial = nil disHwRev = nil + disFirmware = nil whoop5NotifyCharacteristics.removeAll() } @@ -4152,11 +4914,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)…") @@ -4196,6 +4963,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() @@ -4268,9 +5036,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. @@ -4286,6 +5125,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 @@ -4295,12 +5197,42 @@ public final class BLEManager: NSObject, ObservableObject { /// #716 stamp (which only fixed the "WHOOP" placeholder). ONE-DIRECTIONAL: attestation can only upgrade /// 4.0→5.0, never the reverse, and once corrected the guard below no longer matches, so it self-limits. private func reconcileModelFromAttestation(_ variant: Whoop5Variant) { - guard variant != .unknown, let rs = registryStore, - let active = try? rs.all().first(where: { $0.status == .active }), - DeviceFamily.forRegistryDevice(model: active.model, brand: active.brand) == .whoop4 + guard variant != .unknown, let rs = registryStore else { return } + guard let attestingId = peripheral?.identifier.uuidString else { + // Should not happen — the attestation arrives on a connected link — but a silent return is one + // more path that goes quiet exactly when something is off. + log("DIS attestation (variant=\(variant.label)) not applied — the connected strap's id is unknown") + return + } + // Correct the row the attestation CAME FROM, never merely "whichever is active". Those are the + // same device on a single-strap install, and different ones the moment somebody pairs a 4.0 + // alongside a 5/MG and leaves the 4.0 active — at which point relabelling by status rewrites the + // 4.0's row as "WHOOP 5.0 / MG" on the strength of the OTHER strap's DIS block. + // + // Unreachable today, because `Whoop5Variant.from` here is never given a model number and a real MG + // reports a serial prefix and hardware revision matching neither heuristic — so this returns on + // `.unknown` every time. Fixed anyway: the Android twin's identical bug went live the moment its + // resolver was widened, and twinning the DIS model-number read would do exactly that here (#520). + let devices = (try? rs.all())?.filter { $0.status != .archived } ?? [] + let byId = devices.first { $0.peripheralId?.caseInsensitiveCompare(attestingId) == .orderedSame } + // The sole non-archived device is not a guess: there is nothing else the attestation could have + // come from, and `peripheralId` is nil on the seeded row until the strap is adopted — the very + // single-strap install this correction exists for. + // The fallback requires the sole row to be UNADOPTED. "One device, so it must be the one attesting" + // holds only while that row has never been bound to an id; a row that HAS one and does not match is + // a different strap, and relabelling it is the corruption this guard exists to prevent. That is not + // hypothetical — adding a 5/MG to an install whose only row is a 4.0 reaches here before the new + // strap is registered. + let soleUnadopted = (devices.count == 1 && devices[0].peripheralId == nil) ? devices[0] : nil + guard let attesting = byId ?? soleUnadopted else { + log("DIS attestation (variant=\(variant.label)) not applied — \(devices.count) paired devices" + + " and none carries this strap's id, so it cannot be attributed") + return + } + guard DeviceFamily.forRegistryDevice(model: attesting.model, brand: attesting.brand) == .whoop4 else { return } - try? rs.setModel(active.id, model: "WHOOP 5.0 / MG") - log("Corrected device model \"\(active.model ?? "nil")\" → \"WHOOP 5.0 / MG\" from DIS attestation (variant=\(variant.label))") + try? rs.setModel(attesting.id, model: "WHOOP 5.0 / MG") + log("Corrected device model \"\(attesting.model ?? "nil")\" → \"WHOOP 5.0 / MG\" from DIS attestation (variant=\(variant.label))") } private func requestNotify(_ c: CBCharacteristic, on p: CBPeripheral, reason: String) { @@ -4397,6 +5329,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 @@ -4571,20 +5506,42 @@ public final class BLEManager: NSObject, ObservableObject { let now = Date() if lastStandardHRLogAt.map({ now.timeIntervalSince($0) >= 30 }) ?? true { lastStandardHRLogAt = now - let plausibility = (30...220).contains(m.hr) ? "" : " ignored" - log("HR notify: \(m.hr) bpm\(plausibility), rr=\(m.rr.count)") + log(BLEManager.standardHrNotifyLine(hr: m.hr, rrCount: m.rr.count)) } // R-R: the standard profile is the RELIABLE source (the custom REALTIME_DATA stream // usually reports rr_count=0), so always surface intervals when present. setRRIntervals also // feeds the Live console's rolling rrRecent buffer. - if !m.rr.isEmpty { state.setRRIntervals(m.rr) } + // WHOOP 5 sends milliseconds directly (non-compliant with the BLE spec's 1/1024-s unit), + // so use the raw ticks — which ARE ms — instead of the spec-converted values. + let rr = router.family == .whoop5 ? m.rrRawTicks : m.rr + // Only a sample the strap could measure reaches what the app shows (`LiveHeartRateReadability`): a + // plausible heart rate with skin contact not reported absent. The collector below still gets every one. + let readable = LiveHeartRateReadability.isReadable(bpm: m.hr, contact: m.contact) + // Rare-event evidence, always on: what the strap says about skin contact, logged when it changes (and once + // at the first sample), so a strap log shows what the band reports when it comes off the wrist. + if m.contact != lastLoggedHRContact { + log("HR: skin contact \(m.contact.rawValue) (was \(lastLoggedHRContact?.rawValue ?? "unknown")), \(m.hr) bpm") + lastLoggedHRContact = m.contact + } + if !rr.isEmpty, readable { state.setRRIntervals(rr) } + // A run of unreadable samples clears the shown heart rate instead of leaving the last one standing. + if heartRateReadability.clearsShownHeartRate(bpm: m.hr, contact: m.contact), state.heartRate != nil { + state.clearLiveHeartRate() + log("HR: \(LiveHeartRateReadability.clearAfter) unreadable samples in a row (last \(m.hr) bpm, " + + "contact \(m.contact.rawValue)); live heart rate cleared") + } // HR: the standard 0x2A37 profile is the RELIABLE source (BLE-standard, ~1Hz). Let it - // drive the value whenever it's physiologically plausible; reject 0/garbage (off-wrist). - // AppModel medians these into a stable display value. live perf: only publish on a real - // 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 } + // drive the value whenever it's readable. AppModel medians these into a stable display value. + // live perf: only publish on a real change so a steady resting HR doesn't re-render the whole + // Live console every second. + if readable { + state.noteReadableHeartRate() + if 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: rr, contact: m.contact, + family: router.family, + at: Int(Date().timeIntervalSince1970)) } } @@ -4728,6 +5685,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). @@ -4746,10 +5733,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 @@ -4770,12 +5757,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) @@ -4785,9 +5774,23 @@ 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 + // #2397: and the per-link signal shape, for the same reason. + rssiReads = 0; rssiWorstDbm = nil; rssiSumDbm = 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 @@ -4797,6 +5800,9 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate { // registry device (it observes this and calls registry.setPeripheralId). Additive observation // only — BLEManager stays decoupled from the store and the connect flow below is unchanged. connectedPeripheralUUID = peripheral.identifier.uuidString + // Remembered across launches for `connectCore`'s background path: a process iOS launched off-screen + // (the Sync Strap shortcut, a state-restoration relaunch) reconnects by identifier, not by scan. + UserDefaults.standard.set(peripheral.identifier.uuidString, forKey: Self.lastConnectedPeripheralKey) state.connected = true // 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 @@ -4843,13 +5849,82 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate { /// locale-dependent free-text string. We emit the `CBError`/`CBATTError` raw enum value (an Int), so /// the token is locale-independent and carries no free text. A nil error reads "unknown"; an error /// from neither CoreBluetooth domain reads "code?" (no localizedDescription, which could carry text). - private func connErrorToken(_ error: Error?) -> String { + private func connErrorToken(_ error: Error?) -> String { BLEManager.bleErrorToken(error) } + + /// The stable token itself. `nonisolated static` for the same reason `isInsufficientAuthError` is: + /// so a unit test pins it without a CoreBluetooth seam. + nonisolated static func bleErrorToken(_ error: Error?) -> String { guard let error else { return "unknown" } if let cb = error as? CBError { return "cbError\(cb.code.rawValue)" } if let att = error as? CBATTError { return "cbAttError\(att.code.rawValue)" } return "code?" } + /// The same token as a log SUFFIX, for the ordinary strap log rather than Connection test mode. + /// + /// Foundation LOCALIZES CoreBluetooth error strings, so a failure line built from + /// `localizedDescription` alone is unmatchable in a shared log from a non-English phone. That is not + /// hypothetical here: `isInsufficientAuthError` documents the same property silently defeating the + /// old `localizedDescription.contains("encryption")` classification for the entire localized user + /// base (#78 hole-1), which Android was immune to because it matches GATT status ints 5/15. The + /// classification was made code-first then; the LOGGING still says only what the phone's locale says. + /// + /// ADDITIVE, never a replacement: the description stays for readability and for the CoreBluetooth + /// paths that surface plain NSErrors outside both domains (those read `code?`). The tokens line up + /// with the Android side by construction, since ATT insufficient-authentication and + /// insufficient-encryption are the same 5 and 15 that `gattWriteStatusLabel` names. + nonisolated static func bleErrorSuffix(_ error: Error?) -> String { + guard error != nil else { return "" } + return " [\(bleErrorToken(error))]" + } + + /// 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?) { @@ -4875,6 +5950,71 @@ 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) + // #2332: read BEFORE the clear below, exactly like `since` above. The age is computed here + // rather than stored so it is measured against THIS drop; a nil value yields a nil age, so a + // link that ended before any read prints "never read" instead of an invented number. + // + // Two reads of the stash here, where the Kotlin twin deliberately takes ONE snapshot: there, + // `didReadRSSI`'s equivalent runs on a binder thread and a reading landing between the reads + // could pair a value with the wrong age. Apple builds the central with `queue: .main` and the + // keep-alive timer is a `.main` DispatchSource, so the write and both reads are the same + // queue and the pair cannot tear. If that queue ever changes, this needs the snapshot too. + let rssiAgeMs = lastRssiDbm == nil ? nil : lastRssiAt.map { + Int(Double(DispatchTime.now().uptimeNanoseconds &- $0.uptimeNanoseconds) / 1_000_000) + } + log(ConnectionReadout.linkEpitaph(upMillis: upMs, inboundFrames: inboundFrames, + inboundBytes: inboundBytes, cmdChannelFrames: cmdChannelFrames, + realtimeArmed: realtimeArmedAt != nil, ended: endedReason, + rssiDbm: lastRssiDbm, rssiAgeMillis: rssiAgeMs, + rssiReads: rssiReads, rssiWorstDbm: rssiWorstDbm, + rssiSumDbm: rssiSumDbm)) + // #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 + rssiReads = 0; rssiWorstDbm = nil; rssiSumDbm = 0 + liveHr = 0; liveRr = 0; offloadHr = 0; offloadRr = 0 + offloadGravity = 0; offloadResp = 0; offloadSkinTemp = 0; offloadSpo2 = 0; offloadChunks = 0 + linkUpSince = nil + // #2332: scoped to the link, so it dies with it. Leaving it set would hand the NEXT link's epitaph + // a reading taken on this one. The THROTTLE is cleared with them: it measures "how long since we + // asked THIS link", and carrying it over would make a reconnect inside the window wait out the + // previous link's timer before taking its first reading, on exactly the churn this is for. + lastRssiDbm = nil; lastRssiAt = nil; lastRssiReadAt = nil + let timedOut = !intentionalDisconnect && error != nil let sinceArm = realtimeArmedAt.map { Date().timeIntervalSince($0) } if marginalRadio.connectionEnded(wasArmed: realtimeArmedAt != nil, @@ -4967,8 +6107,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 @@ -4978,6 +6134,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 @@ -4991,6 +6150,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) @@ -5006,6 +6166,9 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate { // re-derives it. (The honest flag is per-link, like the syncing pill / reject counters above.) whoop5EmptyOffload.reset() state.historySyncExperimental = false + // #689/#815: the backlog sample is "at connect" by definition, so it must not survive the link it + // was taken on — a stale figure under a fresh connection would be a plain lie. + state.pagesBehindAtConnect = nil lastBatteryReadAt = nil // #battery: next connect's first enableLiveNotifications re-seeds the 5/MG battery reading // #612: the display flag only, not the underlying emptySyncTracker streak (that counter // deliberately survives a reconnect — unchanged, existing behaviour). A fresh link re-derives @@ -5027,12 +6190,12 @@ 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 // re-arms it by tapping Connect. We do NOT schedule a rescan here. - log("Disconnected\(error.map { ": \($0.localizedDescription)" } ?? ""); auto-reconnect paused (strap keeps refusing to pair; tap Connect once it's free)") + log("Disconnected\(error.map { ": \($0.localizedDescription)" } ?? "")\(BLEManager.bleErrorSuffix(error)); auto-reconnect paused (strap keeps refusing to pair; tap Connect once it's free)") // #1539: a connect attempt CONSUMES the parked request, so re-park it — floored, so a reachable // strap that keeps refusing gets one attempt per window instead of a connect/refuse spin. standingConnectWhilePausedIfDue() @@ -5041,7 +6204,7 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate { state.append(log: "reconnect paused=bondLoop (strap refusing bond)", domain: .connection) } } else if !intentionalDisconnect { - log("Disconnected\(error.map { " — \($0.localizedDescription)" } ?? "")") + log("Disconnected\(error.map { " — \($0.localizedDescription)" } ?? "")\(BLEManager.bleErrorSuffix(error))") // Connection test mode: count + describe the involuntary reconnect churn, and mark the link // down for the uptime readout. Gated zero-cost (the .connection bool is read before any string // is built). Diagnostic only - the rescan above is unchanged. The count increments only on an @@ -5076,7 +6239,7 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate { didFailToConnect peripheral: CBPeripheral, error: Error?) { cancelPendingConnectProbe() // #730: it FAILED rather than pending — this log is the answer - log("Failed to connect\(error.map { " — \($0.localizedDescription)" } ?? "")") + log("Failed to connect\(error.map { " — \($0.localizedDescription)" } ?? "")\(BLEManager.bleErrorSuffix(error))") // The strap wiped its bond (a firmware update, or the official WHOOP app re-bonding it). macOS keeps // re-presenting the now-stale pairing key, so every reconnect loops on this same error with no // recovery and no user guidance. Surface an actionable re-pair guide instead of failing silently — @@ -5092,6 +6255,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: @@ -5144,6 +6323,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. @@ -5163,7 +6343,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 @@ -5204,6 +6390,35 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate { log("Strap family switched (\(previous) → \(model.rawValue)) — reset 5/MG-only experimental toggles to off.") } + /// #2332: the answer to `readRSSI()`. Stashed for the link epitaph, which is the line that has to + /// explain a supervision timeout and previously had nothing to say about range. + /// + /// Implausible readings are rejected HERE rather than in the formatter, where the read's error is + /// known. See `rssiReadingIsUsable` for why it is a band. + public func peripheral(_ peripheral: CBPeripheral, didReadRSSI RSSI: NSNumber, error: Error?) { + guard error == nil else { return } + let rssi = RSSI.intValue + guard rssiReadingIsUsable(rssi) else { + log("Signal: RSSI read returned \(rssi) dBm (out of band) — not recorded") + return + } + // A read issued on the PREVIOUS link can answer after that link ended and the next one began. + // Logging it is harmless, but the stash below is per-link state the epitaph attributes to the + // drop, so a late answer would put the old link's reading on the new link's death — the exact + // fabrication the epitaph exists to avoid. Write guard only; the line is still logged, because a + // reading that arrived really did arrive. + let stale = peripheral !== self.peripheral + log("Signal: RSSI \(rssi) dBm" + (stale ? " (from a link that has already ended)" : "")) + guard !stale else { return } + lastRssiDbm = rssi + lastRssiAt = DispatchTime.now() + // #2397: fold into the per-link shape. AFTER the stale guard, for the same reason the stash is: + // a late answer from a dead link must not be counted against the live one's summary. + rssiReads += 1 + rssiSumDbm += rssi + rssiWorstDbm = rssiWorstDbm.map { min($0, rssi) } ?? rssi + } + public func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { if let error { log("Service discovery failed: \(error.localizedDescription)") @@ -5233,9 +6448,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 @@ -5257,6 +6474,28 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate { return } guard let chars = service.characteristics else { return } + // #1635: dump what the strap actually OFFERS, once per peripheral. Every characteristic below is + // matched against a UUID someone hardcoded, so anything a 5/MG exposes that nobody guessed has + // never been visible. Sends nothing — the tree has already been discovered, so this reads a local + // cache and cannot provoke the teardown that writing to an encrypted characteristic can. + // + // Emitted only once EVERY service has reported its characteristics, because this callback fires + // per service: dumping on each would print a partial tree N times. Once per peripheral rather than + // per connect — the tree is static and the dump is ~30 lines, which would otherwise evict the + // connect/drop/bond evidence from a rolling buffer on a strap that reconnects every few seconds. + if TestCentre.active(.connection), + gattTreeDumpedFor != peripheral.identifier, + let all = peripheral.services, + all.allSatisfy({ $0.characteristics != nil }) { + gattTreeDumpedFor = peripheral.identifier + let tree: [(String, [(String, UInt)])] = all.map { svc in + (svc.uuid.uuidString, + (svc.characteristics ?? []).map { ($0.uuid.uuidString, $0.properties.rawValue) }) + } + for line in GattCapability.treeLines(tree) { + state.append(log: line, domain: .connection) + } + } for c in chars { switch c.uuid { case BLEManager.cmdWriteChar: @@ -5274,7 +6513,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 @@ -5284,6 +6541,7 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate { // "Finishing the secure pairing handshake…". log("WHOOP 5/MG: writing CLIENT_HELLO to fd4b0002 with response (to trigger bonding, experimental).") state.pairingHint = nil // fresh attempt; clear any stale pairing-mode guidance + clientHelloWriteAt = Date() // #1635: a hello is now outstanding peripheral.writeValue(Data(hello), for: c, type: .withResponse) } // The realtime-HR stream is armed POST-bond (in didWriteValueFor / startRealtime) with @@ -5306,13 +6564,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 } @@ -5333,7 +6600,11 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate { didWriteValueFor characteristic: CBCharacteristic, error: Error?) { if let error = error { - log("Confirmed write failed: \(error.localizedDescription)") + log("Confirmed write failed: \(error.localizedDescription)\(BLEManager.bleErrorSuffix(error))") + // #1635: a failed write owes no ack. Leaving the window open would let the NEXT completion on + // fd4b0002 — DISABLE_ALARM and every other puffin command share it — satisfy both halves of + // the bond gate below and declare a bond the strap never granted, which is the whole bug. + if characteristic.uuid == BLEManager.whoop5CmdWriteChar { clientHelloWriteAt = nil } // #78 hole-1: classify by ATT code first (locale-proof), English string fallback second. // This one change repairs the pairing hint (streak>=2), the #747 give-up (5) AND the #52 // stale-pin handoff below for non-English devices, which all key off this same flag. @@ -5374,19 +6645,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 @@ -5413,8 +6676,39 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate { // which the strap refused before the link was encrypted. Do NOT run the WHOOP4 command handshake // below — a 5/MG strap rejects WHOOP4-framed commands (the send() guard drops them anyway). if selectedModel.deviceFamily == .whoop5 { - if !didBond { + // #1635: only the CLIENT_HELLO's OWN completion is evidence of a bond. Declining withholds + // the bond declaration ONLY — the puffin re-subscribe below is unchanged, so a link that is + // 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. + // + // An UNACKED hello leaves the window open, and the pair of conditions cannot be satisfied by a + // later command on the same characteristic: `send` writes `.withoutResponse` by default, which + // produces no completion at all, and the `.withResponse` senders (the offload acks) only run + // after a bond, where `alreadyBonded` short-circuits this. The link dropping clears it anyway. + if isHelloChar { clientHelloWriteAt = nil } + if !helloOutstanding, !didBond { + // Declining must not be silent — see `ClientHelloOutcome.declinedLine`. + log(ClientHelloOutcome.declinedLine(charUuid: characteristic.uuid.uuidString, status: nil)) + } + if ClientHelloOutcome.isAck( + isHelloChar: isHelloChar, + helloOutstanding: helloOutstanding, + alreadyBonded: didBond, + 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 @@ -5426,6 +6720,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 @@ -5455,6 +6757,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). @@ -5467,12 +6770,16 @@ 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 // connectHandshakeDone, so a racing foreground/restore trigger can't fire it early. - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in self?.requestSync(.connect) } + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in self?.requestConnectSync() } startBackfillTimer() // re-offload the type-47 store every backfillIntervalSeconds // #34: signal settled directly (skip the cmd-notify gate `maybeSignalConnectSettled()` // uses) — armStrapAlarm's 5/MG branch never sends GET_ALARM_TIME (log-only readback is @@ -5504,6 +6811,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 @@ -5541,9 +6849,11 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate { // throttled by BackfillPolicy). Deferred ~1.5s so SET_CLOCK/GET_DATA_RANGE round-trip first and // SEND_HISTORICAL runs on a settled link, like the paced Mac prototype. beginBackfill is itself // gated on connectHandshakeDone so a racing foreground/restore trigger can't fire it early. - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in self?.requestSync(.connect) } + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in self?.requestConnectSync() } startBackfillTimer() // re-offload the type-47 store every backfillIntervalSeconds startKeepAlive() // always-ping: re-arm realtime, poll battery, watchdog the link + // WHOOP 4's broadcast mode is link/runtime state, so restore an opted-in mode after reconnect. + if PuffinExperiment.broadcastHrEnabled { setBroadcastHr(true) } enableLiveNotifications(reason: "post-bond") // includes 0x2A37 standard HR — the fallback path // #927: RE-DERIVE the want at arm time (same reasoning as the 5/MG branch above): a reconnect // outside the overnight window must not arm the flood from a stale precomputed `wantsRealtime` @@ -5673,11 +6983,22 @@ 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)") + // #815: confirmed on both WHOOP 4.0 and 5.0/MG, so bank it unconditionally. The Today sync + // chip reads this while backfilling is true. Twin of the Android FrameRouter branch. + state.pagesBehindAtConnect = Int(pages) + } else { + log("Strap backlog pages behind: not decodable from this frame (#689 — offsets may have moved; " + + "the raw frame above is the input). Diagnostic only, sync is unaffected.") + } } if let newest = BLEManager.dataRangeNewestUnix(from: frame) { // #695: the sync-affecting side-effects (strapNewestTs, backfill window, LiveState range) only @@ -5709,6 +7030,38 @@ 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) + let pendingAtConnect = + !BackfillContinuation.isFutureDatedNewest(newestForPending, wallNowUnix: wallNowP) + && (newestForPending - f) > BackfillContinuation.defaultBehindGapSeconds + // #2012: say WHY, on the flip only (see the post-offload site). + if state.historyPendingSync != pendingAtConnect { + log(PendingSyncDiagnostic.line( + pending: pendingAtConnect, site: PendingSyncDiagnostic.siteConnect, + newestUnix: newestForPending, frontierUnix: f, + futureDated: BackfillContinuation.isFutureDatedNewest( + newestForPending, wallNowUnix: wallNowP), + persistedRows: nil, + thresholdSec: BackfillContinuation.defaultBehindGapSeconds)) + } + state.historyPendingSync = pendingAtConnect + } + } + } // Connection test mode: promote the CLOCK-DRIFT picture to one upfront tagged line (#767/#754). if TestCentre.active(.connection) { let line = ConnectionTrace.clockDriftLine( @@ -5724,12 +7077,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: @@ -5740,6 +7101,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 = @@ -5757,20 +7123,66 @@ 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) + router.noteReassemblerHeaderDrops(reassembler.headerChecksumDrops) + 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 @@ -5782,6 +7194,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 { @@ -5808,7 +7244,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 @@ -5844,7 +7290,11 @@ 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) + router.noteReassemblerHeaderDrops(reassembler.headerChecksumDrops) + 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 @@ -5856,21 +7306,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 { @@ -5924,7 +7383,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 new file mode 100644 index 0000000000..5018b71075 --- /dev/null +++ b/Strand/BLE/ClientHelloOutcome.swift @@ -0,0 +1,124 @@ +import Foundation + +/// What became of a WHOOP 5/MG CLIENT_HELLO write. +/// +/// A capture could not previously distinguish the three ways the 5/MG bond fails, because two of them +/// produce no line at all. In one field capture 14 of 16 CLIENT_HELLO writes went out and were never +/// acked, 1 was rejected by the stack, and 1 produced an "ack" — from a completion the code never checked +/// the characteristic of (#1635). From the log those look the same: silence, then a link drop. +/// +/// The three outcomes, and why each matters: +/// - acked by the hello characteristic: the only one that is genuinely an ack. +/// - a completion from a DIFFERENT characteristic while the bond is pending: the ack branch matches on +/// family alone, so this is what silently sets `encryptedBond` on a strap that never bonded — and the +/// line names the characteristic that did it. +/// - no callback at all before the link dropped: the dominant case in the field capture, and the one +/// with no evidence today. +/// +/// Reports only what it observed; it does not attribute blame between the strap and the local stack, +/// because a write callback that never arrives cannot distinguish "the strap declined to respond" from +/// "the frame never reached the air". Naming the gap is what makes that answerable next. +/// +/// `status` is passed pre-rendered so each platform supplies its own, leaving the line shape identical. +/// Pure. Kotlin twin: `com.noop.ble.clientHelloOutcomeLine`. +enum ClientHelloOutcome { + static func line(isHelloChar: Bool, charUuid: String?, elapsedMs: Int, status: String?) -> String { + guard let charUuid else { + return "CLIENT_HELLO outcome: NO write callback after \(elapsedMs)ms — the link dropped before" + + " the stack reported, so the strap may never have seen it" + } + let where_ = charUuid.trimmingCharacters(in: .whitespaces).isEmpty ? "unknown" : charUuid + let st = (status?.trimmingCharacters(in: .whitespaces).isEmpty == false) ? " \(status!)" : "" + if isHelloChar { + return "CLIENT_HELLO outcome: acked by \(where_) after \(elapsedMs)ms\(st)" + } + return "CLIENT_HELLO outcome: bond declared from a DIFFERENT characteristic \(where_) after" + + " \(elapsedMs)ms\(st) — this is NOT a CLIENT_HELLO ack (#1635)" + } + + /// Is this write completion genuinely the CLIENT_HELLO's ack, and therefore proof of an encrypted bond? + /// + /// The bond branch used to match on family alone: on a 5/MG link, ANY completion that arrived while + /// `didBond` was false was taken as the ack and set `encryptedBond`. A characteristic check on its own + /// does not fix that, because the puffin command characteristic (fd4b0002) carries BOTH the + /// CLIENT_HELLO and every ordinary command — DISABLE_ALARM is written there on the same connect — so + /// the hello and an unrelated command are indistinguishable by uuid. + /// + /// What separates them is whether a hello is actually OUTSTANDING, which is why both conditions are + /// required and neither is redundant: + /// - `isHelloChar` alone admits DISABLE_ALARM and every other puffin command. + /// - `helloOutstanding` alone admits a completion from a different characteristic that happens to + /// land inside the hello's window. + /// + /// Declining does not claim the strap refused anything — it says only that THIS completion is not + /// evidence of a bond. A real ack still arrives on its own callback and still bonds. + /// + /// Pure, so the rule is tested without a radio. Kotlin twin: `completionIsClientHelloAck`. + static func isAck( + isHelloChar: Bool, + helloOutstanding: Bool, + alreadyBonded: Bool, + isWhoop5: Bool + ) -> Bool { + if alreadyBonded { return false } + if !isWhoop5 { return false } + return isHelloChar && helloOutstanding + } + + /// A pre-bond write completion on a 5/MG link that arrived with NO CLIENT_HELLO outstanding. + /// + /// `isAck` declines these, and without a line it declines in SILENCE — which is worse for a capture + /// than the wrong answer it replaces. The field log for #1635 said "CLIENT_HELLO acked — link + /// established" here; the honest replacement is not nothing, it is "a completion arrived and it was + /// not the hello's". Without it the reader cannot tell a completion arrived at all. + /// + /// Names the characteristic because the whole point is that fd4b0002 carries traffic other than the + /// hello, and the reader needs to see WHICH command completed. + /// + /// Pure. Kotlin twin: `clientHelloDeclinedLine`. + static func declinedLine(charUuid: String?, status: String?) -> String { + let uuid = charUuid?.trimmingCharacters(in: .whitespaces) ?? "" + let whereFrom = uuid.isEmpty ? "unknown" : uuid + let trimmedStatus = status?.trimmingCharacters(in: .whitespaces) ?? "" + let st = trimmedStatus.isEmpty ? "" : " \(trimmedStatus)" + 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..e5b76c4671 100644 --- a/Strand/BLE/Commands.swift +++ b/Strand/BLE/Commands.swift @@ -22,6 +22,12 @@ public enum WhoopCommand: UInt8, CaseIterable { case reportVersionInfo = 7 case setClock = 10 case getClock = 11 + /// TOGGLE_GENERIC_HR_PROFILE (14) — opcode/name come from the canonical `CommandNumber` schema in + /// `Packages/WhoopProtocol/Sources/WhoopProtocol/Resources/whoop_protocol.json` and its WHOOP 4 + /// matrix in `docs/PROTOCOL_COMMANDS.md`. Payload `[0x01]` enabled standard BLE Heart Rate + /// advertising and `[0x00]` disabled it on the WHOOP 4.0 tested for #2400. Safe and reversible, + /// driven only by the explicit Broadcast strap HR opt-in; the effect has no readable confirmation. + case toggleGenericHRProfile = 14 /// ABORT_HISTORICAL_TRANSMITS (20) — ask the strap to stop streaming the offload it is part-way /// through. NON-DESTRUCTIVE, and specifically not a trim: the strap only frees banked records when /// NOOP acks a HISTORY_END, so anything unacked when the abort lands stays in flash and re-offloads @@ -92,6 +98,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 @@ -210,6 +221,7 @@ public enum WhoopCommand: UInt8, CaseIterable { case .reportVersionInfo: return "Report Version Info" case .setClock: return "Set Clock" case .getClock: return "Get Clock" + case .toggleGenericHRProfile:return "Toggle Generic HR Profile" case .abortHistoricalTransmits: return "Abort Historical Transmits" case .sendHistoricalData: return "Send Historical Data" case .historicalDataResult: return "Historical Data Result" @@ -226,6 +238,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..bfe10f6109 100644 --- a/Strand/BLE/FTMSSource.swift +++ b/Strand/BLE/FTMSSource.swift @@ -125,6 +125,15 @@ public final class FTMSSource: NSObject, ObservableObject { /// Connect to the chosen machine and start streaming its machine data. public func connect(_ id: UUID) { stopScan() + // Before the radio is up a retrieve answers nothing even for a machine this device has been bonded + // to for weeks, and the branch below would read that as "never seen" and arm a scan (#2433). + // Park the intent instead: `centralManagerDidUpdateState` asks again once the answer means + // something. + guard central.state == .poweredOn else { + pendingConnectID = id + log("FTMS: connect to \(id) deferred - Bluetooth not powered on (state=\(central.state.rawValue))") + return + } let p = seenPeripherals[id] ?? central.retrievePeripherals(withIdentifiers: [id]).first guard let p else { pendingConnectID = id @@ -135,11 +144,6 @@ public final class FTMSSource: NSObject, ObservableObject { seenPeripherals[id] = p peripheral = p p.delegate = self - guard central.state == .poweredOn else { - pendingConnectID = id - log("FTMS: Bluetooth not powered on — connect to \(id) deferred until ready") - return - } log("FTMS: connecting to \(id)") central.connect(p, options: nil) } @@ -179,12 +183,40 @@ extension FTMSSource: @preconcurrency CBCentralManagerDelegate { public func centralManagerDidUpdateState(_ central: CBCentralManager) { switch central.state { case .poweredOn: - if let id = pendingConnectID, let p = seenPeripherals[id] { - pendingConnectID = nil - central.connect(p, options: nil) - } else if scanning { + // The retrieve is re-issued HERE rather than trusting the one that ran while the state was + // still `.unknown`: that one answers nothing even for a bonded machine, which used to drop this + // replay into the scan branch and leave it unreachable off-screen, where iOS throttles + // scanning hard (#2433). + let parked = pendingConnectID + let held = parked.flatMap { seenPeripherals[$0] } + let fresh = held == nil + ? parked.flatMap { central.retrievePeripherals(withIdentifiers: [$0]).first } + : nil + switch PendingConnect.replay(isPending: parked != nil, + isHeld: held != nil, + didRetrieve: fresh != nil, + isScanning: scanning) { + case .connect: + if let id = parked, let p = held ?? fresh { + pendingConnectID = nil + seenPeripherals[id] = p + peripheral = p + p.delegate = self + log("FTMS: connecting to \(id) - targeted (the radio was not up when it was asked for)") + central.connect(p, options: nil) + } + case .scan: central.scanForPeripherals(withServices: [Self.fitnessMachineService], options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]) + case .discover: + // Nothing to connect and no scan armed, because the intent was parked before the + // retrieve. `scan()` arms `scanning` and `didDiscover` connects the parked id on sight. + // Named, because this whole defect was read off log lines: a line that says a + // connect could not be resolved is only useful if it says which device. + if let id = parked { log("FTMS: \(id) is not resolvable yet - discovering to find it") } + scan() + case .idle: + break } default: if feedsLive { live.connected = false } @@ -200,7 +232,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/FirmwareAttribution.swift b/Strand/BLE/FirmwareAttribution.swift new file mode 100644 index 0000000000..2fe40bf353 --- /dev/null +++ b/Strand/BLE/FirmwareAttribution.swift @@ -0,0 +1,51 @@ +import Foundation + +/// Which firmware string belongs to a device, given what is known about it. +/// +/// The persisted firmware used to live under one global key, written on any WHOOP connect and read back +/// for whichever strap was active. On a single-strap install those are the same strap, which is why it +/// went unnoticed; with two straps paired it reports the OTHER strap's firmware — a WHOOP 5/MG showing a +/// 4.0's 41.17.6.0 because the 4.0 connected last. +/// +/// The rule, in order: +/// - `live` wins: it came from THIS connection's handshake. +/// - `perDevice` next: this device's own persisted value, from a previous connection. +/// - `legacyGlobal` ONLY when `pairedCount` is 1. The old global key cannot say which strap it belongs +/// to, so it is trustworthy exactly when there is only one strap it could have come from. This keeps a +/// single-strap install reading correctly across the upgrade instead of showing "unknown" until the +/// next connect; a multi-strap install refuses it, which is the bug. +/// - otherwise nil — "not known yet" is the honest answer, and better than another strap's number. +/// +/// Pure so the attribution is unit-tested without defaults, a strap, or a registry. Kotlin twin: +/// `com.noop.ble.resolveFirmware`. +enum FirmwareAttribution { + + static func resolve(live: String?, + perDevice: String?, + legacyGlobal: String?, + pairedCount: Int) -> String? { + if let live, !live.isBlank { return live } + if let perDevice, !perDevice.isBlank { return perDevice } + if let legacyGlobal, !legacyGlobal.isBlank, pairedCount == 1 { return legacyGlobal } + return nil + } + + /// The per-device defaults key for a persisted firmware string. + /// + /// Keyed on the BLE peripheral identifier rather than the registry id: the BLE layer knows the + /// peripheral it connected to, and resolving it to a registry row there would repeat the mis-mapping + /// #1527 fixed for `lastSeen` (stamping "the active row" records a sighting of a strap that was never + /// connected). The registry row carries the same identifier, so the read side resolves without + /// guessing. + /// + /// 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.lastFirmware.\(p.lowercased())" + } +} + +private extension String { + var isBlank: Bool { trimmingCharacters(in: .whitespaces).isEmpty } +} diff --git a/Strand/BLE/FrameRouter.swift b/Strand/BLE/FrameRouter.swift index deafe71b71..73615c3067 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,12 +31,29 @@ 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() } + 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. + /// #1634: last firmware-gate line logged, so a stable per-connection value is not repeated on every + /// hello. Cleared alongside the other per-connection routing state. + private var loggedFirmwareGate: String? + private var rawDumpedRespCmds: Set = [] public init(state: LiveState) { @@ -47,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. @@ -79,6 +114,7 @@ public final class FrameRouter { // BLE_REALTIME_HR_ON, so the UI can consume it even though persistence still ignores raw43. // live perf: skip the publish when HR is unchanged — the raw flood carries the same HR // byte across many frames, so an unguarded write re-renders the whole console for nothing. + if let hr = parsed.parsed["heart_rate"]?.intValue, hr >= 30, hr <= 220 { state.noteReadableHeartRate() } if let hr = parsed.parsed["heart_rate"]?.intValue, hr >= 30, hr <= 220, state.heartRate != hr { state.heartRate = hr // Sleep & Rest test mode (Group E): bank the live HR sample for the readout's HR-density @@ -112,6 +148,14 @@ public final class FrameRouter { // Persist so the debug export can name the firmware offline (state clears on disconnect). UserDefaults.standard.set(fw, forKey: "noop.lastFirmware") } + + // #1634: the 5/MG hello decoded no firmware. The guards fail closed by design, so this is the + // only place that can say WHY - a different generation byte vs a MOVED offset. Logged once per + // connection (the value is stable), so a capture from an undecoded strap carries the evidence. + if let gate = parsed.parsed["fw_gate"]?.stringValue, loggedFirmwareGate != gate { + loggedFirmwareGate = gate + state.append(log: gate, domain: .connection) + } // Advertising-name replies (WHOOP 4.0 / Harvard). GET (cmd 76) carries the current name in // its payload; SET (cmd 77) carries only a result byte. The schema has no field decode for // either, so pull them straight from the frame bytes. The COMMAND_RESPONSE inner is @@ -137,8 +181,43 @@ 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 cmd.hasPrefix("TOGGLE_GENERIC_HR_PROFILE") { + // #2400: this is evidence that the strap answered opcode 14, not a read-back of the + // advertising state. Preserve the raw result byte + frame so another firmware's + // response can be compared without turning an acknowledgement into a false verdict. + let r = Self.commandResultByte(in: frame, family: family) + let rhex = r.map { String(format: "0x%02x", UInt8(truncatingIfNeeded: $0)) } ?? "none" + state.append(log: "Broadcast HR: WHOOP 4 command response received " + + "resultByte=\(rhex), effect not confirmed " + + "frame=\(Self.fullFrameHex(frame))") + } else if cmd.hasPrefix("GET_ADVERTISING_NAME_HARVARD") { if let name = Self.advertisingName(in: frame), !name.isEmpty { state.advertisingName = name } @@ -163,15 +242,55 @@ 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 + // #2322: this readback is arriving NOW, so it cannot be stale here and the + // guard is a no-op on this path. Passed anyway so both call sites ask the + // same question: a future caller that judges a STORED readback gets the + // staleness check for free rather than rediscovering the gap. + sentAt: d.object(forKey: "alarm.lastArmAt") as? Double, + reportedAt: Date().timeIntervalSince1970) + 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 == .staleReadback { + // #2322: deliberately does NOTHING to the streak, and deliberately does + // NOT repeat the one-time discard below. #1706 could discard because + // EVERY comparison it replaced was cross-strap and therefore invalid. A + // streak standing here is not like that: most of its increments came from + // readbacks that did answer their arm, so wiping it would hide a real + // refusal to spare a few false ones. Leaving it untouched is the same + // "no evidence either way" stance the verdict itself takes. + } 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 @@ -197,7 +316,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 @@ -213,10 +332,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 @@ -238,6 +365,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 @@ -277,6 +429,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 @@ -285,6 +451,16 @@ public final class FrameRouter { if !ev.hasPrefix("BLE_REALTIME_HR") { state.lastEvent = ev } + // A double-tap is handed on BEFORE the sync kick below, because what it triggers is + // usually a buzz that says "that registered", and a write that follows the sync + // request reaches the strap after it. The strap then starts the history transfer + // first and plays the buzz behind it: in a 16 Sep 2026 gym log the four taps that + // kicked a sync buzzed 1.0–2.8 s after the strap sensed them, and most of the others + // in under one. Live only (this path never sees historical replay, which goes through + // the Backfiller). Event strings are "NAME(rawValue)". + if ev.hasPrefix("DOUBLE_TAP") { + dispatchDoubleTapOnce(eventTimestamp: parsed.parsed["event_timestamp"]?.intValue) + } // Strap-pushed event = "I may have new data" → kick a (rate-limited) sync. onSyncTrigger?() // Belt-and-suspenders: a BLE_BONDED event confirms the link is bonded. @@ -322,14 +498,28 @@ public final class FrameRouter { } else if ev.hasPrefix("CHARGING_OFF") { 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") { - state.onDoubleTap?() - } else if ev.hasPrefix("WRIST_ON") { - if !state.worn { state.worn = true; state.onWristChange?(true) } + // #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 + } + // The other physical inputs the strap exposes — live only, as above. The double-tap + // was handled before the sync kick. + if ev.hasPrefix("WRIST_ON") { + handleWrist(on: true, duringSync: false) } else if ev.hasPrefix("WRIST_OFF") { - if state.worn { state.worn = false; state.onWristChange?(false) } + handleWrist(on: false, duringSync: false) } else if ev.hasPrefix("STRAP_DRIVEN_ALARM_EXECUTED") { // Fire observability (#401 close-out): Android has always logged this line // (WhoopBleClient.handleFrame); iOS/macOS silently ran the callback, which is why a @@ -403,8 +593,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: " ") } @@ -425,10 +621,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 } @@ -439,14 +637,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. @@ -459,6 +672,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 @@ -489,6 +703,10 @@ public final class FrameRouter { /// backfill offload (old ts) is ignored, but a real-time one fires even mid-sync. static let liveGestureWindowSeconds = 45 + /// How far back a DOUBLE_TAP arriving through a sync still earns a log line. Ten minutes covers a + /// gym session's syncs; anything older is ordinary history being offloaded, and stays silent. + static let lateGestureLogSeconds = 600 + /// Parse an EVENT frame and fire ONLY the live physical-gesture handlers (double-tap / wrist) iff the /// event is recent. Called for offload frames during backfill — where `handle(frame:)` is skipped — /// so a real-time gesture still works mid-offload (#69: the 5/MG offload runs for minutes). `now` @@ -497,6 +715,100 @@ 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 same fold for the reassembler's header-checksum drops, which are a DIFFERENT event: a floor + /// drop is a malformed frame, this is a read cursor that was not on a frame boundary at all. + func noteReassemblerHeaderDrops(_ monotonicTotal: Int) { + rejectTally.absorbReassemblerHeaderDrops(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 @@ -505,16 +817,106 @@ 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 } + let age = now - ts + guard abs(age) <= FrameRouter.liveGestureWindowSeconds else { + // A recent double-tap reaching us through a sync gets a line, so a tap reported as "did not + // register" can be checked: a live dispatch leaves "Double-tap → …" at that moment, and a + // tap that only ever came through a sync leaves just this. It asserts only the delivery seen. + if ev.hasPrefix("DOUBLE_TAP"), age > 0, age <= FrameRouter.lateGestureLogSeconds { + state.append(log: AppModel.stamped( + "Double-tap (strap time \(ts)) arrived \(age) s late during a sync; " + + "not acted on (live window \(FrameRouter.liveGestureWindowSeconds) s)")) + } + return + } if ev.hasPrefix("DOUBLE_TAP") { - state.onDoubleTap?() + dispatchDoubleTapOnce(eventTimestamp: ts) } else if ev.hasPrefix("WRIST_ON") { - if !state.worn { state.worn = true; state.onWristChange?(true) } + handleWrist(on: true, duringSync: true) } else if ev.hasPrefix("WRIST_OFF") { - if state.worn { state.worn = false; state.onWristChange?(false) } + handleWrist(on: false, duringSync: true) + } + } + + /// A live WRIST_ON / WRIST_OFF, from either route. Each one leaves a line, always on: whether a strap sends them + /// live decides how soon the Live HR banner can show the dash — a WHOOP 5.0 does, about two seconds after it + /// leaves the wrist (a tester's log, 24 Sep 2026, read from the silence that followed, since nothing named them). + private func handleWrist(on: Bool, duringSync: Bool) { + let changes = state.worn != on + state.append(log: AppModel.stamped("Strap: \(on ? "WRIST_ON" : "WRIST_OFF")" + + (duringSync ? " during a sync" : "") + + (changes && !on ? "; live heart rate cleared" : ""))) + guard changes else { return } + state.worn = on + if !on { state.clearLiveHeartRate() } // nothing shown may outlive the strap leaving the wrist + state.onWristChange?(on) + } + + // MARK: - Double-tap de-duplication + + /// `event_timestamp`s of the DOUBLE_TAPs recently handed to the app. + /// + /// ONE physical gesture can reach us TWICE. It arrives live through `handle(frame:)`, and then + /// again when the strap offloads its banked event log — `dispatchLiveGestureIfFresh` runs over + /// every offload frame and accepts any event whose timestamp is within + /// `liveGestureWindowSeconds` (45 s) of now, which a gesture from moments ago obviously is. + /// + /// `AppModel.handleDoubleTap`'s 1.2 s debounce cannot catch that: the replay can land many + /// seconds after the tap, by which time the debounce has long expired. The result is a phantom + /// second gesture — and with the Lift Log claiming the double-tap, a phantom gesture silently + /// advances the session and costs a logged set. + /// + /// The event's OWN timestamp is what distinguishes the two cases: one gesture replayed carries + /// one timestamp, while two genuine taps carry two. Nil fails OPEN (dispatch), because a real + /// gesture must never be swallowed by a missing field. + /// + /// A SET, not a single slot. Remembering only the last dispatched timestamp suppresses a replay + /// solely when the replayed event is the most recent one dispatched, and a real offload does not + /// oblige. Interleave two taps and each replay looks new again: + /// + /// tap A live -> last = A + /// tap B live -> last = B + /// replay A -> A != B, dispatches + /// replay B -> B != A, dispatches + /// + /// Two phantom advances, and a multi-minute offload that re-walks its banked log repeats the + /// whole pattern — three taps measured as twelve in `FrameRouterDoubleTapDedupTests`. That is the + /// failure this de-duplication exists to prevent, surviving inside it. + /// + /// BOUNDED two ways, because this lives on the BLE path for the lifetime of a connection: entries + /// outside `liveGestureWindowSeconds` of the incoming event are dropped (past that, the freshness + /// guard in `dispatchLiveGestureIfFresh` refuses the replay anyway, so remembering it buys + /// nothing), and a hard cap covers a strap whose clock jumps rather than advances. + private var dispatchedDoubleTapEventTs: [Int] = [] + + /// Far above any plausible number of double-taps inside a 45-second window; a backstop against a + /// clock that jumps, not a working limit. + private static let dispatchedDoubleTapMemory = 32 + + private func dispatchDoubleTapOnce(eventTimestamp ts: Int?) { + if let ts { + guard !dispatchedDoubleTapEventTs.contains(ts) else { + // Only a gesture actually held back leaves a line, so a tap reported as missing can be + // told apart from a replay being suppressed. + state.append(log: AppModel.stamped( + "Double-tap (strap time \(ts)) not dispatched: that event was already handled")) + return + } + // Prune BEFORE appending, so the event just accepted is always the one kept. + dispatchedDoubleTapEventTs.removeAll { + abs(ts - $0) > FrameRouter.liveGestureWindowSeconds + } + dispatchedDoubleTapEventTs.append(ts) + if dispatchedDoubleTapEventTs.count > FrameRouter.dispatchedDoubleTapMemory { + dispatchedDoubleTapEventTs.removeFirst( + dispatchedDoubleTapEventTs.count - FrameRouter.dispatchedDoubleTapMemory) + } } + state.onDoubleTap?() } } diff --git a/Strand/BLE/GattCapability.swift b/Strand/BLE/GattCapability.swift new file mode 100644 index 0000000000..57e34b8744 --- /dev/null +++ b/Strand/BLE/GattCapability.swift @@ -0,0 +1,63 @@ +import Foundation + +/// What the strap's GATT tree actually contains — the Swift twin of `gattTreeLines`. +/// +/// NOOP has never asked a strap what it exposes: every characteristic is looked up by a UUID someone +/// hardcoded, so anything a 5/MG offers that nobody guessed has never been visible, on the one protocol +/// still being reverse-engineered. +/// +/// Twinned rather than left Android-only because nothing prevents it here. CoreBluetooth exposes +/// `peripheral.services`, `service.characteristics` and `characteristic.properties`, so unlike the +/// bond-state and pairing helpers — which have no Apple equivalent at all — this was a gap rather than a +/// constraint. It needs no field evidence either: it sends nothing, reads only the already-discovered +/// local cache, and cannot provoke the teardown that writing to an encrypted characteristic can. +/// +/// Takes the property bitmask as a raw `UInt` and does NOT import CoreBluetooth, deliberately. That keeps +/// it compilable on Linux, which is what lets the Kotlin side pin its output against this file's actual +/// stdout instead of against a reading of it — the byte-identical check the parity contract asks for is +/// only worth anything if it can be run. +enum GattCapability { + // The BLE characteristic-property bits, which are wire constants and identical on both platforms. + private static let broadcast: UInt = 0x01 + private static let read: UInt = 0x02 + private static let writeNoResponse: UInt = 0x04 + private static let write: UInt = 0x08 + private static let notify: UInt = 0x10 + private static let indicate: UInt = 0x20 + private static let signedWrite: UInt = 0x40 + private static let extended: UInt = 0x80 + + /// The property bitmask as names. Order and spelling match the Kotlin twin exactly; "none" for empty. + /// + /// Renders only the eight standard bits. CoreBluetooth additionally exposes + /// `notifyEncryptionRequired` (0x100) and `indicateEncryptionRequired` (0x200), which Android has no + /// equivalent for — and which are genuinely interesting for the #1635 question of whether this strap + /// demands an encrypted link. They are left out ON PURPOSE: rendering them here would make the two + /// platforms print different text for the same strap. If the encryption flags are wanted on Apple they + /// belong in their own line, not smuggled into a twinned one. + static func propertyNames(_ properties: UInt) -> String { + var names: [String] = [] + if properties & broadcast != 0 { names.append("Broadcast") } + if properties & read != 0 { names.append("Read") } + if properties & writeNoResponse != 0 { names.append("WriteNoResponse") } + if properties & write != 0 { names.append("Write") } + if properties & notify != 0 { names.append("Notify") } + if properties & indicate != 0 { names.append("Indicate") } + if properties & signedWrite != 0 { names.append("SignedWrite") } + if properties & extended != 0 { names.append("Extended") } + return names.isEmpty ? "none" : names.joined(separator: "+") + } + + /// One line per characteristic, grouped by service. `services` is `(uuid, [(uuid, rawProperties)])`. + static func treeLines(_ services: [(String, [(String, UInt)])]) -> [String] { + if services.isEmpty { return ["GATT tree: no services discovered"] } + var out = ["GATT tree: \(services.count) service(s)"] + for (svc, chars) in services { + out.append(" service \(svc) (\(chars.count) char)") + for (uuid, props) in chars { + out.append(" \(uuid) props=0x\(String(props, radix: 16)) (\(propertyNames(props)))") + } + } + return out + } +} diff --git a/Strand/BLE/HelloSuppression.swift b/Strand/BLE/HelloSuppression.swift new file mode 100644 index 0000000000..a7593d4953 --- /dev/null +++ b/Strand/BLE/HelloSuppression.swift @@ -0,0 +1,174 @@ +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) + } + } +} + +/// #2332: is a link RSSI reading worth recording? +/// +/// -127...20 dBm is the LE spec's valid range for a read RSSI, and 127 is its reserved "RSSI is not +/// available". Stashing that sentinel would put an impossibly strong reading on a link that died, which +/// argues the radio was fine and sends the next reader of the log anywhere but at range. So the band is +/// the spec's own range, which rejects 127 and any other out-of-spec value a stack invents, and keeps +/// every reading the spec says is real. +/// +/// What it does NOT catch, deliberately: a stack that reports "unavailable" as an IN-BAND value. 0 dBm is +/// not reachable on a real link, but the spec permits it, so it is indistinguishable here from a genuine +/// reading and is kept. Widening the rejection to cover it would start discarding spec-valid readings on +/// a guess, which is the worse trade for a diagnostic. The age printed beside the value in the epitaph is +/// what a reader uses to judge a suspicious one. +/// +/// Twin of the Kotlin `WhoopBleClient.rssiReadingIsUsable`. +func rssiReadingIsUsable(_ rssi: Int) -> Bool { (-127...20).contains(rssi) } + + +/// 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..5450b7df20 100644 --- a/Strand/BLE/HuamiHRSource.swift +++ b/Strand/BLE/HuamiHRSource.swift @@ -133,6 +133,15 @@ public final class HuamiHRSource: NSObject, ObservableObject { public func connect(_ id: UUID) { stopScan() needsPairing = nil + // Before the radio is up a retrieve answers nothing even for a device this device has been bonded + // to for weeks, and the branch below would read that as "never seen" and arm a scan (#2433). + // Park the intent instead: `centralManagerDidUpdateState` asks again once the answer means + // something. + guard central.state == .poweredOn else { + pendingConnectID = id + log("Huami: connect to \(id) deferred - Bluetooth not powered on (state=\(central.state.rawValue))") + return + } let p = seenPeripherals[id] ?? central.retrievePeripherals(withIdentifiers: [id]).first guard let p else { pendingConnectID = id @@ -143,11 +152,6 @@ public final class HuamiHRSource: NSObject, ObservableObject { seenPeripherals[id] = p peripheral = p p.delegate = self - guard central.state == .poweredOn else { - pendingConnectID = id - log("Huami: Bluetooth not powered on — connect to \(id) deferred until ready") - return - } log("Huami: connecting to \(id)") central.connect(p, options: nil) } @@ -206,12 +210,40 @@ extension HuamiHRSource: @preconcurrency CBCentralManagerDelegate { public func centralManagerDidUpdateState(_ central: CBCentralManager) { switch central.state { case .poweredOn: - if let id = pendingConnectID, let p = seenPeripherals[id] { - pendingConnectID = nil - central.connect(p, options: nil) - } else if scanning { + // The retrieve is re-issued HERE rather than trusting the one that ran while the state was + // still `.unknown`: that one answers nothing even for a bonded device, which used to drop this + // replay into the scan branch and leave it unreachable off-screen, where iOS throttles + // scanning hard (#2433). + let parked = pendingConnectID + let held = parked.flatMap { seenPeripherals[$0] } + let fresh = held == nil + ? parked.flatMap { central.retrievePeripherals(withIdentifiers: [$0]).first } + : nil + switch PendingConnect.replay(isPending: parked != nil, + isHeld: held != nil, + didRetrieve: fresh != nil, + isScanning: scanning) { + case .connect: + if let id = parked, let p = held ?? fresh { + pendingConnectID = nil + seenPeripherals[id] = p + peripheral = p + p.delegate = self + log("Huami: connecting to \(id) - targeted (the radio was not up when it was asked for)") + central.connect(p, options: nil) + } + case .scan: central.scanForPeripherals(withServices: nil, options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]) + case .discover: + // Nothing to connect and no scan armed, because the intent was parked before the + // retrieve. `scan()` arms `scanning` and `didDiscover` connects the parked id on sight. + // Named, because this whole defect was read off log lines: a line that says a + // connect could not be resolved is only useful if it says which device. + if let id = parked { log("Huami: \(id) is not resolvable yet - discovering to find it") } + scan() + case .idle: + break } default: if feedsLive { live.connected = false } @@ -230,7 +262,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/LiveHeartRateReadability.swift b/Strand/BLE/LiveHeartRateReadability.swift new file mode 100644 index 0000000000..2feb754908 --- /dev/null +++ b/Strand/BLE/LiveHeartRateReadability.swift @@ -0,0 +1,34 @@ +import Foundation +import WhoopProtocol + +/// Whether a standard heart-rate sample (0x2A37) is one NOOP may show as the live heart rate, and when a run of +/// samples that are not clears what is shown. +/// +/// A sample with a heart rate outside 30–220 bpm (0 included), or whose skin-contact flag says contact is not +/// detected, used to be refused without touching what was shown, so the last readable heart rate stayed on Today, +/// the Live screen, the Lock Screen and the Dynamic Island for as long as the link lasted. A run of `clearAfter` +/// such samples now clears it; a single one does not, so a glitch while the strap is worn cannot blank the number. +/// A WRIST_OFF event clears it at once (`FrameRouter`). If the strap stops notifying altogether, this sees nothing +/// and the link's own watchdog is what ends it. +/// +/// What is stored is unchanged: every sample still reaches the collector as it arrived. +struct LiveHeartRateReadability { + /// Unreadable samples in a row before the shown heart rate is cleared; about three seconds at 1 Hz. + static let clearAfter = 3 + + private(set) var unreadableRun = 0 + + static func isReadable(bpm: Int, contact: StandardHRContact) -> Bool { + (30...220).contains(bpm) && contact != .supportedNotDetected + } + + /// Feed one sample. True exactly once per run of unreadable samples: on the one that makes it `clearAfter` long. + mutating func clearsShownHeartRate(bpm: Int, contact: StandardHRContact) -> Bool { + guard !Self.isReadable(bpm: bpm, contact: contact) else { + unreadableRun = 0 + return false + } + unreadableRun += 1 + return unreadableRun == Self.clearAfter + } +} diff --git a/Strand/BLE/LiveState.swift b/Strand/BLE/LiveState.swift index e49a71aa49..e1012ef8aa 100644 --- a/Strand/BLE/LiveState.swift +++ b/Strand/BLE/LiveState.swift @@ -2,6 +2,7 @@ import Foundation import Combine import StrandAnalytics import WhoopProtocol +import WhoopStore import OuraProtocol /// Observable snapshot of the live connection + biometric state, driven by FrameRouter @@ -24,6 +25,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 @@ -63,15 +72,59 @@ public final class LiveState: ObservableObject { /// separate short history to render an actually-moving R-R strip / rolling RMSSD. Appended (never /// replaced) by `setRRIntervals(_:)`; emptied by `clearBiometrics()`. @Published public private(set) var rrRecent: [Int] = [] + /// The WHOOP strap's last reported charge. NOT the active device's. + /// + /// Two properties make this dangerous to read on its own, and both are deliberate. It is the + /// strap's alone, with no other source writing it. And it is never cleared: [clearBiometrics] + /// blanks the ring's charge beside it and leaves this, so a strap's last percentage outlives its + /// link on purpose, which is what lets a reconnect show a number before the first fresh reading. + /// + /// So `connected` does not qualify it. That flag goes true the moment ANY source streams, including + /// a ring's first live heart rate, at which point a stale strap percentage satisfies both halves of + /// the obvious gate. That is #2076 and #2208, the same bug found twice, across ten surfaces. + /// + /// Any readout naming the ACTIVE device must go through `LiveConsoleReadout.batteryPercent`, which + /// substitutes the ring's own charge. Any readout labelled as the strap's must pair this with + /// [activeIsWhoop] and show nothing when it is false: substituting there would put a ring's number + /// under a WHOOP heading. Which of the two a surface needs depends on what it claims to be showing, + /// and that is a question about the label rather than about this field. @Published public var batteryPct: Double? = nil /// Strap battery pack VOLTAGE (mV), decoded from the ~8-min BATTERY_LEVEL event (mv@21/@25) and the /// 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 @@ -80,6 +133,31 @@ public final class LiveState: ObservableObject { /// live-HR stream drops to `.off` (removed). Lets the Live view show On wrist / Off wrist. @Published public var ouraWearState: OuraWearState? = nil + /// The RING's own charge, when a ring is the live source. Separate from [batteryPct], which is the + /// WHOOP's, because `LiveState` is ONE object both sources write into: a bonded WHOOP beside a + /// streaming ring leaves the WHOOP's charge sitting in `batteryPct`, and a console that reads it + /// while a ring is the active device reports the wrong band's battery under the right band's name. + /// That is #2075, where a ring on 93% displayed the strap's 72%. Nil when no ring has reported. + @Published public var ouraBatteryPct: Int? = nil + + /// Whether the ACTIVE device is a WHOOP, published so a readout can answer "whose charge is this" + /// without observing `AppModel`. + /// + /// It sits here because [batteryPct] does, and the two are only meaningful together. `batteryPct` is + /// the strap's and is never cleared, deliberately, so on its own it cannot say whether it describes + /// the device the wearer is currently looking at. Every surface that reads it needs this alongside, + /// and `Today` in particular cannot reach the device registry: it observes `BLEManager` rather than + /// `AppModel` on purpose, because `AppModel` publishes on the 1 Hz heart-rate tick and observing it + /// would re-render the whole screen every second. + /// + /// Written in ONE place, `SourceCoordinator.activeDeviceChanged`, from the same `activeDeviceId` + /// transition that decides which live source runs. Defaults to true, matching + /// `LiveConsoleReadout.activeIsWhoop`'s WHOOP-first default for an unresolvable row. + /// + /// Not cleared by [clearBiometrics]: which device is active is not a biometric and does not stop + /// being true when a link drops. (#2208) + @Published public var activeIsWhoop: Bool = true + // MARK: - Battery runtime estimate (#713) /// Rolling buffer of `(unix-seconds, SoC%)` battery readings banked from the live link, the twin of @@ -182,6 +260,46 @@ public final class LiveState: ObservableObject { } @Published public private(set) var strapRange: StrapRange? + // MARK: - R-R transport snapshot (#2117) + + /// What this device has banked versus what its unit policy can actually score. + /// + /// Banked here for the same reason `strapRange` is: the export assembler turns it into a UNIVERSAL + /// line that rides EVERY Test Centre report, so a wearer whose HRV went blank self-diagnoses without + /// having known to turn a mode on. Observability only, never gated, and cleared on disconnect so a + /// stale answer cannot outlive the link. nil until resolved for this session. + /// + /// The judgement lives in `UniversalTrace.rrTransportLine`, not here. This carries facts. + public struct RRTransport: Equatable, Sendable { + public var strictWhoop5: Bool + public var firstRecordedUnix: Int? + public var firstScorableUnix: Int? + public init(strictWhoop5: Bool, firstRecordedUnix: Int?, firstScorableUnix: Int?) { + self.strictWhoop5 = strictWhoop5 + self.firstRecordedUnix = firstRecordedUnix + self.firstScorableUnix = firstScorableUnix + } + } + @Published public private(set) var rrTransport: RRTransport? + + /// Bank the device's R-R transport facts. Two indexed MINs at the call site, so this is cheap enough + /// to refresh on connect rather than being cached across links. + public func setRRTransport(strictWhoop5: Bool, firstRecordedUnix: Int?, firstScorableUnix: Int?) { + rrTransport = RRTransport(strictWhoop5: strictWhoop5, firstRecordedUnix: firstRecordedUnix, + firstScorableUnix: firstScorableUnix) + } + + /// Clear the banked facts. Deliberately NOT called from `clearBiometrics` the way `clearStrapRange` + /// is, because the two describe different things: a strap range is the STRAP's own clock, which must + /// not outlive the link that reported it, while these describe what OUR database holds, which stays + /// true after a disconnect. + /// + /// That difference decides whether the line is present when it is wanted. The wearer this exists for + /// is the one who notices a blank HRV, opens Test Centre and exports, and the strap is quite possibly + /// not connected by then. Clearing on disconnect would drop the line from precisely that export. + /// Re-read on each connect, so a newly banked history is picked up. + public func clearRRTransport() { rrTransport = nil } + /// Bank the strap's reported banked-record window (from GET_DATA_RANGE). Additive observability: the /// universal clock-drift export line reads this. `oldest` keeps the previously-known value when this /// reply carries only the upper bound, so a half/short range reply never clears a good lower bound. @@ -297,6 +415,16 @@ public final class LiveState: ObservableObject { /// experimental on 5.0" instead of a WHOOP-4-style "not recording"/sync-error. Reset on connect/disconnect. @Published public var historySyncExperimental: Bool = false + /// #689/#815 — the strap's ring-buffer page backlog, sampled ONCE from the connect-time + /// GET_DATA_RANGE reply and never re-polled mid-offload: the link is already firmware-paced, and #377 + /// rules out re-polling just to feed a readout. So this is a figure AT CONNECT rather than a live one, + /// and the Today sync chip's copy says so — a static number under a "syncing" label otherwise reads as + /// a stalled live one. A bounded ring measure (write pointer − read pointer against the ring size), + /// never a percentage: the strap never reveals a total record count. Confirmed against real captures + /// on WHOOP 4.0 and 5.0/MG. nil before the first reply this session, or when the frame did not decode. + /// Twin of Android `LiveState.pagesBehindAtConnect`. + @Published public var pagesBehindAtConnect: Int? = nil + /// #612 — true when the WHOOP-4/generic empty-offload streak (`EmptySyncTracker`, `BLEManager`) is /// currently SUSTAINED (3+ consecutive completed-but-empty offloads). Not 5/MG-specific and not /// coupled to HR: a connected strap that keeps handing over nothing has this true regardless of @@ -363,16 +491,26 @@ public final class LiveState: ObservableObject { /// card, so the two can't disagree the way they did in #266 (sidebar "Connecting…" vs Settings /// "Connected" for the same connected-but-unbonded 5/MG link). Once the link is up and HR is /// flowing — even over the unbonded standard profile — this reads "Connected", never "Connecting…". + /// + /// "Bonded" means [encryptedBond], never [bonded]. The 5/MG live-HR shortcut (#69) sets `bonded` while + /// HR streams over the OPEN profile with no pairing at all, so keying the green bonded state off it + /// told a strap with no encrypted pairing that it had one — and the encrypted bond is exactly what + /// gates buzz, alarms, double-tap and history sync. LiveView has drawn this line since #69; this + /// shared label (sidebar + Settings) had not, so the two screens disagreed about the same link. public var connectionStatusLabel: String { - if connected && bonded { return "Bonded · streaming" } + if connected && encryptedBond { return "Bonded · streaming" } + if connected && bonded { return "Live HR (not fully paired)" } if connected { return "Connected" } - if bonded { return "Bonded · idle" } + if encryptedBond { return "Bonded · idle" } + // No `bonded`-only idle arm: without an encrypted bond there was never a pairing to be idle from. return "Disconnected" } - /// True when the link is up (HR flowing) → status reads green. Drives the sidebar + Settings tone. - public var connectionStatusIsActive: Bool { connected } - /// True when previously paired but not currently connected → amber. - public var connectionStatusIsIdle: Bool { !connected && bonded } + /// True when the link is up with a REAL encrypted bond → status reads green. A live-HR-only link is + /// amber via [connectionStatusIsIdle]: it works, but every pairing-gated feature is unavailable. + public var connectionStatusIsActive: Bool { connected && encryptedBond } + /// True when previously paired but not currently connected, OR connected with live HR but no + /// encrypted bond → amber either way. + public var connectionStatusIsIdle: Bool { (!connected && bonded) || (connected && !encryptedBond) } /// Fired (live only) when the strap reports a DOUBLE_TAP gesture. Wired by AppModel to the /// user's chosen action. Debounced in AppModel. @@ -400,6 +538,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 @@ -532,6 +677,53 @@ public final class LiveState: ObservableObject { } } + /// Blank the live heart rate and the latest R-R packet while the link stays up: the strap reported itself + /// off the wrist, or sent a run of samples it could not measure (`LiveHeartRateReadability`). `rrRecent` + /// and `rrSeq` are left alone. The next readable sample sets the heart rate again. + /// + /// R-R first: the heart-rate write is the one the surfaces listen for, and by the time it lands both are gone, + /// so `AppModel`'s median resets on it and the banner is handed nil rather than the old number. + public func clearLiveHeartRate() { + if !rr.isEmpty { rr.removeAll() } + if heartRate != nil { heartRate = nil } + } + + /// How long the live heart rate may stand with no readable sample before it is cleared. A WHOOP 5.0 taken off the + /// wrist can simply go quiet, with the link still up — no 0 bpm, no WRIST_OFF (a tester's log, 23 Sep 2026) — and + /// nothing else would ever clear the last number. Normal gaps between samples were at most 2 s in that log, so ten + /// seconds cannot blank a strap that is being worn. + public static let heartRateSilenceSeconds: TimeInterval = 10 + /// Instance copy of `heartRateSilenceSeconds`, so a test can shorten the wait. + var heartRateSilence: TimeInterval = LiveState.heartRateSilenceSeconds + private var heartRateSilenceTimer: DispatchSourceTimer? + private var heartRateSilenceArmedAt: DispatchTime? + + /// A readable heart-rate sample arrived (`BLEManager`'s standard profile, `FrameRouter`'s realtime frames): move the + /// silence deadline on. One timer, rescheduled at most every tenth of the wait (once a second in use), so it costs + /// nothing while samples flow and fires once when they stop. + public func noteReadableHeartRate() { + let now = DispatchTime.now() + let rearmNanos = UInt64(heartRateSilence / 10 * 1_000_000_000) + if let armed = heartRateSilenceArmedAt, now.uptimeNanoseconds &- armed.uptimeNanoseconds < rearmNanos { return } + heartRateSilenceArmedAt = now + let timer = heartRateSilenceTimer ?? { + let t = DispatchSource.makeTimerSource(queue: .main) + t.setEventHandler { [weak self] in MainActor.assumeIsolated { self?.heartRateWentSilent() } } + t.resume() + heartRateSilenceTimer = t + return t + }() + timer.schedule(deadline: now + heartRateSilence, leeway: .nanoseconds(Int(rearmNanos))) + } + + private func heartRateWentSilent() { + heartRateSilenceArmedAt = nil + guard heartRate != nil else { return } + append(log: AppModel.stamped("HR: no readable heart-rate sample for \(Int(heartRateSilence)) s; " + + "live heart rate cleared")) + clearLiveHeartRate() + } + /// Blank all live biometric readouts (HR + R-R + the rolling buffer) so a stale heart rate or /// R-R strip can't outlive the link. Called on CoreBluetooth disconnect (BLEManager), the twin of /// the `charging = nil` / `encryptedBond = false` clears on the same path. @@ -545,52 +737,33 @@ public final class LiveState: ObservableObject { clearStrapRange() // a stale clock-drift window must not outlive the link either lastFrameAtUnix = nil // #987: a stale "last frame" freshness must not outlive it either ouraWearState = nil // a stale worn/charging badge must not outlive the link either - // Perf: flush the durable log tail on disconnect (mirroring is batched in `append`), so a completed - // session's tail is always persisted for a later scheduled export despite the per-line throttle. - Self.persistTail(log) - logsSincePersist = 0 + ouraBatteryPct = nil // nor a stale ring charge (#2075) } /// Cap on the in-app strap-log ring buffer. Raised from the old ~1h (200 lines) to retain a rolling - /// ~24h of activity (#510 — maddognik's protocol RE wants a full day to correlate against): a busy - /// live session emits a few lines a minute, so 5,000 lines comfortably spans a day. Each line is a - /// short redacted string (~100 bytes), so the worst-case buffer is well under ~1 MB — bounded, never - /// unbounded. Drives the Live log card AND the shareable `exportableLogText()`. + /// ~24h of activity (#510 — maddognik's protocol RE wants a full day to correlate against), when a busy + /// live session emitted a few lines a minute. Since the once-a-second standard-HR transport line (#1767) + /// a streaming strap fills it in about 50 minutes, so exports read the whole log from disk (`archive`); + /// this buffer drives the Live log card and the Test Centre readouts. Each line is a short redacted string + /// (~100 bytes), so the worst-case buffer is well under ~1 MB — bounded, never unbounded. static let maxLogLines = 5_000 - /// Perf: the durable UserDefaults tail (`persistTail`) only feeds a scheduled export that fires hours - /// later, so it needn't be current to the last line. Mirroring the whole tail on EVERY append was a - /// hot-path cost that grew as more diagnostics (offload/backfill/#700/#714/#720) funnel through this one - /// sink. Persist in batches of `persistEveryNLines` instead, and always flush on disconnect - /// (`clearBiometrics`) so a finished session stays durable; a few unmirrored lines on an abrupt kill is - /// harmless for a debug tail. iOS-only — Android's `logBuffer` is an O(1) `ArrayDeque` with no per-line - /// persist, already correct. - private static let persistEveryNLines = 32 - private var logsSincePersist = 0 /// Amortize the ring trim: let the buffer overrun by this slack, then trim back to the cap in one batch /// — turning an O(n) `Array.removeFirst` on every line at steady state into one per `trimSlack` lines. /// Still hard-bounded (never exceeds `maxLogLines + trimSlack`). private static let trimSlack = 256 public func append(log line: String, domain: TestDomain? = nil) { - // FIRST append of this process: rescue the previous process's durable tail into the generation ring - // before this process's own `persistTail` overwrites it (see `rollLogGenerationsIfNeeded`). Latched, - // so this is one Bool test per line after the first. - Self.rollLogGenerationsIfNeeded() // Tag inert when nil (today's behaviour, byte-identical). When tagged, prefix a compact, // parseable marker the export filters on. Redaction is STILL the only scrub point // (redactPii below); tagging happens BEFORE redaction so the scrub covers the whole line. let tagged = domain.map { "[\($0.id)] " + line } ?? line - log.append(Self.redactPii(tagged)) + let safe = Self.redactPii(tagged) + log.append(safe) // Batched trim: overrun by `trimSlack`, then trim back to the cap in one shot (amortized O(1)/line). if log.count > Self.maxLogLines + Self.trimSlack { log.removeFirst(log.count - Self.maxLogLines) } - // Batched durable-tail mirror: persist every `persistEveryNLines` lines, not on every line; - // `clearBiometrics()` flushes on disconnect so a completed session is always fully mirrored. - logsSincePersist += 1 - if logsSincePersist >= Self.persistEveryNLines { - logsSincePersist = 0 - Self.persistTail(log) - } + // Onto disk as it is logged, so a restart loses nothing and an export carries the runs before it. + Self.archive.append(safe) // #990: fold the Backfiller's per-session "session persisted N rows" summary into the persisted // ALL-TIME drained-rows tally, right here at the single log sink (no new BLE seam). The summary // is emitted unconditionally whenever rows landed (#150), so the cumulative counter accrues on @@ -609,120 +782,34 @@ public final class LiveState: ObservableObject { return log.filter { $0.hasPrefix(prefix) } } - // MARK: - Durable log tail (#510, scheduled debug export) - - /// The in-memory `log` lives only for the life of the process, so a scheduled debug auto-export that - /// fires hours after the last live session (the Apple analogue of Android's `StrapLogBuffer`) would - /// otherwise find nothing to write. We mirror the rolling log to a single UserDefaults key so the - /// scheduled export can read the last day's lines even with no live BLE session open. Small and - /// bounded: capped to the tail (`tailLimit`, well under `maxLogLines`) of short redacted strings, so - /// the persisted blob stays a few hundred KB at most. On-device only; nothing is sent anywhere. - private static let tailKey = "strapLog.tail" - /// How many recent lines the durable tail retains — a sensible day's worth for a scheduled export, - /// smaller than the live `maxLogLines` ring so the persisted copy stays modest. - static let tailLimit = 2_000 - - /// Mirror the most recent `tailLimit` lines to UserDefaults (called from `append`). Synchronous and - /// cheap (a single small array write); UserDefaults coalesces the disk flush. `nonisolated` (touches - /// only UserDefaults, no actor state) so the background/static export path can read the twin getter. - nonisolated private static func persistTail(_ lines: [String]) { - let tail = lines.count > tailLimit ? Array(lines.suffix(tailLimit)) : lines - UserDefaults.standard.set(tail, forKey: tailKey) - } - - /// The persisted log tail, newest-last — what a scheduled export reads when no live session is open. - /// Empty if nothing has ever been logged on this device. `nonisolated` so a background task with no - /// main-actor instance can read it. - nonisolated public static func persistedLogTail() -> [String] { - (UserDefaults.standard.array(forKey: tailKey) as? [String]) ?? [] - } - - // MARK: - Previous-process log generations (the "why did the app stop" record) - - /// WHY THIS EXISTS. The in-memory `log` lives for the life of the PROCESS, and `exportableLogText()` - /// renders exactly that — so an export taken after a restart begins at the restart and the lines that - /// would explain the restart are gone. Worse, the single durable slot did not survive either: a fresh - /// process starts logging and, 32 lines in, `persistTail` OVERWRITES `strapLog.tail` with the new - /// (short) array, destroying the previous session's tail before anyone can read it. - /// - /// That is not hypothetical — it has now cost THREE consecutive overnight Oura captures, each time the - /// same way: the app restarted after wake, and the whole night (connection drops, drain timings, the - /// `0x6A` lines) was gone by the time the bundle was exported. An unexplained restart is exactly when - /// the previous lines matter most. - /// - /// So: at the first append of each process, the surviving tail is ROLLED into a small ring of previous - /// generations (and the live slot cleared, so a generation is never double-counted). Exports render the - /// generations oldest-first ahead of the current process, which keeps `report.txt` in chronological - /// order — the log-parsing tools read it unchanged, they simply get more of the night. - private static let generationsKey = "strapLog.generations" - /// How many previous processes to keep. Three covers the observed failure shape (a wake-time restart, - /// occasionally two) without turning a debug tail into a database. - static let maxLogGenerations = 3 - /// Per-generation line cap — smaller than the live `tailLimit` because what explains a stop is the END - /// of the previous session. 3 × 1,000 short redacted lines ≈ 300 KB of UserDefaults, bounded. - static let generationTailLimit = 1_000 - /// Once-per-process latch: the roll must happen BEFORE the first `persistTail` of this process, and - /// exactly once, or a second roll would push this process's own partial tail in as a "previous" one. - nonisolated(unsafe) private static var didRollGenerations = false - - /// Roll the surviving durable tail into the generation ring. Idempotent per process, and a NO-OP when - /// the tail is empty — so a launch that logs nothing (or a run right after a roll) never pushes an - /// empty generation and never evicts a real one. - nonisolated static func rollLogGenerationsIfNeeded(now: Date = Date()) { - if didRollGenerations { return } - didRollGenerations = true - let tail = persistedLogTail() - guard !tail.isEmpty else { return } - let iso = ISO8601DateFormatter() - iso.timeZone = TimeZone(identifier: "UTC") - // The stamp is when the roll happened (i.e. this launch), NOT when those lines were written — the - // lines carry their own clock. Said plainly in the text so nobody reads it as the session's end. - let clipped = tail.count > generationTailLimit ? Array(tail.suffix(generationTailLimit)) : tail - // Say the KEPT count, and say so when the head was dropped. The header used to report only - // `tail.count` (the pre-clip total), so a generation that had lost its first 1,000 lines still - // announced "2,000 line(s)" and read as a complete session — a reader (or a log tool) then - // measures the missing head as silence. Both numbers are printed: the pre-clip total is what - // tells anyone how much is gone. - let count = clipped.count == tail.count - ? "\(tail.count) line(s)" - : "\(clipped.count) of \(tail.count) line(s), head clipped" - let header = "===== previous app session, \(count), rolled at " - + iso.string(from: now) + " (this launch) =====" - var gens = persistedLogGenerations() - gens.append([header] + clipped) - if gens.count > maxLogGenerations { gens.removeFirst(gens.count - maxLogGenerations) } - UserDefaults.standard.set(gens, forKey: generationsKey) - // Clear the live slot: this tail now belongs to a generation, and leaving it would duplicate it in - // every export until 32 fresh lines happen to overwrite it. - UserDefaults.standard.set([String](), forKey: tailKey) - } - - /// The stored generations, oldest-first. Each element's first line is its own separator header. - nonisolated static func persistedLogGenerations() -> [[String]] { - (UserDefaults.standard.array(forKey: generationsKey) as? [[String]]) ?? [] - } - - /// The previous processes' lines, oldest-first, ready to sit AHEAD of the current session in an export. - /// Empty string when there are none, so a caller can concatenate unconditionally. - nonisolated static func previousSessionsText() -> String { - let gens = persistedLogGenerations() - guard !gens.isEmpty else { return "" } - return gens.map { $0.joined(separator: "\n") }.joined(separator: "\n") + "\n" - + "===== current app session =====\n" - } - - /// Drop every stored generation (Settings → the same place the log is cleared from). - nonisolated static func clearLogGenerations() { - UserDefaults.standard.removeObject(forKey: generationsKey) - } - - /// Tests only: clear the once-per-process latch so a test can stand in for a fresh app launch. - nonisolated static func resetGenerationRollLatchForTesting() { didRollGenerations = false } - - /// A shareable strap-log body sourced from the DURABLE tail, for a background / scheduled export that - /// runs with no live `LiveState` instance. Mirrors `exportableLogText()`'s header so a scheduled drop - /// reads the same as a manual share; falls back to the live `log` is not available here by design - /// (this is a `static` so a background task needs no main-actor instance). + // MARK: - The log on disk + + /// Every line of every run, kept across restarts within a fixed size — see `StrapLogArchive`. Opened at the + /// first line or export of the process; that first use also carries over the lines the UserDefaults ring + /// kept before the log moved to disk, then drops the ring's keys. + nonisolated static let archive: StrapLogArchive = { + let fm = FileManager.default + let directory = (try? StorePaths.strapLogDirectory()) + ?? fm.temporaryDirectory.appendingPathComponent("strap-log", isDirectory: true) + try? fm.createDirectory(at: directory, withIntermediateDirectories: true) + let archive = StrapLogArchive(directory: directory) + let defaults = UserDefaults.standard + archive.importLegacy(StrapLogArchive.legacyRingLines( + generations: (defaults.array(forKey: legacyGenerationsKey) as? [[String]]) ?? [], + tail: (defaults.array(forKey: legacyTailKey) as? [String]) ?? [], + now: Date())) + defaults.removeObject(forKey: legacyGenerationsKey) + defaults.removeObject(forKey: legacyTailKey) + return archive + }() + + /// Where the ring kept its runs (#510, #1263), read once to carry them over. + private nonisolated static let legacyTailKey = "strapLog.tail" + private nonisolated static let legacyGenerationsKey = "strapLog.generations" + + /// A shareable strap-log body read from disk, for a background / scheduled export that runs with no live + /// `LiveState` instance. Mirrors `exportableLogText()`'s header so a scheduled drop reads the same as a + /// manual share (this is a `static` so a background task needs no main-actor instance). nonisolated public static func scheduledExportText(extraHeaderLines: [String] = []) -> String { let v = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?" #if os(iOS) @@ -732,11 +819,16 @@ public final class LiveState: ObservableObject { #endif var header = "NOOP strap log (scheduled export) — \(osName)\nApp: \(v)\n\(osName): " + ProcessInfo.processInfo.operatingSystemVersionString + "\n" - if !extraHeaderLines.isEmpty { header += extraHeaderLines.joined(separator: "\n") + "\n" } + // #453: the BODY is scrubbed as it is appended, but these header lines come from the diagnostics + // block and never pass through that path - and they carry device ids, which embed a BLE address + // for a re-added or second strap. Same redactor, so one export cannot be safe while the other leaks. + if !extraHeaderLines.isEmpty { + header += extraHeaderLines.map { Self.redactPii($0) }.joined(separator: "\n") + "\n" + } header += String(repeating: "-", count: 40) + "\n" - // Same generations-then-current shape as `exportableLogText()`: a scheduled drop that fires after a - // restart must not report only the (possibly empty) current tail. - return header + previousSessionsText() + persistedLogTail().joined(separator: "\n") + // Same earlier-runs-then-current shape as `exportableLogText()`: a scheduled drop that fires after a + // restart reports the runs before it, not only the (possibly empty) current one. + return header + archive.exportText() } /// Scrub personal identifiers from a strap-log line so it's safe to share publicly (#445): BLE MAC @@ -750,17 +842,188 @@ 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) + // #2092: an Oura device id (`oura-`) is the same #1303 gap for the OTHER brand — neither + // rule above catches it, since the prefix isn't "whoop-". Exact same shape (3-character prefix + + // `…`, matching `OuraSerialIdentity.logSafe`) and the same `-noop`-suffix-preserving pair, since + // `DeviceRegistryStore.computedSuffix` is brand-agnostic — an Oura device gets a `oura- + // -noop` sibling the same way a WHOOP strap does. Applied AFTER the WHOOP rules but that ordering + // is not load-bearing: the two prefixes never overlap. Kotlin twin in `redactStrapLogPii`. + out = out.replacingOccurrences( + of: "oura-([A-Za-z0-9]{3})[A-Za-z0-9-]{3,}(-noop)", + with: "oura-$1…$2", options: .regularExpression) + out = out.replacingOccurrences( + of: "oura-([A-Za-z0-9]{3})[A-Za-z0-9-]{3,}", + with: "oura-$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 } @@ -769,12 +1032,6 @@ public final class LiveState: ObservableObject { /// session log. Shared so BOTH the Live screen's log card AND a macOS Settings shortcut (#507 — a 4.0 /// owner couldn't find the log on Mac) build the SAME text. Call on the main thread (button taps). func exportableLogText(extraHeaderLines: [String] = []) -> String { - // #1263: roll here too, not only in `append`. A restart's export is the whole point of the - // generation ring, and a user can open the app and tap Report BEFORE this process logs its first - // line — at which point the previous session is still in `tailKey` (unrolled) and the in-memory - // `log` is empty, so `previousSessionsText()` below would miss it. The roll is latched + a no-op on - // an empty tail, so this is harmless when `append` already ran. - Self.rollLogGenerationsIfNeeded() let v = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?" #if os(iOS) let osName = "iOS" @@ -791,10 +1048,69 @@ public final class LiveState: ObservableObject { let healthLines = HealthSyncStats.summaryLines() if !healthLines.isEmpty { header += healthLines.joined(separator: "\n") + "\n" } #endif - if !extraHeaderLines.isEmpty { header += extraHeaderLines.joined(separator: "\n") + "\n" } + // #453: the BODY is scrubbed as it is appended, but these header lines come from the diagnostics + // block and never pass through that path - and they carry device ids, which embed a BLE address + // for a re-added or second strap. Same redactor, so one export cannot be safe while the other leaks. + if !extraHeaderLines.isEmpty { + header += extraHeaderLines.map { Self.redactPii($0) }.joined(separator: "\n") + "\n" + } header += String(repeating: "-", count: 40) + "\n" - // Previous processes first, so the body stays in chronological order and the log-parsing tools read - // it unchanged — they just get the night that a wake-time restart used to erase. - return header + Self.previousSessionsText() + log.joined(separator: "\n") + // Earlier runs first, so the body stays in chronological order and the log-parsing tools read it + // unchanged; then this whole run, from disk — not only the newest `maxLogLines` the screen keeps. + // An export before this process logs anything (Report tapped right after a restart) still carries + // the run before it (#1263). + return header + Self.archive.exportText() + } +} + +/// What the Live Console should read out, given WHICH device is active. +/// +/// `LiveState` is one object that every live source writes into, so "is this field populated" is not the +/// same question as "does this field describe the device on screen". A bonded WHOOP sitting beside a +/// streaming Oura ring leaves every WHOOP-only field truthful-looking while the console is naming the +/// ring, which is #2075: the ring's own 93% was decoded and held, and the strap's stale 72% was what got +/// drawn under "Oura Ring 5". +/// +/// Pure and shared so the Apple and Android consoles cannot answer it differently. +public enum LiveConsoleReadout { + + /// Whether the ACTIVE registry device is a WHOOP. + /// + /// Defaults to true when the registry has not opened or the active row is not resolvable, which is + /// the WHOOP-first tone the console's device name already takes. Delegates to `SourceIdentity`, the + /// one place that answers this, rather than adding a second spelling of it. + /// + /// That default is load-bearing rather than cosmetic. A WHOOP adopting its serial identity re-keys + /// the active row mid-session (#1303), so the id being asked about can briefly name a row that no + /// longer exists; answering "WHOOP" there keeps a working strap's console intact, which is the right + /// call because the device that just re-keyed IS a WHOOP. + public static func activeIsWhoop(devices: [PairedDevice], activeId: String?) -> Bool { + guard let activeId, let active = devices.first(where: { $0.id == activeId }) else { return true } + return SourceIdentity.isWhoop(active) + } + + /// Whether the ACTIVE registry device is an Oura ring (#2305). + /// + /// The OPPOSITE default to `activeIsWhoop`: false when the registry has not opened or the active row is + /// not resolvable. The ring-only affordances this gates (the ring status line, "Reconnect ring") have + /// no WHOOP-first tone to keep; showing them for an unknown device would offer a reconnect that + /// reaches nothing. A device that is neither (Polar, Garmin, …) is neither — it gets the Devices row. + public static func activeIsOura(devices: [PairedDevice], activeId: String?) -> Bool { + guard let activeId, let active = devices.first(where: { $0.id == activeId }) else { return false } + return active.brand.caseInsensitiveCompare(ExperimentalBrand.oura.displayBrand) == .orderedSame + } + + /// The charge to show for the ACTIVE device, or nil to show nothing. + /// + /// A non-WHOOP active device never falls back to the WHOOP's charge. Showing nothing is the honest + /// answer when a ring has not reported yet; showing the strap's number would be a confident lie, and + /// it is the exact shape of the reported bug. + public static func batteryPercent(activeIsWhoop: Bool, whoopPct: Double?, ringPct: Int?) -> Int? { + // ROUNDS, and deliberately. The surfaces this replaced disagreed: Devices and the widget rounded, + // the Live Console truncated, so a strap on 72.6% read 73 on one screen and 72 on another. One + // seam has to pick, and for a percentage rounding is the accurate one. `.rounded()` is + // half-away-from-zero and Kotlin's Math.round is half-up, identical over the 0...100 this sees. + if activeIsWhoop { return whoopPct.map { Int($0.rounded()) } } + return ringPct } } diff --git a/Strand/BLE/OuraLiveSource.swift b/Strand/BLE/OuraLiveSource.swift index 58f037ce44..45b0c1b15d 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`. /// @@ -88,6 +112,29 @@ public final class OuraLiveSource: NSObject, ObservableObject { /// The live adopt outcome (see `AdoptPhase`). The wizard observes this to leave its Adopting step. Reset /// to `.idle` on every connect/stop/disconnect so a stale outcome never drives a transition. @Published public private(set) var adoptPhase: AdoptPhase = .idle + /// The most recent `feature status` read-back per feature id, keyed by `OuraFeatureStatus.feature`. + /// Updated on EVERY status frame received, independent of `loggedFeatureStatuses`'s once-per- + /// connection log dedup — Test Centre's enable/disable rows read this to show the ring's current + /// state, and a UI readout must never go stale just because the log line already printed once. + /// Cleared on `stop()` so a previously-paired ring's readings never bleed into a different one. + @Published public private(set) var featureStatuses: [Int: OuraFeatureStatus] = [:] + + /// Where the ring's session is, for the Live console (#2305). `LiveState.connected` is one flag every + /// live source writes into and, for the ring, is only raised by the first live HR push — so under a + /// ring the console could only ever read WHOOP state or nothing, and "connected, authenticating" was + /// indistinguishable from "connected and dead" (#2303, #2304). This names the phase the ring is + /// actually in. `.authenticated` is `auth OK` reached — the stream itself is `LiveState.streamingLiveHR`. + public enum LinkPhase: Equatable, Sendable { + case disconnected + case connecting + case authenticating + case authenticated + } + @Published public private(set) var linkPhase: LinkPhase = .disconnected + + /// Set by `reconnect()` while a CONNECTED link is being cancelled on the user's request, so the + /// ensuing `didDisconnectPeripheral` reconnects immediately instead of on the involuntary-drop backoff. + private var pendingUserReconnectID: UUID? // MARK: - BLE UUIDs (from the platform-pure OuraGatt facts) @@ -115,10 +162,69 @@ public final class OuraLiveSource: NSObject, ObservableObject { /// so a misframed/garbage reply can never mint a bogus `oura-` identity (#771 honest-data guard). /// The captured Gen3 serial "2H3B2405003655" (14 chars) passes; the hardware id "BLB_03" (underscore) does /// not — but it is already routed to the generation path before this is reached. - private static func isPlausibleSerial(_ s: String) -> Bool { + nonisolated private static func isPlausibleSerial(_ s: String) -> Bool { (8...24).contains(s.count) && s.allSatisfy { $0.isLetter || $0.isNumber } } + /// What the product-info reply log line (below) may show (#2092): a serial page identifies the + /// ring's owner, so only its SHAPE is logged, via `OuraSerialIdentity.logSafe` — the same 3-character + /// prefix `WhoopSerialIdentity.logSafe` uses for a WHOOP serial (#1303). A hardware-generation page + /// ("BLB_03", never `isPlausibleSerial`) identifies no one and is still logged in full; either way a + /// masked value still confirms the decode happened, which is all this line exists to do. Masks BOTH + /// halves — logging a masked `ascii` beside the unmasked `hex` would still leak the serial encoded. + /// `nonisolated static` and free of the log call itself, so it is testable without CoreBluetooth. + nonisolated static func logSafeProductInfo(hex: String, ascii: String, + decoded: String?) -> (hex: String, ascii: String) { + guard let decoded, isPlausibleSerial(decoded) else { return (hex, ascii) } + return ("", OuraSerialIdentity.logSafe(serial: decoded)) + } + + /// Re-encode outer frames for the raw diagnostics sidecar, dropping `productInfoResponseOps` — + /// a GetProductInfo reply's body IS the ring's serial/hardware string in plain ASCII, and the + /// sidecar's redaction pass only ever masks the JSON envelope's `deviceId`, never bytes inside a + /// frame body. Found via a #2075 reporter's attachment, whose `oura-raw.jsonl` carried a stable + /// 16-digit identifier this way, unredacted, into a public issue. Never touches decode: only what + /// `rawDump?.record` sees changes. + /// + /// `tail` (PR #2090 review, ryanbr): `frames` only covers what `OuraFraming.parseOuterFrames` fully + /// consumed — it silently drops an incomplete trailing frame (`guard i + total <= bytes.count else { + /// break }`), which is ORDINARY, not rare: the Reassembler exists precisely because notifications + /// split mid-frame. A caller reconstructing the sidecar from `frames` alone therefore loses that + /// unconsumed remainder — e.g. `41 02 AA BB 42 05 01 02` parses one complete frame and silently drops + /// `42 05 01 02`. For a sidecar whose whole purpose is exact wire bytes, that is a real loss, so a + /// caller that reconstructs from `frames` (rather than recording the original notification bytes + /// verbatim) must pass whatever `bytes` remained past the frames it parsed. + nonisolated static func rawDumpBytes(_ frames: [OuraOuterFrame], appending tail: [UInt8] = []) -> [UInt8] { + frames.filter { !productInfoResponseOps.contains($0.op) } + .flatMap { [$0.op, UInt8($0.body.count)] + $0.body } + + tail + } + + /// Convenience for the whole-notification case (the "no secure frame" branch below, where `frames` + /// is already the full parse of `bytes`): computes the `tail` `rawDumpBytes` needs from `frames` + /// itself, rather than duplicating that arithmetic at the call site. Kept CoreBluetooth-free and + /// `nonisolated static` so it is testable directly, the same as `rawDumpBytes`. + /// + /// CORRECTION (PR #2090 review, ryanbr, self-caught 6 minutes after suggesting the tail in the first + /// place): appending the tail unconditionally would put the serial BACK. When the frame split across + /// notifications is itself a GetProductInfo reply, `parseOuterFrames` stops right at it, so the + /// unconsumed remainder starts with `0x19` and continues straight into the ASCII serial - exactly + /// what this whole PR exists to keep out. The refinement that gets both: append the remainder only + /// when its first byte is NOT a `productInfoResponseOps` op. An ordinary split frame (any other op) + /// keeps full fidelity; a split product-info frame drops its (however partial) tail along with the + /// rest of it, same as a complete one does. + nonisolated static func rawDumpBytes(fromNotification bytes: [UInt8], frames: [OuraOuterFrame]) -> [UInt8] { + let consumedLength = frames.reduce(0) { $0 + 2 + $1.body.count } + var tail: [UInt8] = [] + if consumedLength < bytes.count { + let remainder = Array(bytes[consumedLength...]) + if let leadOp = remainder.first, !productInfoResponseOps.contains(leadOp) { + tail = remainder + } + } + return rawDumpBytes(frames, appending: tail) + } + /// Local-time formatter for logging a decoded date/time next to a raw ring-tick cursor value, so a /// number like "1178203" reads as an actual date instead of an opaque tick count. Logging only. private static let cursorDateFormatter: DateFormatter = { @@ -151,6 +257,10 @@ public final class OuraLiveSource: NSObject, ObservableObject { /// #1284 residual 3 (default OFF): read live at persist — when true, an Oura hypnogram night is keyed on /// its rounded 0x49 onset (stable per-night anchor) instead of the end-anchored first-code time. private let onsetKeying: () -> Bool + /// Packed-notification A/B (default OFF): read once per connect — when true the session's + /// SetNotification is the official app's `ff` instead of `3f` (OURA_PROTOCOL.md s2.3). The next + /// connect re-reads it, so switching the toggle off restores the default with nothing left on the ring. + private let notifyMaskFull: () -> Bool private let log: (String) -> Void private let onBattery: (Int) -> Void /// Fired with the ring's TRUE model label ("Oura Ring 3/4/5") once the GetProductInfo hardware id resolves @@ -219,8 +329,21 @@ public final class OuraLiveSource: NSObject, ObservableObject { /// stop/disconnect. These are last-night values from the history fetch, not live pushes, but we still /// only want one log line, not one per sample. Twin of `loggedFirstHR`. private var loggedFirstTemp = false - /// Logs the FIRST SpO2 sample decoded this session only. Twin of `loggedFirstTemp`. - private var loggedFirstSpo2 = false + /// Logs the FIRST SpO2 sample decoded this session, PER CHANNEL. Twin of `loggedFirstTemp`, except + /// that `.spo2` carries two quantities three orders of magnitude apart (`OuraSpO2Channel`), and one + /// latch across both reported whichever the drain served first: the same ring printed `value 93 + /// (raw)` on one reconnect and `value 101144 (dc_raw)` on the next. A reporter read the second as a + /// percentage and filed a defect against SpO2 that was never wrong. One latch per channel, so each + /// line names one quantity and a session that only ever saw perfusion says so instead of implying a + /// percentage arrived. + private var loggedFirstSpo2: Set = [] + /// 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 @@ -231,6 +354,13 @@ public final class OuraLiveSource: NSObject, ObservableObject { /// Feature ids whose status we have already logged this session (SpO2 0x04 / real_steps 0x0b), so the /// read-only feature-status diagnostic prints once per feature, not on every reconnect. private var loggedFeatureStatuses: Set = [] + /// Feature ids the EXPERIMENTAL feature-mode write (`writeFeatureMode`) most recently set to mode=0 + /// this connection. `logFeatureStatus`'s all-zero "server-gated off" label predates that write + /// existing — it can no longer assume all-zero means the cloud never enabled the feature, since a + /// self-induced disable now produces the identical bit pattern. Real-hardware confirmed 2026-09-11: + /// disabling SpO2 flipped mode 1→0 and the very next read logged "cloud never enabled it", which was + /// false — we had just enabled-then-disabled it ourselves seconds earlier. + private var manuallyDisabledFeatures: Set = [] /// Product-info replies already logged this session, keyed by op+body so the #771/#772 serial/hardware /// capture prints each DISTINCT reply once — get_serial and get_hardware both answer under op 0x19, so a /// per-op guard would swallow the second (observed on-device: only the serial reached the log). Distinct @@ -299,6 +429,17 @@ public final class OuraLiveSource: NSObject, ObservableObject { /// ring sent, so after a full connect a hole in a decoded file can be pinned as a decode drop vs ring-side. /// No dedup (a re-serve is still evidence the ring re-sent it); the offline reframer collapses duplicates. private let rawDump: OuraRawDump? + + // MARK: - 0x20 user-info write EXPERIMENT (default-off, Test Centre only) + + /// The most recent `0x5c` `user_information` record this connection has seen, so a later one can be + /// reported as changed or unchanged rather than just printed. Never persisted, never scored. + private var lastUserInfoRecord: OuraUserInfoRecord? + /// The last `0x20` write this connection sent, if any: the field, the value bytes, and when. Set by + /// `writeUserInfo` and read only to caption the ack and the next `0x5c` — so the log can say which + /// record is the first one AFTER a write instead of leaving the reader to line up timestamps. + private var lastUserInfoWrite: (field: OuraUserInfoField, valueHex: String, at: Date)? + /// Cached local-day formatter (the 0x50 stream is high-volume; avoid building one per record). private static let activityDayFormatter: DateFormatter = { let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f // local time zone by default @@ -324,6 +465,14 @@ public final class OuraLiveSource: NSObject, ObservableObject { /// True once the live-HR stream has been requested, so the disconnect handler can tell "we never got /// authenticated/streaming" (-> honest note) from "the link just dropped". private var reachedStreaming = false + /// True while THIS session has actually put the ring into daytime-HR mode: the driver's enable + /// triplet completed, `reengageLiveHR()` wrote an enable, or a live push proved the ring is streaming + /// regardless of what we asked. `disableLiveHR()` keys off it so a connect made while suspended — + /// which now skips the triplet (`OuraDriver.liveHRWanted`) — does not follow up with a `mode 0x00` + /// write for a mode it never set: that write is itself a state change on the ring, and the whole + /// point of the suspended connect is to leave the ring's own night suite untouched. Cleared with + /// `reachedStreaming` and after a disable is sent. + private var liveHRArmedThisSession = false /// The freshly-generated 16-byte key written to the ring during an adopt key install. Held in memory /// ONLY between writing the `0x24` install and receiving the `0x25` ack: it is persisted to the keystore /// ONLY on an OK ack (so a failed/absent ack never leaves a key the next session would wrongly trust). @@ -335,6 +484,8 @@ public final class OuraLiveSource: NSObject, ObservableObject { private var central: CBCentralManager! private var peripheral: CBPeripheral? private var writeCharacteristic: CBCharacteristic? + /// The notify characteristic, kept so the auth watchdog can toggle its subscription (#2304). + private var notifyCharacteristic: CBCharacteristic? /// A peripheral asked to connect before `centralManagerDidUpdateState` reported `.poweredOn`. private var pendingConnectID: UUID? /// Peripherals retained by identifier so a chosen one survives until connection (exact @@ -440,6 +591,14 @@ public final class OuraLiveSource: NSObject, ObservableObject { /// No new outbound command: `central.connect` is the same call `connect(_:)` already makes. private func issueStandingConnect(_ id: UUID) { guard !intentionalDisconnect, reconnectID == id else { return } + // The same trap as `connect(_:)` (#2433), and worse here: a retrieve before `.poweredOn` answers + // nothing, the fallback below reads that as "never seen" and calls `connect(id)`, which scans. + // Scanning is the one thing a STANDING connect exists to avoid. + guard central.state == .poweredOn else { + pendingConnectID = id + log("Oura: standing reconnect deferred - Bluetooth not powered on (state=\(central.state.rawValue))") + return + } guard let p = seenPeripherals[id] ?? central.retrievePeripherals(withIdentifiers: [id]).first else { // No peripheral object to hand CoreBluetooth (never seen on this device). Fall back to the // ordinary connect path, which scans for it — that is the only way to acquire one. @@ -450,15 +609,10 @@ public final class OuraLiveSource: NSObject, ObservableObject { seenPeripherals[id] = p peripheral = p p.delegate = self - guard central.state == .poweredOn else { - // `centralManagerDidUpdateState` replays this on poweredOn. - pendingConnectID = id - log("Oura: standing reconnect deferred - Bluetooth not powered on (state=\(central.state.rawValue))") - return - } standingConnectAt = Date() log("Oura: leaving a STANDING connect outstanding for \(id) - CoreBluetooth will reconnect " + "whenever the ring is reachable, including while the app is suspended") + linkPhase = .connecting central.connect(p, options: nil) } @@ -524,6 +678,19 @@ public final class OuraLiveSource: NSObject, ObservableObject { /// The cursor we resumed FROM at the start of the current fetch — passed into `drain.noteStoredRingTime` /// so a real stored sample OLDER than it flags a genuine ring reboot (clock reset / seek ignored). private var resumeCursorAtFetchStart: UInt32 = 0 + /// The SyncTime anchor persisted from the END of the PREVIOUS connection to this ring (#2097), loaded + /// fresh at the start of THIS session — never an anchor this session itself adopts. Compared against + /// `currentSyncAnchor` in `commitResumeCursor` to tell a genuine ring reboot from a second BLE client + /// (e.g. the Oura app) having served the ring in between: `sawPreResumeData` alone cannot, since both + /// produce the same "stored sample older than the fetch cursor" signature. `nil` on a first-ever + /// connect to this ring, or when no previous session ever adopted an anchor — the comparison then + /// simply declines and today's "treat as reboot" behavior stands. + private var previousSyncAnchor: (ringTicks: UInt32, unixSeconds: Int64)? + /// The SyncTime anchor THIS session has adopted (freshest wins, mirroring `OuraDriver.adoptSyncTimeAnchor`), + /// persisted via `OuraSyncAnchorStore` for the NEXT connection to compare against as its own + /// `previousSyncAnchor`. `nil` until `adoptSyncTimeAnchor(deviceTimestamp:status:receivedAt:source:)` + /// first succeeds this session. + private var currentSyncAnchor: (ringTicks: UInt32, unixSeconds: Int64)? /// Wall-clock start of the current drain; `drain`'s deadline guard force-stops one running too long. private var drainStartedAt: Date? /// The cursor the LAST GetEvents request was issued at — the `start` of open_oura's progress test @@ -548,10 +715,22 @@ public final class OuraLiveSource: NSObject, ObservableObject { /// One-shot delay before a self-chained drain pass (see `finishDrain`). Invalidated on teardown. private var chainedDrainTimer: Timer? /// Periodic re-fetch while connected, so an overnight-connected session (or one left open after a nap) - /// picks up freshly-banked sleep data without needing a reconnect. Mirrors BLEManager's ~15 min - /// periodic WHOOP history-offload floor. + /// picks up freshly-banked sleep data without needing a reconnect. + /// + /// MEASURED 2026-08-10 (capture `…-260810-1556`, 2h31m at 96.6% connected): this interval is not just a + /// backstop, it is the ACTUAL DATA CADENCE. Between fetches the ring delivered **nothing at all** — + /// seven arrival gaps of 893-928 s inside live sessions, clustering exactly on the old 900 s value, + /// while the app was awake (59-60 live-HR re-arms per gap), the link was up and the ring was worn. Only + /// **2.1%** of `0x80` "live HR" arrived within 15 s of its own second; the median record was **385 s** + /// old on arrival. So a user watching live HR was watching a 6-minute-old burst. + /// + /// 900 -> 300 s halves-and-then-some the worst-case staleness at a marginal cost: while live HR is on + /// the app already writes `dhr_enable`+`dhr_subscribe` every 15 s (462 commands/h measured), against + /// which the whole history-fetch machinery was 21 commands/h. The payload is unchanged either way — the + /// same records arrive, in smaller chunks — and `fetchHistoryIfIdle` is a no-op unless the driver is + /// idle-streaming, so a fetch never overlaps a drain. private var historyFetchTimer: Timer? - private let historyFetchInterval: TimeInterval = 900 + private let historyFetchInterval: TimeInterval = 300 /// Kick a history-fetch pass at the current cursor, but ONLY when the driver is idle-streaming (never /// overlaps a fetch already in flight - the driver's own phase is the guard, so this is safe to call @@ -649,8 +828,13 @@ public final class OuraLiveSource: NSObject, ObservableObject { if let burst = hypnogramAssembler.flush() { persistHypnogramBurst(burst) } - let rebootFullPullPending = drain.sawPreResumeData - commitResumeCursor(drainCompleted: completed) + // Ask the cursor commit whether it actually reset for a reboot rather than reading + // `drain.sawPreResumeData` directly: that flag is also raised by a stale replay from a second BLE + // client's serve position, which the #2097 judge inside `commitResumeCursor` rejects as "not a + // reboot" and answers by keeping the cursor. Snapshotting the flag here (as this did until + // 2026-09-13) had the scheduler print "ring reboot detected - starting the honest full re-pull" + // two lines after "not a reboot (#2097)" and burn a chained pass that only re-read the kept cursor. + let rebootFullPullPending = commitResumeCursor(drainCompleted: completed) logActivityEstimateSummary() advance(.historyCursorAdvanced(cursor: historyCursor, moreData: false)) if rebootFullPullPending || resumeBacklog { @@ -669,9 +853,42 @@ public final class OuraLiveSource: NSObject, ObservableObject { chainedDrainTimer = t } else if completed { chainedDrainPasses = 0 // healthy full completion re-arms the cap for future backlogs + noteCompletedOffload() } } + /// Stamp `LiveState.lastSyncedAt` after a drain that COMPLETED and actually banked something, so the + /// ring's freshly-offloaded history gets scored now instead of waiting on the 15-minute periodic tick. + /// + /// WHY THIS EXISTS: `AppModel` re-scores off a debounced `live.$lastSyncedAt` sink + /// (`refreshAfterCompletedBackfill` → `repo.refresh` + `intelligence.analyzeRecent`), but only + /// `BLEManager.exitBackfilling` (the WHOOP `HISTORY_COMPLETE` path) ever stamped it — the Oura drain + /// never did. Observed consequence (2026-08-02 capture): the morning's `analyzeRecent` ran at app + /// launch ~09:25, the ring delivered that night's hypnogram at 09:31:44, and nothing re-scored after, + /// so the night stayed `totalSleepMin=nil, matched=0` despite a complete 541-minute session sitting in + /// the DB. Relaunching did not help — it just re-ran the same premature pass. Reusing the WHOOP signal + /// (rather than adding a second refresh path) means the ring inherits the SAME `.debounce(2s)` + + /// `removeDuplicates()` coalescing that #755 added for slice storms. + /// + /// GATES, so this cannot become a refresh storm: + /// - `feedsLive` — the discovery-only wizard scanner must never touch `LiveState` at all. + /// - `drain.maxStoredRingTime > 0` — only a drain that BANKED a record counts. A reconnect that + /// completes instantly at `bytes_left 0` with nothing new (the common case on repeated connects) + /// banks nothing, so it does not stamp and does not trigger a re-score. + private func noteCompletedOffload() { + guard feedsLive, drain.maxStoredRingTime > 0 else { return } + let now = Date().timeIntervalSince1970 + // Logged because a BLE-path change is only verifiable on real hardware: this line appearing AFTER + // the night's `sleep-phase record` lines, followed by a fresh `sleep day=` with a non-nil + // totalSleepMin, is exactly the sequence that was missing. + log("Oura: offload complete - marking synced so the new history is scored now (not at the next periodic tick)") + live.lastSyncedAt = now + // Mirrors BLEManager: persisted so "last offload completed" survives a relaunch. Before this, a + // ring-only install had no completed-offload timestamp at all and read "No completed offload yet" + // forever, however many nights it had actually synced. + UserDefaults.standard.set(now, forKey: "lastSyncedAt") + } + private func restartBatchQuietTimer() { batchQuietTimer?.invalidate() let t = Timer.scheduledTimer(withTimeInterval: batchQuietInterval, repeats: false) { [weak self] _ in @@ -687,17 +904,31 @@ public final class OuraLiveSource: NSObject, ObservableObject { /// Commit the durable resume cursor at drain end. Only a cursor that (a) moved forward, (b) is below /// the plausibility ceiling, and (c) resolves to a real time under the CURRENT anchor is persisted; - /// a reboot (`sawPreResumeData`) resets to 0 so next connect does an honest full pull. - private func commitResumeCursor(drainCompleted: Bool) { + /// a reboot (`sawPreResumeData`) resets to 0 so next connect does an honest full pull — UNLESS the + /// ring's own SyncTime anchor proves its clock never paused across the gap (#2097: + /// `OuraHistoryDrain.anchorsAreContinuous`), in which case the stale replay is treated like ordinary + /// stale data instead (dropped; cursor follows the same forward-only-if-resolving rule as any other + /// drain) rather than forcing a full re-pull of the ring's entire history. + /// + /// Returns `true` only when the cursor WAS reset to 0 for a reboot — the one outcome that leaves a + /// full re-pull pending for `finishDrain` to chain. A stale replay the judge rejected returns `false`. + private func commitResumeCursor(drainCompleted: Bool) -> Bool { let how = drainCompleted ? "caught up (bytes_left 0)" : "stopped early" let resolves = drain.maxStoredRingTime > 0 && (driver?.unixSeconds(forRingTimestamp: drain.maxStoredRingTime) != nil) - let newCursor = drain.resumeCursorAtDrainEnd(currentCursor: historyCursor, resolvesUnderAnchor: resolves) - if drain.sawPreResumeData { + let anchorConfirmsContinuity = drain.sawPreResumeData && syncAnchorsConfirmContinuity() + let newCursor = drain.resumeCursorAtDrainEnd(currentCursor: historyCursor, resolvesUnderAnchor: resolves, + anchorConfirmsContinuity: anchorConfirmsContinuity) + if drain.sawPreResumeData, !anchorConfirmsContinuity { log("Oura: history \(how) but the ring served data older than cursor \(resumeCursorAtFetchStart) - clock reset/seek ignored; next connect does a full pull") historyCursor = 0 OuraHistoryCursorStore.save(0, deviceId: deviceId) - } else if newCursor != historyCursor { + return true + } + if drain.sawPreResumeData { + log("Oura: history \(how) but the ring served data older than cursor \(resumeCursorAtFetchStart) - the ring's own SyncTime clock never paused across the gap, so this is a second BLE client's serve position, not a reboot (#2097); discarding the stale replay instead of a full re-pull") + } + if newCursor != historyCursor { historyCursor = newCursor OuraHistoryCursorStore.save(newCursor, deviceId: deviceId) log("Oura: history \(how) - resume cursor advanced to \(historyCursor) [\(describeCursor(historyCursor))]") @@ -706,6 +937,18 @@ public final class OuraLiveSource: NSObject, ObservableObject { } else { log("Oura: history \(how) (resume cursor unchanged \(historyCursor) [\(describeCursor(historyCursor))])") } + return false + } + + /// Whether this drain's `sawPreResumeData` flag should be trusted as a genuine ring reboot, or + /// whether the ring's own clock proves otherwise (#2097). Requires BOTH a previous-session anchor + /// (persisted by an earlier connect) and an anchor THIS session adopted; either missing means there + /// is nothing to compare, so this declines (`false`) and the existing reboot handling stands — the + /// safe default when the ring was never anchored before (first pairing) or never got anchored again + /// this session (e.g. the drain finished before any 0x13 SyncTime reply resolved). + private func syncAnchorsConfirmContinuity() -> Bool { + guard let previous = previousSyncAnchor, let current = currentSyncAnchor else { return false } + return OuraHistoryDrain.anchorsAreContinuous(previous: previous, current: current) } /// The 0x49 window in `windows` whose envelope ring-time is nearest `rt` and within `tolerance` ticks, @@ -738,6 +981,20 @@ public final class OuraLiveSource: NSObject, ObservableObject { /// available). Logs the reconstructed window + stage minutes so a capture is self-evident. private func persistHypnogramBurst(_ burst: OuraHypnogramBurst) { guard let driver, burst.totalCodes > 0 else { return } + // STALE-REPLAY GATE (2026-09-13, Oura-app handoff on a Gen 3): a second BLE client on the same + // phone makes the ring re-serve from ITS position, and the #2097 judge rightly keeps our cursor — + // but by then every replayed record has been ingested. Time-series rows dedupe on their keys; a + // burst does not: the last two sleep-phase records of a night came back without their 0x49 window + // and were end-anchored at their write time into a 52-minute `[no-0x49-onset]` session whose codes + // were not the night's tail. A burst written BEFORE where this fetch resumed was already banked + // from its own drain, so it is refused here rather than handed to the persist closure's dedup + // (which only catches a fragment that happens to sit inside a stored row, and only with onset + // keying on). A genuine reboot is unaffected: its judge resets the cursor to 0 and the chained + // full pull re-serves the same records with no floor. + if OuraHistoryDrain.predatesResume(ringTime: burst.lastRingTimestamp, resumeCursorAtFetchStart: resumeCursorAtFetchStart) { + log("Oura: hypnogram burst (\(burst.totalCodes) codes, written at rt \(burst.lastRingTimestamp)) predates the resume cursor \(resumeCursorAtFetchStart) - a re-serve from a second BLE client's position (#2097), already banked from its own drain; not persisted") + return + } // HOLD-UNTIL-ANCHOR (same discipline as pendingAnchorEvents): the burst end IS the night's whole // time axis, so guessing it from wall-clock would persist real stage codes at fabricated times. // An unanchored burst is parked and re-tried when the 0x42 anchor lands; if the session ends @@ -812,9 +1069,13 @@ public final class OuraLiveSource: NSObject, ObservableObject { // startTs on the ROUNDED onset (stable across the ring's re-serves) rather than the end-anchored // first-code time — so re-serves of one night share a PK and the displayed bedtime is the true // onset. The completeness guard in the persist closure then suppresses/replaces any duplicate. + // `safeKeyedStart` refuses the rekey when `sleepStart` didn't actually bind in the assembler's + // own clip (item 22, 2026-09-12: a mis-paired 0x49 window otherwise wrote a startTs 16 min + // AFTER its own endTs) — see its doc comment for why `onset <= mapped.startTs` is the exact test. let session: CachedSleepSession = { - guard onsetKeying(), let onset = sleepStart else { return mapped } - return mapped.withStartTs(SleepSessionDedup.keyedStart(onsetUnixSeconds: onset)) + guard onsetKeying(), let onset = sleepStart, + let keyed = SleepSessionDedup.safeKeyedStart(onset: onset, mapped: mapped) else { return mapped } + return mapped.withStartTs(keyed) }() let start = session.startTs // #1284 residual 3: log when this persist lands wholly inside — or overlapping — a session already @@ -922,6 +1183,23 @@ public final class OuraLiveSource: NSObject, ObservableObject { private let flushCount = 30 private let flushInterval: TimeInterval = 30 + // MARK: - Auth watchdog (#2304) + + /// One-shot timer armed on every `get_nonce` write and cleared by the nonce. nil outside the auth + /// phase. Its policy lives in `OuraAuthWatchdog` (pure, tested); this class only owns the clock and + /// the transport steps. + private var authWatchdogTimer: Timer? + /// When the most recent `get_nonce` of this session went out; nil once the nonce arrived. + private var authNonceRequestedAt: Date? + /// Escalations already taken this session (0 → resend → toggle → drop). Reset per connection. + private var authEscalations = 0 + /// True between the watchdog's CCCD off/on toggle and its re-enable callback, so + /// `didUpdateNotificationStateFor` re-sends `get_nonce` there instead of restarting the handshake. + private var authToggleInFlight = false + /// Consecutive sessions the watchdog dropped for silence, printed on the drop line so a log shows + /// "the ring has answered nothing on N links" without counting by hand. Reset by any nonce. + private var silentSessionsInARow = 0 + // MARK: - Live-HR re-engagement /// Daytime-HR auto-reverts after ~20 s (OURA_PROTOCOL.md s5.7), so while a live session is open we @@ -1053,6 +1331,7 @@ public final class OuraLiveSource: NSObject, ObservableObject { onModel: @escaping (String) -> Void = { _ in }, onSerial: @escaping (String) -> Void = { _ in }, onsetKeying: @escaping () -> Bool = { false }, + notifyMaskFull: @escaping () -> Bool = { false }, feedsLive: Bool = true, adoptIntent: Bool = false) { self.live = live @@ -1066,6 +1345,7 @@ public final class OuraLiveSource: NSObject, ObservableObject { self.onModel = onModel self.onSerial = onSerial self.onsetKeying = onsetKeying + self.notifyMaskFull = notifyMaskFull self.feedsLive = feedsLive self.adoptIntent = adoptIntent // Tier-B MET research corpus: only on a live/persisting source, never the discovery-only scanner. @@ -1238,6 +1518,15 @@ public final class OuraLiveSource: NSObject, ObservableObject { reconnectID = id intentionalDisconnect = false standingConnectAt = nil // an explicit connect supersedes any standing one + linkPhase = .connecting // every branch below is an attempt to reach the ring (scan, defer, connect) + // Before the radio is up a retrieve answers nothing even for a ring this phone has been bonded to + // for weeks, and the branch below would read that as "never seen" and arm a scan (#2433). Park the + // intent instead: `centralManagerDidUpdateState` asks again once the answer means something. + guard central.state == .poweredOn else { + pendingConnectID = id + log("Oura: connect to \(id) deferred - Bluetooth not powered on (state=\(central.state.rawValue))") + return + } let p = seenPeripherals[id] ?? central.retrievePeripherals(withIdentifiers: [id]).first guard let p else { // Never seen by this Mac/iPhone yet -> remember it and scan; didDiscover connects on sight. @@ -1249,23 +1538,53 @@ public final class OuraLiveSource: NSObject, ObservableObject { seenPeripherals[id] = p peripheral = p p.delegate = self - guard central.state == .poweredOn else { - pendingConnectID = id - log("Oura: Bluetooth not powered on - connect to \(id) deferred until ready") - return - } log("Oura: connecting to \(id)") central.connect(p, options: nil) } + /// The user asked for the ring to be reconnected, from the Live console (#2305). Until now there was + /// NO user-facing ring reconnect anywhere: `connect(_:)` is only reached from the coordinator on + /// activation, and the only "connect" button a ring user could find on the Live screen was the WHOOP + /// scan — which is what the #2303 reporter tapped, and which ran a full 5/MG handshake under an + /// active ring. This is the ring's own affordance: drop whatever link exists and connect again, + /// through the same `connect(_:)` every activation uses. Nothing new goes to the ring. + /// + /// A connected link is cancelled and re-connected from `didDisconnectPeripheral` (CoreBluetooth + /// serialises the two; issuing the connect before the cancel lands would be racing it); a pending + /// connect is simply superseded by a fresh one. Also clears a `needsPairing` latch — a user reconnect + /// is the documented way out of that dead end — and never scans past a known ring. + public func reconnect() { + needsPairing = nil + intentionalDisconnect = false + failedReconnectAttempts = 0 + if let p = peripheral, p.state == .connected { + log("Oura: reconnect requested - dropping the current link and connecting again") + pendingUserReconnectID = reconnectID ?? p.identifier + central.cancelPeripheralConnection(p) + return + } + guard let id = reconnectID ?? peripheral?.identifier else { + log("Oura: reconnect requested - no known ring, scanning") + scan() + return + } + if let p = peripheral, p.state == .connecting { + central.cancelPeripheralConnection(p) // supersede the pending / standing connect + } + log("Oura: reconnect requested") + connect(id) + } + /// Tear down: cancel the connection, stop scanning, flush, clear all transient state. Idempotent. public func stop() { // A deliberate teardown (device switch / removal) must NOT auto-reconnect: mark it intentional and // drop the reconnect target so any pending backoff bails and no fresh one is scheduled (#912). intentionalDisconnect = true reconnectID = nil + pendingUserReconnectID = nil failedReconnectAttempts = 0 standingConnectAt = nil // cancelPeripheralConnection below also cancels any standing connect + linkPhase = .disconnected stopScan() pendingConnectID = nil stopReengageTimer() @@ -1284,9 +1603,11 @@ public final class OuraLiveSource: NSObject, ObservableObject { // cases -- user disconnect, source switch, Bluetooth off. A process kill cannot be covered at // all, which is a limit of the transport rather than of this fix. disableLiveHR() + clearAuthWatchdog() if let p = peripheral { central.cancelPeripheralConnection(p) } peripheral = nil writeCharacteristic = nil + notifyCharacteristic = nil // Drain BEFORE driver.stop() clears its anchor, so a pending event still gets a real anchored // time if one exists rather than always falling back to wall-clock at teardown. Same for a // hypnogram burst still accumulating (e.g. the session ended mid-drain). @@ -1302,10 +1623,13 @@ public final class OuraLiveSource: NSObject, ObservableObject { loggedFirstHR = false droppedFirstLiveHR = false loggedFirstTemp = false - loggedFirstSpo2 = false + loggedFirstSpo2.removeAll() loggedAnchor = false + pendingSyncTime = nil loggedTierBKinds.removeAll() loggedFeatureStatuses.removeAll() + manuallyDisabledFeatures.removeAll() + featureStatuses.removeAll() loggedProductInfo.removeAll() greenIbiAmpCount = 0 greenIbiAmpLengths.removeAll() @@ -1328,6 +1652,7 @@ public final class OuraLiveSource: NSObject, ObservableObject { chainedDrainTimer?.invalidate() chainedDrainTimer = nil reachedStreaming = false + liveHRArmedThisSession = false pendingInstallKey = nil adoptPhase = .idle batteryPct = nil @@ -1352,11 +1677,94 @@ public final class OuraLiveSource: NSObject, ObservableObject { } } + // MARK: - 0x20 user-info write EXPERIMENT + + /// Log every `0x5c` `user_information` record the ring sends. ALWAYS-ON: `0x5c` is rare (at most one + /// per full drain, and zero in some), so this costs nothing when nothing happens and is the only + /// readout the write experiment has. Nothing here persists, scores, or acts on the values. + /// + /// Field names are [open_oura-evt]'s INFERENCE (`_status: inferred`), so the raw four bytes are + /// printed first and the named split second — if the inference is wrong, the log still carries the + /// evidence to re-derive it. + private func observeUserInfoRecords(in bytes: [UInt8]) { + for rec in scanOuraUserInfoRecords(tlvBytes: bytes) { + let changed = lastUserInfoRecord.map { $0.raw != rec.raw } ?? false + let sinceWrite = lastUserInfoWrite.map { + " | FIRST 0x5c since our \($0.field.label)=\($0.valueHex) write \(Int(Date().timeIntervalSince($0.at)))s ago" + } ?? "" + log("Oura: 0x5c user_information raw=\(rec.rawHex) rt=\(rec.ringTimestamp)" + + (rec.isFirmwareDefault ? " (FIRMWARE DEFAULTS)" : " (NOT the firmware defaults)") + + (changed ? " CHANGED from \(lastUserInfoRecord?.rawHex ?? "?")" : "") + + " | inferred [open_oura-evt]: age=\(rec.ageYears) weight=\(rec.weightKg)kg" + + " sex=\(rec.sexCode) height=\(rec.heightCm)cm" + sinceWrite) + lastUserInfoRecord = rec + // One readout per write is the experiment; keep the marker so a SECOND 0x5c in the same + // connection is not also captioned as "the first since the write". + if !sinceWrite.isEmpty { lastUserInfoWrite = nil } + } + } + + /// Send one `0x20` user-info write. EXPERIMENT ONLY — reachable exclusively from the Test Centre + /// action; nothing in the connect flow or `OuraDriver` calls it, and it is never sent automatically. + /// + /// Returns false (and writes nothing) if the link is not ready, so the caller can say so rather than + /// silently appearing to have written. The value bytes are logged verbatim: this is a write to the + /// ring's own config, and a strap log that does not say exactly what went out is useless afterwards. + @discardableResult + func writeUserInfo(field: OuraUserInfoField, value: [UInt8]) -> Bool { + guard peripheral != nil, writeCharacteristic != nil else { + log("Oura: 0x20 user-info write SKIPPED - no connected ring / write characteristic") + return false + } + guard let cmd = try? OuraUserInfoWrite.command(field, value: value) else { + log("Oura: 0x20 user-info write REFUSED - \(value.count)B value, field \(field.label) wants \(field.valueByteCount)B") + return false + } + let valueHex = value.map { String(format: "%02x", $0) }.joined() + let frameHex = cmd.bytes.map { String(format: "%02x", $0) }.joined() + log("Oura: 0x20 user-info WRITE field=\(field.label) value=\(valueHex) frame=\(frameHex)" + + " - EXPERIMENT; last 0x5c seen this connection: \(lastUserInfoRecord?.rawHex ?? "none")") + lastUserInfoWrite = (field: field, valueHex: valueHex, at: Date()) + write([cmd]) + return true + } + + /// Send one `2f 03 22 ` feature-mode write. EXPERIMENT ONLY — reachable exclusively from + /// the Test Centre "OURA" section; nothing in the connect flow or `OuraDriver` calls it, and it is + /// never sent automatically. UNVALIDATED per OURA_PROTOCOL.md s7.5. + /// + /// Clears any already-logged status for this feature first, so the read-status probe this method + /// also sends is guaranteed to log a fresh line — the connect-time SpO2/real_steps auto-probe (or an + /// earlier manual call) may have already consumed `logFeatureStatus`'s once-per-connection dedup for + /// this exact feature id. + @discardableResult + func writeFeatureMode(feature: UInt8, mode: UInt8) -> Bool { + guard peripheral != nil, writeCharacteristic != nil else { + log("Oura: feature-mode write SKIPPED - no connected ring / write characteristic") + return false + } + let cmd = OuraCommands.setFeatureMode(feature, mode: mode) + let frameHex = cmd.bytes.map { String(format: "%02x", $0) }.joined() + log("Oura: feature-mode WRITE feature=0x\(String(feature, radix: 16)) mode=\(mode) frame=\(frameHex)" + + " - EXPERIMENT, unvalidated on NOOP hardware (OURA_PROTOCOL.md s7.5)") + loggedFeatureStatuses.remove(Int(feature)) + if mode == 0x00 { + manuallyDisabledFeatures.insert(Int(feature)) + } else { + manuallyDisabledFeatures.remove(Int(feature)) + } + write([cmd, OuraCommands.featureReadStatus(feature)]) + return true + } + /// Advance the driver with a transition and write whatever it asks for next. private func advance(_ transition: OuraTransition) { guard let driver else { return } let commands = driver.nextStep(after: transition) write(commands) + // `.ready` is the step that writes `get_nonce`; from here the session waits on the ring, and + // nothing else is armed until `auth OK`. Start the clock on that wait (#2304). + if transition == .ready, driver.phase == .authenticating { armAuthWatchdog() } // Surface the driver's coarse phase honestly into the UI state. switch driver.phase { case .needsKeyInstall: @@ -1373,14 +1781,16 @@ public final class OuraLiveSource: NSObject, ObservableObject { case .streaming: if !reachedStreaming { reachedStreaming = true + linkPhase = .authenticated adoptPhase = .streaming // re-auth after an install (or a normal auth) reached the stream: adoption complete pendingInstallKey = nil // an OK ack already persisted the key; nothing left in flight - // The driver's own auth-success path (OuraDriver.nextStep, .authCompleted(.success)) has no - // suspend awareness - it unconditionally re-runs the live-HR enable triplet on EVERY connect, - // including a reconnect during a suspended night (08-17/18: 25 reconnects, green never hit - // zero any hour). Only claim the stream is wanted, and only start the keep-alive, when the - // screen is actually on; startReengageTimer's own suspended guard sends the explicit disable - // that undoes what the triplet above just armed. + // The driver ran its live-HR enable triplet only if `liveHRWanted` was set at auth + // (`handleSecure`, `.authStatus`); a suspended connect skipped it and arrives here having + // written nothing to the daytime-HR feature. (Before that flag the triplet ran on EVERY + // connect — 08-17/18: 25 reconnects, green never hit zero any hour — and + // `startReengageTimer()`'s suspended guard sent the disable that undid it.) Only claim the + // stream is wanted, and only start the keep-alive, when the screen is actually on. + if driver.liveHRWanted { liveHRArmedThisSession = true } if !liveHRSuspended { if feedsLive { live.streamingLiveHR = true } // drive the green menu-bar STREAMING pill (no WHOOP bond) log("Oura: live-HR enabled - streaming HR / IBI") @@ -1397,8 +1807,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, @@ -1464,6 +1876,7 @@ public final class OuraLiveSource: NSObject, ObservableObject { // Re-auth with the freshly-installed key. The driver returns enable-notify + get-nonce; the nonce // response then flows through the normal handleSecure -> advance path to streaming. write(driver.keyInstallAcknowledged()) + if driver.phase == .authenticating { armAuthWatchdog() } } /// A fresh 16-byte application key for the adopt install, from the system CSPRNG. Per OURA_PROTOCOL.md @@ -1539,6 +1952,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) } @@ -1642,6 +2059,7 @@ public final class OuraLiveSource: NSObject, ObservableObject { log("Oura: live-HR push arrived while SUSPENDED (\(hr.bpm) bpm) - dhr_disable did not " + "stop the stream, self-healing now") } + liveHRArmedThisSession = true // the push is the proof; let the disable go out startReengageTimer() } // Drop the first (settling) live-HR sample of the session — it is frequently an artifact. @@ -1689,6 +2107,10 @@ public final class OuraLiveSource: NSObject, ObservableObject { case .battery(let bat): batteryPct = bat.percent + // #2075: publish the ring's charge into the shared LiveState too, beside `ouraWearState`. + // Holding it only here left the Live Console with nothing but the WHOOP's `batteryPct`, + // so it showed the strap's charge under the ring's name. + if feedsLive { live.ouraBatteryPct = bat.percent } onBattery(bat.percent) log("Oura: battery \(bat.percent)%") enqueue([e], ts: now) @@ -1707,9 +2129,8 @@ public final class OuraLiveSource: NSObject, ObservableObject { } case .spo2(let s): - if !loggedFirstSpo2 { - loggedFirstSpo2 = true - log("Oura: first SpO2 decoded (last night) - value \(s.value) (\(s.unit))") + if loggedFirstSpo2.insert(s.channel).inserted { + log("Oura: " + OuraSpO2Channel.firstDecodedLogLine(value: s.value, unit: s.unit)) } if let ts = driver.unixSeconds(forRingTimestamp: s.ringTimestamp) { enqueue([e], ts: ts) @@ -1955,19 +2376,34 @@ public final class OuraLiveSource: NSObject, ObservableObject { /// cannot enable these offline (server ClientConfiguration gate), so a `subscription == 0` here is the /// honest "not a bug, it's a gate" reading. Never scored, never stored. private func logFeatureStatus(_ st: OuraFeatureStatus) { + featureStatuses[st.feature] = st guard loggedFeatureStatuses.insert(st.feature).inserted else { return } let name: String switch UInt8(truncatingIfNeeded: st.feature) { case OuraCommands.featureSpO2: name = "SpO2 (0x04)" case OuraCommands.featureRealSteps: name = "real_steps (0x0b)" case OuraCommands.featureDaytimeHR: name = "daytime-HR (0x02)" + case OuraCommands.featureExerciseHR: name = "exercise-HR (0x03)" + case OuraCommands.featureCvaPpg: name = "cva-ppg (0x0d)" default: name = "0x\(String(st.feature, radix: 16))" } // A gated/unavailable feature reports ALL-ZERO (mode/status/state); the streaming daytime-HR, by // contrast, reads mode=1 status=0x11 state=2. Flag the all-zero case as the honest "cloud never // enabled it" — NOT `subscription==0` alone, since daytime-HR is subscription=0 yet active. + // + // BUT all-zero is no longer proof of that: `writeFeatureMode` (s7.5) can now produce the exact + // same bit pattern locally. Real-hardware confirmed 2026-09-11 — disabling SpO2 flipped mode 1→0, + // and the unqualified "cloud never enabled it" claim below would have been false on that very + // read. Only make the cloud-gate claim when nothing local produced this state. let off = st.mode == 0 && st.status == 0 && st.state == 0 - let gate = off ? " - INACTIVE (server-gated off; the cloud never enabled it, not emitted offline)" : "" + let gate: String + if off && manuallyDisabledFeatures.contains(st.feature) { + gate = " - OFF (matches the manual disable just sent this connection; not evidence of a cloud gate either way)" + } else if off { + gate = " - INACTIVE (server-gated off; the cloud never enabled it, not emitted offline)" + } else { + gate = "" + } // Name the enum fields so the log reads plainly (OURA_PROTOCOL.md s7.1 [ring4-ble]) — e.g. a gated // feature prints `mode=0 (off) … subscription=0 (off)`, the active daytime-HR `mode=1 (automatic)`. log("Oura: feature status \(name) mode=\(st.mode) (\(Self.featureModeName(st.mode))) status=\(st.status) state=\(st.state) subscription=\(st.subscription) (\(Self.subscriptionName(st.subscription)))\(gate)") @@ -1988,6 +2424,110 @@ public final class OuraLiveSource: NSObject, ObservableObject { } } + // MARK: - Auth watchdog (#2304) + + /// Settle time between disabling and re-enabling the notify subscription on the `.toggleNotify` + /// step — open_ring's value for the same CCCD round-trip. + private static let authToggleGap: TimeInterval = 2.5 + + /// Start (or restart) the nonce clock: called on every `get_nonce` write of the session — the `.ready` + /// step, the post-install re-auth, and the watchdog's own re-sends — so each wait is measured from + /// the request it belongs to. One-shot; `authWatchdogFired` re-arms it after an escalation. + private func armAuthWatchdog() { + authWatchdogTimer?.invalidate() + authNonceRequestedAt = Date() + let t = Timer.scheduledTimer(withTimeInterval: OuraAuthWatchdog.nonceTimeout, repeats: false) { [weak self] _ in + Task { @MainActor in self?.authWatchdogFired() } + } + authWatchdogTimer = t + } + + /// The nonce arrived, or the session ended: nothing left to watch. + private func clearAuthWatchdog() { + authWatchdogTimer?.invalidate() + authWatchdogTimer = nil + authNonceRequestedAt = nil + } + + /// Ask the driver for the `get_nonce` again (only while it is still `.authenticating`) and restart + /// the clock on it. A driver that has moved on returns nil and the watchdog stays cleared. + private func resendAuthNonce() { + guard let driver, let cmd = driver.authNonceRetryCommand() else { clearAuthWatchdog(); return } + write([cmd]) + armAuthWatchdog() + } + + /// The nonce timer expired with no nonce (#2304). Print the one line that says what was not + /// answered — always-on, because this is exactly the rare-event evidence a report without Test + /// Centre lacks — then take the next bounded step from `OuraAuthWatchdog`: re-send once, toggle the + /// subscription and re-send once, then drop the link and let the ordinary reconnect backoff take + /// over. Never `announceNeedsPairing`: silence is not an auth verdict, and the "re-pair it in the + /// Oura app" dead end is reserved for an explicit non-success status. + /// + /// `canSendWriteWithoutResponse` is on the line because every Oura write goes out `.withoutResponse` + /// and nothing reads that flag: "the ring ignored `get_nonce`" and "CoreBluetooth had no room to + /// send it" produce the identical `-> get_nonce` log line. The boolean is the one thing that tells + /// the two apart after the fact. + private func authWatchdogFired() { + authWatchdogTimer = nil + // Only a session still waiting on the nonce has anything to escalate. A driver that moved on + // (the nonce landed between the timer firing and this running, a stop, a disconnect) is left alone. + guard let driver, driver.phase == .authenticating, let requestedAt = authNonceRequestedAt, + let p = peripheral else { + clearAuthWatchdog() + return + } + let elapsed = Date().timeIntervalSince(requestedAt) + let silent = "Oura: no auth nonce \(Int(elapsed))s after get_nonce - ring silent on the notify channel " + + "(canSendWriteWithoutResponse=\(p.canSendWriteWithoutResponse))" + switch OuraAuthWatchdog.step(secondsSinceNonceRequest: elapsed, attempt: authEscalations) { + case .wait: + // Fired early (clock adjustment / re-arm race): wait out the remainder, no escalation. + let remaining = max(0.5, OuraAuthWatchdog.nonceTimeout - elapsed) + let t = Timer.scheduledTimer(withTimeInterval: remaining, repeats: false) { [weak self] _ in + Task { @MainActor in self?.authWatchdogFired() } + } + authWatchdogTimer = t + case .resendNonce: + authEscalations = 1 + log(silent + " - re-sending get_nonce (1/3)") + resendAuthNonce() + case .toggleNotify: + authEscalations = 2 + guard let nc = notifyCharacteristic else { + // No subscription handle to toggle (never discovered): nothing to re-establish, so go + // straight to the last step rather than pretend a toggle happened. + log(silent + " - no notify characteristic to toggle, dropping the link (3/3)") + authEscalations = 3 + clearAuthWatchdog() + central.cancelPeripheralConnection(p) + return + } + log(silent + " - toggling the notify subscription and re-sending get_nonce (2/3)") + authToggleInFlight = true + p.setNotifyValue(false, for: nc) + // The re-enable's callback (`didUpdateNotificationStateFor`) re-sends `get_nonce` and re-arms + // the clock; until then the clock keeps running from THIS moment so a toggle the ring never + // acknowledges still reaches the drop step instead of parking the session again. + armAuthWatchdog() + DispatchQueue.main.asyncAfter(deadline: .now() + Self.authToggleGap) { [weak self] in + guard let self, self.authToggleInFlight, let p = self.peripheral, + let nc = self.notifyCharacteristic else { return } + p.setNotifyValue(true, for: nc) + } + case .dropLink: + authEscalations = 3 + silentSessionsInARow += 1 + log(silent + " - dropping the link; the ordinary reconnect takes over (3/3, silent session " + + "\(silentSessionsInARow) in a row)") + clearAuthWatchdog() + authToggleInFlight = false + // NOT an intentional teardown: `intentionalDisconnect` stays false and `reconnectID` stays + // set, so `didDisconnectPeripheral` schedules the normal backoff reconnect (#912). + central.cancelPeripheralConnection(p) + } + } + // MARK: - Re-engagement timer (daytime-HR auto-reverts ~20s) /// Arm the re-engage tick — unless the screen has already been off past the grace window. That guard @@ -2055,9 +2595,15 @@ public final class OuraLiveSource: NSObject, ObservableObject { /// intentional teardown must hand the ring back out of daytime mode for the same reason a suspend /// must. The `.streaming` guard covers both callers -- there is nothing to disable if the session /// never got that far. + /// Additionally gated on `liveHRArmedThisSession`: a connect made while suspended never armed + /// daytime HR (the driver skipped its triplet), so there is nothing to hand back and the `mode 0x00` + /// write would be a gratuitous state change on a ring we are trying to leave alone. A live push while + /// suspended marks the session armed first (the ring is evidently streaming), so the self-heal path + /// still sends the disable. private func disableLiveHR() { - guard let driver, driver.phase == .streaming else { return } + guard let driver, driver.phase == .streaming, liveHRArmedThisSession else { return } write([OuraCommands.liveHRDisable(), OuraCommands.liveHRUnsubscribe()]) + liveHRArmedThisSession = false if feedsLive { live.streamingLiveHR = false } } @@ -2080,6 +2626,7 @@ public final class OuraLiveSource: NSObject, ObservableObject { } guard let driver, reachedStreaming, driver.phase != .fetchingHistory else { return } write(driver.reengageLiveHRCommands()) + liveHRArmedThisSession = true // Live-HR watchdog: if the stream has gone silent past the grace window while we were WORN, the // ring came off the finger (no "removed" event exists) -> NOT WORN. Only meaningful once we have // seen at least one live beat this session. @@ -2179,13 +2726,41 @@ extension OuraLiveSource: @preconcurrency CBCentralManagerDelegate { public func centralManagerDidUpdateState(_ central: CBCentralManager) { switch central.state { case .poweredOn: - // Replay any intent that arrived before the radio was ready. - if let id = pendingConnectID, let p = seenPeripherals[id] { - pendingConnectID = nil - central.connect(p, options: nil) - } else if scanning { + // Replay any intent that arrived before the radio was ready. The retrieve is re-issued + // HERE rather than trusting the one that ran while the state was still `.unknown`: that one + // answers nothing even for a bonded ring, which used to drop this replay into the scan branch + // and leave the ring unreachable for half an hour off-screen, where iOS throttles scanning + // hard (#2433). + let parked = pendingConnectID + let held = parked.flatMap { seenPeripherals[$0] } + let fresh = held == nil + ? parked.flatMap { central.retrievePeripherals(withIdentifiers: [$0]).first } + : nil + switch PendingConnect.replay(isPending: parked != nil, + isHeld: held != nil, + didRetrieve: fresh != nil, + isScanning: scanning) { + case .connect: + if let id = parked, let p = held ?? fresh { + pendingConnectID = nil + seenPeripherals[id] = p + peripheral = p + p.delegate = self + log("Oura: connecting to \(id) - targeted (the radio was not up when it was asked for)") + central.connect(p, options: nil) + } + case .scan: central.scanForPeripherals(withServices: [Self.service], options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]) + case .discover: + // Nothing to connect and no scan armed, because the intent was parked before the + // retrieve. `scan()` arms `scanning` and `didDiscover` connects the parked id on sight. + // Named, because this whole defect was read off log lines: a line that says a + // connect could not be resolved is only useful if it says which device. + if let id = parked { log("Oura: \(id) is not resolvable yet - discovering to find it") } + scan() + case .idle: + break } default: // Radio off / unauthorized / resetting -> the link is not live. @@ -2199,15 +2774,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 }) { @@ -2224,6 +2804,7 @@ extension OuraLiveSource: @preconcurrency CBCentralManagerDelegate { public func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { log("Oura: connected - discovering services") + linkPhase = .authenticating // link up; discovery + the nonce handshake follow failedReconnectAttempts = 0 // a real connection clears the reconnect backoff (#912) standingConnectAt = nil // whatever was outstanding has landed peripheral.delegate = self @@ -2236,18 +2817,31 @@ extension OuraLiveSource: @preconcurrency CBCentralManagerDelegate { // ring actually sends (raw bytes per kind, decoded MET for 0x50) so the layouts can be validated // against real captures. It can never leak a value into scoring: OuraStreamMapping drops // .tierB/.activityInfo unconditionally - the Tier-discipline gate that matters lives there, not here. + // Packed-notification A/B: the mask is decided here, once per session, and named on its own line + // ONLY when it is not the default - the `-> notify_all(ff)` write line then confirms it went out. + let notificationMask = notifyMaskFull() ? OuraCommands.notificationMaskFull : OuraCommands.notificationMaskDefault + if notificationMask != OuraCommands.notificationMaskDefault { + log("Oura: SetNotification mask \(String(format: "%02x", notificationMask)) this session (packed-notification A/B, Test Centre) - default is \(String(format: "%02x", OuraCommands.notificationMaskDefault))") + } driver = OuraDriver(ringGen: ringGen, authKey: authKey().map { [UInt8]($0) }, allowTierB: true, - allowKeyInstall: adoptIntent) + allowKeyInstall: adoptIntent, + notificationMask: notificationMask) reachedStreaming = false + clearAuthWatchdog() // a fresh session starts with a clean escalation count + authEscalations = 0 + authToggleInFlight = false + liveHRArmedThisSession = false loggedFirstHR = false droppedFirstLiveHR = false loggedFirstTemp = false - loggedFirstSpo2 = false + loggedFirstSpo2.removeAll() loggedAnchor = false + pendingSyncTime = nil loggedTierBKinds.removeAll() loggedFeatureStatuses.removeAll() + manuallyDisabledFeatures.removeAll() loggedProductInfo.removeAll() greenIbiAmpCount = 0 greenIbiAmpLengths.removeAll() @@ -2280,12 +2874,18 @@ extension OuraLiveSource: @preconcurrency CBCentralManagerDelegate { log("Oura: persisted resume cursor \(loadedCursor) exceeds the plausibility ceiling (pre-fix garbage) - full pull") OuraHistoryCursorStore.save(0, deviceId: deviceId) } + // The anchor from whatever session last adopted one — loaded BEFORE this session gets a chance to + // adopt its own, so `commitResumeCursor` can compare the two (#2097). `currentSyncAnchor` starts + // nil each session; nothing carries a stale in-memory anchor into a fresh connect. + previousSyncAnchor = OuraSyncAnchorStore.read(deviceId: deviceId) + currentSyncAnchor = nil peripheral.discoverServices([Self.service]) } public func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { log("Oura: WARNING failed to connect - \(error?.localizedDescription ?? "unknown error")") + linkPhase = .disconnected if feedsLive { live.connected = false; live.streamingLiveHR = false } // The ring wiped its bond (re-paired in the Oura app, or a firmware reset). CoreBluetooth surfaces // this as a stable CBError, and re-issuing connect just loops the same stale-pairing failure and @@ -2323,16 +2923,21 @@ extension OuraLiveSource: @preconcurrency CBCentralManagerDelegate { dropUnanchoredHypnogramBursts() // never wall-clock a night's time axis; they re-arrive next drain driver?.stop() driver = nil + clearAuthWatchdog() // a link that drops mid-handshake takes this path, never the escalation + authToggleInFlight = false reassembler.reset() wearTracker.reset(); loggedWearState = nil; lastLivePulseAt = nil writeCharacteristic = nil + notifyCharacteristic = nil loggedFirstHR = false droppedFirstLiveHR = false loggedFirstTemp = false - loggedFirstSpo2 = false + loggedFirstSpo2.removeAll() loggedAnchor = false + pendingSyncTime = nil loggedTierBKinds.removeAll() loggedFeatureStatuses.removeAll() + manuallyDisabledFeatures.removeAll() loggedProductInfo.removeAll() greenIbiAmpCount = 0 greenIbiAmpLengths.removeAll() @@ -2346,6 +2951,7 @@ extension OuraLiveSource: @preconcurrency CBCentralManagerDelegate { lastPhaseUtc = nil lastPhaseCodeCount = 0 reachedStreaming = false + liveHRArmedThisSession = false pendingInstallKey = nil // A disconnect MID-install is an honest failure (no ack came); a disconnect after streaming leaves // the completed `.streaming` outcome intact so the wizard's success transition isn't undone. @@ -2354,6 +2960,13 @@ extension OuraLiveSource: @preconcurrency CBCentralManagerDelegate { flush() if feedsLive { live.connected = false; live.streamingLiveHR = false } if self.peripheral?.identifier == peripheral.identifier { self.peripheral = nil } + linkPhase = .disconnected + // A user reconnect (#2305) cancelled this link on purpose: connect again now, not on the backoff. + if let id = pendingUserReconnectID { + pendingUserReconnectID = nil + connect(id) + return + } // Auto-reconnect on an INVOLUNTARY drop (#912): the paired ring went out of range or the link timed // out. Re-issue a connect on the backoff so it comes back on its own, exactly like the WHOOP strap. // A deliberate `stop()` set `intentionalDisconnect`/cleared `reconnectID`, so this is a no-op there. @@ -2401,6 +3014,7 @@ extension OuraLiveSource: @preconcurrency CBPeripheralDelegate { log("Oura: write characteristic NOT FOUND - cannot drive the ring") } if let nc = chars.first(where: { $0.uuid == Self.notifyChar }) { + notifyCharacteristic = nc log("Oura: notify characteristic found - enabling notifications") peripheral.setNotifyValue(true, for: nc) } else { @@ -2416,6 +3030,20 @@ extension OuraLiveSource: @preconcurrency CBPeripheralDelegate { log("Oura: WARNING enabling notifications FAILED - \(error.localizedDescription) - ring will send no data") return } + // The auth watchdog's off/on toggle (#2304) lands here twice — once for the disable, once for the + // re-enable — and must NOT replay `.ready`: the driver is still `.authenticating`, so the only + // thing to do on the re-enable is ask for the nonce again, on the freshly re-established + // subscription. The `.ready` replay below is for the first enable of a session only. + if authToggleInFlight { + guard characteristic.isNotifying else { + log("Oura: notifications disabled for the auth toggle - re-enabling in \(Self.authToggleGap)s") + return + } + authToggleInFlight = false + log("Oura: notifications re-enabled (isNotifying=true) after the auth toggle - re-sending get_nonce") + resendAuthNonce() + return + } log("Oura: notifications enabled (isNotifying=\(characteristic.isNotifying)) - beginning auth") // Notifications are live: tell the driver we're ready. It returns the auth-nonce request (or, with // no key, drives the honest needs-pairing path). @@ -2463,6 +3091,20 @@ extension OuraLiveSource: @preconcurrency CBPeripheralDelegate { let resp = OuraFraming.parseSyncTimeResponse(syncFrame.body) { handleSyncTimeResponse(resp) } + // The `0x21` user-info reply (`21 02 `) to a `0x20` write — EXPERIMENT ONLY, and + // peeked the same non-destructive way as the 0x11/0x13/0x0D frames above (op 0x21 is below the + // event-tag range >= 0x41, so it round-trips through the TLV decoder as a harmless unknown-tag + // no-op). ALWAYS-ON rather than Test-Centre-gated: a 0x21 can only appear if this build wrote a + // 0x20, which nothing but an explicit Test Centre action does, so it costs nothing when nothing + // happens and is exactly the evidence that is missing otherwise. Reports only what the ring said + // — `result` is the ring accepting the WRITE, and says nothing about whether 0x5c moved. + if let ackFrame = frames.first(where: { $0.op == 0x21 }), + let ack = parseOuraUserInfoAck([ackFrame.op, UInt8(ackFrame.body.count)] + ackFrame.body) { + let sent = lastUserInfoWrite.map { " (we wrote \($0.field.label)=\($0.valueHex) \(Int(Date().timeIntervalSince($0.at)))s ago)" } ?? " (no 0x20 write recorded this connection)" + log("Oura: 0x20 user-info ACK field=\(ack.field.label) result=\(ack.result)" + + (ack.isSuccess ? " (accepted)" : " (REFUSED)") + sent + + " - ring accepted the write only; whether 0x5c changes is a separate read") + } // The `0x0D` GetBattery response is ALSO an outer frame (never a TLV record, s6.10), detected the // same non-destructive way as the 0x11 summary: its op is below the event-tag range (>= 0x41), so // it round-trips safely through the TLV decoder as an "unknown tag" no-op if left unfiltered. Routed @@ -2480,12 +3122,16 @@ extension OuraLiveSource: @preconcurrency CBPeripheralDelegate { let hex = frame.body.map { String(format: "%02x", $0) }.joined(separator: " ") guard loggedProductInfo.insert("\(frame.op):\(hex)").inserted else { continue } let ascii = String(bytes: frame.body.map { (0x20...0x7e).contains($0) ? $0 : 0x2e }, encoding: .ascii) ?? "" - log("Oura: product-info reply op=0x\(String(format: "%02x", frame.op)) (\(frame.body.count)B) raw: \(hex) | ascii: \(ascii)") + // #2092: decode BEFORE logging (was after) so the log line can tell a serial page from a + // hardware page — decode itself is unchanged, only reordered. + let decoded = OuraDecoders.productInfoString(frame.body) + let safe = Self.logSafeProductInfo(hex: hex, ascii: ascii, decoded: decoded) + log("Oura: product-info reply op=0x\(String(format: "%02x", frame.op)) (\(frame.body.count)B) raw: \(safe.hex) | ascii: \(safe.ascii)") // The two GetProductInfo pages both arrive under op 0x19; tell them apart by content: // • hardware page ("BLB_03") → resolves a generation → correct the model (#772). // • serial page ("2H3B2405003655", no "_NN" gen marker) → the ring's STABLE identity → surface it // so the app can re-point this device onto its `oura-` id (#771). - if let str = OuraDecoders.productInfoString(frame.body) { + if let str = decoded { if let gen = OuraRingGen.from(hardwareId: str) { if gen != ringGen { log("Oura: generation from hardware id \(str) is \(gen.displayName) (was \(ringGen.displayName)) - correcting model") @@ -2503,41 +3149,89 @@ extension OuraLiveSource: @preconcurrency CBPeripheralDelegate { } // Any non-secure outer frames in the same notification are TLV records; fall through to decode. // The 0x25 ack (if any) is consumed above, so it never reaches here. - let tlvBytes = frames.filter { $0.op != OuraFraming.secureSessionOp && $0.op != Self.setAuthKeyRespOp } - .flatMap { [$0.op, UInt8($0.body.count)] + $0.body } + let nonSecureFrames = frames.filter { $0.op != OuraFraming.secureSessionOp && $0.op != Self.setAuthKeyRespOp } + let tlvBytes = nonSecureFrames.flatMap { [$0.op, UInt8($0.body.count)] + $0.body } if !tlvBytes.isEmpty { - rawDump?.record(bytes: tlvBytes) + let dumpBytes = Self.rawDumpBytes(nonSecureFrames) + if !dumpBytes.isEmpty { + rawDump?.record(bytes: dumpBytes) + } + observeUserInfoRecords(in: tlvBytes) ingestHistory(driver.ingest(notification: tlvBytes, reassembler: reassembler)) } return } - // No secure frame in this notification: treat the whole value as TLV record bytes. - rawDump?.record(bytes: bytes) + // No secure frame in this notification: treat the whole value as TLV record bytes. `frames` was + // parsed from this WHOLE notification (line ~2495), so `rawDumpBytes(fromNotification:frames:)` + // appends whatever incomplete trailing frame `parseOuterFrames` had to leave behind (PR #2090 + // review) — otherwise the sidecar silently loses exactly the wire bytes it exists to preserve. + let dumpBytes = Self.rawDumpBytes(fromNotification: bytes, frames: frames) + if !dumpBytes.isEmpty { + rawDump?.record(bytes: dumpBytes) + } + observeUserInfoRecords(in: bytes) 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") + } + // The freshest pair this session has adopted (mirrors `driver.adoptSyncTimeAnchor` always + // overwriting), kept for `commitResumeCursor`'s continuity check and persisted so the NEXT + // connection can compare against it as its own `previousSyncAnchor` (#2097). + currentSyncAnchor = (ringTicks: rt, unixSeconds: receivedAt) + OuraSyncAnchorStore.save(ringTicks: rt, unixSeconds: receivedAt, deviceId: deviceId) + 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 ring-time + /// (OuraDriver.syncTimeAnchorCandidate — raw ticks vs seconds×10: exactly one must be plausible, or, + /// on a ring under ~5 days of clock where both are, exactly one must sit within an hour of the floor). + /// 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). The retry's floor (`drain.maxSeenRingTime`) is fed by records that + /// land AFTER the reply, so it may sit a few ticks past it — the candidate rule allows that trail; the + /// old rule did not, and adopted ×10 on a young ring (#2239). 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 } } @@ -2545,11 +3239,23 @@ extension OuraLiveSource: @preconcurrency CBPeripheralDelegate { private func handleSecure(_ routing: OuraDriver.SecureRouting) { switch routing { case .nonce(let nonce): + clearAuthWatchdog() // the ring answered: the healthy path, and the watchdog's only exit + silentSessionsInARow = 0 log("Oura: auth nonce received - submitting proof") advance(.nonceReceived(nonce)) case .authStatus(let status): if status.isSuccess { - log("Oura: auth OK - enabling live HR") + // Decide HERE, before the driver's next step, whether this connect arms daytime HR at all. + // A reconnect during a suspended night used to run the enable triplet regardless and have + // `startReengageTimer()` undo it one second later — `DHR_mode:3` → `DHR_mode:0` on the + // ring for every overnight visit. On a Ring 5 overnight capture (#2075's reporter, + // 2026-09-16) four of the five interruptions of the ring's own SpO2 session began on the + // exact second of such a visit (3–49 min each, ≈ 2 h of a 9 h night). Suspended ⇒ the + // driver goes straight to `.streaming` with no daytime-HR write; the log says which. + let wanted = !liveHRSuspended + driver?.liveHRWanted = wanted + log(wanted ? "Oura: auth OK - enabling live HR" + : "Oura: auth OK - live HR suspended (screen off), daytime HR left untouched") } else { log("Oura: WARNING auth status \(status.rawValue)") } @@ -2638,3 +3344,35 @@ enum OuraHistoryCursorStore { UserDefaults.standard.set(Int(cursor), forKey: key(deviceId: deviceId)) } } + +// MARK: - Oura SyncTime anchor persistence (#2097) + +/// Persists the Oura ring's `0x13 SyncTime` (ring-ticks, wall-clock) anchor pair across connects, so a +/// later connection can check whether the ring's own clock ran continuously since the last one — +/// distinguishing a genuine power-cycle from a second BLE client (e.g. the Oura app) having served the +/// ring in between, which otherwise looks identical to `OuraHistoryDrain.sawPreResumeData` +/// (`OuraHistoryDrain.anchorsAreContinuous` does the actual comparison; this type only persists the +/// inputs). Not sensitive — an opaque clock pairing, not a credential — so plain `UserDefaults`, same +/// reasoning as `OuraHistoryCursorStore`. +enum OuraSyncAnchorStore { + private static func ticksKey(deviceId: String) -> String { "com.noop.oura.syncAnchorTicks.\(deviceId)" } + private static func secondsKey(deviceId: String) -> String { "com.noop.oura.syncAnchorSeconds.\(deviceId)" } + + /// The persisted anchor for `deviceId`, or nil if none is stored yet (a ring never anchored before, + /// or an install that predates this feature). + static func read(deviceId: String) -> (ringTicks: UInt32, unixSeconds: Int64)? { + let defaults = UserDefaults.standard + guard let ticksRaw = defaults.object(forKey: ticksKey(deviceId: deviceId)) as? Int, + let secondsRaw = defaults.object(forKey: secondsKey(deviceId: deviceId)) as? Int else { + return nil + } + return (ringTicks: UInt32(clamping: ticksRaw), unixSeconds: Int64(secondsRaw)) + } + + /// Store the freshest anchor for `deviceId`. + static func save(ringTicks: UInt32, unixSeconds: Int64, deviceId: String) { + let defaults = UserDefaults.standard + defaults.set(Int(ringTicks), forKey: ticksKey(deviceId: deviceId)) + defaults.set(Int(unixSeconds), forKey: secondsKey(deviceId: deviceId)) + } +} diff --git a/Strand/BLE/OuraRawDump.swift b/Strand/BLE/OuraRawDump.swift index 335b515b01..de9d135a04 100644 --- a/Strand/BLE/OuraRawDump.swift +++ b/Strand/BLE/OuraRawDump.swift @@ -18,18 +18,70 @@ import WhoopStore /// decoded sidecars there is NO dedup high-water: a re-served record is still evidence the ring re-sent it, /// and the offline reframer collapses duplicates by (tag, ring-time). /// +/// LIFETIME: the live file holds exactly ONE capture session. At the START of a session — the first time +/// this object resolves its file — the previous session is rolled into a numbered generation ring +/// (`.1` … `.N`, newest first) and never touched again while the session runs. One `OuraRawDump` is built +/// per `OuraLiveSource`, and a source survives BLE reconnects (`SourceCoordinator` returns early when the +/// active strap is unchanged), so a drain that disconnects and re-engages several times still lands whole +/// in one file. +/// +/// WHY NOT a size threshold, which is what this used to do: a threshold can fire MID-DRAIN, and on +/// 2026-08-09 it did — 25 MB rolled at 07:06:58 during the morning offload, carrying 32,005 records of +/// that drain and 71 of the night's 75 `0x5D` records into the `.1`. Lowering the threshold makes that +/// MORE frequent, not less; only rolling on a session boundary removes it. The corpus stays bounded +/// because a session is bounded: an overnight drain is ~32,000 records ≈ 4 MB. +/// +/// WHY A RING AND NOT ONE `.1`, which is what the first version of this did: **retaining a single +/// generation is not enough to survive an ordinary morning.** Measured 2026-08-10 — the app relaunched +/// twice after wake (07:45 and 08:17 local), so by the time the capture was pulled off the phone BOTH +/// retained slots held post-wake sessions of 87 KB and 44 KB, and the night's 4 MB was gone. Session +/// scoping fixed the mid-drain roll and then lost the night a different way: rotation ate a night every +/// ~5–6 days at random; one-deep session scoping ate it every morning the app restarted twice, which is +/// the normal morning. An iOS app is relaunched whenever the user opens it, so the number of session +/// boundaries between a night and its export is not something this class can predict — it can only keep +/// enough of them. +/// +/// Two rules keep the ring honest, and both exist because of that measurement: +/// * a session under `minRolledBytes` is DISCARDED rather than rolled, so a connect that captured +/// essentially nothing (the 44 KB one above) cannot evict a real overnight capture; +/// * the retained generations are pruned oldest-first to `maxRetainedBytes`, so the ring is bounded in +/// BYTES and not merely in file count (each generation may be up to `maxSessionBytes`). +/// +/// `TestBundleAssembler` ships the LARGEST generation for this kind, so the export carries the night even +/// when the live file is a fresh 30-second session — which is exactly the state a morning export is taken +/// in. Keeping generations on disk without that change would only help someone with a USB cable. +/// /// Location: `/OpenWhoop/Diagnostics/oura-raw-.jsonl` — beside the SQLite. final class OuraRawDump { private let deviceId: String private let log: (String) -> Void + private let directory: URL? private var fileURL: URL? private var resolveFailed = false private var announced = false + private var sessionBytes = 0 + private var ceilingHit = false + + /// Hard ceiling on ONE session's file. Never a rotation — a session that somehow reaches this stops + /// appending and says so once, because rolling mid-session is the exact failure this class was changed + /// to remove. Far above a real overnight drain (~4 MB), so in practice it never fires; it exists so a + /// pathological backlog cannot fill the disk. + static let maxSessionBytes = 25 * 1024 * 1024 + + /// How many completed sessions are kept beside the live file, as `.1` (newest) … `.N` (oldest). Eight + /// covers a morning of app relaunches with room to spare, and at a typical ~4 MB overnight session the + /// whole ring is ~32 MB — well inside `maxRetainedBytes`, which is what actually bounds it. + static let retainedGenerations = 8 + + /// A finished session smaller than this is deleted instead of rolled. It is not a judgement about which + /// captures matter — it is the one rule that stops a burst of trivial reconnect sessions from flushing + /// the ring. 16 KB is ~100 records: a connect that drained nothing. Deliberately far below the 44 KB + /// session that destroyed the 2026-08-10 capture, so the rule is a backstop and the ring does the work. + static let minRolledBytes = 16 * 1024 - /// Rotate the sidecar past this size (keeping one previous ".1"), so an always-on research corpus is - /// bounded to ~2× this on disk instead of growing forever. Matches `OuraMotionDump`/`OuraActivityDump` — - /// this file needs it MORE than either: full hex of every notification, and no dedup high-water. - private static let maxBytes = 25 * 1024 * 1024 + /// Byte budget for the retained generations (the live file is excluded — it has its own ceiling). + /// Pruned oldest-first, so a pathological run of large sessions cannot fill the disk. + static let maxRetainedBytes = 64 * 1024 * 1024 private static let iso: ISO8601DateFormatter = { let f = ISO8601DateFormatter() @@ -37,28 +89,18 @@ final class OuraRawDump { return f }() - init(deviceId: String, log: @escaping (String) -> Void) { + /// `directory` is injectable so the session-roll behaviour is testable against a temp dir; production + /// passes nil and gets `/OpenWhoop/Diagnostics`. + init(deviceId: String, log: @escaping (String) -> Void, directory: URL? = nil) { self.deviceId = deviceId self.log = log + self.directory = directory } /// Append one raw notification's bytes verbatim (hex-encoded), stamped with wall-clock arrival time. /// No-op on empty input. Best-effort: any file error is logged once and never disrupts the BLE path. func record(bytes: [UInt8]) { - guard !bytes.isEmpty, var url = resolveURL() else { return } - - // Bound the corpus (rotate to a single ".1", dropping the prior one) so an always-on research sidecar - // can't grow unbounded — same rotation as OuraMotionDump. Read the size via a fresh FileManager stat - // rather than URL.resourceValues, whose cache on the reused URL can return a stale small size. - let size = ((try? FileManager.default.attributesOfItem(atPath: url.path))?[.size] as? Int) ?? 0 - if size > Self.maxBytes { - let old = url.deletingLastPathComponent().appendingPathComponent(url.lastPathComponent + ".1") - try? FileManager.default.removeItem(at: old) - try? FileManager.default.moveItem(at: url, to: old) - fileURL = nil - guard let fresh = resolveURL() else { return } - url = fresh - } + guard !bytes.isEmpty, let url = resolveURL() else { return } let now = Date() let line = OuraRawDumpLine.encode( @@ -66,11 +108,24 @@ final class OuraRawDump { iso: Self.iso.string(from: now), bytes: bytes) guard let data = (line + "\n").data(using: .utf8) else { return } + + // Ceiling check against a running counter, not a stat per record: the file starts EMPTY each + // session (resolveURL rolls it), so the counter is authoritative and costs no filesystem call. + guard sessionBytes + data.count <= Self.maxSessionBytes else { + if !ceilingHit { + ceilingHit = true + log("Oura: raw capture reached its \(Self.maxSessionBytes / (1024 * 1024)) MB session " + + "ceiling - no longer appending (the file is NOT rolled mid-session)") + } + return + } + do { let handle = try FileHandle(forWritingTo: url) defer { try? handle.close() } try handle.seekToEnd() handle.write(data) + sessionBytes += data.count } catch { log("Oura: raw dump write failed - \(error.localizedDescription)") return @@ -82,21 +137,41 @@ final class OuraRawDump { } } - /// Resolve (and create on first use) the sidecar file + its parent directory. Cached; a failure is - /// logged once and latched so we never spam the strap log on a read-only volume. + /// Resolve the sidecar file + its parent directory, ROLLING the previous session's file aside on the + /// first call so this session starts from empty. Cached, so the roll happens exactly once per object — + /// which is what makes it a session boundary rather than a size threshold. A failure is logged once and + /// latched so we never spam the strap log on a read-only volume. private func resolveURL() -> URL? { if let fileURL { return fileURL } if resolveFailed { return nil } do { - let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, - appropriateFor: nil, create: true) - let dir = base.appendingPathComponent("OpenWhoop/Diagnostics", isDirectory: true) + let dir: URL + if let directory { + dir = directory + } else { + let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, + appropriateFor: nil, create: true) + dir = base.appendingPathComponent("OpenWhoop/Diagnostics", isDirectory: true) + } try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) let safeId = deviceId.replacingOccurrences(of: "/", with: "_") let url = dir.appendingPathComponent("oura-raw-\(safeId).jsonl") + + // Session roll: shift the previous session into the generation ring and begin fresh, so the live + // file is always exactly this capture. A leftover that captured essentially nothing (including an + // empty one) is dropped rather than rolled — spending a generation on it is how the 2026-08-10 + // capture was lost. + let existing = Self.byteSize(url) + if existing >= Self.minRolledBytes { + rollGenerations(in: dir, base: url) + pruneRetained(in: dir, base: url) + } else if existing > 0 { + try? FileManager.default.removeItem(at: url) + } if !FileManager.default.fileExists(atPath: url.path) { FileManager.default.createFile(atPath: url.path, contents: nil) } + sessionBytes = 0 fileURL = url return url } catch { @@ -105,4 +180,54 @@ final class OuraRawDump { return nil } } + + /// The generation file for `index` (1 = newest retained session). + static func generationURL(base: URL, index: Int) -> URL { + base.deletingLastPathComponent() + .appendingPathComponent(base.lastPathComponent + ".\(index)") + } + + private static func byteSize(_ url: URL) -> Int { + ((try? FileManager.default.attributesOfItem(atPath: url.path))?[.size] as? Int) ?? 0 + } + + /// Shift the ring up by one: the oldest generation is dropped, every other moves down a slot, and the + /// finished live file becomes `.1`. Best-effort throughout — a failed move must never break the BLE + /// path, and the worst outcome is one lost generation rather than a lost session. + private func rollGenerations(in dir: URL, base url: URL) { + let oldest = Self.generationURL(base: url, index: Self.retainedGenerations) + try? FileManager.default.removeItem(at: oldest) + var index = Self.retainedGenerations - 1 + while index >= 1 { + let from = Self.generationURL(base: url, index: index) + let to = Self.generationURL(base: url, index: index + 1) + if FileManager.default.fileExists(atPath: from.path) { + try? FileManager.default.removeItem(at: to) + try? FileManager.default.moveItem(at: from, to: to) + } + index -= 1 + } + try? FileManager.default.moveItem(at: url, to: Self.generationURL(base: url, index: 1)) + } + + /// Drop retained generations oldest-first until they fit `maxRetainedBytes`. Counts only the ring: the + /// live file has its own per-session ceiling and is not part of this budget. + private func pruneRetained(in dir: URL, base url: URL) { + var total = 0 + var kept: [URL] = [] + for index in 1...Self.retainedGenerations { + let gen = Self.generationURL(base: url, index: index) + let size = Self.byteSize(gen) + guard size > 0 else { continue } + total += size + kept.append(gen) + } + guard total > Self.maxRetainedBytes else { return } + for gen in kept.reversed() { // oldest slot first + guard total > Self.maxRetainedBytes else { break } + total -= Self.byteSize(gen) + try? FileManager.default.removeItem(at: gen) + } + log("Oura: raw capture ring pruned to its \(Self.maxRetainedBytes / (1024 * 1024)) MB budget") + } } diff --git a/Strand/BLE/PendingConnectReplay.swift b/Strand/BLE/PendingConnectReplay.swift new file mode 100644 index 0000000000..8e78f500c9 --- /dev/null +++ b/Strand/BLE/PendingConnectReplay.swift @@ -0,0 +1,58 @@ +import Foundation + +/// What a central should do when `centralManagerDidUpdateState` reports `.poweredOn` and a connect +/// intent was parked because the radio was not up yet. +/// +/// `retrievePeripherals(withIdentifiers:)` answers nothing before `.poweredOn`. Every source read that +/// empty answer as "this device has never been seen here" and took the discovery branch: park the id, +/// start a service-filtered scan. The replay on `.poweredOn` then looked the id up in `seenPeripherals` +/// only, which is empty for the same reason, so it fell through to the scan it had just armed. The +/// targeted `central.connect` that CoreBluetooth honours while the app is suspended was never issued. +/// +/// In the foreground that costs nothing, because a scan finds the device in seconds. In the background +/// iOS throttles scanning hard: the #2433 report has a ring that had been bonded to the phone for weeks +/// found 29 min 54 s after a background relaunch, on a night when the WHOOP central beside it attached +/// immediately. `BLEManager` learned this for straps in #2272 ("a background scan would not find it") +/// and connects by identifier off-screen; the four peripheral sources never did. +/// +/// Asking again once the radio IS up is what separates the two meanings of an empty retrieve, so the +/// replay below is a function of whether the id was already held, whether the fresh retrieve found it, +/// and whether a scan was wanted anyway. +enum PendingConnect { + + /// The action a `.poweredOn` transition should take for a parked connect. + enum Replay: Equatable { + /// A peripheral object is in hand: issue the targeted connect. + case connect + /// No peripheral to connect, and a scan was already armed and waiting on the radio: start it. + case scan + /// A parked connect for a device this phone cannot resolve: arm discovery and connect on sight. + case discover + /// Nothing outstanding. + case idle + } + + /// Decide the replay from the four facts that drive it. + /// + /// Deliberately free of CoreBluetooth so the decision can be tested without a radio, a peripheral or + /// a `CBCentralManager`: the hardware proof is a background relaunch followed within seconds by a + /// connect line rather than a scan, which no unit test can stage. + /// + /// - Parameters: + /// - isPending: a connect was parked before the radio was ready. + /// - isHeld: the id is already in `seenPeripherals`. + /// - didRetrieve: a retrieve issued NOW, with the radio up, found the peripheral. + /// - isScanning: a scan was requested and is waiting on the radio. + static func replay(isPending: Bool, isHeld: Bool, didRetrieve: Bool, isScanning: Bool) -> Replay { + guard isPending else { return isScanning ? .scan : .idle } + // Either source of a peripheral object is enough, and the retrieve is the one that used to be + // missing: it is the whole point of re-asking now that the state is `.poweredOn`. + if isHeld || didRetrieve { return .connect } + // Genuinely unknown to this device, so discovery is the only way to acquire a handle. It must be + // ARMED here, not merely resumed: parking the intent before the retrieve means nothing called + // `scan()` on the way past, so there is no deferred scan waiting to be picked up. Returning + // `.idle` instead would strand a connect asked for before the radio was up, which is how a + // first-ever pairing arrives. + return isScanning ? .scan : .discover + } +} diff --git a/Strand/BLE/PendingSyncDiagnostic.swift b/Strand/BLE/PendingSyncDiagnostic.swift new file mode 100644 index 0000000000..ab84f4d8a8 --- /dev/null +++ b/Strand/BLE/PendingSyncDiagnostic.swift @@ -0,0 +1,58 @@ +import Foundation + +/// Why the Rest card's "Pending sync" state is on or off (#2012). +/// +/// That state is `backfilling || historyPendingSync`, and only the first half leaves a trace: an offload +/// announces itself with "Backfill: session started" and a run of bursts. The second half flipped in +/// silence, so a report of the note showing hours after waking could not be answered from a strap log at +/// all. #2012 is exactly that: a log arrived, and it could only be read by inferring from what else was +/// happening at the timestamp, which settles nothing. +/// +/// So the line names the DECIDING input rather than just the verdict. Four things can decide it, and they +/// mean different bugs: a future-dated strap clock, a phantom gap that advertises records it never banks, +/// being genuinely caught up, or being genuinely behind. Reading "behind by 34210s" in the afternoon is +/// the evidence that the trigger is not scoped to the night being scored, which is the open half of #2012. +/// +/// Pure so it is unit-tested directly; byte-identical to the Android twin. +enum PendingSyncDiagnostic { + + /// Where the flag was recomputed. The two sites weigh different evidence, so the line says which. + static let siteConnect = "connect" + static let sitePostOffload = "post-offload" + + /// One line for a CHANGE of the flag; callers log it only on a flip, never per evaluation. + /// + /// - Parameter persistedRows: nil at ``siteConnect``, which cannot weigh it: no offload has run, so + /// there is no row evidence yet. Naming that rather than printing a misleading "no". + static func line(pending: Bool, site: String, newestUnix: Int?, frontierUnix: Int?, + futureDated: Bool, persistedRows: Bool?, thresholdSec: Int) -> String { + // OPTIONAL deliberately, to match the Android twin. There the flag flips to false when either + // input is missing, so requiring them would leave that flip silent, which is the exact hole this + // line exists to close. Apple currently leaves the flag untouched in that case rather than + // flipping it, a divergence worth its own look; the formatter can express either. + guard let newest = newestUnix, let frontier = frontierUnix else { + return "pending-sync \(pending ? "ON" : "OFF") (\(site)): no range to compare " + + "[newest=\(newestUnix.map { String($0) } ?? "unknown") " + + "frontier=\(frontierUnix.map { String($0) } ?? "unknown")]" + } + let gap = newest - frontier + let why: String + if futureDated { + why = "strap clock reads ahead of now, so the gap could never close (#928/#1012)" + } else if persistedRows == false { + why = "strap advertises newer records but banked no rows — phantom gap (#1144)" + } else if gap > thresholdSec { + why = "strap is \(gap)s ahead of our frontier, over the \(thresholdSec)s threshold" + } else { + why = "caught up — \(gap)s gap, within the \(thresholdSec)s threshold" + } + let rows: String + switch persistedRows { + case .none: rows = "n/a at connect" + case .some(true): rows = "yes" + case .some(false): rows = "no" + } + return "pending-sync \(pending ? "ON" : "OFF") (\(site)): \(why) " + + "[newest=\(newest) frontier=\(frontier) gap=\(gap)s futureDated=\(futureDated) rowsBanked=\(rows)]" + } +} diff --git a/Strand/BLE/PuffinDeepBufferLog.swift b/Strand/BLE/PuffinDeepBufferLog.swift index 56b0dff2e3..5eca7ba7ce 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 @@ -139,7 +139,9 @@ final class PuffinDeepBufferLog { guard !disabled, Self.isDeepBuffer(frame), isEnabled else { return } let tsMs = Int(Date().timeIntervalSince1970 * 1000) let strapTs = Self.strapTs(frame).map { String($0) } ?? "null" - let hex = frame.map { String(format: "%02x", $0) }.joined() + // Fast encoder: this runs once per captured frame for a whole offload, and a deep-buffer frame + // is 2140 bytes, so the per-byte String(format:) it replaced bridged to NSString 2140 times. + let hex = frame.hexLower // #423/#455: run the raw-IMU decoder on the 1244-B buffer so every captured IMU frame carries // its decoded activity summary (cadence/energy/jerk/gyro) inline beside the raw hex. This is the // first CALLER of `Whoop5RawImu.decode` outside its own tests — it exercises the decoder on real diff --git a/Strand/BLE/PuffinExperiment.swift b/Strand/BLE/PuffinExperiment.swift index 3290abd585..7b3a2e13d2 100644 --- a/Strand/BLE/PuffinExperiment.swift +++ b/Strand/BLE/PuffinExperiment.swift @@ -28,10 +28,10 @@ enum PuffinExperiment { static var deepDataEnabled: Bool { UserDefaults.standard.bool(forKey: deepDataKey) } - /// Opt-in "Broadcast heart rate": writes the device-config flag `whoop_live_hr_in_adv_ind_pkt="1"` - /// so the strap advertises the standard Heart Rate Service (0x180D) + its live HR, pairable by a - /// Garmin/Zwift/gym HR client. Reversible, default off; applied on each 5/MG connection and driven by - /// `BLEManager.setBroadcastHr(_:)`. Mirrors the Android `PuffinExperiment.KEY_BROADCAST_HR`. (#181) + /// Opt-in "Broadcast heart rate": enables the family's reversible direct-broadcast control so the + /// strap advertises the standard Heart Rate Service (0x180D), pairable by a Garmin/Zwift/gym HR + /// client. Default off; driven by `BLEManager.setBroadcastHr(_:)`. Mirrors the Android + /// `PuffinExperiment.KEY_BROADCAST_HR`. (#181) static let broadcastHrKey = "noopBroadcastHr" static var broadcastHrEnabled: Bool { UserDefaults.standard.bool(forKey: broadcastHrKey) } diff --git a/Strand/BLE/SourceCoordinator.swift b/Strand/BLE/SourceCoordinator.swift index 5b5e87d4cd..568cf5f10d 100644 --- a/Strand/BLE/SourceCoordinator.swift +++ b/Strand/BLE/SourceCoordinator.swift @@ -158,6 +158,11 @@ final class SourceCoordinator: ObservableObject { /// • WHOOP active after a strap → stop the strap source + resume WHOOP. /// • A generic strap → pause WHOOP + (re)start `StandardHRSource` for that strap's id. func activeDeviceChanged(to id: String) { + // #2208: publish whose device this is BEFORE any branch returns. Readouts that show the strap's + // charge need it to know the number is not the active device's, and the Apple Watch path below + // short-circuits, so setting it inside the WHOOP/strap split would leave a watch reading `true`. + live.activeIsWhoop = isWhoop(id) + // The Apple Watch is a HealthKit source with `peripheralId: nil` (see `AppleWatchDevice`): there is // no BLE peripheral to connect, and the M1 live read happens entirely in `HealthKitBridge`'s // observers + sync, off this BLE coordinator. Short-circuit BEFORE the WHOOP branch so we never @@ -434,6 +439,7 @@ final class SourceCoordinator: ObservableObject { onModel: { [registry] model in registry.setModel(id, model: model) }, // #772: correct a name-guessed gen onSerial: { [weak self] serial in self?.adoptOuraSerial(currentId: id, serial: serial) }, // #771 onsetKeying: { UserDefaults.standard.bool(forKey: AppModel.ouraOnsetKeyingKey) }, // #1284 residual 3 + notifyMaskFull: { UserDefaults.standard.bool(forKey: AppModel.ouraNotifyMaskFullKey) }, // packed-notification A/B adoptIntent: adoptIntent) if adoptIntent { straplog("Oura: adopt consent granted - this session may install NOOP's key") } ouraSource = source // the published typed handle for the adopt mirror (same object as activeSource) @@ -477,6 +483,13 @@ final class SourceCoordinator: ObservableObject { pendingAdoptDeviceId = deviceId } + /// The user asked the Live console to reconnect the active ring (#2305). Forwarded to the live Oura + /// source's own `reconnect()`; a no-op when no ring is the live source, so the console can only ever + /// reconnect the device it is showing — never a WHOOP, never a ring that is not active. + func reconnectActiveRing() { + ouraSource?.reconnect() + } + /// Stop the live non-WHOOP source (standard strap, FTMS machine, Huami device, or Oura ring) and drop /// the reference. Idempotent — exactly one source is ever live. Also nils the published `ouraSource` /// handle so the adopt mirror resets to `.idle` (when an Oura ring was live it is the same object as @@ -588,7 +601,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..d8c302a702 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 @@ -187,6 +187,15 @@ public final class StandardHRSource: NSObject, ObservableObject { /// Connect to the chosen discovered strap and start streaming its HR. public func connect(_ id: UUID) { stopScan() + // Before the radio is up a retrieve answers nothing even for a strap this device has been bonded + // to for weeks, and the branch below would read that as "never seen" and arm a scan (#2433). + // Park the intent instead: `centralManagerDidUpdateState` asks again once the answer means + // something. + guard central.state == .poweredOn else { + pendingConnectID = id + log("HR-strap: connect to \(id) deferred - Bluetooth not powered on (state=\(central.state.rawValue))") + return + } // Reach the peripheral directly: use the freshly-discovered handle if we have it, else ask // CoreBluetooth for the cached peripheral by identifier (a strap we've connected before). This is // what lets the active-strap switch CONNECT without depending on a fresh scan — the switchToStrap @@ -202,11 +211,6 @@ public final class StandardHRSource: NSObject, ObservableObject { seenPeripherals[id] = p peripheral = p p.delegate = self - guard central.state == .poweredOn else { - pendingConnectID = id - log("HR-strap: Bluetooth not powered on — connect to \(id) deferred until ready") - return - } log("HR-strap: connecting to \(id)") central.connect(p, options: nil) } @@ -230,8 +234,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 +244,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() @@ -282,12 +287,40 @@ extension StandardHRSource: @preconcurrency CBCentralManagerDelegate { switch central.state { case .poweredOn: // Replay any intent that arrived before the radio was ready. - if let id = pendingConnectID, let p = seenPeripherals[id] { - pendingConnectID = nil - central.connect(p, options: nil) - } else if scanning { + // The retrieve is re-issued HERE rather than trusting the one that ran while the state was + // still `.unknown`: that one answers nothing even for a bonded strap, which used to drop this + // replay into the scan branch and leave it unreachable off-screen, where iOS throttles + // scanning hard (#2433). + let parked = pendingConnectID + let held = parked.flatMap { seenPeripherals[$0] } + let fresh = held == nil + ? parked.flatMap { central.retrievePeripherals(withIdentifiers: [$0]).first } + : nil + switch PendingConnect.replay(isPending: parked != nil, + isHeld: held != nil, + didRetrieve: fresh != nil, + isScanning: scanning) { + case .connect: + if let id = parked, let p = held ?? fresh { + pendingConnectID = nil + seenPeripherals[id] = p + peripheral = p + p.delegate = self + log("HR-strap: connecting to \(id) - targeted (the radio was not up when it was asked for)") + central.connect(p, options: nil) + } + case .scan: central.scanForPeripherals(withServices: [Self.heartRateService], options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]) + case .discover: + // Nothing to connect and no scan armed, because the intent was parked before the + // retrieve. `scan()` arms `scanning` and `didDiscover` connects the parked id on sight. + // Named, because this whole defect was read off log lines: a line that says a + // connect could not be resolved is only useful if it says which device. + if let id = parked { log("HR-strap: \(id) is not resolvable yet - discovering to find it") } + scan() + case .idle: + break } default: // Radio off / unauthorized / resetting → the link is not live. @@ -304,7 +337,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 +511,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..c1c57e8a57 100644 --- a/Strand/BLE/StandardHeartRate.swift +++ b/Strand/BLE/StandardHeartRate.swift @@ -1,11 +1,18 @@ 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), raw R-R ticks (u16), and the contact state. +/// +/// `rr` contains milliseconds converted from 1/1024-second units per the BLE spec. +/// `rrRawTicks` contains the raw u16 values before conversion. WHOOP 5 (firmware 50.41.1.0) +/// sends millisecond values on this characteristic, non-compliant with the BLE spec — callers +/// that know they have a WHOOP 5 should use `rrRawTicks` instead of `rr`. public enum StandardHeartRate { - public static func parse(_ data: [UInt8]) -> (hr: Int, rr: [Int])? { + public static func parse(_ data: [UInt8]) -> (hr: Int, rr: [Int], rrRawTicks: [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 @@ -17,13 +24,15 @@ public enum StandardHeartRate { } if flags & 0x08 != 0 { idx += 2 } // skip Energy Expended (bit 3) var rr: [Int] = [] + var rrRawTicks: [Int] = [] if (flags >> 4) & 0x01 != 0 { // R-R present (bit 4) while idx + 1 < data.count { let raw = Int(data[idx]) | (Int(data[idx + 1]) << 8) + rrRawTicks.append(raw) rr.append(Int((Double(raw) / 1024.0 * 1000.0).rounded())) // 1/1024 s → ms idx += 2 } } - return (hr, rr) + return (hr, rr, rrRawTicks, contact) } } diff --git a/Strand/BLE/StrapLogArchive.swift b/Strand/BLE/StrapLogArchive.swift new file mode 100644 index 0000000000..cbf112dc8c --- /dev/null +++ b/Strand/BLE/StrapLogArchive.swift @@ -0,0 +1,273 @@ +import Foundation + +/// The strap log on disk: every line of every app run, kept across restarts within a fixed size. +/// +/// WHY THIS EXISTS. `LiveState.log` lives in memory and dies with the process. It used to be mirrored to +/// UserDefaults — the newest 2,000 lines, re-serialised every 32 lines (#510) — and each launch rolled that +/// mirror into a ring of the last three runs, 1,000 lines each (#1263). A restart's cause survived only when +/// restarts were rare and the cause was recent. Three things were lost every time: the lines logged since the +/// last mirror (up to 31 — the seconds before iOS kills a process, the ones that explain the kill); everything +/// but a run's last 1,000 lines; and every run before the last three. On 21 Sep 2026 iOS killed NOOP for +/// background CPU four times in 28 minutes of one session, and the log saved afterwards began half an hour +/// after the moment it was saved for. +/// +/// Now each line is appended to its run's file as it is logged — one small write, which the kernel keeps even +/// when the process is killed a moment later. A run is split into segments of `segmentBytes`; once all the +/// segments pass `budgetBytes`, the oldest are deleted. So the log holds the newest ~2 MB — about 20,000 lines, +/// some three hours with a strap streaming heart rate — however often NOOP restarts, and a whole run rather +/// than its last 5,000 lines. Exports render it exactly as the ring did: earlier runs oldest first, each under +/// its "previous app session" header, then the current run after its marker, so every tool that reads a strap +/// log reads it unchanged. +/// +/// Before the first unlock after a boot iOS refuses the files — just when a restart after a reboot is logged — so +/// lines that cannot be written wait in memory, the newest within the budget, and go to disk the moment a file +/// opens. +/// +/// An export reads no file while a run is written: the open segment's lines are kept in memory and everything +/// before them is rendered once, again only when a segment closes — the Test Centre asks for an export on +/// every new line while a guided mode is on. +/// +/// Twin of Android's `com.noop.ui.StrapLogArchive`: same file names, sizes and rendering. +final class StrapLogArchive: @unchecked Sendable { + + /// How much of the log is kept, all runs together. + static let budgetBytes = 2 * 1024 * 1024 + /// The size at which a run's file is closed and the next one begun — what the oldest runs are deleted by. + static let segmentBytes = 256 * 1024 + /// Separates the earlier runs from the current one in an export, as the ring's exports did. + static let currentRunMarker = "===== current app session =====" + /// Lines carried over from the UserDefaults ring the first time this runs: rendered first, as they were. + static let legacyFileName = "legacy.log" + + let directory: URL + /// When this run began, in unix milliseconds: the name its files sort by, and when the run before it + /// ended ("rolled at" in that run's header). + let runStart: Int64 + private let budget: Int + private let segmentLimit: Int + + private let lock = NSLock() + private var handle: FileHandle? + private var segment = 0 + /// Bytes written to the open segment. + private var segmentSize = 0 + /// The open segment's lines, all on disk, so an export reads nothing that is being written. + private var openLines: [String] = [] + /// Lines not on disk yet, all newer than `openLines`: storage refused them (iOS, before the first unlock after + /// a boot — when a restart after a reboot is logged) or no file would open. Written the moment one opens; + /// meanwhile in this run's export, the newest within the budget. + private var unwritten: [String] = [] + private var unwrittenBytes = 0 + /// Lines until the next attempt to open a file while none is open: every 64 lines, not on every line. + private var openAttemptIn = 0 + /// Everything before the open segment, rendered: the earlier runs under their headers, and this run's + /// closed segments. Nil until an export needs it, and again when pruning deletes a file. + private var rendered: (previous: String, closed: String)? + + init(directory: URL, budgetBytes: Int = StrapLogArchive.budgetBytes, + segmentBytes: Int = StrapLogArchive.segmentBytes, now: Date = Date()) { + self.directory = directory + self.budget = budgetBytes + self.segmentLimit = segmentBytes + self.runStart = Int64((now.timeIntervalSince1970 * 1000).rounded()) + } + + /// Log one line: onto disk now, or — if storage cannot be written — into memory until it can, the newest lines + /// within the budget. Either way it is in this run's export. + func append(_ line: String) { + lock.lock(); defer { lock.unlock() } + unwritten.append(line) + unwrittenBytes += line.utf8.count + 1 + writeUnwritten() + guard unwrittenBytes > budget else { return } + var dropped = 0 + while unwrittenBytes > budget { + unwrittenBytes -= unwritten[dropped].utf8.count + 1 + dropped += 1 + } + unwritten.removeFirst(dropped) + // Nothing of this run on disk yet: its first file then starts at segment 1, which reads as a clipped head. + if segment == 0, openLines.isEmpty { segment = 1 } + } + + /// Write what waits, oldest first; a refusal leaves the rest waiting and the next attempt 64 lines away. + private func writeUnwritten() { + if handle == nil { + guard openAttemptIn == 0 else { openAttemptIn -= 1; return } + openSegment() + guard handle != nil else { openAttemptIn = 63; return } + } + var written = 0 + defer { unwritten.removeFirst(written) } + for line in unwritten { + let data = Data((line + "\n").utf8) + if !openLines.isEmpty, segmentSize + data.count > segmentLimit { + closeSegment() + openSegment() + guard handle != nil else { openAttemptIn = 63; return } + } + guard (try? handle?.write(contentsOf: data)) != nil else { + try? handle?.close() + handle = nil + openAttemptIn = 63 + return + } + segmentSize += data.count + unwrittenBytes -= data.count + openLines.append(line) + written += 1 + } + } + + /// The whole log as an export shows it: earlier runs, the marker, then this run. + func exportText() -> String { + lock.lock(); defer { lock.unlock() } + let earlier = renderedEarlier() + let open = (openLines + unwritten).joined(separator: "\n") + let current = earlier.closed.isEmpty ? open : open.isEmpty ? earlier.closed : earlier.closed + "\n" + open + return earlier.previous + current + } + + /// Keep the ring's lines when the log first moves to disk: written once, before every run, as they were. + func importLegacy(_ lines: [String]) { + guard !lines.isEmpty else { return } + lock.lock(); defer { lock.unlock() } + let url = directory.appendingPathComponent(Self.legacyFileName) + guard !FileManager.default.fileExists(atPath: url.path) else { return } + try? Data((lines.joined(separator: "\n") + "\n").utf8).write(to: url, options: .atomic) + rendered = nil + } + + // MARK: - Files + + /// One file: a segment of a run, or the carried-over ring (`run` nil). + private struct File { + var url: URL + var run: Int64? + var index: Int + var bytes: Int + } + + static func fileName(run: Int64, segment: Int) -> String { + String(format: "%013lld-%04ld.log", run, segment) + } + + /// Every file, oldest first: the carried-over ring, then each run's segments in order. + private func files() -> [File] { + let keys: [URLResourceKey] = [.fileSizeKey] + let urls = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: keys)) + ?? [] + return urls.compactMap { url -> File? in + let name = url.lastPathComponent + let bytes = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0 + if name == Self.legacyFileName { return File(url: url, run: nil, index: 0, bytes: bytes) } + let parts = name.dropLast(4).split(separator: "-") + guard name.hasSuffix(".log"), parts.count == 2, + let run = Int64(parts[0]), let index = Int(parts[1]) else { return nil } + return File(url: url, run: run, index: index, bytes: bytes) + } + .sorted { ($0.run ?? -1, $0.index) < ($1.run ?? -1, $1.index) } + } + + private func openSegment() { + let url = directory.appendingPathComponent(Self.fileName(run: runStart, segment: segment)) + if !FileManager.default.fileExists(atPath: url.path) { + FileManager.default.createFile(atPath: url.path, contents: nil) + } + handle = try? FileHandle(forWritingTo: url) + _ = try? handle?.seekToEnd() + prune() + } + + private func closeSegment() { + try? handle?.close() + handle = nil + if let earlier = rendered { + let closing = openLines.joined(separator: "\n") + rendered = (earlier.previous, earlier.closed.isEmpty ? closing : earlier.closed + "\n" + closing) + } + segment += 1 + segmentSize = 0 + openLines = [] + } + + /// Delete the oldest files until everything fits the budget — never the segment being written. + private func prune() { + let open = Self.fileName(run: runStart, segment: segment) + var all = files() + var total = all.reduce(0) { $0 + $1.bytes } + while total > budget, let oldest = all.first(where: { $0.url.lastPathComponent != open }) { + try? FileManager.default.removeItem(at: oldest.url) + total -= oldest.bytes + all.removeAll { $0.url == oldest.url } + rendered = nil + } + } + + // MARK: - Rendering + + private func renderedEarlier() -> (previous: String, closed: String) { + if let rendered { return rendered } + let open = Self.fileName(run: runStart, segment: segment) + var legacy: [String] = [] + var runs: [(start: Int64, lines: [String], clipped: Bool)] = [] + var closed: [String] = [] + for file in files() where file.url.lastPathComponent != open { + let lines = Self.lines(of: file.url) + guard let run = file.run else { legacy += lines; continue } + if run == runStart { closed += lines; continue } + if let last = runs.last, last.start == run { + runs[runs.count - 1].lines += lines + } else { + runs.append((run, lines, file.index > 0)) + } + } + let result = (previous: Self.render(legacy: legacy, runs: runs, currentStart: runStart), + closed: closed.joined(separator: "\n")) + rendered = result + return result + } + + /// The ring's lines as its export printed them: each stored run already carries its own header; a tail the + /// ring had not rolled yet gets the header its roll would have written, now. + static func legacyRingLines(generations: [[String]], tail: [String], now: Date) -> [String] { + var lines = generations.flatMap { $0 } + if !tail.isEmpty { + lines.append(header(lines: tail.count, clipped: false, + rolledAt: Int64((now.timeIntervalSince1970 * 1000).rounded()))) + lines += tail + } + return lines + } + + /// One earlier run's header. Byte-identical to the ring's, which the log tools parse; the time is UTC, + /// to the second. + static func header(lines: Int, clipped: Bool, rolledAt: Int64) -> String { + let iso = ISO8601DateFormatter() + iso.timeZone = TimeZone(identifier: "UTC") + let rolled = iso.string(from: Date(timeIntervalSince1970: TimeInterval(rolledAt / 1000))) + let count = "\(lines) line(s)" + (clipped ? ", head clipped" : "") + return "===== previous app session, \(count), rolled at \(rolled) (this launch) =====" + } + + /// The earlier runs as an export prints them, ending in the current-run marker; "" when there are none. + /// A run's "rolled at" is when the run after it began — the moment the ring used to roll it. + static func render(legacy: [String], runs: [(start: Int64, lines: [String], clipped: Bool)], + currentStart: Int64) -> String { + guard !legacy.isEmpty || !runs.isEmpty else { return "" } + var out = legacy + for (i, run) in runs.enumerated() { + let next = i + 1 < runs.count ? runs[i + 1].start : currentStart + out.append(header(lines: run.lines.count, clipped: run.clipped, rolledAt: next)) + out += run.lines + } + return out.joined(separator: "\n") + "\n" + currentRunMarker + "\n" + } + + private static func lines(of url: URL) -> [String] { + guard let data = try? Data(contentsOf: url), !data.isEmpty else { return [] } + var lines = String(decoding: data, as: UTF8.self).components(separatedBy: "\n") + if lines.last == "" { lines.removeLast() } + return lines + } +} diff --git a/Strand/Collect/Backfiller.swift b/Strand/Collect/Backfiller.swift index 1f78b47a67..f999439801 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 @@ -81,6 +86,16 @@ final class Backfiller { private let log: ((String) -> Void)? /// Versions already reported this session, so the diagnostic logs each once (no spam). private var loggedUnmappedVersions: Set = [] + /// #1992: reject frames still allowed to hex-dump (see `hexDumpAllowance`). + /// + /// Deliberately NOT reset in `begin()`, for the same reason as `lastAckedTrim`: the thing being + /// protected is the ROLLING LOG, which belongs to the process, not to one offload session. The + /// auto-continue re-kicks up to 24 sessions per connection, so a per-session budget would allow + /// 24 x 24 frames and flood the buffer exactly as before, which is the shape the reporter hit. + private(set) var rejectHexBudget = Backfiller.rejectHexDumpBudget + /// Reject frames seen this session, so the suppression line can say what it stopped showing. + private var rejectFramesSeen = 0 + private var rejectHexSuppressedNoted = false /// Per-session persistence tally — the success-side observability the log forensics flagged as the /// blind spot (#150): we logged FAILURES (decoded-to-0) but never SUCCESSES, so a strap log couldn't @@ -142,6 +157,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(). @@ -173,6 +193,44 @@ final class Backfiller { /// `begin()` (it's a cross-session high-water mark, not a per-session tally). private(set) var lastAckedTrim: UInt32? + /// Reject frames one connection may hex-dump (#1992). Three chunks worth at the per-chunk cap: + /// enough distinct records to triangulate field offsets (v25 was mapped from 45, spread over many + /// logs), while leaving room in a 2000-line rolling buffer for the lines that give the dump context. + /// The complete records are always in the reject archive. + static let rejectHexDumpBudget = 24 + + /// #891: the dump line for the first frame of an unmapped packet type, or nil when this chunk holds + /// no frame of that type. + /// + /// Pure and static so the SELECTION is pinnable: the census names a type, and this has to find the + /// bytes that earned the name. Byte-identical to the Android line, which sources its hex from + /// `StreamBatch.unhandledPacketSamples` because the Kotlin extractor is handed raw ByteArrays and can + /// sample in place. Swift's extractor only sees `ParsedFrame`s whose `rawHex` is empty on the ingest + /// fast path (`collectFields: false`, D#969), so the bytes have to be zipped back in here. + /// Takes `typeNames` rather than the `ParsedFrame`s they came from: the selection only needs the + /// name, and `ParsedFrame`'s memberwise init is internal to WhoopProtocol, so a StrandTests case + /// cannot build one. A helper that cannot be called from its own test is not a testable helper. + static func unmappedTypeDumpLine(typeName: String, frames: [[UInt8]], + typeNames: [String]) -> String? { + guard let raw = zip(frames, typeNames).first(where: { $0.1 == typeName })?.0 else { return nil } + let hex = raw.map { String(format: "%02x", $0) }.joined() + return "Backfill: unmapped type \(typeName) first frame \(raw.count)B: \(hex)" + } + + /// How many reject frames this chunk may hex-dump, given what the session has already spent (#1992). + /// + /// The dump is the only channel carrying an unmapped layout's raw bytes to someone who can map it, + /// and it was bounded PER CHUNK with no session budget. On the straps it exists for that defeats + /// itself: a strap rejecting ~25 records per chunk, across many chunks and many sessions per + /// connection, emits 8 long hex lines each time, floods the 2000-line rolling log, and evicts its own + /// earlier dumps along with the context needed to read them. + /// + /// Pure, so the arithmetic is testable without a Backfiller. Kotlin twin: `hexDumpAllowance`. + static func hexDumpAllowance(_ rejectedCount: Int, _ budgetRemaining: Int, + perChunkCap: Int = 8) -> Int { + max(0, min(rejectedCount, perChunkCap, budgetRemaining)) + } + /// Distinct historical layout versions logged this session. Unlike `loggedUnmappedVersions` (which /// only fires for layouts NOOP can't decode), this surfaces the layout on a HEALTHY sync too, so a /// shared strap log always reveals what the strap emits (v18/v24/v25/v26). Mirrors the Android @@ -183,6 +241,15 @@ final class Backfiller { /// `Spo2ReTrace.maxSamples`. Session-scoped so the cap spans chunks; reset per session in `begin`. private var spo2Dumped = 0 + /// SpO2 RE dump: how many records this session dumped for each layout version, so one layout cannot + /// spend the whole session budget. Key -1 buckets a record whose `hist_version` did not decode. + /// Session-scoped alongside `spo2Dumped`; reset in `begin`. Twin of the Android `spo2DumpedByVersion`. + private var spo2DumpedByVersion: [Int: Int] = [:] + + /// SpO2 RE dump: records EXAMINED this session, bounded by `Spo2ReTrace.maxExamined`. Applied here so + /// both platforms examine the same frames and dump the same records. Twin of Android `spo2Examined`. + private var spo2Examined = 0 + /// Durably archives undecodable record frames BEFORE the trim ack (#77 / #91). Returns true once /// the bytes are safe (written OR cap-reached — either way the chunk may be acked) and false on a /// genuine write failure, in which case `finishChunk` holds the cursor/ack so the strap re-sends. @@ -209,6 +276,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 +295,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 @@ -276,6 +347,8 @@ final class Backfiller { sessionDynAccel = Streams.DynAccelDiag() loggedLayoutVersions.removeAll(keepingCapacity: true) spo2Dumped = 0 + spo2DumpedByVersion = [:] + spo2Examined = 0 // #547: the range markers belong to a connection's GET_DATA_RANGE, which BLEManager re-sets per // connect; clear them here so a fresh session never reuses a previous strap's window. BLEManager // re-publishes them as soon as the range reply arrives. @@ -428,6 +501,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. /// @@ -534,9 +670,17 @@ final class Backfiller { // frames carry no record bytes to correlate. Records dump whether or not they carry SpO2 // channels, so "nothing banked" is provable too. Never a user-facing number (never-fabricate; // the #194 lesson). Twin of the Android Backfiller emit. - if spo2Dumped < Spo2ReTrace.maxSamples, connectionActive(), let connectionLog { + if spo2Dumped < Spo2ReTrace.maxSamples, spo2Examined < Spo2ReTrace.maxExamined, + connectionActive(), let connectionLog { for (raw, p) in zip(frames, parsed) where spo2Dumped < Spo2ReTrace.maxSamples { + if spo2Examined >= Spo2ReTrace.maxExamined { break } + spo2Examined += 1 guard let unix = p.parsed["unix"]?.intValue else { continue } + // Stratify by layout: without this the first chunk's dominant layout eats the whole + // budget and the rare, still-unmapped one never gets a frame. See `maxPerVersion`. + let ver = p.parsed["hist_version"]?.intValue ?? -1 + let dumpedForVer = spo2DumpedByVersion[ver] ?? 0 + if dumpedForVer >= Spo2ReTrace.maxPerVersion { continue } connectionLog(Spo2ReTrace.recordLine( frame: raw, version: p.parsed["hist_version"]?.intValue, @@ -544,24 +688,49 @@ final class Backfiller { red: p.parsed["spo2_red"]?.intValue, ir: p.parsed["spo2_ir"]?.intValue, skinRaw: p.parsed["skin_temp_raw"]?.intValue)) + spo2DumpedByVersion[ver] = dumpedForVer + 1 spo2Dumped += 1 } } - // Diagnostic (#30): a historical record whose firmware version we don't have a field map for - // bails out of decode entirely — no HR, no R-R, no GRAVITY — so sleep (which is gravity/ + // Diagnostic (#1992, was #30): a historical record whose firmware version we have no field map + // for bails out of decode entirely — no HR, no R-R, no GRAVITY — so sleep (which is gravity/ // motion-driven) can never be computed from it, even though the offload "completes". Surface // each unmapped version once so the user's strap log reveals what their firmware emits. - // "Decoded nothing" must cover every mapped layout's signature field: v18 emits heart_rate, - // v25 emits gravity_x (no per-second HR — it's PPG-derived), v26 emits ppg_waveform (no HR - // either) — checking heart_rate alone false-flagged v25/v26 as unmapped (#156, sudden-break). + // + // TWO facts, not one (#1992). Whether the layout decodes is asked of the 5/MG dispatch table; + // whether the record carries anything scoreable stays the field test. The single old line said + // "doesn't decode yet" for both, which was wrong for v20 — and simply suppressing it there + // would have dropped the half that IS true, leaving a user with unstaged nights and nothing + // in the log saying why. See `historicalLayoutSupport` and its tests. + // + // Asked of the LAYOUT, not of one record. The conclusion is about a firmware layout, so it is + // taken over every record of that version in the chunk: a layout counts as carrying a signal + // when ANY of its records did. Judging it on whichever record happened to come first would let + // one thin or off-wrist v18 record condemn v18 for the rest of the session — the old form had + // that same hole, and it is worse now that the verdict names a specific reason. + var signalByVersion: [Int: Bool] = [:] for p in parsed { - guard let v = p.parsed["hist_version"]?.intValue, - p.parsed["heart_rate"] == nil, - p.parsed["gravity_x"] == nil, - p.parsed["ppg_waveform"] == nil, - !loggedUnmappedVersions.contains(v) else { continue } + guard let v = p.parsed["hist_version"]?.intValue else { continue } + let carries = p.parsed["heart_rate"] != nil + || p.parsed["gravity_x"] != nil + || p.parsed["ppg_waveform"] != nil + signalByVersion[v] = (signalByVersion[v] ?? false) || carries + } + for (v, carriesSignal) in signalByVersion.sorted(by: { $0.key < $1.key }) { + guard !loggedUnmappedVersions.contains(v) else { continue } + let support = historicalLayoutSupport( + version: v, family: family, + hasHeartRate: carriesSignal, hasGravity: false, hasPpgWaveform: false) + guard support != .supported else { continue } loggedUnmappedVersions.insert(v) - log?("Historical records use firmware layout v\(v), which NOOP doesn't decode yet — no motion data, so sleep can't be computed from the strap. Please report this (issue #30).") + switch support { + case .unmapped: + log?("Historical records use firmware layout v\(v), which NOOP doesn't decode yet: those records carry no heart rate or motion, so any night made only of them can't be staged from the strap. A strap emitting a mix of layouts still stages the nights it can. Please report this (issue #1992).") + case .decodesWithoutNamedSignal: + log?("Historical records use firmware layout v\(v). NOOP decodes it, but these records carry no per-second heart rate and no motion (they hold raw sensor channels nothing scores yet), so any night made only of them can't be staged from the strap. A strap emitting a mix of layouts still stages the nights it can. Please report this (issue #1992).") + case .supported: + break + } } let decoded = d.decoded // #520: accumulate the motion-magnitude diagnostic across the session; logged once at the @@ -607,6 +776,21 @@ final class Backfiller { "decoder has no rows for — they are being dropped. If \(typeName) is not a name " + "you recognise, this is a firmware record type NOOP has never mapped: please " + "report it on #891 with the strap model and firmware build.") + // #891: and the bytes, so the report is actionable. Without this the line above asks a + // reporter to raise an issue about a record that exists nowhere else: `default:` drops + // the frame and the reject archive only ever holds type-47. First sighting only, so a + // long offload of one unmapped type still costs exactly one dump. + // + // Taken HERE rather than in the extractor, which is where the Android twin collects it: + // `extractHistoricalStreams` receives already-parsed frames, and `ParsedFrame.rawHex` is + // deliberately empty on the ingest fast path (`collectFields: false`, D#969), so reading + // it there would emit an empty dump on every real offload while passing any test that + // builds its own ParsedFrame. The raw bytes only exist at this level. Same log line on + // both platforms; no prefix cap, for the reason the reject dump has none. + if let line = Backfiller.unmappedTypeDumpLine(typeName: typeName, frames: frames, + typeNames: parsed.map(\.typeName)) { + log?(line) + } } } // Diagnostic (#77): the AGGREGATE silent-loss case — frames arrived but produced no rows at @@ -637,7 +821,9 @@ final class Backfiller { // prefix — v25/v26 records run ~84 B and the truncated tail is exactly where the // unmapped motion/HR fields sit), and sample a few more so one log carries enough // records to triangulate offsets. These only ever fire for unmapped firmware. - let sample = Array(rejected.prefix(8)) + rejectFramesSeen += rejected.count + // #1992: spend from a SESSION budget, not a fresh 8 per chunk. See `hexDumpAllowance`. + let sample = Array(rejected.prefix(Backfiller.hexDumpAllowance(rejected.count, rejectHexBudget))) var emptySkipped = 0 for (i, f) in sample.enumerated() { // #1007: an all-zero frame has no record layout to map, so its hex dump is pure log @@ -645,10 +831,19 @@ final class Backfiller { if isEmptyRecordFrame(f) { emptySkipped += 1; continue } let hex = f.map { String(format: "%02x", $0) }.joined() log?("Backfill: rejected frame[\(i)] \(f.count)B: \(hex)") + rejectHexBudget -= 1 } if emptySkipped > 0 { log?("Backfill: #1007 \(emptySkipped)/\(sample.count) sampled frame(s) all-zero (empty payload) - hex dump skipped") } + // Say ONCE that the sample is capped, so a reader knows the dump is a sample rather than + // everything the strap sent, and where the rest lives. + if rejectHexBudget <= 0, !rejectHexSuppressedNoted { + rejectHexSuppressedNoted = true + log?("Backfill: hex dumps capped at \(Backfiller.rejectHexDumpBudget) frame(s) while this connection lasts " + + "(\(rejectFramesSeen) reject frame(s) seen so far); the complete records are in the " + + "reject archive. Sample is enough to map a layout (#1992)") + } } // Commit the decoded rows FIRST (durable). Doing this before the reject archive means a // rare insert failure — which returns and re-sends the whole chunk next session — can't @@ -658,7 +853,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..35e9fde3fd 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 {} @@ -57,6 +52,11 @@ final class Collector { /// decoded-only. Injected for tests; backed by UserDefaults in the production init site. private let enableRawCapture: Bool private let now: () -> Int + /// The standard-HR host-received lines, summarised — see `LivePersistTrace.StandardHRHostReceivedTrace`. + private var hostReceived = LivePersistTrace.StandardHRHostReceivedTrace() + /// Whether the per-sample readout is wanted: the app wires this to the Test Centre's HRV and Connection + /// modes, and tests set it directly. Read once per sample, a Bool through a closure. + var hostReceivedDetail: () -> Bool = { false } private let monotonic: () -> TimeInterval /// Set once the GET_CLOCK correlation lands (E1). Until then, frames buffer un-persisted. @@ -72,20 +72,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 +144,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 +197,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 +204,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 +218,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 +232,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 +286,90 @@ 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, + family: DeviceFamily? = 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)) } + let source: RRSourceChannel? = family == .whoop5 ? .whoop5Standard : nil + stdRR.append(contentsOf: acceptedRR.map { RRInterval(ts: ts, rrMs: $0, srcChannel: source) }) + // 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) + } + // One line a minute, not one a second: a refusal is written at once, the routine ones are counted, and + // the full per-sample readout comes back while a Test Centre mode is on (`hostReceivedDetail`). + for line in hostReceived.record( + .init(hostUnixSeconds: ts, + acceptedHRRows: acceptedHR, acceptedRRRows: acceptedRR.count, + rejectedHRRows: 1 - acceptedHR, rejectedRRRows: rr.count - acceptedRR.count, + pendingHRRows: stdHR.count, pendingRRRows: stdRR.count), + detailed: hostReceivedDetail()) { + log?(line) + } + 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 { + // The stream is ending, not just filling: write the window so far, or the last minute of a session + // leaves with the process. A cadence flush is mid-stream and keeps counting. + if reason != .cadence, reason != .explicit { + for line in hostReceived.close() { log?(line) } + } + 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 +381,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/Collect/RawHistoryArchive.swift b/Strand/Collect/RawHistoryArchive.swift index 92d3056736..e323724b4c 100644 --- a/Strand/Collect/RawHistoryArchive.swift +++ b/Strand/Collect/RawHistoryArchive.swift @@ -137,10 +137,6 @@ struct RawHistoryArchive { /// kept as distinct buckets (their layouts differ despite the shared version number). private struct VersionKey: Hashable { let family: String; let version: Int } - private func versionKey(family: DeviceFamily, version: Int) -> VersionKey { - VersionKey(family: family.rawValue, version: version) - } - /// Durably append `frames` as JSONL. `trim`/`family` tag each line so the corpus is replayable. /// Empty input is a no-op success. See `Result` for the ack contract. /// @@ -164,7 +160,9 @@ struct RawHistoryArchive { // Build the new JSONL lines (each newline-terminated). The version that drives floor-aware // retention (#344) is re-derived per line from the stored frame inside `evictLines`. let newLines: [String] = frames.map { f in - let hex = f.map { String(format: "%02x", $0) }.joined() + // Fast encoder: one line per REJECTED frame, which on an unmapped-layout strap is every + // frame of every chunk. + let hex = f.hexLower // Hand-built JSON: the only dynamic field is hex (always [0-9a-f]) so no escaping is // needed, and this avoids a JSONEncoder allocation per frame on the offload hot path. return "{\"capturedAtMs\":\(capturedAtMs),\"trim\":\(Int(trim))," diff --git a/Strand/Collect/StorePaths.swift b/Strand/Collect/StorePaths.swift index cb9136f797..ebe77fe83c 100644 --- a/Strand/Collect/StorePaths.swift +++ b/Strand/Collect/StorePaths.swift @@ -50,6 +50,28 @@ enum StorePaths { return dbURL.path } + /// `/OpenWhoop/strap-log/`, where the strap log keeps its runs (`StrapLogArchive`), beside the + /// store and inside the same macOS container. On iOS it takes the store's protection class, so lines logged + /// in the background while the phone is locked can be written; and it is left out of backups, being a + /// debugging aid for this device. + static func strapLogDirectory() throws -> URL { + let fm = FileManager.default + let appSupport = try fm.url(for: .applicationSupportDirectory, in: .userDomainMask, + appropriateFor: nil, create: true) + var dir = macOSProductionContainerAppSupport(defaultingTo: appSupport) + .appendingPathComponent("OpenWhoop", isDirectory: true) + .appendingPathComponent("strap-log", isDirectory: true) + try fm.createDirectory(at: dir, withIntermediateDirectories: true) + #if os(iOS) + try? fm.setAttributes([.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], + ofItemAtPath: dir.path) + #endif + var values = URLResourceValues() + values.isExcludedFromBackup = true + try? dir.setResourceValues(values) + return dir + } + /// On signed/production macOS builds the app runs sandboxed, so the real /// on-disk location is inside `~/Library/Containers//Data`. We pin /// the store there explicitly so the path is stable regardless of whether the diff --git a/Strand/Data/BackupSync.swift b/Strand/Data/BackupSync.swift index b485d7caee..481ffcce1f 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,23 @@ 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: + // Remove whatever this run left behind, and note that this is the OPPOSITE of what the + // interactive export does with an unverifiable file. The two contexts want opposite answers: + // a file whose location the user chose is theirs to keep and inspect, and they are told so, + // while a file in THIS folder silently becomes `latestSnapshot` in a rotation whose older + // snapshots are still intact, with nobody watching to notice it was never confirmed. A + // snapshot that was never read back is not one to offer the restore picker first; the next + // run writes another. (A torn file already deleted itself; this covers the rest.) + // Twin of the Android FolderBackup delete. + try? FileManager.default.removeItem(at: dest) + return false + } UserDefaults.standard.set(nowMs, forKey: lastKey) prune(in: folder) @@ -304,8 +362,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..f031553bd7 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 } @@ -123,7 +138,7 @@ enum DataBackup { private struct ExportIntegrityFailure: LocalizedError { let complaint: String var errorDescription: String? { - String(localized: "the NOOP database failed its integrity check (SQLite reports: \(complaint)). A backup of it would not restore. Export the WHOOP-format CSV (Settings → Export data) to save what's still readable.") + String(localized: "the NOOP database failed its integrity check (SQLite reports: \(DatabaseIntegrity.readableComplaint(complaint))). A backup of it would not restore. Export the WHOOP-format CSV (Settings → Export data) to save what's still readable.") } } @@ -137,6 +152,14 @@ enum DataBackup { } } + /// Thrown when the written backup could not be READ BACK to check it, which is not the same thing as + /// finding it damaged and must not be reported as though it were. The file is left where it is. + private struct BackupWriteUnverified: LocalizedError { + var errorDescription: String? { + String(localized: "the backup file was written, but couldn't be read back to check it, so it has been left in place rather than deleted. Open it before you rely on it, or export again somewhere else.") + } + } + /// The production export path: verify, then archive. GRDB checkpoints the WAL first (the /// callers' `checkpoint()` guard), so at this point the single file IS the whole store — run a /// read-only `PRAGMA quick_check` over it BEFORE zipping (#1014). Archiving an already-corrupt @@ -144,23 +167,77 @@ 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) + } + + /// The smallest a real database entry can be: SQLite's own file header is exactly this long, so + /// anything shorter cannot be a database whatever else it looks like. + static let minimumBackupEntryBytes: UInt32 = 100 + + /// Whether the `.noopbak` at `url` is a COMPLETE archive carrying a plausible database entry. + /// + /// #1014 (write-side): the SOURCE is verified before archiving, but the PRODUCED file can still be + /// torn by a full disk, a dying filesystem, or a cloud client that drops the tail mid-write, and such + /// a truncated `.noopbak` otherwise "restores" into an empty store, caught only by the import-side + /// quick_check much later, when the original may be long gone. + /// + /// Opening an `Archive` for reading parses the CENTRAL DIRECTORY, which lives at the end of a ZIP, so + /// a file cut short does not open at all. That is cheap (an index read, no extraction) and is what + /// makes this catch truncation rather than merely mis-content. + /// + /// Extracted from `writeVerifiedBackupZip` so it can be driven against hand-built archives: this + /// guard has protected every export since #1014 and, until now, had no test of its own. The Android + /// twin is `DataBackup.hasEndOfCentralDirectory` plus `backupStreamIsIntact`, which has to find the + /// end record itself because `ZipInputStream` never looks for one. + /// What a post-write check concluded about the file just produced. + /// + /// `unverifiable` exists so that failing to READ a backup is never mistaken for evidence against it. + /// The caller DELETES a `torn` file, and deleting on "we could not look" would let a transient read + /// failure destroy a backup that was perfectly good. Twin of the Android `BackupWriteVerdict`. + enum BackupWriteVerdict: Equatable { case intact, torn, unverifiable } + + /// Re-read the `.noopbak` just written to `url` and say what it looks like. + /// + /// Unreadable is its own answer rather than a bad one: it says nothing about the CONTENT, and the + /// caller's response to `torn` is destructive. Past that gate a file that will not open as an + /// archive really is torn, because opening one only reads the central directory a complete ZIP has. + static func verifyWrittenBackup(at url: URL) -> BackupWriteVerdict { + guard FileManager.default.isReadableFile(atPath: url.path) else { return .unverifiable } + guard let written = try? Archive(url: url, accessMode: .read), + let dbEntry = written.first(where: { ($0.path as NSString).lastPathComponent == backupEntryName }), + dbEntry.uncompressedSize >= minimumBackupEntryBytes else { return .torn } + return .intact + } + + /// Convenience over `verifyWrittenBackup(at:)` for callers that only care whether it passed. + static func writtenBackupIsIntact(at url: URL) -> Bool { + verifyWrittenBackup(at: url) == .intact + } + private static func writeVerifiedBackupZip(dbURL: URL, to dest: URL, settingsJSON: Data?) throws { if let complaint = DatabaseIntegrity.quickCheckFailure(atPath: dbURL.path) { throw ExportIntegrityFailure(complaint: complaint) } try writeBackupZip(dbURL: dbURL, to: dest, settingsJSON: settingsJSON, manifestJSON: currentManifestJSON()) - // #1014 (write-side): the SOURCE is verified above, but the PRODUCED file can still be torn by a - // full disk / dying filesystem / flaky cloud-sync mid-write, and such a truncated `.noopbak` - // otherwise "restores" into an empty store — caught only by the import-side quick_check much later. - // Re-open the file we just wrote (a cheap central-directory read, no extraction — and a torn file - // has no valid trailing central directory, so it won't even open) and confirm its DB entry is - // present and non-empty (a SQLite header alone is 100 bytes). Fail HERE if not, and don't leave a - // corrupt file behind masquerading as a good snapshot. Twin of the Android post-write check. - guard let written = try? Archive(url: dest, accessMode: .read), - let dbEntry = written.first(where: { ($0.path as NSString).lastPathComponent == backupEntryName }), - dbEntry.uncompressedSize >= 100 else { + switch verifyWrittenBackup(at: dest) { + case .intact: + break + case .torn: + // Never leave a corrupt file behind masquerading as a good snapshot. try? FileManager.default.removeItem(at: dest) throw BackupWriteIncomplete() + case .unverifiable: + // Failing to READ it back is not evidence against it, so it is LEFT IN PLACE. Deleting here + // would let a transient read failure destroy a backup that was perfectly good. + throw BackupWriteUnverified() } } @@ -246,7 +323,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 +350,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 +382,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 +407,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 +421,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)")) @@ -394,7 +480,7 @@ enum DataBackup { && (fm.fileExists(atPath: source.path + "-wal") || fm.fileExists(atPath: source.path + "-shm")) if !legacySidecarsPresent, let complaint = DatabaseIntegrity.quickCheckFailure(atPath: source.path) { - return .failure(String(localized: "This backup file is damaged and can't be restored (SQLite reports: \(complaint)). Your current data was left untouched. Try an earlier backup file.")) + return .failure(String(localized: "This backup file is damaged and can't be restored (SQLite reports: \(DatabaseIntegrity.readableComplaint(complaint))). Your current data was left untouched. Try an earlier backup file.")) } let dbURL = URL(fileURLWithPath: dbPath) @@ -444,11 +530,11 @@ enum DataBackup { removeIfPresent(dbURL) if sidecar != dbURL, fm.fileExists(atPath: sidecar.path) { try? fm.copyItem(at: sidecar, to: dbURL) - return .failure(String(localized: "Import failed its post-restore integrity check (SQLite reports: \(complaint)). Your previous data was rolled back automatically and is unchanged.")) + return .failure(String(localized: "Import failed its post-restore integrity check (SQLite reports: \(DatabaseIntegrity.readableComplaint(complaint))). Your previous data was rolled back automatically and is unchanged.")) } // Fresh install: there was no previous store to preserve, so removing the damaged // file (done above) restores the exact pre-import state — an empty slate. - return .failure(String(localized: "Import failed its post-restore integrity check (SQLite reports: \(complaint)). The damaged file was removed; there was no previous data to roll back.")) + return .failure(String(localized: "Import failed its post-restore integrity check (SQLite reports: \(DatabaseIntegrity.readableComplaint(complaint))). The damaged file was removed; there was no previous data to roll back.")) } // Restore sidecars only for legacy plain-SQLite backups whose WAL wasn't @@ -591,14 +677,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..b7a922925a --- /dev/null +++ b/Strand/Data/DayCycleIntelligenceIntegration.swift @@ -0,0 +1,362 @@ +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 + } + /// A cycle's Effort and calories, with the key of the inputs they were computed from. + fileprivate struct CachedLoad { + let key: String; let strain: Double?; let calories: Double? + } + final class Cache { + fileprivate var cycles: [String: CachedCycle] = [:] + fileprivate var loads: [String: CachedLoad] = [:] + } + 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 }) } + cache.loads = cache.loads.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) } + } + 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) + // Every pass re-read each cycle's full day of 1 Hz heart rate from every owner and re-scored it, + // for all 21 cycles, although only the open one gains samples between syncs. On a replayed + // phone database this was the costliest step of a pass in which every night was otherwise + // reused (6–12 s of a 16 s pass). The index-only count and newest timestamp per owner witness + // the heart rate the same way the day cache does, so a closed cycle is scored once. + var hrWitness: [String] = [] + if hrEndInclusive >= window.onset { + for owner in owners { + let fp = try? await store.hrFingerprint(deviceId: owner, from: window.onset, to: hrEndInclusive) + hrWitness.append("\(owner)=\(fp.map { "\($0.count):\($0.maxTs)" } ?? "unread")") + } + } + let loadKey = "\(window.onset)-\(window.endExclusive)|\(hrWitness.joined(separator: ","))" + + "|rhr=\(restingHR)|max=\(effectiveMaxHR.map { "\($0)" } ?? "nil")|\(effortMethod)|\(profile.cacheKey)" + let load: CachedLoad + if let hit = cache.loads[window.sleepId], hit.key == loadKey, !hrWitness.contains(where: { $0.hasSuffix("=unread") }) { + load = hit + } else { + 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 } + load = CachedLoad( + key: loadKey, + strain: StrainScorer.strain(cycleHR, maxHR: effectiveMaxHR, restingHR: restingHR, + method: effortMethod, sex: profile.sex), + calories: cycleHR.isEmpty ? nil : Calories.estimateDayCalories( + cycleHR, profile: profile, hrmax: effectiveMaxHR, restingHR: restingHR)) + cache.loads[window.sleepId] = load + } + if let strain = load.strain { strains[day] = strain } + if let kcal = load.calories { calories[day] = kcal } + 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/HapticPrefs.swift b/Strand/Data/HapticPrefs.swift index 8788184185..833c1a8678 100644 --- a/Strand/Data/HapticPrefs.swift +++ b/Strand/Data/HapticPrefs.swift @@ -13,6 +13,10 @@ enum HapticPrefs { // Double-tap is deliberately NOT here: it's already opt-in via the DoubleTapAction picker. static let breathing = "haptics.breathing" static let intervals = "haptics.intervals" + /// The Lift Log rest timer's strap buzz, five seconds before the rest ends. Its own key rather + /// than sharing `intervals`: someone who wants a silent interval timer at home may well still + /// want the buzz at the rack, where the phone is face-down on a bench. + static let liftRest = "haptics.liftRest" static let liveSession = "haptics.liveSession" static let workout = "haptics.workout" diff --git a/Strand/Data/IntelligenceEngine.swift b/Strand/Data/IntelligenceEngine.swift index e30bf429a9..124a96f839 100644 --- a/Strand/Data/IntelligenceEngine.swift +++ b/Strand/Data/IntelligenceEngine.swift @@ -1,3 +1,4 @@ +import Dispatch import Foundation import Combine import WhoopProtocol @@ -21,6 +22,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 @@ -44,6 +46,9 @@ final class IntelligenceEngine: ObservableObject { /// `defer` re-invokes `analyzeRecent(force: true)` ONCE when it clears. A single re-arm (the flag is /// cleared BEFORE the re-invoke) bounds it to one extra pass , no recompute storm. private var pendingForcedRescore = false + /// Uptime the pass holding `computing` started at, and how many days it covers; nil when none is running. + private var runningPassStart: UInt64? + private var runningPassDays = 0 /// #899 heal bound: true while the last heal already re-armed a rescore, so a heal firing again on /// the very next pass cannot re-arm a second time (the Android twin is hard-bounded to exactly one /// re-pass; this mirrors it). Reset by any pass whose heal finds nothing, restoring the budget. @@ -63,12 +68,67 @@ final class IntelligenceEngine: ObservableObject { /// never crosses `.noopbak`. The engine is a single long-lived instance (AppModel), so this survives the /// storm's back-to-back passes the drain is made of. See `AnalyzeRecentDayCache` (StrandAnalytics). private var dayScanCache: [String: (key: String, scan: DayScan)] = [:] + /// UserDefaults key holding the persisted `stepsMotionCache` payload. Versioned in the key as well as + /// in the payload header so a format change cannot even be read, let alone half-parsed. + private static let stepsMotionCacheDefaultsKey = "analyzeRecent.stepsMotionCache.v1" + /// Whether this process has already seeded `stepsMotionCache` from `stepsMotionCacheDefaultsKey`. The + /// read happens once per process, not once per pass: after the first pass the in-memory cache is at + /// least as fresh as the payload, so re-reading it could only ever put back what we just pruned. + private var stepsMotionCacheLoaded = false + /// The payload currently in UserDefaults, so an unchanged cache does not rewrite it. A pass that reused + /// every day renders the string it read, and the passes that do so are the back-to-back ones an offload + /// storm is made of — writing ~4 KB on each of them to store what is already there is the cost this + /// avoids. Not an assumption about what the store does with an identical value: it simply is not asked. + private var stepsMotionCachePersisted = "" + /// Per-day steps-calibration motion folds, `[day: (key, motion)]`, keyed by `StepsMotionCache.cacheKey`. + /// In-memory and per-process exactly like `dayScanCache`; see `StepsMotionCache` for why this one needs + /// no config signature. Pruned to the calibration window each pass so it cannot grow without bound. + private var stepsMotionCache: [String: (key: String, motion: Double)] = [:] /// The scoring-config signature the `dayScanCache` entries were produced under (profile / baselines1 / /// tz / sleep need+consistency / habitual midsleep / stager toggles). Those feed `analyzeDay` but are /// pass-global, not in the per-day key, so when the current pass's signature differs every cached scan is /// potentially stale and the whole cache is dropped. Empty until the first pass. private var dayScanCacheConfigSig = "" + /// Names of the config-signature fields, in the exact order `dayCacheConfigSig` builds them. + /// + /// Kept beside the reader rather than at the construction site so that site stays a plain value list. + /// The cost is that the two must stay in step, which `changedConfigField` refuses to guess about when + /// they are not. Kotlin twin: `IntelligenceEngine.DAY_CACHE_CONFIG_FIELDS`. + /// + /// `nonisolated` for the same reason the reader below is: the engine is @MainActor, so a plain + /// `static let` inherits that isolation, and neither a nonisolated function nor a synchronous test + /// could then touch it. `[String]` is Sendable, so sharing it is safe. + nonisolated static let dayCacheConfigFields: [String] = [ + "hrvBaseline", "rhrBaseline", "age", "sex", "stepTicksPerStep", "maxHROverride", + "tzOffset", "sleepNeedHours", "sleepConsistency", "habitualMidsleep", + "experimentalSleepV2", "motionAwareWake", "deepHrvWindow", "spo2CandidateDisplay", + "effortMethod", "dayCycleMode", + ] + + /// Which config field(s) moved between two signatures, for the `configDropped` tally. + /// + /// `configDropped` said a 21-day re-score happened because the pass-global config changed, and stopped + /// there. A field log pointed at a rolling BASELINE as the likeliest mover, but naming it from the + /// outside is a guess; this names it from the data. + /// + /// Never guesses: "first" on the first drop of a process, because the signature starts EMPTY rather + /// than nil and there is nothing to diff against, and "unknown" when the two signatures do not have + /// the field count this list describes, because a mislabelled field sends a reader somewhere the data + /// never pointed. + /// + /// `nonisolated` because it is a pure function of its arguments: the engine is @MainActor, and without + /// this the rule would inherit that isolation and could not be driven from a synchronous test. + /// Kotlin twin: `IntelligenceEngine.changedConfigField`. + nonisolated static func changedConfigField(previous: String, current: String) -> String { + if previous.isEmpty { return "first" } + let a = previous.split(separator: "|", omittingEmptySubsequences: false) + let b = current.split(separator: "|", omittingEmptySubsequences: false) + guard a.count == b.count, b.count == dayCacheConfigFields.count else { return "unknown" } + let moved = b.indices.filter { a[$0] != b[$0] }.map { dayCacheConfigFields[$0] } + return moved.isEmpty ? "none" : moved.joined(separator: "+") + } + /// Who supplies the dashboard headline for a By-Day row. The By-Day card always shows NOOP's OWN /// on-device numbers, but the WHOLE-DASHBOARD value for the same day can come from an IMPORTED row /// that won the per-day merge (imports win field-by-field over computed , see Repository.mergeDaily). @@ -137,6 +197,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 @@ -187,6 +250,15 @@ final class IntelligenceEngine: ObservableObject { let primarySessionRHRCoverage: PrimarySessionRestingHR.Coverage? } + /// Exact pre-upgrade R-R-derived cells retained only while an unlabelled WHOOP 5 window is withheld. + private struct LegacyScoreSnapshot { + let avgHrv: Double + let recovery: Double? + let respRateBpm: Double? + let avgSdnn: Double? + let recoverySource: String? + } + struct Computed: Identifiable { let day: String let recovery: Double? @@ -212,6 +284,11 @@ final class IntelligenceEngine: ObservableObject { /// "+0.3 C vs your normal" with a relative tier tag; never a fake clinical absolute. var skinTempRel: SkinTempRelative? = nil var id: String { day } + + func withLegacyScore(hrv: Double, recovery: Double?) -> Computed { + Computed(day: day, recovery: recovery, strain: strain, sleepMin: sleepMin, hrv: hrv, rhr: rhr, + source: source, confidence: confidence, drivers: drivers, skinTempRel: skinTempRel) + } } /// Optional sink for the per-day scoring diagnostic, fed line-by-line into the SAME shareable strap @@ -251,10 +328,38 @@ final class IntelligenceEngine: ObservableObject { /// SAME span the floor came from, so the two numbers are directly comparable). Empty in-bed → nightMean /// is "nil". Counts/bpm only , no timestamps or PII. Pure so it's unit-tested directly and is the SAME /// line `analyzeRecent` ships. Byte-identical to the Android `rhrFloorMeanLogLine`. - /// #1331 diagnostic line: the night's computed respiratory rate (breaths/min) or "nil". Format kept - /// simple so it stays byte-identical to the Android `respRateLogLine`. - nonisolated static func respRateLogLine(day: String, respRateBpm: Double?) -> String { - "resp day=\(day) rpm=\(respRateBpm.map { String(format: "%.1f", $0) } ?? "nil")" + /// #1331 diagnostic line: the night's computed respiratory rate (breaths/min) or "nil". + /// + /// When the rate is nil the line now carries WHY, because "nil" on its own sent an investigation + /// across two subsystems to find out. The RSA estimate needs per-beat-accurate R-R, so + /// `HRVAnalyzer.beatValuesAreTrustworthy` refuses a stream whose intervals are not beat-to-beat + /// measurements — and on a WHOOP 4.0 carrying the #1008/#1118 over-count that gate is what empties + /// the card, on nearly every night, silently. Printing the fraction beside the boundary turns + /// "Respiratory: No data" from a mystery into a reading. + /// + /// It distinguishes the two cases rather than asserting one: below the boundary the gate refused the + /// R-R; at or above it the gate passed and the cause is one of the estimator's other exits (too few + /// beats, too short a span, too coarse a grid), which this deliberately does not guess between. + /// Omitting `beatAccurate` restores the original one-field line exactly, so a night that never + /// reached the HRV block reads as it always did. + /// + /// Byte-identical to the Android `respRateLogLine`. + nonisolated static func respRateLogLine(day: String, + respRateBpm: Double?, + beatAccurate: Double? = nil, + rrIntegrity: String? = nil) -> String { + let base = "resp day=\(day) rpm=\(respRateBpm.map { String(format: "%.1f", $0) } ?? "nil")" + guard respRateBpm == nil, let beatAccurate else { return base } + // "NaN" explicitly rather than via %.2f: C-style %f renders a non-finite as "nan" while the + // JVM renders it "NaN", so leaving it to the formatter would make the twin lines differ on + // exactly the input the gate treats specially. One spelling, chosen here, on both platforms. + let acc = beatAccurate.isNaN ? "NaN" : String(format: "%.2f", beatAccurate) + let gate = String(format: "%.2f", HRVAnalyzer.beatAccuracyMinFraction) + let integrity = rrIntegrity ?? "unknown" + if beatAccurate < HRVAnalyzer.beatAccuracyMinFraction { + return "\(base) beatAccurate=\(acc)<\(gate) rrIntegrity=\(integrity) — RSA gate refused the R-R" + } + return "\(base) beatAccurate=\(acc)>=\(gate) rrIntegrity=\(integrity) — gate passed, cause is elsewhere" } nonisolated static func rhrFloorMeanLogLine(day: String, floor: Int, inBedBpms: [Int]) -> String { @@ -296,11 +401,84 @@ 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. + // + // `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`. + // + // Which is why grav=0 alone can no longer name the outcome. A 5/MG overnight capture showed this + // line reading `no-motion` while the HR-only spine on the SAME pass kept four sessions, the + // longest 311 minutes, and handed them over as `provided=3`. The reader was told nothing could + // stage a night while the log above said something had. So the no-gravity case splits by whether + // anything was actually provided: + // + // no-motion no gravity, and nothing was provided either + // no-motion-provided-unused no gravity, sessions WERE provided, and the night is still empty — + // they went in and no night came out, which is a question about what + // dropped them rather than about the strap + // + // Note "provided", not "HR-only". With no gravity `providedSleep` is the HR-only spine's output + // in the no-hypnogram branch, but it is STORED sessions in the stored-hypnogram one, and from + // here the two are indistinguishable. Naming the source would repeat the very over-claim this + // split exists to remove. The `[sleep] hr-only gate` trace is what says which branch ran. + // + // With motion present the inputs were there and staging still produced nothing, which remains the + // case most worth investigating. + let reason: String + if gravCount > 0 { + reason = "staged-none" + } else { + reason = providedCount > 0 ? "no-motion-provided-unused" : "no-motion" + } + // #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 @@ -499,35 +677,46 @@ final class IntelligenceEngine: ObservableObject { /// Compute on-device scores for each of the last `maxDays` that actually has raw HR data. /// Personal baselines (HRV / resting HR) are folded from the imported history, so even the first /// live night can be scored against your norm. - func analyzeRecent(maxDays: Int = 21, force: Bool = true, skipIfUnchanged: Bool = false) async { + func analyzeRecent(maxDays: Int = 21, force: Bool = true, skipIfUnchanged: Bool = false, + triggerLabel: String? = nil) async { // #899-A: a concurrent pass already holds the lock. A NON-forced idle tick is safe to drop (the // in-flight pass already covers the same window). But a FORCED call is a real update path (a // post-backfill rescore after a sync) , dropping it would leave a freshly-synced night unscored // until the next cycle. Re-arm instead: flag it so the running pass's `defer` re-invokes once. - guard !computing else { if force { pendingForcedRescore = true }; return } + guard !computing else { + if force { + // Said once per running pass, not per trigger: a pass that holds the lock for hours otherwise + // turns every post-offload re-score into a silent no-op, and the log shows syncs but no scores. + if !pendingForcedRescore, let started = runningPassStart { + let heldFor = Int(Double(DispatchTime.now().uptimeNanoseconds &- started) / 1_000_000_000) + diagnosticSink?("re-score: queued behind a \(runningPassDays)-day pass running for \(heldFor) s", nil) + } + pendingForcedRescore = true + } + return + } guard let store = await repo.storeHandle() else { note = String(localized: "No on-device store yet."); return } guard let hrvCfg = Baselines.metricCfg["hrv"], let rhrCfg = Baselines.metricCfg["resting_hr"], 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 @@ -537,7 +726,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 @@ -545,7 +734,11 @@ final class IntelligenceEngine: ObservableObject { // import/edit/settings/recalibrate re-score — which changes scores WITHOUT changing the HR // fingerprint — always runs. Twin of the Android WhoopBleClient post-offload `newData` gate. if force, skipIfUnchanged, !wmKey.isEmpty, storedWatermark == wmKey { - diagnosticSink?("re-score: trigger=post-offload newData=no — skipped (nothing changed since last run)", nil) + // #2238: name the caller that actually skipped. This label was hardcoded while the post-offload + // path was the only one opting in; the resume opts in now, and a skip reported under someone + // else's name is the same wrong-caller trail #1538 lost three nights to. + let skipped = triggerLabel ?? "post-offload" + diagnosticSink?("re-score: trigger=\(skipped) newData=no — skipped (nothing changed since last run)", nil) return } // Attribute the re-score that is ABOUT TO RUN. `trigger=post-offload` was previously logged only on @@ -564,22 +757,45 @@ final class IntelligenceEngine: ObservableObject { // `newData=no` means the fingerprint already equals the watermark the last run advanced: a re-score // driven by the trigger, not by data (#1005 background battery). Diagnostic only; the pass runs // either way. Twin of the Android WhoopBleClient / AppViewModel attribution. - let trigger = !force ? "idle" : (skipIfUnchanged ? "post-offload" : "forced") + // #2238: a caller that knows its own name says so. The derivation below reads + // `skipIfUnchanged` as "post-offload", which was an exact witness while that was the only + // caller opting in; the #1538 resume now opts in too when its debt came from a completed + // pass, and labelling it post-offload would put the investigation on the wrong caller for + // the second time in this area. + let trigger = triggerLabel ?? (!force ? "idle" : (skipIfUnchanged ? "post-offload" : "forced")) let hadNew = wmKey.isEmpty || storedWatermark != wmKey diagnosticSink?("re-score: trigger=\(trigger) " + "newData=\(hadNew ? "yes" : "no (nothing changed since last run)")", nil) // #1005: time the whole pass — the trigger line above records WHY; this records how many nights // and how long (the CPU cost per run), so a re-score STORM is visible in the strap log. - let reScoreStart = Date() + // Uptime, not `Date()`: the elapsed figure below is banked as what a pass COSTS, and a wall clock + // also counts every minute the process spent suspended mid-pass. One overnight pass suspended by a + // sleeping phone banked 19 003 s, which then deferred every background re-score after it. + let reScoreStart = DispatchTime.now().uptimeNanoseconds + let reScoreCPUStart = RescoreBackgroundScheduler.processCPUSeconds() + let reScoreExpiriesAtStart = RescoreBackgroundScheduler.assertionExpiries computing = true + runningPassStart = reScoreStart + runningPassDays = maxDays // #1538: the pass is now past every gate and will do real work. Mark it started durably, so that a // process killed mid-pass leaves evidence a LATER process can read — the killed process itself gets // no chance to record anything. Cleared beside the watermark at the end; there is no early return // 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(passStarting: true) // #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. @@ -587,6 +803,7 @@ final class IntelligenceEngine: ObservableObject { // `computing` is already false, so its own `guard !computing` passes and it rescores the new data. defer { computing = false + runningPassStart = nil if pendingForcedRescore { pendingForcedRescore = false // Carry THIS pass's window into the re-pass: a heal firing during a wide one-shot pass @@ -689,8 +906,8 @@ 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, - windowEnd: now, offsetSec: tzOffset) + windowStart: nowLocalMidnight - maxDays * 86_400 - StreamReadCap.lookbackSeconds, + windowEnd: now, finishedBefore: nowLocalMidnight, 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 // learner just above — one personal trait per run, applied to the whole re-scored history so @@ -745,7 +962,12 @@ 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)) + // Zero the per-day probe counters so the line emitted after the steps phase describes THIS pass + // and never accumulates across the back-to-back passes an offload storm is made of. Must precede + // the day loop below, which is the scoring half of the owner probes. See `StoreProbeTally`. + _ = StoreProbeRecorder.take() // ── #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 // day); read them ONCE here too so the config signature can fold them without reaching into the @@ -777,11 +999,17 @@ final class IntelligenceEngine: ObservableObject { // But that is CORRECT invalidation, not churn to be quantized away — a night going from // half-loaded to complete really does change what every day should be scored against, and the // swings are large rather than drift, so no tolerance both preserves scores and stops the drop. + // What stops the churn instead is learning only from nights that finished before today + // (`computeHabitualSleep(finishedBefore:)`): the night still being synced was the one moving. // What keeps it affordable is that the post-backfill re-score is COALESCED on both platforms: iOS // debounces `lastSyncedAt` by 2 s (#755), Android gates on `analyzeAfterBackfillScheduled` plus a // trailing delay. So this fires once per completed backfill, not once per chunk. That coalescing is // load-bearing for the cache — removing it would reintroduce the #1402 storm in a form no signature // change can fix. + // Field names live in `dayCacheConfigFields`, in this exact order. Kept there rather than here so + // the construction stays a plain value list. Add or reorder here and add or reorder there: + // `changedConfigField` refuses to name anything when the counts disagree, but it cannot see a + // REORDER, which would quietly label the wrong field. let dayCacheConfigSig = [ String(describing: baselines1.hrv), String(describing: baselines1.restingHR), @@ -797,13 +1025,21 @@ 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 // and we write it back after `.value`. + // #2073: recorded, because a dropped cache leaves no entry to compare and the miss tally would + // otherwise stay silent on the very case it exists to explain. + var dayCacheConfigDropped = false + var dayCacheConfigMoved = "" if dayCacheConfigSig != dayScanCacheConfigSig { + dayCacheConfigMoved = Self.changedConfigField(previous: dayScanCacheConfigSig, + current: dayCacheConfigSig) dayScanCache.removeAll() dayScanCacheConfigSig = dayCacheConfigSig + dayCacheConfigDropped = true } let inDayScanCache = dayScanCache @@ -823,7 +1059,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() @@ -832,6 +1068,12 @@ final class IntelligenceEngine: ObservableObject { // diagnostic carried on `skippedDayLines`. var dayScanCacheLocal = inDayScanCache var dayCacheReused = 0 + // #2073: WHY a night missed, tallied by cause. The reuse count alone cannot separate "today's + // heart rate grew" from "something shared by all 21 keys moved", and those need different fixes. + var dayCacheMissBy: [String: Int] = [:] + // #2073: a cache dropped wholesale leaves NO entry to compare, so without this the tally would + // stay silent and the line would read reused=0/21 with nothing saying why. + if dayCacheConfigDropped { dayCacheMissBy["configDropped(\(dayCacheConfigMoved))"] = 1 } // #1538: per-phase cost tally. `prep` brackets the nine windowed store reads plus the // session matching that sits between them and `analyzeDay`; `score` brackets `analyzeDay` // itself. Emitted once per pass beside the reuse line. @@ -841,11 +1083,37 @@ 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 activeWhoop5RR = (try? await store.isWhoop5RRSource(deviceId: regActiveId)) ?? true + 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, + unlabelledAliasOfWhoop5: activeWhoop5RR && o == Repository.whoopSource) + } + var paceMark = DispatchTime.now().uptimeNanoseconds for offset in 0.. 0 { await RescoreBackgroundScheduler.paceIfBackgrounded(since: &paceMark) } let dayStart = nowLocalMidnight - offset * 86_400 let day = AnalyticsEngine.dayString(dayStart, offsetSec: tzOffset) // Read a generous window around the night that ends on `day`; the stager finds the span. - let from = dayStart - 30 * 3_600 + let from = dayStart - StreamReadCap.lookbackSeconds // Sleep read-window END — see `sleepReadWindowEnd`. let to = Self.sleepReadWindowEnd(dayStart: dayStart, nowLocalMidnight: nowLocalMidnight, @@ -864,9 +1132,11 @@ final class IntelligenceEngine: ObservableObject { // since we last scored it THIS session, skipping the 7 stream reads + `analyzeDay`. Gated to a // registered WHOOP owner (4.0 or 5/MG) via the UN-coalesced `forRegistryDevice` — which // returns nil for a ring/import/unknown, so the cache never treats one as a WHOOP (a ring's - // `providedSleep` could change a day without an HR move; a WHOOP always streams gravity, so - // its `providedSleep` is empty and the reuse is byte-identical). The per-day key folds the - // night's HR fingerprint (the SAME witness the whole-pass gate at the top trusts) and, for a + // `providedSleep` could change a day without an HR move; a WHOOP normally streams gravity, so + // its `providedSleep` is empty and the reuse is byte-identical — and since #1801 a WHOOP with + // NO gravity gets an HR-only `providedSleep` instead, which is derived from the very HR the + // key already fingerprints, so the reuse stays sound by a different route). The per-day key folds the + // night's HR fingerprint (a narrower per-day witness than the whole-pass raw-input gate) and, for a // 4.0, the window-wide skin anchor (a re-anchor from another night shifts that night's skin // conversion without moving its HR). A 5/MG banks skin-temp centidegrees directly — no // per-device raw anchor — so its anchor slot stays nil. Pass-global inputs (profile/ @@ -885,16 +1155,25 @@ 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 } skinAnchorResolvedOwners.insert(owner) } - if let fp = try? await store.hrFingerprint(deviceId: owner, from: from, to: to) { + if let fp = try? await store.hrFingerprint(deviceId: owner, from: from, to: to), + let streamFp = try? await store.dayStreamFingerprint(deviceId: owner, from: from, to: to) { let key = AnalyzeRecentDayCache.cacheKey( owner: owner, hrCount: fp.count, hrMaxTs: fp.maxTs, skinAnchorRaw: 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 + // session — force refresh included, since `force` only bypasses the whole-pass + // watermark gate above, never this one. Both reads are index-only aggregates + // over the same `(deviceId, ts)` keys; a miss costs the 7 full stream reads + // this gate exists to skip. + streams: streamFp + "|rrAlias5=\(activeWhoop5RR && owner == Repository.whoopSource)", // #1575: `hrvTraceActive &&` 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 @@ -902,10 +1181,19 @@ final class IntelligenceEngine: ObservableObject { // path exactly as it was. hrvWindowDetail: hrvTraceActive && dayStart == nowLocalMidnight) dayCacheKey = key - if let cached = dayScanCacheLocal[day], cached.key == key { - out.append(cached.scan) - dayCacheReused += 1 - continue + if dayScanCacheLocal[day] == nil { + // No entry at all: a first pass, a night new to the window, or a cache just + // dropped wholesale. Counted so a total miss always carries a cause. + dayCacheMissBy["absent", default: 0] += 1 + } + if let cached = dayScanCacheLocal[day] { + if cached.key == key { + out.append(cached.scan) + dayCacheReused += 1 + continue + } + let why = AnalyzeRecentDayCache.missReason(cachedKey: cached.key, freshKey: key) + dayCacheMissBy[why, default: 0] += 1 } } } @@ -916,7 +1204,7 @@ final class IntelligenceEngine: ObservableObject { // what decides whether narrowing the read windows is worth building at all. Measured, not // guessed, for the same reason the day-cache duration is. let tPrep0 = Date() - let hr = (try? await store.hrSamples(deviceId: owner, from: from, to: to, limit: 200_000)) ?? [] + let hr = await hrWindow.rows(owner: owner, from: from, to: to) guard hr.count >= 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. @@ -924,7 +1212,9 @@ 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 strictWhoop5RR = (try? await store.isWhoop5RRSource(deviceId: owner, + unlabelledAliasOfWhoop5: activeWhoop5RR && owner == Repository.whoopSource)) ?? true + let rr = await rrWindow.rows(owner: owner, from: from, to: to, allowReuse: !strictWhoop5RR) // `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 @@ -942,9 +1232,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)) ?? [] @@ -972,7 +1263,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 } @@ -1089,17 +1380,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( + day: day, attempted: false, reason: "stored-hypnogram", + gravRows: grav.count, storedNights: stored.count)) + providedSleep = stored + } else if !stored.isEmpty { + traceSink?(SleepStager.GateTrace.hrOnlyGateLine( + day: day, 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( + day: day, attempted: true, reason: "no-motion-no-hypnogram", + gravRows: grav.count, storedNights: 0)) + providedSleep = SleepStager.hrOnlySessions(day: day, 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, @@ -1141,10 +1473,20 @@ final class IntelligenceEngine: ObservableObject { // shipped windowed avgHrv. Built here (loop 1) where `rr` is in scope, but EMITTED in the // main-actor replay loop below (diagnosticSink is main-actor isolated), carried on `hrvDiag`. // Byte-identical to the Kotlin line. - let sleepRrRows = rr.filter { r in res.cachedSleep.contains { r.ts >= $0.startTs && r.ts < $0.endTs } } + // #2425: the MAIN night the #1118 gate judged, not every session of the day pooled over the + // gaps between them; see `AnalyticsEngine.hrvDiagnosticRows`. `hrvOverCounted` and the RSA + // resp gate below read the same rows, so they now agree with the gate too. + let sleepRrRows = AnalyticsEngine.hrvDiagnosticRows( + rr, mainNight: res.mainNightBlocks, + fallback: res.cachedSleep.map { SleepStageTotals.NightBlock(start: $0.startTs, end: $0.endTs) }) let sleepRr = sleepRrRows.map { Double($0.rrMs) } let hrvDiag: String? let hrvOverCounted: Bool? // #1118: nil = no in-sleep R-R (no HRV to caveat) + // #1331: the RSA gate's inputs, carried to the resp diagnostic below. Declared out here because + // the HRV block is one scope deeper; a night with no sleep R-R leaves them nil and the resp line + // falls back to its original one-field form. + var respGateAcc: Double? + var respGateIntegrity: String? if sleepRr.isEmpty { hrvOverCounted = nil // #1244: no in-sleep R-R means no HRV summary. If the whole night also detected NO @@ -1159,7 +1501,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 } @@ -1191,9 +1534,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 @@ -1202,6 +1548,7 @@ final class IntelligenceEngine: ObservableObject { // measurements: the per-record SUM is right to ~1% (meanNN and RHR stay correct and // WHOOP-validated) while the individual intervals are not. Gate on that too. let accVal = HRVAnalyzer.beatAccurateFraction(tsSec: ts, rrMs: sleepRr) + respGateAcc = accVal let acc = String(format: "%.2f", accVal) let sdnnField = HRVAnalyzer.beatSpreadIsTrustworthy(verdict) && HRVAnalyzer.beatValuesAreTrustworthy(beatAccurateFraction: accVal) @@ -1210,6 +1557,7 @@ final class IntelligenceEngine: ObservableObject { + "meanNN=\(ms(h.meanNN))ms rr=\(h.nInput)/\(h.nClean) rejected=\(rej)% coverage=\(cov) collapsedCov=\(colCov) dupBeats=\(dup) " + "beatAccurate=\(acc) " + "rrIntegrity=\(verdict.rawValue)" + respGateIntegrity = verdict.rawValue // #1008: on an OVER-COUNT night only, append a raw-row sample around the densest second // (carried as a second \n-joined line, split back apart at the emit site) so the // over-count's MECHANISM is readable from the always-on log — clean nights stay quiet. @@ -1317,15 +1665,25 @@ 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. - let respLine: String? = Self.respRateLogLine(day: res.daily.day, respRateBpm: res.daily.respRateBpm) + let respLine: String? = Self.respRateLogLine(day: res.daily.day, + respRateBpm: res.daily.respRateBpm, + beatAccurate: respGateAcc, + rrIntegrity: respGateIntegrity) // #103/queue-11a: SpO₂ candidate nightly mean. Only computed when the display toggle is // ON, and the transform is device-conditional (#1086-style brand lookup, matching // `skinTempWornToleranceSec`'s idiom just above): a WHOOP owner averages the in-band @@ -1368,10 +1726,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) @@ -1398,9 +1758,16 @@ final class IntelligenceEngine: ObservableObject { // so counting it against the ratio made a healthy cache look broken and put a floor under how // good the number could ever get. On a store with gaps the old form could not reach `21/21` even // in principle, which is exactly the misreading #1538 opened with. + // #2073: the miss tally rides the same line. Absent when nothing was cached to miss, so a + // first pass and a healthy pass both read exactly as before. + var missBy = "" + if !dayCacheMissBy.isEmpty { + let parts = dayCacheMissBy.sorted { $0.key < $1.key }.map { "\($0.key):\($0.value)" } + missBy = " missBy=" + parts.joined(separator: ",") + } skippedDayLines.append("analyzeRecent dayCache reused=\(dayCacheReused)/" + "\(dayCacheReused + dayCacheCacheable) " - + "size=\(dayScanCacheLocal.count) days=\(maxDays)") + + "size=\(dayScanCacheLocal.count) days=\(maxDays)\(missBy)") // #1538: where the pass actually goes. `prep` is the nine windowed store reads plus the // session matching between them; `score` is `analyzeDay`. The two do not sum to the pass // total — pass 2, the baseline folds and the reconciliation are outside this loop — so read @@ -1409,11 +1776,35 @@ 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, + hrOwnerFlips: hrWindow.ownerFlips, rrOwnerFlips: rrWindow.ownerFlips, + hrReuseOff: hrWindow.reuseOffReads, rrReuseOff: rrWindow.reuseOffReads)) return (out, skippedDayLines, dayScanCacheLocal) }.value // #1005: write the loop's updated reuse cache back to the (main-actor) stored property. The pass ran // to completion above (`.value` awaited), so there is no concurrent access. dayScanCache = updatedDayScanCache + // #1538: the pass after the day loop was never measured. The cost line above brackets the loop and + // is emitted the moment it returns, so a pass whose time went somewhere later reported a small + // prep/score and no account of the rest — which is where the steps calibration was re-folding sixty + // days of gravity every pass, unseen. Stamp each phase as it completes; `AnalysisPhaseTally` renders + // them in execution order beside `re-score: done`. `Date()` matches the clock the loop's own tally + // uses, and the formatter floors a backwards clock step at zero. + var postLoopPhases: [(name: String, seconds: Double)] = [] + var postLoopMark = Date() + func markPostLoopPhase(_ name: String) { + let now = Date() + postLoopPhases.append((name: name, seconds: now.timeIntervalSince(postLoopMark))) + postLoopMark = now + } // #714: replay each skipped day's diagnostic now that we're back on the main actor (diagnosticSink // is MainActor-bound). Always-on , not gated behind a test mode, mirroring the Kotlin `diag` sink. @@ -1464,6 +1855,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. @@ -1482,6 +1874,7 @@ final class IntelligenceEngine: ObservableObject { detectionFunnel: res.detectionFunnel)) } + markPostLoopPhase("dayReplay") // ── Seed the baseline from the UNION of imported nightly history + the values just computed. // THIS is the BLE-only recovery fix: the "-noop" nightly avgHrv/restingHr finally feed the // baseline so a strap-only user crosses Baselines.minNightsSeed and recovery lights up. @@ -1589,22 +1982,23 @@ 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, to: now, limit: 100_000)) ?? [] + markPostLoopPhase("baselines") // ── Pass 2: re-score ONLY recovery against the now-seeded baseline (cheap, baseline-dependent); // every other field was computed once in pass 1. Recovery stays nil until the HRV baseline is // usable (≥ minNightsSeed valid nights) , honest cold-start, via RecoveryScorer's usable gate. var out: [Computed] = [] var dailies: [DailyMetric] = [] var cachedSleep: [CachedSleepSession] = [] - var workoutRows: [WorkoutRow] = [] // #510: backfilled fields for a REAL (non-detected) row a dropped bout collided with, grouped by - // the deviceId it must be upserted under (see the collision branch below) — never mixed into - // `workoutRows`, which is always written under `computedId`. + // the deviceId it must be upserted under (see the collision branch below). The detector remains an + // analytics/enrichment input, but the opt-in confirmation card is now the only creator of a new + // visible workout; legacy `sport="detected"` rows are preserved rather than reconciled here. var backfilledByDevice: [String: [WorkoutRow]] = [:] // Rest composite (0–100) per computed night, persisted as the `sleep_performance` metric // series so the dashboard's Rest score reflects the new composite, not raw efficiency. @@ -1622,6 +2016,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 / @@ -1653,12 +2090,23 @@ final class IntelligenceEngine: ObservableObject { // CAPTURE-B (#814/#799): the universal dayOwner line rides every export, so its gate is "ANY mode // active" (TestCentre.active(.universal) == anyActive). Read once here, like the other gates. let universalTraceActive = TestCentre.active(.universal) - // Workouts & GPS test mode (#975): read the zero-cost gate ONCE before the scoring loop so the - // detected-bout persist/drop decision can emit ONE `.workouts` line per derived bout. Without this - // the auto path produced NO trace at all (the "mode was on but produced NO trace" report), so an - // "auto workout appeared then vanished" could not be explained from an export. Diagnostic only. + // Workouts & GPS test mode (#975/#2187): read the zero-cost gate ONCE before the scoring loop so + // each analytics-only/backfill decision can emit one `.workouts` line per derived bout. Diagnostic + // only; this path no longer publishes or reconciles generic workout rows. let workoutsTraceActive = TestCentre.active(.workouts) + let oldestDay = AnalyticsEngine.dayString(nowLocalMidnight - (maxDays - 1) * 86_400, + offsetSec: tzOffset) + let newestDay = AnalyticsEngine.dayString(nowLocalMidnight, offsetSec: tzOffset) + let strictCanonicalAlias = (try? await store.isWhoop5RRSource(deviceId: regActiveId)) ?? true + let legacySnapshots = await Self.legacyScoreSnapshots( + store: store, computedId: computedId, from: oldestDay, to: newestDay, + fresh: scoredNights.map { $0.daily }, ownerByDay: resolvedScoreOwnerByDay, + nowLocalMidnight: nowLocalMidnight, now: now, offsetSec: tzOffset, + maxDays: maxDays, strictCanonicalAlias: strictCanonicalAlias) + var appliedLegacySnapshots: [String: LegacyScoreSnapshot] = [:] + var paceMark = DispatchTime.now().uptimeNanoseconds for night in scoredNights { + await RescoreBackgroundScheduler.paceIfBackgrounded(since: &paceMark) // #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. `endTs` // is stable under a bedtime edit (only the onset/`startTsAdjusted` moves), so end-day is the @@ -1666,16 +2114,19 @@ 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) - let recovery = recomputeRecovery(daily, baselines2) + daily = DayCycleIntelligenceIntegration.applying(physiologicalSteps, to: daily) + daily = Self.recomputeRecoveryDaily(daily, nightlySkinTempC: night.nightlySkin, + baselines: baselines2) + let recovery = daily.recovery + let skinDev = daily.skinTempDevC // 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 // score is RecoveryScorer.recovery verbatim, so the `recovery` written above is unchanged. if recoveryTraceActive { for line in recoveryTraceLines(daily, baselines2) { diagnosticSink?(line, .recovery) } } - let skinDev = recomputeSkinTempDev(night.nightlySkin, baselines2.skinTemp) let source = DaySource.classify(day: daily.day, importedWhoopDays: importedWhoopDays, appleHealthDays: appleHealthDays) // SHARED CONTRACT enrichment: the ordered Charge driver list + the relative skin-temp marker, @@ -1687,7 +2138,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)) @@ -1753,10 +2204,18 @@ final class IntelligenceEngine: ObservableObject { hrRows: owned?.hrRows ?? 0, importedWhoop: importedWhoopDays.contains(daily.day), importedApple: appleHealthDays.contains(daily.day)), .universal) } - dailies.append(daily.with(recovery: recovery, skinTempDevC: skinDev)) + // Persist the ABSOLUTE beside the deviation derived from it (#1636). Same value, same pass, + // same `night.nightlySkin` the line above takes the deviation from — so the two can never + // describe different nights, and no second derivation exists to drift. + dailies.append(daily) 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 @@ -1783,9 +2242,9 @@ final class IntelligenceEngine: ObservableObject { restPoints.append(MetricPoint(day: daily.day, key: "rhr_primary_session_duration_s", value: cov.durationSec)) } cachedSleep.append(contentsOf: night.cachedSleep) - // Persist the detected workouts the pipeline already computes (previously discarded). - // Skip any bout overlapping a real imported/manual workout so import+wear users don't - // double-count. sport = "detected"; energyKcal is the APPROXIMATE Keytel/BMR total. + // Keep the analytics detector as an enrichment input for real imported/manual workouts, but do + // not publish its generic bouts. The opt-in Today card is the single confirmation boundary for + // creating a visible workout; this pass neither creates nor reconciles `sport="detected"` rows. // #1545: where the detector lost every candidate workout on this day, emitted BEFORE the // per-bout loop so it is present even when that loop runs zero times — which is exactly the // report it exists for. The `effort bout` line below explains a bout that exists; a strap log @@ -1836,19 +2295,14 @@ final class IntelligenceEngine: ObservableObject { } continue } - workoutRows.append(WorkoutRow(startTs: s.start, endTs: s.end, - sport: "detected", source: computedId, - durationS: s.durationS, energyKcal: s.caloriesKcal, - avgHr: avgBpm, maxHr: s.peakHR, - strain: s.strain, distanceM: nil, - zonesJSON: nil, notes: nil, steps: nil)) if workoutsTraceActive { diagnosticSink?(WorkoutsTrace.detectedBoutLine( - verdict: "persisted", durMin: durMin, avgBpm: avgBpm), .workouts) + verdict: "analyticsOnly", durMin: durMin, avgBpm: avgBpm), .workouts) } } } + markPostLoopPhase("score2") // ── Apple-Watch recovery fold (M1 "Watch as a device") ────────────────────────────────────── // A watch-only user has apple-health DAILY aggregates (SDNN HRV + resting HR) but no raw stream, so // the raw-HR scoring loop above never touched their days and the import left `recovery: nil`. Fill @@ -1867,7 +2321,8 @@ final class IntelligenceEngine: ObservableObject { let appleByDay = Dictionary(appleRows.map { ($0.day, $0) }, uniquingKeysWith: { a, _ in a }) for w in watchScored { guard let recovery = w.recovery, let row = appleByDay[w.day] else { continue } - appleRecoveryRows.append(row.with(recovery: recovery, skinTempDevC: row.skinTempDevC)) + appleRecoveryRows.append(row.with(recovery: recovery, skinTempDevC: row.skinTempDevC, + skinTempC: row.skinTempC)) // Surface the watch-only day in the By-Day list with its watch provenance + confidence. out.append(Computed(day: w.day, recovery: recovery, strain: row.strain, sleepMin: row.totalSleepMin, hrv: row.avgHrv, rhr: row.restingHr, @@ -1877,6 +2332,7 @@ final class IntelligenceEngine: ObservableObject { _ = try? await store.upsertDailyMetrics(appleRecoveryRows, deviceId: Repository.appleHealthSource) } + markPostLoopPhase("watchFold") // #277 migration: the loop now keys days by the LOCAL calendar day. A prior run (before this // fix) wrote the SAME period under UTC-day keys, so without a cleanup an off-by-one UTC row and // the new local row would coexist as duplicate days. We reconcile the COMPUTED ("-noop") daily @@ -1891,10 +2347,6 @@ final class IntelligenceEngine: ObservableObject { // only , imported "my-whoop" rows are never touched (a BLE-only WHOOP 4.0 user has no import // fallback). Rows older than the window keep their old keys (cosmetic off-by-one, acceptable). // yyyy-MM-dd sorts chronologically, so the string range IS a date range. - let oldestDay = AnalyticsEngine.dayString(nowLocalMidnight - (maxDays - 1) * 86_400, - offsetSec: tzOffset) - let newestDay = AnalyticsEngine.dayString(nowLocalMidnight, offsetSec: tzOffset) - // ── Source-only Charge/Rest fold for wearable imports (Oura / Fitbit / Garmin / Health Connect) ── // Same honesty gap the watch fold above closes (#823), extended to the other import-only sources: a // user who ONLY imports an Oura/Fitbit/Garmin export (or Health Connect) has DAILY aggregates (HRV + @@ -1916,7 +2368,8 @@ final class IntelligenceEngine: ObservableObject { let byDay = Dictionary(rows.map { ($0.day, $0) }, uniquingKeysWith: { a, _ in a }) for w in Self.watchRecoveries(appleRows: rows, strapRecoveryDays: importScoredDays) { guard let recovery = w.recovery, let row = byDay[w.day] else { continue } - let scored = row.with(recovery: recovery, skinTempDevC: row.skinTempDevC) + let scored = row.with(recovery: recovery, skinTempDevC: row.skinTempDevC, + skinTempC: row.skinTempC) dailies.append(scored) importScoredDays.insert(w.day) resolvedScoreOwnerByDay[w.day] = source @@ -1929,6 +2382,24 @@ final class IntelligenceEngine: ObservableObject { } } + // Apply the exact snapshot only after current-score traces and derived series were produced from the + // current inputs. A legacy snapshot must not masquerade as a value recalculated against today's + // baselines; it only protects persisted R-R-derived cells from a destructive nil overwrite. + var persistedDailies = dailies + for index in persistedDailies.indices { + let fresh = persistedDailies[index] + guard let snapshot = legacySnapshots[fresh.day], fresh.avgHrv == nil, + (fresh.totalSleepMin ?? 0) > 0 else { continue } + persistedDailies[index] = fresh.with(avgHrv: snapshot.avgHrv, recovery: snapshot.recovery, + respRateBpm: snapshot.respRateBpm, + avgSdnn: snapshot.avgSdnn) + appliedLegacySnapshots[fresh.day] = snapshot + } + for index in out.indices { + guard let snapshot = appliedLegacySnapshots[out[index].day] else { continue } + out[index] = out[index].withLegacyScore(hrv: snapshot.avgHrv, recovery: snapshot.recovery) + } + // Persist the computed scores under a dedicated "-noop" source so the WHOLE dashboard // (Today / Recovery / Strain / Sleep / Trends), not just this screen, reads them. The // Repository merges these UNDER any imported "my-whoop" rows, so a real WHOOP import @@ -1943,11 +2414,18 @@ final class IntelligenceEngine: ObservableObject { // resolver override). Persist scores + provenance atomically so a failed write can never label an // older score with a newer provider. The last row for a duplicate day wins, matching the upsert. var provenanceByCell: [String: ScoreInputProvenanceRow] = [:] - for daily in dailies { + for daily in persistedDailies { guard let source = resolvedScoreOwnerByDay[daily.day] else { continue } if daily.recovery != nil { - provenanceByCell["\(daily.day)\u{1F}recovery"] = - ScoreInputProvenanceRow(day: daily.day, key: "recovery", sourceId: source) + if let snapshot = appliedLegacySnapshots[daily.day] { + if let oldSource = snapshot.recoverySource { + provenanceByCell["\(daily.day)\u{1F}recovery"] = + ScoreInputProvenanceRow(day: daily.day, key: "recovery", sourceId: oldSource) + } + } else { + provenanceByCell["\(daily.day)\u{1F}recovery"] = + ScoreInputProvenanceRow(day: daily.day, key: "recovery", sourceId: source) + } } if daily.strain != nil { provenanceByCell["\(daily.day)\u{1F}strain"] = @@ -1959,13 +2437,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, + dailyMetrics: persistedDailies, 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 @@ -1979,13 +2473,14 @@ final class IntelligenceEngine: ObservableObject { // covers the window, so eviction runs exactly as before; `persistComputedScores` is guarded the // same way, so an empty pass leaves the persisted window untouched. Twin of the Android // WhoopDao.replaceComputedScoreWindow empty guard. - if !dailies.isEmpty { - let freshKeys = Set(dailies.map { $0.day }) + if !persistedDailies.isEmpty { + let freshKeys = Set(persistedDailies.map { $0.day }) let existingWindow = (try? await store.dailyMetrics(deviceId: computedId, from: oldestDay, to: newestDay)) ?? [] for stale in existingWindow where !freshKeys.contains(stale.day) { _ = try? await store.deleteDailyMetrics(deviceId: computedId, from: stale.day, to: stale.day) } } + markPostLoopPhase("persist") // ── Fitness Age (Phase 2) , weekly, keyed to the week's Saturday ──────────────────────────── // Roll the last 7 computed days into the Nes/HUNT inputs and upsert a weekly Fitness Age (+ an // optional VO₂max when a waist is set) under the same "-noop" source. Idempotent on the Saturday @@ -2043,6 +2538,7 @@ final class IntelligenceEngine: ObservableObject { ], deviceId: computedId) } + markPostLoopPhase("weekly") // ── Steps ESTIMATE (WHOOP 4.0) , DAILY, keyed to each strap-only day ──────────────────────── // A WHOOP 4.0 sends no step count over BLE, so for days the phone DIDN'T also count steps we // estimate them: calibrate the strap's daily MOTION VOLUME against the phone's real step count @@ -2062,21 +2558,39 @@ final class IntelligenceEngine: ObservableObject { nowLocalMidnight - (stepsCalDays - 1) * 86_400, offsetSec: tzOffset) // ── FIX 2 (main-actor jank): hoist the 60-day steps-calibration STORE READS off the main actor ── // Same residual stall FIX 1 fixed, smaller scale: this class is `@MainActor`, so each `await store.…` - // below resumes its continuation ON the main actor , the apple-health read + 60 per-day + // below resumes its continuation ON the main actor , the apple-health read + the per-day // owner-resolve/gravity reads add 60+ read-resumes of main-actor contention every analyzeRecent. // The reads touch NO `@Published`/`profile`/`registry`-isolated state , only the captured immutable - // inputs (calOldest/newestDay/nowLocalMidnight/tzOffset/regDevices/regActiveId), the `WhoopStore` - // actor, the nonisolated `registry`, the nonisolated-static `resolveDayOwner`, and the pure static - // `StepsEstimateEngine.dayMotionIntensity`. So we hoist the whole gather into ONE - // `Task.detached(priority:.utility)` whose continuations resume OFF the main actor, returning two - // plain `[String: Double]` value types (fully Sendable , even cleaner than FIX 1's [DayScan]). The - // pure `StepsEstimateEngine.calibrate/estimate/status` fit + the `profile.*` assignments stay on the - // main actor below, consuming those dictionaries. Same per-day inputs (same window, same owner + // inputs (calOldest/newestDay/nowLocalMidnight/tzOffset/regDevices/regActiveId/the incoming motion + // cache), the `WhoopStore` actor, the nonisolated `registry`, the nonisolated-static + // `resolveDayOwner`, and the pure static `StepsEstimateEngine.dayMotionIntensity`. So we hoist the + // whole gather into ONE `Task.detached(priority:.utility)` whose continuations resume OFF the main + // actor, returning plain value types only: two `[String: Double]`, the updated + // `[String: (key: String, motion: Double)]` motion cache, and its log line. All fully Sendable , + // tuples of Sendable elements, the same shape the day loop already returns its `dayScanCache` in. + // The pure `StepsEstimateEngine.calibrate/estimate/status` fit + the `profile.*` assignments stay on + // the main actor below, consuming those dictionaries. Same per-day inputs (same window, same owner // resolution, same `m > 0` / `steps > 0` filters), same outputs , only the executor the reads resume - // on changes. Bind `deviceId` (a MainActor instance `let`) to a local Sendable `String` so the - // @Sendable detached closure captures the VALUE, never `self`, exactly as FIX 1's `ownerFallbackId`. + // on changes, and — since the motion cache — which days need their gravity read at all. Bind + // `deviceId` (a MainActor instance `let`) to a local Sendable `String` so the @Sendable detached + // closure captures the VALUE, never `self`, exactly as FIX 1's `ownerFallbackId`. let stepsFallbackId = deviceId - let (refStepsByDay, motionByDay): ([String: Double], [String: Double]) = + // Seed the fold cache from storage on the first pass of the process. Without this the sixty-day + // fold is re-paid in full after every relaunch — the cache's whole win is a repeat, and a relaunch + // is a repeat the process boundary hid. Nothing pass-global feeds the fold, so a payload written by + // a previous launch is as good as one written by the previous pass; see `StepsMotionCache`. + if !stepsMotionCacheLoaded { + stepsMotionCacheLoaded = true + if let raw = UserDefaults.standard.string(forKey: Self.stepsMotionCacheDefaultsKey) { + stepsMotionCache = StepsMotionCache.deserialize(raw) + // Seed the write guard with what is actually stored. A payload this build renders identically + // then costs no write at all; one carrying a line we dropped is rewritten clean on this pass. + stepsMotionCachePersisted = raw + } + } + let inStepsMotionCache = stepsMotionCache + let (refStepsByDay, motionByDay, updatedStepsMotionCache, stepsMotionLogLine): + ([String: Double], [String: Double], [String: (key: String, motion: Double)], String) = await Task.detached(priority: .utility) { // Phone reference steps per day, from the apple-health daily rows (steps > 0 only). // #693: read `appleDaily`, NOT `dailyMetrics`. Apple-Health import writes the phone step count into @@ -2089,21 +2603,84 @@ final class IntelligenceEngine: ObservableObject { for r in appleRows { if let s = r.steps, s > 0 { refSteps[r.day] = Double(s) } } // Per-day motion volume over the calibration window, read from the owner-resolved strap streams. // (Owner resolution mirrors the scoring loop; one device installs resolve to `deviceId`.) + // + // Each day is re-folded only when its own gravity witness moved. The fold is pure over that one + // stream, so an unchanged witness means an unchanged volume — see `StepsMotionCache`. The + // fingerprint is a COUNT/MAX aggregate over the same `(deviceId, ts)` index the read walks, so a + // hit replaces a read capped at 200,000 rows with one that returns a single row. var motion: [String: Double] = [:] + var motionCacheLocal = inStepsMotionCache + var motionReused = 0 + var motionFolded = 0 + var motionWindow: Set = [] for off in 0.. 0 { motion[dayKey] = m } } - return (refSteps, motion) + motionCacheLocal = motionCacheLocal.filter { motionWindow.contains($0.key) } + let motionLog = StepsMotionCache.logLine(reused: motionReused, folded: motionFolded, + size: motionCacheLocal.count) + return (refSteps, motion, motionCacheLocal, motionLog) }.value + stepsMotionCache = updatedStepsMotionCache + // Write the pruned cache back, only when it moved. `serialize` renders sorted, so a pass that reused + // every day produces the string already stored and skips the write entirely. + let stepsMotionPayload = StepsMotionCache.serialize(stepsMotionCache) + if stepsMotionPayload != stepsMotionCachePersisted { + stepsMotionCachePersisted = stepsMotionPayload + UserDefaults.standard.set(stepsMotionPayload, forKey: Self.stepsMotionCacheDefaultsKey) + } + diagnosticSink?(stepsMotionLogLine, nil) + // What the pass spent on its per-day probe queries, beside the `stepsMotion reused=N/M` line above. + // Together they say whether a warm pass that folded nothing still went into the round trips. + let probeCounts = StoreProbeRecorder.take() + diagnosticSink?(StoreProbeTally.logLine([ + (name: "dayOwner", calls: probeCounts.dayOwner.calls, seconds: probeCounts.dayOwner.seconds), + (name: "ownerHr", calls: probeCounts.ownerHr.calls, seconds: probeCounts.ownerHr.seconds), + (name: "gravityFp", calls: probeCounts.gravityFp.calls, seconds: probeCounts.gravityFp.seconds), + ]), nil) + // #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 } @@ -2161,6 +2738,7 @@ final class IntelligenceEngine: ObservableObject { } } + markPostLoopPhase("steps") // Drop any freshly-detected session that overlaps a night the user has already hand-corrected. // A detected onset can drift second-to-second as more raw data arrives, so without this the // re-detected night would upsert as a SECOND row beside the edited one (different startTs ⇒ no @@ -2208,6 +2786,7 @@ final class IntelligenceEngine: ObservableObject { for (start, states) in sleepStateByStart { _ = try? await store.persistSessionSleepState(deviceId: computedId, sessionStart: start, states: states) } + markPostLoopPhase("sleepWrite") // ── 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 @@ -2223,8 +2802,16 @@ final class IntelligenceEngine: ObservableObject { // re-read as `providedSleep` and re-detected every pass, so one night ballooned to 14 rows / 9 // "naps". Dedup each device's rows AMONG THEMSELVES and delete stale copies under that SAME id // (never across ids, so a survivor is never orphaned under an id the day-owner read skips). - // `freshStarts` (this pass's computed bank witness) only matches the computedId rows; the others - // fall back to longest-wins, the read-side dedup's own default. Sorted for a deterministic order. + // `freshStarts` (this pass's computed bank witness) is handed ONLY to the computedId sweep; every + // other id falls back to longest-wins, the read-side dedup's own default. It used to be passed to + // every id on the claim that it "only matches the computedId rows" — false on an Oura day, where the + // pass's sessions ARE the ring's `providedSleep` rows with `startTs` copied verbatim. The ring row + // the pass had READ was then ranked "fresh" in the ring's own sweep and outranked every fuller + // re-serve the ring banked while the pass was in flight (hours, when iOS suspends the app between + // the read and this heal): on 09-19/20 the heal deleted the 598-min full night one second after it + // landed and kept the 337-min row read at 04:14, so the day ended at 04:48 instead of 08:21. + // `SleepSessionDedup.healWitness` is the one shared rule (twin of Kotlin's). Sorted for a + // deterministic order. let healDeviceIds = Self.healDeviceIds(computedId: computedId, registeredIds: regDevices.map { $0.id }) // Compact shape of a row for the #1284 heal log — the two measures that adjudicate WHICH copy is // fuller (stage-segment count + decoded JSON length), in the SAME format as the dup-gen diagnostic @@ -2242,7 +2829,8 @@ final class IntelligenceEngine: ObservableObject { let healable = storedSessions.filter { (oldestDay...newestDay).contains(AnalyticsEngine.dayString($0.endTs, offsetSec: tzOffset)) } - let sweep = SleepSessionDedup.dedupe(healable, freshStarts: keptStarts) + let witness = SleepSessionDedup.healWitness(for: healId, computedId: computedId, keptStarts: keptStarts) + let sweep = SleepSessionDedup.dedupe(healable, freshStarts: witness) for stale in sweep.dropped { _ = try? await store.deleteSleepSession(deviceId: healId, startTs: stale.startTs) // #1284: log which copy was dropped and which survived, so the corpus can confirm the heal @@ -2274,19 +2862,15 @@ final class IntelligenceEngine: ObservableObject { } else { healRearmedThisCycle = false } - // Make re-detection idempotent across runs: clear the prior computed detected workouts in the - // scored window (a bout's startTs can drift as more HR arrives, which would otherwise orphan - // stale rows under the (deviceId,startTs,sport) key), then re-insert. - _ = try? await store.deleteWorkouts(deviceId: computedId, sport: "detected", - from: windowStart, to: now) - if !workoutRows.isEmpty { _ = try? await store.upsertWorkouts(workoutRows, deviceId: computedId) } - // #510: write back any real (manual/imported) rows a dropped detected bout backfilled, one - // upsert per owning deviceId (see the collision branch above for why these can't share the - // `computedId` batch above). + // #1735/#2187: never delete/reinsert legacy detected history during scoring. A disabled toggle, + // rescoring pass, missing stream or failed insert must not erase a workout the user already saw. + // #510: still write back any real (manual/imported) rows an analytics bout backfilled, one upsert + // per owning deviceId. for (devId, rows) in backfilledByDevice { _ = try? await store.upsertWorkouts(rows, deviceId: devId) } + markPostLoopPhase("sleepHeal") // #137: a manually-started workout is scored from sparse live HR at save time , near-zero // calories/strain on a 5/MG. Now that offloaded HR may cover the window, re-score the // under-sampled ones from that denser data. @@ -2312,22 +2896,37 @@ 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 + markPostLoopPhase("workouts") + // #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) } + markPostLoopPhase("tail") + diagnosticSink?(AnalysisPhaseTally.logLine(scope: "postLoop", postLoopPhases), nil) // #1538: clear the started-mark and bank how long a COMPLETED pass costs on this install. The // measurement is what lets `RescoreBackgroundPolicy` tell an install that finishes comfortably in a // 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 elapsed = Double(DispatchTime.now().uptimeNanoseconds &- reScoreStart) / 1_000_000_000 + let settled = RescoreBackgroundScheduler.markRescoreCompleted(seconds: elapsed, owedToken: owedToken) diagnosticSink?("re-score: done — scored \(scoredNights.count) night(s) in \(Int(elapsed * 1000)) ms (#1005)", nil) + diagnosticSink?(RescoreBackgroundScheduler.passCostLogLine( + cpuSeconds: RescoreBackgroundScheduler.processCPUSeconds().flatMap { end in reScoreCPUStart.map { end - $0 } }, + elapsedSeconds: elapsed, + assertionExpiries: RescoreBackgroundScheduler.assertionExpiries - reScoreExpiriesAtStart, + backgroundedAtEnd: RescoreBackgroundScheduler.isBackgrounded), 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 @@ -2368,9 +2967,15 @@ final class IntelligenceEngine: ObservableObject { devices: [PairedDevice], activeId: String, registry: DeviceRegistryStore, fallbackDeviceId: String) async -> String { - // A locked override wins outright and skips the presence checks entirely. - if let locked = (try? registry.dayOwner(day))?.deviceId { - return locked + // A locked override wins outright and skips the presence checks entirely. Timed because it runs on + // EVERY call, ahead of any presence probe, and the first cut of this instrumentation counted only + // the probe: that reported the cheap half of owner resolution while a warm pass still had seconds + // unaccounted for. See `StoreProbeRecorder`. + let lockedStarted = DispatchTime.now().uptimeNanoseconds + let lockedOwner = (try? registry.dayOwner(day))?.deviceId + StoreProbeRecorder.record(.dayOwner, nanos: DispatchTime.now().uptimeNanoseconds &- lockedStarted) + if let lockedOwner { + return lockedOwner } // No registry rows (shouldn't happen , v15 seeds one , but be safe): keep the legacy id. guard !devices.isEmpty else { return fallbackDeviceId } @@ -2387,23 +2992,42 @@ final class IntelligenceEngine: ObservableObject { return fallbackDeviceId } - var candidates: [DayOwnerResolver.Candidate] = [] - for d in liveDevices { + // Probe in PRIORITY ORDER and stop at the first candidate that has data. + // + // `DayOwnerResolver.resolve` is `candidates.filter(hasData).min(by: priority)`, so the winner is + // the lowest-priority-number candidate with data, and every probe after it cannot change the + // answer. Probing them anyway cost a query per candidate per day: on the 60-day steps window with + // two straps that is 120 per pass, and the active strap usually answers on the first. + // `DayOwnerResolverEquivalenceTests` pins this against the resolver itself rather than trusting + // the reasoning, because the two must not be allowed to drift. + // + // The probe is a scalar EXISTS now, not a fetched `LIMIT 1` row: the question is one bit, and it + // used to be answered by materialising a row and an `HRSample` to test an array for emptiness. + // + // #137: an activity-file ride ranks BELOW whole-day imports (priority 3 vs 2), so a full-day + // WHOOP CSV/cloud import keeps ownership of a day it has HR for; the ride only wins a day that + // nothing else covers (a strap-less day). Kotlin RegistryDayOwnerSource mirrors this ordering. + let ranked: [(id: String, priority: Int)] = liveDevices.map { d in let isImport = d.sourceKind == .cloudImport || d.sourceKind == .fileImport - // #137: an activity-file ride ranks BELOW whole-day imports (priority 3 vs 2), so a full-day - // WHOOP CSV/cloud import keeps ownership of a day it has HR for; the ride only wins a day that - // nothing else covers (a strap-less day). Kotlin RegistryDayOwnerSource mirrors this ordering. let priority: Int if d.id == activeId { priority = 0 } else if d.sourceKind == .activityFile { priority = 3 } else if isImport { priority = 2 } else { priority = 1 } - // Cheap presence check: a single HR row for this device in the night window is enough to - // mark it a candidate. (LIMIT 1 , not the full pull the caller does once an owner is chosen.) - let hasData = !((try? await store.hrSamples(deviceId: d.id, from: from, to: to, limit: 1)) ?? []).isEmpty - candidates.append(DayOwnerResolver.Candidate(deviceId: d.id, priority: priority, hasData: hasData)) + return (d.id, priority) } - return DayOwnerResolver.resolve(day: day, lockedOwner: nil, candidates: candidates) ?? fallbackDeviceId + // Sorted on (priority, ORIGINAL INDEX), not priority alone. Swift's `sorted` is not a stable sort + // by contract, so two candidates sharing a priority — which two non-active straps do, both being + // priority 1 — could come back in either order and the first-with-data probe would then pick + // either one. Kotlin's `sortedBy` is stable and its resolver's `minByOrNull` takes the first + // minimum, so leaving this to the sort would let the platforms disagree on a tie. Carrying the + // index makes the order total and matches Kotlin exactly. + .enumerated().sorted { ($0.element.priority, $0.offset) < ($1.element.priority, $1.offset) } + .map(\.element) + for c in ranked { + if (try? await store.hasHrInWindow(deviceId: c.id, from: from, to: to)) == true { return c.id } + } + return fallbackDeviceId } /// The strap family that wrote `owner`'s skin-temp rows (#938), so the nightly funnel converts the raw @@ -2475,12 +3099,61 @@ final class IntelligenceEngine: ObservableObject { if !updated.isEmpty { _ = try? await store.upsertWorkouts(updated, deviceId: deviceId) } } + /// Pass 1 has no seeded skin baseline. Attach the deviation before scoring so the score, + /// explanation, trace and persisted row all consume the same temperature. Internal for regression tests. + static func recomputeRecoveryDaily(_ daily: DailyMetric, nightlySkinTempC: Double?, + baselines: AnalyticsEngine.ProfileBaselines) -> DailyMetric { + let skinDev = recomputeSkinTempDev(nightlySkinTempC, baselines.skinTemp) + let input = daily.with(recovery: daily.recovery, skinTempDevC: skinDev, skinTempC: nightlySkinTempC) + return input.with(recovery: recomputeRecovery(input, baselines), skinTempDevC: skinDev, + skinTempC: nightlySkinTempC) + } + + /// Resolve conservative legacy snapshots before the computed-window upsert can replace them. The date + /// loop reuses the scorer's own day/window arithmetic instead of parsing calendar dates, keeping both + /// DST behaviour and today's shortened forward window identical to the scoring read. + private static func legacyScoreSnapshots( + store: WhoopStore, computedId: String, from: String, to: String, + fresh: [DailyMetric], ownerByDay: [String: String], + nowLocalMidnight: Int, now: Int, offsetSec: Int, maxDays: Int, + strictCanonicalAlias: Bool + ) async -> [String: LegacyScoreSnapshot] { + let existing = (try? await store.dailyMetrics(deviceId: computedId, from: from, to: to)) ?? [] + let existingByDay = Dictionary(existing.map { ($0.day, $0) }, uniquingKeysWith: { a, _ in a }) + var snapshots: [String: LegacyScoreSnapshot] = [:] + for day in fresh { + guard day.avgHrv == nil, let old = existingByDay[day.day], let oldHrv = old.avgHrv, + let owner = ownerByDay[day.day] else { continue } + var dayStart: Int? + for offset in 0.. Double? { + private static func recomputeRecovery(_ daily: DailyMetric, _ baselines: AnalyticsEngine.ProfileBaselines) -> Double? { guard let hrvVal = daily.avgHrv, let rhrVal = daily.restingHr, let hrvBase = baselines.hrv else { return nil } // Charge enrichment: feed the Rest COMPOSITE (÷100) as the sleep-quality term instead of raw // efficiency, and fold in the night's skin-temp deviation. Both come from the persisted daily @@ -2637,7 +3310,7 @@ final class IntelligenceEngine: ObservableObject { /// baseline, mirroring the avgHrv→recovery re-score. Nil when the night had no wear-gated mean or /// the skin-temp baseline isn't usable yet (< minNightsSeed) , honest cold-start. Rounded to 2 dp /// to match the imported/demo precision. APPROXIMATE. - private func recomputeSkinTempDev(_ nightly: Double?, _ base: BaselineState?) -> Double? { + private static func recomputeSkinTempDev(_ nightly: Double?, _ base: BaselineState?) -> Double? { guard let v = nightly, let b = base, b.usable else { return nil } return (Baselines.deviation(v, state: b).delta * 100.0).rounded() / 100.0 } @@ -2688,9 +3361,18 @@ final class IntelligenceEngine: ObservableObject { /// naps drop out. One read serves both the main-night midsleep learner (#547) and the personal /// sleep-need + regularity that thread into `analyzeDay` (Wave 0 · SL1/T1). The midsleep result is /// byte-identical to before; the nightly-hours output is the Swift-side extension. - private static func computeHabitualSleep( + /// + /// Only sessions that ended before `finishedBefore` (the pass's local midnight) are learned from. Tonight's + /// session is re-banked by every sync while it is still growing, and each time it moved the learned + /// consistency and midsleep, so every pass through a morning found the day-cache signature changed and + /// re-scored all 21 nights from scratch. On a backgrounded phone that turned a seconds-long pass into + /// hours (a field log: 8 813 s and 2 345 s, back to back). A night still being slept is not a habit yet; + /// it joins the history the day after, once, when the window rolls anyway. + /// + /// Internal rather than private only so a test can drive the `finishedBefore` cutoff directly. + static func computeHabitualSleep( store: WhoopStore, importedId: String, computedId: String, - windowStart: Int, windowEnd: Int, offsetSec: Int + windowStart: Int, windowEnd: Int, finishedBefore: Int, offsetSec: Int ) async -> (midsleepSec: Int?, nightlyHours: [Double]) { let imported = (try? await store.sleepSessions(deviceId: importedId, from: windowStart, to: windowEnd, limit: 4000)) ?? [] @@ -2702,7 +3384,7 @@ final class IntelligenceEngine: ObservableObject { // then steered the main-night pick (day assignment) to the stale block. The same collapse also // covers an imported night and its computed twin (the longest capture wins, exactly what the // per-day length rule chose anyway). - let merged = SleepSessionDedup.dedupe(imported + computed).kept + let merged = SleepSessionDedup.dedupe(imported + computed).kept.filter { $0.endTs < finishedBefore } // Longest block per LOCAL day (naps drop out), chosen by in-bed SPAN — reused for BOTH the // midsleep learner and the per-night durations (Wave 0 · SL1/T1), so the two can never read a // different history. For the DURATIONS we keep TST (span × efficiency), NOT the in-bed span: @@ -2751,16 +3433,32 @@ final class IntelligenceEngine: ObservableObject { } } -private extension DailyMetric { +// `internal`, not `private`: the two `with(...)` rebuilds below are the seams a new DailyMetric column +// is most easily dropped at (they respell every field by name), so StrandTests asserts them directly +// rather than through a copy that could drift. Nothing outside this module can see them either way. +extension DailyMetric { + /// Rebuild with the exact legacy R-R-derived snapshot while keeping every other freshly-scored cell. + func with(avgHrv hrv: Double, recovery r: Double?, respRateBpm resp: Double?, + avgSdnn sdnn: Double?) -> DailyMetric { + DailyMetric(day: day, totalSleepMin: totalSleepMin, efficiency: efficiency, deepMin: deepMin, + remMin: remMin, lightMin: lightMin, disturbances: disturbances, restingHr: restingHr, + avgHrv: hrv, recovery: r, strain: strain, exerciseCount: exerciseCount, + spo2Pct: spo2Pct, skinTempDevC: skinTempDevC, respRateBpm: resp, + steps: steps, activeKcalEst: activeKcalEst, + spo2Red: spo2Red, spo2Ir: spo2Ir, avgSdnn: sdnn, skinTempC: skinTempC, + sleepHrOnly: sleepHrOnly) + } + /// Rebuild the immutable DailyMetric with a substituted recovery + skin-temp deviation /// (the struct has no `copy()`). (#78) - func with(recovery r: Double?, skinTempDevC sd: Double?) -> DailyMetric { + func with(recovery r: Double?, skinTempDevC sd: Double?, skinTempC sa: Double?) -> DailyMetric { DailyMetric(day: day, totalSleepMin: totalSleepMin, efficiency: efficiency, deepMin: deepMin, remMin: remMin, lightMin: lightMin, disturbances: disturbances, restingHr: restingHr, avgHrv: avgHrv, recovery: r, strain: strain, exerciseCount: exerciseCount, spo2Pct: spo2Pct, skinTempDevC: sd, respRateBpm: respRateBpm, steps: steps, activeKcalEst: activeKcalEst, - spo2Red: spo2Red, spo2Ir: spo2Ir, avgSdnn: avgSdnn) + spo2Red: spo2Red, spo2Ir: spo2Ir, avgSdnn: avgSdnn, skinTempC: sa, + sleepHrOnly: sleepHrOnly) } /// Rebuild with substituted sleep-derived fields (a user-corrected wake window), leaving every @@ -2771,7 +3469,8 @@ private extension DailyMetric { disturbances: disturbances, restingHr: restingHr, avgHrv: avgHrv, recovery: recovery, strain: strain, exerciseCount: exerciseCount, spo2Pct: spo2Pct, skinTempDevC: skinTempDevC, respRateBpm: respRateBpm, steps: steps, - activeKcalEst: activeKcalEst, spo2Red: spo2Red, spo2Ir: spo2Ir, avgSdnn: avgSdnn) + activeKcalEst: activeKcalEst, spo2Red: spo2Red, spo2Ir: spo2Ir, avgSdnn: avgSdnn, + 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/LiftBannerPushPolicy.swift b/Strand/Data/LiftBannerPushPolicy.swift new file mode 100644 index 0000000000..742baf9e48 --- /dev/null +++ b/Strand/Data/LiftBannerPushPolicy.swift @@ -0,0 +1,49 @@ +import Foundation + +/// When a new heart rate is worth pushing the gym session's Lock Screen banner. +/// +/// WHY THIS EXISTS. Live heart rate arrives once a second while a strap streams, and the banner shows it. +/// Pushing the banner for each one spends the phone twice over: every push wakes the widget extension to +/// re-render the Lock Screen and the Dynamic Island, and ActivityKit budgets how often an app may update an +/// activity — an app that spends that budget on a number nobody is reading has none left for the update that +/// matters, the one carrying the light-up alert on a strap double-tap. On 22 Sep 2026 a 75-minute session sent +/// about 450 heart-rate pushes (one per 10 s) and 30 alerts, and the light-ups ran 5–10 seconds late for the +/// middle of the session while the buzz stayed immediate. +/// +/// So a heart rate alone moves the banner rarely: only when it has changed enough to read differently, and not +/// more often than `interval`. Everything else a person would notice — the stage, the set, the numbers — still +/// pushes at once, carrying whatever the heart rate is at that moment, and the clocks tick client-side without +/// any push at all. Appearing or disappearing (the strap dropping, or coming back) is a visible change rather +/// than a moving number, so it is allowed sooner. +/// +/// Pure and platform-free so `StrandTests` covers it: `StrandTests` runs on macOS and cannot exercise +/// ActivityKit, so the rule has to live outside the controller to be unit tested at all. (The controller +/// itself IS compiled in CI, by `app-build.yml`'s `NOOPiOS` leg.) +enum LiftBannerPushPolicy { + + /// The shortest time between two pushes caused by the heart rate alone. A glance at the Lock Screen + /// between sets wants a number that is current to the set, not to the second. + static let interval: TimeInterval = 30 + + /// How far the heart rate must have moved to be worth a push of its own. Under this it is the same + /// reading with noise on it. + static let step = 2 + + /// The floor for the strap appearing or disappearing (a number becoming "—", or the reverse): rare, and + /// a visible change of state rather than a moving number. + static let presenceInterval: TimeInterval = 5 + + /// `shown` is the heart rate the banner is currently showing, `latest` what the app has now; nil is the + /// dash the banner shows with no live strap. `sinceLastPush` is how long ago the banner was last pushed + /// for any reason. + static func heartRateDue(shown: Int?, latest: Int?, sinceLastPush: TimeInterval) -> Bool { + switch (shown, latest) { + case let (shown?, latest?): + return abs(latest - shown) >= step && sinceLastPush >= interval + case (nil, nil): + return false + default: + return sinceLastPush >= presenceInterval + } + } +} diff --git a/Strand/Data/LiftFormat.swift b/Strand/Data/LiftFormat.swift new file mode 100644 index 0000000000..6b769ed8a9 --- /dev/null +++ b/Strand/Data/LiftFormat.swift @@ -0,0 +1,82 @@ +import Foundation + +// Number and unit formatting for the Lift Log. +// +// WEIGHT IS ALWAYS STORED IN KILOGRAMS. Only display and typed input are converted, using the +// unit system the user already picked for the whole app (`UnitPrefs.systemKey`) — the Lift Log +// deliberately does not add a second weight-unit setting of its own, so a pounds user gets pounds +// here for free and never has two settings that can disagree. +// +// Conversion goes through ONE constant in BOTH directions (`UnitFormatter.poundsPerKilogram`), so a typed +// value round-trips: enter 225 lb, store 102.058… kg, read it back and it renders 225 lb again. +// Using the exact 0.45359237 for input while displaying with the rounded 2.20462 would drift the +// number by a tenth on the way back and make the log look like it had edited itself. + +enum LiftFormat { + + // MARK: - Weight + + /// Convert a typed weight in the user's display unit to the kilograms that get stored. + static func kilograms(fromDisplay value: Double, system: UnitSystem) -> Double { + system == .imperial ? value / UnitFormatter.poundsPerKilogram : value + } + + /// Convert stored kilograms to the user's display unit, as a number (not a string) so callers + /// can put it straight into an editable text field. + static func display(fromKilograms kg: Double, system: UnitSystem) -> Double { + system == .imperial ? kg * UnitFormatter.poundsPerKilogram : kg + } + + /// A stored weight rendered for display with its unit: "60 kg", "132.5 lb". + static func weight(_ kg: Double?, system: UnitSystem) -> String { + guard let kg else { return "—" } + return "\(trim(display(fromKilograms: kg, system: system))) \(UnitFormatter.massUnit(system))" + } + + /// The bare unit label for a field suffix. + static func weightUnit(_ system: UnitSystem) -> String { UnitFormatter.massUnit(system) } + + // MARK: - Numbers + + /// A number with up to TWO decimals and no trailing noise: 8.0 → "8", 7.5 → "7.5", + /// 12.25 → "12.25", 45.50 → "45.5". + /// + /// Weights and RPE are entered as decimals but are usually whole, and "8.0 × 10" in a summary + /// line reads like a precision the user did not type — so a whole number loses its ".0". + /// + /// TWO decimals, not one. Gym plates come in quarter-kilos and microplates in smaller steps, so + /// 12.25 kg is a real weight a user types; formatting to one decimal turned it into 12.3 and, + /// because the entry field reads its text back through this function, that rounded value then + /// replaced what they typed. One decimal was silently lossy, not merely terse. + /// + /// Always renders "." regardless of locale — `String(format:)` takes no locale here — which + /// matches what the rest of the Lift Log displays. + static func trim(_ value: Double) -> String { + let rounded = (value * 100).rounded() / 100 + if rounded == rounded.rounded() && abs(rounded) < 1e9 { + return String(Int(rounded.rounded())) + } + var text = String(format: "%.2f", rounded) + while text.hasSuffix("0") { text.removeLast() } + if text.hasSuffix(".") { text.removeLast() } + return text + } + + /// Parse a typed number, accepting both "7.5" and the comma decimal separator "7,5" that most of + /// NOOP's shipped locales use on their keyboards. Returns nil for anything else. + static func number(_ text: String) -> Double? { + let cleaned = text.trimmingCharacters(in: .whitespaces).replacingOccurrences(of: ",", with: ".") + guard !cleaned.isEmpty else { return nil } + return Double(cleaned) + } + + // MARK: - Durations + + /// A rest period as "2:00" / "45s" — minutes and seconds, which is how rest is spoken about: a program + /// line's rest, a finished set's measured rest. A clock that is RUNNING (the bar, the session sheet) + /// is written `ActiveWorkoutClock.clock` instead, as the Lock Screen writes it. + static func duration(_ seconds: Int) -> String { + guard seconds >= 60 else { return "\(seconds)s" } + return String(format: "%d:%02d", seconds / 60, seconds % 60) + } +} diff --git a/Strand/Data/LiftMuscleNames.swift b/Strand/Data/LiftMuscleNames.swift new file mode 100644 index 0000000000..7b4a8abc15 --- /dev/null +++ b/Strand/Data/LiftMuscleNames.swift @@ -0,0 +1,87 @@ +import Foundation +import WhoopStore + +// Display names for the stored `LiftMuscle` vocabulary. +// +// `WhoopStore` deliberately holds no UI strings: `LiftMuscle`'s raw values are a stored-data +// contract (an Android twin must one day write byte-identical tokens), so the human-readable name +// has to live at the app layer, where it can be localized without touching what is written to the +// database. Renaming a label here is free; renaming a token is not — see the header of +// `LiftMuscle.swift`. +// +// Names are the ones lifters use, not the anatomical Latin: "Lats", not "latissimus dorsi". The +// vocabulary is meant to be picked from in a gym in a few seconds, and the granularity is already +// justified in the enum — the label's only job is to be recognised instantly. + +extension LiftMuscle { + /// Localized display name, e.g. "Front delts". + /// + /// A computed `String` rather than a `LocalizedStringKey` because callers interpolate it — into + /// a picker row, a summary line, an accessibility label — and an interpolated + /// `LocalizedStringKey` would look up the *interpolated* result as a key and miss. + var displayName: String { + switch self { + // Push + case .chest: return String(localized: "Chest") + case .frontDelts: return String(localized: "Front delts") + case .sideDelts: return String(localized: "Side delts") + case .rearDelts: return String(localized: "Rear delts") + case .triceps: return String(localized: "Triceps") + // Pull + case .lats: return String(localized: "Lats") + case .upperBack: return String(localized: "Upper back") + case .traps: return String(localized: "Traps") + case .biceps: return String(localized: "Biceps") + case .forearms: return String(localized: "Forearms") + // Legs + case .quads: return String(localized: "Quads") + case .hamstrings: return String(localized: "Hamstrings") + case .glutes: return String(localized: "Glutes") + case .adductors: return String(localized: "Adductors") + case .abductors: return String(localized: "Abductors") + case .calves: return String(localized: "Calves") + // Trunk + case .abs: return String(localized: "Abs") + case .obliques: return String(localized: "Obliques") + case .lowerBack: return String(localized: "Lower back") + case .neck: return String(localized: "Neck") + } + } +} + +extension LiftMuscle.Region { + /// Localized section title for the muscle picker. Presentation-only, like the region itself. + /// + /// "Push muscles", not "Push" — and for the same reason the rest timer says "Rest period" rather + /// than "Rest". The catalog's bare `"Push"` key is the TODAY screen's readiness nudge ("push + /// yourself"), translated accordingly: 推送 in Chinese is a push NOTIFICATION, and "Вперёд" in + /// Russian is "forward". Reusing it would have rendered this picker's section headers as words + /// from an unrelated feature. A generic English word is never safe as a key here. + var displayName: String { + switch self { + case .push: return String(localized: "Push muscles") + case .pull: return String(localized: "Pull muscles") + case .legs: return String(localized: "Leg muscles") + case .trunk: return String(localized: "Trunk muscles") + } + } +} + +// MARK: - Summarising a classification + +enum LiftMuscleSummary { + + /// One line describing how an exercise is classified, for a program row or a picker subtitle: + /// "Chest · Front delts, Triceps", or "Not classified" when it has no primary. + /// + /// The primary is listed first and separated from the secondaries, because the direct/indirect + /// split is what makes the per-muscle counts computable at all — collapsing them into one list + /// would hide the distinction the whole rollup rests on. + static func line(primary: LiftMuscle?, secondaries: [LiftMuscle]) -> String { + guard let primary else { return String(localized: "Not classified") } + let secondary = secondaries.filter { $0 != primary } + guard !secondary.isEmpty else { return primary.displayName } + let joined = secondary.map(\.displayName).joined(separator: ", ") + return "\(primary.displayName) · \(joined)" + } +} diff --git a/Strand/Data/LiftSessionController.swift b/Strand/Data/LiftSessionController.swift new file mode 100644 index 0000000000..72c3ca1bbf --- /dev/null +++ b/Strand/Data/LiftSessionController.swift @@ -0,0 +1,737 @@ +import Foundation +import Combine +import WhoopStore +import StrandAnalytics + +// The live session, owned ABOVE any screen. +// +// WHY THIS EXISTS. The session used to live inside the sheet that displayed it. Swiping that sheet +// down dismissed it, which tore down the view — and with it the strap's double-tap handler and the +// rest-timer tick. The session looked alive (it was still on disk, and "Resume" brought it back) but +// was deaf: taps did nothing, no buzz arrived, in the app or out of it. Re-entering also re-fired +// the five-second warning, because the "already buzzed" flag was view state that reset on every +// present. +// +// A workout outlives the screen you happen to be looking at, so the session has to as well. This +// controller owns the engine, the rest's timers, the buzz gating and the persistence. The sheet is a +// rendering of it; the bottom bar is another. Dismissing either changes nothing about the session. +// +// The strap gesture is claimed for the LIFETIME OF THE SESSION rather than the lifetime of a view, +// and handed back untouched when the session ends. + +@MainActor +final class LiftSessionController: ObservableObject { + + /// The running session, or nil when none is in flight. Every change re-arms the rest's timers + /// (`scheduleRestTimers`), which only act when the rest itself changed. + @Published private(set) var engine: LiftSessionEngine? { didSet { scheduleRestTimers() } } + @Published private(set) var programId: String? + @Published private(set) var programName: String? + /// True while the full sheet is presented; false when minimised to the bottom bar. + @Published var isPresented = false + + /// Bumped each time a finished session is written. The session sheet is presented above every + /// screen, so its save cannot call back into the one listing sessions; that screen reloads on this. + @Published private(set) var savedSessions = 0 + + /// Sends the moment a strap double-tap has moved the session on, with the new stage in place — unlike + /// `$engine`, which publishes before the change lands. The Lock Screen banner uses it to light the + /// screen on the step just taken, and because it comes before anything else about the step reaches + /// the banner, that one lit update is usually the only update the step causes. + let strapStepTaken = PassthroughSubject() + + /// Anything about the session changed, once the change has landed and settled — what the Lock Screen + /// banner follows. `objectWillChange` fires BEFORE a change lands, so a banner pushed from it showed the + /// step before; the debounce also folds a burst (typing a number, the several changes of one tap) into one + /// push. Built once, so its subscribers keep one pipeline. + private(set) lazy var changesSettled: AnyPublisher = objectWillChange + .debounce(for: .milliseconds(250), scheduler: RunLoop.main) + .map { _ in () } + .eraseToAnyPublisher() + + var isActive: Bool { engine != nil && engine?.isFinished == false } + + /// Rest period the five-second warning has already fired for. Lives HERE, not in a view, so + /// re-opening the sheet mid-rest cannot re-fire it. + private var warnedFor: Int? + + /// Slots the user marked as a warm-up BEFORE performing them. You know a set is a warm-up on the + /// way in, not afterwards, but an unperformed set has no record to carry the flag — and inventing + /// one would create a set nobody did. So the mark is held here and applied the instant the set is + /// recorded. Owned by the controller rather than a view so it survives the sheet being minimised. + @Published private(set) var pendingWarmups: Set = [] + + /// Numbers typed into a set BEFORE it was performed, held exactly the way a warm-up mark is. + /// + /// Reported from a real session: "when I type something during an active set to other sets it + /// refreshes to the empty". It did — `LiftSessionView.write` could only edit a set that already + /// had a record, so every keystroke into a pending row was silently discarded, and the field only + /// LOOKED like it had taken until focus left and the draft was dropped. + /// + /// The engine invariant it ran into is real and stays: typing must never append a set, or a set + /// nobody performed becomes data. So the value is held here instead, shown back on the row, and + /// applied the instant the set is recorded — at which point it BEATS the carried plan, because a + /// number the user typed for this set is better evidence than the one the sheet guessed for it. + @Published private(set) var pendingValues: [LiftSlot: PendingSetValues] = [:] + + /// What a user typed into a set that has not happened yet. All optional: a row where only the + /// weight was typed keeps carrying its reps. + struct PendingSetValues: Equatable { + var weightKg: Double? + var reps: Int? + var rpe: Double? + + var isEmpty: Bool { weightKg == nil && reps == nil && rpe == nil } + } + + /// What the store holds for each exercise LAST session, keyed by exercise name then set number — + /// the middle layer of `LiftSessionEngine.carry(for:lastSession:)`. + /// + /// It lives here rather than in the sheet because the minimised bar and the Lock Screen show a + /// set's numbers too, and they must be the grey numbers the sheet shows. `LiftSessionView` loads it + /// and hands it over; until it does (a session resumed straight into the bar after a relaunch, say) + /// the grey numbers fall through to the program's target, which is the layer below. + @Published private var lastSession: [String: [Int: LiftSetCarry]] = [:] + /// The running rest's warning and end, and the end they were set for — see `scheduleRestTimers`. + private var restTimers: [Task] = [] + private var scheduledRestEnd: Int? + + /// Fires the strap buzz. Injected so the controller has no opinion about BLE and stays testable. + private let buzz: (UInt8) -> Void + /// Claims/releases the strap's double-tap for the session's lifetime. + private let setStrapHandler: ((@MainActor () -> Void)?) -> Void + /// Writes a line to the strap log — how a double-tap the session holds back is accounted for. + private let log: (String) -> Void + + /// When the session last acted on a strap double-tap (unix seconds). Nil until it has, and after a + /// relaunch: a knock is judged against a tap in the same sitting, never one from before it. + private var lastStrapStepAt: Int? + + /// One pulse confirms a strap double-tap registered — with the phone face-down there is + /// otherwise no way to know. Three means the rest is nearly up. Two patterns that cannot be + /// mistaken for each other on a wrist that has been knocked about all session. + static let advanceConfirmBuzzes: UInt8 = 1 + static let restWarningBuzzes: UInt8 = 3 + /// How long before the rest ends the warning fires. + static let restWarningLeadSec = 5 + /// A strap double-tap this soon after the last one the session acted on is taken as a knock. See + /// `isKnock(secondsSinceLastStep:stage:now:)`. + static let strapKnockWindowSec = 5 + + init(buzz: @escaping (UInt8) -> Void, + setStrapHandler: @escaping ((@MainActor () -> Void)?) -> Void, + log: @escaping (String) -> Void = { _ in }) { + self.buzz = buzz + self.setStrapHandler = setStrapHandler + self.log = log + } + + // MARK: - Lifecycle + + func start(plan: [LiftPlanItem], programId: String?, programName: String?) { + let stamp = Int(Date().timeIntervalSince1970) + engine = LiftSessionEngine(plan: plan, startTs: stamp) + self.programId = programId + self.programName = programName + warnedFor = nil + isPresented = true + claimStrap() + persist() + } + + /// Pick up the session a previous run of NOOP left going, as the app process starts. + /// + /// iOS closes NOOP in the background and relaunches it when the strap next sends something — four + /// times in 28 minutes of one gym session (strap log, 21 Sep 2026). The session used to come back only + /// when the first screen appeared, and the Lock Screen banner is driven by the same screen: its first + /// push found no session, ended the banner, and iOS allows a new one only while NOOP is open. Every + /// strap step after a restart then lit nothing ("no Lift Log banner is running") until NOOP was opened. + /// Resumed here, before any screen exists, the session is back before anything asks about it, and + /// the banner iOS kept on the Lock Screen is picked up again instead. The line it logs is how a later + /// strap log shows a restart in the middle of a session. + func resumeSaved(from defaults: UserDefaults = .standard) { + guard !isActive, let snapshot = LiftSessionPersistence.load(from: defaults) else { return } + resume(from: snapshot) + log("Lift Log: session picked up again after NOOP restarted") + } + + /// Rehydrate an interrupted session found on disk. Does NOT present the sheet: the session comes + /// back as the bottom bar, and the user opens it if they want to. + func resume(from snapshot: LiftSessionPersistence.Snapshot, present: Bool = false) { + engine = LiftSessionPersistence.engine(from: snapshot) + programId = snapshot.programId + programName = snapshot.programName + // Numbers typed for sets not yet performed, and warm-ups marked in advance, come back too: + // they are intent the user already expressed, and losing them is the bug this pair exists + // to prevent, whether it is lost to a blur or to a relaunch. + pendingValues = LiftSessionPersistence.pendingValues(from: snapshot) + pendingWarmups = LiftSessionPersistence.pendingWarmups(from: snapshot) + // Suppress the warning for a rest that is ALREADY inside its final seconds. Without this, + // reopening a session mid-rest greets the user with three buzzes for a rest they have been + // watching count down all along. + if case .resting(_, let endsAt) = engine?.stage, + endsAt - Self.unixNow <= LiftSessionController.restWarningLeadSec { + warnedFor = endsAt + } else { + warnedFor = nil + } + isPresented = present + claimStrap() + } + + /// Give up the session without saving. + func discard() { + teardown() + LiftSessionPersistence.clear() + } + + /// Called once the session has been written to the store. + func finishedSaving() { + teardown() + LiftSessionPersistence.clear() + savedSessions += 1 + } + + private func teardown() { + engine = nil + programId = nil + programName = nil + warnedFor = nil + lastStrapStepAt = nil + pendingWarmups = [] + pendingValues = [:] + isPresented = false + setStrapHandler(nil) + } + + /// The handler runs SYNCHRONOUSLY, inside the frame handling that delivered the tap. It used to hop + /// through a `Task`, which let the sync request the same strap event triggers reach the strap + /// first; the strap then started a history transfer before playing the confirming buzz, and those + /// buzzes came 1–2.8 s after the tap, where most others came in under one (strap log, 16 Sep 2026). + private func claimStrap() { + setStrapHandler({ [weak self] in self?.advance(fromStrap: true) }) + } + + /// The current unix second. Read when needed; nothing about a session is stored per second. + static var unixNow: Int { Int(Date().timeIntervalSince1970) } + + // MARK: - Actions + + /// The one action. `fromStrap` earns a single confirming buzz, unless the tap reads as a knock. + func advance(fromStrap: Bool = false) { + guard let current = engine else { return } + let stamp = Int(Date().timeIntervalSince1970) + if fromStrap { + if let last = lastStrapStepAt, + Self.isKnock(secondsSinceLastStep: stamp - last, stage: current.stage, now: stamp) { + // No buzz: the missing confirmation is the lifter's cue to tap again. + log("Lift Log: that double-tap was not acted on — \(stamp - last) s after the last one it " + + "acted on (under \(Self.strapKnockWindowSec) s is taken as a knock)") + return + } + lastStrapStepAt = stamp + // BUZZ FIRST, before any state work. The confirmation is a latency signal — its whole job + // is to say "that registered" — so it must not queue behind a JSON encode and a defaults write. + buzz(LiftSessionController.advanceConfirmBuzzes) + } + + engine?.advance(now: stamp) + // Straight after the stage moves, so the screen lights with no wait behind the bookkeeping below. + // What follows cannot change what the banner shows: numbers typed into a set are already shown + // before they are applied (`setNumbers`). + if fromStrap { strapStepTaken.send() } + applyPendingInput() + warnedFor = nil + persist() + } + + /// Whether a strap double-tap `secondsSinceLastStep` after the last one the session acted on is a + /// knock rather than a tap. + /// + /// The strap's own sensor log for the 16 Sep 2026 session shows two double-taps it detected 3 s + /// and 4 s after one that had just started a set — the arm going onto the bar, not a second tap. + /// Each was a genuine detection with its own timestamp, so the de-duplication in `FrameRouter` + /// rightly let it through, and each finished a set seconds old and started its rest: "it skipped + /// two things when it should have done only one". Only the timing tells such a knock from a tap. + /// + /// Under `strapKnockWindowSec` counts as a knock, because no set a lifter means to finish, and no + /// rest a lifter means to end, is that short. It was 8 s at first; after the next session Utku found + /// that too long to wait for a deliberate second tap (21 Sep 2026), and 5 s still covers the knocks + /// measured at +2.9 s and +4.1 s. The exception is a rest that is already over — a + /// line planned with no rest, or the one left once every set is done — where going straight on is + /// the plan. The on-screen button is never held back: a knock does not press it. A tap held back + /// gets no buzz, which tells the lifter to tap again. + static func isKnock(secondsSinceLastStep: Int, stage: LiftSessionEngine.Stage, now: Int) -> Bool { + guard (0.. Presentation? { + guard let engine, !engine.isFinished else { return nil } + let started = Date(timeIntervalSince1970: TimeInterval(engine.stageStartedAt)) + let next = Self.nextLine(engine) + + guard let slot = engine.currentSlot, let item = engine.planItem(for: slot) else { + return Presentation(isResting: false, + exercise: programName ?? String(localized: "Session"), + status: String(localized: "Warm-up"), detail: nil, next: next, + stageStartedAt: started, restEndsAt: nil) + } + + let detail = setNumbers(for: slot, system: system) + switch engine.stage { + case .resting(_, let endsAt): + let ready = endsAt <= Self.unixNow + return Presentation( + isResting: true, exercise: item.exercise, + status: ready ? String(localized: "Ready for the next set") + : String(localized: "Resting after set \(slot.setIndex)"), + detail: detail, next: next, + stageStartedAt: started, + restEndsAt: Date(timeIntervalSince1970: TimeInterval(endsAt))) + default: + return Presentation( + isResting: false, exercise: item.exercise, + status: String(localized: "Set \(slot.setIndex)"), + detail: detail, next: next, + stageStartedAt: started, restEndsAt: nil) + } + } + + /// The set after this one, as the bar and the Lock Screen show it on one line. + /// + /// It replaced "3 of 19 sets done", which answered nothing a lifter acts on mid-session, while the + /// set coming up says where to walk (Utku, 16 Sep 2026). It is always a SET, never the rest before + /// it. The set number comes before the exercise so that a narrow line cuts the name, not the + /// number. "Last set" while the final set is worked; "All sets done" once it is. + static func nextLine(_ engine: LiftSessionEngine) -> String { + if let upcoming = engine.upcomingSlot, let item = engine.planItem(for: upcoming) { + return String(localized: "Next: Set \(upcoming.setIndex) · \(item.exercise)") + } + return engine.allCompleted ? String(localized: "All sets done") : String(localized: "Last set") + } + + /// Reps x weight for a slot, as "8 x 30 kg": what the set's row on the sheet shows — typed numbers, + /// else the grey ones. + /// + /// That includes numbers typed into a set BEFORE it is recorded (`pendingValues`), which the row shows + /// black. Without them the bar and the Lock Screen showed the grey plan for the set being lifted while + /// its row showed what was typed (simulator, 16 Sep 2026: 70 kg × 9 typed, "8 x 60 kg" on the bar). + func setNumbers(for slot: LiftSlot, system: UnitSystem) -> String? { + guard engine != nil else { return nil } + let grey = values(of: slot) + let typed = pendingValues[slot] + let shown = LiftSetCarry(weightKg: typed?.weightKg ?? grey.weightKg, reps: typed?.reps ?? grey.reps) + + let weight = shown.weightKg.map { + LiftFormat.trim(LiftFormat.display(fromKilograms: $0, system: system)) + + " " + LiftFormat.weightUnit(system) + } + switch (shown.reps, weight) { + case (let r?, let w?): return "\(r) x \(w)" + case (let r?, nil): return String(localized: "\(r) reps") + case (nil, let w?): return w + case (nil, nil): return nil + } + } + + /// Hand over what the store knows about previous sessions. Called by the sheet once it has read + /// it; safe to call again if it reloads. + func setLastSession(_ values: [String: [Int: LiftSetCarry]]) { + lastSession = values + } + + /// The grey numbers a slot shows — the one chain every surface reads, so the sheet, the minimised + /// bar and the Lock Screen cannot disagree about them. + func carry(for slot: LiftSlot) -> LiftSetCarry { + engine?.carry(for: slot, lastSession: lastSessionSets(for: slot)) ?? .none + } + + /// What a slot counts as: typed numbers, else its grey ones. + func values(of slot: LiftSlot) -> LiftSetCarry { + engine?.values(of: slot, lastSession: lastSessionSets(for: slot)) ?? .none + } + + private func lastSessionSets(for slot: LiftSlot) -> [Int: LiftSetCarry] { + engine?.planItem(for: slot).flatMap { lastSession[$0.exercise] } ?? [:] + } + + /// Mark a slot as a warm-up (or not). Applies immediately when the set already exists, and is + /// remembered for when it does not yet. + func setWarmup(_ slot: LiftSlot, _ isWarmup: Bool) { + if isWarmup { pendingWarmups.insert(slot) } else { pendingWarmups.remove(slot) } + if let row = engine?.recordedSet(for: slot) { + engine?.updateSet(slot, weightKg: row.weightKg, reps: row.reps, + rpe: row.rpe, isWarmup: isWarmup) + } + persist() + } + + func isWarmup(_ slot: LiftSlot) -> Bool { + if let row = engine?.recordedSet(for: slot) { return row.isWarmup } + return pendingWarmups.contains(slot) + } + + /// Carry a pre-marked warm-up, and any numbers typed in advance, onto the set just recorded. + /// + /// The set is recorded with its timing only, so typed numbers become its own and a field left + /// untouched stays grey — typing only the weight does not blank the reps. + /// + /// The entry is CONSUMED. A redo (`start` on a completed slot) drops the record and should show + /// the ghosts again, exactly as it did before; leaving the entry behind would resurrect numbers + /// the user is in the middle of redoing. + private func applyPendingInput() { + guard let engine, let last = engine.sets.last else { return } + let typed = pendingValues.removeValue(forKey: last.slot) + let warmup = last.isWarmup || pendingWarmups.contains(last.slot) + guard typed != nil || warmup != last.isWarmup else { return } + self.engine?.updateSet(last.slot, + weightKg: typed?.weightKg ?? last.weightKg, + reps: typed?.reps ?? last.reps, + rpe: typed?.rpe ?? last.rpe, + isWarmup: warmup) + } + + /// Begin a specific set — the out-of-order path, for when a machine is occupied. + func start(_ slot: LiftSlot, fromStrap: Bool = false) { + guard engine != nil else { return } + if fromStrap { buzz(LiftSessionController.advanceConfirmBuzzes) } + let stamp = Int(Date().timeIntervalSince1970) + engine?.start(slot, now: stamp) + // Starting a slot drops any record it had, so its warm-up mark reverts to pending — which is + // where it already lives. + warnedFor = nil + persist() + } + + /// Add one set to an exercise — the unplanned fifth set. Returns whether anything changed, which + /// is what tells the caller whether the program behind the session needs rewriting. + @discardableResult + func addSet(toExercise index: Int) -> Bool { + guard engine?.addSet(toExercise: index) == true else { return false } + persist() + return true + } + + /// Add an exercise the program does not have (Utku, 21 Sep 2026), at the end of the sheet: one set, + /// planned as 0 kg × 0 reps with no max RPE and the default rest, so its row shows zeros until + /// numbers are typed — or last session's numbers, when the exercise has been done before. It + /// carries the id its program line will have if finishing adds it. Returns whether it was added. + @discardableResult + func addExercise(_ name: String, primaryMuscle: LiftMuscle?, secondaryMuscles: [LiftMuscle]) -> Bool { + let line = LiftPlanItem(exercise: name, primaryMuscle: primaryMuscle, + secondaryMuscles: secondaryMuscles.filter { $0 != primaryMuscle }, + targetSets: 1, targetRepsLow: 0, targetWeightKg: 0, + programItemId: UUID().uuidString, addedInSession: true) + guard engine?.addExercise(line) == true else { return false } + persist() + return true + } + + /// Drop the last pending set of an exercise. See `LiftSessionEngine.canRemoveSet(fromExercise:)` + /// for what "can" means — a completed set is never removed this way. + @discardableResult + func removeSet(fromExercise index: Int) -> Bool { + guard let engine, engine.canRemoveSet(fromExercise: index) else { return false } + let dropped = LiftSlot(exerciseIndex: index, setIndex: engine.plan[index].targetSets) + self.engine?.removeSet(fromExercise: index) + // A slot that no longer exists must not keep a warm-up mark or typed numbers: adding the set + // back would return them silently, from input the user gave a set they then removed. + pendingWarmups.remove(dropped) + pendingValues.removeValue(forKey: dropped) + persist() + return true + } + + /// Fill in or correct a set's numbers — **any** set, at any time. + /// + /// A set that has been performed is edited in the engine. A set that has NOT been performed + /// cannot be (that would invent it), so its numbers are held in `pendingValues` until it is. + /// From the screen the two are indistinguishable, which is the point: the user asked to be able + /// to type into whichever row they are looking at, and being mid-set somewhere else is not a + /// reason to refuse. + func updateSet(_ slot: LiftSlot, weightKg: Double?, reps: Int?, rpe: Double?, isWarmup: Bool) { + if engine?.recordedSet(for: slot) != nil { + engine?.updateSet(slot, weightKg: weightKg, reps: reps, rpe: rpe, isWarmup: isWarmup) + } else if engine?.planItem(for: slot) != nil { + let values = PendingSetValues(weightKg: weightKg, reps: reps, rpe: rpe) + // Clearing the last field clears the entry rather than leaving an empty one behind, so + // the row goes back to showing the plan's grey ghost instead of a blank it has to keep. + if values.isEmpty { pendingValues.removeValue(forKey: slot) } + else { pendingValues[slot] = values } + } + persist() + } + + /// What a slot is currently showing: what it recorded, or what was typed into it in advance. + func enteredValues(for slot: LiftSlot) -> PendingSetValues { + if let row = engine?.recordedSet(for: slot) { + return PendingSetValues(weightKg: row.weightKg, reps: row.reps, rpe: row.rpe) + } + return pendingValues[slot] ?? PendingSetValues(weightKg: nil, reps: nil, rpe: nil) + } + + func undo() { + engine?.undo() + persist() + } + + func finish() { + engine?.finish(now: Int(Date().timeIntervalSince1970)) + persist() + } + + // MARK: - Finishing + + /// Sets never started. Finishing asks once whether to complete them with their grey numbers or + /// discard them; a set that WAS done is never in question (`setsToSave`). + var unfinishedSlots: [LiftSlot] { engine?.unperformedSlots ?? [] } + + /// One set as the finished session saves it. Timing is nil for a set completed at finish without + /// ever being started: there is no moment to record, and inventing one would give it a rest and a + /// heart-rate window it never had. + struct FinishedSet: Equatable { + var slot: LiftSlot + var weightKg: Double? + var reps: Int? + var rpe: Double? + var isWarmup: Bool + var startTs: Int? + var endTs: Int? + var restSec: Int? + } + + /// The sets the session saves — every slot on the sheet. + /// + /// A set that was DONE — ticked by Set done or a strap double-tap — is complete, with no question at + /// finish (Utku, 21 Sep 2026): it saves what was typed into it, and a number left blank takes the grey + /// value the sheet showed. That includes RPE: a set left unrated saves the program line's max RPE + /// (16 Sep 2026). A rating typed for the set always wins, and a previous set's rating is never copied + /// onto another — only the plan's own number fills a blank. Sets never started are the only + /// unfinished ones: they save with their grey numbers (and anything typed in advance) when + /// `completingUnfinished`; otherwise as 0 kg × 0 reps, which every figure leaves out + /// (`LiftMetrics.isPerformed`) and Edit sets still shows, so a discard made by mistake can be filled + /// back in. Done sets keep the order they happened in and their timing; sets never started follow in + /// plan order, with no timing. + func setsToSave(completingUnfinished: Bool) -> [FinishedSet] { + guard let engine else { return [] } + // The plan's max RPE, which the session shows grey in the RPE field. + func planned(_ slot: LiftSlot) -> Double? { engine.planItem(for: slot)?.targetRpe } + var out = engine.sets.map { set -> FinishedSet in + let shown = values(of: set.slot) + return FinishedSet(slot: set.slot, weightKg: shown.weightKg, reps: shown.reps, + rpe: set.rpe ?? planned(set.slot), isWarmup: set.isWarmup, + startTs: set.startTs, endTs: set.endTs, restSec: set.restSec) + } + for slot in engine.allSlots where !engine.isCompleted(slot) { + let typed = pendingValues[slot] + let grey = carry(for: slot) + out.append(FinishedSet(slot: slot, + weightKg: completingUnfinished ? typed?.weightKg ?? grey.weightKg : 0, + reps: completingUnfinished ? typed?.reps ?? grey.reps : 0, + rpe: completingUnfinished ? typed?.rpe ?? planned(slot) : nil, + isWarmup: pendingWarmups.contains(slot), + startTs: nil, endTs: nil, restSec: nil)) + } + return out + } + + /// Whether any of `sets` was performed. When none was (no set done, and the rest discarded), there + /// is nothing to file: `LiftSessionView.save` writes no session, no sets and no workout, and the + /// finish sheet says so before Save. + static func anyPerformed(_ sets: [FinishedSet]) -> Bool { + sets.contains { LiftMetrics.isPerformed(reps: $0.reps) } + } + + /// A program line whose set count this session changed. + struct SetCountChange: Equatable { + var itemId: String + var exercise: String + var from: Int + var to: Int + } + + /// Lines whose set count in this session differs from the program's current one. A line with no + /// count counts as one set, as it does when a session starts, and a line deleted from the program + /// since is skipped rather than resurrected — as is a line added during the session, which the + /// program does not have yet (`programAfterSession`). + static func setCountChanges(plan: [LiftPlanItem], program items: [LiftProgramItemRow]) -> [SetCountChange] { + let byId = Dictionary(items.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + return plan.compactMap { line in + guard let id = line.programItemId, let row = byId[id] else { return nil } + let saved = max(1, row.targetSets ?? 1) + guard saved != line.targetSets else { return nil } + return SetCountChange(itemId: id, exercise: line.exercise, from: saved, to: line.targetSets) + } + } + + /// The program's lines with each one's HEAVIEST done set this session as its new weight and reps. + /// + /// Utku, 21 Sep 2026: numbers typed during a session update the program, without asking. A line holds + /// one weight and one rep count for all its sets, so it takes the heaviest set — the working weight, + /// which a lighter back-off set must not pull down; between sets of equal weight, the one with more + /// reps. Only sets actually done count: a warm-up, a set discarded to zeros and a set completed at + /// finish without being started (it carries grey numbers, not new ones) move nothing. A number the + /// heaviest set does not have (a bodyweight line's weight) is left as it was; a leftover rep range + /// top below the new count is dropped, since the editor keeps one rep count. Lines without a program + /// line behind them, or deleted since, are skipped. + static func applyingHeaviestSets(_ sets: [FinishedSet], plan: [LiftPlanItem], + to items: [LiftProgramItemRow]) -> [LiftProgramItemRow] { + var heaviest: [String: FinishedSet] = [:] + for set in sets where set.startTs != nil && !set.isWarmup && LiftMetrics.isPerformed(reps: set.reps) { + guard plan.indices.contains(set.slot.exerciseIndex), + let id = plan[set.slot.exerciseIndex].programItemId else { continue } + if let best = heaviest[id], !isHeavier(set, than: best) { continue } + heaviest[id] = set + } + return items.map { row in + guard let top = heaviest[row.id] else { return row } + var edited = row + if let weight = top.weightKg { edited.targetWeightKg = weight } + if let reps = top.reps { + edited.targetRepsLow = reps + if let high = edited.targetRepsHigh, high < reps { edited.targetRepsHigh = nil } + } + return edited + } + } + + /// More weight wins; at equal weight, more reps. A missing number ranks below any number. + private static func isHeavier(_ a: FinishedSet, than b: FinishedSet) -> Bool { + let (weightA, weightB) = (a.weightKg ?? -1, b.weightKg ?? -1) + if weightA != weightB { return weightA > weightB } + return (a.reps ?? -1) > (b.reps ?? -1) + } + + /// The program's lines after this session — what `LiftSessionView.save` writes. + /// + /// Every line already in the program takes its heaviest done set (`applyingHeaviestSets`, always). + /// With `keepingChanges` — the answer to the finish sheet's program question — changed set counts + /// move too (`applying`), and each exercise added during the session becomes a new line at the end, + /// in the order added: the session's set count for it, its heaviest done set's weight and reps, else + /// 0 kg × 0 reps, and nothing else (Utku, 21 Sep 2026: the rest is set later in the program editor). + static func programAfterSession(_ sets: [FinishedSet], plan: [LiftPlanItem], + program items: [LiftProgramItemRow], keepingChanges: Bool, + programId: String, deviceId: String) -> [LiftProgramItemRow] { + var lines = items + if keepingChanges { + lines = applying(setCountChanges(plan: plan, program: items), to: lines) + let next = (items.map(\.ord).max() ?? -1) + 1 + for (offset, line) in plan.filter(\.addedInSession).enumerated() { + guard let id = line.programItemId, !lines.contains(where: { $0.id == id }) else { continue } + lines.append(LiftProgramItemRow( + id: id, deviceId: deviceId, programId: programId, ord: next + offset, + exercise: line.exercise, targetSets: line.targetSets, + targetRepsLow: 0, targetRepsHigh: nil, targetRpe: nil, targetWeightKg: 0, + restSec: nil, note: nil)) + } + } + return applyingHeaviestSets(sets, plan: plan, to: lines) + } + + /// The program's lines with `changes` applied. Only `targetSets` moves. + static func applying(_ changes: [SetCountChange], to items: [LiftProgramItemRow]) -> [LiftProgramItemRow] { + let counts = Dictionary(changes.map { ($0.itemId, $0.to) }, uniquingKeysWith: { first, _ in first }) + return items.map { row in + guard let sets = counts[row.id] else { return row } + var edited = row + edited.targetSets = sets + return edited + } + } + + // MARK: - The rest's two moments + // + // A running session changes with time alone at two moments of each rest: the warning buzz + // `restWarningLeadSec` before it ends, and its end. Each gets a one-shot timer, set when the rest starts + // and replaced whenever the rest does; between taps a session does no work at all. + // + // It used to tick once a second, and publish the tick to every screen watching the session — the whole + // tab shell, the session sheet, the bar, the Lift Log hub — so all of them were redrawn every second, on + // screen or not. iOS killed NOOP four times in one gym session for background CPU (Utku's crash reports, + // 21 Sep 2026: over 80% for 60 s, busy redrawing SwiftUI views), and every kill cost a Lock Screen banner + // and the log before it. The clocks on screen tick by themselves, and only while shown (`LiftRunningClock`). + + /// Re-arm the rest's timers when the rest changed — a new rest, a rest undone or cut short, no rest. + private func scheduleRestTimers() { + let endsAt: Int? = { if case .resting(_, let end) = engine?.stage { return end }; return nil }() + guard endsAt != scheduledRestEnd else { return } + scheduledRestEnd = endsAt + restTimers.forEach { $0.cancel() } + restTimers = [] + guard let endsAt else { return } + let times = Self.restEventTimes(endsAt: endsAt, now: Self.unixNow) + restTimers.append(after(times.warning) { $0.fireRestWarningIfDue() }) + if let end = times.end { + restTimers.append(after(end) { $0.restDidEnd() }) + } + } + + /// When a rest's warning and end fire, in unix seconds. The warning comes `restWarningLeadSec` before the + /// end, or one second from now when the rest is already inside that window (a short rest, none at all, + /// or the rest left once every set is done) — when the once-a-second tick used to fire it, and clear of + /// the confirming buzz the same tap just sent. The end fires only for a rest still to run. + static func restEventTimes(endsAt: Int, now: Int) -> (warning: Int, end: Int?) { + (warning: max(now + 1, endsAt - restWarningLeadSec), end: endsAt > now ? endsAt : nil) + } + + /// Run `action` on the main actor at unix second `unix`, unless cancelled first. + private func after(_ unix: Int, + _ action: @escaping @MainActor (LiftSessionController) -> Void) -> Task { + let delay = Date(timeIntervalSince1970: TimeInterval(unix)).timeIntervalSinceNow + return Task { [weak self] in + if delay > 0 { try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) } + guard !Task.isCancelled, let self else { return } + action(self) + } + } + + private func fireRestWarningIfDue() { + guard let engine, case .resting(_, let endsAt) = engine.stage else { return } + guard warnedFor != endsAt else { return } + guard endsAt - Self.unixNow <= LiftSessionController.restWarningLeadSec else { return } + warnedFor = endsAt + buzz(LiftSessionController.restWarningBuzzes) + } + + /// The rest is over: the one moment a session's words change with time alone — on screen and, through + /// `changesSettled`, on the Lock Screen, "Resting after set 2" becomes "Ready for the next set". + private func restDidEnd() { + objectWillChange.send() + } + + // MARK: - Persistence + + private func persist() { + guard let engine, !engine.isFinished else { return } + LiftSessionPersistence.store( + LiftSessionPersistence.snapshot(engine: engine, + programId: programId, + programName: programName, + pendingValues: pendingValues, + pendingWarmups: pendingWarmups)) + } +} diff --git a/Strand/Data/LiftSessionEngine.swift b/Strand/Data/LiftSessionEngine.swift new file mode 100644 index 0000000000..3e33e6e490 --- /dev/null +++ b/Strand/Data/LiftSessionEngine.swift @@ -0,0 +1,474 @@ +import Foundation +import WhoopStore + +// The session state machine: the heart of the Lift Log. +// +// A GYM IS NOT A QUEUE. The first version walked the plan strictly in order, one set at a time, and +// the first real session killed that: when a machine is occupied you move on and come back. So the +// plan is a SHEET of slots — every set of every exercise, all visible — and any pending slot can be +// started at any time. Order is a suggestion the session follows by default, not a rail. +// +// PURE (no timers, no store, no SwiftUI): it is the piece most likely to be wrong in a way that +// costs someone a logged set, and the only part testable with no strap, no database, no simulator. +// +// TIME ENTERS ONLY AS A PARAMETER, so a test can drive a whole session through a known timeline. +// Rest is an ABSOLUTE end instant, never a decrementing counter — a phone that sleeps through a rest +// must wake up telling the truth. +// +// THERE IS NO COOL-DOWN STAGE. Warm-up is the time before the first set and cool-down the time after +// the last; both fall out of the timestamps, so neither needs a stage of its own. The session ends +// when the user says it does. + +/// One set of one exercise — a position on the sheet. `setIndex` is 1-based within its exercise. +struct LiftSlot: Hashable { + var exerciseIndex: Int + var setIndex: Int +} + +/// One planned exercise line, flattened from a program for the session to run. +struct LiftPlanItem: Equatable { + var exercise: String + var primaryMuscle: LiftMuscle? + var secondaryMuscles: [LiftMuscle] + /// Planned working sets. Always >= 1 — a line scheduling zero sets could not be tapped through. + var targetSets: Int + /// Intended rest after each set, in seconds. + var restSec: Int + var targetRepsLow: Int? + var targetRepsHigh: Int? + var targetRpe: Double? + /// The weight the program plans, in kilograms. + var targetWeightKg: Double? + var note: String? + /// The `liftProgramItem.id` this line was flattened from, so a set count changed during the session + /// can be offered back to the program when it is finished. Nil for a line with no program behind + /// it, which is then never offered. A line added during the session carries the id its program + /// line will have if finishing adds it (`addedInSession`). + var programItemId: String? + /// Added while the session ran (`LiftSessionEngine.addExercise`), so the program has no line for it + /// yet; finishing offers to add one. + var addedInSession: Bool + + /// Rest used when a program line does not specify one. Two minutes sits in the middle of the + /// range the hypertrophy literature uses for compound work, and is only a starting value: what + /// is actually rested is measured from the taps. + static let defaultRestSec = 120 + + init(exercise: String, + primaryMuscle: LiftMuscle? = nil, + secondaryMuscles: [LiftMuscle] = [], + targetSets: Int? = nil, + restSec: Int? = nil, + targetRepsLow: Int? = nil, + targetRepsHigh: Int? = nil, + targetRpe: Double? = nil, + targetWeightKg: Double? = nil, + note: String? = nil, + programItemId: String? = nil, + addedInSession: Bool = false) { + self.exercise = exercise + self.primaryMuscle = primaryMuscle + self.secondaryMuscles = secondaryMuscles + self.targetSets = max(1, targetSets ?? 1) + self.restSec = max(0, restSec ?? LiftPlanItem.defaultRestSec) + self.targetRepsLow = targetRepsLow + self.targetRepsHigh = targetRepsHigh + self.targetRpe = targetRpe + self.targetWeightKg = targetWeightKg + self.note = note + self.programItemId = programItemId + self.addedInSession = addedInSession + } +} + +/// One set as actually performed. +struct LiftRecordedSet: Equatable { + var exerciseIndex: Int + var setIndex: Int + var weightKg: Double? + var reps: Int? + var rpe: Double? + var isWarmup: Bool + var startTs: Int + var endTs: Int + /// Rest actually taken after this set. Nil while the rest is still running, or when the user + /// moved on without resting. + var restSec: Int? + + var slot: LiftSlot { LiftSlot(exerciseIndex: exerciseIndex, setIndex: setIndex) } +} + +/// The numbers a set shows in grey: what it would save if completed without anything typed. +/// +/// Only weight and reps. RPE is deliberately absent — see `carry(for:lastSession:)`. +struct LiftSetCarry: Equatable { + var weightKg: Double? + var reps: Int? + + static let none = LiftSetCarry(weightKg: nil, reps: nil) +} + +struct LiftSessionEngine: Equatable { + + enum Stage: Equatable { + /// Before the first set — the warm-up. + case warmup + /// Performing a set. + case working(LiftSlot) + /// Resting after a set. `endsAt` is an absolute unix second. + case resting(LiftSlot, endsAt: Int) + /// Ended; ready to save. + case finished + } + + /// The lines being worked. MUTABLE in two ways only, both because a gym decides them as it goes: + /// how many sets a line holds — a fifth set on a line that planned four, or dropping the last one + /// when the tank is empty — and a line added at the end for an exercise the program does not have. + /// Nothing about a line already there changes mid-session, so the plan stays the snapshot it was at + /// start, plus whatever was added. + private(set) var plan: [LiftPlanItem] + /// When the session began (unix seconds). + let startTs: Int + private(set) var stage: Stage + /// Completed sets in COMPLETION order — which is the order they happened, not the plan's order, + /// and is what `ord` is written from. + private(set) var sets: [LiftRecordedSet] + /// When the current stage began, so a set's duration is measurable. + private(set) var stageStartedAt: Int + + /// Undo stack: whole-state snapshots rather than inverse operations. A gym is a bad place to be + /// one tap ahead of yourself, and restoring a snapshot cannot get the arithmetic wrong the way a + /// hand-written inverse can. + private var history: [Snapshot] = [] + + /// The plan travels in the snapshot with the stage and the sets, and is restored with them. + /// + /// It has to. Once the set count can move, a stage saved under one count is only meaningful + /// under that count: undoing back past a removed set would otherwise leave the session working + /// a slot the sheet no longer draws — and completing it would write a set nobody could see. + private struct Snapshot: Equatable { + var plan: [LiftPlanItem] + var stage: Stage + var sets: [LiftRecordedSet] + var stageStartedAt: Int + } + + init(plan: [LiftPlanItem], startTs: Int) { + self.plan = plan + self.startTs = startTs + self.stage = .warmup + self.sets = [] + self.stageStartedAt = startTs + } + + /// Rebuild an interrupted session — see `LiftSessionPersistence`. + /// + /// The undo history is deliberately NOT restored: it is a within-sitting convenience, and a + /// stack that survives a relaunch invites reaching back past a save boundary. + init(restoring plan: [LiftPlanItem], startTs: Int, stage: Stage, + sets: [LiftRecordedSet], stageStartedAt: Int) { + self.plan = plan + self.startTs = startTs + self.stage = stage + self.sets = sets + self.stageStartedAt = stageStartedAt + } + + // MARK: - The sheet + + /// Every slot of one exercise, in order — the rows the sheet draws. + func slots(forExercise index: Int) -> [LiftSlot] { + guard plan.indices.contains(index) else { return [] } + return (1...plan[index].targetSets).map { LiftSlot(exerciseIndex: index, setIndex: $0) } + } + + /// Every slot in the whole session, in plan order. + var allSlots: [LiftSlot] { plan.indices.flatMap { slots(forExercise: $0) } } + + func recordedSet(for slot: LiftSlot) -> LiftRecordedSet? { + sets.first { $0.slot == slot } + } + + func isCompleted(_ slot: LiftSlot) -> Bool { recordedSet(for: slot) != nil } + + /// The slot being worked or rested from. + var currentSlot: LiftSlot? { + switch stage { + case .working(let s): return s + case .resting(let s, _): return s + case .warmup, .finished: return nil + } + } + + /// The next slot the plan would suggest — the first uncompleted one in plan order. Nil when the + /// whole sheet is done. + /// + /// This is the SHEET's answer, used to open a session and to know when everything is done. It is + /// deliberately NOT what `advance` follows after a set — see `slotAfter(_:)`. + var nextPendingSlot: LiftSlot? { + allSlots.first { !isCompleted($0) } + } + + /// Where the session goes after finishing `slot`: the next uncompleted set of the SAME exercise, + /// and only once that exercise is finished, the first uncompleted slot in plan order. + /// + /// Plan order alone is wrong, and wrong in a way that costs sets. A gym is not a queue — the + /// whole point of being able to start any pending set is that machines get occupied — so a user + /// who skips exercise 1 and starts exercise 3 has an EARLIER slot still uncompleted. Following + /// plan order then throws them back to the machine they just walked away from, mid-exercise, + /// after every single set. Reported from a real session: "when I double-tap for the next set, it + /// reverts to the first set of the exercise I couldn't do earlier." + /// + /// Staying on the current exercise until it is finished is also simply what lifting is: you do + /// your sets on the machine you are standing at. The skipped exercise is not forgotten — it is + /// still pending, and it is what you get once the current one is done. + func slotAfter(_ slot: LiftSlot) -> LiftSlot? { + slots(forExercise: slot.exerciseIndex).first { !isCompleted($0) } ?? nextPendingSlot + } + + /// The set the session is heading to after the current one — what the minimised bar and the Lock + /// Screen name as "next". During the warm-up it is the set `advance` starts; while a set is worked + /// or rested from, it is where `advance` goes once that set is done: `slotAfter(_:)`, with the + /// current set counted as done. Never a rest, which sits between sets rather than being one of + /// them. Nil when nothing is left to do after the current set. + var upcomingSlot: LiftSlot? { + switch stage { + case .warmup: + return nextPendingSlot + case .working(let slot), .resting(let slot, _): + return slots(forExercise: slot.exerciseIndex).first { !isCompleted($0) && $0 != slot } + ?? allSlots.first { !isCompleted($0) && $0 != slot } + case .finished: + return nil + } + } + + var allCompleted: Bool { nextPendingSlot == nil } + var isFinished: Bool { stage == .finished } + var canUndo: Bool { !history.isEmpty } + + var plannedWorkingSets: Int { plan.reduce(0) { $0 + $1.targetSets } } + var completedWorkingSets: Int { sets.filter { !$0.isWarmup }.count } + + func planItem(for slot: LiftSlot) -> LiftPlanItem? { + plan.indices.contains(slot.exerciseIndex) ? plan[slot.exerciseIndex] : nil + } + + /// Seconds left in the current rest, floored at zero. Nil when not resting. + /// + /// Floored rather than negative so an overrun rest reads "0:00" and waits, which is what it + /// actually means: the user has not moved on yet. + func restRemaining(now: Int) -> Int? { + guard case .resting(_, let endsAt) = stage else { return nil } + return max(0, endsAt - now) + } + + /// The nearest earlier set of this exercise already performed in THIS session. Nil for the first + /// set, or when nothing before it has been done yet. + func previousSetInSession(for slot: LiftSlot) -> LiftRecordedSet? { + (1.. LiftSetCarry { + let previous = previousSetInSession(for: slot).map { values(of: $0.slot, lastSession: lastSession) } + let last = lastSession[slot.setIndex] + let item = planItem(for: slot) + return LiftSetCarry( + weightKg: previous?.weightKg ?? last?.weightKg ?? item?.targetWeightKg, + reps: previous?.reps ?? last?.reps ?? item?.targetRepsLow) + } + + /// What a slot counts as: each number typed into it, else its grey number. + func values(of slot: LiftSlot, lastSession: [Int: LiftSetCarry]) -> LiftSetCarry { + let grey = carry(for: slot, lastSession: lastSession) + guard let set = recordedSet(for: slot) else { return grey } + return LiftSetCarry(weightKg: set.weightKg ?? grey.weightKg, reps: set.reps ?? grey.reps) + } + + /// Slots never performed. Finishing the session asks once what happens to all of them; a set that + /// was performed is complete whether or not anything was typed into it (Utku, 21 Sep 2026). + var unperformedSlots: [LiftSlot] { + allSlots.filter { !isCompleted($0) } + } + + // MARK: - Actions + + /// Begin a specific set. Works from any stage, which is the whole point: a machine being busy + /// must not block the session. + /// + /// Starting a set while ANOTHER set is in progress abandons that one — nothing is recorded, + /// because nothing was finished. Starting an already-completed slot re-opens it for a redo, + /// dropping its previous record so the set is not counted twice. + mutating func start(_ slot: LiftSlot, now: Int) { + guard planItem(for: slot) != nil else { return } + pushHistory() + sets.removeAll { $0.slot == slot } + stage = .working(slot) + stageStartedAt = now + } + + /// The big button. Context decides what it means. + mutating func advance(now: Int) { + switch stage { + case .warmup: + guard let next = nextPendingSlot else { return } + start(next, now: now) + + case .working(let slot): + pushHistory() + // Timing only: the numbers stay grey until typed (see `carry(for:lastSession:)`). + sets.append(LiftRecordedSet( + exerciseIndex: slot.exerciseIndex, setIndex: slot.setIndex, + weightKg: nil, reps: nil, rpe: nil, isWarmup: false, + startTs: stageStartedAt, endTs: now, restSec: nil)) + let rest = planItem(for: slot)?.restSec ?? LiftPlanItem.defaultRestSec + stage = .resting(slot, endsAt: now + rest) + stageStartedAt = now + + case .resting(let slot, _): + pushHistory() + // Record what was ACTUALLY rested — the figure each set row shows, and the one thing + // only the taps can know. + if let i = sets.firstIndex(where: { $0.slot == slot }) { + sets[i].restSec = max(0, now - stageStartedAt) + } + if let next = slotAfter(slot) { + stage = .working(next) + stageStartedAt = now + } else { + // Sheet complete: stay put rather than inventing a stage. The user finishes when + // they are ready, and the time until then is the cool-down. + stage = .resting(slot, endsAt: now) + stageStartedAt = now + } + + case .finished: + break + } + } + + /// Fill in (or correct) what a set actually was. Editing, never appending — the set already + /// exists, so typing can never create a phantom. No-op for a slot that has not been completed. + mutating func updateSet(_ slot: LiftSlot, + weightKg: Double?, reps: Int?, rpe: Double?, isWarmup: Bool) { + guard let i = sets.firstIndex(where: { $0.slot == slot }) else { return } + sets[i].weightKg = weightKg + sets[i].reps = reps + sets[i].rpe = rpe + sets[i].isWarmup = isWarmup + } + + // MARK: - Changing how many sets a line holds + // + // A program is what you INTENDED, and a gym argues with it. Five sets when the program says four + // is ordinary; so is stopping at three because the tank is empty. Until this existed the fifth + // set simply could not be recorded — the sheet drew exactly `1...targetSets` and there was no + // way past it — so the set was done and then lost, which is the failure this feature exists to + // prevent. + + /// The most sets one line may hold. A bound against a stuck finger, not a recommendation: every + /// added set is a row on the sheet and a slot in the crash snapshot. + static let maxSetsPerExercise = 20 + + /// Append one set to an exercise's line. Returns false when the line is already at the bound, so + /// a caller can tell "did nothing" from "done" without re-deriving the rule. + @discardableResult + mutating func addSet(toExercise index: Int) -> Bool { + guard plan.indices.contains(index), + plan[index].targetSets < LiftSessionEngine.maxSetsPerExercise else { return false } + pushHistory() + plan[index].targetSets += 1 + return true + } + + /// Whether the last set of a line can be dropped. + /// + /// Only the LAST one, and only while it is still pending. Set numbers are positions — the sheet + /// draws `1...targetSets` — so removing from the middle would renumber every set after it and + /// silently re-label what was already recorded. And a completed set is DATA: dropping it here + /// would delete a set the user actually performed, from a button whose job is to edit a plan. + /// The set being worked or rested from is excluded for the same reason it cannot be renumbered: + /// the session is standing on it. + func canRemoveSet(fromExercise index: Int) -> Bool { + guard plan.indices.contains(index), plan[index].targetSets > 1 else { return false } + let last = LiftSlot(exerciseIndex: index, setIndex: plan[index].targetSets) + return !isCompleted(last) && currentSlot != last + } + + /// Drop the last (pending) set of an exercise's line. Returns false when the rule above says no. + @discardableResult + mutating func removeSet(fromExercise index: Int) -> Bool { + guard canRemoveSet(fromExercise: index) else { return false } + pushHistory() + plan[index].targetSets -= 1 + return true + } + + // MARK: - Adding an exercise + + /// The most lines one session may hold: the importer's cap on one program, so a session grown during + /// the workout stays inside the bound its crash snapshot (written on every change) was sized for. + static let maxExercises = 200 + + /// Append an exercise the program does not have, as ONE set at the end of the sheet (Utku, 21 Sep + /// 2026). Any set count the caller gives is ignored: a line starts with one set, and ⊕/⊖ change it + /// like any other. Undoable, like adding a set. Returns false when nothing was added — the session + /// is finished, or already at `maxExercises`. + @discardableResult + mutating func addExercise(_ item: LiftPlanItem) -> Bool { + guard stage != .finished, plan.count < LiftSessionEngine.maxExercises else { return false } + pushHistory() + var line = item + line.targetSets = 1 + plan.append(line) + return true + } + + /// End the session. The rest that was running is closed out first, so its measured duration is + /// not silently lost. + mutating func finish(now: Int) { + guard stage != .finished else { return } + pushHistory() + if case .resting(let slot, _) = stage, + let i = sets.firstIndex(where: { $0.slot == slot }), sets[i].restSec == nil { + sets[i].restSec = max(0, now - stageStartedAt) + } + stage = .finished + stageStartedAt = now + } + + mutating func undo() { + guard let previous = history.popLast() else { return } + plan = previous.plan + stage = previous.stage + sets = previous.sets + stageStartedAt = previous.stageStartedAt + } + + private mutating func pushHistory() { + history.append(Snapshot(plan: plan, stage: stage, + sets: sets, stageStartedAt: stageStartedAt)) + } +} diff --git a/Strand/Data/LiftSessionPersistence.swift b/Strand/Data/LiftSessionPersistence.swift new file mode 100644 index 0000000000..e6017fe333 --- /dev/null +++ b/Strand/Data/LiftSessionPersistence.swift @@ -0,0 +1,258 @@ +import Foundation +import WhoopStore + +// Crash-safety for an in-flight gym session. +// +// A session is written to UserDefaults on every tap and rehydrated on launch, so a crash, a phone +// call, a flat battery or simply swiping the app away mid-workout costs nothing. This mirrors +// `ActiveWorkoutPersistence` exactly — a small `Codable` snapshot, unix-second anchors, and an +// encode/decode pair that is pure (no UserDefaults dependency of its own) so the round-trip is +// testable without touching the defaults database. +// +// Unix seconds rather than `Date` for the same reason the store uses them: an absolute instant +// survives suspension, timezone changes and a relaunch, and it is the only anchor that keeps a rest +// countdown honest. + +enum LiftSessionPersistence { + + /// The durable shape of an in-flight session — the minimum needed to rebuild the engine and still + /// finish and save the session after a relaunch. + struct Snapshot: Codable, Equatable { + var startSec: Int + var programId: String? + var programName: String? + /// The plan, flattened at start. Stored rather than re-read from the program so that editing + /// or deleting the program mid-session cannot change what is being run. + var plan: [PlanItem] + var stage: StageBox + var sets: [RecordedSet] + var stageStartedAt: Int + /// Numbers typed into sets that have NOT happened yet, and slots marked a warm-up in + /// advance. Both are intent the user has already expressed, so a crash must not cost them — + /// that is the whole point of this snapshot. + /// + /// Optional because a snapshot written before these existed does not carry them: it decodes + /// as absent and the session resumes with nothing pending, which is exactly right. + var pendingValues: [PendingValue]? + var pendingWarmups: [SlotBox]? + + struct PendingValue: Codable, Equatable { + var exerciseIndex: Int + var setIndex: Int + var weightKg: Double? + var reps: Int? + var rpe: Double? + } + + struct SlotBox: Codable, Equatable { + var exerciseIndex: Int + var setIndex: Int + } + + struct PlanItem: Codable, Equatable { + var exercise: String + var primaryMuscle: String? + var secondaryMuscles: [String] + var targetSets: Int + var restSec: Int + var targetRepsLow: Int? + var targetRepsHigh: Int? + var targetRpe: Double? + var targetWeightKg: Double? + var note: String? + /// The program line this was flattened from. Absent from a snapshot written before the + /// set count could be changed mid-session, which decodes to nil and simply skips the + /// write-back — the session itself is unaffected. + var programItemId: String? + /// True for a line added during the session. Absent from a snapshot written before lines + /// could be added, which decodes to nil: a line from the program. + var addedInSession: Bool? + } + + /// The stage as a flat, forward-compatible record rather than an encoded enum: a persisted + /// enum with associated values is a migration hazard the moment a case is added, and this + /// shape decodes to "warm-up" rather than to garbage if it is ever read by an older build. + struct StageBox: Codable, Equatable { + var kind: String // warmup | working | resting | cooldown | finished + var item: Int? + var set: Int? + var endsAt: Int? + } + + struct RecordedSet: Codable, Equatable { + var exerciseIndex: Int + var setIndex: Int + var weightKg: Double? + var reps: Int? + var rpe: Double? + var isWarmup: Bool + var startTs: Int + var endTs: Int + var restSec: Int? + } + } + + /// The single UserDefaults key (JSON-encoded `Snapshot`), namespaced like `noop.activeWorkout`. + static let defaultsKey = "noop.activeLiftSession" + + // MARK: - Codec + + static func encode(_ snapshot: Snapshot) -> Data? { + try? JSONEncoder().encode(snapshot) + } + + /// Decode a snapshot, bound-checking the untrusted persisted values. Returns nil for + /// nil/garbage/empty input or an implausible start time, so a corrupt write is treated as "no + /// session in flight" rather than reviving a broken screen the user cannot get out of. + static func decode(_ data: Data?) -> Snapshot? { + guard let data, !data.isEmpty, + let raw = try? JSONDecoder().decode(Snapshot.self, from: data), + raw.startSec > 1_000_000_000, // after 2001; not a zero/garbage anchor + raw.startSec < Int(Date().timeIntervalSince1970) + 86_400, + !raw.plan.isEmpty + else { return nil } + return raw + } + + // MARK: - UserDefaults + + static func store(_ snapshot: Snapshot?, into d: UserDefaults = .standard) { + guard let snapshot, let data = encode(snapshot) else { + d.removeObject(forKey: defaultsKey) + return + } + d.set(data, forKey: defaultsKey) + } + + static func load(from d: UserDefaults = .standard) -> Snapshot? { + decode(d.data(forKey: defaultsKey)) + } + + static func clear(_ d: UserDefaults = .standard) { + d.removeObject(forKey: defaultsKey) + } + + // MARK: - Engine bridge + + static func snapshot(engine: LiftSessionEngine, + programId: String?, + programName: String?, + pendingValues: [LiftSlot: LiftSessionController.PendingSetValues], + pendingWarmups: Set) -> Snapshot { + Snapshot( + startSec: engine.startTs, + programId: programId, + programName: programName, + plan: engine.plan.map { + Snapshot.PlanItem(exercise: $0.exercise, + primaryMuscle: $0.primaryMuscle?.rawValue, + secondaryMuscles: $0.secondaryMuscles.map(\.rawValue), + targetSets: $0.targetSets, + restSec: $0.restSec, + targetRepsLow: $0.targetRepsLow, + targetRepsHigh: $0.targetRepsHigh, + targetRpe: $0.targetRpe, + targetWeightKg: $0.targetWeightKg, + note: $0.note, + programItemId: $0.programItemId, + // Written only when true, so a session nobody added to encodes as before. + addedInSession: $0.addedInSession ? true : nil) + }, + stage: box(engine.stage), + sets: engine.sets.map { + Snapshot.RecordedSet(exerciseIndex: $0.exerciseIndex, setIndex: $0.setIndex, + weightKg: $0.weightKg, reps: $0.reps, rpe: $0.rpe, + isWarmup: $0.isWarmup, startTs: $0.startTs, endTs: $0.endTs, + restSec: $0.restSec) + }, + stageStartedAt: engine.stageStartedAt, + // Sorted so the encoded snapshot is stable: a dictionary and a set have no order, and an + // unstable encoding would rewrite the defaults blob on every tick for no reason. + pendingValues: pendingValues + .sorted { ($0.key.exerciseIndex, $0.key.setIndex) < ($1.key.exerciseIndex, $1.key.setIndex) } + .map { slot, values in + Snapshot.PendingValue(exerciseIndex: slot.exerciseIndex, setIndex: slot.setIndex, + weightKg: values.weightKg, reps: values.reps, rpe: values.rpe) + }, + pendingWarmups: pendingWarmups + .sorted { ($0.exerciseIndex, $0.setIndex) < ($1.exerciseIndex, $1.setIndex) } + .map { Snapshot.SlotBox(exerciseIndex: $0.exerciseIndex, setIndex: $0.setIndex) }) + } + + /// The numbers typed in advance, back as the controller holds them. + static func pendingValues(from s: Snapshot) -> [LiftSlot: LiftSessionController.PendingSetValues] { + var out: [LiftSlot: LiftSessionController.PendingSetValues] = [:] + for p in s.pendingValues ?? [] { + out[LiftSlot(exerciseIndex: p.exerciseIndex, setIndex: p.setIndex)] = + LiftSessionController.PendingSetValues(weightKg: p.weightKg, reps: p.reps, rpe: p.rpe) + } + return out + } + + /// The warm-up marks made in advance, back as the controller holds them. + static func pendingWarmups(from s: Snapshot) -> Set { + Set((s.pendingWarmups ?? []).map { LiftSlot(exerciseIndex: $0.exerciseIndex, setIndex: $0.setIndex) }) + } + + /// Rebuild an engine from a snapshot. Unknown muscle tokens are dropped rather than failing the + /// read, matching `LiftMuscle.decodeList`: a snapshot written by a newer build must not strand a + /// session in an older one. + static func engine(from s: Snapshot) -> LiftSessionEngine { + let plan = s.plan.map { + LiftPlanItem(exercise: $0.exercise, + primaryMuscle: $0.primaryMuscle.flatMap(LiftMuscle.init(rawValue:)), + secondaryMuscles: $0.secondaryMuscles.compactMap(LiftMuscle.init(rawValue:)), + targetSets: $0.targetSets, + restSec: $0.restSec, + targetRepsLow: $0.targetRepsLow, + targetRepsHigh: $0.targetRepsHigh, + targetRpe: $0.targetRpe, + targetWeightKg: $0.targetWeightKg, + note: $0.note, + programItemId: $0.programItemId, + addedInSession: $0.addedInSession ?? false) + } + let sets = s.sets.map { + LiftRecordedSet(exerciseIndex: $0.exerciseIndex, setIndex: $0.setIndex, + weightKg: $0.weightKg, reps: $0.reps, rpe: $0.rpe, + isWarmup: $0.isWarmup, startTs: $0.startTs, endTs: $0.endTs, + restSec: $0.restSec) + } + return LiftSessionEngine(restoring: plan, startTs: s.startSec, + stage: unbox(s.stage), sets: sets, stageStartedAt: s.stageStartedAt) + } + + private static func box(_ stage: LiftSessionEngine.Stage) -> Snapshot.StageBox { + switch stage { + case .warmup: + return .init(kind: "warmup", item: nil, set: nil, endsAt: nil) + case .working(let slot): + return .init(kind: "working", item: slot.exerciseIndex, set: slot.setIndex, endsAt: nil) + case .resting(let slot, let endsAt): + return .init(kind: "resting", item: slot.exerciseIndex, set: slot.setIndex, endsAt: endsAt) + case .finished: + return .init(kind: "finished", item: nil, set: nil, endsAt: nil) + } + } + + /// Anything unrecognised — a `working`/`resting` box missing its indices, or the retired + /// "cooldown" kind written by an earlier build — falls back to the warm-up. + /// + /// That is a real recovery, not a shrug: every COMPLETED set is carried in `sets` regardless, so + /// nothing logged is lost. Only the cursor's position is forgotten, and the next tap simply + /// resumes at the first set still outstanding. + private static func unbox(_ box: Snapshot.StageBox) -> LiftSessionEngine.Stage { + switch box.kind { + case "working": + guard let i = box.item, let s = box.set else { return .warmup } + return .working(LiftSlot(exerciseIndex: i, setIndex: s)) + case "resting": + guard let i = box.item, let s = box.set, let e = box.endsAt else { return .warmup } + return .resting(LiftSlot(exerciseIndex: i, setIndex: s), endsAt: e) + case "finished": + return .finished + default: + return .warmup + } + } +} diff --git a/Strand/Data/LiveHRBannerLifecycle.swift b/Strand/Data/LiveHRBannerLifecycle.swift new file mode 100644 index 0000000000..1d644d7558 --- /dev/null +++ b/Strand/Data/LiveHRBannerLifecycle.swift @@ -0,0 +1,42 @@ +import Foundation + +/// What NOOP's Live HR banner does with the latest live values: start, push, end, or nothing. +/// +/// WHY THIS EXISTS. iOS lets an app START a Live Activity only while it is on screen; one already running can be +/// updated from the background. The banner used to be ENDED whenever the strap's link dropped and whenever a history +/// sync ran — including the automatic one every 15 minutes, which in the background shows no sync banner at all — +/// so within minutes of leaving NOOP it was gone, and it stayed gone until NOOP was opened again. A tester's log +/// held three link timeouts in one afternoon, each recovered within seconds (23 Sep 2026). Meanwhile, with no banner +/// and the app in the background, every heart-rate tick asked iOS for a new one and was refused. +/// +/// So NOOP never ends the banner for something that passes: not for a dropped link however long, not for a strap off +/// the wrist, not for a sync, not for having nothing to show while NOOP is on screen. It shows the dash then (iOS +/// draws it at the banner's stale date even while NOOP is suspended) and the number again by itself; a banner NOOP +/// ended could come back only once NOOP was opened, which the tester found illogical (24 Sep 2026). It ends only when +/// its switch is turned off — the one way to be rid of it — or while the Lift Log banner, which carries the heart +/// rate itself, is on screen. A new one is asked for only in the foreground, with the strap connected, whether or not +/// a heart rate has arrived yet. +/// +/// iOS itself ends a Live Activity about eight hours after it was started. So when NOOP is on screen with a banner +/// more than `renewAfter` old, it starts a fresh one and then lets the old one go (`renew`): each time NOOP is opened +/// the banner has most of those hours ahead of it again. The swap happens while NOOP is in front, with the Lock Screen +/// out of sight, and the new banner exists before the old one ends, so there is no moment without one. +/// +/// Pure and platform-free so `StrandTests` covers it; the controller it serves is in the iOS app target. +enum LiveHRBannerLifecycle { + + enum Step: Equatable { case nothing, start, push, renew, end } + + /// A banner older than this is replaced by a fresh one while NOOP is on screen. + static let renewAfter: TimeInterval = 60 * 60 + + /// `showing`: a banner exists, started `age` ago (nil when NOOP does not know, which counts as old). + /// `standsAside`: the Lift Log banner is on screen. `linkUp`: the strap is connected. `appActive`: NOOP is on + /// screen, the only time iOS lets it start a banner. + static func step(switchOn: Bool, standsAside: Bool, linkUp: Bool, showing: Bool, age: TimeInterval?, + appActive: Bool) -> Step { + guard switchOn, !standsAside else { return showing ? .end : .nothing } + if showing { return appActive && (age ?? .infinity) >= renewAfter ? .renew : .push } + return linkUp && appActive ? .start : .nothing + } +} diff --git a/Strand/Data/LiveHRBannerPushPolicy.swift b/Strand/Data/LiveHRBannerPushPolicy.swift new file mode 100644 index 0000000000..ec7fc00ec9 --- /dev/null +++ b/Strand/Data/LiveHRBannerPushPolicy.swift @@ -0,0 +1,41 @@ +import Foundation + +/// When NOOP's Live HR banner (the Lock Screen / Dynamic Island Live Activity) is worth a push. +/// +/// WHY THIS EXISTS. The banner is driven off the live heart-rate stream, which ticks about once a second for as +/// long as a strap stays connected, and it used to be pushed on the first tick more than 2 s after the last +/// push — every ~3 s, 1,200 times an hour — whether or not anything on it had changed. Each push wakes the +/// widget extension to re-render the Lock Screen and the Dynamic Island, and ActivityKit budgets how often an +/// app may update an activity. The banner shows three numbers — the smoothed +/// heart rate, recovery and effort — and the smoothed heart rate is a 10-second median that holds still across +/// most ticks, so most of those pushes re-drew exactly what was already on screen. +/// +/// So the banner is pushed when what it shows has changed (still no more often than `minimumSpacing`), and an +/// unchanged banner is re-pushed only often enough to keep its stale date ahead of it. What the banner shows, +/// and how soon a new number reaches it, is unchanged. This is the rule Android's connection notification has +/// followed since #216: re-post only when a rendered field changes. +/// +/// Pure and platform-free so `StrandTests` covers it: `StrandTests` runs on macOS and cannot exercise +/// ActivityKit, so the rule has to live outside the controller to be unit tested at all. (The controller +/// itself IS compiled in CI, by `app-build.yml`'s `NOOPiOS` leg.) +enum LiveHRBannerPushPolicy { + + /// The shortest time between two pushes, as before: well under ActivityKit's update budget. + static let minimumSpacing: TimeInterval = 2 + + /// `shown` is what the banner was last pushed with (nil when this controller has pushed nothing yet, so the + /// first update always goes out), `next` what it would show now, and `reading` its heart rate. `sinceLastPush` + /// is how long ago the last push was; `staleAfter` is the stale date each push carries. An unchanged banner is + /// re-pushed once half of that has passed, so it never reaches its stale date while the strap is connected. + /// + /// The number giving way to the dash, or coming back, is pushed at once, whatever the spacing. It is often the + /// last thing that happens: a strap taken off the wrist sends WRIST_OFF and then nothing, so no later tick + /// retries a push skipped for spacing, and a tester's banner kept "91" for minutes after the strap came off + /// (24 Sep 2026) until iOS's stale date drew the dash. + static func due(shown: Content?, next: Content, reading: KeyPath, + sinceLastPush: TimeInterval, staleAfter: TimeInterval) -> Bool { + if let shown, (shown[keyPath: reading] == nil) != (next[keyPath: reading] == nil) { return true } + guard sinceLastPush > minimumSpacing else { return false } + return shown != next || sinceLastPush >= staleAfter / 2 + } +} diff --git a/Strand/Data/MetricCatalog.swift b/Strand/Data/MetricCatalog.swift index 4af440a69f..c6af8a2ba4 100644 --- a/Strand/Data/MetricCatalog.swift +++ b/Strand/Data/MetricCatalog.swift @@ -212,8 +212,14 @@ enum MetricCatalog { static func inCategory(_ c: String) -> [MetricDescriptor] { all.filter { $0.category == c } } + /// Explicit fused view for the rolling-average tile, not a replacement for source-specific entries. + static let combinedStepsSource = "noop-combined-steps" + static func metric(key: String, source: String) -> MetricDescriptor? { - all.first { $0.key == key && $0.source == source } + if key == "steps", source == combinedStepsSource { + return d("steps", String(localized: "Steps"), "Effort", "", source, "figure.walk", 0, true) + } + return all.first { $0.key == key && $0.source == source } } /// The source the Today steps tile taps through to, matching the value it displays. Precedence 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 0a26933b4b..924c1ae3cf 100644 --- a/Strand/Data/Repository.swift +++ b/Strand/Data/Repository.swift @@ -266,6 +266,71 @@ 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) + } + + /// Every namespace the Workouts list reads a row out of, in read order, duplicates collapsed. + /// + /// Why this exists: the list unions many device ids while `deleteWorkout` deleted from exactly ONE, + /// the active strap. A row banked under any other namespace (a retained strap, a computed `-noop` + /// sibling, Apple Health, an imported lifting session or activity file) was therefore VISIBLE BUT + /// UNDELETABLE: the delete reported nothing, the reload re-read the row from the namespace the delete + /// never touched, and it came straight back. Reported as workouts that ignore the delete button + /// (#2278). + /// + /// Deriving both sides from one function is the point. Spelling the union out twice is what let them + /// disagree, and a future namespace added to the read alone would reintroduce exactly this bug. + nonisolated static func workoutNamespaces(rawIds: [String]) -> [String] { + deletableWorkoutNamespaces(rawIds: rawIds) + + [WorkoutSource.appleHealthSource, "lifting", "activity-file"] + } + + /// The subset of [workoutNamespaces] a DELETE may touch: the strap namespaces only. + /// + /// Imported history is read-only, and that is enforced everywhere else: the row menu offers only + /// "Duplicate as manual…" for an imported row, `bulkDeleteWorkouts` skips those classes outright, and + /// `mergeWorkouts` refuses them with "never rewrite imported history". A delete that swept the import + /// namespaces would reach underneath all three guards and destroy a wearer's imported Apple Health, + /// Hevy/Liftosaur or FIT/GPX/TCX row, which nothing in the UI ever offers to remove. + /// + /// That a cross-source twin is COLLAPSED into one row at display time does not license deleting the + /// imported half of the pair: the dedup is a presentation decision, and the surviving import is + /// exactly the history this repository promises not to rewrite. + /// + /// A `.manual` row, the only class the delete button is offered for, is written under a strap id, so + /// this set is what a delete actually needs. + nonisolated static func deletableWorkoutNamespaces(rawIds: [String]) -> [String] { + (rawIds + rawIds.map { $0.hasSuffix("-noop") ? $0 : $0 + "-noop" }) + .reduce(into: [String]()) { acc, id in + if !acc.contains(id) { acc.append(id) } + } + } + + /// 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 +350,68 @@ 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) + } + } + if out.contains(where: { $0.srcChannel?.isWhoop5Transport == true }) { + // Retain the selected stream's emission order within a second. Value sorting corrupts + // successive differences; original offsets also keep owner precedence deterministic. + return out.enumerated().sorted { + $0.element.ts != $1.element.ts ? $0.element.ts < $1.element.ts : $0.offset < $1.offset + }.map(\.element) + } + 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 @@ -317,7 +444,14 @@ final class Repository: ObservableObject { steps: winner.steps ?? filler.steps, activeKcalEst: winner.activeKcalEst ?? filler.activeKcalEst, spo2Red: rawSpo2FromFiller ? filler.spo2Red : winner.spo2Red, - spo2Ir: rawSpo2FromFiller ? filler.spo2Ir : winner.spo2Ir + 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, + // 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 ) } @@ -333,11 +467,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 @@ -352,10 +486,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 @@ -522,6 +656,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 @@ -936,7 +1087,8 @@ final class Repository: ObservableObject { steps: steps, activeKcalEst: existing.activeKcalEst, spo2Red: existing.spo2Red, - spo2Ir: existing.spo2Ir + spo2Ir: existing.spo2Ir, + skinTempC: existing.skinTempC ) } } else { @@ -1022,11 +1174,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 } @@ -1034,6 +1187,44 @@ final class Repository: ObservableObject { return byTs.values.sorted { $0.ts < $1.ts } } + /// Cheap change-detector over a window of heart rate: a COUNT and a MAX on an indexed column, no + /// rows and no decode. + /// + /// Exists for the stress widget (#2040), whose producer must not read a day's streams on a periodic + /// tick just to discover nothing moved. Unions the same ids the reads above do, so a change under + /// either source is seen; nil when there is no store yet, which a caller treats as "cannot tell" + /// rather than as "unchanged". Twin of Kotlin's `hrFingerprintWindow`. + func hrFingerprint(from: Int, to: Int) async -> (count: Int, maxTs: Int)? { + guard let store = await ensureStore() else { return nil } + var count = 0 + var maxTs = 0 + for id in rawPhysiologyReadIds(store: store) { + guard let fp = try? await store.hrFingerprint(deviceId: id, from: from, to: to) else { continue } + count += fp.count + maxTs = max(maxTs, fp.maxTs) + } + return (count, maxTs) + } + + /// 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 [] } + // Keep each physical strap's independently filtered history after a device switch. Only the + // ambiguous canonical alias inherits WHOOP 5's unit guard; a confirmed WHOOP 4 keeps its policy. + let activeWhoop5 = (try? await store.isWhoop5RRSource(deviceId: deviceId)) == true + 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, + unlabelledAliasOfWhoop5: activeWhoop5 && id == Self.whoopSource)) ?? []) + } + 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). @@ -1065,19 +1256,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 + @@ -1161,11 +1344,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))) @@ -1183,12 +1368,85 @@ 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) + let starts = sessions.map(\.startTs) + let lo = starts.min() ?? 0 + let hi = starts.max() ?? 0 + + // Phase 1, ownership. + // + // A block read from the store now carries the device it was read from, so most of the time there + // is nothing to resolve. The probe below runs only for blocks built by hand, which is tests and + // the importers, and the bounds read happens only if at least one such block is present. + // + // Provenance gives the SAME answer the probe does. The search takes the first id in + // `rawIds + computedIds` whose bounds match, then normalises it to its `-noop` twin, and + // `computedIds` is exactly `rawIds` mapped to that suffix. So a block read under a raw id and the + // same block read under its computed one both resolve to that one computed source either way. + // Disagreeing would take two DIFFERENT straps sharing a start and an end to the second, and + // `dedupBlocks` already collapses that pair to a single block, so the probe was picking one of + // them arbitrarily as well. + // + // `sleepSessionBounds`, not `sleepSessions`: the check needs two integers per block, and the fuller + // read selects `stagesJSON` among other columns, so it would haul every night's staging blob once + // per candidate device to compare a pair of timestamps. Unpaged, so a caller passing a sparse + // subset of a wide span cannot page short and lose the owners it dropped. + var boundsByDevice: [String: [Int: Int]] = [:] // deviceId -> startTs -> endTs + if sessions.contains(where: { $0.deviceId == nil }) { + for id in rawIds + computedIds { + boundsByDevice[id] = (try? await store.sleepSessionBounds(deviceId: id, + from: lo, to: hi)) ?? [:] + } + } + + // Resolve each block's ordered source list: its own device when the read supplied one, else the + // imported-wins owner search, then its computed twin first and the computed ids behind it. + var sourcesByStart: [Int: [String]] = [:] + for session in sessions where sourcesByStart[session.startTs] == nil { + var owner: String? = session.deviceId + if owner == nil { + for id in rawIds + computedIds { + if boundsByDevice[id]?[session.startTs] == session.endTs { + owner = id + break + } + } + } + let ownerComputed = owner.map { $0.hasSuffix("-noop") ? $0 : $0 + "-noop" } + sourcesByStart[session.startTs] = ([ownerComputed].compactMap { $0 } + computedIds) + .reduce(into: [String]()) { if !$0.contains($1) { $0.append($1) } } + } + + // Phase 2, motion, in ROUNDS rather than all sources for all blocks. + // + // A night's motion is one value per epoch, so a night is kilobytes of JSON and a long history is + // tens of megabytes. Asking every computed device for every block would decode that several times + // over to keep one copy, which is the same trade as pulling `stagesJSON` above: fewer round trips + // bought with far more bytes. Round k asks each device only for the blocks whose k-th source it is + // and which are still unfilled, so a block is read from its second source only if its first had + // nothing. That is the early exit the per-session loop had, kept, with D reads per round instead + // of one per block. Owned blocks resolve in the first round, so the second rarely runs. + var out: [Int: [Double]] = [:] + var round = 0 + let maxRounds = sourcesByStart.values.map(\.count).max() ?? 0 + while round < maxRounds { + var wantedByDevice: [String: [Int]] = [:] + for (start, sources) in sourcesByStart where out[start] == nil && round < sources.count { + wantedByDevice[sources[round], default: []].append(start) + } + if wantedByDevice.isEmpty { break } + for (id, wanted) in wantedByDevice { + let motions = (try? await store.sessionMotions(deviceId: id, sessionStarts: wanted)) ?? [:] + for (start, motion) in motions where out[start] == nil && !motion.isEmpty { + out[start] = motion + } + } + round += 1 + } + return out } /// The user's learned habitual midsleep (local time-of-day seconds), or nil under @@ -1205,10 +1463,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 @@ -1682,11 +1942,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 @@ -1768,6 +2026,17 @@ final class Repository: ObservableObject { return DeviceFamily.isWhoop5Registry(model: d?.model, brand: d?.brand) } + /// The active device's registry display name (nickname, else "Brand Model") for a screen that names + /// the source of what it plots — the Deep Timeline's source row. `nil` when the active id has no + /// registry row (the pre-registry seeded strap), so the caller keeps its legacy "My WHOOP" copy. + /// Reads the registry, not a brand string compare: an active Oura ring must read "Oura …", not the + /// hardcoded strap label it was shipped with. Twin of Android's `FullDayChartScreen` source pill. + func activeDeviceDisplayName() -> String? { + guard let store else { return nil } + let devices = (try? DeviceRegistryStore(dbQueue: store.registryWriter).all()) ?? [] + return devices.first(where: { $0.id == deviceId })?.displayName + } + /// Whether the active strap has EVER banked a sample of `metric` (#623) — distinguishes a strap that /// never produces it (honest "not supported on this strap" copy) from one with just an unsynced window. /// Only SpO₂/respiration are asked; any other metric returns true so the generic empty copy stands. @@ -1802,7 +2071,9 @@ final class Repository: ObservableObject { // rather than a noisy spike. The `to - from` span chooses the window width: a 2-min rMSSD for a // zoomed-in look, widening with the visible span so a day-scale view stays readable. The thinning // stride keeps a 1 Hz R-R stream from emitting a point per beat. - let rr = (try? await store.rrIntervals(deviceId: source, from: from, to: to, limit: 200_000)) ?? [] + let activeWhoop5 = (try? await store.isWhoop5RRSource(deviceId: deviceId)) ?? true + let rr = (try? await store.rrIntervals(deviceId: source, from: from, to: to, limit: 200_000, + unlabelledAliasOfWhoop5: activeWhoop5 && source == Self.whoopSource)) ?? [] let window = Self.hrvRollingWindowSec(spanSeconds: to - from) // rollingRmssd + the map over its output run OFF the main actor (mirrors the HR branch's // Task.detached in `timelineSeries`): only the already-read Sendable `rr` rows cross in. @@ -2122,7 +2393,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)) @@ -2149,7 +2422,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 @@ -2417,31 +2712,28 @@ final class Repository: ObservableObject { _ = try? await store.deleteJournal(deviceId: Self.journalDeviceId, day: day, question: question) } - /// All workouts (Whoop + Apple Health + on-device detected bouts), newest first. + /// All workouts (WHOOP + Apple Health + manual + grandfathered detected bouts), newest first. /// - /// Detected bouts are surfaced with an honest "Detected" badge so the user can see , and - /// dismiss or re-label , a duplicate the auto-detector created (#107). Dismissed detected spans - /// are filtered HERE so every consumer (Workouts screen, Today, Coach context) agrees: the engine - /// re-derives the detected rows each run, so a plain delete would resurrect them; the dismissed - /// span list is the durable "not a workout" record. + /// Legacy detected bouts remain surfaced with an honest "Detected" badge and stay editable, + /// dismissible and exportable; new scoring passes neither create nor reconcile them (#2187). + /// Dismissed spans are still filtered HERE so every consumer (Workouts, Today, Coach) preserves the + /// user's prior "not a workout" decisions and the confirmation card cannot resurrect those windows. func workoutRows(days: Int = 4000) async -> [WorkoutRow] { 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)) ?? [] } - 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)) ?? [] - // #29: imported activity FILES (FIT / GPX / TCX) live under their own "activity-file" source — read - // them too, or a successful file import never appears in the Workouts list (Data Sources counts it, - // the load didn't). HR is reconciled from the strap trace at the end like every other row. - rows += (try? await store.workouts(deviceId: "activity-file", from: lo, to: hi, limit: 5000)) ?? [] + // Every namespace in one list, shared with `deleteWorkout` so the two cannot disagree about where a + // row lives. Covers each raw id, its computed `-noop` sibling, Apple Health, imported lifting + // sessions (Hevy / Liftosaur) and imported activity FILES (#29: FIT / GPX / TCX, or a successful + // file import never appears here at all). HR is reconciled from the strap trace at the end. + for id in Self.workoutNamespaces(rawIds: rawPhysiologyReadIds(store: store)) { + rows += (try? await store.workouts(deviceId: id, from: lo, to: hi, limit: 5000)) ?? [] + } rows = Self.dedupWorkoutsByNaturalKey(rows) let spans = WorkoutSource.parseDismissedSpans(dismissedDetectedSpans) // #687: collapse the SAME activity tracked live under the strap AND imported from Health Connect / @@ -2628,13 +2920,12 @@ final class Repository: ObservableObject { // MARK: - Workout editing (manual add/edit · relabel · dismiss · delete) // // Manual workouts live under the strap source (deviceId == `deviceId`, source "manual") , the same - // place v1.67's live-tracked sessions already land (AppModel.endWorkout). Detected bouts live under - // the computed `computedDeviceId` with sport "detected" and are wiped + re-derived each engine run, - // so the only durable way to keep one hidden after a re-detect is the dismissed-span list below. + // place v1.67's live-tracked sessions already land (AppModel.endWorkout). Legacy detected bouts live + // under the computed `computedDeviceId` with sport "detected". New scoring passes preserve those rows; + // the dismissal list remains part of the transition contract and keeps prior user intent intact. - /// The persisted dismissed detected spans ("startTs:endTs"). Read straight off UserDefaults so the - /// read path and the write path share one source of truth (the engine never sees this , it always - /// re-derives; only the read filter and these mutators consult it). + /// The persisted legacy detected spans ("startTs:endTs"). Read straight off UserDefaults so the read + /// filter, mutators and confirmation-candidate selector share one source of truth. private var dismissedDetectedSpans: [String] { get { UserDefaults.standard.stringArray(forKey: WorkoutSource.dismissedDefaultsKey) ?? [] } set { UserDefaults.standard.set(newValue, forKey: WorkoutSource.dismissedDefaultsKey) } @@ -2642,37 +2933,54 @@ final class Repository: ObservableObject { /// Persist a retroactive / edited manual workout under the strap source. `replacing` is the row the /// edit started from: - /// - editing a DETECTED bout ("Edit details…") replaces it with this manual row , the detected - /// original is dismissed durably so the re-detector doesn't bring it back (else both would show); - /// - editing a MANUAL row whose natural key (startTs/sport) changed deletes the stale strap row - /// first (the (deviceId, startTs, sport) PK upsert would otherwise orphan it); + /// - editing a grandfathered DETECTED bout ("Edit details…") first saves this manual row, then + /// retires the original while retaining its legacy dismissal marker; + /// - editing a MANUAL row whose natural key (startTs/sport) changed first saves the replacement, + /// then retires the stale strap row. A failed write therefore preserves the original; /// - an IMPORTED row is never passed here as `replacing` (duplicating one is a pure add), so its /// history is never touched. func saveManualWorkout(_ row: WorkoutRow, replacing old: WorkoutRow? = nil) async { guard let store = await ensureStore() else { return } if let old, WorkoutSource.classify(old.source) == .detected { + // Write the replacement first. If that insert fails, the grandfathered source row and its + // visibility remain untouched; a failed explicit edit must not turn into data loss. + do { _ = try await store.upsertWorkouts([row], deviceId: deviceId) } + catch { return } await dismissDetected(old) + return } else if let old, old.startTs != row.startTs || old.sport != row.sport { - _ = try? await store.deleteWorkouts(deviceId: deviceId, sport: old.sport, - from: old.startTs, to: old.startTs) + // Write the replacement before deleting anything. If SQLite rejects the insert, leave both the + // original row and its route untouched; if the later delete fails, the recoverable result is two + // rows rather than lost history. + do { _ = try await store.upsertWorkouts([row], deviceId: deviceId) } + catch { return } // #10: the GPS route lives in RouteStore keyed by the natural key (startTs + sport), NOT in the - // DB row. Re-keying the DB row above without moving the route would orphan it under the OLD key, - // so the detail view's route + distance vanish after a sport/start edit. Re-key the route too: - // read it under the old key and, ONLY if one exists, store it under the new key then drop the old. - if let route = RouteStore.load(startTs: old.startTs, sport: old.sport) { + // DB row. Copy it only after the replacement row is durable. Keep the old copy until the old + // DB row is successfully retired, so a delete failure preserves both complete versions. + let oldRoute = RouteStore.load(startTs: old.startTs, sport: old.sport) + if let route = oldRoute { RouteStore.store(route, startTs: row.startTs, sport: row.sport) - RouteStore.remove(startTs: old.startTs, sport: old.sport) } + do { + _ = try await store.deleteWorkouts(deviceId: deviceId, sport: old.sport, + from: old.startTs, to: old.startTs) + if oldRoute != nil { + RouteStore.remove(startTs: old.startTs, sport: old.sport) + } + } catch { + // Replacement and both route keys remain available; retrying the edit is safe. + } + return } _ = try? await store.upsertWorkouts([row], deviceId: deviceId) } - /// Re-label a detected bout: copy it to a manual strap row with the chosen sport, then delete the - /// detected original. This survives analyzeRecent , the engine wipes + re-derives only sport - /// "detected" rows under the computed id AND skips any re-derived bout overlapping a real strap - /// workout, which this copy now is , so the same session is never re-created as a duplicate. (#107) + /// Re-label a legacy detected bout: copy it to a manual strap row with the chosen sport, then delete + /// the detected original. The analytics detector may still enrich this real row on a later pass, but + /// it no longer recreates a generic detected twin. (#107/#2187) func relabelDetected(_ row: WorkoutRow, sport: String) async { guard let store = await ensureStore() else { return } + guard WorkoutSource.classify(row.source) == .detected else { return } let trimmed = sport.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty else { return } let manual = WorkoutRow(startTs: row.startTs, endTs: row.endTs, sport: trimmed, source: "manual", @@ -2680,32 +2988,50 @@ final class Repository: ObservableObject { avgHr: row.avgHr, maxHr: row.maxHr, strain: row.strain, distanceM: row.distanceM, zonesJSON: row.zonesJSON, notes: row.notes, steps: row.steps) - _ = try? await store.upsertWorkouts([manual], deviceId: deviceId) - _ = try? await store.deleteWorkouts(deviceId: computedDeviceId, sport: "detected", - from: row.startTs, to: row.startTs) + do { _ = try await store.upsertWorkouts([manual], deviceId: deviceId) } + catch { return } + // Retire through the shared path so the legacy marker also prevents resurrection if deletion + // fails or the manual replacement is later removed. + await dismissDetected(row) } - /// Dismiss a DETECTED bout the user says isn't a workout. Records its span in the durable dismissed - /// list (so a re-detect that recreates the same span stays hidden) AND deletes the current row so it - /// disappears immediately. Idempotent: a span already present isn't duplicated. (#107) + /// Dismiss a grandfathered DETECTED bout the user says isn't a workout. Records its span for legacy + /// compatibility and suggestion suppression, then deletes the current row so it disappears immediately. + /// Idempotent: a span already present isn't duplicated. (#107/#2187) func dismissDetected(_ row: WorkoutRow) async { guard WorkoutSource.classify(row.source) == .detected else { return } let token = WorkoutSource.dismissedToken(for: row) var spans = dismissedDetectedSpans if !spans.contains(token) { spans.append(token); dismissedDetectedSpans = spans } guard let store = await ensureStore() else { return } - _ = try? await store.deleteWorkouts(deviceId: computedDeviceId, sport: row.sport, + // The displayed history is a union across current and archived computed ids; `source` is the + // detected row's owning id and is therefore the only safe deletion target. + _ = try? await store.deleteWorkouts(deviceId: row.source, sport: row.sport, from: row.startTs, to: row.startTs) } - /// Delete ONE workout by natural key. The read model has no deviceId, so reconstruct it from the - /// source: detected rows live under the computed id (and also get their span dismissed so they don't - /// come back); everything else the screen can delete (manual) lives under the strap id. + /// Delete ONE workout by natural key. A detected row carries its computed owner in `source` and also + /// gets a durable dismissal marker; everything else the screen can delete (manual) lives under the + /// active strap id. func deleteWorkout(_ row: WorkoutRow) async { if WorkoutSource.classify(row.source) == .detected { await dismissDetected(row); return } guard let store = await ensureStore() else { return } - _ = try? await store.deleteWorkouts(deviceId: deviceId, sport: row.sport, - from: row.startTs, to: row.startTs) + // Sweep every STRAP namespace, not just the active one. A manual row banked under a retained + // strap or a computed sibling is shown by `workoutRows` and was previously undeletable: the + // delete touched one namespace, the reload re-read the row from another, and it reappeared + // (#2278). + // + // Import namespaces are deliberately excluded, see `deletableWorkoutNamespaces`: imported + // history is read-only and no UI offers to remove it. + // + // Narrow by construction. The natural key is exact (`sport` plus a single `startTs`), so this + // removes the row the wearer tapped and its copies in the strap namespaces, nothing else. An + // overlapping-but-differently-keyed session is NOT touched; collapsing those is the dedup's job + // at display time, not a delete's. + for id in Self.deletableWorkoutNamespaces(rawIds: rawPhysiologyReadIds(store: store)) { + _ = try? await store.deleteWorkouts(deviceId: id, sport: row.sport, + from: row.startTs, to: row.startTs) + } } /// #64: merge two-or-more overlapping / adjacent MANUAL or DETECTED sessions into ONE manual session @@ -2715,7 +3041,7 @@ final class Repository: ObservableObject { /// 1. re-key at most one GPS route onto the merged natural key (the longest, matching #10), dropping /// the others so no route is orphaned; /// 2. save the merged row under the strap id (the manual path); - /// 3. per original: a DETECTED bout is durably dismissed (so a re-detect can't resurrect it), a + /// 3. per original: a DETECTED bout is retired with its legacy dismissal marker, a /// MANUAL row is deleted by natural key, BUT never touch a source that equals the merged row's /// own natural key (a detected original that shares the merged start/sport would otherwise dismiss /// a span the merged row now occupies). @@ -2733,8 +3059,12 @@ final class Repository: ObservableObject { } } - // Save the merged manual row. - await saveManualWorkout(merged) + // Save the merged manual row before retiring any source row. `saveManualWorkout` deliberately + // swallows persistence failures for UI callers, but this destructive follow-up must know whether + // the insert succeeded: a failed merge write must leave every original untouched. + guard let store = await ensureStore() else { return } + do { _ = try await store.upsertWorkouts([merged], deviceId: deviceId) } + catch { return } // Retire each original. Skip any row whose natural key matches the merged row's, so we never // dismiss/delete the span the merged row now owns. @@ -2772,16 +3102,15 @@ final class Repository: ObservableObject { } } - // MARK: - Auto-detect workouts (opt-in MVP) , the "Looks like a workout?" Today prompt + // MARK: - Auto-detect workouts , the opt-in "Looks like a workout?" Today prompt // - // Pure read + suggestion path for the opt-in `AutoWorkoutDetector`. This is SEPARATE from the - // gravity-gated detected-bouts pipeline above (which writes "detected" rows under the computed id): - // nothing here is ever persisted as a workout until the user taps Save, and a dismissed suggestion - // is remembered in its OWN durable span list (distinct key from `dismissedDetected`) so it never - // re-prompts. The detector + thresholds are byte-mirrored in the Android twin. - - /// Dismissed AUTO-DETECT spans ("startSec:endSec"), kept apart from the gravity detector's - /// `dismissedDetected` list so the two features never cross-suppress each other. + // This is the only path that can create a NEW visible automatically suggested workout, and it still + // saves nothing until the user taps Save. The analytics detector remains internal for daily scoring + // and overlap enrichment; it no longer persists generic `sport="detected"` rows. Both historical + // dismissal stores are honored during the transition so retiring that writer cannot resurrect a bout. + + /// Dismissed AUTO-DETECT spans ("startSec:endSec"). The key remains distinct for backup compatibility; + /// candidate selection also honors the legacy `dismissedDetected` intervals during the transition. private static let autoDetectDismissedKey = "workouts.autoDetectDismissed" private var autoDetectDismissedSpans: [String] { get { UserDefaults.standard.stringArray(forKey: Self.autoDetectDismissedKey) ?? [] } @@ -2789,7 +3118,30 @@ final class Repository: ObservableObject { } /// Token for one auto-detect span (matches the detector's integer seconds). - private func autoDetectToken(_ w: DetectedWorkout) -> String { "\(w.startSec):\(w.endSec)" } + nonisolated private static func autoDetectToken(_ w: DetectedWorkout) -> String { + "\(w.startSec):\(w.endSec)" + } + + /// Select the newest published suggestion after applying BOTH durable dismissal contracts. The + /// suggestion detector historically used exact span tokens; engine-created rows used half-open overlap + /// tombstones because a re-score could shift the inferred boundary by a few seconds. Preserve both + /// semantics so neither kind of prior dismissal can reappear during the unification. + nonisolated static func selectAutoDetectCandidate( + _ candidates: [DetectedWorkout], + autoDismissedTokens: [String], + detectedDismissedTokens: [String] + ) -> DetectedWorkout? { + let exact = Set(autoDismissedTokens) + let legacy = WorkoutSource.parseDismissedSpans(detectedDismissedTokens) + return candidates + .filter { candidate in + !exact.contains(autoDetectToken(candidate)) + && !legacy.contains { span in + candidate.startSec < span.end && span.start < candidate.endSec + } + } + .max(by: { $0.startSec < $1.startSec }) + } /// Hard cap on the dismissed-span list , a backstop so the UserDefaults array can't grow without /// bound even in pathological use. 200 most-recent (by span END) is far more than detection's ~2-day @@ -2844,25 +3196,40 @@ final class Repository: ObservableObject { let saved = await workoutRows() let savedSpans = saved.map { SavedWorkoutSpan(startSec: $0.startTs, endSec: $0.endTs) } - // Workouts & GPS test mode: when on, run the diagnostic twin which returns the SAME candidates - // detect(...) does (it reuses detect verbatim) plus the inputs / thresholds / per-window why trace, - // tagged `.workouts`. Zero-cost when off: the gate is one UserDefaults bool read inside emitWorkouts, - // and detectTrace is only called on that branch, so the default path runs the untraced detect. + // Workouts & GPS test mode: the published 12-minute result remains byte-identical. Aggregate, + // local-only comparisons include that 12-minute baseline plus the 10- and 15-minute alternatives. + // Shadow candidates are compared only with manual/imported labels; legacy detector rows are not + // ground truth. No shadow output can mutate the DB, visible card, daily scores or anything off-device. let candidates: [DetectedWorkout] if TestCentre.active(.workouts), workoutsLog != nil { let (results, trace) = AutoWorkoutDetector.detectTrace( - hr: hr, restingBpm: restingBpm, motion: nil, savedSpans: savedSpans, path: "autoDetect") + hr: hr, restingBpm: restingBpm, motion: nil, savedSpans: savedSpans, + minimumSustainedMinutes: AutoWorkoutDetector.minSustainedMin, + path: "autoDetect") for line in trace { emitWorkouts(line) } + let labelledSpans = saved + // `workoutRows()` intentionally returns long-lived history for the UI. Shadow candidates + // only cover this detector scan, so count labels in that same [from, now] start-time window + // (matching Android's range-scoped queries) or old sessions become false "missed" results. + .filter { + $0.startTs >= from && $0.startTs <= now + && WorkoutSource.classify($0.source) != .detected + } + .map { SavedWorkoutSpan(startSec: $0.startTs, endSec: $0.endTs) } + for line in AutoWorkoutDetector.shadowComparisonLines( + hr: hr, restingBpm: restingBpm, motion: nil, savedSpans: labelledSpans) { + emitWorkouts(line) + } candidates = results } else { candidates = AutoWorkoutDetector.detect(hr: hr, restingBpm: restingBpm, - motion: nil, savedSpans: savedSpans) + motion: nil, savedSpans: savedSpans, + minimumSustainedMinutes: AutoWorkoutDetector.minSustainedMin) } - // Drop anything the user already dismissed, then take the most recent. - let dismissed = Set(autoDetectDismissedSpans) - return candidates - .filter { !dismissed.contains(autoDetectToken($0)) } - .max(by: { $0.startSec < $1.startSec }) + return Self.selectAutoDetectCandidate( + candidates, + autoDismissedTokens: autoDetectDismissedSpans, + detectedDismissedTokens: dismissedDetectedSpans) } /// SAVE a suggested window as a manual-style "Workout" (generic sport , we don't claim a sport we @@ -2884,7 +3251,7 @@ final class Repository: ObservableObject { /// Prunes the stored list on every add (drop spans older than ~30 days + hard-cap to 200 most-recent) /// so it can never grow unbounded. Byte-mirrored in the Android `AutoWorkoutPrefs.dismiss`. func dismissDetectedSuggestion(_ w: DetectedWorkout) { - let token = autoDetectToken(w) + let token = Self.autoDetectToken(w) var spans = autoDetectDismissedSpans guard !spans.contains(token) else { return } spans.append(token) @@ -3046,7 +3413,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). @@ -3073,7 +3444,13 @@ private extension DailyMetric { // backfilled from the computed fallback — otherwise the nightly means would be lost. (#93) spo2Red: spo2Red ?? fallback.spo2Red, spo2Ir: spo2Ir ?? fallback.spo2Ir, - avgSdnn: avgSdnn ?? fallback.avgSdnn + 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, + // 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 ) } @@ -3102,7 +3479,12 @@ private extension DailyMetric { activeKcalEst: activeKcalEst, spo2Red: spo2Red, // non-sleep field: preserved as-is (#93) spo2Ir: spo2Ir, - avgSdnn: avgSdnn // non-sleep (HRV) field: preserved as-is + avgSdnn: avgSdnn, // non-sleep (HRV) field: preserved as-is + 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/RescoreBackgroundPolicy.swift b/Strand/Data/RescoreBackgroundPolicy.swift index 9a65eda307..52e65fd085 100644 --- a/Strand/Data/RescoreBackgroundPolicy.swift +++ b/Strand/Data/RescoreBackgroundPolicy.swift @@ -7,7 +7,7 @@ import Foundation /// backgrounded — it stays alive as a `bluetooth-central` to receive the offload in the first place. But /// `analyzeRecent` is all-or-nothing: pass 1 writes nothing, every store write happens after both loops, /// and the watermark advances only at the very end so an interrupted run can never mark unscored data as -/// scored. On a heavy install the pass measured **474,778 ms** — nearly eight minutes. iOS suspends the +/// scored. On a heavy install the pass measured **474,778 ms** — nearly eight minutes. iOS ends the /// process long before that, so the work is lost in full. /// /// The lost work is not the worst of it. Because the watermark never advanced, the NEXT trigger still saw @@ -17,7 +17,9 @@ import Foundation /// guarantees it will be attempted again. The score appeared 1 h 57 m after the data was complete, and only /// because the app happened to stay foregrounded for eight unbroken minutes. /// -/// So this decides, before spending anything: is this a pass that can plausibly finish here? +/// What ends those passes is the background CPU limit, not suspension (see `backgroundRestPerWorkSecond`): +/// a suspended pass resumes on the next wake, a killed one does not. A backgrounded pass therefore paces +/// itself under that limit, and this decides only whether one should start here at all. /// /// Deliberately NOT a fix for how long the pass takes. A cold process still re-scores every night in the /// window, because the per-day reuse cache is in-memory and starts empty (`IntelligenceEngine.dayScanCache`). @@ -35,56 +37,85 @@ enum RescoreBackgroundPolicy { case deferToBackgroundTask(reason: String) } - /// What a background execution assertion is worth relying on, in seconds. + /// How long a backgrounded pass rests per second of work it just did. /// - /// `beginBackgroundTask` buys roughly 30 s on current iOS, and that figure is a courtesy rather than a - /// contract — it shrinks under memory pressure and in Low Power Mode. 20 s leaves headroom for the - /// assertion to be granted late and for the pass's own store writes to land, since being killed - /// mid-write is the one outcome worth spending real caution to avoid. - static let backgroundBudgetSeconds: Double = 20 + /// What actually killed the background passes was CPU, not time: iOS terminates a background process + /// that holds more than 80% CPU over 60 s (`cpu_resource_fatal`). A cold pass is roughly 144 s of + /// near-continuous CPU on a large install, so every overnight attempt was killed about 52 s in — 26 kills + /// on one phone in five nights, each leaving the debt for the next attempt to be killed on. Resting as + /// long as it worked holds the pass near 50%. A suspension between rests is harmless: the pass is not + /// killed by it, it resumes on the next wake, so a pass longer than any single wake still completes. + static let backgroundRestPerWorkSecond: Double = 1.0 + + /// The longest single rest. Work measured on the uptime clock can include a suspension the process + /// spent mid-unit; resting for all of it would stall a pass that has already been idle. + static let maxBackgroundRestSeconds: Double = 30 + + /// How much work a backgrounded pass does between rests. + /// + /// Resting after every unit (every night) turned out to be the expensive part. A rest is a `Task.sleep`, + /// and a backgrounded process that is only sleeping is exactly what iOS suspends, until the next + /// bluetooth wake about ten minutes later. Each unit took milliseconds to a few seconds of CPU, so a pass + /// advanced roughly one night per wake. On one phone a 21-night pass ran 2 h 27 min, and a one-time + /// full-history pass held the re-score lock from 21:34 until after 11:00 the next day. Every post-offload + /// pass in between returned at the lock, so that morning's night was never scored. Working for ten seconds + /// before resting ten keeps the same ~50% ceiling under iOS's 80%-over-60 s kill, with one suspension + /// opportunity per ten seconds of work instead of one per night. + static let backgroundWorkQuantumSeconds: Double = 10 + + /// Seconds to rest after `workSeconds` of re-score work done since the last rest. Zero until a quantum of + /// work has accumulated (`backgroundWorkQuantumSeconds`), and always zero in the foreground, where no CPU + /// limit applies and the user is waiting on the result. A non-finite measurement rests zero. + static func restSeconds(afterWorkSeconds workSeconds: Double, isBackground: Bool) -> Double { + guard isBackground, workSeconds.isFinite, workSeconds >= backgroundWorkQuantumSeconds else { return 0 } + return min(workSeconds * backgroundRestPerWorkSecond, maxBackgroundRestSeconds) + } /// - Parameters: /// - isBackground: whether the app is currently backgrounded. A foregrounded app is never deferred: /// the user is looking at the screen, there is no suspension deadline, and the existing behaviour /// is correct. + /// - isRealUpdate: the trigger carries new data that must be scored (an offload), as opposed to the + /// steady-state backstop tick. A backgrounded backstop does not run: a paced pass costs minutes, + /// the tick cannot tell live HR from a real change, and every real update already runs its own. /// - rescoreAlreadyOwed: a re-score is outstanding — either a pass marked itself started and never /// marked itself finished (it was killed; the mark survives process death, which is the point, /// because the killed process gets no chance to record anything) or an earlier trigger already - /// deferred one. Both mean the same operationally: the work is spoken for, and starting it here - /// would duplicate a pass that something better placed is going to run. This is the - /// self-correcting part — the FIRST background attempt on an install we know nothing about is - /// allowed to run, and from then on the work escalates instead of being re-killed on every - /// offload. - /// - lastCompletedPassSeconds: how long the last pass that ran to completion took, or nil if none - /// has. Measured rather than assumed — the cost varies by more than an order of magnitude with - /// history size, and a fixed guess would either defer installs that finish comfortably or wave - /// through ones that never could. - /// - budgetSeconds: see `backgroundBudgetSeconds`; a parameter so the tests can state the boundary - /// rather than inherit it. + /// deferred one. The work is spoken for by the processing task this escalated to. + /// - passInProgress: a pass is running in THIS process. Its own started-mark is what reads as owed, so + /// it is not evidence of a killed pass; the engine re-arms one follow-up pass for a trigger that + /// lands mid-run. Deferring instead recorded a newer debt, the running pass then finished without + /// settling it (#1681), and every offload after that deferred on it. + /// - secondsSinceLastAttempt: how long ago the last pass STARTED, nil when unknown. An outstanding + /// debt defers only while that attempt is recent (`interruptedRetryCooldownSeconds`). static func decide(isBackground: Bool, + isRealUpdate: Bool = true, rescoreAlreadyOwed: Bool, - lastCompletedPassSeconds: Double?, - budgetSeconds: Double = backgroundBudgetSeconds) -> Decision { + passInProgress: Bool = false, + secondsSinceLastAttempt: Double? = nil) -> Decision { guard isBackground else { return .run } - if rescoreAlreadyOwed { + guard isRealUpdate else { return .deferToBackgroundTask( - reason: "a re-score is already outstanding from an earlier trigger") + reason: "the backstop tick does not re-score while backgrounded; offloads run their own") } - // Only a FINITE, positive measurement can justify deferring. A nil (nothing has ever completed), - // a zero, or a NaN/infinity from a corrupted default all mean "unknown", and unknown must fall - // through to running: refusing to score on the strength of a value we cannot read would be a far - // worse failure than one wasted pass. - if budgetSeconds > 0, - let measured = lastCompletedPassSeconds, - measured.isFinite, measured > 0, - measured > budgetSeconds { + if rescoreAlreadyOwed, !passInProgress, + let since = secondsSinceLastAttempt, since >= 0, since < interruptedRetryCooldownSeconds { return .deferToBackgroundTask( - reason: "last completed pass took \(Int(measured.rounded()))s, over the " - + "\(Int(budgetSeconds.rounded()))s a background wake can be relied on for") + reason: "a re-score is already outstanding from an earlier trigger") } return .run } + + /// How long after an attempt that did not finish a backgrounded offload waits before trying again. + /// + /// Deferring on ANY outstanding debt, with no end, stopped scoring outright. A suspended app is + /// routinely terminated by iOS for memory, not CPU, so a pass interrupted that way is ordinary, and the + /// processing task it escalates to is granted rarely if ever. On one phone a pass left unfinished at + /// 11:25 deferred every offload until the app was next opened: 19 hours, with no score for the night + /// in between. Pacing (`backgroundRestPerWorkSecond`) is what keeps a background attempt under the CPU + /// limit now, so this only needs to stop a retry on every offload, not forever. + static let interruptedRetryCooldownSeconds: Double = 30 * 60 } diff --git a/Strand/Data/RollingStepsAverage.swift b/Strand/Data/RollingStepsAverage.swift new file mode 100644 index 0000000000..7c7d0105fa --- /dev/null +++ b/Strand/Data/RollingStepsAverage.swift @@ -0,0 +1,90 @@ +import Foundation +import SwiftUI +import StrandDesign +import WhoopStore + +/// Calendar-day window, never 30 observed rows. Missing observations are not zero. +struct RollingStepsAverage: Equatable { + let mean: Double? + let observedDays: Int + + static func startDay(ending day: String) -> String? { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "yyyy-MM-dd" + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + guard let end = formatter.date(from: day), + let start = calendar.date(byAdding: .day, value: -29, to: end) else { return nil } + return formatter.string(from: start) + } + + static func calculate(readings: [(day: String, value: Double)], ending day: String) -> Self { + guard let start = startDay(ending: day) else { return .init(mean: nil, observedDays: 0) } + var byDay: [String: Double] = [:] + for reading in readings where reading.day >= start && reading.day <= day + && reading.value.isFinite && reading.value >= 0 { + byDay[reading.day] = reading.value + } + return .init(mean: byDay.isEmpty ? nil : byDay.values.reduce(0, +) / Double(byDay.count), + observedDays: byDay.count) + } +} + +extension Repository { + /// Shared by the detail and rolling tile: measured strap, phone import, then strap estimate. + func resolvedSteps(from: String, to: String) async -> MetricSeriesResolution { + async let strap = resolvedSeries(key: "steps", source: Self.whoopSource, from: from, to: to) + async let phone = resolvedSeries(key: "steps", source: "apple-health", from: from, to: to) + async let estimate = resolvedSeries(key: "steps_est", source: Self.whoopSource, from: from, to: to) + let resolutions = await [strap, phone, estimate] + var byDay: [String: ResolvedMetricPoint] = [:] + for resolution in resolutions { + for point in resolution.points where byDay[point.day] == nil { byDay[point.day] = point } + } + return MetricSeriesResolution(requestedSource: Self.whoopSource, + candidates: resolutions.flatMap(\.candidates), + points: byDay.values.sorted { $0.day < $1.day }) + } +} + +/// Loads only when explicitly enabled. Task identity follows the selected day and repository refresh. +struct RollingStepsAverageCard: View { + let day: String + @EnvironmentObject private var repo: Repository + @State private var result: RollingStepsAverage? + @State private var resultDay: String? + + var body: some View { + let current = resultDay == day ? result : nil + NavigationLink(value: TabRoute.metricSourced(key: "steps", source: MetricCatalog.combinedStepsSource)) { + HStack(spacing: 12) { + Image(systemName: DashboardCard.stepsAverage30.icon) + .foregroundStyle(StrandPalette.metricCyan) + VStack(alignment: .leading, spacing: 4) { + Text(DashboardCard.stepsAverage30.title) + .font(StrandFont.subhead).foregroundStyle(StrandPalette.textPrimary) + Text(current.map { String(localized: "\($0.observedDays) of 30 days") } ?? "—") + .font(StrandFont.caption).foregroundStyle(StrandPalette.textSecondary) + } + Spacer(minLength: 8) + Text(current?.mean.map { $0.formatted(.number.locale(AppLanguage.activeLocale).precision(.fractionLength(0))) } ?? "—") + .font(StrandFont.number(20)).foregroundStyle(StrandPalette.textPrimary) + .fixedSize(horizontal: true, vertical: false) + Image(systemName: "chevron.right").font(.system(size: 12, weight: .semibold)) + .foregroundStyle(StrandPalette.textTertiary) + } + .padding(14) + .background(NoopPanelSurface(tint: StrandPalette.metricCyan, cornerRadius: 20)) + } + .buttonStyle(.plain) + .task(id: "\(day)|\(repo.refreshSeq)") { + guard let start = RollingStepsAverage.startDay(ending: day) else { return } + let readings = await repo.resolvedSteps(from: start, to: day) + guard !Task.isCancelled else { return } + result = RollingStepsAverage.calculate(readings: readings.values, ending: day) + resultDay = day + } + } +} 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/StressDayCurve.swift b/Strand/Data/StressDayCurve.swift new file mode 100644 index 0000000000..9afecc01ce --- /dev/null +++ b/Strand/Data/StressDayCurve.swift @@ -0,0 +1,123 @@ +import Foundation +import StrandAnalytics + +/// Scores today's stress for the surfaces that draw it, and does it as rarely as it can get away with. +/// +/// Swift twin of the Kotlin `StressWidgetProducer`, and the same reasoning governs both. +/// +/// THE GATE IS THE POINT. Scoring a day means reading today's heart rate, R-R and gravity, three reads +/// bounded at 200 000 rows each, which is the work the Stress screen does when you open it. The screen +/// does that once, on a deliberate act. A widget producer runs on the publish path, which fires when +/// the app becomes active and after every Health sync, so nothing is read until `Repository`'s cheap +/// heart-rate fingerprint — a COUNT and a MAX over an indexed column — says today's heart rate actually +/// moved. A publish that changed nothing costs that one query and reuses the previous curve. +/// +/// SCORING MODE. Always the `.dayRelative` default, never the opt-in personal-baseline lens. Resolving +/// that mode reads fourteen trailing days of heart rate to decide whether enough worn history exists, +/// which the screen can afford on demand and a publish path cannot. Stated plainly because it is +/// user-visible: with the personal-baseline toggle on, the widget shows the default lens while the +/// screen shows the refined one, so the two can differ. +enum StressDayCurve { + + /// What the last scoring saw and produced, swapped in as ONE value. + /// + /// Separate fields could tear: a second call arriving between two assignments would read one + /// call's fingerprint beside another's result and serve a curve for a day it was not scored + /// against. Both callers are `@MainActor` today, so the window is narrow, but an immutable holder + /// closes it for free. + private struct Memo { + let count: Int + let maxTs: Int + let day: Int + let result: DaytimeStress.Result + } + + @MainActor private static var memo: Memo? + + /// Today's curve and the local day number it belongs to, or nil when it could not be scored. + /// + /// Nil is not "today scored nothing": it means "say nothing about stress right now", so a caller + /// that persists this must carry forward what it already had rather than blanking. An EMPTY result + /// with a day is the real "nothing scored today" answer. + /// + /// Returns the analytics `Result` rather than any one drawing's point type, because two surfaces + /// read it: the iOS widget, which maps it into its own `StressPoint`, and the Today host card, + /// which hands `timeline` straight to `DaytimeLoadLine`. Keeping the widget's type here would have + /// kept this file iOS-only, and the Today card is shared with macOS. + @MainActor + static func today(repo: Repository, now: Date = Date(), + calendar: Calendar = .current) async -> (result: DaytimeStress.Result, day: Int)? { + let startOfDay = calendar.startOfDay(for: now) + let from = Int(startOfDay.timeIntervalSince1970) + let to = Int(now.timeIntervalSince1970) + let day = localDayNumber(now, calendar: calendar) + + guard let fingerprint = await repo.hrFingerprint(from: from, to: to) else { return nil } + // Same day, same heart rate: nothing can have changed the score, so nothing is read. The day is + // part of the check because a fingerprint that happened to match across midnight would otherwise + // serve yesterday's curve as today's. + if let memo, memo.day == day, memo.count == fingerprint.count, memo.maxTs == fingerprint.maxTs { + return (memo.result, day) + } + + let hr = await repo.hrSamples(from: from, to: to, limit: 200_000) + var scored: DaytimeStress.Result = .empty + if hr.count >= DaytimeStress.minHourHRSamples { + let rr = await repo.rrIntervals(from: from, to: to, limit: 200_000) + // Wrist accelerometer for the motion gate, so an ambulatory hour reads as exertion rather + // than as stress. Empty on hardware or imports without gravity, which degrades to no masking + // exactly as the screen does. + let gravity = await repo.gravitySamplesUnion(from: from, to: to, limit: 200_000) + let tz = TimeZone.current.secondsFromGMT(for: now) + // Scored OFF the main actor. `Repository` is `@MainActor`, so without this hop a day's worth + // of hours would be bucketed and averaged on the main thread — and unlike the Stress screen, + // which does this because the user asked for it and is waiting, this runs unprompted when + // the app becomes active and after every Health sync, which is exactly when the UI is busy. + // The Kotlin twin does NOT get this for free, which this comment used to claim. A coroutine + // runs on whatever dispatcher it inherits, and a LaunchedEffect body inherits the main one, + // so Android was scoring a day of samples on its UI thread until the producer was given an + // explicit Dispatchers.Default of its own. Both sides now ask for the hop; neither is + // handed it by its concurrency model. + // The samples are plain value structs, so the hop retains rather than copies them. + // The half-step display series is asked for here (`includeTimeline`), because both readers + // draw a curve. Nothing downstream of this counts hours, so the overlap is free. + // #2181: `runUnescalated`, not an awaited detached task. This caller is @MainActor, and + // awaiting a task hands it the caller's priority, so the `.utility` here used to be a label + // rather than a behaviour: the hop off the main actor was real, but the scoring then raced + // the UI for cores at the UI's own quality of service. This runs unprompted on activation + // and after every Health sync, exactly when the UI is busy, so it must yield. + scored = await runUnescalated { + DaytimeStress.analyze(hr: hr, rr: rr, gravity: gravity, + tzOffsetSeconds: tz, mode: .dayRelative, + includeTimeline: true) + } + } + // Too little signal leaves an EMPTY result, which is a real answer about today rather than a + // refusal: a reader should drop yesterday's line rather than keep drawing it. + memo = Memo(count: fingerprint.count, maxTs: fingerprint.maxTs, day: day, result: scored) + return (scored, day) + } + + /// Days since the epoch on the LOCAL calendar. + /// + /// RESTATED from `WidgetSnapshot.localDayNumber` rather than shared, because there is no module + /// both readers can see: `WidgetSnapshot` lives in the iOS/widget sources, which the macOS app does + /// not compile, and the widget extension links no packages, so it cannot reach this file either. + /// + /// The two MUST agree. The widget stores the day this returns and later compares it against + /// `WidgetSnapshot.localDayNumber` to decide whether the stored curve is still today's, so a + /// divergence would silently drop a valid curve. `HostedCardPrefsTests` pins them equal from the + /// app target, which can see both. + /// + /// Counted by the calendar rather than by dividing by 86 400, because that arithmetic is wrong on a + /// DST day: `Europe/London` produces one day a year whose number would equal the previous day's. + static func localDayNumber(_ date: Date, calendar: Calendar = .current) -> Int { + let epoch = calendar.startOfDay(for: Date(timeIntervalSince1970: 0)) + return calendar.dateComponents([.day], from: epoch, + to: calendar.startOfDay(for: date)).day ?? 0 + } + + /// Drops the memo so a test starts from a known state. + @MainActor + static func resetForTest() { memo = nil } +} diff --git a/Strand/Data/UnescalatedWork.swift b/Strand/Data/UnescalatedWork.swift new file mode 100644 index 0000000000..7919d884fe --- /dev/null +++ b/Strand/Data/UnescalatedWork.swift @@ -0,0 +1,38 @@ +import Foundation + +/// Run `work` off the caller's executor at `priority`, without the awaiting caller dragging it back up. +/// +/// `await Task.detached(priority: .utility) { … }.value` does NOT run at `.utility` when a main-actor +/// caller awaits it. Awaiting a task records a DEPENDENCY, and the runtime raises the awaited task to +/// the waiter's priority so a high-priority waiter cannot be starved by what it is waiting on. Every +/// such call site reached from the main actor therefore ran the work at the UI's own quality of +/// service: the hop off the main actor was real and the main thread stayed free, but the work competed +/// with the UI for cores instead of yielding to it, which is the opposite of what `.utility` reads as. +/// +/// Awaiting a CONTINUATION records no dependency. The runtime cannot see which task will resume it, so +/// it has nothing to escalate and the declared priority stands. Measured on Linux Swift 5.9, caller at +/// 21: an awaited detached task reports 21 inside the body, this reports 17, with and without a +/// suspension point in the body. +/// +/// Use this ONLY where EVERY caller is unprompted. Work the wearer started and is watching should +/// escalate: finishing sooner is the whole point. Escalation is a bug only where the work was meant +/// to yield. +/// +/// Judge that at the ENTRY POINT, not at the block that burns the CPU. The analysis pass looks like an +/// obvious candidate from inside `IntelligenceEngine`, and is not one: `analyzeRecent` is reached from +/// a fifteen-minute background tick AND from a just-logged workout, whose call site asks for it so the +/// new strain and calories "appear immediately", and from an Intelligence screen sitting empty while +/// the wearer waits for it to fill. One internal block, several kinds of caller, so the priority +/// belongs to whoever asked. Giving that pass a caller-aware priority is worth doing and is a larger +/// change than this one. +/// +/// Cancellation is unchanged from the shape this replaces: a detached task already inherits none, and +/// the continuation is always resumed exactly once, so a cancelled caller cannot leak it. +func runUnescalated( + priority: TaskPriority = .utility, + _ work: @escaping @Sendable () async -> T +) async -> T { + await withCheckedContinuation { (continuation: CheckedContinuation) in + Task.detached(priority: priority) { continuation.resume(returning: await work()) } + } +} diff --git a/Strand/Data/Units.swift b/Strand/Data/Units.swift index b5dd1d90ac..fb692b2eaa 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,27 @@ 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. + /// Whether the strap-sync Live Activity may show, iOS only. Its own switch, deliberately separate from + /// the live-HR one above: wanting a sync readout says nothing about wanting a heart rate on the Lock + /// Screen, and the reverse. Defaults to ON, read default-true like its sibling. + static let syncLiveActivityKey = "liveActivity.sync.enabled" + static func syncLiveActivityEnabled() -> Bool { + UserDefaults.standard.object(forKey: syncLiveActivityKey) == nil + ? true : UserDefaults.standard.bool(forKey: syncLiveActivityKey) + } + + /// 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 +218,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 +284,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/Data/WorkoutCatalog.swift b/Strand/Data/WorkoutCatalog.swift index 82a224199c..795bb5cedd 100644 --- a/Strand/Data/WorkoutCatalog.swift +++ b/Strand/Data/WorkoutCatalog.swift @@ -121,6 +121,28 @@ enum WorkoutCatalog { Sport(name: "Wheelchair", isDistanceSport: false), Sport(name: "Gaming", isDistanceSport: false), Sport(name: "Motor racing", isDistanceSport: false), + // Asked for by a user. GPS off to match Android, where every EXTRA sport is built with + // isDistanceSport = false regardless of whether it has a route (Mountain biking and Rucking + // sit the same way). Worth revisiting for all of them together rather than singling this out. + Sport(name: "Nordic walking", isDistanceSport: false), + // WHOOP-parity batch 2 (twin of ExerciseTypes EXTRA). GPS off throughout, matching the + // Android EXTRA convention. + Sport(name: "Ballet", isDistanceSport: false), + Sport(name: "Billiards", isDistanceSport: false), + Sport(name: "Breakdancing", isDistanceSport: false), + Sport(name: "Cheerleading", isDistanceSport: false), + Sport(name: "Darts", isDistanceSport: false), + Sport(name: "Disc golf", isDistanceSport: false), + Sport(name: "Hurling/Camogie", isDistanceSport: false), + Sport(name: "Jiu jitsu", isDistanceSport: false), + Sport(name: "Judo", isDistanceSport: false), + Sport(name: "Kiteboarding", isDistanceSport: false), + Sport(name: "Motocross", isDistanceSport: false), + Sport(name: "Muay Thai", isDistanceSport: false), + Sport(name: "Paintball", isDistanceSport: false), + Sport(name: "Parkour", isDistanceSport: false), + Sport(name: "Polo", isDistanceSport: false), + Sport(name: "Skydiving", isDistanceSport: false), Sport(name: "Other", isDistanceSport: false), ] diff --git a/Strand/Data/WorkoutSource.swift b/Strand/Data/WorkoutSource.swift index ca03fd1834..3168e7537d 100644 --- a/Strand/Data/WorkoutSource.swift +++ b/Strand/Data/WorkoutSource.swift @@ -8,8 +8,9 @@ import StrandAnalytics // WorkoutsTrace: the dedup-decision line formatter for /// - "whoop" — WhoopImporter (imported WHOOP session) /// - "apple_health" / "apple-health" — AppleHealthImport /// - "manual" — AppModel.endWorkout (v1.67 live session) AND the retro add/edit sheet -/// - "my-whoop-noop"— IntelligenceEngine detected bouts (source == the computed deviceId, i.e. -/// it ends in "-noop"). These are re-derived every analyzeRecent run. +/// - "my-whoop-noop"— legacy IntelligenceEngine detected bouts (source == the computed deviceId, +/// i.e. it ends in "-noop"). These remain readable/editable but are no longer +/// created or reconciled by `analyzeRecent`. /// /// Classification order matters: "-noop" is checked BEFORE "whoop" because the computed id /// "my-whoop-noop" also contains the substring "whoop". @@ -71,14 +72,12 @@ enum WorkoutSource: Equatable { sport == "detected" ? "Activity" : splitCamelCase(sport) } - // MARK: - Dismissed detected bouts (durable across re-detection) + // MARK: - Dismissed legacy detected bouts // - // The engine wipes + re-derives "detected" rows every run, so deleting a detected row from the - // table would only hide it until the next analyzeRecent recreates the same (startTs, sport) PK. - // The durable "this isn't a workout" record is a list of dismissed time spans persisted in - // UserDefaults (the macOS WorkoutRow lives in the WhoopStore Journal file, which this layer must - // not extend with a new column). A detected row overlapping any dismissed span stays hidden. - // (#107) + // Before #2187 the engine wiped + re-derived "detected" rows every run, so the durable "this isn't a + // workout" record had to be a list of dismissed spans in UserDefaults. New passes preserve history + // and create no such rows, but the tombstones remain load-bearing for existing databases and suppress + // a matching confirmation candidate during the transition. (#107/#2187) /// UserDefaults key holding the dismissed spans as "startTs:endTs" strings. static let dismissedDefaultsKey = "workouts.dismissedDetected" @@ -96,9 +95,8 @@ enum WorkoutSource: Equatable { /// The "startTs:endTs" token persisted for a dismissed row (caller appends it to the defaults list). static func dismissedToken(for row: WorkoutRow) -> String { "\(row.startTs):\(row.endTs)" } - /// Read-time filter: a DETECTED row overlapping any dismissed span is hidden. Imported / manual - /// rows are never auto-hidden (the user deletes those outright), so dismissal only applies to the - /// re-derived detected source. Half-open overlap test: `row.start < span.end && span.start < row.end`. + /// Read-time filter: a legacy DETECTED row overlapping any dismissed span is hidden. Imported/manual + /// rows are never auto-hidden. Half-open overlap test: `row.start < span.end && span.start < row.end`. static func isDismissed(_ row: WorkoutRow, spans: [(start: Int, end: Int)]) -> Bool { classify(row.source) == .detected && spans.contains { row.startTs < $0.end && $0.start < row.endTs } @@ -190,16 +188,12 @@ enum WorkoutSource: Equatable { // MARK: - Detected-vs-real overlap collapse (#975) // - // The engine derives a "detected" bout from raw HR and DROPS it when it overlaps a real logged session - // (IntelligenceEngine: bare time overlap, ANY source), but only on the next analyze pass. Between a live - // /manual session ending and that pass, BOTH the manual row AND the detected shadow of the same bout show - // in the list, and the detected shadow (a WIDER, sport-agnostic HR window) reads an implausibly high - // interpolated Effort/HR next to the real one. `sameActivity` cannot collapse them because their SPORTS - // differ ("detected" vs the user's sport). This read-time guard mirrors the engine's own rule so the list - // never shows the transient duplicate: a DETECTED row is dropped when its time window overlaps a REAL - // (non-detected) session by more than half of the shorter of the two. The >50%-of-shorter test (not bare - // touching) keeps a genuinely separate back-to-back session distinct, matching `sameActivity`'s overlap - // rule. Runs BEFORE the same-sport cross-source dedup, so the detected shadow is gone before that walk. + // Grandfathered generic detections can overlap a real workout logged later. `sameActivity` cannot collapse + // that pair because their SPORTS differ ("detected" vs the user's sport), so the wider generic row would + // otherwise remain beside the real one indefinitely. This read-time guard hides a DETECTED row when its + // window overlaps a REAL (non-detected) session by more than half of the shorter of the two. The + // >50%-of-shorter test (not bare touching) keeps a genuinely separate back-to-back session distinct, + // matching `sameActivity`'s overlap rule. It runs BEFORE same-sport cross-source dedup. /// True when `detected` (a detected bout) is a redundant shadow of `real` (a logged session of any source /// other than detected): their windows overlap by more than half of the shorter session. Order matters , @@ -336,33 +330,105 @@ enum WorkoutSource: Equatable { zonesJSON: old.zonesJSON, notes: old.notes, steps: old.steps) } + /// The span cap a manual workout may cover, shared by both builders and the sheet's binding. + static let maxManualSpanSeconds = 24 * 60 * 60 + + /// Shortest manual session worth keeping, matching the live-session floor in `AppModel.endWorkout`. + /// + /// The duration-shaped front door already enforced this by accident, since it counts whole minutes and + /// rejects zero. The SPAN-shaped door did not, and that is the one the Add/Edit sheet uses, so a + /// start and end thirty seconds apart made a row the live path would have discarded. + /// + /// Enforcing it here rather than at the sheet means the Save button disables itself and the sheet's + /// existing validation note explains why, with no new UI and no new string. + static let minManualSpanSeconds = 60 + + /// The end a given duration implies. The sheet uses this when the user types a duration, so a typed + /// duration and a picked end produce identical rows. + static func endForDuration(start: Date, durationMin: Int) -> Date { + Date(timeIntervalSince1970: start.timeIntervalSince1970 + TimeInterval(durationMin * 60)) + } + + /// Whole minutes in a span, for the duration field's DISPLAY. + /// + /// Rounds, so a stored 45m17s bout reads as "45". That rounding is display-only now: the sheet keeps + /// the exact end as its state of record and saves that, where it previously round-tripped the span + /// through this number and wrote the rounded end back. Editing a detected workout's sport therefore + /// no longer shortens it by up to 30 seconds. + static func spanDurationMin(start: Date, end: Date) -> Int { + // .rounded() is half-away-from-zero, matching Kotlin's Math.round on the positive spans this + // sees, so the two platforms show the same minute for the same stored row. + Int(((end.timeIntervalSince1970 - start.timeIntervalSince1970) / 60).rounded()) + } + + /// Where the end lands when the START moves. + /// + /// Moving the start keeps the workout the same LENGTH rather than pinning the end, which is what a + /// user correcting "this began an hour earlier than I said" means. Pinning the end instead would + /// silently restretch the duration on every start correction. + static func endAfterStartMove(oldStart: Date, oldEnd: Date, newStart: Date) -> Date { + Date(timeIntervalSince1970: newStart.timeIntervalSince1970 + + (oldEnd.timeIntervalSince1970 - oldStart.timeIntervalSince1970)) + } + + /// Build a retroactive manual workout from an explicit SPAN. + /// + /// The row has always been stored as `startTs`/`endTs`, so this is the shape the storage already + /// speaks; `buildManualRow` is the duration-shaped front door that delegates here (#2034). Split out + /// so the sheet can offer an end time without the value making a lossy round trip through whole + /// minutes. + /// + /// Validation is the duration form's, re-expressed: a span must be positive, at most + /// `maxManualSpanSeconds`, and must not end in the future. Twin of Android + /// `WorkoutEditing.buildManualRowFromSpan`. + static func buildManualRowFromSpan(start: Date, end: Date, sport: String, + avgHr: Int?, energyKcal: Double?, distanceM: Double? = nil, + now: Date = Date()) -> WorkoutRow? { + let trimmed = sport.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty, start <= now else { return nil } + let s = Int(start.timeIntervalSince1970) + let e = Int(end.timeIntervalSince1970) + guard s > 0, e > s else { return nil } + let spanSeconds = e - s + guard spanSeconds >= minManualSpanSeconds else { return nil } + guard spanSeconds <= maxManualSpanSeconds else { return nil } + guard e <= Int(now.timeIntervalSince1970) else { return nil } + if let hr = avgHr, !(25...250).contains(hr) { return nil } + if let k = energyKcal, k < 0 || k > 20_000 { return nil } + // Distance 0-1000 km (#1195): rejects a negative or absurd manual entry. 1000 km comfortably + // covers any single session (an Ironman bike is 180 km, an ultra 160 km). + if let d = distanceM, d < 0 || d > 1_000_000 { return nil } + return WorkoutRow(startTs: s, endTs: e, sport: trimmed, source: "manual", + durationS: Double(spanSeconds), energyKcal: energyKcal, + avgHr: avgHr, maxHr: nil, strain: nil, distanceM: distanceM, + zonesJSON: nil, notes: nil, steps: nil) + } + /// Build a retroactive manual workout (source "manual", persisted under the strap deviceId by the /// caller — where v1.67's live sessions live). Returns nil when the input can't make an honest row. /// strain/zones stay nil: with no captured HR window an APPROXIMATE strain is never fabricated. + /// + /// The duration-shaped front door. Delegates to `buildManualRowFromSpan` so the two entry points + /// cannot drift; it keeps the whole-minute bounds and the overflow guard, which only this shape needs. static func buildManualRow(start: Date, durationMin: Int, sport: String, avgHr: Int?, energyKcal: Double?, distanceM: Double? = nil, now: Date = Date()) -> WorkoutRow? { guard durationMin > 0, durationMin <= 24 * 60 else { return nil } - let trimmed = sport.trimmingCharacters(in: .whitespaces) - guard !trimmed.isEmpty, start <= now else { return nil } - if let hr = avgHr, !(25...250).contains(hr) { return nil } - if let k = energyKcal, k < 0 || k > 20_000 { return nil } - // Distance 0–1000 km (#1195): rejects a negative or absurd manual entry. 1000 km comfortably - // covers any single session (an Ironman bike is 180 km, an ultra 160 km). - if let d = distanceM, d < 0 || d > 1_000_000 { return nil } let s = Int(start.timeIntervalSince1970) guard s > 0 else { return nil } // Reject a row whose END lands in the future: `start <= now` alone still lets `start + duration` - // overshoot (a start 10 min ago + a 45 min duration ends 35 min ahead). Overflow-safe, and a row - // ending exactly at `now` stays valid. Twin of Android `WorkoutEditing` (#1067). + // overshoot (a start 10 min ago + a 45 min duration ends 35 min ahead). A row ending exactly at + // `now` stays valid. Twin of Android `WorkoutEditing` (#1067). The end check itself now lives in + // `buildManualRowFromSpan`; the overflow guard stays HERE, because only this shape does the + // addition that can overflow. It also runs BEFORE the Int -> TimeInterval -> Int round trip the + // delegation introduces. That round trip is exact for anything the guards admit: a real timestamp + // plus at most 24h is ~1.7e9, and Double represents integers exactly to 2^53. let durationSeconds = durationMin * 60 guard durationSeconds <= Int.max - s else { return nil } - let end = s + durationSeconds - guard end <= Int(now.timeIntervalSince1970) else { return nil } - return WorkoutRow(startTs: s, endTs: end, sport: trimmed, source: "manual", - durationS: Double(durationSeconds), energyKcal: energyKcal, - avgHr: avgHr, maxHr: nil, strain: nil, distanceM: distanceM, - zonesJSON: nil, notes: nil, steps: nil) + return buildManualRowFromSpan( + start: start, + end: Date(timeIntervalSince1970: TimeInterval(s + durationSeconds)), + sport: sport, avgHr: avgHr, energyKcal: energyKcal, distanceM: distanceM, now: now) } } diff --git a/Strand/Health/HealthExportMerge.swift b/Strand/Health/HealthExportMerge.swift new file mode 100644 index 0000000000..65e5eb1a9a --- /dev/null +++ b/Strand/Health/HealthExportMerge.swift @@ -0,0 +1,62 @@ +import Foundation +import WhoopStore + +// MARK: - Apple Health export row precedence (#2264) +// +// The vitals write-back unions two sources for the same day: NOOP's COMPUTED dailies and any rows a +// WHOOP CSV import produced, with imported taking precedence to match the dashboard. That precedence is +// right, and the wholesale replacement it was written as is not. +// +// `WhoopImporter` never sets `avgSdnn` - a CSV carries no raw R-R to derive it from - so on any day an +// import covers, the replacement drops a computed SDNN that was already correct. The export then fell +// back to `avgHrv`, which for a strap row is RMSSD, and wrote it under `heartRateVariabilitySDNN`. That +// fallback is gone (the export now writes no HRV for a day without an SDNN), so the carry-across below is +// what keeps such a day exporting at all. +// +// The result is a REGRESSION of data that was right: a re-scored night exports a correctly-labelled SDNN +// until an import lands on that day, after which the same night exports RMSSD under the SDNN label, with +// nothing in the series to say the metric changed. RMSSD and SDNN are different magnitudes, so this shows +// up as a step no physiology produced. + +/// How a day's computed row and its imported row combine for the Apple Health export. +enum HealthExportMerge { + + /// The row to export for one day. + /// + /// Imported still wins field-for-field, preserving the existing precedence. The single departure is a + /// field the importer CANNOT populate: where the imported row has no `avgSdnn` and the computed row + /// does, the computed value is carried across rather than discarded. + /// + /// Deliberately narrow. Other fields the importer leaves unset (`spo2Red`, `spo2Ir`, `skinTempC`, + /// `sleepHrOnly`, `activeKcalEst`) are NOT carried across, because the export does not read them: the + /// write path uses `avgHrv`, `avgSdnn`, `restingHr`, `respRateBpm` and `spo2Pct` only. Widening this + /// to a general field-wise merge would change what the dashboard's precedence means for rows this + /// function never had a mandate over. + static func merged(computed: DailyMetric?, imported: DailyMetric) -> DailyMetric { + guard imported.avgSdnn == nil, let carried = computed?.avgSdnn else { return imported } + return DailyMetric( + day: imported.day, + totalSleepMin: imported.totalSleepMin, + efficiency: imported.efficiency, + deepMin: imported.deepMin, + remMin: imported.remMin, + lightMin: imported.lightMin, + disturbances: imported.disturbances, + restingHr: imported.restingHr, + avgHrv: imported.avgHrv, + recovery: imported.recovery, + strain: imported.strain, + exerciseCount: imported.exerciseCount, + spo2Pct: imported.spo2Pct, + skinTempDevC: imported.skinTempDevC, + respRateBpm: imported.respRateBpm, + steps: imported.steps, + activeKcalEst: imported.activeKcalEst, + spo2Red: imported.spo2Red, + spo2Ir: imported.spo2Ir, + avgSdnn: carried, + skinTempC: imported.skinTempC, + sleepHrOnly: imported.sleepHrOnly + ) + } +} diff --git a/Strand/Liquid/LiquidPrimitives.swift b/Strand/Liquid/LiquidPrimitives.swift index 9bacaefab9..096c4b8181 100644 --- a/Strand/Liquid/LiquidPrimitives.swift +++ b/Strand/Liquid/LiquidPrimitives.swift @@ -176,7 +176,12 @@ enum LiquidRender { } /// The live heart-rate curve as a glowing liquid thread with a travelling glint. - static func thread(_ base: GraphicsContext, _ size: CGSize, values: [Double], now: Double, tint: Color) { + /// - Parameter segments: per-value line identity from `hrGapSegments`, or nil for a series known to be + /// contiguous. Values whose ids differ are stroked as SEPARATE subpaths, so a stretch the strap never + /// recorded reads as a break instead of a straight climb across it (#2082). Nil produces byte-for-byte + /// the path this drew before, which is what keeps the live 1 Hz stream untouched. + static func thread(_ base: GraphicsContext, _ size: CGSize, values: [Double], now: Double, tint: Color, + segments: [String]? = nil) { guard values.count >= 2 else { return } let w = size.width, h = size.height, pad: Double = 10 var mn = Double.greatestFiniteMagnitude, mx = -Double.greatestFiniteMagnitude @@ -185,21 +190,47 @@ enum LiquidRender { let n = values.count func px(_ i: Int) -> Double { pad + Double(i) * (w - 2 * pad) / Double(n - 1) } func py(_ v: Double) -> Double { h - pad - (v - mn) / span * (h - 2 * pad) } - func curve() -> Path { - var p = Path() - p.move(to: CGPoint(x: px(0), y: py(values[0]))) - for i in 1..<(n - 1) { + func appendRun(_ p: inout Path, _ lo: Int, _ hi: Int) { + // A lone bucket between two gaps is real data, and it has to draw as something. A bare `move` + // strokes nothing at all, and a ZERO-length line is at the mercy of whether the renderer keeps + // a degenerate subpath alive for its round cap. Give it a hair of width instead, so the cap has + // something to round and the reading is a dot rather than a coin flip. Losing it would be the + // same class of lie as the joined line this change removes: data on screen that is not there. + guard hi > lo else { + let x = px(lo), y = py(values[lo]) + p.move(to: CGPoint(x: x - 0.6, y: y)) + p.addLine(to: CGPoint(x: x + 0.6, y: y)) + return + } + p.move(to: CGPoint(x: px(lo), y: py(values[lo]))) + for i in (lo + 1)..] = [0...(n - 1)] + if let segs = segments, segs.count == n { runs = hrGapRuns(segments: segs) } + func curve() -> Path { + var p = Path() + for r in runs { appendRun(&p, r.lowerBound, r.upperBound) } return p } var ctx = base - ctx.stroke(curve(), with: .color(tint.opacity(0.9)), style: StrokeStyle(lineWidth: 2.4, lineCap: .round, lineJoin: .round)) - // travelling glint + // Built ONCE. The glint strokes the same geometry as the line under it, and this runs inside a + // 60fps TimelineView, so building it per stroke walked the whole series twice a frame for two + // identical paths. + let line = curve() + ctx.stroke(line, with: .color(tint.opacity(0.9)), style: StrokeStyle(lineWidth: 2.4, lineCap: .round, lineJoin: .round)) + // Travelling glint. The dash pattern restarts at each subpath, so a day broken into several runs + // shows a tick per run rather than one glint travelling the whole line. Cosmetic, and the honest + // alternative (one glint walking across gaps) would re-assert the continuity this change removes. let phase = -(now * 55).truncatingRemainder(dividingBy: 414) - ctx.stroke(curve(), with: .color(.white.opacity(0.55)), + ctx.stroke(line, with: .color(.white.opacity(0.55)), style: StrokeStyle(lineWidth: 1.1, lineCap: .round, dash: [14, 400], dashPhase: phase)) // endpoint pulse let ex = px(n - 1), ey = py(values[n - 1]) @@ -211,6 +242,25 @@ enum LiquidRender { // MARK: - Views +/// Applies the splash tap either as a normal tap (consuming it) or as a simultaneous gesture (sharing +/// it with whatever wraps the view). +/// +/// A plain `onTapGesture` inside a `NavigationLink` swallows the tap, so the link never pushes. That is +/// why the hero rings could splash but not navigate. `simultaneousGesture` lets both run, which is the +/// behaviour a tappable gauge wants; standalone vessels keep the consuming tap so nothing else changes. +private struct LiquidSplashTap: ViewModifier { + let passesThrough: Bool + let action: () -> Void + + func body(content: Content) -> some View { + if passesThrough { + content.simultaneousGesture(TapGesture().onEnded { action() }) + } else { + content.onTapGesture { action() } + } + } +} + /// A circular liquid gauge. `value` is 0...1 (nil = empty/no-data). Tap → splash. /// /// `animated: false` renders a single static frame (no TimelineView, no CoreMotion) — the small @@ -220,16 +270,24 @@ struct LiquidVessel: View { let value: Double? let tint: Color var animated: Bool = true + /// When the vessel sits inside a NavigationLink or Button, the splash tap must not CONSUME the + /// tap or the wrapping control never fires. Opt in and the splash runs as a simultaneous gesture + /// instead, so both happen: the liquid still splashes and the link still pushes. Default false + /// keeps every standalone vessel byte-identical (#1995). + var tapPassesThrough: Bool = false @Environment(\.accessibilityReduceMotion) private var reduceMotion @ObservedObject private var motion = NoopMotionState.shared @State private var sim: LiquidSim @State private var splashes = 0 - init(value: Double?, tint: Color, animated: Bool = true) { + // The custom init exists to seed `_sim` from `value`, which also means the memberwise init is NOT + // synthesised: any new stored property has to be threaded through here or callers cannot pass it. + init(value: Double?, tint: Color, animated: Bool = true, tapPassesThrough: Bool = false) { self.value = value self.tint = tint self.animated = animated + self.tapPassesThrough = tapPassesThrough _sim = State(initialValue: LiquidSim(target: value ?? 0)) } @@ -250,7 +308,7 @@ struct LiquidVessel: View { } .aspectRatio(1, contentMode: .fit) .contentShape(Circle()) - .onTapGesture { sim.splash(12); splashes &+= 1 } + .modifier(LiquidSplashTap(passesThrough: tapPassesThrough) { sim.splash(12); splashes &+= 1 }) .liquidTapHaptic(trigger: splashes) // light tap feedback (guarded so the primitives compile on macOS 13) .onAppear { LiquidMotion.shared.acquire() } .onDisappear { LiquidMotion.shared.release() } @@ -314,6 +372,10 @@ struct LiquidTube: View { /// The live heart-rate thread. `bpm` is the recent series (any length ≥ 2). struct LiquidThread: View { let bpm: [Double] + /// Per-value line identity from `hrGapSegments`, or nil for a series known to be contiguous (#2082). + /// The live 1 Hz stream passes nil and is drawn exactly as before; the banked 5-minute fallback passes + /// ids so the hours a strap recorded nothing read as breaks rather than a climb across them. + var segments: [String]? = nil var tint: Color = Color(.sRGB, red: 1, green: 107/255, blue: 129/255, opacity: 1) var height: CGFloat = 96 var animated: Bool = true @@ -329,7 +391,7 @@ struct LiquidThread: View { TimelineView(.animation(minimumInterval: 1.0 / 60.0)) { tl in // 60fps to flow smoothly on ProMotion let now = liquidSeconds(tl.date) Canvas { context, size in - LiquidRender.thread(context, size, values: bpm, now: now, tint: tint) + LiquidRender.thread(context, size, values: bpm, now: now, tint: tint, segments: segments) } } .frame(height: height) @@ -338,7 +400,7 @@ struct LiquidThread: View { /// One-shot render (no travelling glint / pulse) — used until first data load settles. private var staticThread: some View { Canvas { context, size in - LiquidRender.thread(context, size, values: bpm, now: 0, tint: tint) + LiquidRender.thread(context, size, values: bpm, now: 0, tint: tint, segments: segments) } .frame(height: height) } @@ -396,12 +458,16 @@ struct CountUpNumber: View, Animatable { /// byte-identical; the WHOOP 0–21 Effort scale passes 1 so the hero matches the app-wide one-decimal /// `effortDisplay` convention instead of rounding 12.6 → "13" (#45). var decimals: Int = 0 + /// Rendered ahead of the number and never animated, for a value that is bounded rather than + /// measured: Fitness Age arrives clamped, so a reading at the edge of the scale shows "≤20" while + /// the count-up still runs (#2173). Empty by default, which leaves every existing caller identical. + var prefix: String = "" var animatableData: Double { get { value } set { value = newValue } } var body: some View { - Text(decimals > 0 ? String(format: "%.\(decimals)f", value) : "\(Int(value.rounded()))") + Text(prefix + (decimals > 0 ? String(format: "%.\(decimals)f", value) : "\(Int(value.rounded()))")) .font(font).monospacedDigit() } } @@ -425,6 +491,8 @@ struct LiquidScoreGauge: View { var captionText: String? = nil var numberColor: Color = StrandPalette.textPrimary var captionColor: Color = StrandPalette.textTertiary + /// Forwarded to `LiquidVessel` so a gauge inside a link still splashes AND still navigates (#1995). + var tapPassesThrough: Bool = false @State private var shown: Double = 0 @@ -434,7 +502,8 @@ struct LiquidScoreGauge: View { var body: some View { ZStack { - LiquidVessel(value: frac, tint: tint, animated: animated) + LiquidVessel(value: frac, tint: tint, animated: animated, + tapPassesThrough: tapPassesThrough) .frame(width: diameter, height: diameter) VStack(spacing: captionText == nil ? 0 : 1) { Group { diff --git a/Strand/Liquid/LiquidTodayView.swift b/Strand/Liquid/LiquidTodayView.swift index d811a4484f..cdc0fcd936 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 @@ -40,6 +42,9 @@ struct LiquidTodayView: View { /// #989 parity with classic Today + Android: the hydration card is opt-in twice over — the feature /// toggle AND an explicit add in CUSTOMISE. Liquid filtered on neither, so a user who added the card /// and later switched the feature off kept a permanently-blank row. + /// The Coach master switch (`noop.coachEnabled`, shared by name with Android). Default ON. Gates the + /// Today launcher card here; the tab and the daily brief read the same key. + @AppStorage("noop.coachEnabled") private var coachEnabled = true @AppStorage(HydrationStore.enabledKey) private var hydrationEnabled = false /// Today's hydration total + goal (ml), resolved in `load()`. nil → the card shows "—". @State private var hydrationTotalML: Double? @@ -62,7 +67,14 @@ struct LiquidTodayView: View { @State private var stepsEst: Double? // steps_est, day-keyed to the selected day (fallback) @State private var importedStepsDay: Int? // Apple Health steps for the selected day (middle tier) @State private var importedActiveKcalDay: Double? // #616: Apple Health active energy for the day (calorie fallback) + @State private var weightKg: Double? // #204: Apple Health weight ?: profile fallback @State private var hrValues: [Double] = [] // hrBuckets since midnight → 5-min means + /// Line identity for [hrValues], from the bucket timestamps this used to discard (#2082). + /// + /// A bucket with no samples is simply absent from the aggregate, so mapping straight to `bpm` closed + /// every hole up and drew a day of sparse live windows as one continuous line. That bites hardest on a + /// strap whose history never offloads, where heart rate exists ONLY for the windows it was connected. + @State private var hrSegments: [String] = [] @State private var workouts: [WorkoutRow] = [] // newest-first /// #today-hosted-cards: the shared SleepModel that backs every SleepModel-derived hosted sleep card /// (Stages vs typical today; more to follow). Built ONCE in `load()` from the SAME inputs the Sleep tab @@ -70,9 +82,17 @@ struct LiquidTodayView: View { /// hosted sleep card pays none of the extra Repository work. nil until (and unless) it's built. @State private var hostedSleepModel: SleepModel? = nil + // #2040: today's scored stress for the hosted curve card. Loaded only when that card is hosted, the + // same "hosting none pays nothing" rule the sleep model follows. `StressDayCurve` self-gates on a + // cheap heart-rate fingerprint and memoises, so the widget, this shell and the other Today view all + // share one computation rather than scoring the day three times. + @State private var hostedStressHours: [DaytimeStress.HourPoint] = [] + // 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 +119,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 +139,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 +206,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 } @@ -218,13 +265,14 @@ struct LiquidTodayView: View { } ) } - /// Horizontal swipe between days (left = older, right = newer), clamped to [today, earliest]. + /// Horizontal swipe between days (right = older, left = newer — `TodayView.daySwipeDelta`, #2378), + /// clamped to [today, earliest]. private var daySwipeGesture: some Gesture { DragGesture(minimumDistance: 24) .onEnded { value in let dx = value.translation.width, dy = value.translation.height guard abs(dx) > abs(dy) * 1.5, abs(dx) > 50 else { return } - let delta = dx < 0 ? 1 : -1 + let delta = TodayView.daySwipeDelta(dx: dx) let next = Self.clampedDayOffset(current: selectedDayOffset, delta: delta, maxOffset: earliestDayOffset) guard next != selectedDayOffset else { return } @@ -394,7 +442,10 @@ 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)") { + DashboardCardPrefs.migrateLegacyStepsAverage() + await load() + } .sheet(item: $guideSection) { section in NavigationStack { ScoringGuideView(initialSection: section, onClose: { guideSection = nil }) } } @@ -410,6 +461,9 @@ struct LiquidTodayView: View { hostedCardsRaw: $hostedCardsRaw ) } + .sheet(isPresented: $showCoachLauncher) { + CoachLauncherSheet() + } .sheet(isPresented: $showSettings) { NavigationStack { SettingsView() @@ -453,7 +507,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 } @@ -613,20 +676,24 @@ struct LiquidTodayView: View { // the state pill) rather than an empty vessel, matching the classic Today, the widget/watch/Live // Activity (`Repository.widgetAnchor`) and Android. Effort deliberately does NOT carry — it is // today's own accumulation, so yesterday's number would be a false statement, not a stale one. - HeroScoreCell(label: String(localized: "Charge"), score: chargeDisplay.pct, tint: StrandPalette.chargeColor, - animated: dataLoaded, onGuide: { guideSection = .charge }) + HeroScoreCell(label: String(localized: "Charge"), score: chargeDisplay.pct, + tint: chargeDisplay.pct.map { StrandPalette.recoveryColor($0) } ?? StrandPalette.chargeColor, + animated: dataLoaded, onGuide: { guideSection = .charge }, + detailRoute: .metric(HeroRingMetric.charge)) // #45: the hero Effort must honour the user's Effort scale like every other Effort read-out. // Show the value on the chosen scale (0–100 or WHOOP 0–21) with the matching vessel max, and // 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, - decimals: effortScale == .whoop ? 1 : 0) + decimals: effortScale == .whoop ? 1 : 0, + detailRoute: .metric(HeroRingMetric.effort)) HeroScoreCell(label: String(localized: "Rest"), score: restScore, tint: StrandPalette.restColor, - animated: dataLoaded, onGuide: { guideSection = .rest }) + animated: dataLoaded, onGuide: { guideSection = .rest }, + detailRoute: .metric(HeroRingMetric.rest)) .overlay(alignment: .top) { if let sourceLabel = heroSourceLabel { SourceBadge("\(sourceLabel)", tint: StrandPalette.textSecondary) @@ -656,7 +723,8 @@ struct LiquidTodayView: View { // Isolated leaf: it observes LiveState so the ~1 Hz HR notifies re-render ONLY // this card, never the whole Today. Shows the current bpm live with a rolling // beat-by-beat trace; falls back to today's banked 5-minute trace when idle. - LiquidLiveHR(tint: liquidHeart, fallback: hrValues, animated: dataLoaded) + LiquidLiveHR(tint: liquidHeart, fallback: hrValues, fallbackSegments: hrSegments, + animated: dataLoaded) } } .buttonStyle(LiquidPressStyle()) @@ -688,7 +756,11 @@ struct LiquidTodayView: View { // reorder in Customise reflects on the home screen live. The hydration filter mirrors classic // TodayView's `enabledDashboardCards` and Android's `it != HYDRATION || hydrationEnabled`. ForEach(DashboardCardPrefs.decodeEnabled(dashboardCardsRaw) - .filter { hydrationEnabled || $0 != .hydration }) { card in + .filter { hydrationEnabled || $0 != .hydration } + // Coach off means the AI is off, so the launcher card goes with the tab: leaving it + // on Today would offer a feature the wearer has just switched off. Same gate shape + // as hydration, so a card they had added keeps its place and returns on re-enable. + .filter { coachEnabled || $0 != .coach }) { card in liquidCard(for: card) } } @@ -706,7 +778,12 @@ struct LiquidTodayView: View { if !cards.isEmpty { VStack(spacing: NoopMetrics.sectionGap) { ForEach(cards) { card in - hostedCard(for: card) + if let route = card.route { + NavigationLink(value: route) { hostedCard(for: card) } + .buttonStyle(.plain) + } else { + hostedCard(for: card) + } } } } @@ -718,6 +795,30 @@ struct LiquidTodayView: View { private func hostedCard(for card: HostedCard) -> some View { switch card { case .sleepMarks: SleepMarkCard() + case .trendHRV, .trendRestingHR, .trendEffort: + // The Trends charts, drawn by the tab's own ChartCard + TrendChart from the SAME resolved + // points. `HostedTrendData` walks the `days` already in hand, so unlike the sleep model and + // the stress curve there is no read behind these and nothing to gate. + HostedTrendCard(card: card, days: repo.days, effortScale: effortScale) + case .stressToday: + // READ-ONLY, like `stages`: the Stress tab keeps the interactive timeline and this mirrors + // only the display. `DaytimeLoadLine` is the tab's OWN line, so the host cannot drift into + // a second drawing of the same day. + NoopCard(tint: StressRamp.calm) { + VStack(alignment: .leading, spacing: 14) { + Text("Stress through the day").strandOverline() + if hostedStressHours.contains(where: { $0.level != nil }) { + DaytimeLoadLine(hours: hostedStressHours) + } else { + // The honest blank: only waking hours score and an hour needs enough heart + // rate, so early morning is empty by construction rather than by failure. + Text("Calibrating") + .font(StrandFont.subhead) + .foregroundStyle(StrandPalette.textTertiary) + .frame(maxWidth: .infinity, minHeight: 60, alignment: .center) + } + } + } case .asleepDuration: AsleepDurationCard(data: AsleepDurationData.build(days: repo.days)) case .stagesVsTypical: // Renders from the shared SleepModel built in load() (same inputs as the Sleep tab). Until that @@ -850,12 +951,18 @@ struct LiquidTodayView: View { @ViewBuilder private func liquidCard(for card: DashboardCard) -> some View { switch card { + case .stepsAverage30: + RollingStepsAverageCard(day: selectedDayKey) case .stress: cardLink(.stress, title: card.title, sub: card.subtitle, value: stressText, tint: StrandPalette.accent, frac: fracOver(stress, 3)) case .fitnessAge: cardLink(.metric("fitness_age"), title: card.title, sub: card.subtitle, - value: unitText(fitnessAge, card.unit), tint: StrandPalette.chargeColor, frac: 0.5) + // Bound symbol as on the Health hero (#2173), so a floored reading does not read + // exact here and bounded there. + value: fitnessAge.map { "\(fitnessAgeBoundSymbol($0))" + unitText($0, card.unit) } + ?? unitText(fitnessAge, card.unit), + tint: StrandPalette.chargeColor, frac: 0.5) case .vo2max: cardLink(.metric("vo2max_est"), title: card.title, sub: card.subtitle, value: unitText(vo2max, card.unit), tint: StrandPalette.chargeColor, frac: 0.5) @@ -881,12 +988,52 @@ struct LiquidTodayView: View { cardLink(.metricSourced(key: stepsDetailKey, source: stepsDetailSource), title: card.title, sub: card.subtitle, value: stepsText, tint: StrandPalette.metricCyan, frac: fracOver(stepCount, 10000)) case .bloodOxygen: - // Not wired to a real read yet — render EMPTY (not half-full) so it doesn't imply a reading. - cardLink(.metric("spo2"), title: card.title, sub: card.subtitle, - value: "–", tint: StrandPalette.metricCyan, frac: nil) + // #1627: these two were the last cards still on the "not wired yet" placeholder, so on iOS 26 — + // where Liquid Today is the DEFAULT Today screen — Blood Oxygen and Skin Temp read "–" for + // everyone while every other card on the same screen showed a real number off the same + // `displayDay`. Reported with a clean A/B: turning Liquid Today off restored both immediately. + // + // The VALUE resolution is copied from the Key Metrics tile below rather than reinvented — + // candidate fallback and experimental gating included — so the card and the tile cannot + // disagree about the same day's number. The tile's key/route handling is deliberately NOT + // copied; see below for why the two are not interchangeable. + let spo2Real = displayDay?.spo2Pct ?? vitalsDay?.spo2Pct + let spo2CandidateOn = PuffinExperiment.spo2CandidateDisplayEnabled + let spo2Candidate = spo2Real == nil && spo2CandidateOn + ? spo2CandidateByDay[cachedDisplayDay?.day ?? selectedDayKey] + : nil + let spo2 = spo2Real ?? spo2Candidate + // ALWAYS routes to "spo2", never "spo2_candidate". The Key Metrics tile switches that string, + // but there it is a SPARKLINE SERIES key (ktile feeds it to windowedSpark; navigation goes + // through its separate detailMetric argument). Here the string is a NAVIGATION route resolved + // against MetricCatalog — which has no "spo2_candidate" entry — so switching it would drop the + // tap into the Health catch-all instead of the Blood Oxygen detail. Same literal, two different + // key spaces. + // The candidate MUST carry its label. Every other surface that shows it says "strap estimate + // (unverified)" — the Key Metrics tile below, VitalSignsSummary, the classic TodayView — and + // PuffinExperiment's own doc says the toggle surfaces it "in the Blood Oxygen tile/card, + // labelled". An unlabelled number here would read as a measured SpO2 on the one surface that + // is the DEFAULT Today screen on iOS 26. The subtitle is the slot this card has. + cardLink(.metric("spo2"), + title: card.title, + sub: spo2Candidate != nil ? String(localized: "strap estimate (unverified)") : card.subtitle, + // Em dash, not the en dash the stub used: the classic Blood Oxygen card and + // skinTempCardValue both return "—", so the stub's "–" would have left the two + // adjacent cards printing different glyphs for the same "no reading" state. + value: spo2.map { String(format: "%.0f%%", locale: AppLanguage.activeLocale, $0) } ?? "—", + tint: StrandPalette.metricCyan, frac: fracOver(spo2, 100)) case .skinTemp: + // Skin temp has NO Key Metrics tile to mirror (KeyMetric has no skinTemp case), so this uses + // the classic card's extracted resolver instead — the same one TodayView calls, which is why + // it is a static: the formatting decision is testable without a live 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. + // #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: "–", tint: StrandPalette.metricAmber, frac: nil) + 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 // the Steps card — was a "–" placeholder wired to the imported-only detail. @@ -912,6 +1059,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 } } } @@ -920,8 +1072,30 @@ 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) { - LiquidVessel(value: frac, tint: tint, animated: false).frame(width: 30, height: 30) + 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) { + // tapPassesThrough: the vessel's splash gesture would otherwise swallow the enclosing + // Button's tap, leaving a dead 30pt disc on the leading edge of a tappable card row. + LiquidVessel(value: frac, tint: tint, animated: false, tapPassesThrough: true) + .frame(width: 30, height: 30) VStack(alignment: .leading, spacing: 1) { Text(title.uppercased()).font(StrandFont.overlineScaled(11)).tracking(1.0) .foregroundStyle(StrandPalette.textPrimary) @@ -934,9 +1108,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) @@ -945,7 +1117,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.") } @@ -1000,6 +1172,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 { @@ -1030,9 +1211,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) { @@ -1044,22 +1226,50 @@ 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) + // Same as cardLinkBody: without this the disc eats the row's NavigationLink tap. + LiquidVessel(value: frac, tint: tint, animated: false, tapPassesThrough: true) + .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) + } } } @@ -1094,11 +1304,14 @@ 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) + // The label names the window the DETAILED tiles graph, so it is only honest while they + // are drawn: with the trend graphs off (the default) nothing in this section renders a + // trend, and the header was still announcing one (#2376). + sectionHead("KEY METRICS", trailing: keyMetricsDetailed ? trendWindowLabel : nil) // #430 parity: the SAME editor the classic grid uses — selection + order + Detailed tiles. Button { customizationDestination = .keyMetrics } label: { Text(String(localized: "Edit").uppercased()) @@ -1142,11 +1355,16 @@ struct LiquidTodayView: View { // hero are the same number, so a carry that reached only one of them would put two answers for // Charge on one screen. (#543: one prior row feeds every recovery-derived read-out.) Strain below // 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") + ktile(String(localized: "Recovery"), icon: keyMetricIcon(metric), intText(chargeDisplay.pct), "%", StrandPalette.chargeColor, frac(chargeDisplay.pct), key: HeroRingMetric.charge) 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: HeroRingMetric.effort) case .rest: - ktile(String(localized: "Rest"), icon: keyMetricIcon(metric), intText(restScore), "%", StrandPalette.restColor, frac(restScore), key: "sleep_performance") + ktile(String(localized: "Rest"), icon: keyMetricIcon(metric), intText(restScore), "%", StrandPalette.restColor, frac(restScore), key: HeroRingMetric.rest) case .hrv: ktile("HRV", icon: keyMetricIcon(metric), intText(hrv), "ms", StrandPalette.metricCyan, fracOver(hrv, 120), key: "hrv") case .restingHr: @@ -1173,12 +1391,26 @@ struct LiquidTodayView: View { ktile(String(localized: "Steps"), icon: keyMetricIcon(metric), stepsText, "", StrandPalette.chargeColor, fracOver(stepCount, 10000), key: stepsDetailKey, detailMetric: stepsDetailMetric) case .weight: - ktile(String(localized: "Weight"), icon: keyMetricIcon(metric), "—", "", StrandPalette.metricAmber, nil, key: "weight") + let (val, cap) = weightTile(weightKg) + ktile(String(localized: "Weight"), icon: keyMetricIcon(metric), val, "", StrandPalette.metricAmber, nil, key: "weight", caption: cap) case .calories: // #616: imported-first value (imported ?: activeKcalEst) + route the tap to the matching // 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") } } @@ -1194,12 +1426,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) @@ -1332,11 +1565,16 @@ struct LiquidTodayView: View { // MARK: - Reusable chrome - private func sectionHead(_ title: String, trailing: String) -> some View { + /// `trailing` is optional so a section can omit it entirely rather than carry a caption for + /// something it is not drawing (the Key Metrics window label, when the trend graphs are off). + /// Matches the Android twin, whose `SectionHeader` already takes `trailing: String? = null`. + private func sectionHead(_ title: String, trailing: String? = nil) -> some View { HStack(alignment: .firstTextBaseline) { Text(LocalizedStringKey(title)).font(StrandFont.overline).tracking(1.6).foregroundStyle(StrandPalette.textTertiary) Spacer() - Text(LocalizedStringKey(trailing)).font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary) + if let trailing { + Text(LocalizedStringKey(trailing)).font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary) + } } .padding(.horizontal, 2) .padding(.top, 4) @@ -1371,6 +1609,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 @@ -1394,11 +1635,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") @@ -1408,6 +1677,7 @@ struct LiquidTodayView: View { async let stepsA = repo.exploreSeries(key: "steps_est", source: "my-whoop") // Queue 11a: SpO₂ candidate fallback (see `spo2CandidateByDay`'s declaration). async let spo2CandA = repo.exploreSeries(key: "spo2_candidate", source: "my-whoop") + async let weightA = repo.series(key: "weight", source: "apple-health", days: 91) async let appleA = repo.appleDailyRows() async let hrA = repo.hrBuckets(from: from, to: to, bucketSeconds: 300) async let wkA = repo.workoutRows() @@ -1472,6 +1742,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 @@ -1482,6 +1755,7 @@ struct LiquidTodayView: View { .map { ($0.day, $0.value) }, "sleep_performance": restSeries.filter { $0.day >= sparkCutoff && $0.day <= selectedDayKey } .map { ($0.day, $0.value) }, + "weight": (await weightA).filter { $0.day >= sparkCutoff && $0.day <= selectedDayKey }, ] stress = await Task.detached(priority: .utility) { StressModel(days: daysSnapshot, stored: storedStress)?.score @@ -1501,8 +1775,21 @@ struct LiquidTodayView: View { // #616: same-day imported active energy — the calorie fallback when the strap banked no on-device // 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 + + // Weight for the SELECTED day: prefers a real Apple-Health reading (today's daily, else the + // "weight" series' newest point so a sparse-but-recent value still renders). Falls back to the + // user's profile weight in the renderer (weightTile). + let appleRows = await appleA + let weightSeries = await weightA + weightKg = appleRows.filter { $0.day == selectedDayKey }.compactMap { $0.weightKg }.max() + ?? weightSeries.last(where: { $0.day <= selectedDayKey })?.value + + // Awaited ONCE: the timestamps and the means have to come from the same read, or the segments + // would describe a different series than the one drawn. + let hrBuckets = await hrA + hrValues = hrBuckets.map { $0.bpm } + hrSegments = hrGapSegments(bucketTs: hrBuckets.map { $0.ts }, bucketSeconds: 300) + workouts = (await wkA).filter { $0.startTs >= from && $0.startTs < to } let (chargeSource, effortSource, restSource) = await (chargeSourceA, effortSourceA, restSourceA) let sourceResolutions = [ @@ -1536,7 +1823,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, @@ -1548,6 +1835,11 @@ struct LiquidTodayView: View { hostedSleepModel = nil } + // #2040: and today's stress, on the same "only when hosted" rule. + hostedStressHours = HostedCardPrefs.decodeEnabled(hostedCardsRaw).contains(.stressToday) + ? (await StressDayCurve.today(repo: repo)?.result.timeline ?? []) + : [] + // First load done — bring the hero gauges + sky to life now the launch churn has settled. if !dataLoaded { withAnimation(.easeIn(duration: 0.4)) { dataLoaded = true } } } @@ -1658,10 +1950,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)" } @@ -1680,23 +1988,61 @@ struct LiquidTodayView: View { return f.string(from: NSNumber(value: Int(s))) ?? "\(Int(s))" } + // °C / °F for the Skin Temp card, resolved exactly the way the other six screens that show a + // temperature resolve it (TodayView, FullDayChartView, MetricExplorerView x2, SettingsView, + // HealthView): the explicit override when set, else derived from the unit system. Liquid Today was + // the ONLY one of them missing it — which is why its Skin Temp card could not honour the preference + // 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) + } + // The user's Effort display scale (#268), 0–100 by default or the WHOOP 0–21 axis if chosen — the SAME // preference the Workouts screen + Trends read, so a workout's Effort number is identical everywhere. @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) } + /// The Weight tile's display string + an honest caption ("from profile" only on the fallback). + /// Always formatted through the shared `UnitFormatter` so the Imperial/Metric toggle reaches this + /// tile. Mirrors the classic TodayView's `weightTile`. + private func weightTile(_ appleWeightKg: Double?) -> (value: String, caption: String) { + if let kg = appleWeightKg { + return (UnitFormatter.massFromKilograms(kg, system: unitSystem), String(localized: "latest")) + } + return (UnitFormatter.massFromKilograms(profile.weightKg, system: unitSystem), String(localized: "from profile")) + } + private func workoutSub(_ w: WorkoutRow) -> String { 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: " · ") } @@ -1716,11 +2062,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) } @@ -1811,17 +2161,52 @@ private struct HeroScoreCell: View { // Decimal places for the displayed number. 0 keeps the whole-number scores; the WHOOP 0–21 Effort // scale passes 1 to match the app-wide one-decimal `effortDisplay` convention (#45). var decimals: Int = 0 + /// Where the GAUGE taps through to, or nil to keep the ring inert (#1995). + /// + /// Same `TabRoute.metric(key)` the Recovery Vitals rows use, so a ring and the Key-Metrics tile for + /// the same score land on the identical dossier rather than diverging. The LABEL keeps its own job: + /// it opens the scoring guide, which is this screen's only route to that explainer. + var detailRoute: TabRoute? = nil + + /// The gauge, linked when there is somewhere to go. + /// + /// Built here rather than inline so the linked and plain forms stay in one place and the body reads + /// as three stacked elements rather than a branch. + @ViewBuilder + private var gaugeView: some View { + let gauge = LiquidScoreGauge( + score: score, + tint: tint, + diameter: Self.vesselDiameter, + animated: animated, + maxValue: maxValue, + decimals: decimals, + tapPassesThrough: detailRoute != nil + ) + if let detailRoute { + NavigationLink(value: detailRoute) { gauge } + .buttonStyle(LiquidPressStyle()) + // The ring is what shows the NUMBER, so its spoken label carries the number too. Without + // this a VoiceOver user hears only the metric name on the element displaying the value, + // while the label below it reads the score, which is backwards. + .accessibilityLabel(Text("\(label), \(spokenScore)")) + .accessibilityHint(Text("Opens the trend and readings")) + } else { + gauge + } + } + + /// The score as VoiceOver should say it, matching the label row's own phrasing. + private var spokenScore: String { + guard let score else { return String(localized: "no data yet") } + return decimals > 0 + ? String(format: "%.\(decimals)f", locale: AppLanguage.activeLocale, score) + : String(Int(score.rounded())) + } var body: some View { VStack(spacing: 7) { - LiquidScoreGauge( - score: score, - tint: tint, - diameter: Self.vesselDiameter, - animated: animated, - maxValue: maxValue, - decimals: decimals - ) + gaugeView Button(action: onGuide) { HStack(spacing: 3) { // #74: one line, shrink-to-fit rather than wrap under large Dynamic Type (mirrors the @@ -1835,7 +2220,7 @@ private struct HeroScoreCell: View { .foregroundStyle(StrandPalette.textSecondary) } .buttonStyle(.plain) - .accessibilityLabel(Text("\(label), \(score.map { decimals > 0 ? String(format: "%.\(decimals)f", locale: AppLanguage.activeLocale, $0) : String(Int($0.rounded())) } ?? String(localized: "no data yet")). See how it is scored.")) + .accessibilityLabel(Text("\(label), \(spokenScore). See how it is scored.")) } .frame(maxWidth: .infinity) } @@ -2009,6 +2394,10 @@ private struct LiquidFullWidthNavigationAction: View { private struct LiquidLiveHR: View { var tint: Color var fallback: [Double] // today's banked 5-minute buckets — shown when there's no live stream + /// Line identity for [fallback] only (#2082). The live series is 1 Hz and contiguous by construction, + /// so it passes nil and draws exactly as before; the banked buckets skip the hours nothing was + /// recorded, and without this the sparkline joined across them as though the day were continuous. + var fallbackSegments: [String] = [] var animated: Bool @EnvironmentObject private var live: LiveState @@ -2018,10 +2407,12 @@ private struct LiquidLiveHR: View { private var isLive: Bool { live.connected && samples.count >= 2 } private var series: [Double] { isLive ? samples : fallback } + /// The big number is the LIVE heart rate only. It used to fall back to the last banked 5-minute average, drawn + /// exactly like a live reading, so with the strap off the wrist the card went on showing one (a tester's 91). The + /// day's trace and its labelled min / avg / max still show without a live strap. private var bigBpm: Int? { - if let hr = live.heartRate, hr > 0, live.connected { return hr } - if let last = fallback.last { return Int(last.rounded()) } - return nil + guard let hr = live.heartRate, hr > 0, live.connected else { return nil } + return hr } private var subtitle: String { if isLive { return String(localized: "Live · beat by beat") } @@ -2070,7 +2461,10 @@ private struct LiquidLiveHR: View { if series.count >= 2 { ZStack { LiquidHeartRateGrid() - LiquidThread(bpm: series, tint: tint, height: 92, animated: animated) + LiquidThread(bpm: series, + segments: isLive ? nil : (fallbackSegments.count == series.count + ? fallbackSegments : nil), + tint: tint, height: 92, animated: animated) } .frame(height: 92) .clipShape(RoundedRectangle(cornerRadius: NoopMetrics.space2, style: .continuous)) @@ -2093,7 +2487,9 @@ private struct LiquidLiveHR: View { } .onAppear { if samples.isEmpty, let hr = live.heartRate, hr > 0 { samples = [Double(hr)] } } .onChangeCompat(of: live.heartRate) { hr in - guard let hr, hr > 0 else { return } + // No live heart rate (the strap off the wrist, or gone): drop the trace, so the card stops calling an + // old one "Live" and shows today's average under its own label. + guard let hr, hr > 0 else { samples.removeAll(); return } samples.append(Double(hr)) if samples.count > maxSamples { samples.removeFirst(samples.count - maxSamples) } beat.toggle() @@ -2159,9 +2555,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 } @@ -2175,13 +2571,41 @@ extension LiquidTodayView { case offline /// Linked, but no charge reading has landed yet. `charging` is still knowable on its own. case pending(charging: Bool) - /// A reading from the current link. - case charge(pct: Double, charging: Bool) - - static func resolve(connected: Bool, batteryPct: Double?, charging: Bool?) -> StrapBatteryDisplay { + /// A reading from the current link. `isRing` says whose: the ring's own charge under an active + /// ring, the strap's under an active strap — the label names the device the number belongs to. + case charge(pct: Double, charging: Bool, isRing: Bool) + /// The active device is neither the strap nor a ring that has reported its charge this link, so + /// this control has nothing to say and is not drawn. + /// + /// Distinct from [offline], which asserts a strap that IS active is not connected. Collapsing the + /// two put a crossed-out bolt and "strap not connected" on the header of a wearer whose ring was + /// streaming, which is a different false claim from the one #2208 is about rather than a fix for + /// it, and the only one a ring-only wearer would see every day. (@pipiche38 on #2216) + case notActiveDevice + + /// #2208: `activeIsWhoop` is required, not defaulted. `connected` alone was never enough: it is + /// true the moment ANY source streams, `batteryPct` is the strap's and is never cleared, so under + /// an active ring both halves of the old gate passed and this drew the strap's charge. Charging + /// is strap-only for the same reason, so a non-WHOOP active device reports neither of the strap's. + /// + /// A ring reports its OWN charge into `ringPct` (`LiveState.ouraBatteryPct`), cleared with the + /// link, so under a non-WHOOP active device a non-nil `ringPct` is a reading from the ring that is + /// live right now and is drawn as such; nil (no ring, or none has reported yet) keeps the control + /// off the header. `ringCharging` is the ring's charger state (`OuraWearState.charging`), the only + /// charging evidence a ring gives. Same resolution `LiveConsoleReadout.batteryPercent` applies. + /// + /// No default values on purpose. A defaulted flag is one a future call site can forget, and + /// forgetting it reinstates exactly this bug in a form that still compiles. + static func resolve(activeIsWhoop: Bool, connected: Bool, + batteryPct: Double?, charging: Bool?, + ringPct: Int?, ringCharging: Bool) -> StrapBatteryDisplay { + guard activeIsWhoop else { + guard let ringPct else { return .notActiveDevice } + return .charge(pct: Double(ringPct), charging: ringCharging, isRing: true) + } guard connected else { return .offline } guard let pct = batteryPct else { return .pending(charging: charging == true) } - return .charge(pct: pct, charging: charging == true) + return .charge(pct: pct, charging: charging == true, isRing: false) } } @@ -2242,6 +2666,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) } @@ -2257,8 +2690,9 @@ extension LiquidTodayView { } } -/// Strap-battery ring. At sync start it briefly expands within the trailing control row, then settles into -/// an in-place spinner; the layered header keeps either state from moving the Today content. Tap → Devices. +/// Active-device battery ring: the strap's charge under an active strap, the ring's own under an active +/// ring. At sync start it briefly expands within the trailing control row, then settles into an in-place +/// spinner; the layered header keeps either state from moving the Today content. Tap → Devices. private struct LiquidBatteryButton: View { @EnvironmentObject var live: LiveState @EnvironmentObject var router: NavRouter @@ -2290,47 +2724,64 @@ private struct LiquidBatteryButton: View { private var batteryDisplay: LiquidTodayView.StrapBatteryDisplay { #if DEBUG if DemoSyncHarness.active { + // The harness stands in for a connected WHOOP, so it answers this the way one would. return .resolve( + activeIsWhoop: true, connected: true, batteryPct: DemoSyncHarness.batteryPercent, - charging: DemoSyncHarness.charging + charging: DemoSyncHarness.charging, + ringPct: nil, + ringCharging: false ) } #endif return .resolve( + activeIsWhoop: live.activeIsWhoop, connected: live.connected, batteryPct: live.batteryPct, - charging: live.charging + charging: live.charging, + ringPct: live.ouraBatteryPct, + ringCharging: live.ouraWearState == .charging ) } private var indicatorState: ChargeSyncIndicator.BatteryState { switch batteryDisplay { - case .offline: + case .offline, .notActiveDevice: return .offline case .pending(let charging): return .pending(charging: charging) - case .charge(let percent, let charging): + case .charge(let percent, let charging, _): return .charge(percent: percent, charging: charging) } } var body: some View { - Button { router.openDevices() } label: { - ChargeSyncIndicator( - batteryState: indicatorState, - syncing: syncing, - chunks: syncChunks - ) + // Not drawn at all when the active device is neither the strap nor a ring with a charge of its + // own to show. The alternative is a glyph that has to say SOMETHING about a strap nobody is + // wearing, and every option is a claim: a charge that is not the active device's, or a crossed-out + // bolt asserting a disconnection that is not the interesting fact. (#2208) A ring that HAS + // reported its charge is the active device's own reading, and #2208's fix left it undrawn only + // because the control could not yet tell whose number it held. + if case .notActiveDevice = batteryDisplay { + EmptyView() + } else { + Button { router.openDevices() } label: { + ChargeSyncIndicator( + batteryState: indicatorState, + syncing: syncing, + chunks: syncChunks + ) + } + .nativeLiquidGlassSyncButton() + .accessibilityLabel(batteryAccessibility) + .debouncedSyncSignal(syncingRaw, into: $syncing) + // DEBUG-gated at the CALL SITE too, not just in the body: in Release the harness must cost + // literally nothing, rather than an async task created and immediately returned per appearance. + #if DEBUG + .task { await runDemoSyncCycleIfNeeded() } + #endif } - .nativeLiquidGlassSyncButton() - .accessibilityLabel(batteryAccessibility) - .debouncedSyncSignal(syncingRaw, into: $syncing) - // DEBUG-gated at the CALL SITE too, not just in the body: in Release the harness must cost - // literally nothing, rather than an async task created and immediately returned per appearance. - #if DEBUG - .task { await runDemoSyncCycleIfNeeded() } - #endif } /// DEBUG `--demo-sync` only: loop the syncing signal so the charge→sync morph plays in both @@ -2381,20 +2832,39 @@ private struct LiquidBatteryButton: View { // VoiceOver announcing a real count while the ring showed the synthetic one — i.e. the // harness could not be used to check the read-out it exists to exercise. let n = syncChunks - return n > 0 - ? String(localized: "Syncing strap history, \(n) chunks") - : String(localized: "Syncing strap history") + guard n > 0 else { return String(localized: "Syncing strap history") } + // #689/#815: the connect-time ring backlog, when the strap reported one. Zero is dropped by + // `SyncChipState.resolve`, and dropped here for the same reason: "0 pages behind" beside a + // running sync contradicts itself. Both counts inflect — the phrase is built from its own + // entry and joined through a template, so "1 chunk" and "1 page" read correctly and the + // joining punctuation stays inside the translated template rather than being concatenated. + let behind = live.pagesBehindAtConnect + .flatMap { $0 > 0 ? $0 : nil } + .map { String(localized: "\($0) pages behind at connect") } + if let behind { + return String(localized: "Syncing strap history, \(n) chunks, \(behind)") + } + return String(localized: "Syncing strap history, \(n) chunks") } switch batteryDisplay { + case .notActiveDevice: + return "" // not drawn; the label is unreachable and must not claim anything case .offline: return String(localized: "Strap battery, strap not connected") case .pending(let charging): return charging ? String(localized: "Strap battery charging, no reading yet") : String(localized: "Strap battery, no reading yet") - case .charge(let percent, let charging): + case .charge(let percent, let charging, let isRing): let n = Int(percent.rounded()) + // Named for the device the number belongs to: "Strap battery" over a ring's charge would be + // the #2208 misattribution again, in the label instead of the number. + if isRing { + return charging + ? String(localized: "Ring battery \(n) percent, charging") + : String(localized: "Ring battery \(n) percent") + } return charging ? String(localized: "Strap battery \(n) percent, charging") : String(localized: "Strap battery \(n) percent") @@ -2503,7 +2973,8 @@ private struct LiquidSyncStatusRow: View { private struct LiquidStrapBatteryRow: View { @EnvironmentObject var live: LiveState var body: some View { - if live.connected, let pct = live.batteryPct { + // #2208: the strap's charge only when the strap is the active device. + if live.connected, live.activeIsWhoop, let pct = live.batteryPct { HStack { Text("Strap battery").font(StrandFont.subhead).foregroundStyle(StrandPalette.textSecondary) Spacer() diff --git a/Strand/MenuBar/MenuBarContent.swift b/Strand/MenuBar/MenuBarContent.swift index 21d589740d..f9eca49445 100644 --- a/Strand/MenuBar/MenuBarContent.swift +++ b/Strand/MenuBar/MenuBarContent.swift @@ -105,8 +105,15 @@ public struct MenuBarContent: View { : live.connected ? String(localized: "CONNECTED") : String(localized: "OFFLINE") } + /// #2208: the strap's charge, or nil when it is not the active device's to report. This surface read + /// `live.batteryPct` with NO gate, so it showed the strap's last percentage with nothing connected and + /// under an active ring alike. One accessor so the number and its tint cannot disagree. + private var strapBatteryPct: Double? { + (live.connected && live.activeIsWhoop) ? live.batteryPct : nil + } + private var batteryTone: StrandTone { - guard let pct = live.batteryPct else { return .neutral } + guard let pct = strapBatteryPct else { return .neutral } switch pct { case ..<15: return .critical case ..<35: return .warning @@ -212,8 +219,8 @@ public struct MenuBarContent: View { HStack(spacing: 0) { statCell( "BATTERY", - live.batteryPct.map { "\(Int($0.rounded()))%" } ?? "—", - tint: live.batteryPct == nil ? StrandPalette.textPrimary : toneColor(batteryTone) + strapBatteryPct.map { "\(Int($0.rounded()))%" } ?? "—", + tint: strapBatteryPct == nil ? StrandPalette.textPrimary : toneColor(batteryTone) ) cellDivider statCell( 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..f48591a1fd 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -1,14 +1,2479 @@ { "sourceLanguage": "en", "strings": { + "Ring battery %lld percent": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Ring-Akku %lld Prozent"}}, "es": {"stringUnit": {"state": "translated", "value": "Batería del anillo %lld por ciento"}}, "fr": {"stringUnit": {"state": "translated", "value": "Batterie de la bague %lld pour cent"}}, "it": {"stringUnit": {"state": "translated", "value": "Batteria dell'anello %lld percento"}}, "pl": {"stringUnit": {"state": "translated", "value": "Bateria pierścienia %lld procent"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Bateria do anel %lld por cento"}}, "ru": {"stringUnit": {"state": "translated", "value": "Заряд кольца %lld процентов"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "戒指电量百分之 %lld"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "戒指電量百分之 %lld"}} + } }, + "Ring battery %lld percent, charging": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Ring-Akku %lld Prozent, wird geladen"}}, "es": {"stringUnit": {"state": "translated", "value": "Batería del anillo %lld por ciento, cargando"}}, "fr": {"stringUnit": {"state": "translated", "value": "Batterie de la bague %lld pour cent, en charge"}}, "it": {"stringUnit": {"state": "translated", "value": "Batteria dell'anello %lld percento, in carica"}}, "pl": {"stringUnit": {"state": "translated", "value": "Bateria pierścienia %lld procent, ładowanie"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Bateria do anel %lld por cento, a carregar"}}, "ru": {"stringUnit": {"state": "translated", "value": "Заряд кольца %lld процентов, заряжается"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "戒指电量百分之 %lld,正在充电"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "戒指電量百分之 %lld,正在充電"}} + } }, + "Oura notification mask ff (experimental)": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Oura-Benachrichtigungsmaske ff (experimentell)"}}, "es": {"stringUnit": {"state": "translated", "value": "Máscara de notificación Oura ff (experimental)"}}, "fr": {"stringUnit": {"state": "translated", "value": "Masque de notification Oura ff (expérimental)"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Máscara de notificação Oura ff (experimental)"}}, "it": {"stringUnit": {"state": "translated", "value": "Maschera di notifica Oura ff (sperimentale)"}}, "pl": {"stringUnit": {"state": "translated", "value": "Maska powiadomień Oura ff (eksperymentalne)"}}, "ru": {"stringUnit": {"state": "translated", "value": "Маска уведомлений Oura ff (экспериментально)"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "Oura 通知掩码 ff(实验性)"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "Oura 通知遮罩 ff(實驗性)"}} + } }, + "Sends the official app’s SetNotification mask (1c 01 ff) instead of NOOP’s 3f at the next connect. The ring packs ~10 packets per notification for the official app and one for NOOP (9× slower drains); this is the first candidate switch. Off by default; the next connect after turning it off is back on 3f. Watch the strap log for “-> notify_all(ff)” and compare notification sizes in the raw capture.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Sendet beim nächsten Verbinden die SetNotification-Maske der offiziellen App (1c 01 ff) statt NOOPs 3f. Der Ring packt für die offizielle App ~10 Pakete in eine Benachrichtigung, für NOOP nur eines (9× langsameres Auslesen); das ist der erste Kandidat für den Schalter. Standardmäßig aus; die erste Verbindung nach dem Ausschalten läuft wieder mit 3f. Im Strap-Protokoll auf „-> notify_all(ff)“ achten und die Benachrichtigungsgrößen in der Rohaufzeichnung vergleichen."}}, "es": {"stringUnit": {"state": "translated", "value": "Envía la máscara SetNotification de la aplicación oficial (1c 01 ff) en lugar del 3f de NOOP en la próxima conexión. El anillo agrupa ~10 paquetes por notificación para la aplicación oficial y uno para NOOP (descarga 9× más lenta); este es el primer interruptor candidato. Desactivado por defecto; la conexión siguiente a desactivarlo vuelve a 3f. Busca «-> notify_all(ff)» en el registro de la pulsera y compara los tamaños de notificación en la captura sin procesar."}}, "fr": {"stringUnit": {"state": "translated", "value": "Envoie le masque SetNotification de l’application officielle (1c 01 ff) au lieu du 3f de NOOP à la prochaine connexion. La bague regroupe ~10 paquets par notification pour l’application officielle et un seul pour NOOP (vidage 9× plus lent) ; c’est le premier commutateur candidat. Désactivé par défaut ; la connexion suivant la désactivation repasse en 3f. Surveillez « -> notify_all(ff) » dans le journal du bracelet et comparez la taille des notifications dans la capture brute."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Envia a máscara SetNotification da aplicação oficial (1c 01 ff) em vez do 3f do NOOP na próxima ligação. O anel agrupa ~10 pacotes por notificação para a aplicação oficial e um para o NOOP (descarga 9× mais lenta); este é o primeiro candidato a interruptor. Desligado por predefinição; a ligação seguinte após desligar volta a 3f. Procure «-> notify_all(ff)» no registo da pulseira e compare os tamanhos das notificações na captura em bruto."}}, "it": {"stringUnit": {"state": "translated", "value": "Alla prossima connessione invia la maschera SetNotification dell’app ufficiale (1c 01 ff) invece del 3f di NOOP. L’anello impacchetta ~10 pacchetti per notifica per l’app ufficiale e uno solo per NOOP (scarico 9× più lento); questo è il primo interruttore candidato. Disattivato per impostazione predefinita; la connessione successiva alla disattivazione torna a 3f. Cerca «-> notify_all(ff)» nel registro del cinturino e confronta le dimensioni delle notifiche nella cattura grezza."}}, "pl": {"stringUnit": {"state": "translated", "value": "Przy następnym połączeniu wysyła maskę SetNotification oficjalnej aplikacji (1c 01 ff) zamiast 3f używanego przez NOOP. Pierścień pakuje ~10 pakietów w jedno powiadomienie dla oficjalnej aplikacji, a dla NOOP tylko jeden (9× wolniejsze zrzuty); to pierwszy kandydat na przełącznik. Domyślnie wyłączone; pierwsze połączenie po wyłączeniu wraca do 3f. Szukaj „-> notify_all(ff)” w dzienniku opaski i porównaj rozmiary powiadomień w surowym przechwyceniu."}}, "ru": {"stringUnit": {"state": "translated", "value": "При следующем подключении отправляет маску SetNotification официального приложения (1c 01 ff) вместо 3f, которую использует NOOP. Для официального приложения кольцо упаковывает ~10 пакетов в одно уведомление, для NOOP — только один (выгрузка в 9× медленнее); это первый кандидат на переключатель. По умолчанию выключено; первое подключение после выключения снова использует 3f. Ищите «-> notify_all(ff)» в журнале браслета и сравните размеры уведомлений в сырой записи."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "下次连接时发送官方应用的 SetNotification 掩码(1c 01 ff),而不是 NOOP 的 3f。戒指为官方应用把约 10 个数据包打包进一条通知,为 NOOP 只发一个(读取慢 9 倍);这是第一个候选开关。默认关闭;关闭后的下一次连接恢复为 3f。请在腕带日志中查看“-> notify_all(ff)”,并在原始捕获中比较通知大小。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "下次連線時傳送官方應用程式的 SetNotification 遮罩(1c 01 ff),而非 NOOP 的 3f。戒指為官方應用程式把約 10 個封包打包進一則通知,為 NOOP 只送一個(讀取慢 9 倍);這是第一個候選開關。預設關閉;關閉後的下一次連線恢復為 3f。請在腕帶記錄中查看「-> notify_all(ff)」,並在原始擷取中比較通知大小。"}} + } }, + "Over-reports R-R, so no value is shown": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Meldet zu viele RR-Intervalle, daher kein Wert"}}, "es": {"stringUnit": {"state": "translated", "value": "Cuenta de más los intervalos R-R, por eso no se muestra"}}, "fr": {"stringUnit": {"state": "translated", "value": "Surestime les intervalles R-R, donc aucune valeur"}}, "it": {"stringUnit": {"state": "translated", "value": "Conta troppi intervalli R-R, quindi nessun valore"}}, "pl": {"stringUnit": {"state": "translated", "value": "Zawyża liczbę interwałów R-R, więc nie pokazujemy wartości"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Conta intervalos R-R a mais, por isso não há valor"}}, "ru": {"stringUnit": {"state": "translated", "value": "Завышает число интервалов R-R, поэтому значение не показано"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "R-R 间期计数偏多,因此不显示数值"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "R-R 間期計數偏多,因此不顯示數值"}} + } }, + "7-day trend": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "7-Tage-Trend"}}, "es": {"stringUnit": {"state": "translated", "value": "Tendencia de 7 días"}}, "fr": {"stringUnit": {"state": "translated", "value": "Tendance sur 7 jours"}}, "it": {"stringUnit": {"state": "translated", "value": "Tendenza a 7 giorni"}}, "pl": {"stringUnit": {"state": "translated", "value": "Trend 7-dniowy"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Tendência de 7 dias"}}, "ru": {"stringUnit": {"state": "translated", "value": "Тренд за 7 дней"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "7 天趋势"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "7 天趨勢"}} + } }, + "Added to Today": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Zu Heute hinzugefügt"}}, "es": {"stringUnit": {"state": "translated", "value": "Añadido a Hoy"}}, "fr": {"stringUnit": {"state": "translated", "value": "Ajouté à Aujourd'hui"}}, "it": {"stringUnit": {"state": "translated", "value": "Aggiunto a Oggi"}}, "pl": {"stringUnit": {"state": "translated", "value": "Dodano do Dzisiaj"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Adicionado a Hoje"}}, "ru": {"stringUnit": {"state": "translated", "value": "Добавлено в «Сегодня»"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "已添加到今天"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "已新增至今天"}} + } }, + "Available": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Verfügbar"}}, "es": {"stringUnit": {"state": "translated", "value": "Disponible"}}, "fr": {"stringUnit": {"state": "translated", "value": "Disponible"}}, "it": {"stringUnit": {"state": "translated", "value": "Disponibile"}}, "pl": {"stringUnit": {"state": "translated", "value": "Dostępne"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Disponível"}}, "ru": {"stringUnit": {"state": "translated", "value": "Доступно"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "可用"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "可用"}} + } }, + "None added yet": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Noch nichts hinzugefügt"}}, "es": {"stringUnit": {"state": "translated", "value": "Aún no has añadido ninguno"}}, "fr": {"stringUnit": {"state": "translated", "value": "Aucun ajouté pour l'instant"}}, "it": {"stringUnit": {"state": "translated", "value": "Nessuno aggiunto finora"}}, "pl": {"stringUnit": {"state": "translated", "value": "Nic jeszcze nie dodano"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Ainda não foi adicionado nenhum"}}, "ru": {"stringUnit": {"state": "translated", "value": "Пока ничего не добавлено"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "尚未添加任何内容"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "尚未新增任何項目"}} + } }, + "Shown on Today": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Auf Heute angezeigt"}}, "es": {"stringUnit": {"state": "translated", "value": "Mostrado en Hoy"}}, "fr": {"stringUnit": {"state": "translated", "value": "Affiché dans Aujourd'hui"}}, "it": {"stringUnit": {"state": "translated", "value": "Mostrato in Oggi"}}, "pl": {"stringUnit": {"state": "translated", "value": "Pokazywane w Dzisiaj"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Mostrado em Hoje"}}, "ru": {"stringUnit": {"state": "translated", "value": "Показано в «Сегодня»"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "显示在今天"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "顯示在今天"}} + } }, + "last night": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "letzte Nacht"}}, "es": {"stringUnit": {"state": "translated", "value": "anoche"}}, "fr": {"stringUnit": {"state": "translated", "value": "la nuit dernière"}}, "it": {"stringUnit": {"state": "translated", "value": "la notte scorsa"}}, "pl": {"stringUnit": {"state": "translated", "value": "ostatniej nocy"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "ontem à noite"}}, "ru": {"stringUnit": {"state": "translated", "value": "прошлой ночью"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "昨晚"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "昨晚"}} + } }, + "no data yet": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "noch keine Daten"}}, "es": {"stringUnit": {"state": "translated", "value": "aún sin datos"}}, "fr": {"stringUnit": {"state": "translated", "value": "pas encore de données"}}, "it": {"stringUnit": {"state": "translated", "value": "ancora nessun dato"}}, "pl": {"stringUnit": {"state": "translated", "value": "brak danych"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "ainda sem dados"}}, "ru": {"stringUnit": {"state": "translated", "value": "данных пока нет"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "暂无数据"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "暫無資料"}} + } }, + "strap estimate (unverified)": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Strap-Schätzung (nicht verifiziert)"}}, "es": {"stringUnit": {"state": "translated", "value": "estimación de la banda (sin verificar)"}}, "fr": {"stringUnit": {"state": "translated", "value": "estimation du bracelet (non vérifiée)"}}, "it": {"stringUnit": {"state": "translated", "value": "stima della fascia (non verificata)"}}, "pl": {"stringUnit": {"state": "translated", "value": "szacunek opaski (niezweryfikowany)"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "estimativa da pulseira (não verificada)"}}, "ru": {"stringUnit": {"state": "translated", "value": "оценка ремешка (не проверена)"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "手环估算值(未验证)"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "手環估算值(未驗證)"}} + } }, + "toggle ON · no estimate yet": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Schalter AN · noch keine Schätzung"}}, "es": {"stringUnit": {"state": "translated", "value": "interruptor activado · aún sin estimación"}}, "fr": {"stringUnit": {"state": "translated", "value": "commutateur activé · pas encore d'estimation"}}, "it": {"stringUnit": {"state": "translated", "value": "interruttore attivo · nessuna stima"}}, "pl": {"stringUnit": {"state": "translated", "value": "przełącznik włączony · brak szacunku"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "interruptor ligado · ainda sem estimativa"}}, "ru": {"stringUnit": {"state": "translated", "value": "переключатель включён · оценки пока нет"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "开关已开启 · 尚无估算值"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "開關已開啟 · 尚無估算值"}} + } }, + "Pending sync · strap history still offloading": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Synchronisierung ausstehend · Strap-Verlauf wird noch übertragen"}}, "es": {"stringUnit": {"state": "translated", "value": "Sincronización pendiente · el historial de la banda aún se está descargando"}}, "fr": {"stringUnit": {"state": "translated", "value": "Synchronisation en attente · l'historique du bracelet est encore en cours de transfert"}}, "it": {"stringUnit": {"state": "translated", "value": "Sincronizzazione in attesa · la cronologia della fascia è ancora in trasferimento"}}, "pl": {"stringUnit": {"state": "translated", "value": "Oczekiwanie na synchronizację · historia opaski wciąż jest pobierana"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Sincronização pendente · o histórico da pulseira ainda está a ser transferido"}}, "ru": {"stringUnit": {"state": "translated", "value": "Ожидание синхронизации · история ремешка ещё выгружается"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "等待同步 · 手环历史数据仍在传输"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "等待同步 · 手環歷史資料仍在傳輸"}} + } }, + "Strap battery charging, no reading yet": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Strap-Akku wird geladen, noch kein Messwert"}}, "es": {"stringUnit": {"state": "translated", "value": "Batería de la banda cargando, aún sin lectura"}}, "fr": {"stringUnit": {"state": "translated", "value": "Batterie du bracelet en charge, pas encore de relevé"}}, "it": {"stringUnit": {"state": "translated", "value": "Batteria della fascia in carica, nessuna lettura"}}, "pl": {"stringUnit": {"state": "translated", "value": "Bateria opaski ładuje się, brak odczytu"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Bateria da pulseira a carregar, ainda sem leitura"}}, "ru": {"stringUnit": {"state": "translated", "value": "Ремешок заряжается, показаний пока нет"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "手环正在充电,尚无读数"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "手環正在充電,尚無讀數"}} + } }, + "Strap battery, no reading yet": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Strap-Akku, noch kein Messwert"}}, "es": {"stringUnit": {"state": "translated", "value": "Batería de la banda, aún sin lectura"}}, "fr": {"stringUnit": {"state": "translated", "value": "Batterie du bracelet, pas encore de relevé"}}, "it": {"stringUnit": {"state": "translated", "value": "Batteria della fascia, nessuna lettura"}}, "pl": {"stringUnit": {"state": "translated", "value": "Bateria opaski, brak odczytu"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Bateria da pulseira, ainda sem leitura"}}, "ru": {"stringUnit": {"state": "translated", "value": "Заряд ремешка, показаний пока нет"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "手环电量,尚无读数"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "手環電量,尚無讀數"}} + } }, + "Strap battery, strap not connected": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Strap-Akku, Strap nicht verbunden"}}, "es": {"stringUnit": {"state": "translated", "value": "Batería de la banda, banda no conectada"}}, "fr": {"stringUnit": {"state": "translated", "value": "Batterie du bracelet, bracelet non connecté"}}, "it": {"stringUnit": {"state": "translated", "value": "Batteria della fascia, fascia non connessa"}}, "pl": {"stringUnit": {"state": "translated", "value": "Bateria opaski, opaska niepołączona"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Bateria da pulseira, pulseira não ligada"}}, "ru": {"stringUnit": {"state": "translated", "value": "Заряд ремешка, ремешок не подключён"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "手环电量,手环未连接"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "手環電量,手環未連線"}} + } }, + "Strap battery %lld percent": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Strap-Akku %lld Prozent"}}, "es": {"stringUnit": {"state": "translated", "value": "Batería de la banda %lld por ciento"}}, "fr": {"stringUnit": {"state": "translated", "value": "Batterie du bracelet %lld pour cent"}}, "it": {"stringUnit": {"state": "translated", "value": "Batteria della fascia %lld percento"}}, "pl": {"stringUnit": {"state": "translated", "value": "Bateria opaski %lld procent"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Bateria da pulseira %lld por cento"}}, "ru": {"stringUnit": {"state": "translated", "value": "Заряд ремешка %lld процентов"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "手环电量百分之 %lld"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "手環電量百分之 %lld"}} + } }, + "Strap battery %lld percent, charging": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Strap-Akku %lld Prozent, wird geladen"}}, "es": {"stringUnit": {"state": "translated", "value": "Batería de la banda %lld por ciento, cargando"}}, "fr": {"stringUnit": {"state": "translated", "value": "Batterie du bracelet %lld pour cent, en charge"}}, "it": {"stringUnit": {"state": "translated", "value": "Batteria della fascia %lld percento, in carica"}}, "pl": {"stringUnit": {"state": "translated", "value": "Bateria opaski %lld procent, ładowanie"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Bateria da pulseira %lld por cento, a carregar"}}, "ru": {"stringUnit": {"state": "translated", "value": "Заряд ремешка %lld процентов, заряжается"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "手环电量百分之 %lld,正在充电"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "手環電量百分之 %lld,正在充電"}} + } }, + "Syncing strap history, chunk %lld": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Strap-Verlauf wird synchronisiert, Block %lld"}}, "es": {"stringUnit": {"state": "translated", "value": "Sincronizando historial de la banda, bloque %lld"}}, "fr": {"stringUnit": {"state": "translated", "value": "Synchronisation de l'historique du bracelet, bloc %lld"}}, "it": {"stringUnit": {"state": "translated", "value": "Sincronizzazione della cronologia della fascia, blocco %lld"}}, "pl": {"stringUnit": {"state": "translated", "value": "Synchronizacja historii opaski, fragment %lld"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "A sincronizar o histórico da pulseira, bloco %lld"}}, "ru": {"stringUnit": {"state": "translated", "value": "Синхронизация истории ремешка, фрагмент %lld"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "正在同步手环历史数据,第 %lld 块"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "正在同步手環歷史資料,第 %lld 塊"}} + } }, + "%lld cards shown": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "%lld Karten angezeigt"}}, "es": {"stringUnit": {"state": "translated", "value": "%lld tarjetas mostradas"}}, "fr": {"stringUnit": {"state": "translated", "value": "%lld cartes affichées"}}, "it": {"stringUnit": {"state": "translated", "value": "%lld schede mostrate"}}, "pl": {"stringUnit": {"state": "translated", "value": "Pokazane karty: %lld"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "%lld cartões mostrados"}}, "ru": {"stringUnit": {"state": "translated", "value": "Показано карточек: %lld"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "显示 %lld 张卡片"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "顯示 %lld 張卡片"}} + } }, + "%lld added": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "%lld hinzugefügt"}}, "es": {"stringUnit": {"state": "translated", "value": "%lld añadidos"}}, "fr": {"stringUnit": {"state": "translated", "value": "%lld ajoutés"}}, "it": {"stringUnit": {"state": "translated", "value": "%lld aggiunti"}}, "pl": {"stringUnit": {"state": "translated", "value": "Dodano: %lld"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "%lld adicionados"}}, "ru": {"stringUnit": {"state": "translated", "value": "Добавлено: %lld"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "已添加 %lld 个"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "已新增 %lld 個"}} + } }, + "%lld metrics shown": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "%lld Werte angezeigt"}}, "es": {"stringUnit": {"state": "translated", "value": "%lld métricas mostradas"}}, "fr": {"stringUnit": {"state": "translated", "value": "%lld mesures affichées"}}, "it": {"stringUnit": {"state": "translated", "value": "%lld metriche mostrate"}}, "pl": {"stringUnit": {"state": "translated", "value": "Pokazane wskaźniki: %lld"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "%lld métricas mostradas"}}, "ru": {"stringUnit": {"state": "translated", "value": "Показано показателей: %lld"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "显示 %lld 项指标"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "顯示 %lld 項指標"}} + } }, + "Looks like a workout around %@-%@ (avg HR %lld, %lld min). Save it?": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Sieht nach einem Workout zwischen %@ und %@ aus (Ø HF %lld, %lld Min.). Speichern?"}}, "es": {"stringUnit": {"state": "translated", "value": "Parece un entrenamiento entre %@ y %@ (FC media %lld, %lld min). ¿Guardarlo?"}}, "fr": {"stringUnit": {"state": "translated", "value": "Cela ressemble à une séance entre %@ et %@ (FC moy. %lld, %lld min). L'enregistrer ?"}}, "it": {"stringUnit": {"state": "translated", "value": "Sembra un allenamento tra %@ e %@ (FC media %lld, %lld min). Salvarlo?"}}, "pl": {"stringUnit": {"state": "translated", "value": "To wygląda na trening między %@ a %@ (śr. tętno %lld, %lld min). Zapisać?"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Parece um treino entre %@ e %@ (FC média %lld, %lld min). Guardar?"}}, "ru": {"stringUnit": {"state": "translated", "value": "Похоже на тренировку с %@ до %@ (средний пульс %lld, %lld мин). Сохранить?"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "看起来是一次 %@-%@ 的锻炼(平均心率 %lld,%lld 分钟)。保存吗?"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "看起來是一次 %@-%@ 的鍛鍊(平均心率 %lld,%lld 分鐘)。要儲存嗎?"}} + } }, + "Looks like a workout yesterday around %@-%@ (avg HR %lld, %lld min). Save it?": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Sieht nach einem Workout gestern zwischen %@ und %@ aus (Ø HF %lld, %lld Min.). Speichern?"}}, "es": {"stringUnit": {"state": "translated", "value": "Parece un entrenamiento ayer entre %@ y %@ (FC media %lld, %lld min). ¿Guardarlo?"}}, "fr": {"stringUnit": {"state": "translated", "value": "Cela ressemble à une séance hier entre %@ et %@ (FC moy. %lld, %lld min). L'enregistrer ?"}}, "it": {"stringUnit": {"state": "translated", "value": "Sembra un allenamento di ieri tra %@ e %@ (FC media %lld, %lld min). Salvarlo?"}}, "pl": {"stringUnit": {"state": "translated", "value": "To wygląda na wczorajszy trening między %@ a %@ (śr. tętno %lld, %lld min). Zapisać?"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Parece um treino ontem entre %@ e %@ (FC média %lld, %lld min). Guardar?"}}, "ru": {"stringUnit": {"state": "translated", "value": "Похоже на вчерашнюю тренировку с %@ до %@ (средний пульс %lld, %lld мин). Сохранить?"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "看起来是昨天 %@-%@ 的一次锻炼(平均心率 %lld,%lld 分钟)。保存吗?"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "看起來是昨天 %@-%@ 的一次鍛鍊(平均心率 %lld,%lld 分鐘)。要儲存嗎?"}} + } }, + "Looks like a workout on %@ around %@-%@ (avg HR %lld, %lld min). Save it?": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Sieht nach einem Workout am %@ zwischen %@ und %@ aus (Ø HF %lld, %lld Min.). Speichern?"}}, "es": {"stringUnit": {"state": "translated", "value": "Parece un entrenamiento el %@ entre %@ y %@ (FC media %lld, %lld min). ¿Guardarlo?"}}, "fr": {"stringUnit": {"state": "translated", "value": "Cela ressemble à une séance le %@ entre %@ et %@ (FC moy. %lld, %lld min). L'enregistrer ?"}}, "it": {"stringUnit": {"state": "translated", "value": "Sembra un allenamento del %@ tra %@ e %@ (FC media %lld, %lld min). Salvarlo?"}}, "pl": {"stringUnit": {"state": "translated", "value": "To wygląda na trening %@ między %@ a %@ (śr. tętno %lld, %lld min). Zapisać?"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Parece um treino em %@ entre %@ e %@ (FC média %lld, %lld min). Guardar?"}}, "ru": {"stringUnit": {"state": "translated", "value": "Похоже на тренировку %@ с %@ до %@ (средний пульс %lld, %lld мин). Сохранить?"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "看起来是 %@ 的一次 %@-%@ 锻炼(平均心率 %lld,%lld 分钟)。保存吗?"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "看起來是 %@ 的一次 %@-%@ 鍛鍊(平均心率 %lld,%lld 分鐘)。要儲存嗎?"}} + } }, + "I am not a WHOOP employee, contractor, or affiliate, and I am not using NOOP on WHOOP's behalf.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Ich bin kein WHOOP-Mitarbeiter, -Auftragnehmer oder -Partner und nutze NOOP nicht im Auftrag von WHOOP."}}, "es": {"stringUnit": {"state": "translated", "value": "No soy empleado, contratista ni afiliado de WHOOP, y no utilizo NOOP en nombre de WHOOP."}}, "fr": {"stringUnit": {"state": "translated", "value": "Je ne suis ni employé, ni sous-traitant, ni affilié de WHOOP, et je n'utilise pas NOOP pour le compte de WHOOP."}}, "it": {"stringUnit": {"state": "translated", "value": "Non sono un dipendente, un collaboratore o un affiliato di WHOOP e non utilizzo NOOP per conto di WHOOP."}}, "pl": {"stringUnit": {"state": "translated", "value": "Nie jestem pracownikiem, wykonawcą ani podmiotem powiązanym WHOOP i nie używam NOOP w imieniu WHOOP."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Não sou empregado, prestador de serviços nem afiliado da WHOOP, e não utilizo o NOOP em nome da WHOOP."}}, "ru": {"stringUnit": {"state": "translated", "value": "Я не являюсь сотрудником, подрядчиком или аффилированным лицом WHOOP и не использую NOOP от имени WHOOP."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "我不是 WHOOP 的员工、承包商或关联方,也不代表 WHOOP 使用 NOOP。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "我不是 WHOOP 的員工、承包商或關聯方,也不代表 WHOOP 使用 NOOP。"}} + } }, + "I own the WHOOP device I will use with NOOP and will only use it to access my own data. Doing so is my decision and my risk, including any effect on my WHOOP account, subscription, device, or warranty, and it may breach WHOOP's Terms of Service.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Das WHOOP-Gerät, das ich mit NOOP nutzen werde, gehört mir, und ich nutze es nur, um auf meine eigenen Daten zuzugreifen. Das ist meine Entscheidung und mein Risiko, einschließlich jeder Auswirkung auf mein WHOOP-Konto, mein Abo, das Gerät oder die Garantie, und es kann gegen WHOOPs Nutzungsbedingungen verstoßen."}}, "es": {"stringUnit": {"state": "translated", "value": "Soy propietario del dispositivo WHOOP que usaré con NOOP y solo lo usaré para acceder a mis propios datos. Hacerlo es mi decisión y mi riesgo, incluido cualquier efecto en mi cuenta WHOOP, suscripción, dispositivo o garantía, y puede infringir los Términos de Servicio de WHOOP."}}, "fr": {"stringUnit": {"state": "translated", "value": "Je suis propriétaire de l'appareil WHOOP que j'utiliserai avec NOOP et je ne m'en servirai que pour accéder à mes propres données. C'est ma décision et mon risque, y compris tout effet sur mon compte WHOOP, mon abonnement, mon appareil ou ma garantie, et cela peut enfreindre les conditions d'utilisation de WHOOP."}}, "it": {"stringUnit": {"state": "translated", "value": "Sono proprietario del dispositivo WHOOP che userò con NOOP e lo userò solo per accedere ai miei dati. È una mia decisione e un mio rischio, compreso qualsiasi effetto sul mio account WHOOP, sull'abbonamento, sul dispositivo o sulla garanzia, e può violare i Termini di Servizio di WHOOP."}}, "pl": {"stringUnit": {"state": "translated", "value": "Jestem właścicielem urządzenia WHOOP, którego użyję z NOOP, i użyję go wyłącznie w celu dostępu do własnych danych. To moja decyzja i moje ryzyko, w tym wszelki wpływ na moje konto WHOOP, subskrypcję, urządzenie lub gwarancję, i może to naruszać Warunki korzystania z usługi WHOOP."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Sou proprietário do dispositivo WHOOP que utilizarei com o NOOP e utilizá-lo-ei apenas para aceder aos meus próprios dados. Fazê-lo é a minha decisão e o meu risco, incluindo qualquer efeito na minha conta WHOOP, subscrição, dispositivo ou garantia, e pode violar os Termos de Serviço da WHOOP."}}, "ru": {"stringUnit": {"state": "translated", "value": "Я являюсь владельцем устройства WHOOP, которое буду использовать с NOOP, и буду использовать его только для доступа к своим собственным данным. Это моё решение и мой риск, включая любые последствия для моей учётной записи WHOOP, подписки, устройства или гарантии, и это может нарушать Условия обслуживания WHOOP."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "我拥有将与 NOOP 一起使用的 WHOOP 设备,并且只会用它访问我自己的数据。这样做是我自己的决定和风险,包括对我的 WHOOP 账户、订阅、设备或保修产生的任何影响,并且可能违反 WHOOP 的服务条款。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "我擁有將與 NOOP 一起使用的 WHOOP 裝置,並且只會用它存取我自己的資料。這樣做是我自己的決定和風險,包括對我的 WHOOP 帳戶、訂閱、裝置或保固產生的任何影響,並且可能違反 WHOOP 的服務條款。"}} + } }, + "I understand NOOP is unofficial and experimental, is provided free and \"as is\" with no warranty, and is not a medical device or medical advice.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Mir ist klar, dass NOOP inoffiziell und experimentell ist, kostenlos und „wie besehen“ ohne Gewährleistung bereitgestellt wird und weder ein Medizinprodukt noch medizinischer Rat ist."}}, "es": {"stringUnit": {"state": "translated", "value": "Entiendo que NOOP es no oficial y experimental, se proporciona gratis y \"como está\" sin garantía, y no es un dispositivo médico ni consejo médico."}}, "fr": {"stringUnit": {"state": "translated", "value": "Je comprends que NOOP est non officiel et expérimental, qu'il est fourni gratuitement et « en l'état » sans garantie, et qu'il n'est ni un dispositif médical ni un avis médical."}}, "it": {"stringUnit": {"state": "translated", "value": "Comprendo che NOOP non è ufficiale ed è sperimentale, è fornito gratuitamente e \"così com'è\" senza garanzia, e non è un dispositivo medico né un consiglio medico."}}, "pl": {"stringUnit": {"state": "translated", "value": "Rozumiem, że NOOP jest nieoficjalny i eksperymentalny, jest dostarczany bezpłatnie i w stanie takim, jakim jest, bez gwarancji, oraz nie jest wyrobem medycznym ani poradą medyczną."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Compreendo que o NOOP é não oficial e experimental, é fornecido gratuitamente e \"tal como está\", sem garantia, e não é um dispositivo médico nem aconselhamento médico."}}, "ru": {"stringUnit": {"state": "translated", "value": "Я понимаю, что NOOP является неофициальным и экспериментальным, предоставляется бесплатно и «как есть» без каких-либо гарантий и не является медицинским изделием или медицинской консультацией."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "我了解 NOOP 是非官方的实验性项目,免费且“按原样”提供,不提供任何保证,并且不是医疗设备或医疗建议。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "我了解 NOOP 是非官方的實驗性專案,免費且「按原樣」提供,不提供任何保證,並且不是醫療器材或醫療建議。"}} + } }, + "To the fullest extent the law allows, I will not hold the NOOP project or its contributors liable for any loss or damage arising from my use of it.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Soweit das Gesetz es zulässt, werde ich das NOOP-Projekt und seine Mitwirkenden nicht für Verluste oder Schäden haftbar machen, die aus meiner Nutzung entstehen."}}, "es": {"stringUnit": {"state": "translated", "value": "En la máxima medida permitida por la ley, no responsabilizaré al proyecto NOOP ni a sus colaboradores de ninguna pérdida o daño derivado de mi uso del mismo."}}, "fr": {"stringUnit": {"state": "translated", "value": "Dans toute la mesure permise par la loi, je ne tiendrai ni le projet NOOP ni ses contributeurs responsables de toute perte ou de tout dommage découlant de mon utilisation."}}, "it": {"stringUnit": {"state": "translated", "value": "Nella massima misura consentita dalla legge, non riterrò il progetto NOOP o i suoi contributori responsabili di perdite o danni derivanti dal mio utilizzo."}}, "pl": {"stringUnit": {"state": "translated", "value": "W maksymalnym zakresie dozwolonym przez prawo nie będę pociągać projektu NOOP ani jego współtwórców do odpowiedzialności za jakiekolwiek straty lub szkody wynikające z korzystania z niego."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Na máxima medida permitida por lei, não responsabilizarei o projeto NOOP nem os seus contribuidores por qualquer perda ou dano resultante da minha utilização."}}, "ru": {"stringUnit": {"state": "translated", "value": "В максимальной степени, допустимой законом, я не буду возлагать на проект NOOP или его участников ответственность за любые убытки или ущерб, возникшие в результате моего использования."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "在法律允许的最大范围内,我不会就因我使用 NOOP 而产生的任何损失或损害追究 NOOP 项目或其贡献者的责任。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "在法律允許的最大範圍內,我不會就因我使用 NOOP 而產生的任何損失或損害追究 NOOP 專案或其貢獻者的責任。"}} + } }, + "1m %lld": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "1 Min. %lld"}}, "es": {"stringUnit": {"state": "translated", "value": "1 min %lld"}}, "fr": {"stringUnit": {"state": "translated", "value": "1 min %lld"}}, "it": {"stringUnit": {"state": "translated", "value": "1 min %lld"}}, "pl": {"stringUnit": {"state": "translated", "value": "1 min %lld"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "1 min %lld"}}, "ru": {"stringUnit": {"state": "translated", "value": "1 мин %lld"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "1 分钟 %lld"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "1 分鐘 %lld"}} + } }, + "2m %lld": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "2 Min. %lld"}}, "es": {"stringUnit": {"state": "translated", "value": "2 min %lld"}}, "fr": {"stringUnit": {"state": "translated", "value": "2 min %lld"}}, "it": {"stringUnit": {"state": "translated", "value": "2 min %lld"}}, "pl": {"stringUnit": {"state": "translated", "value": "2 min %lld"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "2 min %lld"}}, "ru": {"stringUnit": {"state": "translated", "value": "2 мин %lld"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "2 分钟 %lld"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "2 分鐘 %lld"}} + } }, + "5m %lld": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "5 Min. %lld"}}, "es": {"stringUnit": {"state": "translated", "value": "5 min %lld"}}, "fr": {"stringUnit": {"state": "translated", "value": "5 min %lld"}}, "it": {"stringUnit": {"state": "translated", "value": "5 min %lld"}}, "pl": {"stringUnit": {"state": "translated", "value": "5 min %lld"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "5 min %lld"}}, "ru": {"stringUnit": {"state": "translated", "value": "5 мин %lld"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "5 分钟 %lld"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "5 分鐘 %lld"}} + } }, + "Close coach settings": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Coach-Einstellungen schließen"}}, "en": {"stringUnit": {"state": "translated", "value": "Close coach settings"}}, "es": {"stringUnit": {"state": "translated", "value": "Cerrar los ajustes del coach"}}, "fr": {"stringUnit": {"state": "translated", "value": "Fermer les réglages du coach"}}, "it": {"stringUnit": {"state": "translated", "value": "Chiudi le impostazioni del coach"}}, "pl": {"stringUnit": {"state": "translated", "value": "Zamknij ustawienia trenera"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Fechar as definições do coach"}}, "ru": {"stringUnit": {"state": "translated", "value": "Закрыть настройки тренера"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "关闭教练设置"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "關閉教練設定"}} + } }, + "Coach settings": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Coach-Einstellungen"}}, "en": {"stringUnit": {"state": "translated", "value": "Coach settings"}}, "es": {"stringUnit": {"state": "translated", "value": "Ajustes del coach"}}, "fr": {"stringUnit": {"state": "translated", "value": "Réglages du coach"}}, "it": {"stringUnit": {"state": "translated", "value": "Impostazioni del coach"}}, "pl": {"stringUnit": {"state": "translated", "value": "Ustawienia trenera"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Definições do coach"}}, "ru": {"stringUnit": {"state": "translated", "value": "Настройки тренера"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "教练设置"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "教練設定"}} + } }, + "Connected, authenticating…": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Verbunden, Authentifizierung läuft …" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Conectado, autenticando…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Connecté, authentification…" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Connesso, autenticazione in corso…" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Połączono, uwierzytelnianie…" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Ligado, a autenticar…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Подключено, аутентификация…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已连接,正在认证…" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已連接,正在驗證…" + } + } + } + }, + "Connected, waiting for live heart rate.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Verbunden, warte auf Live-Herzfrequenz." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Conectado, esperando la frecuencia cardíaca en vivo." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Connecté, en attente de la fréquence cardiaque en direct." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Connesso, in attesa della frequenza cardiaca in tempo reale." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Połączono, czekam na tętno na żywo." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Ligado, aguardando a frequência cardíaca em direto." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Подключено, ожидание пульса в реальном времени." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已连接,等待实时心率。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已連接,等待即時心率。" + } + } + } + }, + "Connecting to the ring…": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Verbindung mit dem Ring wird hergestellt …" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Conectando con el anillo…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Connexion à l'anneau…" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Connessione all'anello…" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Łączenie z pierścieniem…" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "A ligar ao anel…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Подключение к кольцу…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在连接戒指…" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在連接戒指…" + } + } + } + }, + "Live heart rate is flowing from the ring.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Live-Herzfrequenz kommt vom Ring." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La frecuencia cardíaca en vivo llega desde el anillo." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La fréquence cardiaque en direct arrive de l'anneau." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "La frequenza cardiaca in tempo reale arriva dall'anello." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Tętno na żywo płynie z pierścienia." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "A frequência cardíaca em direto está a chegar do anel." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пульс в реальном времени поступает с кольца." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "实时心率正从戒指传来。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "即時心率正從戒指傳來。" + } + } + } + }, + "NOOP is connecting to your strap and will sync as soon as it's ready.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "NOOP verbindet sich mit deinem Strap und synchronisiert, sobald er bereit ist." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "NOOP se está conectando a tu pulsera y sincronizará en cuanto esté lista." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "NOOP se connecte à votre bracelet et synchronisera dès qu'il sera prêt." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "NOOP si sta collegando alla fascia e sincronizzerà non appena sarà pronta." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "O NOOP está a ligar-se à sua pulseira e sincronizará assim que estiver pronta." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "NOOP подключается к браслету и синхронизирует, как только он будет готов." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "NOOP 正在连接你的手环,准备就绪后会立即同步。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "NOOP 正在連接你的手環,準備就緒後會立即同步。" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "NOOP łączy się z paskiem i zsynchronizuje go, gdy tylko będzie gotowy." + } + } + } + }, + "Reconnect drops the current link, if any, and connects to the ring again. To pair or switch bands, open Devices.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "„Neu verbinden“ trennt die aktuelle Verbindung, falls vorhanden, und verbindet den Ring erneut. Zum Koppeln oder Wechseln von Bändern öffne „Geräte“." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reconectar corta el enlace actual, si lo hay, y vuelve a conectar con el anillo. Para emparejar o cambiar de banda, abre Dispositivos." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Reconnecter coupe le lien actuel, s'il existe, et se reconnecte à l'anneau. Pour associer ou changer de bande, ouvrez Appareils." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Riconnetti interrompe il collegamento attuale, se presente, e si ricollega all'anello. Per accoppiare o cambiare fascia, apri Dispositivi." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Połącz ponownie zrywa bieżące połączenie, jeśli istnieje, i ponownie łączy się z pierścieniem. Aby sparować lub przełączyć pasma, otwórz Urządzenia." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Voltar a ligar termina a ligação atual, se existir, e liga-se novamente ao anel. Para emparelhar ou mudar de banda, abra Dispositivos." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "«Переподключить» разрывает текущее соединение, если оно есть, и снова подключается к кольцу. Чтобы выполнить сопряжение или сменить браслет, откройте раздел «Устройства»." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重新连接会断开当前连接(如果有)并再次连接戒指。要配对或切换手环,请打开「设备」。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新連接會中斷目前連線(如果有)並再次連接戒指。要配對或切換手環,請打開「裝置」。" + } + } + } + }, + "Reconnect ring": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Ring neu verbinden" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reconectar anillo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Reconnecter l'anneau" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Riconnetti anello" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Połącz ponownie pierścień" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Voltar a ligar o anel" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Переподключить кольцо" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重新连接戒指" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新連接戒指" + } + } + } + }, + "Ring not connected.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Ring nicht verbunden." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Anillo no conectado." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Anneau non connecté." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Anello non connesso." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pierścień niepołączony." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Anel não ligado." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Кольцо не подключено." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "戒指未连接。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "戒指未連接。" + } + } + } + }, + "Shows Connecting… / Syncing… with the chunk count and elapsed time while NOOP pulls history from your strap, including a sync started by the Sync Strap shortcut. Independent of the live heart rate switch above.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zeigt „Verbinde…“ / „Synchronisiere…“ mit Chunk-Anzahl und verstrichener Zeit, während NOOP Daten von deinem Strap abruft – auch bei einer Synchronisierung über den Kurzbefehl „Strap synchronisieren“. Unabhängig vom Schalter für den Live-Puls oben." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Muestra Conectando… / Sincronizando… con el número de fragmentos y el tiempo transcurrido mientras NOOP descarga el historial de tu pulsera, incluida una sincronización iniciada con el atajo Sincronizar pulsera. Independiente del interruptor de frecuencia cardíaca en vivo de arriba." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Affiche Connexion… / Synchronisation… avec le nombre de blocs et le temps écoulé pendant que NOOP récupère l'historique de votre bracelet, y compris une synchronisation lancée par le raccourci Synchroniser le bracelet. Indépendant de l'interrupteur de fréquence cardiaque en direct ci-dessus." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Mostra Connessione… / Sincronizzazione… con il numero di blocchi e il tempo trascorso mentre NOOP scarica la cronologia dalla fascia, anche per una sincronizzazione avviata dal comando rapido Sincronizza la fascia. Indipendente dall'interruttore della frequenza cardiaca in tempo reale qui sopra." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Mostra A ligar… / A sincronizar… com o número de blocos e o tempo decorrido enquanto o NOOP transfere o histórico da sua pulseira, incluindo uma sincronização iniciada pelo atalho Sincronizar pulseira. Independente do interruptor de frequência cardíaca em direto acima." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показывает «Подключение…» / «Синхронизация…» с числом блоков и прошедшим временем, пока NOOP загружает историю с браслета, в том числе при синхронизации через команду «Синхронизировать браслет». Не зависит от переключателя пульса в реальном времени выше." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "NOOP 从手环读取历史数据时显示“连接中…”/“同步中…”、数据块数量和已用时间,包括通过“同步手环”快捷指令启动的同步。与上方的实时心率开关无关。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "NOOP 從手環讀取歷史資料時顯示「連接中…」/「同步中…」、資料塊數量和已用時間,包括透過「同步手環」捷徑啟動的同步。與上方的即時心率開關無關。" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pokazuje Łączenie… / Synchronizowanie… z liczbą fragmentów i czasem trwania, gdy NOOP pobiera historię z paska, także przy synchronizacji uruchomionej skrótem Synchronizuj pasek. Niezależne od przełącznika tętna na żywo powyżej." + } + } + } + }, + "Shows sync progress on the Lock Screen and in the Dynamic Island": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zeigt den Synchronisierungsfortschritt auf dem Sperrbildschirm und in der Dynamic Island" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Muestra el progreso de la sincronización en la pantalla de bloqueo y en la Dynamic Island" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Affiche la progression de la synchronisation sur l'écran verrouillé et dans la Dynamic Island" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Mostra l'avanzamento della sincronizzazione nella schermata di blocco e nella Dynamic Island" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Mostra o progresso da sincronização no ecrã bloqueado e na Dynamic Island" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показывает ход синхронизации на экране блокировки и в Dynamic Island" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在锁定屏幕和灵动岛中显示同步进度" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在鎖定畫面和動態島中顯示同步進度" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pokazuje postęp synchronizacji na ekranie blokady i w Dynamic Island" + } + } + } + }, + "Strap sync in Dynamic Island": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Strap-Synchronisierung in der Dynamic Island" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sincronización de la pulsera en la Dynamic Island" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Synchronisation du bracelet dans la Dynamic Island" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sincronizzazione della fascia nella Dynamic Island" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Sincronização da pulseira na Dynamic Island" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Синхронизация браслета в Dynamic Island" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "灵动岛中显示手环同步" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "動態島中顯示手環同步" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Synchronizacja paska w Dynamic Island" + } + } + } + }, + "Sync didn't start": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Synchronisierung nicht gestartet" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La sincronización no se inició" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La synchronisation n'a pas démarré" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "La sincronizzazione non è partita" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "A sincronização não começou" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Синхронизация не началась" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "同步未开始" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "同步未開始" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Synchronizacja się nie rozpoczęła" + } + } + } + }, + "Sync interrupted": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Synchronisierung unterbrochen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sincronización interrumpida" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Synchronisation interrompue" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sincronizzazione interrotta" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Sincronização interrompida" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Синхронизация прервана" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "同步中断" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "同步中斷" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Synchronizacja przerwana" + } + } + } + }, + "Synced": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Synchronisiert" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sincronizado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Synchronisé" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sincronizzato" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Sincronizado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Синхронизировано" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已同步" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已同步" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zsynchronizowano" + } + } + } + }, + "Synced · %lld chunks": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Synchronisiert · %lld Chunks" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sincronizado · %lld fragmentos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Synchronisé · %lld blocs" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sincronizzato · %lld blocchi" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Sincronizado · %lld blocos" + } + }, + "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": "Zsynchronizowano · %lld fragmentów" + } + } + } + }, + "Syncing… %lld chunks": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Synchronisiere… %lld Chunks" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sincronizando… %lld fragmentos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Synchronisation… %lld blocs" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sincronizzazione… %lld blocchi" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "A sincronizar… %lld blocos" + } + }, + "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": "Synchronizowanie… %lld fragmentów" + } + } + } + }, + "What the coach may read, how it is told to answer, and when it writes to you.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Worauf der Coach zugreifen darf, wie er antworten soll und wann er dir schreibt."}}, "en": {"stringUnit": {"state": "translated", "value": "What the coach may read, how it is told to answer, and when it writes to you."}}, "es": {"stringUnit": {"state": "translated", "value": "Qué puede leer el coach, cómo se le indica que responda y cuándo te escribe."}}, "fr": {"stringUnit": {"state": "translated", "value": "Ce que le coach peut lire, comment il doit répondre et quand il vous écrit."}}, "it": {"stringUnit": {"state": "translated", "value": "Cosa può leggere il coach, come deve rispondere e quando ti scrive."}}, "pl": {"stringUnit": {"state": "translated", "value": "Co trener może odczytywać, jak ma odpowiadać i kiedy pisze do Ciebie."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "O que o coach pode ler, como lhe é pedido que responda e quando te escreve."}}, "ru": {"stringUnit": {"state": "translated", "value": "Что тренер может читать, как ему отвечать и когда он вам пишет."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "教练可以读取哪些数据、如何作答,以及何时向你发送消息。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "教練可以讀取哪些資料、如何作答,以及何時向你傳送訊息。"}} + } }, + "3 weeks": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"3 Wochen"}},"es":{"stringUnit":{"state":"translated","value":"3 semanas"}},"fr":{"stringUnit":{"state":"translated","value":"3 semaines"}},"pt-PT":{"stringUnit":{"state":"translated","value":"3 semanas"}},"pl":{"stringUnit":{"state":"translated","value":"3 tygodnie"}},"ru":{"stringUnit":{"state":"translated","value":"3 недели"}},"it":{"stringUnit":{"state":"translated","value":"3 settimane"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"3 周"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"3 週"}}}}, + "sparse, widened to %@": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"wenige Daten, erweitert auf %@"}},"es":{"stringUnit":{"state":"translated","value":"pocos datos, ampliado a %@"}},"fr":{"stringUnit":{"state":"translated","value":"peu de données, période étendue à %@"}},"pt-PT":{"stringUnit":{"state":"translated","value":"poucos dados, alargado para %@"}},"pl":{"stringUnit":{"state":"translated","value":"mało danych, rozszerzono do %@"}},"ru":{"stringUnit":{"state":"translated","value":"мало данных, период расширен до %@"}},"it":{"stringUnit":{"state":"translated","value":"pochi dati, periodo esteso a %@"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"数据较少,已扩展至 %@"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"資料較少,已擴展至 %@"}}}}, + "30-day step average": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"30-Tage-Schrittedurchschnitt"}},"es":{"stringUnit":{"state":"translated","value":"Promedio de pasos de 30 días"}},"fr":{"stringUnit":{"state":"translated","value":"Moyenne des pas sur 30 jours"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Média de passos de 30 dias"}},"pl":{"stringUnit":{"state":"translated","value":"Średnia kroków z 30 dni"}},"ru":{"stringUnit":{"state":"translated","value":"Среднее число шагов за 30 дней"}},"it":{"stringUnit":{"state":"translated","value":"Media dei passi su 30 giorni"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"30 天平均步数"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"30 天平均步數"}}}}, + "Rolling average over the last 30 days": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Fließender Durchschnitt der letzten 30 Tage"}},"es":{"stringUnit":{"state":"translated","value":"Promedio móvil de los últimos 30 días"}},"fr":{"stringUnit":{"state":"translated","value":"Moyenne glissante sur les 30 derniers jours"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Média móvel dos últimos 30 dias"}},"pl":{"stringUnit":{"state":"translated","value":"Średnia krocząca z ostatnich 30 dni"}},"ru":{"stringUnit":{"state":"translated","value":"Скользящее среднее за последние 30 дней"}},"it":{"stringUnit":{"state":"translated","value":"Media mobile degli ultimi 30 giorni"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"最近 30 天的滚动平均值"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"最近 30 天的移動平均值"}}}}, + "%lld of 30 days": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"%lld von 30 Tagen"}},"es":{"stringUnit":{"state":"translated","value":"%lld de 30 días"}},"fr":{"stringUnit":{"state":"translated","value":"%lld jours sur 30"}},"pt-PT":{"stringUnit":{"state":"translated","value":"%lld de 30 dias"}},"pl":{"stringUnit":{"state":"translated","value":"Liczba dni: %lld z 30"}},"ru":{"stringUnit":{"state":"translated","value":"Дней: %lld из 30"}},"it":{"stringUnit":{"state":"translated","value":"%lld giorni su 30"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"30 天中的 %lld 天"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"30 天中的 %lld 天"}}}}, + "Historical trend": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Historischer Verlauf"}},"es":{"stringUnit":{"state":"translated","value":"Tendencia histórica"}},"fr":{"stringUnit":{"state":"translated","value":"Évolution historique"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Tendência histórica"}},"pl":{"stringUnit":{"state":"translated","value":"Trend historyczny"}},"ru":{"stringUnit":{"state":"translated","value":"История изменений"}},"it":{"stringUnit":{"state":"translated","value":"Andamento storico"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"历史趋势"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"歷史趨勢"}}}}, + "bar": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Balken"}},"es":{"stringUnit":{"state":"translated","value":"barra"}},"fr":{"stringUnit":{"state":"translated","value":"barre"}},"pt-PT":{"stringUnit":{"state":"translated","value":"barra"}},"pl":{"stringUnit":{"state":"translated","value":"słupek"}},"ru":{"stringUnit":{"state":"translated","value":"столбец"}},"it":{"stringUnit":{"state":"translated","value":"barra"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"柱形"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"長條"}}}}, + "bars": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Balken"}},"es":{"stringUnit":{"state":"translated","value":"barras"}},"fr":{"stringUnit":{"state":"translated","value":"barres"}},"pt-PT":{"stringUnit":{"state":"translated","value":"barras"}},"pl":{"stringUnit":{"state":"translated","value":"słupki"}},"ru":{"stringUnit":{"state":"translated","value":"столбцы"}},"it":{"stringUnit":{"state":"translated","value":"barre"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"柱形"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"長條"}}}}, + "week of %@": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Woche ab %@"}},"es":{"stringUnit":{"state":"translated","value":"semana del %@"}},"fr":{"stringUnit":{"state":"translated","value":"semaine du %@"}},"pt-PT":{"stringUnit":{"state":"translated","value":"semana de %@"}},"pl":{"stringUnit":{"state":"translated","value":"tydzień od %@"}},"ru":{"stringUnit":{"state":"translated","value":"неделя с %@"}},"it":{"stringUnit":{"state":"translated","value":"settimana del %@"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"%@ 所在周"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"%@ 所在週"}}}}, + "2W": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"2W"}},"es":{"stringUnit":{"state":"translated","value":"2S"}},"fr":{"stringUnit":{"state":"translated","value":"2S"}},"pt-PT":{"stringUnit":{"state":"translated","value":"2S"}},"pl":{"stringUnit":{"state":"translated","value":"2T"}},"ru":{"stringUnit":{"state":"translated","value":"2Н"}},"it":{"stringUnit":{"state":"translated","value":"2S"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"2周"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"2週"}}}}, + "3W": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"3W"}},"es":{"stringUnit":{"state":"translated","value":"3S"}},"fr":{"stringUnit":{"state":"translated","value":"3S"}},"pt-PT":{"stringUnit":{"state":"translated","value":"3S"}},"pl":{"stringUnit":{"state":"translated","value":"3T"}},"ru":{"stringUnit":{"state":"translated","value":"3Н"}},"it":{"stringUnit":{"state":"translated","value":"3S"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"3周"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"3週"}}}}, + "%lld daily %@ · %@": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"%1$lld tägliche %2$@ · %3$@"}},"es":{"stringUnit":{"state":"translated","value":"%lld %@ por día · %@"}},"fr":{"stringUnit":{"state":"translated","value":"%lld %@ par jour · %@"}},"pt-PT":{"stringUnit":{"state":"translated","value":"%lld %@ por dia · %@"}},"pl":{"stringUnit":{"state":"translated","value":"dziennie — %2$@: %1$lld · %3$@"}},"ru":{"stringUnit":{"state":"translated","value":"по дням — %2$@: %1$lld · %3$@"}},"it":{"stringUnit":{"state":"translated","value":"%lld %@ per giorno · %@"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"%lld 个每日%@ · %@"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"%lld 個每日%@ · %@"}}}}, + "%lld weekly %@ · average per observed day · %@": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"%1$lld wöchentliche %2$@ · Durchschnitt je erfasstem Tag · %3$@"}},"es":{"stringUnit":{"state":"translated","value":"%lld %@ por semana · promedio por día registrado · %@"}},"fr":{"stringUnit":{"state":"translated","value":"%lld %@ par semaine · moyenne par jour enregistré · %@"}},"pt-PT":{"stringUnit":{"state":"translated","value":"%lld %@ por semana · média por dia registado · %@"}},"pl":{"stringUnit":{"state":"translated","value":"tygodniowo — %2$@: %1$lld · średnia na zarejestrowany dzień · %3$@"}},"ru":{"stringUnit":{"state":"translated","value":"по неделям — %2$@: %1$lld · среднее за день с данными · %3$@"}},"it":{"stringUnit":{"state":"translated","value":"%lld %@ per settimana · media per giorno registrato · %@"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"%lld 个每周%@ · 有记录日的平均值 · %@"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"%lld 個每週%@ · 有紀錄日的平均值 · %@"}}}}, + "%lld monthly %@ · average per observed day · %@": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"%1$lld monatliche %2$@ · Durchschnitt je erfasstem Tag · %3$@"}},"es":{"stringUnit":{"state":"translated","value":"%lld %@ por mes · promedio por día registrado · %@"}},"fr":{"stringUnit":{"state":"translated","value":"%lld %@ par mois · moyenne par jour enregistré · %@"}},"pt-PT":{"stringUnit":{"state":"translated","value":"%lld %@ por mês · média por dia registado · %@"}},"pl":{"stringUnit":{"state":"translated","value":"miesięcznie — %2$@: %1$lld · średnia na zarejestrowany dzień · %3$@"}},"ru":{"stringUnit":{"state":"translated","value":"по месяцам — %2$@: %1$lld · среднее за день с данными · %3$@"}},"it":{"stringUnit":{"state":"translated","value":"%lld %@ per mese · media per giorno registrato · %@"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"%lld 个每月%@ · 有记录日的平均值 · %@"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"%lld 個每月%@ · 有紀錄日的平均值 · %@"}}}}, + "%@ steps": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"%@ Schritte"}},"es":{"stringUnit":{"state":"translated","value":"%@ pasos"}},"fr":{"stringUnit":{"state":"translated","value":"%@ pas"}},"pt-PT":{"stringUnit":{"state":"translated","value":"%@ passos"}},"pl":{"stringUnit":{"state":"translated","value":"Liczba kroków: %@"}},"ru":{"stringUnit":{"state":"translated","value":"Шаги: %@"}},"it":{"stringUnit":{"state":"translated","value":"%@ passi"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"%@ 步"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"%@ 步"}}}}, + "%@ average steps per observed day": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"%@ Schritte im Durchschnitt je erfasstem Tag"}},"es":{"stringUnit":{"state":"translated","value":"%@ pasos de promedio por día registrado"}},"fr":{"stringUnit":{"state":"translated","value":"%@ pas en moyenne par jour enregistré"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Média de %@ passos por dia registado"}},"pl":{"stringUnit":{"state":"translated","value":"Średnio %@ kroków na zarejestrowany dzień"}},"ru":{"stringUnit":{"state":"translated","value":"В среднем %@ шагов за день с данными"}},"it":{"stringUnit":{"state":"translated","value":"Media di %@ passi per giorno registrato"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"有记录日平均 %@ 步"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"有紀錄日平均 %@ 步"}}}}, + "Steps chart, no data": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Schrittdiagramm, keine Daten"}},"es":{"stringUnit":{"state":"translated","value":"Gráfico de pasos, sin datos"}},"fr":{"stringUnit":{"state":"translated","value":"Graphique des pas, aucune donnée"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Gráfico de passos, sem dados"}},"pl":{"stringUnit":{"state":"translated","value":"Wykres kroków, brak danych"}},"ru":{"stringUnit":{"state":"translated","value":"График шагов, нет данных"}},"it":{"stringUnit":{"state":"translated","value":"Grafico dei passi, nessun dato"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"步数图表,无数据"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"步數圖表,無資料"}}}}, + "Steps chart, %lld daily %@, latest %lld steps, %@": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Schrittdiagramm, %1$lld tägliche %2$@, zuletzt %3$lld Schritte, %4$@"}},"es":{"stringUnit":{"state":"translated","value":"Gráfico de pasos, %lld %@ por día, último registro: %lld pasos, %@"}},"fr":{"stringUnit":{"state":"translated","value":"Graphique des pas, %lld %@ par jour, dernier relevé : %lld pas, %@"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Gráfico de passos, %lld %@ por dia, último registo: %lld passos, %@"}},"pl":{"stringUnit":{"state":"translated","value":"Wykres kroków, dziennie — %2$@: %1$lld, ostatnia liczba kroków: %3$lld, %4$@"}},"ru":{"stringUnit":{"state":"translated","value":"График шагов, по дням — %2$@: %1$lld, последнее число шагов: %3$lld, %4$@"}},"it":{"stringUnit":{"state":"translated","value":"Grafico dei passi, %lld %@ per giorno, ultima rilevazione: %lld passi, %@"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"步数图表,%lld 个每日%@,最新 %lld 步,%@"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"步數圖表,%lld 個每日%@,最新 %lld 步,%@"}}}}, + "Steps chart, %lld weekly %@, latest %lld average steps per observed day, %@": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Schrittdiagramm, %1$lld wöchentliche %2$@, zuletzt %3$lld Schritte im Durchschnitt je erfasstem Tag, %4$@"}},"es":{"stringUnit":{"state":"translated","value":"Gráfico de pasos, %lld %@ por semana, último promedio: %lld pasos por día registrado, %@"}},"fr":{"stringUnit":{"state":"translated","value":"Graphique des pas, %lld %@ par semaine, dernière moyenne : %lld pas par jour enregistré, %@"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Gráfico de passos, %lld %@ por semana, última média: %lld passos por dia registado, %@"}},"pl":{"stringUnit":{"state":"translated","value":"Wykres kroków, tygodniowo — %2$@: %1$lld, ostatnia średnia: %3$lld kroków na zarejestrowany dzień, %4$@"}},"ru":{"stringUnit":{"state":"translated","value":"График шагов, по неделям — %2$@: %1$lld, последнее среднее: %3$lld шагов за день с данными, %4$@"}},"it":{"stringUnit":{"state":"translated","value":"Grafico dei passi, %lld %@ per settimana, ultima media: %lld passi per giorno registrato, %@"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"步数图表,%lld 个每周%@,最新有记录日平均 %lld 步,%@"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"步數圖表,%lld 個每週%@,最新有紀錄日平均 %lld 步,%@"}}}}, + "Steps chart, %lld monthly %@, latest %lld average steps per observed day, %@": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"Schrittdiagramm, %1$lld monatliche %2$@, zuletzt %3$lld Schritte im Durchschnitt je erfasstem Tag, %4$@"}},"es":{"stringUnit":{"state":"translated","value":"Gráfico de pasos, %lld %@ por mes, último promedio: %lld pasos por día registrado, %@"}},"fr":{"stringUnit":{"state":"translated","value":"Graphique des pas, %lld %@ par mois, dernière moyenne : %lld pas par jour enregistré, %@"}},"pt-PT":{"stringUnit":{"state":"translated","value":"Gráfico de passos, %lld %@ por mês, última média: %lld passos por dia registado, %@"}},"pl":{"stringUnit":{"state":"translated","value":"Wykres kroków, miesięcznie — %2$@: %1$lld, ostatnia średnia: %3$lld kroków na zarejestrowany dzień, %4$@"}},"ru":{"stringUnit":{"state":"translated","value":"График шагов, по месяцам — %2$@: %1$lld, последнее среднее: %3$lld шагов за день с данными, %4$@"}},"it":{"stringUnit":{"state":"translated","value":"Grafico dei passi, %lld %@ per mese, ultima media: %lld passi per giorno registrato, %@"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"步数图表,%lld 个每月%@,最新有记录日平均 %lld 步,%@"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"步數圖表,%lld 個每月%@,最新有紀錄日平均 %lld 步,%@"}}}}, + "1 observed day": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"1 erfasster Tag"}},"es":{"stringUnit":{"state":"translated","value":"1 día registrado"}},"fr":{"stringUnit":{"state":"translated","value":"1 jour enregistré"}},"pt-PT":{"stringUnit":{"state":"translated","value":"1 dia registado"}},"pl":{"stringUnit":{"state":"translated","value":"1 zarejestrowany dzień"}},"ru":{"stringUnit":{"state":"translated","value":"1 день с данными"}},"it":{"stringUnit":{"state":"translated","value":"1 giorno registrato"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"1 天有记录"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"1 天有紀錄"}}}}, + "%lld observed days": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"%lld erfasste Tage"}},"es":{"stringUnit":{"state":"translated","value":"%lld días registrados"}},"fr":{"stringUnit":{"state":"translated","value":"%lld jours enregistrés"}},"pt-PT":{"stringUnit":{"state":"translated","value":"%lld dias registados"}},"pl":{"stringUnit":{"state":"translated","value":"Zarejestrowane dni: %lld"}},"ru":{"stringUnit":{"state":"translated","value":"Дней с данными: %lld"}},"it":{"stringUnit":{"state":"translated","value":"%lld giorni registrati"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"%lld 天有记录"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"%lld 天有紀錄"}}}}, + "1 week · averages per observed day": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"1 Woche · Durchschnitt je erfasstem Tag"}},"es":{"stringUnit":{"state":"translated","value":"1 semana · promedios por día registrado"}},"fr":{"stringUnit":{"state":"translated","value":"1 semaine · moyennes par jour enregistré"}},"pt-PT":{"stringUnit":{"state":"translated","value":"1 semana · médias por dia registado"}},"pl":{"stringUnit":{"state":"translated","value":"1 tydzień · średnie na zarejestrowany dzień"}},"ru":{"stringUnit":{"state":"translated","value":"1 неделя · средние за день с данными"}},"it":{"stringUnit":{"state":"translated","value":"1 settimana · medie per giorno registrato"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"1 周 · 有记录日的平均值"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"1 週 · 有紀錄日的平均值"}}}}, + "%lld weeks · averages per observed day": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"%lld Wochen · Durchschnitt je erfasstem Tag"}},"es":{"stringUnit":{"state":"translated","value":"%lld semanas · promedios por día registrado"}},"fr":{"stringUnit":{"state":"translated","value":"%lld semaines · moyennes par jour enregistré"}},"pt-PT":{"stringUnit":{"state":"translated","value":"%lld semanas · médias por dia registado"}},"pl":{"stringUnit":{"state":"translated","value":"Liczba tygodni: %lld · średnie na zarejestrowany dzień"}},"ru":{"stringUnit":{"state":"translated","value":"Недель: %lld · средние за день с данными"}},"it":{"stringUnit":{"state":"translated","value":"%lld settimane · medie per giorno registrato"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"%lld 周 · 有记录日的平均值"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"%lld 週 · 有紀錄日的平均值"}}}}, + "1 month · averages per observed day": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"1 Monat · Durchschnitt je erfasstem Tag"}},"es":{"stringUnit":{"state":"translated","value":"1 mes · promedios por día registrado"}},"fr":{"stringUnit":{"state":"translated","value":"1 mois · moyennes par jour enregistré"}},"pt-PT":{"stringUnit":{"state":"translated","value":"1 mês · médias por dia registado"}},"pl":{"stringUnit":{"state":"translated","value":"1 miesiąc · średnie na zarejestrowany dzień"}},"ru":{"stringUnit":{"state":"translated","value":"1 месяц · средние за день с данными"}},"it":{"stringUnit":{"state":"translated","value":"1 mese · medie per giorno registrato"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"1 个月 · 有记录日的平均值"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"1 個月 · 有紀錄日的平均值"}}}}, + "%lld months · averages per observed day": {"localizations":{"de":{"stringUnit":{"state":"translated","value":"%lld Monate · Durchschnitt je erfasstem Tag"}},"es":{"stringUnit":{"state":"translated","value":"%lld meses · promedios por día registrado"}},"fr":{"stringUnit":{"state":"translated","value":"%lld mois · moyennes par jour enregistré"}},"pt-PT":{"stringUnit":{"state":"translated","value":"%lld meses · médias por dia registado"}},"pl":{"stringUnit":{"state":"translated","value":"Liczba miesięcy: %lld · średnie na zarejestrowany dzień"}},"ru":{"stringUnit":{"state":"translated","value":"Месяцев: %lld · средние за день с данными"}},"it":{"stringUnit":{"state":"translated","value":"%lld mesi · medie per giorno registrato"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"%lld 个月 · 有记录日的平均值"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"%lld 個月 · 有紀錄日的平均值"}}}}, + "%lld working sets · compare when you repeat this program": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "%lld Arbeitssätze · vergleichbar, wenn du dieses Programm wiederholst"}}, "es": {"stringUnit": {"state": "translated", "value": "%lld series efectivas · compara al repetir este programa"}}, "fr": {"stringUnit": {"state": "translated", "value": "%lld séries de travail · à comparer quand tu refais ce programme"}}, "it": {"stringUnit": {"state": "translated", "value": "%lld serie di lavoro · confronta quando ripeti questo programma"}}, "pl": {"stringUnit": {"state": "translated", "value": "%lld serii roboczych · porównuj, gdy powtarzasz ten program"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "%lld séries de trabalho · compara quando repetires este programa"}}, "ru": {"stringUnit": {"state": "translated", "value": "%lld рабочих подходов · сравнивай при повторе этой программы"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "%lld 个正式组 · 重复同一计划时才可比较"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "%lld 個正式組 · 重複同一計畫時才可比較"}} + } }, + "%@ in sets": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "%@ in Sätzen"}}, "es": {"stringUnit": {"state": "translated", "value": "%@ en series"}}, "fr": {"stringUnit": {"state": "translated", "value": "%@ en séries"}}, "it": {"stringUnit": {"state": "translated", "value": "%@ nelle serie"}}, "pl": {"stringUnit": {"state": "translated", "value": "%@ w seriach"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "%@ em séries"}}, "ru": {"stringUnit": {"state": "translated", "value": "%@ в подходах"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "%@ 在组内"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "%@ 在組內"}} + } }, + "Holds the screen awake while NOOP pulls stored history from your strap, so you can watch a long sync finish without the phone locking. Only applies while a sync is running and NOOP is open. The screen sleeps normally the rest of the time. It uses a bit more battery while the screen stays on.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Hält den Bildschirm wach, während NOOP gespeicherte Daten von deinem Strap abruft, damit du eine lange Synchronisierung bis zum Ende verfolgen kannst, ohne dass sich das Telefon sperrt. Gilt nur, solange eine Synchronisierung läuft und NOOP geöffnet ist. Sonst schaltet sich der Bildschirm normal ab. Solange der Bildschirm an bleibt, verbraucht das etwas mehr Akku." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mantiene la pantalla activa mientras NOOP descarga el historial guardado de tu pulsera, para que puedas ver terminar una sincronización larga sin que el teléfono se bloquee. Solo se aplica mientras hay una sincronización en curso y NOOP está abierto. El resto del tiempo la pantalla se apaga con normalidad. Consume algo más de batería mientras la pantalla sigue encendida." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Garde l'écran allumé pendant que NOOP récupère l'historique enregistré de votre bracelet, pour suivre une longue synchronisation jusqu'au bout sans que le téléphone se verrouille. Ne s'applique que pendant une synchronisation, lorsque NOOP est ouvert. Le reste du temps, l'écran se met en veille normalement. Consomme un peu plus de batterie tant que l'écran reste allumé." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Mantiene lo schermo attivo mentre NOOP scarica la cronologia salvata dalla fascia, così puoi seguire una sincronizzazione lunga fino alla fine senza che il telefono si blocchi. Vale solo mentre è in corso una sincronizzazione e NOOP è aperto. Per il resto del tempo lo schermo si spegne normalmente. Consuma un po' più di batteria finché lo schermo resta acceso." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Mantém o ecrã ativo enquanto o NOOP transfere o histórico guardado da sua pulseira, para poder acompanhar uma sincronização longa até ao fim sem o telefone bloquear. Só se aplica enquanto decorre uma sincronização e o NOOP está aberto. No resto do tempo o ecrã desliga-se normalmente. Gasta um pouco mais de bateria enquanto o ecrã fica ligado." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не даёт экрану погаснуть, пока NOOP загружает сохранённую историю с браслета, чтобы вы могли дождаться окончания долгой синхронизации без блокировки телефона. Действует только во время синхронизации при открытом NOOP. В остальное время экран гаснет как обычно. Пока экран включён, расход батареи немного выше." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "NOOP 从手环读取已存储的历史数据时保持屏幕常亮,这样你可以看着较长的同步完成,而手机不会锁定。仅在同步进行中且 NOOP 处于打开状态时生效,其余时间屏幕照常休眠。屏幕保持常亮期间会多耗一些电量。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "NOOP 從手環讀取已儲存的歷史資料時保持螢幕常亮,讓你可以看著較長的同步完成,而手機不會鎖定。僅在同步進行中且 NOOP 開啟時生效,其餘時間螢幕照常休眠。螢幕保持常亮期間會多耗一些電量。" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Utrzymuje ekran włączony, gdy NOOP pobiera zapisaną historię z paska, aby można było zobaczyć koniec długiej synchronizacji bez blokady telefonu. Działa tylko w trakcie synchronizacji, gdy NOOP jest otwarty. Poza tym ekran wygasa normalnie. Gdy ekran pozostaje włączony, zużywa nieco więcej baterii." + } + } + } + }, + "How NOOP behaves while it pulls stored history from your strap.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Wie sich NOOP verhält, während es gespeicherte Daten von deinem Strap abruft." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cómo se comporta NOOP mientras descarga el historial guardado de tu pulsera." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le comportement de NOOP pendant qu'il récupère l'historique enregistré de votre bracelet." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Come si comporta NOOP mentre scarica la cronologia salvata dalla fascia." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Como o NOOP se comporta enquanto transfere o histórico guardado da sua pulseira." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Как ведёт себя NOOP, пока загружает сохранённую историю с браслета." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "NOOP 从手环读取已存储的历史数据时的行为。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "NOOP 從手環讀取已儲存的歷史資料時的行為。" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Jak NOOP działa podczas pobierania zapisanej historii z paska." + } + } + } + }, + "Keep screen on while syncing": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Bildschirm während der Synchronisierung anlassen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mantener la pantalla encendida durante la sincronización" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Garder l'écran allumé pendant la synchronisation" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Mantieni lo schermo acceso durante la sincronizzazione" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Manter o ecrã ligado durante a sincronização" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не выключать экран во время синхронизации" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "同步期间保持屏幕常亮" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "同步期間保持螢幕常亮" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Trzymaj ekran włączony podczas synchronizacji" + } + } + } + }, + "Last 7 days · estimated": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Letzte 7 Tage · geschätzt"}}, "es": {"stringUnit": {"state": "translated", "value": "Últimos 7 días · estimado"}}, "fr": {"stringUnit": {"state": "translated", "value": "7 derniers jours · estimé"}}, "it": {"stringUnit": {"state": "translated", "value": "Ultimi 7 giorni · stimato"}}, "pl": {"stringUnit": {"state": "translated", "value": "Ostatnie 7 dni · szacunkowo"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Últimos 7 dias · estimado"}}, "ru": {"stringUnit": {"state": "translated", "value": "Последние 7 дней · оценка"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "最近 7 天 · 估算"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "最近 7 天 · 估算"}} + } }, + "NOOP couldn't start the sync. Open NOOP to see the strap log.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "NOOP konnte die Synchronisierung nicht starten. Öffne NOOP, um das Strap-Protokoll zu sehen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "NOOP no pudo iniciar la sincronización. Abre NOOP para ver el registro de la pulsera." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "NOOP n'a pas pu lancer la synchronisation. Ouvrez NOOP pour consulter le journal du bracelet." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "NOOP non è riuscito ad avviare la sincronizzazione. Apri NOOP per vedere il registro della fascia." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "O NOOP não conseguiu iniciar a sincronização. Abra o NOOP para ver o registo da pulseira." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "NOOP не удалось начать синхронизацию. Откройте NOOP, чтобы посмотреть журнал браслета." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "NOOP 无法开始同步。打开 NOOP 查看手环日志。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "NOOP 無法開始同步。打開 NOOP 查看手環日誌。" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "NOOP nie mógł rozpocząć synchronizacji. Otwórz NOOP, aby zobaczyć dziennik paska." + } + } + } + }, + "Pull your WHOOP strap's stored history into NOOP now.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Die gespeicherten Daten deines WHOOP-Straps jetzt in NOOP übertragen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Trae ahora a NOOP el historial guardado de tu pulsera WHOOP." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Récupérer maintenant dans NOOP l'historique enregistré de votre bracelet WHOOP." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Trasferisci ora in NOOP la cronologia salvata della tua fascia WHOOP." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Transferir agora para o NOOP o histórico guardado da sua pulseira WHOOP." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Загрузить в NOOP сохранённую историю браслета WHOOP сейчас." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "立即将 WHOOP 手环中保存的历史数据同步到 NOOP。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "立即將 WHOOP 手環中儲存的歷史資料同步到 NOOP。" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pobierz teraz do NOOP zapisaną historię z paska WHOOP." + } + } + } + }, + "Stops the screen locking while your strap's history syncs": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Verhindert, dass sich der Bildschirm sperrt, während der Verlauf deines Straps synchronisiert wird" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Evita que la pantalla se bloquee mientras se sincroniza el historial de tu pulsera" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Empêche l'écran de se verrouiller pendant la synchronisation de l'historique de votre bracelet" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Impedisce il blocco dello schermo mentre si sincronizza la cronologia della fascia" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Impede o bloqueio do ecrã enquanto o histórico da pulseira é sincronizado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не даёт экрану заблокироваться, пока синхронизируется история браслета" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "手环历史数据同步期间防止屏幕锁定" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "手環歷史資料同步期間防止螢幕鎖定" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zapobiega blokadzie ekranu podczas synchronizacji historii paska" + } + } + } + }, + "Sync Strap": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Strap synchronisieren" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sincronizar pulsera" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Synchroniser le bracelet" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sincronizza la fascia" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Sincronizar pulseira" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Синхронизировать браслет" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "同步手环" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "同步手環" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Synchronizuj pasek" + } + } + } + }, + "Sync your strap, buzz it or mark a moment from Siri, Spotlight, the Shortcuts app, or a Back-Tap / automation. No setup needed.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Synchronisiere deinen Strap, lass ihn vibrieren oder markiere einen Moment – über Siri, Spotlight, die Kurzbefehle-App oder Back-Tap / eine Automation. Keine Einrichtung nötig." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sincroniza tu pulsera, hazla vibrar o marca un momento desde Siri, Spotlight, la app Atajos o con Back Tap / una automatización. Sin configuración." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Synchronisez votre bracelet, faites-le vibrer ou marquez un moment depuis Siri, Spotlight, l'app Raccourcis, ou via Toucher le dos / une automatisation. Aucune configuration requise." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sincronizza la fascia, falla vibrare o segna un momento da Siri, Spotlight, l'app Comandi Rapidi, oppure con Tocco posteriore / un'automazione. Nessuna configurazione." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Sincronize a pulseira, faça-a vibrar ou marque um momento a partir da Siri, do Spotlight, da app Atalhos, ou com Toque posterior / uma automatização. Sem configuração." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Синхронизируйте браслет, включите вибрацию или отметьте момент через Siri, Spotlight, приложение «Команды», касание задней панели или автоматизацию. Настройка не нужна." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "通过 Siri、Spotlight、快捷指令 App,或轻点背面 / 自动化,同步手环、让它震动或标记时刻。无需设置。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "透過 Siri、Spotlight、捷徑 App,或背面輕點 / 自動化,同步手環、讓它震動或標記時刻。無需設定。" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Synchronizuj pasek, włącz wibrację lub zaznacz chwilę z Siri, Spotlight, aplikacji Skróty, stuknięcia w tył telefonu / automatyzacji. Bez konfiguracji." + } + } + } + }, + "Syncing your strap.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Dein Strap wird synchronisiert." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sincronizando tu pulsera." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Synchronisation de votre bracelet en cours." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sincronizzazione della fascia in corso." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "A sincronizar a sua pulseira." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Синхронизация браслета запущена." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在同步你的手环。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在同步你的手環。" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Trwa synchronizacja paska." + } + } + } + }, + "This session · estimated": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Diese Einheit · geschätzt"}}, "es": {"stringUnit": {"state": "translated", "value": "Esta sesión · estimado"}}, "fr": {"stringUnit": {"state": "translated", "value": "Cette séance · estimé"}}, "it": {"stringUnit": {"state": "translated", "value": "Questa sessione · stimato"}}, "pl": {"stringUnit": {"state": "translated", "value": "Ta sesja · szacunkowo"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Esta sessão · estimado"}}, "ru": {"stringUnit": {"state": "translated", "value": "Эта тренировка · оценка"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "本次训练 · 估算"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "本次訓練 · 估算"}} + } }, + "Counted from the muscles you assigned each exercise: direct sets count once, indirect ones count as a half — the method the reference figures were derived under. An estimate from your own labels, not something measured off your body.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Gezählt nach den Muskeln, die du jeder Übung zugeordnet hast: direkte Sätze zählen einfach, indirekte als halber Satz — die Methode, unter der die Referenzwerte ermittelt wurden. Eine Schätzung aus deinen eigenen Angaben, nichts an deinem Körper Gemessenes."}}, "es": {"stringUnit": {"state": "translated", "value": "Se cuenta según los músculos que asignaste a cada ejercicio: las series directas cuentan una vez y las indirectas como media — el método con el que se obtuvieron las cifras de referencia. Es una estimación a partir de tus propias etiquetas, no algo medido en tu cuerpo."}}, "fr": {"stringUnit": {"state": "translated", "value": "Compté d'après les muscles que tu as attribués à chaque exercice : les séries directes comptent une fois, les indirectes pour moitié — la méthode qui a servi à établir les valeurs de référence. Une estimation issue de tes propres libellés, pas une mesure prise sur ton corps."}}, "it": {"stringUnit": {"state": "translated", "value": "Conteggiato dai muscoli che hai assegnato a ogni esercizio: le serie dirette contano una volta, quelle indirette metà — il metodo con cui sono stati ricavati i valori di riferimento. Una stima dalle tue etichette, non qualcosa misurato sul tuo corpo."}}, "pl": {"stringUnit": {"state": "translated", "value": "Liczone na podstawie mięśni przypisanych do każdego ćwiczenia: serie bezpośrednie liczą się raz, pośrednie za pół — metoda, na której oparto wartości odniesienia. To szacunek z twoich własnych oznaczeń, a nie pomiar na twoim ciele."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Contado a partir dos músculos que atribuíste a cada exercício: as séries diretas contam uma vez e as indiretas metade — o método com que os valores de referência foram obtidos. É uma estimativa a partir das tuas etiquetas, não algo medido no teu corpo."}}, "ru": {"stringUnit": {"state": "translated", "value": "Считается по мышцам, которые ты назначил каждому упражнению: прямые подходы идут за один, косвенные за половину — метод, по которому получены эталонные значения. Это оценка по твоим же меткам, а не измерение тела."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "根据你为每个动作指定的肌群统计:直接组算一组,间接组算半组——参考数值正是用这个方法得出的。这是基于你自己标注的估算,而非在你身上测得的数据。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "根據你為每個動作指定的肌群統計:直接組算一組,間接組算半組——參考數值正是用這個方法得出的。這是基於你自己標註的估算,而非在你身上測得的數據。"}} + } }, + "Counted from the muscles you assigned each exercise: direct sets count once, indirect ones half. The tick is about 4 sets a week — below that, studies across GROUPS of people stop reliably detecting growth. It is a research reference, not a target for you, and above it gains continue with strongly diminishing returns and no clear ceiling, so the bar has no \"full\".": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Gezählt nach den Muskeln, die du jeder Übung zugeordnet hast: direkte Sätze zählen einfach, indirekte halb. Der Strich markiert etwa 4 Sätze pro Woche — darunter weisen Studien über GRUPPEN von Menschen Wachstum nicht mehr zuverlässig nach. Das ist ein Forschungsbezug, kein Ziel für dich; darüber geht es weiter, mit stark abnehmendem Ertrag und ohne klare Obergrenze, der Balken hat deshalb kein „voll“."}}, "es": {"stringUnit": {"state": "translated", "value": "Se cuenta según los músculos que asignaste a cada ejercicio: las series directas cuentan una vez y las indirectas media. La marca señala unas 4 series por semana: por debajo, los estudios sobre GRUPOS de personas dejan de detectar crecimiento de forma fiable. Es una referencia de investigación, no un objetivo para ti; por encima se sigue ganando, con rendimientos muy decrecientes y sin techo claro, así que la barra no tiene «lleno»."}}, "fr": {"stringUnit": {"state": "translated", "value": "Compté d'après les muscles que tu as attribués à chaque exercice : les séries directes comptent une fois, les indirectes pour moitié. Le repère marque environ 4 séries par semaine : en dessous, les études portant sur des GROUPES de personnes ne détectent plus la croissance de façon fiable. C'est une référence issue de la recherche, pas un objectif pour toi ; au-dessus, les gains continuent, avec des rendements très décroissants et sans plafond net, donc la barre n'a pas de « plein »."}}, "it": {"stringUnit": {"state": "translated", "value": "Conteggiato dai muscoli che hai assegnato a ogni esercizio: le serie dirette contano una volta, quelle indirette metà. La tacca segna circa 4 serie a settimana: sotto quella soglia gli studi su GRUPPI di persone non rilevano più la crescita in modo affidabile. È un riferimento della ricerca, non un obiettivo per te; sopra i guadagni continuano, con rendimenti fortemente decrescenti e senza un tetto chiaro, quindi la barra non ha un «pieno»."}}, "pl": {"stringUnit": {"state": "translated", "value": "Liczone na podstawie mięśni przypisanych do każdego ćwiczenia: serie bezpośrednie liczą się raz, pośrednie za pół. Znacznik to około 4 serie tygodniowo — poniżej badania na GRUPACH ludzi przestają wiarygodnie wykrywać wzrost. To odniesienie z badań, a nie cel dla ciebie; powyżej przyrosty trwają, przy mocno malejących zyskach i bez wyraźnego sufitu, więc pasek nie ma „pełna”."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Contado a partir dos músculos que atribuíste a cada exercício: as séries diretas contam uma vez e as indiretas metade. A marca assinala cerca de 4 séries por semana — abaixo disso, os estudos sobre GRUPOS de pessoas deixam de detetar crescimento de forma fiável. É uma referência da investigação, não um objetivo para ti; acima, os ganhos continuam, com rendimentos muito decrescentes e sem teto claro, por isso a barra não tem «cheio»."}}, "ru": {"stringUnit": {"state": "translated", "value": "Считается по мышцам, которые ты назначил каждому упражнению: прямые подходы идут за один, косвенные за половину. Отметка — примерно 4 подхода в неделю: ниже неё исследования на ГРУППАХ людей перестают надёжно фиксировать рост. Это ориентир из исследований, а не цель для тебя; выше прогресс продолжается, с резко убывающей отдачей и без явного потолка, поэтому у шкалы нет «полного»."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "根据你为每个动作指定的肌群统计:直接组算一组,间接组算半组。刻度约为每周 4 组——低于此线,针对人群的研究就无法可靠地检测到增长。这是研究参考,不是给你设定的目标;高于它仍会继续增长,但收益显著递减且没有明确上限,所以这个条没有“满”。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "根據你為每個動作指定的肌群統計:直接組算一組,間接組算半組。刻度約為每週 4 組——低於此線,針對人群的研究就無法可靠地偵測到增長。這是研究參考,不是給你設定的目標;高於它仍會繼續增長,但收益顯著遞減且沒有明確上限,所以這個條沒有「滿」。"}} + } }, + "%@ reps": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "%@ Wdh."}}, "en": {"stringUnit": {"state": "translated", "value": "%@ reps"}}, "es": {"stringUnit": {"state": "translated", "value": "%@ reps"}}, "fr": {"stringUnit": {"state": "translated", "value": "%@ répétitions"}}, "it": {"stringUnit": {"state": "translated", "value": "%@ ripetizioni"}}, "pl": {"stringUnit": {"state": "translated", "value": "%@ powtórzeń"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "%@ repetições"}}, "ru": {"stringUnit": {"state": "translated", "value": "%@ повторений"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "%@ 次"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "%@ 次"}} + } }, + "%lld reps": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "%lld Wdh."}}, "en": {"stringUnit": {"state": "translated", "value": "%lld reps"}}, "es": {"stringUnit": {"state": "translated", "value": "%lld reps"}}, "fr": {"stringUnit": {"state": "translated", "value": "%lld répétitions"}}, "it": {"stringUnit": {"state": "translated", "value": "%lld ripetizioni"}}, "pl": {"stringUnit": {"state": "translated", "value": "%lld powtórzeń"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "%lld repetições"}}, "ru": {"stringUnit": {"state": "translated", "value": "%lld повторений"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "%lld 次"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "%lld 次"}} + } }, + "%@ rest": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "%@ Pause"}}, "es": {"stringUnit": {"state": "translated", "value": "%@ de descanso"}}, "fr": {"stringUnit": {"state": "translated", "value": "%@ de repos"}}, "it": {"stringUnit": {"state": "translated", "value": "%@ di recupero"}}, "pl": {"stringUnit": {"state": "translated", "value": "%@ przerwy"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "%@ de descanso"}}, "ru": {"stringUnit": {"state": "translated", "value": "отдых %@"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "休息 %@"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "休息 %@"}} + } }, + "%lld sets": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "%lld Sätze"}}, "es": {"stringUnit": {"state": "translated", "value": "%lld series"}}, "fr": {"stringUnit": {"state": "translated", "value": "%lld séries"}}, "it": {"stringUnit": {"state": "translated", "value": "%lld serie"}}, "pl": {"stringUnit": {"state": "translated", "value": "%lld serii"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "%lld séries"}}, "ru": {"stringUnit": {"state": "translated", "value": "%lld подходов"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "%lld 组"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "%lld 組"}} + } }, + "Anything you want to remember": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Alles, was du dir merken willst"}}, "es": {"stringUnit": {"state": "translated", "value": "Cualquier cosa que quieras recordar"}}, "fr": {"stringUnit": {"state": "translated", "value": "Tout ce que tu veux retenir"}}, "it": {"stringUnit": {"state": "translated", "value": "Qualsiasi cosa tu voglia ricordare"}}, "pl": {"stringUnit": {"state": "translated", "value": "Cokolwiek chcesz zapamiętać"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Tudo o que queiras recordar"}}, "ru": {"stringUnit": {"state": "translated", "value": "Всё, что хочешь запомнить"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "任何你想记住的内容"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "任何你想記住的內容"}} + } }, + "Build a program once, then tap through it at the gym. Kept on %@.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Erstelle ein Programm einmal und tippe dich im Gym hindurch. Bleibt auf %@."}}, "es": {"stringUnit": {"state": "translated", "value": "Crea un programa una vez y ve tocando en el gimnasio. Se guarda en %@."}}, "fr": {"stringUnit": {"state": "translated", "value": "Crée un programme une fois, puis parcours-le à la salle. Conservé sur %@."}}, "it": {"stringUnit": {"state": "translated", "value": "Crea un programma una volta e scorrilo in palestra. Resta su %@."}}, "pl": {"stringUnit": {"state": "translated", "value": "Zbuduj program raz i przeklikaj go na siłowni. Zostaje na %@."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Cria um programa uma vez e percorre-o no ginásio. Fica em %@."}}, "ru": {"stringUnit": {"state": "translated", "value": "Создай программу один раз и отмечай подходы в зале. Хранится на %@."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "创建一次训练计划,然后在健身房逐组点按。保存在 %@ 上。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "建立一次訓練計畫,然後在健身房逐組點按。保存在 %@ 上。"}} + } }, + "Counted once per exercise": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Zählt einmal pro Übung"}}, "es": {"stringUnit": {"state": "translated", "value": "Se cuenta una vez por ejercicio"}}, "fr": {"stringUnit": {"state": "translated", "value": "Compté une fois par exercice"}}, "it": {"stringUnit": {"state": "translated", "value": "Contato una volta per esercizio"}}, "pl": {"stringUnit": {"state": "translated", "value": "Liczone raz na ćwiczenie"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Contado uma vez por exercício"}}, "ru": {"stringUnit": {"state": "translated", "value": "Учитывается один раз на упражнение"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "每个动作计一次"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "每個動作計一次"}} + } }, + "Details": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Details"}}, "es": {"stringUnit": {"state": "translated", "value": "Detalles"}}, "fr": {"stringUnit": {"state": "translated", "value": "Détails"}}, "it": {"stringUnit": {"state": "translated", "value": "Dettagli"}}, "pl": {"stringUnit": {"state": "translated", "value": "Szczegóły"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Detalhes"}}, "ru": {"stringUnit": {"state": "translated", "value": "Детали"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "详情"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "詳情"}} + } }, + "Edit exercise": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Übung bearbeiten"}}, "es": {"stringUnit": {"state": "translated", "value": "Editar ejercicio"}}, "fr": {"stringUnit": {"state": "translated", "value": "Modifier l'exercice"}}, "it": {"stringUnit": {"state": "translated", "value": "Modifica esercizio"}}, "pl": {"stringUnit": {"state": "translated", "value": "Edytuj ćwiczenie"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Editar exercício"}}, "ru": {"stringUnit": {"state": "translated", "value": "Изменить упражнение"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "编辑动作"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "編輯動作"}} + } }, + "Edit program": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Programm bearbeiten"}}, "es": {"stringUnit": {"state": "translated", "value": "Editar programa"}}, "fr": {"stringUnit": {"state": "translated", "value": "Modifier le programme"}}, "it": {"stringUnit": {"state": "translated", "value": "Modifica programma"}}, "pl": {"stringUnit": {"state": "translated", "value": "Edytuj program"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Editar programa"}}, "ru": {"stringUnit": {"state": "translated", "value": "Изменить программу"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "编辑计划"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "編輯計畫"}} + } }, + "Finish session": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Einheit beenden"}}, "es": {"stringUnit": {"state": "translated", "value": "Terminar sesión"}}, "fr": {"stringUnit": {"state": "translated", "value": "Terminer la séance"}}, "it": {"stringUnit": {"state": "translated", "value": "Termina sessione"}}, "pl": {"stringUnit": {"state": "translated", "value": "Zakończ sesję"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Terminar sessão"}}, "ru": {"stringUnit": {"state": "translated", "value": "Завершить тренировку"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "结束训练"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "結束訓練"}} + } }, + "In order": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Der Reihe nach"}}, "es": {"stringUnit": {"state": "translated", "value": "En orden"}}, "fr": {"stringUnit": {"state": "translated", "value": "Dans l'ordre"}}, "it": {"stringUnit": {"state": "translated", "value": "In ordine"}}, "pl": {"stringUnit": {"state": "translated", "value": "Po kolei"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Por ordem"}}, "ru": {"stringUnit": {"state": "translated", "value": "По порядку"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "按顺序"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "按順序"}} + } }, + "In your words": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "In deinen Worten"}}, "es": {"stringUnit": {"state": "translated", "value": "En tus palabras"}}, "fr": {"stringUnit": {"state": "translated", "value": "Dans tes mots"}}, "it": {"stringUnit": {"state": "translated", "value": "Con parole tue"}}, "pl": {"stringUnit": {"state": "translated", "value": "Twoimi słowami"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Nas tuas palavras"}}, "ru": {"stringUnit": {"state": "translated", "value": "Своими словами"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "用你的话"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "用你的話"}} + } }, + "Incline dumbbell press": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Schrägbankdrücken mit Kurzhanteln"}}, "es": {"stringUnit": {"state": "translated", "value": "Press inclinado con mancuernas"}}, "fr": {"stringUnit": {"state": "translated", "value": "Développé incliné haltères"}}, "it": {"stringUnit": {"state": "translated", "value": "Panca inclinata con manubri"}}, "pl": {"stringUnit": {"state": "translated", "value": "Wyciskanie hantli na skosie"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Supino inclinado com halteres"}}, "ru": {"stringUnit": {"state": "translated", "value": "Жим гантелей на наклонной"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "上斜哑铃卧推"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "上斜啞鈴臥推"}} + } }, + "Lift Log": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Trainingsbuch"}}, "es": {"stringUnit": {"state": "translated", "value": "Registro de fuerza"}}, "fr": {"stringUnit": {"state": "translated", "value": "Carnet de force"}}, "it": {"stringUnit": {"state": "translated", "value": "Diario dei pesi"}}, "pl": {"stringUnit": {"state": "translated", "value": "Dziennik siłowni"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Registo de força"}}, "ru": {"stringUnit": {"state": "translated", "value": "Дневник тренировок"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "力量日志"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "力量日誌"}} + } }, + "No targets set": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Keine Ziele gesetzt"}}, "es": {"stringUnit": {"state": "translated", "value": "Sin objetivos"}}, "fr": {"stringUnit": {"state": "translated", "value": "Aucun objectif défini"}}, "it": {"stringUnit": {"state": "translated", "value": "Nessun obiettivo impostato"}}, "pl": {"stringUnit": {"state": "translated", "value": "Brak celów"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Sem objetivos definidos"}}, "ru": {"stringUnit": {"state": "translated", "value": "Цели не заданы"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "未设定目标"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "未設定目標"}} + } }, + "One number for the whole session, so a leg day can be compared with a run.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Eine Zahl für die ganze Einheit, damit sich ein Beintag mit einem Lauf vergleichen lässt."}}, "es": {"stringUnit": {"state": "translated", "value": "Un número para toda la sesión, para poder comparar un día de piernas con una carrera."}}, "fr": {"stringUnit": {"state": "translated", "value": "Un seul chiffre pour toute la séance, pour comparer un jour de jambes à une course."}}, "it": {"stringUnit": {"state": "translated", "value": "Un solo numero per tutta la sessione, così un giorno gambe si confronta con una corsa."}}, "pl": {"stringUnit": {"state": "translated", "value": "Jedna liczba dla całej sesji, żeby dzień nóg dało się porównać z biegiem."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Um número para toda a sessão, para comparar um dia de pernas com uma corrida."}}, "ru": {"stringUnit": {"state": "translated", "value": "Одно число на всю тренировку, чтобы день ног можно было сравнить с пробежкой."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "整场训练一个数字,这样练腿日就能和跑步比较。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "整場訓練一個數字,這樣練腿日就能和跑步比較。"}} + } }, + "Reading your programs…": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Programme werden gelesen …"}}, "es": {"stringUnit": {"state": "translated", "value": "Leyendo tus programas…"}}, "fr": {"stringUnit": {"state": "translated", "value": "Lecture de tes programmes…"}}, "it": {"stringUnit": {"state": "translated", "value": "Lettura dei tuoi programmi…"}}, "pl": {"stringUnit": {"state": "translated", "value": "Wczytywanie programów…"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "A ler os teus programas…"}}, "ru": {"stringUnit": {"state": "translated", "value": "Читаем твои программы…"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "正在读取你的计划…"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "正在讀取你的計畫…"}} + } }, + "Rest (seconds)": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Pause (Sekunden)"}}, "es": {"stringUnit": {"state": "translated", "value": "Descanso (segundos)"}}, "fr": {"stringUnit": {"state": "translated", "value": "Repos (secondes)"}}, "it": {"stringUnit": {"state": "translated", "value": "Recupero (secondi)"}}, "pl": {"stringUnit": {"state": "translated", "value": "Przerwa (sekundy)"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Descanso (segundos)"}}, "ru": {"stringUnit": {"state": "translated", "value": "Отдых (секунды)"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "休息(秒)"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "休息(秒)"}} + } }, + "Set done": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Satz fertig"}}, "es": {"stringUnit": {"state": "translated", "value": "Serie hecha"}}, "fr": {"stringUnit": {"state": "translated", "value": "Série terminée"}}, "it": {"stringUnit": {"state": "translated", "value": "Serie completata"}}, "pl": {"stringUnit": {"state": "translated", "value": "Seria gotowa"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Série concluída"}}, "ru": {"stringUnit": {"state": "translated", "value": "Подход выполнен"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "完成这一组"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "完成這一組"}} + } }, + "Slow eccentric, pause at the bottom": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Langsam ablassen, unten kurz halten"}}, "es": {"stringUnit": {"state": "translated", "value": "Excéntrica lenta, pausa abajo"}}, "fr": {"stringUnit": {"state": "translated", "value": "Excentrique lente, pause en bas"}}, "it": {"stringUnit": {"state": "translated", "value": "Eccentrica lenta, pausa in basso"}}, "pl": {"stringUnit": {"state": "translated", "value": "Powolna faza ekscentryczna, pauza na dole"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Excêntrica lenta, pausa em baixo"}}, "ru": {"stringUnit": {"state": "translated", "value": "Медленная негативная фаза, пауза внизу"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "慢速离心,底部停顿"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "慢速離心,底部停頓"}} + } }, + "Start first set": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Ersten Satz starten"}}, "es": {"stringUnit": {"state": "translated", "value": "Empezar la primera serie"}}, "fr": {"stringUnit": {"state": "translated", "value": "Démarrer la première série"}}, "it": {"stringUnit": {"state": "translated", "value": "Avvia la prima serie"}}, "pl": {"stringUnit": {"state": "translated", "value": "Rozpocznij pierwszą serię"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Iniciar a primeira série"}}, "ru": {"stringUnit": {"state": "translated", "value": "Начать первый подход"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "开始第一组"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "開始第一組"}} + } }, + "Start next set": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Nächsten Satz starten"}}, "es": {"stringUnit": {"state": "translated", "value": "Empezar la siguiente serie"}}, "fr": {"stringUnit": {"state": "translated", "value": "Démarrer la série suivante"}}, "it": {"stringUnit": {"state": "translated", "value": "Avvia la serie successiva"}}, "pl": {"stringUnit": {"state": "translated", "value": "Rozpocznij następną serię"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Iniciar a série seguinte"}}, "ru": {"stringUnit": {"state": "translated", "value": "Начать следующий подход"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "开始下一组"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "開始下一組"}} + } }, + "Type any name you like. NOOP remembers it, with the muscles you give it.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Gib einen beliebigen Namen ein. NOOP merkt ihn sich, mit den Muskeln, die du zuordnest."}}, "es": {"stringUnit": {"state": "translated", "value": "Escribe el nombre que quieras. NOOP lo recuerda, con los músculos que le asignes."}}, "fr": {"stringUnit": {"state": "translated", "value": "Saisis le nom que tu veux. NOOP le retient, avec les muscles que tu lui attribues."}}, "it": {"stringUnit": {"state": "translated", "value": "Scrivi il nome che preferisci. NOOP lo ricorda, con i muscoli che gli assegni."}}, "pl": {"stringUnit": {"state": "translated", "value": "Wpisz dowolną nazwę. NOOP ją zapamięta wraz z mięśniami, które przypiszesz."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Escreve o nome que quiseres. O NOOP guarda-o, com os músculos que lhe deres."}}, "ru": {"stringUnit": {"state": "translated", "value": "Введи любое название. NOOP запомнит его вместе с мышцами, которые ты укажешь."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "输入任意名称。NOOP 会连同你指定的肌群一起记住。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "輸入任意名稱。NOOP 會連同你指定的肌群一起記住。"}} + } }, + "Upper A": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Oberkörper A"}}, "es": {"stringUnit": {"state": "translated", "value": "Tren superior A"}}, "fr": {"stringUnit": {"state": "translated", "value": "Haut du corps A"}}, "it": {"stringUnit": {"state": "translated", "value": "Parte alta A"}}, "pl": {"stringUnit": {"state": "translated", "value": "Góra A"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Tronco superior A"}}, "ru": {"stringUnit": {"state": "translated", "value": "Верх A"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "上肢 A"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "上肢 A"}} + } }, + "Warm-up": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Aufwärmen"}}, "es": {"stringUnit": {"state": "translated", "value": "Calentamiento"}}, "fr": {"stringUnit": {"state": "translated", "value": "Échauffement"}}, "it": {"stringUnit": {"state": "translated", "value": "Riscaldamento"}}, "pl": {"stringUnit": {"state": "translated", "value": "Rozgrzewka"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Aquecimento"}}, "ru": {"stringUnit": {"state": "translated", "value": "Разминка"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "热身"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "熱身"}} + } }, + "What you're aiming for": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Was du anpeilst"}}, "es": {"stringUnit": {"state": "translated", "value": "Lo que buscas"}}, "fr": {"stringUnit": {"state": "translated", "value": "Ce que tu vises"}}, "it": {"stringUnit": {"state": "translated", "value": "Il tuo obiettivo"}}, "pl": {"stringUnit": {"state": "translated", "value": "Do czego dążysz"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "O que procuras"}}, "ru": {"stringUnit": {"state": "translated", "value": "К чему стремишься"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "你的目标"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "你的目標"}} + } }, + "Working sets": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Arbeitssätze"}}, "es": {"stringUnit": {"state": "translated", "value": "Series efectivas"}}, "fr": {"stringUnit": {"state": "translated", "value": "Séries de travail"}}, "it": {"stringUnit": {"state": "translated", "value": "Serie di lavoro"}}, "pl": {"stringUnit": {"state": "translated", "value": "Serie robocze"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Séries de trabalho"}}, "ru": {"stringUnit": {"state": "translated", "value": "Рабочие подходы"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "正式组"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "正式組"}} + } }, + "Your strap is already syncing.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Dein Strap wird bereits synchronisiert." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Tu pulsera ya se está sincronizando." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Votre bracelet est déjà en cours de synchronisation." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "La fascia è già in sincronizzazione." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "A sua pulseira já está a sincronizar." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Браслет уже синхронизируется." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "你的手环已在同步中。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "你的手環已在同步中。" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pasek jest już synchronizowany." + } + } + } + }, + "Your strap isn't connected to NOOP yet, so the sync didn't start.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Dein Strap ist noch nicht mit NOOP verbunden, daher wurde die Synchronisierung nicht gestartet." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Tu pulsera aún no está conectada a NOOP, así que la sincronización no se ha iniciado." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Votre bracelet n'est pas encore connecté à NOOP, la synchronisation n'a donc pas démarré." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "La fascia non è ancora collegata a NOOP, quindi la sincronizzazione non è partita." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "A sua pulseira ainda não está ligada ao NOOP, por isso a sincronização não começou." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Браслет ещё не подключён к NOOP, поэтому синхронизация не началась." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "你的手环尚未连接到 NOOP,因此同步没有开始。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "你的手環尚未連接到 NOOP,因此同步沒有開始。" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pasek nie jest jeszcze połączony z NOOP, więc synchronizacja się nie rozpoczęła." + } + } + } + }, + "Your targets for each exercise. What you actually lift is recorded when you run it.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Deine Ziele für jede Übung. Was du tatsächlich hebst, wird beim Durchführen aufgezeichnet."}}, "es": {"stringUnit": {"state": "translated", "value": "Tus objetivos para cada ejercicio. Lo que realmente levantas se registra al ejecutarlo."}}, "fr": {"stringUnit": {"state": "translated", "value": "Tes objectifs pour chaque exercice. Ce que tu soulèves vraiment est enregistré pendant la séance."}}, "it": {"stringUnit": {"state": "translated", "value": "I tuoi obiettivi per ogni esercizio. Ciò che sollevi davvero viene registrato durante la sessione."}}, "pl": {"stringUnit": {"state": "translated", "value": "Twoje cele dla każdego ćwiczenia. To, co faktycznie podnosisz, zapisuje się podczas treningu."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Os teus objetivos para cada exercício. O que levantas mesmo é registado quando o executas."}}, "ru": {"stringUnit": {"state": "translated", "value": "Твои цели для каждого упражнения. Что ты поднял на самом деле, записывается во время тренировки."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "每个动作的目标。你实际举起的重量会在训练时记录。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "每個動作的目標。你實際舉起的重量會在訓練時記錄。"}} + } }, + "measured from heart rate": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "aus der Herzfrequenz gemessen"}}, "es": {"stringUnit": {"state": "translated", "value": "medido a partir de la frecuencia cardíaca"}}, "fr": {"stringUnit": {"state": "translated", "value": "mesuré à partir de la fréquence cardiaque"}}, "it": {"stringUnit": {"state": "translated", "value": "misurato dalla frequenza cardiaca"}}, "pl": {"stringUnit": {"state": "translated", "value": "mierzone z tętna"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "medido a partir da frequência cardíaca"}}, "ru": {"stringUnit": {"state": "translated", "value": "измерено по пульсу"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "由心率测得"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "由心率測得"}} + } }, + "Chest": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Brust"}}, "es": {"stringUnit": {"state": "translated", "value": "Pecho"}}, "fr": {"stringUnit": {"state": "translated", "value": "Pectoraux"}}, "it": {"stringUnit": {"state": "translated", "value": "Petto"}}, "pl": {"stringUnit": {"state": "translated", "value": "Klatka piersiowa"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Peito"}}, "ru": {"stringUnit": {"state": "translated", "value": "Грудь"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "胸部"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "胸部"}} + } }, + "Front delts": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Vordere Schulter"}}, "es": {"stringUnit": {"state": "translated", "value": "Deltoides anterior"}}, "fr": {"stringUnit": {"state": "translated", "value": "Deltoïdes antérieurs"}}, "it": {"stringUnit": {"state": "translated", "value": "Deltoidi anteriori"}}, "pl": {"stringUnit": {"state": "translated", "value": "Barki przednie"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Deltoides anteriores"}}, "ru": {"stringUnit": {"state": "translated", "value": "Передние дельты"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "前束三角肌"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "前束三角肌"}} + } }, + "Side delts": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Seitliche Schulter"}}, "es": {"stringUnit": {"state": "translated", "value": "Deltoides lateral"}}, "fr": {"stringUnit": {"state": "translated", "value": "Deltoïdes latéraux"}}, "it": {"stringUnit": {"state": "translated", "value": "Deltoidi laterali"}}, "pl": {"stringUnit": {"state": "translated", "value": "Barki boczne"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Deltoides laterais"}}, "ru": {"stringUnit": {"state": "translated", "value": "Средние дельты"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "中束三角肌"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "中束三角肌"}} + } }, + "Rear delts": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Hintere Schulter"}}, "es": {"stringUnit": {"state": "translated", "value": "Deltoides posterior"}}, "fr": {"stringUnit": {"state": "translated", "value": "Deltoïdes postérieurs"}}, "it": {"stringUnit": {"state": "translated", "value": "Deltoidi posteriori"}}, "pl": {"stringUnit": {"state": "translated", "value": "Barki tylne"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Deltoides posteriores"}}, "ru": {"stringUnit": {"state": "translated", "value": "Задние дельты"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "后束三角肌"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "後束三角肌"}} + } }, + "Triceps": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Trizeps"}}, "es": {"stringUnit": {"state": "translated", "value": "Tríceps"}}, "fr": {"stringUnit": {"state": "translated", "value": "Triceps"}}, "it": {"stringUnit": {"state": "translated", "value": "Tricipiti"}}, "pl": {"stringUnit": {"state": "translated", "value": "Triceps"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Tríceps"}}, "ru": {"stringUnit": {"state": "translated", "value": "Трицепс"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "肱三头肌"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "肱三頭肌"}} + } }, + "Lats": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Latissimus"}}, "es": {"stringUnit": {"state": "translated", "value": "Dorsales"}}, "fr": {"stringUnit": {"state": "translated", "value": "Dorsaux"}}, "it": {"stringUnit": {"state": "translated", "value": "Dorsali"}}, "pl": {"stringUnit": {"state": "translated", "value": "Najszersze grzbietu"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Dorsais"}}, "ru": {"stringUnit": {"state": "translated", "value": "Широчайшие"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "背阔肌"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "背闊肌"}} + } }, + "Upper back": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Oberer Rücken"}}, "es": {"stringUnit": {"state": "translated", "value": "Espalda alta"}}, "fr": {"stringUnit": {"state": "translated", "value": "Haut du dos"}}, "it": {"stringUnit": {"state": "translated", "value": "Dorso alto"}}, "pl": {"stringUnit": {"state": "translated", "value": "Górna część pleców"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Costas superiores"}}, "ru": {"stringUnit": {"state": "translated", "value": "Верх спины"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "上背"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "上背"}} + } }, + "Traps": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Trapez"}}, "es": {"stringUnit": {"state": "translated", "value": "Trapecios"}}, "fr": {"stringUnit": {"state": "translated", "value": "Trapèzes"}}, "it": {"stringUnit": {"state": "translated", "value": "Trapezi"}}, "pl": {"stringUnit": {"state": "translated", "value": "Czworoboczne"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Trapézios"}}, "ru": {"stringUnit": {"state": "translated", "value": "Трапеции"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "斜方肌"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "斜方肌"}} + } }, + "Biceps": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Bizeps"}}, "es": {"stringUnit": {"state": "translated", "value": "Bíceps"}}, "fr": {"stringUnit": {"state": "translated", "value": "Biceps"}}, "it": {"stringUnit": {"state": "translated", "value": "Bicipiti"}}, "pl": {"stringUnit": {"state": "translated", "value": "Biceps"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Bíceps"}}, "ru": {"stringUnit": {"state": "translated", "value": "Бицепс"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "肱二头肌"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "肱二頭肌"}} + } }, + "Forearms": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Unterarme"}}, "es": {"stringUnit": {"state": "translated", "value": "Antebrazos"}}, "fr": {"stringUnit": {"state": "translated", "value": "Avant-bras"}}, "it": {"stringUnit": {"state": "translated", "value": "Avambracci"}}, "pl": {"stringUnit": {"state": "translated", "value": "Przedramiona"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Antebraços"}}, "ru": {"stringUnit": {"state": "translated", "value": "Предплечья"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "前臂"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "前臂"}} + } }, + "Quads": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Quadrizeps"}}, "es": {"stringUnit": {"state": "translated", "value": "Cuádriceps"}}, "fr": {"stringUnit": {"state": "translated", "value": "Quadriceps"}}, "it": {"stringUnit": {"state": "translated", "value": "Quadricipiti"}}, "pl": {"stringUnit": {"state": "translated", "value": "Czworogłowe uda"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Quadríceps"}}, "ru": {"stringUnit": {"state": "translated", "value": "Квадрицепс"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "股四头肌"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "股四頭肌"}} + } }, + "Hamstrings": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Beinbeuger"}}, "es": {"stringUnit": {"state": "translated", "value": "Isquiotibiales"}}, "fr": {"stringUnit": {"state": "translated", "value": "Ischio-jambiers"}}, "it": {"stringUnit": {"state": "translated", "value": "Femorali"}}, "pl": {"stringUnit": {"state": "translated", "value": "Dwugłowe uda"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Isquiotibiais"}}, "ru": {"stringUnit": {"state": "translated", "value": "Бицепс бедра"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "腘绳肌"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "膕繩肌"}} + } }, + "Glutes": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Gesäß"}}, "es": {"stringUnit": {"state": "translated", "value": "Glúteos"}}, "fr": {"stringUnit": {"state": "translated", "value": "Fessiers"}}, "it": {"stringUnit": {"state": "translated", "value": "Glutei"}}, "pl": {"stringUnit": {"state": "translated", "value": "Pośladki"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Glúteos"}}, "ru": {"stringUnit": {"state": "translated", "value": "Ягодицы"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "臀部"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "臀部"}} + } }, + "Adductors": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Adduktoren"}}, "es": {"stringUnit": {"state": "translated", "value": "Aductores"}}, "fr": {"stringUnit": {"state": "translated", "value": "Adducteurs"}}, "it": {"stringUnit": {"state": "translated", "value": "Adduttori"}}, "pl": {"stringUnit": {"state": "translated", "value": "Przywodziciele"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Adutores"}}, "ru": {"stringUnit": {"state": "translated", "value": "Приводящие"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "内收肌"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "內收肌"}} + } }, + "Abductors": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Abduktoren"}}, "es": {"stringUnit": {"state": "translated", "value": "Abductores"}}, "fr": {"stringUnit": {"state": "translated", "value": "Abducteurs"}}, "it": {"stringUnit": {"state": "translated", "value": "Abduttori"}}, "pl": {"stringUnit": {"state": "translated", "value": "Odwodziciele"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Abdutores"}}, "ru": {"stringUnit": {"state": "translated", "value": "Отводящие"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "外展肌"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "外展肌"}} + } }, + "Calves": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Waden"}}, "es": {"stringUnit": {"state": "translated", "value": "Gemelos"}}, "fr": {"stringUnit": {"state": "translated", "value": "Mollets"}}, "it": {"stringUnit": {"state": "translated", "value": "Polpacci"}}, "pl": {"stringUnit": {"state": "translated", "value": "Łydki"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Gémeos"}}, "ru": {"stringUnit": {"state": "translated", "value": "Икры"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "小腿"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "小腿"}} + } }, + "Abs": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Bauch"}}, "es": {"stringUnit": {"state": "translated", "value": "Abdominales"}}, "fr": {"stringUnit": {"state": "translated", "value": "Abdominaux"}}, "it": {"stringUnit": {"state": "translated", "value": "Addominali"}}, "pl": {"stringUnit": {"state": "translated", "value": "Brzuch"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Abdominais"}}, "ru": {"stringUnit": {"state": "translated", "value": "Пресс"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "腹肌"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "腹肌"}} + } }, + "Obliques": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Schräge Bauchmuskeln"}}, "es": {"stringUnit": {"state": "translated", "value": "Oblicuos"}}, "fr": {"stringUnit": {"state": "translated", "value": "Obliques"}}, "it": {"stringUnit": {"state": "translated", "value": "Obliqui"}}, "pl": {"stringUnit": {"state": "translated", "value": "Skośne brzucha"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Oblíquos"}}, "ru": {"stringUnit": {"state": "translated", "value": "Косые мышцы"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "腹斜肌"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "腹斜肌"}} + } }, + "Lower back": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Unterer Rücken"}}, "es": {"stringUnit": {"state": "translated", "value": "Espalda baja"}}, "fr": {"stringUnit": {"state": "translated", "value": "Bas du dos"}}, "it": {"stringUnit": {"state": "translated", "value": "Lombari"}}, "pl": {"stringUnit": {"state": "translated", "value": "Dolna część pleców"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Lombar"}}, "ru": {"stringUnit": {"state": "translated", "value": "Поясница"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "下背"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "下背"}} + } }, + "Neck": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Nacken"}}, "es": {"stringUnit": {"state": "translated", "value": "Cuello"}}, "fr": {"stringUnit": {"state": "translated", "value": "Cou"}}, "it": {"stringUnit": {"state": "translated", "value": "Collo"}}, "pl": {"stringUnit": {"state": "translated", "value": "Kark"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Pescoço"}}, "ru": {"stringUnit": {"state": "translated", "value": "Шея"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "颈部"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "頸部"}} + } }, + "Push muscles": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Drückmuskulatur"}}, "es": {"stringUnit": {"state": "translated", "value": "Músculos de empuje"}}, "fr": {"stringUnit": {"state": "translated", "value": "Muscles de poussée"}}, "it": {"stringUnit": {"state": "translated", "value": "Muscoli di spinta"}}, "pl": {"stringUnit": {"state": "translated", "value": "Mięśnie pchające"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Músculos de empurrar"}}, "ru": {"stringUnit": {"state": "translated", "value": "Жимовые мышцы"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "推类肌群"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "推類肌群"}} + } }, + "Pull muscles": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Zugmuskulatur"}}, "es": {"stringUnit": {"state": "translated", "value": "Músculos de tracción"}}, "fr": {"stringUnit": {"state": "translated", "value": "Muscles de tirage"}}, "it": {"stringUnit": {"state": "translated", "value": "Muscoli di tirata"}}, "pl": {"stringUnit": {"state": "translated", "value": "Mięśnie ciągnące"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Músculos de puxar"}}, "ru": {"stringUnit": {"state": "translated", "value": "Тяговые мышцы"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "拉类肌群"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "拉類肌群"}} + } }, + "Leg muscles": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Beinmuskulatur"}}, "es": {"stringUnit": {"state": "translated", "value": "Músculos de pierna"}}, "fr": {"stringUnit": {"state": "translated", "value": "Muscles des jambes"}}, "it": {"stringUnit": {"state": "translated", "value": "Muscoli delle gambe"}}, "pl": {"stringUnit": {"state": "translated", "value": "Mięśnie nóg"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Músculos das pernas"}}, "ru": {"stringUnit": {"state": "translated", "value": "Мышцы ног"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "腿部肌群"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "腿部肌群"}} + } }, + "Trunk muscles": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Rumpfmuskulatur"}}, "es": {"stringUnit": {"state": "translated", "value": "Músculos del tronco"}}, "fr": {"stringUnit": {"state": "translated", "value": "Muscles du tronc"}}, "it": {"stringUnit": {"state": "translated", "value": "Muscoli del tronco"}}, "pl": {"stringUnit": {"state": "translated", "value": "Mięśnie tułowia"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Músculos do tronco"}}, "ru": {"stringUnit": {"state": "translated", "value": "Мышцы корпуса"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "躯干肌群"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "軀幹肌群"}} + } }, + "Add set": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Satz hinzufügen"}}, "en": {"stringUnit": {"state": "translated", "value": "Add set"}}, "es": {"stringUnit": {"state": "translated", "value": "Añadir serie"}}, "fr": {"stringUnit": {"state": "translated", "value": "Ajouter une série"}}, "it": {"stringUnit": {"state": "translated", "value": "Aggiungi serie"}}, "pl": {"stringUnit": {"state": "translated", "value": "Dodaj serię"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Adicionar série"}}, "ru": {"stringUnit": {"state": "translated", "value": "Добавить подход"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "添加一组"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "新增一組"}} + } }, + "Unfinished sets": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Unvollständige Sätze"}}, "en": {"stringUnit": {"state": "translated", "value": "Unfinished sets"}}, "es": {"stringUnit": {"state": "translated", "value": "Series sin terminar"}}, "fr": {"stringUnit": {"state": "translated", "value": "Séries non terminées"}}, "it": {"stringUnit": {"state": "translated", "value": "Serie incomplete"}}, "pl": {"stringUnit": {"state": "translated", "value": "Niedokończone serie"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Séries por terminar"}}, "ru": {"stringUnit": {"state": "translated", "value": "Незавершённые подходы"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "未完成的组"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "未完成的組"}} + } }, + "Sets not started: %lld": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Nicht begonnene Sätze: %lld"}}, "en": {"stringUnit": {"state": "translated", "value": "Sets not started: %lld"}}, "es": {"stringUnit": {"state": "translated", "value": "Series sin empezar: %lld"}}, "fr": {"stringUnit": {"state": "translated", "value": "Séries non commencées : %lld"}}, "it": {"stringUnit": {"state": "translated", "value": "Serie non iniziate: %lld"}}, "pl": {"stringUnit": {"state": "translated", "value": "Nierozpoczęte serie: %lld"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Séries não começadas: %lld"}}, "ru": {"stringUnit": {"state": "translated", "value": "Не начатых подходов: %lld"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "未开始的组:%lld"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "未開始的組:%lld"}} + } }, + "Complete them": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Abschließen"}}, "en": {"stringUnit": {"state": "translated", "value": "Complete them"}}, "es": {"stringUnit": {"state": "translated", "value": "Completarlas"}}, "fr": {"stringUnit": {"state": "translated", "value": "Les compléter"}}, "it": {"stringUnit": {"state": "translated", "value": "Completale"}}, "pl": {"stringUnit": {"state": "translated", "value": "Uzupełnij"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Completá-las"}}, "ru": {"stringUnit": {"state": "translated", "value": "Завершить"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "补全"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "補全"}} + } }, + "Discard them": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Verwerfen"}}, "en": {"stringUnit": {"state": "translated", "value": "Discard them"}}, "es": {"stringUnit": {"state": "translated", "value": "Descartarlas"}}, "fr": {"stringUnit": {"state": "translated", "value": "Les abandonner"}}, "it": {"stringUnit": {"state": "translated", "value": "Scartale"}}, "pl": {"stringUnit": {"state": "translated", "value": "Odrzuć"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Descartá-las"}}, "ru": {"stringUnit": {"state": "translated", "value": "Отбросить"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "放弃"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "放棄"}} + } }, + "Every set would be a zero, so discarding saves no session and no workout.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Jeder Satz wäre eine Null, daher speichert Verwerfen keine Einheit und kein Workout."}}, "en": {"stringUnit": {"state": "translated", "value": "Every set would be a zero, so discarding saves no session and no workout."}}, "es": {"stringUnit": {"state": "translated", "value": "Todas las series quedarían en cero, así que descartarlas no guarda ninguna sesión ni entrenamiento."}}, "fr": {"stringUnit": {"state": "translated", "value": "Toutes les séries seraient à zéro : les abandonner n'enregistre ni séance ni entraînement."}}, "it": {"stringUnit": {"state": "translated", "value": "Ogni serie sarebbe a zero, quindi scartarle non salva né la sessione né l'allenamento."}}, "pl": {"stringUnit": {"state": "translated", "value": "Każda seria byłaby zerem, więc odrzucenie nie zapisze ani sesji, ani treningu."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Todas as séries ficariam a zero, por isso descartá-las não guarda sessão nem treino."}}, "ru": {"stringUnit": {"state": "translated", "value": "Все подходы стали бы нулями, поэтому если отбросить, не сохранится ни сессия, ни тренировка."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "每一组都会是 0,因此放弃后既不会保存本次训练,也不会创建训练记录。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "每一組都會是 0,因此放棄後既不會儲存本次訓練,也不會建立訓練記錄。"}} + } }, + "You changed the number of sets. Keep the new counts in the program for next time?": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Du hast die Anzahl der Sätze geändert. Die neuen Zahlen fürs nächste Mal ins Programm übernehmen?"}}, "en": {"stringUnit": {"state": "translated", "value": "You changed the number of sets. Keep the new counts in the program for next time?"}}, "es": {"stringUnit": {"state": "translated", "value": "Has cambiado el número de series. ¿Guardar las nuevas cifras en el programa para la próxima vez?"}}, "fr": {"stringUnit": {"state": "translated", "value": "Tu as modifié le nombre de séries. Garder les nouveaux nombres dans le programme pour la prochaine fois ?"}}, "it": {"stringUnit": {"state": "translated", "value": "Hai cambiato il numero di serie. Tenere i nuovi numeri nel programma per la prossima volta?"}}, "pl": {"stringUnit": {"state": "translated", "value": "Zmieniono liczbę serii. Zachować nowe liczby w programie na następny raz?"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Alteraste o número de séries. Guardar os novos números no programa para a próxima vez?"}}, "ru": {"stringUnit": {"state": "translated", "value": "Количество подходов изменено. Сохранить новые значения в программе на следующий раз?"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "你更改了组数。要把新的组数保存到计划里,下次使用吗?"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "你更改了組數。要把新的組數儲存到計畫裡,下次使用嗎?"}} + } }, + "Pick one you have done before, or type a new name.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Wähle eine, die du schon gemacht hast, oder gib einen neuen Namen ein."}}, "en": {"stringUnit": {"state": "translated", "value": "Pick one you have done before, or type a new name."}}, "es": {"stringUnit": {"state": "translated", "value": "Elige uno que ya hayas hecho o escribe un nombre nuevo."}}, "fr": {"stringUnit": {"state": "translated", "value": "Choisis-en un que tu as déjà fait, ou tape un nouveau nom."}}, "it": {"stringUnit": {"state": "translated", "value": "Scegline uno che hai già fatto o scrivi un nome nuovo."}}, "pl": {"stringUnit": {"state": "translated", "value": "Wybierz jedno z wykonanych wcześniej albo wpisz nową nazwę."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Escolhe um que já fizeste ou escreve um nome novo."}}, "ru": {"stringUnit": {"state": "translated", "value": "Выбери одно из уже выполненных или введи новое название."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "选择做过的动作,或输入新名称。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "選擇做過的動作,或輸入新名稱。"}} + } }, + "It joins this session with one set, its weight and reps at 0 until you type what you lift. Finishing asks whether the program keeps it.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Sie kommt mit einem Satz in diese Einheit, Gewicht und Wiederholungen auf 0, bis du einträgst, was du hebst. Beim Beenden wirst du gefragt, ob das Programm sie behält."}}, "en": {"stringUnit": {"state": "translated", "value": "It joins this session with one set, its weight and reps at 0 until you type what you lift. Finishing asks whether the program keeps it."}}, "es": {"stringUnit": {"state": "translated", "value": "Se añade a esta sesión con una serie, con peso y repeticiones a 0 hasta que escribas lo que levantas. Al terminar se te pregunta si el programa lo conserva."}}, "fr": {"stringUnit": {"state": "translated", "value": "Il rejoint cette séance avec une série, poids et répétitions à 0 jusqu'à ce que tu saisisses ce que tu soulèves. À la fin, on te demande si le programme le garde."}}, "it": {"stringUnit": {"state": "translated", "value": "Si aggiunge a questa sessione con una serie, peso e ripetizioni a 0 finché non scrivi cosa sollevi. Alla fine ti viene chiesto se il programma lo tiene."}}, "pl": {"stringUnit": {"state": "translated", "value": "Trafia do tej sesji z jedną serią, z ciężarem i powtórzeniami 0, dopóki nie wpiszesz, co podnosisz. Przy zakończeniu padnie pytanie, czy program ma je zachować."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Entra nesta sessão com uma série, peso e repetições a 0 até escreveres o que levantas. Ao terminar, é-te perguntado se o programa o mantém."}}, "ru": {"stringUnit": {"state": "translated", "value": "Оно добавляется в эту сессию с одним подходом, вес и повторения — 0, пока не введёшь, что поднимаешь. При завершении спросим, оставить ли его в программе."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "它会以一组加入本次训练,重量和次数为 0,直到你输入实际举起的数值。结束时会询问是否将它保留在计划中。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "它會以一組加入本次訓練,重量和次數為 0,直到你輸入實際舉起的數值。結束時會詢問是否將它保留在計畫中。"}} + } }, + "Add to session": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Zur Einheit hinzufügen"}}, "en": {"stringUnit": {"state": "translated", "value": "Add to session"}}, "es": {"stringUnit": {"state": "translated", "value": "Añadir a la sesión"}}, "fr": {"stringUnit": {"state": "translated", "value": "Ajouter à la séance"}}, "it": {"stringUnit": {"state": "translated", "value": "Aggiungi alla sessione"}}, "pl": {"stringUnit": {"state": "translated", "value": "Dodaj do sesji"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Adicionar à sessão"}}, "ru": {"stringUnit": {"state": "translated", "value": "Добавить в сессию"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "加入本次训练"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "加入本次訓練"}} + } }, + "You added exercises and changed the number of sets. Keep these changes in the program for next time?": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Du hast Übungen hinzugefügt und die Anzahl der Sätze geändert. Diese Änderungen fürs nächste Mal ins Programm übernehmen?"}}, "en": {"stringUnit": {"state": "translated", "value": "You added exercises and changed the number of sets. Keep these changes in the program for next time?"}}, "es": {"stringUnit": {"state": "translated", "value": "Has añadido ejercicios y cambiado el número de series. ¿Guardar estos cambios en el programa para la próxima vez?"}}, "fr": {"stringUnit": {"state": "translated", "value": "Tu as ajouté des exercices et modifié le nombre de séries. Garder ces changements dans le programme pour la prochaine fois ?"}}, "it": {"stringUnit": {"state": "translated", "value": "Hai aggiunto esercizi e cambiato il numero di serie. Tenere queste modifiche nel programma per la prossima volta?"}}, "pl": {"stringUnit": {"state": "translated", "value": "Dodano ćwiczenia i zmieniono liczbę serii. Zachować te zmiany w programie na następny raz?"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Adicionaste exercícios e alteraste o número de séries. Guardar estas alterações no programa para a próxima vez?"}}, "ru": {"stringUnit": {"state": "translated", "value": "Добавлены упражнения и изменено количество подходов. Сохранить эти изменения в программе на следующий раз?"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "你添加了动作并更改了组数。要把这些更改保存到计划里,下次使用吗?"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "你新增了動作並更改了組數。要把這些更改儲存到計畫裡,下次使用嗎?"}} + } }, + "You added exercises. Add them to the program for next time?": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Du hast Übungen hinzugefügt. Fürs nächste Mal ins Programm aufnehmen?"}}, "en": {"stringUnit": {"state": "translated", "value": "You added exercises. Add them to the program for next time?"}}, "es": {"stringUnit": {"state": "translated", "value": "Has añadido ejercicios. ¿Añadirlos al programa para la próxima vez?"}}, "fr": {"stringUnit": {"state": "translated", "value": "Tu as ajouté des exercices. Les ajouter au programme pour la prochaine fois ?"}}, "it": {"stringUnit": {"state": "translated", "value": "Hai aggiunto esercizi. Aggiungerli al programma per la prossima volta?"}}, "pl": {"stringUnit": {"state": "translated", "value": "Dodano ćwiczenia. Dodać je do programu na następny raz?"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Adicionaste exercícios. Adicioná-los ao programa para a próxima vez?"}}, "ru": {"stringUnit": {"state": "translated", "value": "Добавлены упражнения. Добавить их в программу на следующий раз?"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "你添加了动作。要把它们加入计划,下次使用吗?"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "你新增了動作。要把它們加入計畫,下次使用嗎?"}} + } }, + "New: %@ · sets: %lld": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Neu: %1$@ · Sätze: %2$lld"}}, "en": {"stringUnit": {"state": "translated", "value": "New: %@ · sets: %lld"}}, "es": {"stringUnit": {"state": "translated", "value": "Nuevo: %1$@ · series: %2$lld"}}, "fr": {"stringUnit": {"state": "translated", "value": "Nouveau : %1$@ · séries : %2$lld"}}, "it": {"stringUnit": {"state": "translated", "value": "Nuovo: %1$@ · serie: %2$lld"}}, "pl": {"stringUnit": {"state": "translated", "value": "Nowe: %1$@ · serie: %2$lld"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Novo: %1$@ · séries: %2$lld"}}, "ru": {"stringUnit": {"state": "translated", "value": "Новое: %1$@ · подходов: %2$lld"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "新增:%1$@ · 组数:%2$lld"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "新增:%1$@ · 組數:%2$lld"}} + } }, + "%@: %lld → %lld sets": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "%1$@: %2$lld → %3$lld Sätze"}}, "en": {"stringUnit": {"state": "translated", "value": "%@: %lld → %lld sets"}}, "es": {"stringUnit": {"state": "translated", "value": "%1$@: %2$lld → %3$lld series"}}, "fr": {"stringUnit": {"state": "translated", "value": "%1$@ : %2$lld → %3$lld séries"}}, "it": {"stringUnit": {"state": "translated", "value": "%1$@: %2$lld → %3$lld serie"}}, "pl": {"stringUnit": {"state": "translated", "value": "%1$@: %2$lld → %3$lld serii"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "%1$@: %2$lld → %3$lld séries"}}, "ru": {"stringUnit": {"state": "translated", "value": "%1$@: %2$lld → %3$lld подх."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "%1$@:%2$lld → %3$lld 组"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "%1$@:%2$lld → %3$lld 組"}} + } }, + "Update program": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Programm aktualisieren"}}, "en": {"stringUnit": {"state": "translated", "value": "Update program"}}, "es": {"stringUnit": {"state": "translated", "value": "Actualizar programa"}}, "fr": {"stringUnit": {"state": "translated", "value": "Mettre à jour"}}, "it": {"stringUnit": {"state": "translated", "value": "Aggiorna programma"}}, "pl": {"stringUnit": {"state": "translated", "value": "Zaktualizuj program"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Atualizar programa"}}, "ru": {"stringUnit": {"state": "translated", "value": "Обновить программу"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "更新计划"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "更新計畫"}} + } }, + "Keep as it was": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Unverändert lassen"}}, "en": {"stringUnit": {"state": "translated", "value": "Keep as it was"}}, "es": {"stringUnit": {"state": "translated", "value": "Dejar como estaba"}}, "fr": {"stringUnit": {"state": "translated", "value": "Laisser tel quel"}}, "it": {"stringUnit": {"state": "translated", "value": "Lascia com'era"}}, "pl": {"stringUnit": {"state": "translated", "value": "Zostaw bez zmian"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Deixar como estava"}}, "ru": {"stringUnit": {"state": "translated", "value": "Оставить как было"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "保持不变"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "保持不變"}} + } }, + "Choose an option above to save.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Wähle oben eine Option, um zu speichern."}}, "en": {"stringUnit": {"state": "translated", "value": "Choose an option above to save."}}, "es": {"stringUnit": {"state": "translated", "value": "Elige una opción arriba para guardar."}}, "fr": {"stringUnit": {"state": "translated", "value": "Choisis une option ci-dessus pour enregistrer."}}, "it": {"stringUnit": {"state": "translated", "value": "Scegli un'opzione qui sopra per salvare."}}, "pl": {"stringUnit": {"state": "translated", "value": "Wybierz opcję powyżej, aby zapisać."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Escolhe uma opção acima para guardar."}}, "ru": {"stringUnit": {"state": "translated", "value": "Чтобы сохранить, выбери вариант выше."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "请先在上方选择一项再保存。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "請先在上方選擇一項再儲存。"}} + } }, + "Edit sets": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Sätze bearbeiten"}}, "en": {"stringUnit": {"state": "translated", "value": "Edit sets"}}, "es": {"stringUnit": {"state": "translated", "value": "Editar series"}}, "fr": {"stringUnit": {"state": "translated", "value": "Modifier les séries"}}, "it": {"stringUnit": {"state": "translated", "value": "Modifica serie"}}, "pl": {"stringUnit": {"state": "translated", "value": "Edytuj serie"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Editar séries"}}, "ru": {"stringUnit": {"state": "translated", "value": "Изменить подходы"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "编辑各组"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "編輯各組"}} + } }, + "Save changes": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Änderungen speichern"}}, "en": {"stringUnit": {"state": "translated", "value": "Save changes"}}, "es": {"stringUnit": {"state": "translated", "value": "Guardar cambios"}}, "fr": {"stringUnit": {"state": "translated", "value": "Enregistrer"}}, "it": {"stringUnit": {"state": "translated", "value": "Salva modifiche"}}, "pl": {"stringUnit": {"state": "translated", "value": "Zapisz zmiany"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Guardar alterações"}}, "ru": {"stringUnit": {"state": "translated", "value": "Сохранить изменения"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "保存更改"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "儲存變更"}} + } }, + "Completing saves them with the grey numbers shown. Discarding keeps them out of every figure; they stay under Edit sets as zeros you can fill in later.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Abschließen speichert sie mit den grau angezeigten Werten. Verwerfen hält sie aus allen Kennzahlen heraus; unter „Sätze bearbeiten“ bleiben sie als Nullen, die du später ausfüllen kannst."}}, "en": {"stringUnit": {"state": "translated", "value": "Completing saves them with the grey numbers shown. Discarding keeps them out of every figure; they stay under Edit sets as zeros you can fill in later."}}, "es": {"stringUnit": {"state": "translated", "value": "Completarlas las guarda con los números en gris. Descartarlas las deja fuera de todas las cifras; quedan en Editar series como ceros que puedes rellenar después."}}, "fr": {"stringUnit": {"state": "translated", "value": "Les compléter les enregistre avec les chiffres affichés en gris. Les abandonner les exclut de tous les chiffres ; elles restent dans Modifier les séries sous forme de zéros à remplir plus tard."}}, "it": {"stringUnit": {"state": "translated", "value": "Completarle le salva con i numeri in grigio. Scartarle le esclude da tutti i dati; restano in Modifica serie come zeri da compilare più tardi."}}, "pl": {"stringUnit": {"state": "translated", "value": "Uzupełnienie zapisze je z liczbami pokazanymi na szaro. Odrzucenie wyłączy je ze wszystkich wskaźników; zostaną w Edytuj serie jako zera do uzupełnienia później."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Completá-las guarda-as com os números a cinzento. Descartá-las deixa-as fora de todos os valores; ficam em Editar séries como zeros que podes preencher depois."}}, "ru": {"stringUnit": {"state": "translated", "value": "Если завершить, они сохранятся с серыми числами. Если отбросить, они не войдут ни в какие показатели и останутся в «Изменить подходы» нулями, которые можно заполнить позже."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "补全会用灰色显示的数字保存它们。放弃则不计入任何数据;它们会以 0 保留在“编辑各组”中,之后可以补填。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "補全會用灰色顯示的數字儲存它們。放棄則不計入任何數據;它們會以 0 保留在「編輯各組」中,之後可以補填。"}} + } }, + "Fix numbers, or add and remove sets. Sets left at 0 reps stay out of the figures, and only this session changes — not the program.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Korrigiere Werte oder füge Sätze hinzu und entferne sie. Sätze mit 0 Wiederholungen zählen in keiner Kennzahl, und nur diese Einheit ändert sich – nicht das Programm."}}, "en": {"stringUnit": {"state": "translated", "value": "Fix numbers, or add and remove sets. Sets left at 0 reps stay out of the figures, and only this session changes — not the program."}}, "es": {"stringUnit": {"state": "translated", "value": "Corrige números o añade y quita series. Las series con 0 repeticiones no cuentan en las cifras, y solo cambia esta sesión, no el programa."}}, "fr": {"stringUnit": {"state": "translated", "value": "Corrige les chiffres, ou ajoute et retire des séries. Les séries à 0 répétition ne comptent dans aucun chiffre, et seule cette séance change — pas le programme."}}, "it": {"stringUnit": {"state": "translated", "value": "Correggi i numeri, oppure aggiungi e togli serie. Le serie a 0 ripetizioni restano fuori dai dati, e cambia solo questa sessione, non il programma."}}, "pl": {"stringUnit": {"state": "translated", "value": "Popraw liczby albo dodaj i usuń serie. Serie z 0 powtórzeń nie wchodzą do wskaźników, a zmienia się tylko ta sesja — nie program."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Corrige números ou adiciona e remove séries. As séries com 0 repetições ficam fora dos valores, e só esta sessão muda — não o programa."}}, "ru": {"stringUnit": {"state": "translated", "value": "Исправь числа или добавь и удали подходы. Подходы с 0 повторений не входят в показатели, и меняется только эта сессия — не программа."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "修正数字,或添加和删除组。0 次的组不计入数据,且只修改本次训练,不会改动计划。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "修正數字,或新增和刪除組。0 次的組不計入數據,且只修改本次訓練,不會改動計畫。"}} + } }, + "No sets were performed. Discarded sets stay under Edit sets as zeros you can fill in.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Keine Sätze absolviert. Verworfene Sätze bleiben unter „Sätze bearbeiten“ als Nullen, die du ausfüllen kannst."}}, "en": {"stringUnit": {"state": "translated", "value": "No sets were performed. Discarded sets stay under Edit sets as zeros you can fill in."}}, "es": {"stringUnit": {"state": "translated", "value": "No se hizo ninguna serie. Las series descartadas quedan en Editar series como ceros que puedes rellenar."}}, "fr": {"stringUnit": {"state": "translated", "value": "Aucune série effectuée. Les séries abandonnées restent dans Modifier les séries sous forme de zéros à remplir."}}, "it": {"stringUnit": {"state": "translated", "value": "Nessuna serie eseguita. Le serie scartate restano in Modifica serie come zeri da compilare."}}, "pl": {"stringUnit": {"state": "translated", "value": "Nie wykonano żadnej serii. Odrzucone serie zostają w Edytuj serie jako zera do uzupełnienia."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Nenhuma série feita. As séries descartadas ficam em Editar séries como zeros que podes preencher."}}, "ru": {"stringUnit": {"state": "translated", "value": "Ни один подход не выполнен. Отброшенные подходы остаются в «Изменить подходы» нулями, которые можно заполнить."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "没有完成任何一组。放弃的组会以 0 保留在“编辑各组”中,可以补填。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "沒有完成任何一組。放棄的組會以 0 保留在「編輯各組」中,可以補填。"}} + } }, + "Max RPE (1–10)": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Max. RPE (1–10)"}}, "en": {"stringUnit": {"state": "translated", "value": "Max RPE (1–10)"}}, "es": {"stringUnit": {"state": "translated", "value": "RPE máx. (1–10)"}}, "fr": {"stringUnit": {"state": "translated", "value": "RPE max (1–10)"}}, "it": {"stringUnit": {"state": "translated", "value": "RPE max (1–10)"}}, "pl": {"stringUnit": {"state": "translated", "value": "Maks. RPE (1–10)"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "RPE máx. (1–10)"}}, "ru": {"stringUnit": {"state": "translated", "value": "Макс. RPE (1–10)"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "最高 RPE(1–10)"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "最高 RPE(1–10)"}} + } }, + "Max RPE must be between 1 and 10.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Die maximale RPE muss zwischen 1 und 10 liegen."}}, "en": {"stringUnit": {"state": "translated", "value": "Max RPE must be between 1 and 10."}}, "es": {"stringUnit": {"state": "translated", "value": "El RPE máximo debe estar entre 1 y 10."}}, "fr": {"stringUnit": {"state": "translated", "value": "Le RPE max doit être compris entre 1 et 10."}}, "it": {"stringUnit": {"state": "translated", "value": "L'RPE max deve essere tra 1 e 10."}}, "pl": {"stringUnit": {"state": "translated", "value": "Maks. RPE musi mieścić się w zakresie od 1 do 10."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "O RPE máximo tem de estar entre 1 e 10."}}, "ru": {"stringUnit": {"state": "translated", "value": "Макс. RPE должен быть от 1 до 10."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "最高 RPE 必须在 1 到 10 之间。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "最高 RPE 必須介於 1 到 10 之間。"}} + } }, + "Max RPE is a ceiling: the hardest a set should feel, where 10 means nothing left. It shows grey during the session, and a set you leave unrated saves it as its rating.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Die maximale RPE ist eine Obergrenze: So hart darf sich ein Satz höchstens anfühlen, 10 heißt, nichts mehr übrig. Sie erscheint während der Einheit grau, und ein Satz ohne eigene Bewertung speichert sie als seine Bewertung."}}, "en": {"stringUnit": {"state": "translated", "value": "Max RPE is a ceiling: the hardest a set should feel, where 10 means nothing left. It shows grey during the session, and a set you leave unrated saves it as its rating."}}, "es": {"stringUnit": {"state": "translated", "value": "El RPE máximo es un techo: lo más duro que debería sentirse una serie, donde 10 significa no poder más. Se muestra en gris durante la sesión, y una serie que dejes sin valorar lo guarda como su valoración."}}, "fr": {"stringUnit": {"state": "translated", "value": "Le RPE max est un plafond : le plus dur qu'une série devrait sembler, 10 signifiant qu'il ne reste plus rien. Il s'affiche en gris pendant la séance, et une série laissée sans note l'enregistre comme la sienne."}}, "it": {"stringUnit": {"state": "translated", "value": "L'RPE max è un tetto: la fatica massima che una serie dovrebbe dare, dove 10 significa non averne più. Appare in grigio durante la sessione e una serie lasciata senza valutazione lo salva come propria."}}, "pl": {"stringUnit": {"state": "translated", "value": "Maks. RPE to górna granica: najcięższe odczucie, jakie powinna dać seria, gdzie 10 oznacza brak zapasu. Podczas sesji wyświetla się na szaro, a seria bez własnej oceny zapisze je jako swoją ocenę."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "O RPE máximo é um teto: o mais difícil que uma série deve parecer, em que 10 significa não ter mais nada. Aparece a cinzento durante a sessão, e uma série que deixes sem classificação guarda-o como a sua."}}, "ru": {"stringUnit": {"state": "translated", "value": "Макс. RPE — это потолок: насколько тяжёлым может ощущаться подход, где 10 — сил больше нет. Во время сессии он показан серым, и подход без своей оценки сохранит его как свою оценку."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "最高 RPE 是上限:一组最多应感觉多吃力,10 表示已毫无余力。训练中以灰色显示;若某组未自行评分,就会以它作为该组的评分保存。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "最高 RPE 是上限:一組最多應感覺多吃力,10 表示已毫無餘力。訓練中以灰色顯示;若某組未自行評分,就會以它作為該組的評分儲存。"}} + } }, + "max RPE %@": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "max. RPE %@"}}, "en": {"stringUnit": {"state": "translated", "value": "max RPE %@"}}, "es": {"stringUnit": {"state": "translated", "value": "RPE máx. %@"}}, "fr": {"stringUnit": {"state": "translated", "value": "RPE max %@"}}, "it": {"stringUnit": {"state": "translated", "value": "RPE max %@"}}, "pl": {"stringUnit": {"state": "translated", "value": "maks. RPE %@"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "RPE máx. %@"}}, "ru": {"stringUnit": {"state": "translated", "value": "макс. RPE %@"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "最高 RPE %@"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "最高 RPE %@"}} + } }, + "Add a set to %@": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Einen Satz zu %@ hinzufügen"}}, "en": {"stringUnit": {"state": "translated", "value": "Add a set to %@"}}, "es": {"stringUnit": {"state": "translated", "value": "Añadir una serie a %@"}}, "fr": {"stringUnit": {"state": "translated", "value": "Ajouter une série à %@"}}, "it": {"stringUnit": {"state": "translated", "value": "Aggiungi una serie a %@"}}, "pl": {"stringUnit": {"state": "translated", "value": "Dodaj serię do %@"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Adicionar uma série a %@"}}, "ru": {"stringUnit": {"state": "translated", "value": "Добавить подход к %@"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "为 %@ 添加一组"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "為 %@ 新增一組"}} + } }, + "Remove the last set from %@": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Letzten Satz von %@ entfernen"}}, "en": {"stringUnit": {"state": "translated", "value": "Remove the last set from %@"}}, "es": {"stringUnit": {"state": "translated", "value": "Quitar la última serie de %@"}}, "fr": {"stringUnit": {"state": "translated", "value": "Retirer la dernière série de %@"}}, "it": {"stringUnit": {"state": "translated", "value": "Rimuovi l'ultima serie da %@"}}, "pl": {"stringUnit": {"state": "translated", "value": "Usuń ostatnią serię z %@"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Remover a última série de %@"}}, "ru": {"stringUnit": {"state": "translated", "value": "Убрать последний подход из %@"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "移除 %@ 的最后一组"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "移除 %@ 的最後一組"}} + } }, + "The tick marks about 4 sets a week — the point below which the research doesn't reliably detect growth. Above it, gains continue with strongly diminishing returns and no clear ceiling, so the bar has no \"full\".": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Der Strich markiert etwa 4 Sätze pro Woche – den Punkt, unter dem die Forschung Wachstum nicht zuverlässig nachweist. Darüber geht es weiter, mit stark abnehmendem Ertrag und ohne klare Obergrenze; der Balken hat deshalb kein „voll“."}}, "en": {"stringUnit": {"state": "translated", "value": "The tick marks about 4 sets a week — the point below which the research doesn't reliably detect growth. Above it, gains continue with strongly diminishing returns and no clear ceiling, so the bar has no \"full\"."}}, "es": {"stringUnit": {"state": "translated", "value": "La marca señala unas 4 series por semana: el punto por debajo del cual la investigación no detecta crecimiento de forma fiable. Por encima, las ganancias continúan con rendimientos muy decrecientes y sin techo claro, así que la barra no tiene «lleno»."}}, "fr": {"stringUnit": {"state": "translated", "value": "Le repère marque environ 4 séries par semaine — le seuil en dessous duquel la recherche ne détecte pas de croissance de façon fiable. Au-dessus, les gains continuent avec de forts rendements décroissants et sans plafond net : la barre n'a donc pas de « plein »."}}, "it": {"stringUnit": {"state": "translated", "value": "La tacca segna circa 4 serie a settimana: il punto sotto il quale la ricerca non rileva la crescita in modo affidabile. Sopra, i guadagni continuano con rendimenti fortemente decrescenti e senza un tetto chiaro, quindi la barra non ha un «pieno»."}}, "pl": {"stringUnit": {"state": "translated", "value": "Znacznik wskazuje około 4 serie tygodniowo — punkt, poniżej którego badania nie wykrywają wzrostu w sposób wiarygodny. Powyżej postępy trwają, z silnie malejącymi zyskami i bez wyraźnego pułapu, więc pasek nie ma stanu „pełny”."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "A marca assinala cerca de 4 séries por semana — o ponto abaixo do qual a investigação não deteta crescimento de forma fiável. Acima, os ganhos continuam com retornos fortemente decrescentes e sem teto claro, por isso a barra não tem «cheio»."}}, "ru": {"stringUnit": {"state": "translated", "value": "Отметка показывает примерно 4 подхода в неделю — точку, ниже которой исследования не фиксируют рост надёжно. Выше рост продолжается с резко убывающей отдачей и без явного потолка, поэтому у шкалы нет «полного»."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "刻度标示每周约 4 组 —— 低于此点,研究无法可靠地检测到增长。高于此点,收益仍在继续,但回报急剧递减且没有明确上限,因此这条进度条没有“满”。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "刻度標示每週約 4 組 —— 低於此點,研究無法可靠地偵測到增長。高於此點,收益仍在持續,但回報急劇遞減且沒有明確上限,因此這條進度條沒有「滿」。"}} + } }, + "%@: %@ sets, at or above the weekly floor of %@": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "%1$@: %2$@ Sätze, auf oder über dem Wochen-Minimum von %3$@"}}, "en": {"stringUnit": {"state": "translated", "value": "%1$@: %2$@ sets, at or above the weekly floor of %3$@"}}, "es": {"stringUnit": {"state": "translated", "value": "%1$@: %2$@ series, en o por encima del mínimo semanal de %3$@"}}, "fr": {"stringUnit": {"state": "translated", "value": "%1$@ : %2$@ séries, au niveau ou au-dessus du seuil hebdomadaire de %3$@"}}, "it": {"stringUnit": {"state": "translated", "value": "%1$@: %2$@ serie, pari o superiori al minimo settimanale di %3$@"}}, "pl": {"stringUnit": {"state": "translated", "value": "%1$@: serie %2$@, na poziomie tygodniowego minimum %3$@ lub powyżej"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "%1$@: %2$@ séries, no mínimo semanal de %3$@ ou acima"}}, "ru": {"stringUnit": {"state": "translated", "value": "%1$@: подходов %2$@, на недельном минимуме %3$@ или выше"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "%1$@:%2$@ 组,达到或超过每周下限 %3$@"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "%1$@:%2$@ 組,達到或超過每週下限 %3$@"}} + } }, + "%@: %@ sets, below the weekly floor of %@": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "%1$@: %2$@ Sätze, unter dem Wochen-Minimum von %3$@"}}, "en": {"stringUnit": {"state": "translated", "value": "%1$@: %2$@ sets, below the weekly floor of %3$@"}}, "es": {"stringUnit": {"state": "translated", "value": "%1$@: %2$@ series, por debajo del mínimo semanal de %3$@"}}, "fr": {"stringUnit": {"state": "translated", "value": "%1$@ : %2$@ séries, en dessous du seuil hebdomadaire de %3$@"}}, "it": {"stringUnit": {"state": "translated", "value": "%1$@: %2$@ serie, sotto il minimo settimanale di %3$@"}}, "pl": {"stringUnit": {"state": "translated", "value": "%1$@: serie %2$@, poniżej tygodniowego minimum %3$@"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "%1$@: %2$@ séries, abaixo do mínimo semanal de %3$@"}}, "ru": {"stringUnit": {"state": "translated", "value": "%1$@: подходов %2$@, ниже недельного минимума %3$@"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "%1$@:%2$@ 组,低于每周下限 %3$@"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "%1$@:%2$@ 組,低於每週下限 %3$@"}} + } }, + "Discard session": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Einheit verwerfen"}}, "en": {"stringUnit": {"state": "translated", "value": "Discard session"}}, "es": {"stringUnit": {"state": "translated", "value": "Descartar sesión"}}, "fr": {"stringUnit": {"state": "translated", "value": "Abandonner la séance"}}, "it": {"stringUnit": {"state": "translated", "value": "Scarta la sessione"}}, "pl": {"stringUnit": {"state": "translated", "value": "Odrzuć sesję"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Descartar sessão"}}, "ru": {"stringUnit": {"state": "translated", "value": "Отменить сессию"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "放弃本次训练"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "放棄本次訓練"}} + } }, + "Discard this session?": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Diese Einheit verwerfen?"}}, "en": {"stringUnit": {"state": "translated", "value": "Discard this session?"}}, "es": {"stringUnit": {"state": "translated", "value": "¿Descartar esta sesión?"}}, "fr": {"stringUnit": {"state": "translated", "value": "Abandonner cette séance ?"}}, "it": {"stringUnit": {"state": "translated", "value": "Scartare questa sessione?"}}, "pl": {"stringUnit": {"state": "translated", "value": "Odrzucić tę sesję?"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Descartar esta sessão?"}}, "ru": {"stringUnit": {"state": "translated", "value": "Отменить эту сессию?"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "放弃本次训练?"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "放棄本次訓練?"}} + } }, + "Keep going": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Weitermachen"}}, "en": {"stringUnit": {"state": "translated", "value": "Keep going"}}, "es": {"stringUnit": {"state": "translated", "value": "Seguir"}}, "fr": {"stringUnit": {"state": "translated", "value": "Continuer"}}, "it": {"stringUnit": {"state": "translated", "value": "Continua"}}, "pl": {"stringUnit": {"state": "translated", "value": "Kontynuuj"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Continuar"}}, "ru": {"stringUnit": {"state": "translated", "value": "Продолжить"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "继续"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "繼續"}} + } }, + "%lld recorded sets will be thrown away. Nothing is saved and no workout is created.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "%lld aufgezeichnete Sätze werden verworfen. Nichts wird gespeichert und kein Workout angelegt."}}, "en": {"stringUnit": {"state": "translated", "value": "%lld recorded sets will be thrown away. Nothing is saved and no workout is created."}}, "es": {"stringUnit": {"state": "translated", "value": "Se descartarán %lld series registradas. No se guarda nada ni se crea ningún entrenamiento."}}, "fr": {"stringUnit": {"state": "translated", "value": "%lld séries enregistrées seront supprimées. Rien n'est sauvegardé et aucun entraînement n'est créé."}}, "it": {"stringUnit": {"state": "translated", "value": "%lld serie registrate verranno eliminate. Non viene salvato nulla e non viene creato alcun allenamento."}}, "pl": {"stringUnit": {"state": "translated", "value": "Zapisane serie (%lld) zostaną odrzucone. Nic nie zostanie zapisane i nie powstanie żaden trening."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "%lld séries registadas serão descartadas. Nada é guardado e nenhum treino é criado."}}, "ru": {"stringUnit": {"state": "translated", "value": "Записанных подходов будет удалено: %lld. Ничего не сохранится и тренировка не создастся."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "将丢弃已记录的 %lld 组。不会保存任何内容,也不会创建训练记录。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "將丟棄已記錄的 %lld 組。不會儲存任何內容,也不會建立訓練記錄。"}} + } }, + "%lld recorded sets will be removed, and so will the workout this session created. This cannot be undone.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "%lld aufgezeichnete Sätze werden entfernt, ebenso das von dieser Einheit erstellte Workout. Das lässt sich nicht rückgängig machen."}}, "en": {"stringUnit": {"state": "translated", "value": "%lld recorded sets will be removed, and so will the workout this session created. This cannot be undone."}}, "es": {"stringUnit": {"state": "translated", "value": "Se eliminarán %lld series registradas, y también el entrenamiento que creó esta sesión. Esto no se puede deshacer."}}, "fr": {"stringUnit": {"state": "translated", "value": "%lld séries enregistrées seront supprimées, ainsi que l'entraînement créé par cette séance. C'est irréversible."}}, "it": {"stringUnit": {"state": "translated", "value": "Verranno rimosse %lld serie registrate e anche l'allenamento creato da questa sessione. L'operazione non è reversibile."}}, "pl": {"stringUnit": {"state": "translated", "value": "Zapisane serie (%lld) zostaną usunięte, podobnie jak trening utworzony przez tę sesję. Tej operacji nie można cofnąć."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "%lld séries registadas serão removidas, tal como o treino que esta sessão criou. Isto não pode ser anulado."}}, "ru": {"stringUnit": {"state": "translated", "value": "Записанных подходов будет удалено: %lld, вместе с тренировкой, созданной этой сессией. Отменить это нельзя."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "将移除已记录的 %lld 组,以及本次训练创建的锻炼记录。此操作无法撤销。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "將移除已記錄的 %lld 組,以及本次訓練建立的鍛鍊記錄。此操作無法復原。"}} + } }, + "That file is too big to be a program sheet.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Diese Datei ist zu groß für ein Programmblatt."}}, "en": {"stringUnit": {"state": "translated", "value": "That file is too big to be a program sheet."}}, "es": {"stringUnit": {"state": "translated", "value": "Ese archivo es demasiado grande para una hoja de programa."}}, "fr": {"stringUnit": {"state": "translated", "value": "Ce fichier est trop volumineux pour une feuille de programme."}}, "it": {"stringUnit": {"state": "translated", "value": "Quel file è troppo grande per un foglio di programma."}}, "pl": {"stringUnit": {"state": "translated", "value": "Ten plik jest za duży jak na arkusz programu."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Esse ficheiro é demasiado grande para uma folha de programa."}}, "ru": {"stringUnit": {"state": "translated", "value": "Этот файл слишком велик для таблицы программы."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "该文件太大,不像是计划表格。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "該檔案太大,不像是計畫表格。"}} + } }, + "Worth checking": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Zum Prüfen"}}, "en": {"stringUnit": {"state": "translated", "value": "Worth checking"}}, "es": {"stringUnit": {"state": "translated", "value": "Conviene revisar"}}, "fr": {"stringUnit": {"state": "translated", "value": "À vérifier"}}, "it": {"stringUnit": {"state": "translated", "value": "Da controllare"}}, "pl": {"stringUnit": {"state": "translated", "value": "Warto sprawdzić"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "A verificar"}}, "ru": {"stringUnit": {"state": "translated", "value": "Стоит проверить"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "需要检查"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "需要檢查"}} + } }, + "%lld exercises": { "localizations": { + "de": {"variations": {"plural": {"one": {"stringUnit": {"state": "translated", "value": "%lld Übung"}}, "other": {"stringUnit": {"state": "translated", "value": "%lld Übungen"}}}}}, "en": {"variations": {"plural": {"one": {"stringUnit": {"state": "translated", "value": "%lld exercise"}}, "other": {"stringUnit": {"state": "translated", "value": "%lld exercises"}}}}}, "es": {"variations": {"plural": {"one": {"stringUnit": {"state": "translated", "value": "%lld ejercicio"}}, "other": {"stringUnit": {"state": "translated", "value": "%lld ejercicios"}}}}}, "fr": {"variations": {"plural": {"one": {"stringUnit": {"state": "translated", "value": "%lld exercice"}}, "other": {"stringUnit": {"state": "translated", "value": "%lld exercices"}}}}}, "it": {"variations": {"plural": {"one": {"stringUnit": {"state": "translated", "value": "%lld esercizio"}}, "other": {"stringUnit": {"state": "translated", "value": "%lld esercizi"}}}}}, "pl": {"stringUnit": {"state": "translated", "value": "Ćwiczenia: %lld"}}, "pt-PT": {"variations": {"plural": {"one": {"stringUnit": {"state": "translated", "value": "%lld exercício"}}, "other": {"stringUnit": {"state": "translated", "value": "%lld exercícios"}}}}}, "ru": {"stringUnit": {"state": "translated", "value": "Упражнений: %lld"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "%lld 个动作"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "%lld 個動作"}} + } }, + "Import a program": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Programm importieren"}}, "en": {"stringUnit": {"state": "translated", "value": "Import a program"}}, "es": {"stringUnit": {"state": "translated", "value": "Importar un programa"}}, "fr": {"stringUnit": {"state": "translated", "value": "Importer un programme"}}, "it": {"stringUnit": {"state": "translated", "value": "Importa un programma"}}, "pl": {"stringUnit": {"state": "translated", "value": "Importuj program"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Importar um programa"}}, "ru": {"stringUnit": {"state": "translated", "value": "Импорт программы"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "导入计划"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "匯入計畫"}} + } }, + "Fill it in on a computer": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Am Computer ausfüllen"}}, "en": {"stringUnit": {"state": "translated", "value": "Fill it in on a computer"}}, "es": {"stringUnit": {"state": "translated", "value": "Rellénalo en un ordenador"}}, "fr": {"stringUnit": {"state": "translated", "value": "Remplis-le sur un ordinateur"}}, "it": {"stringUnit": {"state": "translated", "value": "Compilalo al computer"}}, "pl": {"stringUnit": {"state": "translated", "value": "Wypełnij go na komputerze"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Preenche-o num computador"}}, "ru": {"stringUnit": {"state": "translated", "value": "Заполни на компьютере"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "在电脑上填写"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "在電腦上填寫"}} + } }, + "Download the template from the NOOP repository, fill in one row per exercise, then bring the file here. Excel, Numbers, Google Sheets and LibreOffice all work — .xlsx or .csv.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Lade die Vorlage aus dem NOOP-Repository, trage pro Übung eine Zeile ein und bring die Datei hierher. Excel, Numbers, Google Sheets und LibreOffice funktionieren alle – .xlsx oder .csv."}}, "en": {"stringUnit": {"state": "translated", "value": "Download the template from the NOOP repository, fill in one row per exercise, then bring the file here. Excel, Numbers, Google Sheets and LibreOffice all work — .xlsx or .csv."}}, "es": {"stringUnit": {"state": "translated", "value": "Descarga la plantilla del repositorio de NOOP, rellena una fila por ejercicio y trae el archivo aquí. Excel, Numbers, Google Sheets y LibreOffice sirven — .xlsx o .csv."}}, "fr": {"stringUnit": {"state": "translated", "value": "Télécharge le modèle depuis le dépôt NOOP, remplis une ligne par exercice, puis apporte le fichier ici. Excel, Numbers, Google Sheets et LibreOffice fonctionnent tous — .xlsx ou .csv."}}, "it": {"stringUnit": {"state": "translated", "value": "Scarica il modello dal repository di NOOP, compila una riga per esercizio e porta qui il file. Excel, Numbers, Google Sheets e LibreOffice vanno tutti bene — .xlsx o .csv."}}, "pl": {"stringUnit": {"state": "translated", "value": "Pobierz szablon z repozytorium NOOP, wypełnij jeden wiersz na ćwiczenie i przynieś plik tutaj. Excel, Numbers, Google Sheets i LibreOffice — wszystkie działają — .xlsx lub .csv."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Descarrega o modelo do repositório do NOOP, preenche uma linha por exercício e traz o ficheiro para aqui. Excel, Numbers, Google Sheets e LibreOffice funcionam todos — .xlsx ou .csv."}}, "ru": {"stringUnit": {"state": "translated", "value": "Скачай шаблон из репозитория NOOP, заполни по одной строке на упражнение и открой файл здесь. Excel, Numbers, Google Sheets и LibreOffice — подойдёт любой, .xlsx или .csv."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "从 NOOP 仓库下载模板,每个动作填一行,然后把文件拿到这里。Excel、Numbers、Google 表格和 LibreOffice 都可以 — .xlsx 或 .csv。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "從 NOOP 儲存庫下載範本,每個動作填一列,然後把檔案帶到這裡。Excel、Numbers、Google 試算表和 LibreOffice 都可以 — .xlsx 或 .csv。"}} + } }, + "Only the exercise name is required. Anything you leave blank can be filled in later, or during the session.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Nur der Übungsname ist Pflicht. Alles, was du leer lässt, kannst du später oder während der Einheit ergänzen."}}, "en": {"stringUnit": {"state": "translated", "value": "Only the exercise name is required. Anything you leave blank can be filled in later, or during the session."}}, "es": {"stringUnit": {"state": "translated", "value": "Solo el nombre del ejercicio es obligatorio. Lo que dejes en blanco puedes rellenarlo después, o durante la sesión."}}, "fr": {"stringUnit": {"state": "translated", "value": "Seul le nom de l'exercice est obligatoire. Ce que tu laisses vide peut être complété plus tard, ou pendant la séance."}}, "it": {"stringUnit": {"state": "translated", "value": "Solo il nome dell'esercizio è obbligatorio. Quello che lasci vuoto puoi compilarlo dopo, o durante la sessione."}}, "pl": {"stringUnit": {"state": "translated", "value": "Wymagana jest tylko nazwa ćwiczenia. To, co zostawisz puste, możesz uzupełnić później albo w trakcie sesji."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Só o nome do exercício é obrigatório. O que deixares em branco podes preencher depois, ou durante a sessão."}}, "ru": {"stringUnit": {"state": "translated", "value": "Обязательно только название упражнения. Всё, что оставишь пустым, можно заполнить позже или прямо во время сессии."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "只有动作名称是必填的。留空的内容可以稍后补上,或在训练中填写。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "只有動作名稱是必填的。留空的內容可以稍後補上,或在訓練中填寫。"}} + } }, + "Choose a file": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Datei auswählen"}}, "en": {"stringUnit": {"state": "translated", "value": "Choose a file"}}, "es": {"stringUnit": {"state": "translated", "value": "Elegir un archivo"}}, "fr": {"stringUnit": {"state": "translated", "value": "Choisir un fichier"}}, "it": {"stringUnit": {"state": "translated", "value": "Scegli un file"}}, "pl": {"stringUnit": {"state": "translated", "value": "Wybierz plik"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Escolher um ficheiro"}}, "ru": {"stringUnit": {"state": "translated", "value": "Выбрать файл"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "选择文件"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "選擇檔案"}} + } }, + "Choose a different file": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Andere Datei auswählen"}}, "en": {"stringUnit": {"state": "translated", "value": "Choose a different file"}}, "es": {"stringUnit": {"state": "translated", "value": "Elegir otro archivo"}}, "fr": {"stringUnit": {"state": "translated", "value": "Choisir un autre fichier"}}, "it": {"stringUnit": {"state": "translated", "value": "Scegli un altro file"}}, "pl": {"stringUnit": {"state": "translated", "value": "Wybierz inny plik"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Escolher outro ficheiro"}}, "ru": {"stringUnit": {"state": "translated", "value": "Выбрать другой файл"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "选择其他文件"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "選擇其他檔案"}} + } }, + "These lines still import — anything unclassified can be set in the app.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Diese Zeilen werden trotzdem importiert – alles ohne Zuordnung kannst du in der App setzen."}}, "en": {"stringUnit": {"state": "translated", "value": "These lines still import — anything unclassified can be set in the app."}}, "es": {"stringUnit": {"state": "translated", "value": "Estas líneas se importan igualmente: lo que quede sin clasificar puedes ajustarlo en la app."}}, "fr": {"stringUnit": {"state": "translated", "value": "Ces lignes sont quand même importées — ce qui n'est pas classé peut être réglé dans l'app."}}, "it": {"stringUnit": {"state": "translated", "value": "Queste righe vengono comunque importate: ciò che resta non classificato si imposta nell'app."}}, "pl": {"stringUnit": {"state": "translated", "value": "Te wiersze i tak zostaną zaimportowane — to, co nieprzypisane, ustawisz w aplikacji."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Estas linhas são importadas na mesma — o que ficar por classificar podes definir na app."}}, "ru": {"stringUnit": {"state": "translated", "value": "Эти строки всё равно импортируются — всё неразмеченное можно задать в приложении."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "这些行仍会导入 — 未归类的部分可以在 App 里设置。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "這些列仍會匯入 — 未歸類的部分可以在 App 裡設定。"}} + } }, + "That file could not be read.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Diese Datei konnte nicht gelesen werden."}}, "en": {"stringUnit": {"state": "translated", "value": "That file could not be read."}}, "es": {"stringUnit": {"state": "translated", "value": "No se ha podido leer ese archivo."}}, "fr": {"stringUnit": {"state": "translated", "value": "Ce fichier n'a pas pu être lu."}}, "it": {"stringUnit": {"state": "translated", "value": "Non è stato possibile leggere quel file."}}, "pl": {"stringUnit": {"state": "translated", "value": "Nie udało się odczytać tego pliku."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Não foi possível ler esse ficheiro."}}, "ru": {"stringUnit": {"state": "translated", "value": "Не удалось прочитать этот файл."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "无法读取该文件。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "無法讀取該檔案。"}} + } }, + "That does not look like a spreadsheet. Use the template, saved as .xlsx or .csv.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Das sieht nicht nach einer Tabelle aus. Nutze die Vorlage, gespeichert als .xlsx oder .csv."}}, "en": {"stringUnit": {"state": "translated", "value": "That does not look like a spreadsheet. Use the template, saved as .xlsx or .csv."}}, "es": {"stringUnit": {"state": "translated", "value": "Eso no parece una hoja de cálculo. Usa la plantilla, guardada como .xlsx o .csv."}}, "fr": {"stringUnit": {"state": "translated", "value": "Cela ne ressemble pas à un tableur. Utilise le modèle, enregistré en .xlsx ou .csv."}}, "it": {"stringUnit": {"state": "translated", "value": "Non sembra un foglio di calcolo. Usa il modello, salvato come .xlsx o .csv."}}, "pl": {"stringUnit": {"state": "translated", "value": "To nie wygląda na arkusz kalkulacyjny. Użyj szablonu zapisanego jako .xlsx lub .csv."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Isso não parece uma folha de cálculo. Usa o modelo, guardado como .xlsx ou .csv."}}, "ru": {"stringUnit": {"state": "translated", "value": "Это не похоже на таблицу. Используй шаблон, сохранённый как .xlsx или .csv."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "这看起来不是电子表格。请使用模板,另存为 .xlsx 或 .csv。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "這看起來不是試算表。請使用範本,另存為 .xlsx 或 .csv。"}} + } }, + "That sheet has no Exercise column. Use the template — the import matches on the header names.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Dieser Tabelle fehlt die Spalte „Exercise“. Nutze die Vorlage – der Import geht nach den Spaltennamen."}}, "en": {"stringUnit": {"state": "translated", "value": "That sheet has no Exercise column. Use the template — the import matches on the header names."}}, "es": {"stringUnit": {"state": "translated", "value": "Esa hoja no tiene columna «Exercise». Usa la plantilla: la importación se basa en los nombres de las cabeceras."}}, "fr": {"stringUnit": {"state": "translated", "value": "Cette feuille n'a pas de colonne « Exercise ». Utilise le modèle — l'import se base sur les noms d'en-tête."}}, "it": {"stringUnit": {"state": "translated", "value": "In quel foglio manca la colonna «Exercise». Usa il modello: l'importazione si basa sui nomi delle intestazioni."}}, "pl": {"stringUnit": {"state": "translated", "value": "Ten arkusz nie ma kolumny „Exercise”. Użyj szablonu — import dopasowuje się po nazwach nagłówków."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Essa folha não tem coluna «Exercise». Usa o modelo — a importação baseia-se nos nomes dos cabeçalhos."}}, "ru": {"stringUnit": {"state": "translated", "value": "В этой таблице нет столбца «Exercise». Используй шаблон — импорт ориентируется на названия заголовков."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "该表格没有 Exercise 列。请使用模板 — 导入是按表头名称匹配的。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "該表格沒有 Exercise 欄。請使用範本 — 匯入是按標題名稱比對的。"}} + } }, + "That sheet has no exercises in it yet.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "In dieser Tabelle stehen noch keine Übungen."}}, "en": {"stringUnit": {"state": "translated", "value": "That sheet has no exercises in it yet."}}, "es": {"stringUnit": {"state": "translated", "value": "Esa hoja todavía no tiene ejercicios."}}, "fr": {"stringUnit": {"state": "translated", "value": "Cette feuille ne contient encore aucun exercice."}}, "it": {"stringUnit": {"state": "translated", "value": "Quel foglio non contiene ancora esercizi."}}, "pl": {"stringUnit": {"state": "translated", "value": "Ten arkusz nie zawiera jeszcze ćwiczeń."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Essa folha ainda não tem exercícios."}}, "ru": {"stringUnit": {"state": "translated", "value": "В этой таблице пока нет упражнений."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "该表格里还没有动作。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "該表格裡還沒有動作。"}} + } }, + "Set %lld": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Satz %lld"}}, "en": {"stringUnit": {"state": "translated", "value": "Set %lld"}}, "es": {"stringUnit": {"state": "translated", "value": "Serie %lld"}}, "fr": {"stringUnit": {"state": "translated", "value": "Série %lld"}}, "it": {"stringUnit": {"state": "translated", "value": "Serie %lld"}}, "pl": {"stringUnit": {"state": "translated", "value": "Seria %lld"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Série %lld"}}, "ru": {"stringUnit": {"state": "translated", "value": "Подход %lld"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "第 %lld 组"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "第 %lld 組"}} + } }, + "Heart rate %lld": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Herzfrequenz %lld"}}, "en": {"stringUnit": {"state": "translated", "value": "Heart rate %lld"}}, "es": {"stringUnit": {"state": "translated", "value": "Frecuencia cardíaca %lld"}}, "fr": {"stringUnit": {"state": "translated", "value": "Fréquence cardiaque %lld"}}, "it": {"stringUnit": {"state": "translated", "value": "Frequenza cardiaca %lld"}}, "pl": {"stringUnit": {"state": "translated", "value": "Tętno %lld"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Frequência cardíaca %lld"}}, "ru": {"stringUnit": {"state": "translated", "value": "Пульс %lld"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "心率 %lld"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "心率 %lld"}} + } }, + "HR": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "HF"}}, "en": {"stringUnit": {"state": "translated", "value": "HR"}}, "es": {"stringUnit": {"state": "translated", "value": "FC"}}, "fr": {"stringUnit": {"state": "translated", "value": "FC"}}, "it": {"stringUnit": {"state": "translated", "value": "FC"}}, "pl": {"stringUnit": {"state": "translated", "value": "HR"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "FC"}}, "ru": {"stringUnit": {"state": "translated", "value": "Пульс"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "心率"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "心率"}} + } }, + "A program is a name and an ordered list of exercises with your targets — working sets, reps, weight, rest and your own technique note.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Ein Programm ist ein Name und eine geordnete Liste von Übungen mit deinen Zielen – Arbeitssätze, Wiederholungen, Gewicht, Pause und deine eigene Technik-Notiz."}}, "en": {"stringUnit": {"state": "translated", "value": "A program is a name and an ordered list of exercises with your targets — working sets, reps, weight, rest and your own technique note."}}, "es": {"stringUnit": {"state": "translated", "value": "Un programa es un nombre y una lista ordenada de ejercicios con tus objetivos: series efectivas, repeticiones, peso, descanso y tu propia nota de técnica."}}, "fr": {"stringUnit": {"state": "translated", "value": "Un programme, c'est un nom et une liste ordonnée d'exercices avec tes objectifs : séries de travail, répétitions, charge, repos et ta propre note de technique."}}, "it": {"stringUnit": {"state": "translated", "value": "Un programma è un nome e un elenco ordinato di esercizi con i tuoi obiettivi: serie di lavoro, ripetizioni, carico, recupero e la tua nota sulla tecnica."}}, "pl": {"stringUnit": {"state": "translated", "value": "Program to nazwa i uporządkowana lista ćwiczeń z Twoimi celami – serie robocze, powtórzenia, ciężar, przerwa i Twoja własna notatka o technice."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Um programa é um nome e uma lista ordenada de exercícios com os teus objetivos: séries de trabalho, repetições, peso, descanso e a tua própria nota de técnica."}}, "ru": {"stringUnit": {"state": "translated", "value": "Программа — это название и упорядоченный список упражнений с твоими целями: рабочие подходы, повторения, вес, отдых и твоя заметка о технике."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "一个计划就是一个名称加上一份有序的动作列表,附带你的目标:正式组、次数、重量、休息时间,以及你自己的技术笔记。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "一個計畫就是一個名稱加上一份有序的動作列表,附帶你的目標:正式組、次數、重量、休息時間,以及你自己的技術筆記。"}} + } }, + "Set %lld — tap to mark it a warm-up": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Satz %lld – tippen, um ihn als Aufwärmsatz zu markieren" } }, "en": { "stringUnit": { "state": "translated", "value": "Set %lld — tap to mark it a warm-up" } }, "es": { "stringUnit": { "state": "translated", "value": "Serie %lld: toca para marcarla como calentamiento" } }, "fr": { "stringUnit": { "state": "translated", "value": "Série %lld — touche pour la marquer comme échauffement" } }, "it": { "stringUnit": { "state": "translated", "value": "Serie %lld — tocca per segnarla come riscaldamento" } }, "pl": { "stringUnit": { "state": "translated", "value": "Seria %lld – dotknij, aby oznaczyć jako rozgrzewkową" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Série %lld — toca para marcar como aquecimento" } }, "ru": { "stringUnit": { "state": "translated", "value": "Подход %lld — нажми, чтобы отметить как разминочный" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "第 %lld 组——点按标记为热身组" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "第 %lld 組——點按標記為熱身組" } } + } }, + "Warm-up set — tap to make it a working set": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Aufwärmsatz – tippen, um ihn zum Arbeitssatz zu machen" } }, "en": { "stringUnit": { "state": "translated", "value": "Warm-up set — tap to make it a working set" } }, "es": { "stringUnit": { "state": "translated", "value": "Serie de calentamiento: toca para convertirla en serie efectiva" } }, "fr": { "stringUnit": { "state": "translated", "value": "Série d'échauffement — touche pour en faire une série de travail" } }, "it": { "stringUnit": { "state": "translated", "value": "Serie di riscaldamento — tocca per renderla una serie di lavoro" } }, "pl": { "stringUnit": { "state": "translated", "value": "Seria rozgrzewkowa – dotknij, aby zmienić na roboczą" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Série de aquecimento — toca para a tornar série de trabalho" } }, "ru": { "stringUnit": { "state": "translated", "value": "Разминочный подход — нажми, чтобы сделать рабочим" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "热身组——点按改为正式组" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "熱身組——點按改為正式組" } } + } }, + "Forget": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Vergessen" } }, "en": { "stringUnit": { "state": "translated", "value": "Forget" } }, "es": { "stringUnit": { "state": "translated", "value": "Olvidar" } }, "fr": { "stringUnit": { "state": "translated", "value": "Oublier" } }, "it": { "stringUnit": { "state": "translated", "value": "Dimentica" } }, "pl": { "stringUnit": { "state": "translated", "value": "Zapomnij" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Esquecer" } }, "ru": { "stringUnit": { "state": "translated", "value": "Забыть" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "忘记" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "忘記" } } + } }, + "Forget this exercise": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Diese Übung vergessen" } }, "en": { "stringUnit": { "state": "translated", "value": "Forget this exercise" } }, "es": { "stringUnit": { "state": "translated", "value": "Olvidar este ejercicio" } }, "fr": { "stringUnit": { "state": "translated", "value": "Oublier cet exercice" } }, "it": { "stringUnit": { "state": "translated", "value": "Dimentica questo esercizio" } }, "pl": { "stringUnit": { "state": "translated", "value": "Zapomnij to ćwiczenie" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Esquecer este exercício" } }, "ru": { "stringUnit": { "state": "translated", "value": "Забыть это упражнение" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "忘记这个动作" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "忘記這個動作" } } + } }, + "Forget %@?": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "%@ vergessen?" } }, "en": { "stringUnit": { "state": "translated", "value": "Forget %@?" } }, "es": { "stringUnit": { "state": "translated", "value": "¿Olvidar %@?" } }, "fr": { "stringUnit": { "state": "translated", "value": "Oublier %@ ?" } }, "it": { "stringUnit": { "state": "translated", "value": "Dimenticare %@?" } }, "pl": { "stringUnit": { "state": "translated", "value": "Zapomnieć %@?" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Esquecer %@?" } }, "ru": { "stringUnit": { "state": "translated", "value": "Забыть %@?" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "忘记「%@」?" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "忘記「%@」?" } } + } }, + "It stops being offered here. Sessions you already logged with it are kept exactly as they are.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Sie wird hier nicht mehr vorgeschlagen. Bereits damit aufgezeichnete Sitzungen bleiben unverändert." } }, "en": { "stringUnit": { "state": "translated", "value": "It stops being offered here. Sessions you already logged with it are kept exactly as they are." } }, "es": { "stringUnit": { "state": "translated", "value": "Deja de ofrecerse aquí. Las sesiones que ya registraste con él se mantienen tal cual." } }, "fr": { "stringUnit": { "state": "translated", "value": "Il cesse d'être proposé ici. Les séances déjà enregistrées avec lui restent intactes." } }, "it": { "stringUnit": { "state": "translated", "value": "Smette di essere proposto qui. Le sessioni già registrate con esso restano identiche." } }, "pl": { "stringUnit": { "state": "translated", "value": "Przestaje być proponowane tutaj. Zapisane już z nim sesje pozostają bez zmian." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Deixa de ser sugerido aqui. As sessões que já registaste com ele mantêm-se tal como estão." } }, "ru": { "stringUnit": { "state": "translated", "value": "Оно перестанет предлагаться здесь. Уже записанные с ним сессии останутся без изменений." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "它不会再出现在建议里。你已经用它记录的训练完全不受影响。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "它不會再出現在建議裡。你已經用它記錄的訓練完全不受影響。" } } + } }, + "You've saved the most exercises NOOP remembers": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Du hast so viele Übungen gespeichert, wie NOOP sich merkt" } }, "en": { "stringUnit": { "state": "translated", "value": "You've saved the most exercises NOOP remembers" } }, "es": { "stringUnit": { "state": "translated", "value": "Has guardado el máximo de ejercicios que NOOP recuerda" } }, "fr": { "stringUnit": { "state": "translated", "value": "Tu as enregistré le maximum d'exercices que NOOP retient" } }, "it": { "stringUnit": { "state": "translated", "value": "Hai salvato il massimo di esercizi che NOOP ricorda" } }, "pl": { "stringUnit": { "state": "translated", "value": "Zapisałeś maksymalną liczbę ćwiczeń, jaką NOOP pamięta" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Guardaste o máximo de exercícios que o NOOP memoriza" } }, "ru": { "stringUnit": { "state": "translated", "value": "Ты сохранил столько упражнений, сколько NOOP запоминает" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "你保存的动作已达 NOOP 记忆上限" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "你儲存的動作已達 NOOP 記憶上限" } } + } }, + "Forget one you no longer use and this one will save. Your logged sessions are never affected.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Vergiss eine, die du nicht mehr nutzt, dann lässt sich diese speichern. Deine aufgezeichneten Sitzungen sind nie betroffen." } }, "en": { "stringUnit": { "state": "translated", "value": "Forget one you no longer use and this one will save. Your logged sessions are never affected." } }, "es": { "stringUnit": { "state": "translated", "value": "Olvida uno que ya no uses y este se guardará. Tus sesiones registradas nunca se ven afectadas." } }, "fr": { "stringUnit": { "state": "translated", "value": "Oublie-en un que tu n'utilises plus et celui-ci sera enregistré. Tes séances enregistrées ne sont jamais touchées." } }, "it": { "stringUnit": { "state": "translated", "value": "Dimenticane uno che non usi più e questo verrà salvato. Le tue sessioni registrate non vengono mai toccate." } }, "pl": { "stringUnit": { "state": "translated", "value": "Zapomnij jedno, którego już nie używasz, a to się zapisze. Twoje zapisane sesje nigdy nie są naruszane." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Esquece um que já não uses e este será guardado. As tuas sessões registadas nunca são afetadas." } }, "ru": { "stringUnit": { "state": "translated", "value": "Забудь то, которым больше не пользуешься, и это сохранится. Записанные сессии никогда не затрагиваются." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "忘记一个你不再用的,这个就能保存。你已记录的训练不会受任何影响。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "忘記一個你不再用的,這個就能儲存。你已記錄的訓練不會受任何影響。" } } + } }, + "Set": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Satz" } }, "en": { "stringUnit": { "state": "translated", "value": "Set" } }, "es": { "stringUnit": { "state": "translated", "value": "Serie" } }, "fr": { "stringUnit": { "state": "translated", "value": "Série" } }, "it": { "stringUnit": { "state": "translated", "value": "Serie" } }, "pl": { "stringUnit": { "state": "translated", "value": "Seria" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Série" } }, "ru": { "stringUnit": { "state": "translated", "value": "Подход" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "组" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "組" } } + } }, + "Reps": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Wdh." } }, "en": { "stringUnit": { "state": "translated", "value": "Reps" } }, "es": { "stringUnit": { "state": "translated", "value": "Reps" } }, "fr": { "stringUnit": { "state": "translated", "value": "Réps" } }, "it": { "stringUnit": { "state": "translated", "value": "Rip." } }, "pl": { "stringUnit": { "state": "translated", "value": "Powt." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Reps" } }, "ru": { "stringUnit": { "state": "translated", "value": "Повт." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "次数" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "次數" } } + } }, + "RPE": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "RPE" } }, "en": { "stringUnit": { "state": "translated", "value": "RPE" } }, "es": { "stringUnit": { "state": "translated", "value": "RPE" } }, "fr": { "stringUnit": { "state": "translated", "value": "RPE" } }, "it": { "stringUnit": { "state": "translated", "value": "RPE" } }, "pl": { "stringUnit": { "state": "translated", "value": "RPE" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "RPE" } }, "ru": { "stringUnit": { "state": "translated", "value": "RPE" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "RPE" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "RPE" } } + } }, + "Kg": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "kg" } }, "en": { "stringUnit": { "state": "translated", "value": "Kg" } }, "es": { "stringUnit": { "state": "translated", "value": "kg" } }, "fr": { "stringUnit": { "state": "translated", "value": "kg" } }, "it": { "stringUnit": { "state": "translated", "value": "kg" } }, "pl": { "stringUnit": { "state": "translated", "value": "kg" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "kg" } }, "ru": { "stringUnit": { "state": "translated", "value": "кг" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "公斤" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "公斤" } } + } }, + "Lb": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "lb" } }, "en": { "stringUnit": { "state": "translated", "value": "Lb" } }, "es": { "stringUnit": { "state": "translated", "value": "lb" } }, "fr": { "stringUnit": { "state": "translated", "value": "lb" } }, "it": { "stringUnit": { "state": "translated", "value": "lb" } }, "pl": { "stringUnit": { "state": "translated", "value": "lb" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "lb" } }, "ru": { "stringUnit": { "state": "translated", "value": "фунты" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "磅" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "磅" } } + } }, + "Start this set": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Diesen Satz starten" } }, "en": { "stringUnit": { "state": "translated", "value": "Start this set" } }, "es": { "stringUnit": { "state": "translated", "value": "Empezar esta serie" } }, "fr": { "stringUnit": { "state": "translated", "value": "Démarrer cette série" } }, "it": { "stringUnit": { "state": "translated", "value": "Avvia questa serie" } }, "pl": { "stringUnit": { "state": "translated", "value": "Rozpocznij tę serię" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Iniciar esta série" } }, "ru": { "stringUnit": { "state": "translated", "value": "Начать этот подход" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "开始这一组" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "開始這一組" } } + } }, + "Redo this set": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Diesen Satz wiederholen" } }, "en": { "stringUnit": { "state": "translated", "value": "Redo this set" } }, "es": { "stringUnit": { "state": "translated", "value": "Rehacer esta serie" } }, "fr": { "stringUnit": { "state": "translated", "value": "Refaire cette série" } }, "it": { "stringUnit": { "state": "translated", "value": "Rifai questa serie" } }, "pl": { "stringUnit": { "state": "translated", "value": "Powtórz tę serię" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Refazer esta série" } }, "ru": { "stringUnit": { "state": "translated", "value": "Переделать подход" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "重做这一组" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "重做這一組" } } + } }, + "Open the running session": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Laufende Sitzung öffnen" } }, "en": { "stringUnit": { "state": "translated", "value": "Open the running session" } }, "es": { "stringUnit": { "state": "translated", "value": "Abrir la sesión en curso" } }, "fr": { "stringUnit": { "state": "translated", "value": "Ouvrir la séance en cours" } }, "it": { "stringUnit": { "state": "translated", "value": "Apri la sessione in corso" } }, "pl": { "stringUnit": { "state": "translated", "value": "Otwórz trwającą sesję" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Abrir a sessão em curso" } }, "ru": { "stringUnit": { "state": "translated", "value": "Открыть текущую сессию" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "打开进行中的训练" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "打開進行中的訓練" } } + } }, + "How hard was the whole session? (1–10)": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Wie schwer war die ganze Sitzung? (1–10)" } }, "en": { "stringUnit": { "state": "translated", "value": "How hard was the whole session? (1–10)" } }, "es": { "stringUnit": { "state": "translated", "value": "¿Cómo de dura fue toda la sesión? (1–10)" } }, "fr": { "stringUnit": { "state": "translated", "value": "Quelle a été la difficulté de la séance ? (1–10)" } }, "it": { "stringUnit": { "state": "translated", "value": "Quanto è stata dura l'intera sessione? (1–10)" } }, "pl": { "stringUnit": { "state": "translated", "value": "Jak ciężka była cała sesja? (1–10)" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Quão difícil foi toda a sessão? (1–10)" } }, "ru": { "stringUnit": { "state": "translated", "value": "Насколько тяжёлой была вся сессия? (1–10)" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "整场训练有多吃力?(1–10)" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "整場訓練有多吃力?(1–10)" } } + } }, + "%lld of %lld sets done": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "%1$lld von %2$lld Sätzen erledigt" } }, "en": { "stringUnit": { "state": "translated", "value": "%lld of %lld sets done" } }, "es": { "stringUnit": { "state": "translated", "value": "%1$lld de %2$lld series hechas" } }, "fr": { "stringUnit": { "state": "translated", "value": "%1$lld sur %2$lld séries faites" } }, "it": { "stringUnit": { "state": "translated", "value": "%1$lld di %2$lld serie fatte" } }, "pl": { "stringUnit": { "state": "translated", "value": "%1$lld z %2$lld serii zrobione" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "%1$lld de %2$lld séries feitas" } }, "ru": { "stringUnit": { "state": "translated", "value": "%1$lld из %2$lld подходов сделано" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "已完成 %1$lld / %2$lld 组" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "已完成 %1$lld / %2$lld 組" } } + } }, + "Set %lld — working": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Satz %lld – läuft" } }, "en": { "stringUnit": { "state": "translated", "value": "Set %lld — working" } }, "es": { "stringUnit": { "state": "translated", "value": "Serie %lld — en curso" } }, "fr": { "stringUnit": { "state": "translated", "value": "Série %lld — en cours" } }, "it": { "stringUnit": { "state": "translated", "value": "Serie %lld — in corso" } }, "pl": { "stringUnit": { "state": "translated", "value": "Seria %lld – trwa" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Série %lld — em curso" } }, "ru": { "stringUnit": { "state": "translated", "value": "Подход %lld — идёт" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "第 %lld 组进行中" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "第 %lld 組進行中" } } + } }, + "Resting after set %lld": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Pause nach Satz %lld" } }, "en": { "stringUnit": { "state": "translated", "value": "Resting after set %lld" } }, "es": { "stringUnit": { "state": "translated", "value": "Descansando tras la serie %lld" } }, "fr": { "stringUnit": { "state": "translated", "value": "Repos après la série %lld" } }, "it": { "stringUnit": { "state": "translated", "value": "Recupero dopo la serie %lld" } }, "pl": { "stringUnit": { "state": "translated", "value": "Przerwa po serii %lld" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "A descansar após a série %lld" } }, "ru": { "stringUnit": { "state": "translated", "value": "Отдых после подхода %lld" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "第 %lld 组后休息" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "第 %lld 組後休息" } } + } }, + "Ready for the next set": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Bereit für den nächsten Satz" } }, "en": { "stringUnit": { "state": "translated", "value": "Ready for the next set" } }, "es": { "stringUnit": { "state": "translated", "value": "Listo para la siguiente serie" } }, "fr": { "stringUnit": { "state": "translated", "value": "Prêt pour la série suivante" } }, "it": { "stringUnit": { "state": "translated", "value": "Pronto per la serie successiva" } }, "pl": { "stringUnit": { "state": "translated", "value": "Gotowe na następną serię" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Pronto para a próxima série" } }, "ru": { "stringUnit": { "state": "translated", "value": "Готов к следующему подходу" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "可以开始下一组" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "可以開始下一組" } } + } }, + "Next: Set %lld · %@": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Als Nächstes: Satz %1$lld · %2$@" } }, "en": { "stringUnit": { "state": "translated", "value": "Next: Set %lld · %@" } }, "es": { "stringUnit": { "state": "translated", "value": "Siguiente: serie %1$lld · %2$@" } }, "fr": { "stringUnit": { "state": "translated", "value": "Ensuite : série %1$lld · %2$@" } }, "it": { "stringUnit": { "state": "translated", "value": "Prossima: serie %1$lld · %2$@" } }, "pl": { "stringUnit": { "state": "translated", "value": "Następna: seria %1$lld · %2$@" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "A seguir: série %1$lld · %2$@" } }, "ru": { "stringUnit": { "state": "translated", "value": "Далее: подход %1$lld · %2$@" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "接下来:第 %1$lld 组 · %2$@" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "接下來:第 %1$lld 組 · %2$@" } } + } }, + "Last set": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Letzter Satz" } }, "en": { "stringUnit": { "state": "translated", "value": "Last set" } }, "es": { "stringUnit": { "state": "translated", "value": "Última serie" } }, "fr": { "stringUnit": { "state": "translated", "value": "Dernière série" } }, "it": { "stringUnit": { "state": "translated", "value": "Ultima serie" } }, "pl": { "stringUnit": { "state": "translated", "value": "Ostatnia seria" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Última série" } }, "ru": { "stringUnit": { "state": "translated", "value": "Последний подход" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "最后一组" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "最後一組" } } + } }, + "No session running": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Keine Sitzung aktiv" } }, "en": { "stringUnit": { "state": "translated", "value": "No session running" } }, "es": { "stringUnit": { "state": "translated", "value": "No hay sesión en curso" } }, "fr": { "stringUnit": { "state": "translated", "value": "Aucune séance en cours" } }, "it": { "stringUnit": { "state": "translated", "value": "Nessuna sessione in corso" } }, "pl": { "stringUnit": { "state": "translated", "value": "Brak trwającej sesji" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Nenhuma sessão em curso" } }, "ru": { "stringUnit": { "state": "translated", "value": "Нет активной сессии" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "没有进行中的训练" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "沒有進行中的訓練" } } + } }, + "All sets done": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Alle Sätze erledigt" } }, "en": { "stringUnit": { "state": "translated", "value": "All sets done" } }, "es": { "stringUnit": { "state": "translated", "value": "Todas las series hechas" } }, "fr": { "stringUnit": { "state": "translated", "value": "Toutes les séries faites" } }, "it": { "stringUnit": { "state": "translated", "value": "Tutte le serie fatte" } }, "pl": { "stringUnit": { "state": "translated", "value": "Wszystkie serie zrobione" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Todas as séries feitas" } }, "ru": { "stringUnit": { "state": "translated", "value": "Все подходы сделаны" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "所有组已完成" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "所有組已完成" } } + } }, + "This set": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Dieser Satz" } }, "en": { "stringUnit": { "state": "translated", "value": "This set" } }, "es": { "stringUnit": { "state": "translated", "value": "Esta serie" } }, "fr": { "stringUnit": { "state": "translated", "value": "Cette série" } }, "it": { "stringUnit": { "state": "translated", "value": "Questa serie" } }, "pl": { "stringUnit": { "state": "translated", "value": "Ta seria" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Esta série" } }, "ru": { "stringUnit": { "state": "translated", "value": "Этот подход" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "本组" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "本組" } } + } }, + "RPE %@ × %@ min": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$@ Min" } }, "en": { "stringUnit": { "state": "translated", "value": "RPE %@ × %@ min" } }, "es": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$@ min" } }, "fr": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$@ min" } }, "it": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$@ min" } }, "pl": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$@ min" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$@ min" } }, "ru": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$@ мин" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$@ 分钟" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$@ 分鐘" } } + } }, + "Best set": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Bester Satz" } }, "en": { "stringUnit": { "state": "translated", "value": "Best set" } }, "es": { "stringUnit": { "state": "translated", "value": "Mejor serie" } }, "fr": { "stringUnit": { "state": "translated", "value": "Meilleure série" } }, "it": { "stringUnit": { "state": "translated", "value": "Serie migliore" } }, "pl": { "stringUnit": { "state": "translated", "value": "Najlepsza seria" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Melhor série" } }, "ru": { "stringUnit": { "state": "translated", "value": "Лучший подход" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "最佳组" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "最佳組" } } + } }, + "Mean RPE": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Mittlerer RPE" } }, "en": { "stringUnit": { "state": "translated", "value": "Mean RPE" } }, "es": { "stringUnit": { "state": "translated", "value": "RPE medio" } }, "fr": { "stringUnit": { "state": "translated", "value": "RPE moyen" } }, "it": { "stringUnit": { "state": "translated", "value": "RPE medio" } }, "pl": { "stringUnit": { "state": "translated", "value": "Średnie RPE" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "RPE médio" } }, "ru": { "stringUnit": { "state": "translated", "value": "Средний RPE" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "平均 RPE" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "平均 RPE" } } + } }, + "How hard it felt": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Wie schwer es sich anfühlte" } }, "en": { "stringUnit": { "state": "translated", "value": "How hard it felt" } }, "es": { "stringUnit": { "state": "translated", "value": "Lo duro que se sintió" } }, "fr": { "stringUnit": { "state": "translated", "value": "À quel point c'était dur" } }, "it": { "stringUnit": { "state": "translated", "value": "Quanto è sembrato duro" } }, "pl": { "stringUnit": { "state": "translated", "value": "Jak ciężko było" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Quão difícil pareceu" } }, "ru": { "stringUnit": { "state": "translated", "value": "Насколько было тяжело" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "感觉有多吃力" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "感覺有多吃力" } } + } }, + "Sets per muscle": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Sätze pro Muskel" } }, "en": { "stringUnit": { "state": "translated", "value": "Sets per muscle" } }, "es": { "stringUnit": { "state": "translated", "value": "Series por músculo" } }, "fr": { "stringUnit": { "state": "translated", "value": "Séries par muscle" } }, "it": { "stringUnit": { "state": "translated", "value": "Serie per muscolo" } }, "pl": { "stringUnit": { "state": "translated", "value": "Serie na mięsień" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Séries por músculo" } }, "ru": { "stringUnit": { "state": "translated", "value": "Подходы на мышцу" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "每个肌群的组数" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "每個肌群的組數" } } + } }, + "Session load": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Sitzungsbelastung" } }, "en": { "stringUnit": { "state": "translated", "value": "Session load" } }, "es": { "stringUnit": { "state": "translated", "value": "Carga de sesión" } }, "fr": { "stringUnit": { "state": "translated", "value": "Charge de séance" } }, "it": { "stringUnit": { "state": "translated", "value": "Carico della sessione" } }, "pl": { "stringUnit": { "state": "translated", "value": "Obciążenie sesji" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Carga da sessão" } }, "ru": { "stringUnit": { "state": "translated", "value": "Нагрузка сессии" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "训练负荷" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "訓練負荷" } } + } }, + "Work vs rest": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Arbeit vs. Pause" } }, "en": { "stringUnit": { "state": "translated", "value": "Work vs rest" } }, "es": { "stringUnit": { "state": "translated", "value": "Trabajo vs descanso" } }, "fr": { "stringUnit": { "state": "translated", "value": "Travail vs repos" } }, "it": { "stringUnit": { "state": "translated", "value": "Lavoro vs recupero" } }, "pl": { "stringUnit": { "state": "translated", "value": "Praca vs przerwa" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Trabalho vs descanso" } }, "ru": { "stringUnit": { "state": "translated", "value": "Работа и отдых" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "做功与休息" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "做功與休息" } } + } }, + "Figures": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Zahlen" } }, "en": { "stringUnit": { "state": "translated", "value": "Figures" } }, "es": { "stringUnit": { "state": "translated", "value": "Cifras" } }, "fr": { "stringUnit": { "state": "translated", "value": "Chiffres" } }, "it": { "stringUnit": { "state": "translated", "value": "Numeri" } }, "pl": { "stringUnit": { "state": "translated", "value": "Liczby" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Números" } }, "ru": { "stringUnit": { "state": "translated", "value": "Показатели" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "数据" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "數據" } } + } }, + "As performed": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Wie ausgeführt" } }, "en": { "stringUnit": { "state": "translated", "value": "As performed" } }, "es": { "stringUnit": { "state": "translated", "value": "Tal como se hizo" } }, "fr": { "stringUnit": { "state": "translated", "value": "Tel qu'exécuté" } }, "it": { "stringUnit": { "state": "translated", "value": "Come eseguito" } }, "pl": { "stringUnit": { "state": "translated", "value": "Jak wykonano" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Tal como feito" } }, "ru": { "stringUnit": { "state": "translated", "value": "Как выполнено" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "实际完成" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "實際完成" } } + } }, + "Reading the session…": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Sitzung wird gelesen…" } }, "en": { "stringUnit": { "state": "translated", "value": "Reading the session…" } }, "es": { "stringUnit": { "state": "translated", "value": "Leyendo la sesión…" } }, "fr": { "stringUnit": { "state": "translated", "value": "Lecture de la séance…" } }, "it": { "stringUnit": { "state": "translated", "value": "Lettura della sessione…" } }, "pl": { "stringUnit": { "state": "translated", "value": "Wczytywanie sesji…" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "A ler a sessão…" } }, "ru": { "stringUnit": { "state": "translated", "value": "Читаем сессию…" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "正在读取训练…" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "正在讀取訓練…" } } + } }, + "not rated": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "nicht bewertet" } }, "en": { "stringUnit": { "state": "translated", "value": "not rated" } }, "es": { "stringUnit": { "state": "translated", "value": "sin valorar" } }, "fr": { "stringUnit": { "state": "translated", "value": "non évaluée" } }, "it": { "stringUnit": { "state": "translated", "value": "non valutata" } }, "pl": { "stringUnit": { "state": "translated", "value": "bez oceny" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "sem classificação" } }, "ru": { "stringUnit": { "state": "translated", "value": "без оценки" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "未评分" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "未評分" } } + } }, + "same as last time": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "wie letztes Mal" } }, "en": { "stringUnit": { "state": "translated", "value": "same as last time" } }, "es": { "stringUnit": { "state": "translated", "value": "igual que la última vez" } }, "fr": { "stringUnit": { "state": "translated", "value": "comme la dernière fois" } }, "it": { "stringUnit": { "state": "translated", "value": "come l'ultima volta" } }, "pl": { "stringUnit": { "state": "translated", "value": "tak jak ostatnio" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "igual à última vez" } }, "ru": { "stringUnit": { "state": "translated", "value": "как в прошлый раз" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "与上次相同" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "與上次相同" } } + } }, + "RPE %@": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "RPE %@" } }, "en": { "stringUnit": { "state": "translated", "value": "RPE %@" } }, "es": { "stringUnit": { "state": "translated", "value": "RPE %@" } }, "fr": { "stringUnit": { "state": "translated", "value": "RPE %@" } }, "it": { "stringUnit": { "state": "translated", "value": "RPE %@" } }, "pl": { "stringUnit": { "state": "translated", "value": "RPE %@" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "RPE %@" } }, "ru": { "stringUnit": { "state": "translated", "value": "RPE %@" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "RPE %@" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "RPE %@" } } + } }, + "Sets at RPE %@ or above": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Sätze bei RPE %@ oder höher" } }, "en": { "stringUnit": { "state": "translated", "value": "Sets at RPE %@ or above" } }, "es": { "stringUnit": { "state": "translated", "value": "Series con RPE %@ o más" } }, "fr": { "stringUnit": { "state": "translated", "value": "Séries à RPE %@ ou plus" } }, "it": { "stringUnit": { "state": "translated", "value": "Serie a RPE %@ o superiore" } }, "pl": { "stringUnit": { "state": "translated", "value": "Serie przy RPE %@ lub wyżej" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Séries com RPE %@ ou mais" } }, "ru": { "stringUnit": { "state": "translated", "value": "Подходы с RPE %@ и выше" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "RPE %@ 及以上的组数" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "RPE %@ 及以上的組數" } } + } }, + "%lld working sets": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "%lld Arbeitssätze" } }, "en": { "stringUnit": { "state": "translated", "value": "%lld working sets" } }, "es": { "stringUnit": { "state": "translated", "value": "%lld series efectivas" } }, "fr": { "stringUnit": { "state": "translated", "value": "%lld séries de travail" } }, "it": { "stringUnit": { "state": "translated", "value": "%lld serie di lavoro" } }, "pl": { "stringUnit": { "state": "translated", "value": "%lld serii roboczych" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "%lld séries de trabalho" } }, "ru": { "stringUnit": { "state": "translated", "value": "%lld рабочих подходов" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "%lld 个正式组" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "%lld 個正式組" } } + } }, + "%@ under load": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "%@ unter Last" } }, "en": { "stringUnit": { "state": "translated", "value": "%@ under load" } }, "es": { "stringUnit": { "state": "translated", "value": "%@ bajo carga" } }, "fr": { "stringUnit": { "state": "translated", "value": "%@ sous charge" } }, "it": { "stringUnit": { "state": "translated", "value": "%@ sotto carico" } }, "pl": { "stringUnit": { "state": "translated", "value": "%@ pod obciążeniem" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "%@ sob carga" } }, "ru": { "stringUnit": { "state": "translated", "value": "%@ под нагрузкой" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "负重 %@" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "負重 %@" } } + } }, + "RPE %@ × %lld min": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$lld Min" } }, "en": { "stringUnit": { "state": "translated", "value": "RPE %@ × %lld min" } }, "es": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$lld min" } }, "fr": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$lld min" } }, "it": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$lld min" } }, "pl": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$lld min" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$lld min" } }, "ru": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$lld мин" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$lld 分钟" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "RPE %1$@ × %2$lld 分鐘" } } + } }, + "%lld direct · %lld indirect": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "%1$lld direkt · %2$lld indirekt" } }, "en": { "stringUnit": { "state": "translated", "value": "%lld direct · %lld indirect" } }, "es": { "stringUnit": { "state": "translated", "value": "%1$lld directas · %2$lld indirectas" } }, "fr": { "stringUnit": { "state": "translated", "value": "%1$lld directes · %2$lld indirectes" } }, "it": { "stringUnit": { "state": "translated", "value": "%1$lld dirette · %2$lld indirette" } }, "pl": { "stringUnit": { "state": "translated", "value": "%1$lld bezpośrednich · %2$lld pośrednich" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "%1$lld diretas · %2$lld indiretas" } }, "ru": { "stringUnit": { "state": "translated", "value": "%1$lld прямых · %2$lld косвенных" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "%1$lld 直接 · %2$lld 间接" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "%1$lld 直接 · %2$lld 間接" } } + } }, + "%lld direct": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "%lld direkt" } }, "en": { "stringUnit": { "state": "translated", "value": "%lld direct" } }, "es": { "stringUnit": { "state": "translated", "value": "%lld directas" } }, "fr": { "stringUnit": { "state": "translated", "value": "%lld directes" } }, "it": { "stringUnit": { "state": "translated", "value": "%lld dirette" } }, "pl": { "stringUnit": { "state": "translated", "value": "%lld bezpośrednich" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "%lld diretas" } }, "ru": { "stringUnit": { "state": "translated", "value": "%lld прямых" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "%lld 直接" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "%lld 直接" } } + } }, + "%lld indirect": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "%lld indirekt" } }, "en": { "stringUnit": { "state": "translated", "value": "%lld indirect" } }, "es": { "stringUnit": { "state": "translated", "value": "%lld indirectas" } }, "fr": { "stringUnit": { "state": "translated", "value": "%lld indirectes" } }, "it": { "stringUnit": { "state": "translated", "value": "%lld indirette" } }, "pl": { "stringUnit": { "state": "translated", "value": "%lld pośrednich" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "%lld indiretas" } }, "ru": { "stringUnit": { "state": "translated", "value": "%lld косвенных" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "%lld 间接" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "%lld 間接" } } + } }, + "≈ %@ estimated 1RM": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "≈ %@ geschätztes 1RM" } }, "en": { "stringUnit": { "state": "translated", "value": "≈ %@ estimated 1RM" } }, "es": { "stringUnit": { "state": "translated", "value": "≈ %@ 1RM estimado" } }, "fr": { "stringUnit": { "state": "translated", "value": "≈ %@ 1RM estimé" } }, "it": { "stringUnit": { "state": "translated", "value": "≈ %@ 1RM stimato" } }, "pl": { "stringUnit": { "state": "translated", "value": "≈ %@ szacowane 1RM" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "≈ %@ 1RM estimado" } }, "ru": { "stringUnit": { "state": "translated", "value": "≈ %@ расчётный 1ПМ" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "≈ %@ 估算 1RM" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "≈ %@ 估算 1RM" } } + } }, + "%lld working sets weren't rated, so the mean is drawn from %lld.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "%1$lld Arbeitssätze wurden nicht bewertet, der Mittelwert stammt also aus %2$lld." } }, "en": { "stringUnit": { "state": "translated", "value": "%lld working sets weren't rated, so the mean is drawn from %lld." } }, "es": { "stringUnit": { "state": "translated", "value": "%1$lld series efectivas no se valoraron, así que la media sale de %2$lld." } }, "fr": { "stringUnit": { "state": "translated", "value": "%1$lld séries de travail n'ont pas été évaluées ; la moyenne porte donc sur %2$lld." } }, "it": { "stringUnit": { "state": "translated", "value": "%1$lld serie di lavoro non sono state valutate, quindi la media si basa su %2$lld." } }, "pl": { "stringUnit": { "state": "translated", "value": "%1$lld serii roboczych nie oceniono, więc średnia pochodzi z %2$lld." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "%1$lld séries de trabalho não foram classificadas, por isso a média vem de %2$lld." } }, "ru": { "stringUnit": { "state": "translated", "value": "%1$lld рабочих подходов не оценены, поэтому среднее посчитано по %2$lld." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "有 %1$lld 个正式组未评分,因此平均值来自 %2$lld 组。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "有 %1$lld 個正式組未評分,因此平均值來自 %2$lld 組。" } } + } }, + "Direct sets count once, indirect sets count as a half — the method the reference figures were derived under.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Direkte Sätze zählen einfach, indirekte als halber Satz – die Methode, unter der die Referenzwerte ermittelt wurden." } }, "en": { "stringUnit": { "state": "translated", "value": "Direct sets count once, indirect sets count as a half — the method the reference figures were derived under." } }, "es": { "stringUnit": { "state": "translated", "value": "Las series directas cuentan una vez y las indirectas media: el método con el que se obtuvieron las cifras de referencia." } }, "fr": { "stringUnit": { "state": "translated", "value": "Les séries directes comptent pour une, les indirectes pour une demie — la méthode sous laquelle les valeurs de référence ont été établies." } }, "it": { "stringUnit": { "state": "translated", "value": "Le serie dirette contano una volta, quelle indirette una metà: il metodo con cui sono stati ricavati i valori di riferimento." } }, "pl": { "stringUnit": { "state": "translated", "value": "Serie bezpośrednie liczą się raz, pośrednie jako pół – metoda, według której wyznaczono wartości odniesienia." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "As séries diretas contam uma vez e as indiretas meia — o método com que os valores de referência foram obtidos." } }, "ru": { "stringUnit": { "state": "translated", "value": "Прямые подходы считаются за один, косвенные — за половину: именно так были получены эталонные значения." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "直接组计为一组,间接组计为半组——参考数值正是在这个方法下得出的。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "直接組計為一組,間接組計為半組——參考數值正是在這個方法下得出的。" } } + } }, + "Finished sessions land here, with every set you logged.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Abgeschlossene Sitzungen landen hier – mit jedem Satz, den du aufgezeichnet hast." } }, "en": { "stringUnit": { "state": "translated", "value": "Finished sessions land here, with every set you logged." } }, "es": { "stringUnit": { "state": "translated", "value": "Las sesiones terminadas aparecen aquí, con cada serie que registraste." } }, "fr": { "stringUnit": { "state": "translated", "value": "Les séances terminées arrivent ici, avec chaque série que tu as enregistrée." } }, "it": { "stringUnit": { "state": "translated", "value": "Le sessioni concluse finiscono qui, con ogni serie che hai registrato." } }, "pl": { "stringUnit": { "state": "translated", "value": "Zakończone sesje trafiają tutaj, z każdą zapisaną serią." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "As sessões terminadas aparecem aqui, com todas as séries que registaste." } }, "ru": { "stringUnit": { "state": "translated", "value": "Завершённые сессии попадают сюда — со всеми записанными подходами." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "完成的训练会出现在这里,包含你记录的每一组。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "完成的訓練會出現在這裡,包含你記錄的每一組。" } } + } }, + "Lifting figures are worked out from the sets above. Effort stays measured from heart rate and is never derived from weights and reps.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Die Kraftzahlen werden aus den Sätzen oben berechnet. Die Belastung bleibt aus der Herzfrequenz gemessen und wird nie aus Gewichten und Wiederholungen abgeleitet." } }, "en": { "stringUnit": { "state": "translated", "value": "Lifting figures are worked out from the sets above. Effort stays measured from heart rate and is never derived from weights and reps." } }, "es": { "stringUnit": { "state": "translated", "value": "Las cifras de fuerza salen de las series de arriba. El Esfuerzo se sigue midiendo por frecuencia cardíaca y nunca se deriva de pesos y repeticiones." } }, "fr": { "stringUnit": { "state": "translated", "value": "Les chiffres de musculation sont calculés à partir des séries ci-dessus. L'Effort reste mesuré par la fréquence cardiaque et n'est jamais déduit des charges et répétitions." } }, "it": { "stringUnit": { "state": "translated", "value": "I numeri della forza sono calcolati dalle serie qui sopra. Lo Sforzo resta misurato dalla frequenza cardiaca e non viene mai derivato da pesi e ripetizioni." } }, "pl": { "stringUnit": { "state": "translated", "value": "Liczby siłowe wynikają z serii powyżej. Wysiłek nadal mierzony jest tętnem i nigdy nie jest wyprowadzany z ciężarów i powtórzeń." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Os números de força são calculados a partir das séries acima. O Esforço continua medido pela frequência cardíaca e nunca é derivado de pesos e repetições." } }, "ru": { "stringUnit": { "state": "translated", "value": "Силовые показатели считаются из подходов выше. Усилие по-прежнему измеряется по пульсу и никогда не выводится из весов и повторений." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "力量数据由上面的组次算出。消耗始终由心率测得,绝不会从重量和次数推导。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "力量數據由上面的組次算出。消耗始終由心率測得,絕不會從重量和次數推導。" } } + } }, + "None of these exercises has a muscle group yet. Add one on the exercise and every future session counts toward it.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Noch keine dieser Übungen hat eine Muskelgruppe. Trag eine bei der Übung ein, und jede künftige Sitzung zählt darauf ein." } }, "en": { "stringUnit": { "state": "translated", "value": "None of these exercises has a muscle group yet. Add one on the exercise and every future session counts toward it." } }, "es": { "stringUnit": { "state": "translated", "value": "Ninguno de estos ejercicios tiene aún grupo muscular. Añade uno al ejercicio y todas las sesiones futuras contarán para él." } }, "fr": { "stringUnit": { "state": "translated", "value": "Aucun de ces exercices n'a encore de groupe musculaire. Ajoutes-en un à l'exercice et chaque séance future comptera dedans." } }, "it": { "stringUnit": { "state": "translated", "value": "Nessuno di questi esercizi ha ancora un gruppo muscolare. Aggiungine uno all'esercizio e ogni sessione futura conterà su di esso." } }, "pl": { "stringUnit": { "state": "translated", "value": "Żadne z tych ćwiczeń nie ma jeszcze grupy mięśniowej. Dodaj ją do ćwiczenia, a każda kolejna sesja będzie się na nią liczyć." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Nenhum destes exercícios tem ainda grupo muscular. Adiciona um ao exercício e todas as sessões futuras contam para ele." } }, "ru": { "stringUnit": { "state": "translated", "value": "Ни одно из этих упражнений пока не имеет мышечной группы. Добавь её к упражнению — и каждая следующая сессия пойдёт в зачёт." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "这些动作还没有肌群。给动作添加一个,之后每次训练都会计入其中。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "這些動作還沒有肌群。給動作新增一個,之後每次訓練都會計入其中。" } } + } }, + "Once you've logged a session, this shows how many sets each muscle got this week, against what the research associates with growth.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Sobald du eine Sitzung aufgezeichnet hast, siehst du hier, wie viele Sätze jeder Muskel diese Woche bekommen hat – im Vergleich zu dem, was die Forschung mit Wachstum verbindet." } }, "en": { "stringUnit": { "state": "translated", "value": "Once you've logged a session, this shows how many sets each muscle got this week, against what the research associates with growth." } }, "es": { "stringUnit": { "state": "translated", "value": "En cuanto registres una sesión, aquí verás cuántas series recibió cada músculo esta semana, frente a lo que la investigación asocia con el crecimiento." } }, "fr": { "stringUnit": { "state": "translated", "value": "Dès que tu auras enregistré une séance, tu verras ici combien de séries chaque muscle a reçues cette semaine, face à ce que la recherche associe à la croissance." } }, "it": { "stringUnit": { "state": "translated", "value": "Appena registri una sessione, qui vedrai quante serie ha ricevuto ogni muscolo questa settimana, rispetto a ciò che la ricerca associa alla crescita." } }, "pl": { "stringUnit": { "state": "translated", "value": "Gdy zapiszesz sesję, zobaczysz tu, ile serii dostał w tym tygodniu każdy mięsień, na tle tego, co badania wiążą ze wzrostem." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Assim que registares uma sessão, verás aqui quantas séries cada músculo recebeu esta semana, face ao que a investigação associa ao crescimento." } }, "ru": { "stringUnit": { "state": "translated", "value": "Как только запишешь сессию, здесь появится, сколько подходов получила каждая мышца за неделю — рядом с тем, что исследования связывают с ростом." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "记录一次训练后,这里会显示本周每个肌群做了多少组,并与研究中和增长相关的数值对照。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "記錄一次訓練後,這裡會顯示本週每個肌群做了多少組,並與研究中和增長相關的數值對照。" } } + } }, + "The bar marks about 4 sets a week — the point below which the research doesn't reliably detect growth. Above it, gains continue with strongly diminishing returns and no clear ceiling.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Der Balken markiert etwa 4 Sätze pro Woche – die Grenze, unterhalb derer die Forschung kein verlässliches Wachstum feststellt. Darüber gehen die Zuwächse weiter, mit stark abnehmendem Ertrag und ohne klare Obergrenze." } }, "en": { "stringUnit": { "state": "translated", "value": "The bar marks about 4 sets a week — the point below which the research doesn't reliably detect growth. Above it, gains continue with strongly diminishing returns and no clear ceiling." } }, "es": { "stringUnit": { "state": "translated", "value": "La barra marca unas 4 series por semana: el punto por debajo del cual la investigación no detecta crecimiento de forma fiable. Por encima, las ganancias siguen con rendimientos muy decrecientes y sin techo claro." } }, "fr": { "stringUnit": { "state": "translated", "value": "La barre marque environ 4 séries par semaine — le seuil sous lequel la recherche ne détecte pas de croissance fiable. Au-dessus, les gains continuent avec des rendements fortement décroissants et sans plafond net." } }, "it": { "stringUnit": { "state": "translated", "value": "La barra segna circa 4 serie a settimana: la soglia sotto la quale la ricerca non rileva crescita in modo affidabile. Sopra, i guadagni continuano con rendimenti fortemente decrescenti e senza un tetto chiaro." } }, "pl": { "stringUnit": { "state": "translated", "value": "Pasek oznacza około 4 serie tygodniowo – próg, poniżej którego badania nie wykrywają wzrostu w sposób wiarygodny. Powyżej przyrosty trwają, ale z silnie malejącym zwrotem i bez wyraźnego sufitu." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "A barra marca cerca de 4 séries por semana — o ponto abaixo do qual a investigação não deteta crescimento de forma fiável. Acima, os ganhos continuam com retornos fortemente decrescentes e sem teto claro." } }, "ru": { "stringUnit": { "state": "translated", "value": "Полоса отмечает примерно 4 подхода в неделю — порог, ниже которого исследования не выявляют рост надёжно. Выше прирост продолжается, но с резко убывающей отдачей и без явного потолка." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "这条线标记每周约 4 组——低于此值,研究无法可靠地检测到增长。高于此值,增长仍在继续,但收益急剧递减,且没有明确上限。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "這條線標記每週約 4 組——低於此值,研究無法可靠地檢測到增長。高於此值,增長仍在繼續,但收益急劇遞減,且沒有明確上限。" } } + } }, + "Every target is optional — this is the plan, not the record. What you actually lift is entered set by set during the session.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Jedes Ziel ist optional – das ist der Plan, nicht das Protokoll. Was du tatsächlich hebst, trägst du während der Sitzung Satz für Satz ein." } }, "en": { "stringUnit": { "state": "translated", "value": "Every target is optional — this is the plan, not the record. What you actually lift is entered set by set during the session." } }, "es": { "stringUnit": { "state": "translated", "value": "Todos los objetivos son opcionales: esto es el plan, no el registro. Lo que realmente levantas se introduce serie a serie durante la sesión." } }, "fr": { "stringUnit": { "state": "translated", "value": "Chaque objectif est facultatif : ceci est le plan, pas le relevé. Ce que tu soulèves vraiment se saisit série par série pendant la séance." } }, "it": { "stringUnit": { "state": "translated", "value": "Ogni obiettivo è facoltativo: questo è il piano, non il registro. Quello che sollevi davvero lo inserisci serie per serie durante la sessione." } }, "pl": { "stringUnit": { "state": "translated", "value": "Każdy cel jest opcjonalny – to plan, nie zapis. To, co naprawdę podniesiesz, wpisujesz seria po serii w trakcie sesji." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Todos os objetivos são opcionais: isto é o plano, não o registo. O que levantas de facto é introduzido série a série durante a sessão." } }, "ru": { "stringUnit": { "state": "translated", "value": "Любая цель необязательна — это план, а не запись. То, что ты действительно поднял, вводится подход за подходом во время сессии." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "所有目标都是可选的——这是计划,不是记录。你实际举起的重量,在训练中一组一组地输入。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "所有目標都是可選的——這是計畫,不是記錄。你實際舉起的重量,在訓練中一組一組地輸入。" } } + } }, + "Pre-filled with your target, or what you did last time. Correct it to what you actually lifted.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Vorausgefüllt mit deinem Ziel oder dem, was du letztes Mal gemacht hast. Korrigiere es auf das, was du tatsächlich gehoben hast." } }, "en": { "stringUnit": { "state": "translated", "value": "Pre-filled with your target, or what you did last time. Correct it to what you actually lifted." } }, "es": { "stringUnit": { "state": "translated", "value": "Rellenado con tu objetivo o con lo que hiciste la última vez. Corrígelo con lo que de verdad levantaste." } }, "fr": { "stringUnit": { "state": "translated", "value": "Pré-rempli avec ton objectif ou ce que tu as fait la dernière fois. Corrige-le avec ce que tu as vraiment soulevé." } }, "it": { "stringUnit": { "state": "translated", "value": "Precompilato con il tuo obiettivo o con quello che hai fatto l'ultima volta. Correggilo con ciò che hai davvero sollevato." } }, "pl": { "stringUnit": { "state": "translated", "value": "Wypełnione Twoim celem lub tym, co robiłeś ostatnio. Popraw na to, co naprawdę podniosłeś." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Preenchido com o teu objetivo ou com o que fizeste da última vez. Corrige para o que levantaste realmente." } }, "ru": { "stringUnit": { "state": "translated", "value": "Заполнено твоей целью или тем, что ты делал в прошлый раз. Исправь на то, что действительно поднял." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "已填入你的目标,或你上次的数据。改成你实际举起的重量。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "已填入你的目標,或你上次的資料。改成你實際舉起的重量。" } } + } }, + "What you just did — %@, set %lld": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Was du gerade gemacht hast – %1$@, Satz %2$lld" } }, "en": { "stringUnit": { "state": "translated", "value": "What you just did — %@, set %lld" } }, "es": { "stringUnit": { "state": "translated", "value": "Lo que acabas de hacer: %1$@, serie %2$lld" } }, "fr": { "stringUnit": { "state": "translated", "value": "Ce que tu viens de faire — %1$@, série %2$lld" } }, "it": { "stringUnit": { "state": "translated", "value": "Quello che hai appena fatto — %1$@, serie %2$lld" } }, "pl": { "stringUnit": { "state": "translated", "value": "To, co przed chwilą zrobiłeś — %1$@, seria %2$lld" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "O que acabaste de fazer — %1$@, série %2$lld" } }, "ru": { "stringUnit": { "state": "translated", "value": "Что ты только что сделал — %1$@, подход %2$lld" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "你刚做的——%1$@,第 %2$lld 组" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "你剛做的——%1$@,第 %2$lld 組" } } + } }, + "This set %@": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Dieser Satz %@" } }, "en": { "stringUnit": { "state": "translated", "value": "This set %@" } }, "es": { "stringUnit": { "state": "translated", "value": "Esta serie %@" } }, "fr": { "stringUnit": { "state": "translated", "value": "Cette série %@" } }, "it": { "stringUnit": { "state": "translated", "value": "Questa serie %@" } }, "pl": { "stringUnit": { "state": "translated", "value": "Ta seria %@" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Esta série %@" } }, "ru": { "stringUnit": { "state": "translated", "value": "Этот подход %@" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "本组 %@" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "本組 %@" } } + } }, + "Resting %@": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Pause %@" } }, "en": { "stringUnit": { "state": "translated", "value": "Resting %@" } }, "es": { "stringUnit": { "state": "translated", "value": "Descansando %@" } }, "fr": { "stringUnit": { "state": "translated", "value": "Repos %@" } }, "it": { "stringUnit": { "state": "translated", "value": "Recupero %@" } }, "pl": { "stringUnit": { "state": "translated", "value": "Przerwa %@" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "A descansar %@" } }, "ru": { "stringUnit": { "state": "translated", "value": "Отдых %@" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "休息 %@" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "休息 %@" } } + } }, + "Warming up %@": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Aufwärmen %@" } }, "en": { "stringUnit": { "state": "translated", "value": "Warming up %@" } }, "es": { "stringUnit": { "state": "translated", "value": "Calentando %@" } }, "fr": { "stringUnit": { "state": "translated", "value": "Échauffement %@" } }, "it": { "stringUnit": { "state": "translated", "value": "Riscaldamento %@" } }, "pl": { "stringUnit": { "state": "translated", "value": "Rozgrzewka %@" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "A aquecer %@" } }, "ru": { "stringUnit": { "state": "translated", "value": "Разминка %@" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "热身 %@" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "熱身 %@" } } + } }, + "Cooling down %@": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Abwärmen %@" } }, "en": { "stringUnit": { "state": "translated", "value": "Cooling down %@" } }, "es": { "stringUnit": { "state": "translated", "value": "Enfriando %@" } }, "fr": { "stringUnit": { "state": "translated", "value": "Retour au calme %@" } }, "it": { "stringUnit": { "state": "translated", "value": "Defaticamento %@" } }, "pl": { "stringUnit": { "state": "translated", "value": "Schłodzenie %@" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "A arrefecer %@" } }, "ru": { "stringUnit": { "state": "translated", "value": "Заминка %@" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "放松 %@" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "放鬆 %@" } } + } }, + "Enter the set you just did, then start the next one.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Trag den Satz ein, den du gerade gemacht hast, und starte dann den nächsten." } }, "en": { "stringUnit": { "state": "translated", "value": "Enter the set you just did, then start the next one." } }, "es": { "stringUnit": { "state": "translated", "value": "Introduce la serie que acabas de hacer y luego empieza la siguiente." } }, "fr": { "stringUnit": { "state": "translated", "value": "Saisis la série que tu viens de faire, puis lance la suivante." } }, "it": { "stringUnit": { "state": "translated", "value": "Inserisci la serie che hai appena fatto, poi inizia la successiva." } }, "pl": { "stringUnit": { "state": "translated", "value": "Wpisz serię, którą przed chwilą zrobiłeś, a potem zacznij następną." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Introduz a série que acabaste de fazer e depois começa a seguinte." } }, "ru": { "stringUnit": { "state": "translated", "value": "Введи подход, который только что сделал, затем начни следующий." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "输入你刚做完的那组,然后开始下一组。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "輸入你剛做完的那組,然後開始下一組。" } } + } }, + "Enter your last set, then finish and save.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Trag deinen letzten Satz ein, dann abschließen und sichern." } }, "en": { "stringUnit": { "state": "translated", "value": "Enter your last set, then finish and save." } }, "es": { "stringUnit": { "state": "translated", "value": "Introduce tu última serie, luego termina y guarda." } }, "fr": { "stringUnit": { "state": "translated", "value": "Saisis ta dernière série, puis termine et enregistre." } }, "it": { "stringUnit": { "state": "translated", "value": "Inserisci l'ultima serie, poi concludi e salva." } }, "pl": { "stringUnit": { "state": "translated", "value": "Wpisz ostatnią serię, a potem zakończ i zapisz." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Introduz a tua última série, depois termina e guarda." } }, "ru": { "stringUnit": { "state": "translated", "value": "Введи последний подход, затем заверши и сохрани." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "输入最后一组,然后结束并保存。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "輸入最後一組,然後結束並儲存。" } } + } }, + "Press the button, or double-tap your strap, when the set is done.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Drück den Knopf oder tippe zweimal auf dein Band, wenn der Satz fertig ist." } }, "en": { "stringUnit": { "state": "translated", "value": "Press the button, or double-tap your strap, when the set is done." } }, "es": { "stringUnit": { "state": "translated", "value": "Pulsa el botón, o toca dos veces tu banda, cuando termines la serie." } }, "fr": { "stringUnit": { "state": "translated", "value": "Appuie sur le bouton, ou tape deux fois sur ton bracelet, quand la série est finie." } }, "it": { "stringUnit": { "state": "translated", "value": "Premi il pulsante, o tocca due volte la fascia, quando la serie è finita." } }, "pl": { "stringUnit": { "state": "translated", "value": "Naciśnij przycisk albo stuknij dwukrotnie w opaskę, gdy skończysz serię." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Carrega no botão, ou toca duas vezes na tua banda, quando acabares a série." } }, "ru": { "stringUnit": { "state": "translated", "value": "Нажми кнопку или дважды коснись браслета, когда закончишь подход." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "做完这组后,按下按钮,或双击手环。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "做完這組後,按下按鈕,或輕點兩下手環。" } } + } }, + "That's the last set": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Das war der letzte Satz" } }, "en": { "stringUnit": { "state": "translated", "value": "That's the last set" } }, "es": { "stringUnit": { "state": "translated", "value": "Esa fue la última serie" } }, "fr": { "stringUnit": { "state": "translated", "value": "C'était la dernière série" } }, "it": { "stringUnit": { "state": "translated", "value": "Quella era l'ultima serie" } }, "pl": { "stringUnit": { "state": "translated", "value": "To była ostatnia seria" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Essa foi a última série" } }, "ru": { "stringUnit": { "state": "translated", "value": "Это был последний подход" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "这是最后一组" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "這是最後一組" } } + } }, + "Pre-filled with what you did last time. Change anything that's different today.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Vorausgefüllt mit dem, was du letztes Mal gemacht hast. Ändere alles, was heute anders ist." } }, "en": { "stringUnit": { "state": "translated", "value": "Pre-filled with what you did last time. Change anything that's different today." } }, "es": { "stringUnit": { "state": "translated", "value": "Rellenado con lo que hiciste la última vez. Cambia lo que hoy sea distinto." } }, "fr": { "stringUnit": { "state": "translated", "value": "Pré-rempli avec ce que tu as fait la dernière fois. Modifie ce qui change aujourd'hui." } }, "it": { "stringUnit": { "state": "translated", "value": "Precompilato con quello che hai fatto l'ultima volta. Cambia ciò che oggi è diverso." } }, "pl": { "stringUnit": { "state": "translated", "value": "Wypełnione tym, co robiłeś ostatnio. Zmień to, co dziś jest inne." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Preenchido com o que fizeste da última vez. Muda o que hoje for diferente." } }, "ru": { "stringUnit": { "state": "translated", "value": "Заполнено тем, что ты делал в прошлый раз. Измени всё, что сегодня иначе." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "已填入你上次的数据。今天有变化的地方改一下就行。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "已填入你上次的資料。今天有變化的地方改一下就行。" } } + } }, + "This is session RPE. Multiplied by the session's length it gives session load — the one figure that compares across completely different training.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Das ist der Sitzungs-RPE. Mal der Dauer der Sitzung ergibt er die Sitzungsbelastung – die eine Zahl, die sich über völlig verschiedene Trainingsarten hinweg vergleichen lässt." } }, "en": { "stringUnit": { "state": "translated", "value": "This is session RPE. Multiplied by the session's length it gives session load — the one figure that compares across completely different training." } }, "es": { "stringUnit": { "state": "translated", "value": "Esto es el RPE de sesión. Multiplicado por la duración da la carga de sesión: la única cifra comparable entre entrenamientos completamente distintos." } }, "fr": { "stringUnit": { "state": "translated", "value": "C'est le RPE de séance. Multiplié par la durée, il donne la charge de séance — le seul chiffre comparable entre des entraînements totalement différents." } }, "it": { "stringUnit": { "state": "translated", "value": "Questo è l'RPE della sessione. Moltiplicato per la durata dà il carico della sessione: l'unico numero confrontabile fra allenamenti completamente diversi." } }, "pl": { "stringUnit": { "state": "translated", "value": "To jest RPE sesji. Pomnożone przez czas trwania daje obciążenie sesji – jedyną liczbę porównywalną między zupełnie różnymi treningami." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Isto é o RPE da sessão. Multiplicado pela duração dá a carga da sessão — o único número comparável entre treinos completamente diferentes." } }, "ru": { "stringUnit": { "state": "translated", "value": "Это RPE сессии. Умноженный на её длительность, он даёт нагрузку сессии — единственную величину, сравнимую между совершенно разными тренировками." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "这是整场 RPE。乘以训练时长就得到训练负荷——唯一能在完全不同的训练之间比较的数字。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "這是整場 RPE。乘以訓練時長就得到訓練負荷——唯一能在完全不同的訓練之間比較的數字。" } } + } }, + "Discard": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Verwerfen" } }, "en": { "stringUnit": { "state": "translated", "value": "Discard" } }, "es": { "stringUnit": { "state": "translated", "value": "Descartar" } }, "fr": { "stringUnit": { "state": "translated", "value": "Abandonner" } }, "it": { "stringUnit": { "state": "translated", "value": "Scarta" } }, "pl": { "stringUnit": { "state": "translated", "value": "Odrzuć" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Descartar" } }, "ru": { "stringUnit": { "state": "translated", "value": "Отбросить" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "放弃" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "放棄" } } + } }, + "Next": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Weiter" } }, "en": { "stringUnit": { "state": "translated", "value": "Next" } }, "es": { "stringUnit": { "state": "translated", "value": "Siguiente" } }, "fr": { "stringUnit": { "state": "translated", "value": "Suivant" } }, "it": { "stringUnit": { "state": "translated", "value": "Avanti" } }, "pl": { "stringUnit": { "state": "translated", "value": "Dalej" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Seguinte" } }, "ru": { "stringUnit": { "state": "translated", "value": "Далее" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "下一步" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "下一步" } } + } }, + "Rest period": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Pause" } }, "en": { "stringUnit": { "state": "translated", "value": "Rest period" } }, "es": { "stringUnit": { "state": "translated", "value": "Descanso entre series" } }, "fr": { "stringUnit": { "state": "translated", "value": "Temps de repos" } }, "it": { "stringUnit": { "state": "translated", "value": "Recupero" } }, "pl": { "stringUnit": { "state": "translated", "value": "Przerwa" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Descanso entre séries" } }, "ru": { "stringUnit": { "state": "translated", "value": "Отдых между подходами" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "组间休息" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "組間休息" } } + } }, + "Resting": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Pause läuft" } }, "en": { "stringUnit": { "state": "translated", "value": "Resting" } }, "es": { "stringUnit": { "state": "translated", "value": "Descansando" } }, "fr": { "stringUnit": { "state": "translated", "value": "Repos en cours" } }, "it": { "stringUnit": { "state": "translated", "value": "In recupero" } }, "pl": { "stringUnit": { "state": "translated", "value": "Przerwa" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "A descansar" } }, "ru": { "stringUnit": { "state": "translated", "value": "Отдыхаешь" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "休息中" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "休息中" } } + } }, + "Ready when you are": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Bereit, wenn du es bist" } }, "en": { "stringUnit": { "state": "translated", "value": "Ready when you are" } }, "es": { "stringUnit": { "state": "translated", "value": "Cuando quieras" } }, "fr": { "stringUnit": { "state": "translated", "value": "Quand tu veux" } }, "it": { "stringUnit": { "state": "translated", "value": "Quando vuoi" } }, "pl": { "stringUnit": { "state": "translated", "value": "Gotowe, gdy będziesz gotowy" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Quando quiseres" } }, "ru": { "stringUnit": { "state": "translated", "value": "Готово, когда будешь готов" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "准备好就开始" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "準備好就開始" } } + } }, + "Warming up": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Aufwärmen" } }, "en": { "stringUnit": { "state": "translated", "value": "Warming up" } }, "es": { "stringUnit": { "state": "translated", "value": "Calentando" } }, "fr": { "stringUnit": { "state": "translated", "value": "Échauffement" } }, "it": { "stringUnit": { "state": "translated", "value": "Riscaldamento" } }, "pl": { "stringUnit": { "state": "translated", "value": "Rozgrzewka" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "A aquecer" } }, "ru": { "stringUnit": { "state": "translated", "value": "Разминка" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "热身中" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "熱身中" } } + } }, + "Warm-up set": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Aufwärmsatz" } }, "en": { "stringUnit": { "state": "translated", "value": "Warm-up set" } }, "es": { "stringUnit": { "state": "translated", "value": "Serie de calentamiento" } }, "fr": { "stringUnit": { "state": "translated", "value": "Série d'échauffement" } }, "it": { "stringUnit": { "state": "translated", "value": "Serie di riscaldamento" } }, "pl": { "stringUnit": { "state": "translated", "value": "Seria rozgrzewkowa" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Série de aquecimento" } }, "ru": { "stringUnit": { "state": "translated", "value": "Разминочный подход" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "热身组" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "熱身組" } } + } }, + "Everything before your first set counts as the warm-up. Nothing is being recorded yet.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Alles vor deinem ersten Satz zählt als Aufwärmen. Es wird noch nichts aufgezeichnet." } }, "en": { "stringUnit": { "state": "translated", "value": "Everything before your first set counts as the warm-up. Nothing is being recorded yet." } }, "es": { "stringUnit": { "state": "translated", "value": "Todo lo anterior a tu primera serie cuenta como calentamiento. Aún no se registra nada." } }, "fr": { "stringUnit": { "state": "translated", "value": "Tout ce qui précède ta première série compte comme échauffement. Rien n'est encore enregistré." } }, "it": { "stringUnit": { "state": "translated", "value": "Tutto ciò che precede la prima serie conta come riscaldamento. Non viene ancora registrato nulla." } }, "pl": { "stringUnit": { "state": "translated", "value": "Wszystko przed pierwszą serią liczy się jako rozgrzewka. Nic nie jest jeszcze zapisywane." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Tudo antes da tua primeira série conta como aquecimento. Ainda não está a ser registado nada." } }, "ru": { "stringUnit": { "state": "translated", "value": "Всё до первого подхода считается разминкой. Пока ничего не записывается." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "第一组之前的一切都算热身。现在还没有开始记录。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "第一組之前的一切都算熱身。現在還沒有開始記錄。" } } + } }, + "Tap to finish. The session is saved as a workout, so it shows up in Workouts and Today like any other.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Tippe zum Abschließen. Die Sitzung wird als Workout gesichert und taucht wie jedes andere in Workouts und Heute auf." } }, "en": { "stringUnit": { "state": "translated", "value": "Tap to finish. The session is saved as a workout, so it shows up in Workouts and Today like any other." } }, "es": { "stringUnit": { "state": "translated", "value": "Toca para terminar. La sesión se guarda como un entrenamiento, así que aparece en Entrenamientos y Hoy como cualquier otro." } }, "fr": { "stringUnit": { "state": "translated", "value": "Touche pour terminer. La séance est enregistrée comme un entraînement et apparaît dans Entraînements et Aujourd'hui comme les autres." } }, "it": { "stringUnit": { "state": "translated", "value": "Tocca per concludere. La sessione viene salvata come allenamento, quindi compare in Allenamenti e Oggi come tutti gli altri." } }, "pl": { "stringUnit": { "state": "translated", "value": "Dotknij, aby zakończyć. Sesja zapisuje się jako trening, więc pojawi się w Treningach i Dzisiaj jak każdy inny." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Toca para terminar. A sessão é guardada como treino, por isso aparece em Treinos e Hoje como qualquer outro." } }, "ru": { "stringUnit": { "state": "translated", "value": "Нажми, чтобы завершить. Сессия сохраняется как тренировка и появится в Тренировках и Сегодня, как любая другая." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "点按结束。这次训练会作为一次锻炼保存,和其他锻炼一样出现在「锻炼」和「今天」里。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "點按結束。這次訓練會作為一次鍛鍊儲存,和其他鍛鍊一樣出現在「鍛鍊」和「今天」裡。" } } + } }, + "Double-tap your strap to log a set without picking the phone up.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Tippe zweimal auf dein Band, um einen Satz zu speichern, ohne zum Telefon zu greifen." } }, "en": { "stringUnit": { "state": "translated", "value": "Double-tap your strap to log a set without picking the phone up." } }, "es": { "stringUnit": { "state": "translated", "value": "Toca dos veces tu banda para registrar una serie sin coger el teléfono." } }, "fr": { "stringUnit": { "state": "translated", "value": "Tape deux fois sur ton bracelet pour enregistrer une série sans toucher au téléphone." } }, "it": { "stringUnit": { "state": "translated", "value": "Tocca due volte la fascia per registrare una serie senza prendere il telefono." } }, "pl": { "stringUnit": { "state": "translated", "value": "Stuknij dwukrotnie w opaskę, aby zapisać serię bez sięgania po telefon." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Toca duas vezes na tua banda para registar uma série sem pegar no telemóvel." } }, "ru": { "stringUnit": { "state": "translated", "value": "Дважды коснись браслета, чтобы записать подход, не беря телефон в руки." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "双击手环即可记录一组,不用拿起手机。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "輕點兩下手環即可記錄一組,不用拿起手機。" } } + } }, + "Session in progress": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Sitzung läuft" } }, "en": { "stringUnit": { "state": "translated", "value": "Session in progress" } }, "es": { "stringUnit": { "state": "translated", "value": "Sesión en curso" } }, "fr": { "stringUnit": { "state": "translated", "value": "Séance en cours" } }, "it": { "stringUnit": { "state": "translated", "value": "Sessione in corso" } }, "pl": { "stringUnit": { "state": "translated", "value": "Sesja w toku" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Sessão em curso" } }, "ru": { "stringUnit": { "state": "translated", "value": "Сессия идёт" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "训练进行中" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "訓練進行中" } } + } }, + "You left a session running. Nothing was lost — pick it up where you stopped.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Du hast eine Sitzung laufen lassen. Nichts ist verloren – mach da weiter, wo du aufgehört hast." } }, "en": { "stringUnit": { "state": "translated", "value": "You left a session running. Nothing was lost — pick it up where you stopped." } }, "es": { "stringUnit": { "state": "translated", "value": "Dejaste una sesión en marcha. No se ha perdido nada: retómala donde la dejaste." } }, "fr": { "stringUnit": { "state": "translated", "value": "Tu as laissé une séance en cours. Rien n'est perdu : reprends là où tu t'es arrêté." } }, "it": { "stringUnit": { "state": "translated", "value": "Hai lasciato una sessione in corso. Non è andato perso nulla: riprendi da dove eri." } }, "pl": { "stringUnit": { "state": "translated", "value": "Zostawiłeś rozpoczętą sesję. Nic nie przepadło – wróć tam, gdzie skończyłeś." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Deixaste uma sessão a decorrer. Não se perdeu nada: retoma onde paraste." } }, "ru": { "stringUnit": { "state": "translated", "value": "У тебя осталась незавершённая сессия. Ничего не потеряно — продолжи с того места, где остановился." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "你有一次训练还没结束。什么都没丢——从停下的地方继续吧。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "你有一次訓練還沒結束。什麼都沒丟——從停下的地方繼續吧。" } } + } }, + "Save session": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Sitzung sichern" } }, "en": { "stringUnit": { "state": "translated", "value": "Save session" } }, "es": { "stringUnit": { "state": "translated", "value": "Guardar sesión" } }, "fr": { "stringUnit": { "state": "translated", "value": "Enregistrer la séance" } }, "it": { "stringUnit": { "state": "translated", "value": "Salva sessione" } }, "pl": { "stringUnit": { "state": "translated", "value": "Zapisz sesję" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Guardar sessão" } }, "ru": { "stringUnit": { "state": "translated", "value": "Сохранить сессию" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "保存训练" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "儲存訓練" } } + } }, + "Start this program": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Dieses Programm starten" } }, "en": { "stringUnit": { "state": "translated", "value": "Start this program" } }, "es": { "stringUnit": { "state": "translated", "value": "Empezar este programa" } }, "fr": { "stringUnit": { "state": "translated", "value": "Démarrer ce programme" } }, "it": { "stringUnit": { "state": "translated", "value": "Avvia questo programma" } }, "pl": { "stringUnit": { "state": "translated", "value": "Rozpocznij ten program" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Iniciar este programa" } }, "ru": { "stringUnit": { "state": "translated", "value": "Начать эту программу" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "开始这个计划" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "開始這個計畫" } } + } }, + "Tap to edit": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Zum Bearbeiten tippen" } }, "en": { "stringUnit": { "state": "translated", "value": "Tap to edit" } }, "es": { "stringUnit": { "state": "translated", "value": "Toca para editar" } }, "fr": { "stringUnit": { "state": "translated", "value": "Touche pour modifier" } }, "it": { "stringUnit": { "state": "translated", "value": "Tocca per modificare" } }, "pl": { "stringUnit": { "state": "translated", "value": "Dotknij, aby edytować" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Toca para editar" } }, "ru": { "stringUnit": { "state": "translated", "value": "Нажми, чтобы изменить" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "点按编辑" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "點按編輯" } } + } }, + "Sets": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Sätze" } }, "en": { "stringUnit": { "state": "translated", "value": "Sets" } }, "es": { "stringUnit": { "state": "translated", "value": "Series" } }, "fr": { "stringUnit": { "state": "translated", "value": "Séries" } }, "it": { "stringUnit": { "state": "translated", "value": "Serie" } }, "pl": { "stringUnit": { "state": "translated", "value": "Serie" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Séries" } }, "ru": { "stringUnit": { "state": "translated", "value": "Подходы" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "组数" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "組數" } } + } }, + "Elapsed": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Vergangen" } }, "en": { "stringUnit": { "state": "translated", "value": "Elapsed" } }, "es": { "stringUnit": { "state": "translated", "value": "Transcurrido" } }, "fr": { "stringUnit": { "state": "translated", "value": "Écoulé" } }, "it": { "stringUnit": { "state": "translated", "value": "Trascorso" } }, "pl": { "stringUnit": { "state": "translated", "value": "Upłynęło" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Decorrido" } }, "ru": { "stringUnit": { "state": "translated", "value": "Прошло" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "已用时" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "已用時" } } + } }, + "Volume": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Volumen" } }, "en": { "stringUnit": { "state": "translated", "value": "Volume" } }, "es": { "stringUnit": { "state": "translated", "value": "Volumen" } }, "fr": { "stringUnit": { "state": "translated", "value": "Volume" } }, "it": { "stringUnit": { "state": "translated", "value": "Volume" } }, "pl": { "stringUnit": { "state": "translated", "value": "Objętość" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Volume" } }, "ru": { "stringUnit": { "state": "translated", "value": "Объём" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "容量" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "容量" } } + } }, + "Set %lld of %lld": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Satz %1$lld von %2$lld" } }, "en": { "stringUnit": { "state": "translated", "value": "Set %lld of %lld" } }, "es": { "stringUnit": { "state": "translated", "value": "Serie %1$lld de %2$lld" } }, "fr": { "stringUnit": { "state": "translated", "value": "Série %1$lld sur %2$lld" } }, "it": { "stringUnit": { "state": "translated", "value": "Serie %1$lld di %2$lld" } }, "pl": { "stringUnit": { "state": "translated", "value": "Seria %1$lld z %2$lld" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Série %1$lld de %2$lld" } }, "ru": { "stringUnit": { "state": "translated", "value": "Подход %1$lld из %2$lld" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "第 %1$lld 组,共 %2$lld 组" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "第 %1$lld 組,共 %2$lld 組" } } + } }, + "session RPE %@": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Sitzungs-RPE %@" } }, "en": { "stringUnit": { "state": "translated", "value": "session RPE %@" } }, "es": { "stringUnit": { "state": "translated", "value": "RPE de sesión %@" } }, "fr": { "stringUnit": { "state": "translated", "value": "RPE de séance %@" } }, "it": { "stringUnit": { "state": "translated", "value": "RPE della sessione %@" } }, "pl": { "stringUnit": { "state": "translated", "value": "RPE sesji %@" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "RPE da sessão %@" } }, "ru": { "stringUnit": { "state": "translated", "value": "RPE сессии %@" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "整场 RPE %@" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "整場 RPE %@" } } + } }, + "Weight (kg)": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Gewicht (kg)" } }, "en": { "stringUnit": { "state": "translated", "value": "Weight (kg)" } }, "es": { "stringUnit": { "state": "translated", "value": "Peso (kg)" } }, "fr": { "stringUnit": { "state": "translated", "value": "Poids (kg)" } }, "it": { "stringUnit": { "state": "translated", "value": "Peso (kg)" } }, "pl": { "stringUnit": { "state": "translated", "value": "Ciężar (kg)" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Peso (kg)" } }, "ru": { "stringUnit": { "state": "translated", "value": "Вес (кг)" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "重量(公斤)" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "重量(公斤)" } } + } }, + "Weight (lb)": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Gewicht (lb)" } }, "en": { "stringUnit": { "state": "translated", "value": "Weight (lb)" } }, "es": { "stringUnit": { "state": "translated", "value": "Peso (lb)" } }, "fr": { "stringUnit": { "state": "translated", "value": "Poids (lb)" } }, "it": { "stringUnit": { "state": "translated", "value": "Peso (lb)" } }, "pl": { "stringUnit": { "state": "translated", "value": "Ciężar (lb)" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Peso (lb)" } }, "ru": { "stringUnit": { "state": "translated", "value": "Вес (фунты)" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "重量(磅)" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "重量(磅)" } } + } }, + "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 無法可靠識別睡眠時段。請保持手環連接並再次同步;下方較早的一夜仍是最近一次偵測到的睡眠。" } } + } }, + "Save exercise": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Übung sichern" } }, "en": { "stringUnit": { "state": "translated", "value": "Save exercise" } }, "es": { "stringUnit": { "state": "translated", "value": "Guardar ejercicio" } }, "fr": { "stringUnit": { "state": "translated", "value": "Enregistrer l'exercice" } }, "it": { "stringUnit": { "state": "translated", "value": "Salva esercizio" } }, "pl": { "stringUnit": { "state": "translated", "value": "Zapisz ćwiczenie" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Guardar exercício" } }, "ru": { "stringUnit": { "state": "translated", "value": "Сохранить упражнение" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "保存动作" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "儲存動作" } } + } }, + "Your log book": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Dein Logbuch" } }, "en": { "stringUnit": { "state": "translated", "value": "Your log book" } }, "es": { "stringUnit": { "state": "translated", "value": "Tu cuaderno" } }, "fr": { "stringUnit": { "state": "translated", "value": "Ton carnet" } }, "it": { "stringUnit": { "state": "translated", "value": "Il tuo diario" } }, "pl": { "stringUnit": { "state": "translated", "value": "Twój dziennik" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "O teu diário" } }, "ru": { "stringUnit": { "state": "translated", "value": "Твой дневник" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "你的训练日志" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "你的訓練日誌" } } + } }, + "Programs, sessions and per-set history": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Programme, Sitzungen und Satz-für-Satz-Verlauf" } }, "en": { "stringUnit": { "state": "translated", "value": "Programs, sessions and per-set history" } }, "es": { "stringUnit": { "state": "translated", "value": "Programas, sesiones e historial por serie" } }, "fr": { "stringUnit": { "state": "translated", "value": "Programmes, séances et historique par série" } }, "it": { "stringUnit": { "state": "translated", "value": "Programmi, sessioni e storico per serie" } }, "pl": { "stringUnit": { "state": "translated", "value": "Programy, sesje i historia serii" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Programas, sessões e histórico por série" } }, "ru": { "stringUnit": { "state": "translated", "value": "Программы, сессии и история по подходам" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "计划、训练与每组历史" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "計畫、訓練與每組歷史" } } + } }, + "Lifting adds volume and set counts. It never changes your Effort, which stays measured from heart rate.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Krafttraining ergänzt Volumen und Satzzahlen. Deine Belastung ändert es nie – die wird weiterhin aus der Herzfrequenz gemessen." } }, "en": { "stringUnit": { "state": "translated", "value": "Lifting adds volume and set counts. It never changes your Effort, which stays measured from heart rate." } }, "es": { "stringUnit": { "state": "translated", "value": "Las pesas añaden volumen y recuento de series. Nunca cambian tu Esfuerzo, que se sigue midiendo por la frecuencia cardíaca." } }, "fr": { "stringUnit": { "state": "translated", "value": "La musculation ajoute du volume et un décompte de séries. Elle ne change jamais ton Effort, toujours mesuré à partir de la fréquence cardiaque." } }, "it": { "stringUnit": { "state": "translated", "value": "I pesi aggiungono volume e conteggio delle serie. Non cambiano mai il tuo Sforzo, che resta misurato dalla frequenza cardiaca." } }, "pl": { "stringUnit": { "state": "translated", "value": "Trening siłowy dodaje objętość i liczbę serii. Nigdy nie zmienia Twojego Wysiłku, który nadal jest mierzony tętnem." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Os pesos acrescentam volume e contagem de séries. Nunca alteram o teu Esforço, que continua medido pela frequência cardíaca." } }, "ru": { "stringUnit": { "state": "translated", "value": "Силовые добавляют объём и число подходов. Они никогда не меняют твоё Усилие — оно по-прежнему измеряется по пульсу." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "力量训练会增加容量和组数统计,但绝不会改变你的消耗——消耗始终由心率测得。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "力量訓練會增加容量和組數統計,但絕不會改變你的消耗——消耗始終由心率測得。" } } + } }, + "Programs": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Programme" } }, "en": { "stringUnit": { "state": "translated", "value": "Programs" } }, "es": { "stringUnit": { "state": "translated", "value": "Programas" } }, "fr": { "stringUnit": { "state": "translated", "value": "Programmes" } }, "it": { "stringUnit": { "state": "translated", "value": "Programmi" } }, "pl": { "stringUnit": { "state": "translated", "value": "Programy" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Programas" } }, "ru": { "stringUnit": { "state": "translated", "value": "Программы" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "计划" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "計畫" } } + } }, + "New program": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Neues Programm" } }, "en": { "stringUnit": { "state": "translated", "value": "New program" } }, "es": { "stringUnit": { "state": "translated", "value": "Nuevo programa" } }, "fr": { "stringUnit": { "state": "translated", "value": "Nouveau programme" } }, "it": { "stringUnit": { "state": "translated", "value": "Nuovo programma" } }, "pl": { "stringUnit": { "state": "translated", "value": "Nowy program" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Novo programa" } }, "ru": { "stringUnit": { "state": "translated", "value": "Новая программа" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "新建计划" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "新增計畫" } } + } }, + "No programs yet": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Noch keine Programme" } }, "en": { "stringUnit": { "state": "translated", "value": "No programs yet" } }, "es": { "stringUnit": { "state": "translated", "value": "Aún no hay programas" } }, "fr": { "stringUnit": { "state": "translated", "value": "Aucun programme pour l'instant" } }, "it": { "stringUnit": { "state": "translated", "value": "Ancora nessun programma" } }, "pl": { "stringUnit": { "state": "translated", "value": "Brak programów" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Ainda sem programas" } }, "ru": { "stringUnit": { "state": "translated", "value": "Программ пока нет" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "还没有计划" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "還沒有計畫" } } + } }, + "Program": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Programm" } }, "en": { "stringUnit": { "state": "translated", "value": "Program" } }, "es": { "stringUnit": { "state": "translated", "value": "Programa" } }, "fr": { "stringUnit": { "state": "translated", "value": "Programme" } }, "it": { "stringUnit": { "state": "translated", "value": "Programma" } }, "pl": { "stringUnit": { "state": "translated", "value": "Program" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Programa" } }, "ru": { "stringUnit": { "state": "translated", "value": "Программа" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "计划" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "計畫" } } + } }, + "Exercises": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Übungen" } }, "en": { "stringUnit": { "state": "translated", "value": "Exercises" } }, "es": { "stringUnit": { "state": "translated", "value": "Ejercicios" } }, "fr": { "stringUnit": { "state": "translated", "value": "Exercices" } }, "it": { "stringUnit": { "state": "translated", "value": "Esercizi" } }, "pl": { "stringUnit": { "state": "translated", "value": "Ćwiczenia" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Exercícios" } }, "ru": { "stringUnit": { "state": "translated", "value": "Упражнения" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "动作" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "動作" } } + } }, + "No exercises yet. Add the first one below — you can type any name you like; NOOP remembers it for next time.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Noch keine Übungen. Füge unten die erste hinzu – du kannst jeden Namen eingeben, NOOP merkt ihn sich fürs nächste Mal." } }, "en": { "stringUnit": { "state": "translated", "value": "No exercises yet. Add the first one below — you can type any name you like; NOOP remembers it for next time." } }, "es": { "stringUnit": { "state": "translated", "value": "Aún no hay ejercicios. Añade el primero abajo: puedes escribir el nombre que quieras y NOOP lo recordará para la próxima vez." } }, "fr": { "stringUnit": { "state": "translated", "value": "Aucun exercice pour l'instant. Ajoute le premier ci-dessous : tu peux saisir le nom que tu veux, NOOP le retient pour la prochaine fois." } }, "it": { "stringUnit": { "state": "translated", "value": "Ancora nessun esercizio. Aggiungi il primo qui sotto: puoi scrivere il nome che preferisci e NOOP lo ricorda per la prossima volta." } }, "pl": { "stringUnit": { "state": "translated", "value": "Brak ćwiczeń. Dodaj pierwsze poniżej – możesz wpisać dowolną nazwę, a NOOP zapamięta ją na następny raz." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Ainda sem exercícios. Adiciona o primeiro abaixo: podes escrever o nome que quiseres e o NOOP guarda-o para a próxima vez." } }, "ru": { "stringUnit": { "state": "translated", "value": "Упражнений пока нет. Добавь первое ниже — можешь ввести любое название, NOOP запомнит его на следующий раз." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "还没有动作。在下面添加第一个——名称随你怎么写,NOOP 会记住它,下次直接用。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "還沒有動作。在下面新增第一個——名稱隨你怎麼寫,NOOP 會記住它,下次直接用。" } } + } }, + "Add exercise": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Übung hinzufügen" } }, "en": { "stringUnit": { "state": "translated", "value": "Add exercise" } }, "es": { "stringUnit": { "state": "translated", "value": "Añadir ejercicio" } }, "fr": { "stringUnit": { "state": "translated", "value": "Ajouter un exercice" } }, "it": { "stringUnit": { "state": "translated", "value": "Aggiungi esercizio" } }, "pl": { "stringUnit": { "state": "translated", "value": "Dodaj ćwiczenie" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Adicionar exercício" } }, "ru": { "stringUnit": { "state": "translated", "value": "Добавить упражнение" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "添加动作" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "新增動作" } } + } }, + "Move up": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Nach oben" } }, "en": { "stringUnit": { "state": "translated", "value": "Move up" } }, "es": { "stringUnit": { "state": "translated", "value": "Subir" } }, "fr": { "stringUnit": { "state": "translated", "value": "Monter" } }, "it": { "stringUnit": { "state": "translated", "value": "Sposta su" } }, "pl": { "stringUnit": { "state": "translated", "value": "W górę" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Mover para cima" } }, "ru": { "stringUnit": { "state": "translated", "value": "Вверх" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "上移" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "上移" } } + } }, + "Move down": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Nach unten" } }, "en": { "stringUnit": { "state": "translated", "value": "Move down" } }, "es": { "stringUnit": { "state": "translated", "value": "Bajar" } }, "fr": { "stringUnit": { "state": "translated", "value": "Descendre" } }, "it": { "stringUnit": { "state": "translated", "value": "Sposta giù" } }, "pl": { "stringUnit": { "state": "translated", "value": "W dół" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Mover para baixo" } }, "ru": { "stringUnit": { "state": "translated", "value": "Вниз" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "下移" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "下移" } } + } }, + "Remove exercise": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Übung entfernen" } }, "en": { "stringUnit": { "state": "translated", "value": "Remove exercise" } }, "es": { "stringUnit": { "state": "translated", "value": "Quitar ejercicio" } }, "fr": { "stringUnit": { "state": "translated", "value": "Retirer l'exercice" } }, "it": { "stringUnit": { "state": "translated", "value": "Rimuovi esercizio" } }, "pl": { "stringUnit": { "state": "translated", "value": "Usuń ćwiczenie" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Remover exercício" } }, "ru": { "stringUnit": { "state": "translated", "value": "Убрать упражнение" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "移除动作" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "移除動作" } } + } }, + "Delete program": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Programm löschen" } }, "en": { "stringUnit": { "state": "translated", "value": "Delete program" } }, "es": { "stringUnit": { "state": "translated", "value": "Eliminar programa" } }, "fr": { "stringUnit": { "state": "translated", "value": "Supprimer le programme" } }, "it": { "stringUnit": { "state": "translated", "value": "Elimina programma" } }, "pl": { "stringUnit": { "state": "translated", "value": "Usuń program" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Eliminar programa" } }, "ru": { "stringUnit": { "state": "translated", "value": "Удалить программу" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "删除计划" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "刪除計畫" } } + } }, + "Delete this program?": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Dieses Programm löschen?" } }, "en": { "stringUnit": { "state": "translated", "value": "Delete this program?" } }, "es": { "stringUnit": { "state": "translated", "value": "¿Eliminar este programa?" } }, "fr": { "stringUnit": { "state": "translated", "value": "Supprimer ce programme ?" } }, "it": { "stringUnit": { "state": "translated", "value": "Eliminare questo programma?" } }, "pl": { "stringUnit": { "state": "translated", "value": "Usunąć ten program?" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Eliminar este programa?" } }, "ru": { "stringUnit": { "state": "translated", "value": "Удалить эту программу?" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "删除这个计划?" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "刪除這個計畫?" } } + } }, + "Sessions you already logged from it are kept.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Bereits damit aufgezeichnete Sitzungen bleiben erhalten." } }, "en": { "stringUnit": { "state": "translated", "value": "Sessions you already logged from it are kept." } }, "es": { "stringUnit": { "state": "translated", "value": "Las sesiones que ya registraste con él se conservan." } }, "fr": { "stringUnit": { "state": "translated", "value": "Les séances déjà enregistrées avec lui sont conservées." } }, "it": { "stringUnit": { "state": "translated", "value": "Le sessioni già registrate con esso vengono mantenute." } }, "pl": { "stringUnit": { "state": "translated", "value": "Zapisane już sesje pozostaną." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "As sessões que já registaste com ele são mantidas." } }, "ru": { "stringUnit": { "state": "translated", "value": "Уже записанные по ней сессии сохранятся." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "已经用它记录的训练会保留。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "已經用它記錄的訓練會保留。" } } + } }, + "Save program": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Programm sichern" } }, "en": { "stringUnit": { "state": "translated", "value": "Save program" } }, "es": { "stringUnit": { "state": "translated", "value": "Guardar programa" } }, "fr": { "stringUnit": { "state": "translated", "value": "Enregistrer le programme" } }, "it": { "stringUnit": { "state": "translated", "value": "Salva programma" } }, "pl": { "stringUnit": { "state": "translated", "value": "Zapisz program" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Guardar programa" } }, "ru": { "stringUnit": { "state": "translated", "value": "Сохранить программу" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "保存计划" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "儲存計畫" } } + } }, + "Exercise": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Übung" } }, "en": { "stringUnit": { "state": "translated", "value": "Exercise" } }, "es": { "stringUnit": { "state": "translated", "value": "Ejercicio" } }, "fr": { "stringUnit": { "state": "translated", "value": "Exercice" } }, "it": { "stringUnit": { "state": "translated", "value": "Esercizio" } }, "pl": { "stringUnit": { "state": "translated", "value": "Ćwiczenie" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Exercício" } }, "ru": { "stringUnit": { "state": "translated", "value": "Упражнение" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "动作" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "動作" } } + } }, + "Used before": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Schon verwendet" } }, "en": { "stringUnit": { "state": "translated", "value": "Used before" } }, "es": { "stringUnit": { "state": "translated", "value": "Ya usados" } }, "fr": { "stringUnit": { "state": "translated", "value": "Déjà utilisés" } }, "it": { "stringUnit": { "state": "translated", "value": "Già usati" } }, "pl": { "stringUnit": { "state": "translated", "value": "Już używane" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Já usados" } }, "ru": { "stringUnit": { "state": "translated", "value": "Уже использованные" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "用过的" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "用過的" } } + } }, + "Muscles": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Muskeln" } }, "en": { "stringUnit": { "state": "translated", "value": "Muscles" } }, "es": { "stringUnit": { "state": "translated", "value": "Músculos" } }, "fr": { "stringUnit": { "state": "translated", "value": "Muscles" } }, "it": { "stringUnit": { "state": "translated", "value": "Muscoli" } }, "pl": { "stringUnit": { "state": "translated", "value": "Mięśnie" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Músculos" } }, "ru": { "stringUnit": { "state": "translated", "value": "Мышцы" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "肌群" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "肌群" } } + } }, + "Primary": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Primär" } }, "en": { "stringUnit": { "state": "translated", "value": "Primary" } }, "es": { "stringUnit": { "state": "translated", "value": "Principal" } }, "fr": { "stringUnit": { "state": "translated", "value": "Principal" } }, "it": { "stringUnit": { "state": "translated", "value": "Primario" } }, "pl": { "stringUnit": { "state": "translated", "value": "Główny" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Principal" } }, "ru": { "stringUnit": { "state": "translated", "value": "Основная" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "主要" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "主要" } } + } }, + "Not classified": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Nicht zugeordnet" } }, "en": { "stringUnit": { "state": "translated", "value": "Not classified" } }, "es": { "stringUnit": { "state": "translated", "value": "Sin clasificar" } }, "fr": { "stringUnit": { "state": "translated", "value": "Non classé" } }, "it": { "stringUnit": { "state": "translated", "value": "Non classificato" } }, "pl": { "stringUnit": { "state": "translated", "value": "Bez przypisania" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Sem classificação" } }, "ru": { "stringUnit": { "state": "translated", "value": "Без категории" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "未分类" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "未分類" } } + } }, + "Primary muscle": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Primärer Muskel" } }, "en": { "stringUnit": { "state": "translated", "value": "Primary muscle" } }, "es": { "stringUnit": { "state": "translated", "value": "Músculo principal" } }, "fr": { "stringUnit": { "state": "translated", "value": "Muscle principal" } }, "it": { "stringUnit": { "state": "translated", "value": "Muscolo primario" } }, "pl": { "stringUnit": { "state": "translated", "value": "Główny mięsień" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Músculo principal" } }, "ru": { "stringUnit": { "state": "translated", "value": "Основная мышца" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "主要肌群" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "主要肌群" } } + } }, + "Also works (counted as half a set)": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Trainiert außerdem (zählt als halber Satz)" } }, "en": { "stringUnit": { "state": "translated", "value": "Also works (counted as half a set)" } }, "es": { "stringUnit": { "state": "translated", "value": "También trabaja (cuenta como media serie)" } }, "fr": { "stringUnit": { "state": "translated", "value": "Sollicite aussi (compté comme une demi-série)" } }, "it": { "stringUnit": { "state": "translated", "value": "Coinvolge anche (conta come mezza serie)" } }, "pl": { "stringUnit": { "state": "translated", "value": "Angażuje też (liczy się jako pół serii)" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Também trabalha (conta como meia série)" } }, "ru": { "stringUnit": { "state": "translated", "value": "Также задействует (считается за полподхода)" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "同时练到(计为半组)" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "同時練到(計為半組)" } } + } }, + "Direct sets count once, indirect sets count as a half. That split is what makes the weekly per-muscle figures mean anything.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Direkte Sätze zählen einfach, indirekte als halber Satz. Erst diese Trennung macht die wöchentlichen Zahlen pro Muskel aussagekräftig." } }, "en": { "stringUnit": { "state": "translated", "value": "Direct sets count once, indirect sets count as a half. That split is what makes the weekly per-muscle figures mean anything." } }, "es": { "stringUnit": { "state": "translated", "value": "Las series directas cuentan una vez; las indirectas, media. Esa distinción es lo que da sentido a las cifras semanales por músculo." } }, "fr": { "stringUnit": { "state": "translated", "value": "Les séries directes comptent pour une, les indirectes pour une demie. C'est cette distinction qui donne un sens aux chiffres hebdomadaires par muscle." } }, "it": { "stringUnit": { "state": "translated", "value": "Le serie dirette contano una volta, quelle indirette una metà. È questa distinzione a dare senso ai numeri settimanali per muscolo." } }, "pl": { "stringUnit": { "state": "translated", "value": "Serie bezpośrednie liczą się raz, pośrednie jako pół. To właśnie ten podział nadaje sens tygodniowym liczbom dla każdego mięśnia." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "As séries diretas contam uma vez, as indiretas contam meia. É essa distinção que dá sentido aos números semanais por músculo." } }, "ru": { "stringUnit": { "state": "translated", "value": "Прямые подходы считаются за один, косвенные — за половину. Именно это разделение делает недельные цифры по мышцам осмысленными." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "直接组计为一组,间接组计为半组。正是这个区分,才让每周的各肌群数字有意义。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "直接組計為一組,間接組計為半組。正是這個區分,才讓每週的各肌群數字有意義。" } } + } }, + "Targets": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Ziele" } }, "en": { "stringUnit": { "state": "translated", "value": "Targets" } }, "es": { "stringUnit": { "state": "translated", "value": "Objetivos" } }, "fr": { "stringUnit": { "state": "translated", "value": "Objectifs" } }, "it": { "stringUnit": { "state": "translated", "value": "Obiettivi" } }, "pl": { "stringUnit": { "state": "translated", "value": "Cele" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Objetivos" } }, "ru": { "stringUnit": { "state": "translated", "value": "Цели" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "目标" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "目標" } } + } }, + "Every target is optional — fill in what you actually plan against.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Jedes Ziel ist optional – trag ein, wonach du dich tatsächlich richtest." } }, "en": { "stringUnit": { "state": "translated", "value": "Every target is optional — fill in what you actually plan against." } }, "es": { "stringUnit": { "state": "translated", "value": "Todos los objetivos son opcionales: rellena solo aquello con lo que de verdad planificas." } }, "fr": { "stringUnit": { "state": "translated", "value": "Chaque objectif est facultatif : remplis seulement ce sur quoi tu te bases vraiment." } }, "it": { "stringUnit": { "state": "translated", "value": "Ogni obiettivo è facoltativo: compila solo ciò su cui pianifichi davvero." } }, "pl": { "stringUnit": { "state": "translated", "value": "Każdy cel jest opcjonalny – wpisz to, według czego naprawdę planujesz." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Todos os objetivos são opcionais: preenche apenas aquilo com que planeias de facto." } }, "ru": { "stringUnit": { "state": "translated", "value": "Любая цель необязательна — заполняй то, на что действительно ориентируешься." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "所有目标都是可选的——只填你真正会照着做的那些。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "所有目標都是可選的——只填你真正會照著做的那些。" } } + } }, + "Technique note": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Technik-Notiz" } }, "en": { "stringUnit": { "state": "translated", "value": "Technique note" } }, "es": { "stringUnit": { "state": "translated", "value": "Nota de técnica" } }, "fr": { "stringUnit": { "state": "translated", "value": "Note de technique" } }, "it": { "stringUnit": { "state": "translated", "value": "Nota sulla tecnica" } }, "pl": { "stringUnit": { "state": "translated", "value": "Notatka o technice" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Nota de técnica" } }, "ru": { "stringUnit": { "state": "translated", "value": "Заметка о технике" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "技术笔记" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "技術筆記" } } + } }, "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 +2519,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." } }, @@ -390,6 +2858,64 @@ } } }, + "the backup file was written, but couldn't be read back to check it, so it has been left in place rather than deleted. Open it before you rely on it, or export again somewhere else.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Backup-Datei wurde geschrieben, konnte aber zur Prüfung nicht zurückgelesen werden und wurde deshalb belassen statt gelöscht. Öffne sie, bevor du dich auf sie verlässt, oder exportiere erneut an einen anderen Ort." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El archivo de copia de seguridad se escribió, pero no se pudo volver a leer para comprobarlo, así que se ha conservado en lugar de borrarlo. Ábrelo antes de confiar en él o vuelve a exportar en otro lugar." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le fichier de sauvegarde a été écrit, mais n'a pas pu être relu pour être vérifié, il a donc été conservé plutôt que supprimé. Ouvre-le avant de t'y fier, ou exporte à nouveau à un autre endroit." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Il file di backup è stato scritto, ma non è stato possibile rileggerlo per verificarlo, quindi è stato mantenuto anziché eliminato. Aprilo prima di farci affidamento, oppure esporta di nuovo altrove." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "plik kopii zapasowej został zapisany, ale nie udało się go ponownie odczytać w celu sprawdzenia, więc został zachowany, a nie usunięty. Otwórz go, zanim mu zaufasz, lub wyeksportuj ponownie w inne miejsce." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "O ficheiro de cópia de segurança foi escrito, mas não foi possível voltar a lê-lo para o verificar, pelo que foi mantido em vez de eliminado. Abre-o antes de confiares nele, ou exporta novamente para outro local." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Файл резервной копии записан, но прочитать его для проверки не удалось, поэтому он оставлен, а не удалён. Откройте его, прежде чем полагаться на него, или выполните экспорт в другое место." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "备份文件已写入,但无法重新读取以进行检查,因此予以保留而未删除。请先打开确认后再依赖它,或导出到其他位置。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "備份檔案已寫入,但無法重新讀取以進行檢查,因此予以保留而未刪除。請先開啟確認後再依賴它,或匯出到其他位置。" + } + } + } + }, "Forget device": { "localizations": { "de": { @@ -676,6 +3202,24 @@ "state": "translated", "value": "%@ · typowo %@ do %@, %lld." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": " %@ · обычно от %@ до %@, %lld." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": " %@ · 通常 %@ 至 %@,%lld。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": " %@ · 通常 %@ 至 %@,%lld。" + } } } }, @@ -1006,6 +3550,18 @@ "state": "translated", "value": "%.2f V" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%.2f В" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%.2f V" + } } } }, @@ -2894,6 +5450,18 @@ "state": "translated", "value": "Potrzebny %@" } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "需要 %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "需要 %@" + } } } }, @@ -3178,6 +5746,18 @@ "state": "translated", "value": "%@ spał" } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已睡 %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已睡 %@" + } } } }, @@ -5887,6 +8467,18 @@ "state": "translated", "value": "%@-%@" } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@-%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@-%@" + } } } }, @@ -7377,6 +9969,24 @@ "state": "translated", "value": "%@: %@. Za mało historii dla typowego asortymentu." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@: %@. Пока недостаточно истории для типичного диапазона." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@:%@。历史数据尚不足以给出典型范围。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@:%@。歷史資料尚不足以給出典型範圍。" + } } } }, @@ -7817,71 +10427,6 @@ } } }, - "%lld app%@ on": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "%lld App%@ aktiv" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "%lld app%@ on" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "%lld app%@ activas" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "%lld app%@ sur" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "%lld app%@ attive" - } - }, - "pt-PT": { - "stringUnit": { - "state": "translated", - "value": "%lld aplicação%@ ativado" - } - }, - "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": "Aplikacja %lld%@ włączona" - } - } - } - }, "%lld apps on": { "localizations": { "de": { @@ -8559,6 +11104,24 @@ "state": "translated", "value": "%lld kilka dni temu" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%lld дней назад" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%lld 天前" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%lld 天前" + } } } }, @@ -9236,71 +11799,6 @@ } } }, - "%lld frame%@ captured this session.": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "%lld Frame%@ in dieser Sitzung erfasst." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "%lld frame%@ captured this session." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "%lld fotograma%@ capturados en esta sesión." - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "%lld trame%@ capturée cette séance." - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "%lld frame%@ acquisiti in questa sessione." - } - }, - "pt-PT": { - "stringUnit": { - "state": "translated", - "value": "%lld frame%@ capturou esta sessão." - } - }, - "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": "Ramka %lld%@ przechwyciła tę sesję." - } - } - } - }, "%lld frames captured this session.": { "localizations": { "de": { @@ -10475,71 +12973,6 @@ } } }, - "%lld of %lld nights": { - "extractionState": "stale", - "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 de %lld nuits" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "%lld di %lld notti" - } - }, - "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 晚" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "%lld z %lld nocy" - } - } - } - }, "%lld of %lld sessions": { "localizations": { "de": { @@ -11138,136 +13571,6 @@ } } }, - "%lld reading%@ line up so far, not enough to read a trend yet (NOOP waits for %lld).": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "%1$lld Messwert%2$@ stimmen bisher überein, noch nicht genug für einen Trend (NOOP wartet auf %3$lld)." - } - }, - "en": { - "stringUnit": { - "state": "new", - "value": "%1$lld reading%2$@ line up so far, not enough to read a trend yet (NOOP waits for %3$lld)." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "%lld lectura%@ coinciden hasta ahora, no es suficiente para leer una tendencia todavía (NOOP espera %lld)." - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "%1$lld lecture%2$@ concordent jusqu'à présent, pas suffisant pour dégager une tendance (NOOP attend %3$lld)." - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "%1$lld lettura%2$@ allineate finora, non abbastanza per leggere un andamento (NOOP aspetta %3$lld)." - } - }, - "pt-PT": { - "stringUnit": { - "state": "translated", - "value": "%1$lld leitura%2$@ alinhado até ao momento, ainda não é suficiente para ler uma tendência (NOOP espera por %3$lld)." - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "%1$lld измерени%2$@ пока набрано, недостаточно для определения тренда (NOOP ждёт %3$lld)." - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "目前已有 %1$lld 条读数%2$@对齐,还不足以读出趋势(NOOP 需等待 %3$lld 条)。" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "目前已有 %1$lld 筆讀數%2$@對齊,還不足以讀出趨勢(NOOP 需等待 %3$lld 筆)。" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "%1$lld odczyt %2$@ jest w kolejce, ale to jeszcze za mało, aby odczytać trend (NOOP czeka na %3$lld)." - } - } - } - }, - "%lld reading%@ · your own entries": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "%1$lld Messwert%2$@ · deine eigenen Einträge" - } - }, - "en": { - "stringUnit": { - "state": "new", - "value": "%1$lld reading%2$@ · your own entries" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "%lld lectura%@ · tus propias entradas" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "%1$lld lecture%2$@ · vos propres entrées" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "%1$lld lettura%2$@ · le tue voci" - } - }, - "pt-PT": { - "stringUnit": { - "state": "translated", - "value": "%1$lld leitura%2$@ · as tuas entradas" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "%1$lld измерени%2$@ · ваши собственные записи" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "%1$lld 条读数%2$@ · 你自己的记录" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "%1$lld 筆讀數%2$@ · 你自己的記錄" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "%1$lld czytanie%2$@ · własne wpisy" - } - } - } - }, "%lld readings across %lld · %@": { "localizations": { "de": { @@ -12530,71 +14833,6 @@ } } }, - "%lld type-0x2F historical-offload frame%@ seen outside our sync. These are history (e.g. another app pulling the strap's backlog), not a live R22 stream (#494).": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "%1$lld type-0x2F-Frame%2$@ (historischer Offload) außerhalb unserer Synchronisierung gesehen. Das ist Verlauf (z. B. eine andere App, die den Rückstand des Straps abruft), kein Live-R22-Stream (#494)." - } - }, - "en": { - "stringUnit": { - "state": "new", - "value": "%1$lld type-0x2F historical-offload frame%2$@ seen outside our sync, these are history (e.g. another app pulling the strap's backlog), not a live R22 stream (#494)." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "%lld trama%@ de descarga histórica tipo 0x2F vistos fuera de nuestra sincronización. Son historial (p. ej., otra app extrayendo el archivo de la pulsera), no una transmisión R22 en vivo (#494)." - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "%1$lld trame%2$@ de transfert historique type-0x2F repérée hors de notre synchronisation. Ce sont des éléments d'historique (par ex. une autre appli qui récupère l'arriéré du bracelet), pas un flux R22 en direct (#494)." - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "%1$lld frame%2$@ di scarico storico type-0x2F visti al di fuori della nostra sincronizzazione, sono cronologia (es. un'altra app che scarica l'arretrato della fascia), non uno stream R22 in diretta (#494)." - } - }, - "pt-PT": { - "stringUnit": { - "state": "translated", - "value": "%1$lld type-0x2F histórico-offload frame%2$@ visto fora da nossa sincronização, estes são históricos (por exemplo, outra aplicação a puxar o backlog da bracelete), não uma transmissão R22 em direto (#494)." - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "%1$lld кадр%2$@ типа 0x2F (historical-offload) вне нашей синхронизации — это история (например, другое приложение считывает архив браслета), а не активный поток R22 (#494)." - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "在我们的同步之外检测到 %1$lld 个 type-0x2F 历史卸载帧%2$@,这些是历史数据(例如另一个应用在拉取手环的积压数据),而非实时 R22 流(#494)。" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "在我們的同步之外偵測到 %1$lld 個 type-0x2F 歷史卸載幀%2$@,這些是歷史資料(例如另一個應用程式在拉取手環的積壓資料),而非即時 R22 流(#494)。" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "%1$lld typ-0x2F historyczna ramka odciążająca%2$@ widoczna poza naszą synchronizacją, to jest historia (np. inna aplikacja pobierająca zaległości paska), a nie strumień na żywo R22 (#494)." - } - } - } - }, "%lld type-0x2F historical-offload frames seen outside our sync. These are history (e.g. another app pulling the strap's backlog), not a live R22 stream (#494).": { "localizations": { "de": { @@ -12889,71 +15127,6 @@ } } } } }, - "%lld year%@ %@ than your age": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "%1$lld Jahr%2$@ %3$@ als dein Alter" - } - }, - "en": { - "stringUnit": { - "state": "new", - "value": "%1$lld year%2$@ %3$@ than your age" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "%lld año%@ %@ que tu edad" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "%1$lld an%2$@ %3$@ que votre âge" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "%1$lld ann%2$@ %3$@ della tua età" - } - }, - "pt-PT": { - "stringUnit": { - "state": "translated", - "value": "%lld ano%@ %@ que a tua idade" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "%1$lld год%2$@ %3$@ вашего возраста" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "比你的年龄%3$@ %1$lld 岁%2$@" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "比你的年齡%3$@ %1$lld 歲%2$@" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "%1$lld rok%2$@ %3$@ niż Twój wiek" - } - } - } - }, "%lld years older than your age": { "localizations": { "de": { @@ -13630,6 +15803,24 @@ "state": "translated", "value": "%lld-%lld%@ · krok %lld" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%lld–%lld%@ · шаг %lld" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%lld-%lld%@ · 第 %lld 步" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%lld-%lld%@ · 第 %lld 步" + } } } }, @@ -14262,6 +16453,24 @@ "state": "translated", "value": "%lld–%lld bpm" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%lld–%lld уд/мин" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%lld–%lld bpm" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%lld–%lld bpm" + } } } }, @@ -14836,6 +17045,24 @@ "state": "translated", "value": "Skala 0-%lld" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Шкала 0–%lld" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "0-%lld 量表" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "0-%lld 量表" + } } } }, @@ -15798,6 +18025,18 @@ "state": "translated", "value": "1 minuta" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "1 мин" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "1 分钟" + } } } }, @@ -16946,6 +19185,24 @@ "state": "translated", "value": "1 tydzień" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "1 неделя" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "1 周" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "1 週" + } } } }, @@ -16980,6 +19237,24 @@ "state": "translated", "value": "1 trening" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "1 тренировка" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "1 次锻炼" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "1 次鍛鍊" + } } } }, @@ -17594,6 +19869,24 @@ "state": "translated", "value": "2 dni" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "2 дня" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "2 天" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "2 天" + } } } }, @@ -17628,6 +19921,18 @@ "state": "translated", "value": "2 minuty" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "2 мин" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "2 分钟" + } } } }, @@ -17662,6 +19967,24 @@ "state": "translated", "value": "2 tygodnie" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "2 недели" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "2 周" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "2 週" + } } } }, @@ -18282,6 +20605,18 @@ "state": "translated", "value": "5 min" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "5 мин" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "5 分钟" + } } } }, @@ -18664,6 +20999,24 @@ "state": "translated", "value": "Wartość bazowa 7 nocy %lld ms, normalna %lld do %lld ms%@" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Норма за 7 ночей %lld мс, обычный диапазон %lld–%lld мс%@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "7 夜基线 %lld ms,正常范围 %lld 至 %lld ms%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "7 夜基線 %lld ms,正常範圍 %lld 至 %lld ms%@" + } } } }, @@ -19302,6 +21655,24 @@ "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." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "某个快捷指令想向 Apple 健康导入源添加 %lld 天和 %lld 次锻炼的数据。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "某個捷徑想向 Apple 健康匯入來源新增 %lld 天和 %lld 次鍛鍊的資料。" + } } } }, @@ -19336,6 +21707,24 @@ "state": "translated", "value": "Skrót chce dodać dane do źródła importu Apple Health." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Команда хочет добавить данные в источник импорта Apple Health." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "某个快捷指令想向 Apple 健康导入源添加数据。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "某個捷徑想向 Apple 健康匯入來源新增資料。" + } } } }, @@ -19861,70 +22250,6 @@ } } }, - "A calm nudge %lldh %lldm before your wake time.": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Ein sanfter Anstoß %1$lldh %2$lldm vor deiner Aufwachzeit." - } - }, - "en": { - "stringUnit": { - "state": "new", - "value": "A calm nudge %1$lldh %2$lldm before your wake time." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Un aviso suave %lldh %lldm antes de tu hora de despertar." - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Un rappel doux %1$lldh %2$lldm avant votre heure de réveil." - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Un avviso tranquillo %1$lldh %2$lldm prima della tua ora di risveglio." - } - }, - "pt-PT": { - "stringUnit": { - "state": "translated", - "value": "Um empurrãozinho calmo %1$lldh %2$lldm antes de acordar." - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Спокойное напоминание за %1$lldч %2$lldм до времени пробуждения." - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "在你起床时间前 %1$lld 小时 %2$lld 分钟的一次平静提醒。" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "在你起床時間前 %1$lld 小時 %2$lld 分鐘的一次平靜提醒。" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Spokojne szturchnięcie %1$lldh %2$lldm przed pobudką." - } - } - } - }, "A cardiovascular load in the Banister TRIMP family: time spent in each heart-rate zone, weighted so harder zones count for more, summed into one daily figure.": { "localizations": { "de": { @@ -20020,6 +22345,18 @@ "state": "translated", "value": "Klasyczna lektura jednym spojrzeniem na partytury NOOP. Te same dane, inny obiektyw." } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "一眼看尽 NOOP 自有评分的经典视图。相同的数据,不同的视角。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "一眼看盡 NOOP 自有評分的經典檢視。相同的資料,不同的視角。" + } } } }, @@ -21504,6 +23841,24 @@ "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": "Одно напоминание в день, когда ваше Усилие достигает нижней границы сегодняшнего оптимального диапазона нагрузки, рассчитанного по вашему восстановлению." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "当你的消耗达到今日最佳强度区间的下限时,每天提醒一次;该区间根据你的恢复情况得出。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "當你的消耗達到今日最佳強度區間的下限時,每天提醒一次;該區間根據你的恢復情況得出。" + } } } }, @@ -21815,7 +24170,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 +24196,24 @@ "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)." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "对你每夜 HRV 的只读 Plews/Altini 最小有意义变化解读:显示你的 7 夜 HRV 基线处于个人正常区间之上、之内还是之下。它不改变其他任何内容,无论开启与否,能量环都完全相同。这属于粗略的早期测试,尚未针对多样的真实数据验证(n=1)。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "對你每夜 HRV 的唯讀 Plews/Altini 最小有意義變化解讀:顯示你的 7 夜 HRV 基線處於個人正常區間之上、之內還是之下。它不改變其他任何內容,無論開啟與否,能量環都完全相同。這屬於粗略的早期測試,尚未針對多樣的真實資料驗證(n=1)。" + } } } }, @@ -22552,7 +24925,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 +24951,24 @@ "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. Вступает в силу для следующих размеченных ночей." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "一套透明的心肺分期方法,比旧的 V1 分期能更好地还原深睡和 REM,现已成为默认。它只改变已检测到的夜间如何划分睡眠阶段(检测与评分不变);关闭它即可回到 V1。对之后分期的夜晚生效。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "一套透明的心肺分期方法,比舊的 V1 分期能更好地還原深睡和 REM,現已成為預設。它只改變已偵測到的夜間如何劃分睡眠階段(偵測與評分不變);關閉它即可回到 V1。對之後分期的夜晚生效。" + } } } }, @@ -25894,7 +28285,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 +28311,24 @@ "state": "translated", "value": "Dodaj poświadczenia aplikacji Oura do pliku OuraSecrets.xcconfig, aby to włączyć." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Добавьте учётные данные приложения Oura в OuraSecrets.xcconfig, чтобы включить это." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "将你的 Oura 应用凭据添加到 OuraSecrets.xcconfig 以启用此功能。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "將你的 Oura 應用程式憑證加入 OuraSecrets.xcconfig 以啟用此功能。" + } } } }, @@ -26561,60 +28970,60 @@ } } }, - "After a sync, NOOP looks over your recent heart rate for a sustained, raised stretch that looks like exercise and offers to save it. It only ever suggests. Nothing is saved until you tap Save, and you can dismiss any suggestion. Deliberately conservative, so the odd workout may be missed. On %@ only.": { + "After a sync, NOOP looks over your recent heart rate for a sustained, raised stretch that looks like exercise and offers to save it. It only ever suggests. Nothing is saved until you tap Save, and you can dismiss any suggestion. Turning this off stops future suggestions but keeps your existing workout history. Deliberately conservative, so the odd workout may be missed. On %@ only.": { "localizations": { "de": { "stringUnit": { "state": "translated", - "value": "Nach einer Synchronisierung prüft NOOP deine jüngste Herzfrequenz auf eine anhaltend erhöhte Phase, die nach Training aussieht, und bietet an, sie zu speichern. Es schlägt nur vor. Nichts wird gespeichert, bis du auf „Sichern“ tippst, und du kannst jeden Vorschlag verwerfen. Bewusst konservativ, das eine oder andere Workout kann also durchrutschen. Nur auf %@." + "value": "Nach einer Synchronisierung prüft NOOP deine jüngste Herzfrequenz auf eine anhaltend erhöhte Phase, die nach Training aussieht, und bietet an, sie zu speichern. Es schlägt nur vor. Nichts wird gespeichert, bis du auf „Sichern“ tippst, und du kannst jeden Vorschlag verwerfen. Wenn du dies deaktivierst, werden künftige Vorschläge gestoppt, dein bestehender Trainingsverlauf bleibt jedoch erhalten. Bewusst konservativ, das eine oder andere Workout kann also durchrutschen. Nur auf %@." } }, "es": { "stringUnit": { "state": "translated", - "value": "Después de una sincronización, NOOP revisa tu frecuencia cardíaca reciente buscando un tramo sostenido y elevado que parezca ejercicio y ofrece guardarlo. Solo sugiere. No se guarda nada hasta que tocas Guardar, y puedes descartar cualquier sugerencia. Es deliberadamente conservador, así que algún entrenamiento puede pasarse por alto. Solo en %@." + "value": "Después de una sincronización, NOOP revisa tu frecuencia cardíaca reciente buscando un tramo sostenido y elevado que parezca ejercicio y ofrece guardarlo. Solo sugiere. No se guarda nada hasta que tocas Guardar, y puedes descartar cualquier sugerencia. Al desactivarlo, se detienen las sugerencias futuras, pero se conserva el historial de entrenamientos existente. Es deliberadamente conservador, así que algún entrenamiento puede pasarse por alto. Solo en %@." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Après une synchronisation, NOOP examine votre fréquence cardiaque récente à la recherche d'une période soutenue et élevée qui ressemble à de l'exercice, et propose de l'enregistrer. Il ne fait que suggérer. Rien n'est enregistré tant que vous n'appuyez pas sur Enregistrer, et vous pouvez ignorer toute suggestion. Délibérément prudent, il se peut donc qu'un entraînement occasionnel soit manqué. Sur %@ uniquement." + "value": "Après une synchronisation, NOOP examine votre fréquence cardiaque récente à la recherche d'une période soutenue et élevée qui ressemble à de l'exercice, et propose de l'enregistrer. Il ne fait que suggérer. Rien n'est enregistré tant que vous n'appuyez pas sur Enregistrer, et vous pouvez ignorer toute suggestion. La désactivation interrompt les suggestions futures tout en conservant votre historique d'entraînement existant. Délibérément prudent, il se peut donc qu'un entraînement occasionnel soit manqué. Sur %@ uniquement." } }, "it": { "stringUnit": { "state": "translated", - "value": "Dopo una sincronizzazione, NOOP esamina la tua frequenza cardiaca recente alla ricerca di un tratto prolungato ed elevato che sembra esercizio fisico e propone di salvarlo. Si limita sempre a suggerire, niente viene salvato finché non tocchi Salva, e puoi ignorare qualsiasi suggerimento. Volutamente conservativo, quindi qualche allenamento può sfuggire. Solo su %@." + "value": "Dopo una sincronizzazione, NOOP esamina la tua frequenza cardiaca recente alla ricerca di un tratto prolungato ed elevato che sembra esercizio fisico e propone di salvarlo. Si limita sempre a suggerire, niente viene salvato finché non tocchi Salva, e puoi ignorare qualsiasi suggerimento. Disattivandolo, le proposte future si interrompono, ma la cronologia degli allenamenti esistente viene conservata. Volutamente conservativo, quindi qualche allenamento può sfuggire. Solo su %@." } }, "pt-PT": { "stringUnit": { "state": "translated", - "value": "Após uma sincronização, o NOOP analisa a tua frequência cardíaca recente em busca de um alongamento sustentado e elevado que pareça exercício e oferece-se para o guardar. Apenas sugere. Nada é guardado até tocar em Guardar e pode descartar qualquer sugestão. Deliberadamente conservador, pelo que alguns treinos podem ser perdidos. Apenas em %@." + "value": "Após uma sincronização, o NOOP analisa a tua frequência cardíaca recente em busca de um alongamento sustentado e elevado que pareça exercício e oferece-se para o guardar. Apenas sugere. Nada é guardado até tocar em Guardar e pode descartar qualquer sugestão. Desativar esta opção impede sugestões futuras, mas mantém o histórico de treinos existente. Deliberadamente conservador, pelo que alguns treinos podem ser perdidos. Apenas em %@." } }, "ru": { "stringUnit": { "state": "translated", - "value": "После синхронизации NOOP просматривает недавний пульс в поисках устойчивого повышенного отрезка, похожего на тренировку, и предлагает его сохранить. Это только предложение. Ничего не сохраняется, пока вы не нажмёте «Сохранить», и любое предложение можно отклонить. Специально осторожный подход, поэтому отдельные тренировки могут быть пропущены. Только на %@." + "value": "После синхронизации NOOP просматривает недавний пульс в поисках устойчивого повышенного отрезка, похожего на тренировку, и предлагает его сохранить. Это только предложение. Ничего не сохраняется, пока вы не нажмёте «Сохранить», и любое предложение можно отклонить. Если отключить эту функцию, новые предложения перестанут появляться, а существующая история тренировок сохранится. Специально осторожный подход, поэтому отдельные тренировки могут быть пропущены. Только на %@." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "同步后,NOOP 会检查你近期的心率,寻找一段看起来像运动的、持续升高的区段,并建议保存它。它只会建议,在你点击「保存」之前不会保存任何内容,你也可以忽略任何建议。它刻意保守,因此偶尔可能会漏掉某次锻炼。仅在 %@ 上。" + "value": "同步后,NOOP 会检查你近期的心率,寻找一段看起来像运动的、持续升高的区段,并建议保存它。它只会建议,在你点击「保存」之前不会保存任何内容,你也可以忽略任何建议。关闭此功能会停止今后的建议,但会保留现有的锻炼历史记录。它刻意保守,因此偶尔可能会漏掉某次锻炼。仅在 %@ 上。" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "同步後,NOOP 會檢查你近期的心率,尋找一段看起來像運動的、持續升高的區段,並建議保存它。它只會建議,在你點擊「儲存」之前不會保存任何內容,你也可以忽略任何建議。它刻意保守,因此偶爾可能會漏掉某次鍛鍊。僅在 %@ 上。" + "value": "同步後,NOOP 會檢查你近期的心率,尋找一段看起來像運動的、持續升高的區段,並建議保存它。它只會建議,在你點擊「儲存」之前不會保存任何內容,你也可以忽略任何建議。關閉此功能會停止日後的建議,但會保留現有的鍛鍊歷史記錄。它刻意保守,因此偶爾可能會漏掉某次鍛鍊。僅在 %@ 上。" } }, "pl": { "stringUnit": { "state": "translated", - "value": "Po synchronizacji NOOP sprawdza Twoje ostatnie tętno pod kątem trwałego, podwyższonego odcinka rozciągającego, który wygląda jak ćwiczenie, i proponuje jego zapisanie. Zawsze tylko sugeruje. Nic nie zostanie zapisane, dopóki nie klikniesz Zapisz. Możesz odrzucić każdą sugestię. Celowo konserwatywny, więc dziwny trening może zostać pominięty. Tylko w %@." + "value": "Po synchronizacji NOOP sprawdza Twoje ostatnie tętno pod kątem trwałego, podwyższonego odcinka rozciągającego, który wygląda jak ćwiczenie, i proponuje jego zapisanie. Zawsze tylko sugeruje. Nic nie zostanie zapisane, dopóki nie klikniesz Zapisz. Możesz odrzucić każdą sugestię. Wyłączenie tej funkcji zatrzyma przyszłe sugestie, ale zachowa dotychczasową historię treningów. Celowo konserwatywny, więc dziwny trening może zostać pominięty. Tylko w %@." } } } @@ -26650,6 +29059,18 @@ "state": "translated", "value": "Po wysiłku o dużej intensywności" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "После высокоинтенсивной нагрузки" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "高强度运动之后" + } } } }, @@ -27288,6 +29709,24 @@ "state": "translated", "value": "Wszystkie działania" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Все активности" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "所有活动" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "所有活動" + } } } }, @@ -29704,7 +32143,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 +32169,24 @@ "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), поэтому оставьте резервный будильник для всего, что действительно нельзя пропустить." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "使用实验性的 5/MG 命令在手环上设置。我们尚未确认 5/MG 上由手环驱动的唤醒(仅在 WHOOP 4.0 上确认过),因此对于绝不能错过的事项请保留备用闹钟。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "使用實驗性的 5/MG 指令在手環上設定。我們尚未確認 5/MG 上由手環驅動的喚醒(僅在 WHOOP 4.0 上確認過),因此對於絕不能錯過的事項請保留備用鬧鐘。" + } } } }, @@ -29738,7 +32195,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 +32221,24 @@ "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). Оставьте резервный будильник для всего, что действительно нельзя пропустить." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "直接在手环上设置,因此即使手机休眠或 NOOP 已关闭,它也能在你的起床时间震动。发送与官方应用完全相同的闹钟命令,已在真实的 WHOOP 4.0 上确认可以震动(社区抓包 + 设备实测,#535)。对于绝不能错过的事项请保留备用闹钟。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "直接在手環上設定,因此即使手機休眠或 NOOP 已關閉,它也能在你的起床時間震動。傳送與官方應用程式完全相同的鬧鐘指令,已在真實的 WHOOP 4.0 上確認可以震動(社群封包擷取 + 裝置實測,#535)。對於絕不能錯過的事項請保留備用鬧鐘。" + } } } }, @@ -29830,7 +32305,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 +32331,24 @@ "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). Оставьте резервный будильник для всего, что действительно нельзя пропустить." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "设置手环在你的起床时间震动,即使 NOOP 已关闭。发送与官方应用完全相同的闹钟命令,已在真实的 WHOOP 4.0 上确认可以震动(社区抓包 + 设备实测,#535)。对于绝不能错过的事项请保留备用闹钟。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "設定手環在你的起床時間震動,即使 NOOP 已關閉。傳送與官方應用程式完全相同的鬧鐘指令,已在真實的 WHOOP 4.0 上確認可以震動(社群封包擷取 + 裝置實測,#535)。對於絕不能錯過的事項請保留備用鬧鐘。" + } } } }, @@ -29937,6 +32430,12 @@ "value": "Organiser Aujourd'hui" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Настроить «Сегодня»" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -34524,6 +37023,24 @@ "state": "translated", "value": "Napięcie akumulatora %.2f w woltach" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Напряжение батареи %.2f вольт" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "电池电压 %.2f 伏" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "電池電壓 %.2f 伏特" + } } } }, @@ -34558,6 +37075,24 @@ "state": "translated", "value": "Sonda informacji o akumulatorze (#592 RE)" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Проба сведений о батарее (#592 RE)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "电池信息探测(#592 RE)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "電池資訊探測(#592 RE)" + } } } }, @@ -34592,6 +37127,18 @@ "state": "translated", "value": "Sonda informacji o akumulatorze (#592 RE)…" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Проба сведений о батарее (#592 RE)…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "电池信息探测(#592 RE)…" + } } } }, @@ -34626,6 +37173,18 @@ "state": "translated", "value": "Wynik sondy informacji o akumulatorze (#592)" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Результат пробы батареи (#592)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "电池信息探测结果(#592)" + } } } }, @@ -36399,192 +38958,222 @@ } } }, - "Body clock": { + "Body clock": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Innere Uhr" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reloj biológico" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Horloge biologique" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Orologio biologico" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Relógio biológico" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Биологические часы" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "生物钟" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "生理時鐘" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zegar biologiczny" + } + } + } + }, + "Body fat": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Körperfett" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Body fat" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Grasa corporal" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Graisse corporelle" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Grasso corporeo" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Gordura corporal" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Процент жира в организме" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "体脂" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "體脂" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Tłuszcz w organizmie" + } + } + } + }, + "Body-location probe (#690 RE)": { "localizations": { "de": { "stringUnit": { "state": "translated", - "value": "Innere Uhr" + "value": "Körperpositions-Test (#690 RE)" } }, "es": { "stringUnit": { "state": "translated", - "value": "Reloj biológico" + "value": "Sonda de posición corporal (#690 RE)" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Horloge biologique" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Orologio biologico" + "value": "Sonde de position corporelle (#690 RE)" } }, "pt-PT": { "stringUnit": { "state": "translated", - "value": "Relógio biológico" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Биологические часы" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "生物钟" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "生理時鐘" + "value": "Sonda de localização corporal (#690 RE)" } }, "pl": { "stringUnit": { "state": "translated", - "value": "Zegar biologiczny" - } - } - } - }, - "Body fat": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Körperfett" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Body fat" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Grasa corporal" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Graisse corporelle" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Grasso corporeo" - } - }, - "pt-PT": { - "stringUnit": { - "state": "translated", - "value": "Gordura corporal" + "value": "Sonda do lokalizacji ciała (#690 RE)" } }, "ru": { "stringUnit": { "state": "translated", - "value": "Процент жира в организме" + "value": "Проба положения на теле (#690 RE)" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "体脂" + "value": "佩戴位置探测(#690 RE)" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "體脂" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Tłuszcz w organizmie" + "value": "佩戴位置探測(#690 RE)" } } } }, - "Body-location probe (#690 RE)": { + "Body-location probe (#690 RE)…": { "localizations": { "de": { "stringUnit": { "state": "translated", - "value": "Körperpositions-Test (#690 RE)" + "value": "Körperpositions-Test (#690 RE)…" } }, "es": { "stringUnit": { "state": "translated", - "value": "Sonda de posición corporal (#690 RE)" + "value": "Sonda de posición corporal (#690 RE)…" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Sonde de position corporelle (#690 RE)" + "value": "Sonde de position corporelle (#690 RE)…" } }, "pt-PT": { "stringUnit": { "state": "translated", - "value": "Sonda de localização corporal (#690 RE)" + "value": "Sonda de localização corporal (#690 RE)…" } }, "pl": { "stringUnit": { "state": "translated", - "value": "Sonda do lokalizacji ciała (#690 RE)" - } - } - } - }, - "Body-location probe (#690 RE)…": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Körperpositions-Test (#690 RE)…" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Sonda de posición corporal (#690 RE)…" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Sonde de position corporelle (#690 RE)…" + "value": "Sonda do lokalizacji ciała (#690 RE)…" } }, - "pt-PT": { + "ru": { "stringUnit": { "state": "translated", - "value": "Sonda de localização corporal (#690 RE)…" + "value": "Проба положения на теле (#690 RE)…" } }, - "pl": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "Sonda do lokalizacji ciała (#690 RE)…" + "value": "佩戴位置探测(#690 RE)…" } } } @@ -36620,6 +39209,18 @@ "state": "translated", "value": "Wynik sondy lokalizacji ciała (#690)" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Результат пробы положения на теле (#690)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "佩戴位置探测结果(#690)" + } } } }, @@ -37806,7 +40407,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 +40433,24 @@ "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 с маркерами (дата, маркер, значение, единица). Названия, совпадающие с каталогом, объединяются с вашими существующими маркерами; всё остальное добавляется как свой маркер. Строки, которые не удалось прочитать, пропускаются и подсчитываются — никогда не угадываются. Всё импортированное остаётся на %@." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "导入标记 CSV(日期、标记、数值、单位)。与目录匹配的名称会并入你已有的标记;其余的作为自定义标记导入。无法读取的行会被跳过并计数,绝不猜测。你导入的一切都保留在 %@ 上。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "匯入標記 CSV(日期、標記、數值、單位)。與目錄相符的名稱會併入你已有的標記;其餘的作為自訂標記匯入。無法讀取的列會被略過並計數,絕不猜測。你匯入的一切都保留在 %@ 上。" + } } } }, @@ -37913,6 +40532,12 @@ "value": "Diffusion des ressources humaines de ce téléphone" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Транслировать пульс с этого телефона" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -38185,6 +40810,12 @@ "value": "Sangle de diffusion HR (Garmin/ANT)" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Транслировать пульс браслета (Garmin/ANT)" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -39872,64 +42503,6 @@ } } }, - "Buzz your strap or mark a moment from Siri, Spotlight, the Shortcuts app, or a Back-Tap / automation. No setup needed.": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Lass deinen Strap vibrieren oder markiere einen Moment über Siri, Spotlight, die Kurzbefehle-App oder ein Back-Tap/eine Automation. Keine Einrichtung nötig." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Haz vibrar tu pulsera o marca un momento desde Siri, Spotlight, la app Atajos o un Back-Tap / automatización. Sin configuración." - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Faites vibrer votre bracelet ou marquez un moment depuis Siri, Spotlight, l'app Raccourcis, ou un Back Tap / une automatisation. Aucune configuration nécessaire." - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Fai vibrare la fascia o registra un momento da Siri, Spotlight, l'app Comandi o un Back-Tap / un'automazione, senza alcuna configurazione." - } - }, - "pt-PT": { - "stringUnit": { - "state": "translated", - "value": "Toque na tua bracelete ou marque um momento no Siri, no Spotlight, na aplicação Shortcuts ou num Back-Tap/automação. Nenhuma configuração necessária." - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Вызовите вибрацию браслета или отметьте момент через Siri, Spotlight, приложение «Команды», Back Tap или автоматизацию. Настройка не требуется." - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "通过 Siri、聚焦搜索、快捷指令应用,或轻点背面 / 自动化来震动你的手环或标记某个时刻,无需任何设置。" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "透過 Siri、Spotlight、捷徑應用程式,或輕點背面 / 自動化來震動你的手環或標記某個時刻,無需任何設定。" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Włącz sygnał dźwiękowy na pasku lub zaznacz chwilę za pomocą Siri, Spotlight, aplikacji Skróty lub Back-Tap/automatyzacji. Nie jest wymagana żadna konfiguracja." - } - } - } - }, "Buzz your strap when these apps notify you. Everything runs on %@.": { "localizations": { "de": { @@ -41332,6 +43905,24 @@ "state": "translated", "value": "Kalibracja (%lld/%lld w nocy)" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Калибровка (%lld из %lld ночей)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "校准中(%lld/%lld 夜)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "校準中(%lld/%lld 夜)" + } } } }, @@ -42526,6 +45117,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 +45685,24 @@ "state": "translated", "value": "Zmień na numer" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сменить на число" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "改为数字" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "改為數字" + } } } }, @@ -43125,6 +45737,24 @@ "state": "translated", "value": "Zmień na Tak/Nie" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сменить на «Да/Нет»" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "改为是/否" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "改為是/否" + } } } }, @@ -44305,7 +46935,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 +46961,24 @@ "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 собрал сырые потоки." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "能量将你的 HRV 与个人基线(约 55%%)、静息心率(约 20%%)、休息质量(约 15%%)、呼吸(约 5%%)和皮肤温度偏差(约 5%%)加权计算。消耗是根据心率区间时长得出的 0-%lld 心血管负荷。休息由体动和心率分期得出。一切都在本地依据手环的原始数据计算。只要 NOOP 采集到原始数据流,任何一天都适用。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "能量將你的 HRV 與個人基線(約 55%%)、靜息心率(約 20%%)、休息品質(約 15%%)、呼吸(約 5%%)和皮膚溫度偏差(約 5%%)加權計算。消耗是根據心率區間時長得出的 0-%lld 心血管負荷。休息由體動和心率分期得出。一切都在本機依據手環的原始資料計算。只要 NOOP 採集到原始資料流,任何一天都適用。" + } } } }, @@ -50640,6 +53288,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": { @@ -52981,6 +56013,18 @@ "state": "translated", "value": "Czytanie w połączeniu" } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "耦合读数" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "耦合讀數" + } } } }, @@ -55679,6 +58723,24 @@ "state": "translated", "value": "Dzień 1: jasne światło %@-%@, wyłączenie świateł wokół %@." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "День 1: яркий свет %@–%@, отбой около %@." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "第 1 天:强光 %@-%@,约 %@ 熄灯。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "第 1 天:強光 %@-%@,約 %@ 熄燈。" + } } } }, @@ -57428,6 +60490,12 @@ "value": "Carreaux détaillés" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Подробные плитки" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -61111,6 +64179,24 @@ "state": "translated", "value": "ALGORYTMY EKSPERYMENTALNE" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Экспериментальные алгоритмы" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "实验性算法" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "實驗性演算法" + } } } }, @@ -61447,6 +64533,18 @@ "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": "Каждая линия показывает, на сколько ударов в минуту изменился ваш пульс после нагрузки. Учитываются только высокоинтенсивные тренировки с записанным пульсом после тренировки." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "每条线显示运动后心率的变化幅度(每分钟心跳次数)。仅包含记录了运动后心率的高强度锻炼。" + } } } }, @@ -69714,6 +72812,64 @@ } } }, + "Fitness Age stops here. VO₂max keeps moving.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Das Fitnessalter endet hier. VO₂max nicht." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La edad de forma física se detiene aquí. El VO₂máx no." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "L’âge de forme s’arrête ici. Pas la VO₂max." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "L’età di fitness si ferma qui. Il VO₂max no." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wiek sprawności zatrzymuje się tutaj. VO₂max nie." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "A idade de forma física para aqui. O VO₂máx não." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Фитнес-возраст здесь останавливается. VO₂max — нет." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "体能年龄到此为止,VO₂max 仍在变化。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "體能年齡到此為止,VO₂max 仍在變化。" + } + } + } + }, "Fitness Age %lld, %@. Tap to see the trend.": { "localizations": { "de": { @@ -70222,6 +73378,24 @@ "state": "translated", "value": "Zapomnij o dostępie Oura" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Забыть доступ к Oura" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "忘记 Oura 授权" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "忘記 Oura 授權" + } } } }, @@ -72730,6 +75904,24 @@ "state": "translated", "value": "Grupa" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Группа" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "分组" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "分組" + } } } }, @@ -73118,6 +76310,24 @@ "state": "translated", "value": "GODZINY SNU" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "ЧАСОВ СНА" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "睡眠小时数" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "睡眠小時數" + } } } }, @@ -73861,6 +77071,12 @@ "value": "Interpolation du sous-logement HR-from-PPG (v26)" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пульс из PPG: интерполяция внутри задержки (заполнение пропусков v26)" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -74487,6 +77703,12 @@ "value": "Préparation à la VFC (Plews/Altini)" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Готовность по ВСР (Plews/Altini)" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -74527,6 +77749,12 @@ "value": "Préparation à la VFC (expérimental)" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Готовность по ВСР (эксперимент)" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -74578,6 +77806,24 @@ "state": "translated", "value": "Gotowość HRV (eksperymentalna): %@" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Готовность по ВСР (эксперимент): %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "HRV 就绪度(实验性):%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "HRV 就緒度(實驗性):%@" + } } } }, @@ -74717,6 +77963,12 @@ "value": "Fenêtre VFC" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Окно ВСР" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -75161,7 +78413,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 +78439,24 @@ "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": "Внимание: этот тестовый режим выключен, поэтому в отчёте нет соответствующего захвата. Чтобы отчёт был полезен, включите режим, воспроизведите проблему в надетом браслете и отправьте отчёт заново." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "提示:此测试模式已关闭,因此报告中没有相应的采集数据。若要获得有用的报告,请开启该模式,佩戴手环复现问题,然后再次上报。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "提示:此測試模式已關閉,因此報告中沒有相應的擷取資料。若要獲得有用的報告,請開啟該模式,佩戴手環重現問題,然後再次回報。" + } } } }, @@ -75709,6 +78979,18 @@ "state": "translated", "value": "Tętno Regeneracja" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Восстановление пульса" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "心率恢复" + } } } }, @@ -76155,6 +79437,18 @@ "state": "translated", "value": "Odzyskiwanie tętna przy %@, %lld uderzeń na minutę" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Восстановление пульса на %@, %lld ударов в минуту" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ 的心率恢复,每分钟 %lld 次" + } } } }, @@ -76189,6 +79483,24 @@ "state": "translated", "value": "Odzyskiwanie tętna przy %@, niedostępne" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Восстановление пульса на %@ — нет данных" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ 的心率恢复,暂无数据" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ 的心率恢復,暫無資料" + } } } }, @@ -76635,6 +79947,18 @@ "state": "translated", "value": "Trend odzyskiwania tętna w uderzeniach na minutę" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Тренд восстановления пульса в ударах в минуту" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "心率恢复趋势(每分钟心跳次数)" + } } } }, @@ -76669,6 +79993,18 @@ "state": "translated", "value": "Odzyskiwanie tętna · %@" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Восстановление пульса · %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "心率恢复 · %@" + } } } }, @@ -77760,6 +81096,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 +83218,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 +83244,24 @@ "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 по пульсу в покое и ВСР." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "如何解读:HRV、静息心率、睡眠时长、呼吸频率和皮肤温度均由手环测量(皮肤温度显示为相对你自身基线的偏差)。锻炼是你记录或被检测到的活动次数。恢复、强度和压力是 NOOP 自有的设备端评分,并非临床指标:恢复是每日就绪度综合值(HRV、静息心率、睡眠和皮肤温度趋势),强度是由心率推导的心血管负荷,压力是由静息心率和 HRV 得出的 0-3 自主神经负荷指数。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "如何解讀:HRV、靜息心率、睡眠時長、呼吸頻率和皮膚溫度均由手環測量(皮膚溫度顯示為相對你自身基線的偏差)。鍛鍊是你記錄或被偵測到的活動次數。恢復、強度和壓力是 NOOP 自有的裝置端評分,並非臨床指標:恢復是每日就緒度綜合值(HRV、靜息心率、睡眠和皮膚溫度趨勢),強度是由心率推導的心血管負荷,壓力是由靜息心率和 HRV 得出的 0-3 自主神經負荷指數。" + } } } }, @@ -81863,6 +85223,24 @@ "state": "translated", "value": "Zaimportować dane Apple Health?" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Импортировать данные Apple Health?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "要导入 Apple 健康数据吗?" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "要匯入 Apple 健康資料嗎?" + } } } }, @@ -82187,6 +85565,24 @@ "state": "translated", "value": "Zaimportuj ponownie" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Импортировать снова" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "再次导入" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "再次匯入" + } } } }, @@ -85283,6 +88679,12 @@ "value": "Rappel du journal" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Напоминание о журнале" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -85671,6 +89073,12 @@ "value": "Garder les derniers instantanés" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Хранить последние снимки" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -86181,6 +89589,12 @@ "value": "Démarrage à (batterie)" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Включать при (заряде браслета)" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -87953,6 +91367,64 @@ } } }, + "Last reported by the ring": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zuletzt vom Ring gemeldet" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Último valor informado por el anillo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Dernière valeur transmise par la bague" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Ultimo dato riportato dall'anello" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Último valor comunicado pelo anel" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Последние данные от кольца" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "由指环最后上报" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "由指環最後回報" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Ostatnio zgłoszone przez pierścień" + } + } + } + }, "Last seen %@": { "localizations": { "de": { @@ -89830,6 +93302,18 @@ "state": "translated", "value": "Styl życia" } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "生活方式" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "生活型態" + } } } }, @@ -90276,6 +93760,24 @@ "state": "translated", "value": "Liquid Today (prototyp)" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Liquid Today (прототип)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Liquid Today(原型)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Liquid Today(原型)" + } } } }, @@ -90769,6 +94271,12 @@ "value": "Sessions en direct (bêta)" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сессии в реальном времени (бета)" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -92998,6 +96506,12 @@ "value": "Remplir le journal du jour" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Заполнить журнал за сегодня" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -93688,6 +97202,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 +97265,24 @@ "state": "translated", "value": "Najdłuższy: %lld dni" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Дольше всего: %lld дней" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "最长:%lld 天" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "最長:%lld 天" + } } } }, @@ -93785,6 +97323,24 @@ "state": "translated", "value": "Najdłuższy: 1 dzień" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Дольше всего: 1 день" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "最长:1 天" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "最長:1 天" + } } } }, @@ -94508,6 +98064,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 +102653,12 @@ "value": "Raffinement de la sillage en mouvement" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Уточнение пробуждения по движению" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -99401,6 +102996,12 @@ "value": "Bouge de toute façon" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Всё равно перенести" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -99484,7 +103085,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Bewegen Sie diesen Schlaf?" + "value": "Bewege diesen Schlaf?" } }, "es": { @@ -99499,6 +103100,12 @@ "value": "Bouge ce sommeil ?" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перенести этот сон?" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -100534,7 +104141,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 +104167,24 @@ "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 по направлению, но не сходятся число в число. В этом и смысл." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "NOOP 为你提供三项每日评分(能量、消耗和休息),每项均为 0-100 分。它们基于你手环的原始信号,采用公开发表的同行评审运动科学方法,完全在你的设备上计算。它们不是 WHOOP 的评分:我们没有 WHOOP 的私有算法,也不会假装有。它们用开放科学回答同样的三个问题,因此通常在趋势方向上与 WHOOP 一致,但不会逐个数字吻合。这正是重点所在。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "NOOP 為你提供三項每日評分(能量、消耗和休息),每項均為 0-100 分。它們基於你手環的原始訊號,採用公開發表的同儕審查運動科學方法,完全在你的裝置上計算。它們不是 WHOOP 的評分:我們沒有 WHOOP 的私有演算法,也不會假裝有。它們用開放科學回答同樣的三個問題,因此通常在趨勢方向上與 WHOOP 一致,但不會逐個數字吻合。這正是重點所在。" + } } } }, @@ -106045,6 +109670,24 @@ "state": "translated", "value": "Brak szczegółów tętna tej nocy" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нет данных о пульсе за эту ночь" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这一夜没有心率明细" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "這一夜沒有心率明細" + } } } }, @@ -106833,6 +110476,24 @@ "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 дней. Проверьте, что он подключён и сохраняет данные." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已有 %lld 天未从你的手环获得新的夜间数据。请检查它是否已连接并在保存数据。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已有 %lld 天未從你的手環獲得新的夜間資料。請檢查它是否已連線並在儲存資料。" + } } } }, @@ -107221,6 +110882,24 @@ "state": "translated", "value": "Brak zdjęcia → rezerwowa osoba.przycięcie.koło" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нет фото → запасной вариант person.crop.circle" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无照片 → 回退到 person.crop.circle" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無相片 → 退回 person.crop.circle" + } } } }, @@ -111854,6 +115533,24 @@ "state": "translated", "value": "Powiadamiaj, kiedy zostanie osiągnięte optymalne napięcie" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Уведомлять о достижении оптимальной нагрузки" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "达到最佳强度时通知我" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "達到最佳強度時通知我" + } } } }, @@ -112306,6 +116003,18 @@ "state": "translated", "value": "OPTYMALNE" } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "最佳" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "最佳" + } } } }, @@ -112907,7 +116616,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 +116642,24 @@ "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": "Более старые копии сверх этого числа удаляются, начиная с самых старых (≈ столько дней). Если данные когда-нибудь повредятся, восстановите самую свежую." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "超出此数量的较旧备份会被清理,最旧的先删(约等于同样天数)。若数据出现损坏,请恢复最新的备份。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "超出此數量的較舊備份會被清理,最舊的先刪(約等於同樣天數)。若資料出現損壞,請還原最新的備份。" + } } } }, @@ -114837,6 +118564,12 @@ "value": "Ouvrir le journal" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открыть журнал" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -115481,6 +119214,18 @@ "state": "translated", "value": "Osiągnięto optymalne napięcie" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Достигнута оптимальная нагрузка" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已达到最佳负荷" + } } } }, @@ -118330,6 +122075,12 @@ "value": "Capture de la VFC en pause" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Приостановить сбор ВСР" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -118509,6 +122260,18 @@ "state": "translated", "value": "Szczyt %lld bpm" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пик %lld уд/мин" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "峰值 %lld bpm" + } } } }, @@ -120229,7 +123992,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Bitte bestätigen Sie diese:" + "value": "Bitte bestätige diese:" } }, "es": { @@ -120244,6 +124007,12 @@ "value": "Veuillez confirmer chacun de ces éléments :" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пожалуйста, подтвердите каждый пункт:" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -120327,7 +124096,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 +124111,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 +124348,24 @@ "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": "Появляется после синхронизации браслета и подсчёта оценок за день — не в ту самую секунду, когда вы пересекаете порог. Не чаще раза в день." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在你的手环同步且 NOOP 为当天评分之后发布,而非你跨过阈值的那一秒。每天最多一次。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在你的手環同步且 NOOP 為當天評分之後發布,而非你跨過閾值的那一秒。每天最多一次。" + } } } }, @@ -120596,6 +124389,12 @@ "value": "Mode d'économie d'énergie" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Режим энергосбережения" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -123700,6 +127499,24 @@ "state": "translated", "value": "REGENERUJĄCY SEN" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "ВОССТАНАВЛИВАЮЩИЙ СОН" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "恢复性睡眠" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "恢復性睡眠" + } } } }, @@ -125171,7 +128988,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 +129014,24 @@ "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 обратно: сон с полными стадиями, непрерывный пульс, тренировки и ночные показатели. Всё остаётся на %@." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "直接从 Apple 健康读取你的心率、HRV、血氧、呼吸频率、睡眠、步数和能量消耗,并将 NOOP 的手环数据写回:包含完整阶段的睡眠、连续心率、锻炼和每夜生理指标。一切都保留在 %@ 上。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "直接從 Apple 健康讀取你的心率、HRV、血氧、呼吸頻率、睡眠、步數和能量消耗,並將 NOOP 的手環資料寫回:包含完整階段的睡眠、連續心率、鍛鍊和每夜生理指標。一切都保留在 %@ 上。" + } } } }, @@ -126259,6 +130094,24 @@ "state": "translated", "value": "Odczyty" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Измерения" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "读数" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "讀數" + } } } }, @@ -126769,6 +130622,24 @@ "state": "translated", "value": "Zrestartuj sondę (4.0 RE)…" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Проба перезагрузки (4.0 RE)…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重启探测(4.0 RE)…" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新啟動探測(4.0 RE)…" + } } } }, @@ -128277,6 +132148,18 @@ "state": "translated", "value": "Trend Regeneracja" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Тренд восстановления" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "恢复趋势" + } } } }, @@ -128782,6 +132665,24 @@ "state": "translated", "value": "Odśwież wiek fitness" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Обновить фитнес-возраст" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "刷新体能年龄" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新整理體能年齡" + } } } }, @@ -130395,6 +134296,12 @@ "value": "Renommer l'élément" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Переименовать пункт" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -130435,6 +134342,12 @@ "value": "Renommer..." } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Переименовать…" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -130692,7 +134605,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 +134631,24 @@ "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": "Заменяет вкладку «Сегодня» прототипом нового дизайна. Выключите в любой момент, чтобы вернуться к классической панели. Читает те же живые данные с вашего браслета." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "用原型改版替换“今天”标签页。随时可关闭以回到经典仪表板。它读取来自你手环的同一份实时数据。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "用原型改版取代「今天」標籤頁。隨時可關閉以回到經典儀表板。它讀取來自你手環的同一份即時資料。" + } } } }, @@ -130984,6 +134915,24 @@ "state": "translated", "value": "Alternatywy klasy badawczej / ulepszenia precyzyjne. Możliwość zapisania się, domyślnie wyłączona, niekliniczna." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Исследовательские альтернативы и точная подстройка. По желанию, по умолчанию выключено, не для клинического применения." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "研究级替代方案/精度微调。需自行开启,默认关闭,非临床用途。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "研究級替代方案/精度微調。需自行開啟,預設關閉,非臨床用途。" + } } } }, @@ -132486,7 +136435,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 +136461,24 @@ "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 секунд, пока перезагружается, затем подключится сам. Записанные данные сохраняются." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "要重新启动 %@ 吗?它会断开约 30 秒进行重启,然后自行重新连接。已记录的数据会保留。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "要重新啟動 %@ 嗎?它會斷線約 30 秒進行重新啟動,然後自行重新連線。已記錄的資料會保留。" + } } } }, @@ -132546,6 +136513,24 @@ "state": "translated", "value": "Uruchom ponownie pasek…" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перезапустить браслет…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重新启动手环…" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新啟動手環…" + } } } }, @@ -132554,7 +136539,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Starten Sie dieses Band neu?" + "value": "Starte dieses Band neu?" } }, "es": { @@ -132569,6 +136554,12 @@ "value": "Redémarrer cette sangle ?" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перезапустить этот браслет?" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -132626,6 +136617,24 @@ "state": "translated", "value": "Uruchomiony ponownie po ponownej kalibracji %@ — nie trzeba go ponownie dotykać." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перезапущено, когда вы откалибровали заново %@ — повторно нажимать не нужно." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已在你于 %@ 重新校准时重启,无需再次点按。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已在你於 %@ 重新校準時重新啟動,無需再次點按。" + } } } }, @@ -134134,6 +138143,24 @@ "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). По умолчанию выключено; вступает в силу для следующих размеченных ночей." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "检查每个被评为清醒的区块是否有真正起身的证据(步行步频、体位变化),而不仅仅是心率上升。没有位移且体位稳定的清醒区块——闷热的夜晚、短暂翻身——会被并回浅睡;真正的起身则保持不变。它会自检手环实际记录了多少体动细节,并在夜间数据过于稀疏而不可信时保持关闭(主要是较旧的 WHOOP 4.0 固件)。默认关闭;对之后分期的夜晚生效。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "檢查每個被評為清醒的區塊是否有真正起身的證據(步行步頻、體位變化),而不僅僅是心率上升。沒有位移且體位穩定的清醒區塊——悶熱的夜晚、短暫翻身——會被併回淺睡;真正的起身則保持不變。它會自檢手環實際記錄了多少體動細節,並在夜間資料過於稀疏而不可信時保持關閉(主要是較舊的 WHOOP 4.0 韌體)。預設關閉;對之後分期的夜晚生效。" + } } } }, @@ -135308,7 +139335,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 +139361,24 @@ "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), примерно вдвое снижая расход батареи. Дневные измерения стресса станут реже. Примечание: непрерывный фоновый сбор ВСР (включая дневной сон) вне этого окна приостановлен. Для дневных измерений ВСР по запросу (в том числе во время дневного сна) используйте кнопку «Сделать замер ВСР» на экране «В реальном времени»." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "仅在你的安静时段窗口(默认 22:00–07:00)内运行连续 HRV 采集,大约可将电量开销减半。白天的压力读数会更稀疏。注意:此窗口之外会暂停后台连续 HRV 采集(包括白天小睡)。如需白天的按需 HRV 读数(包括小睡),请使用“实时”界面上的“进行一次 HRV 测量”按钮。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "僅在你的安靜時段視窗(預設 22:00–07:00)內執行連續 HRV 擷取,大約可將電量開銷減半。白天的壓力讀數會更稀疏。注意:此視窗之外會暫停背景連續 HRV 擷取(包括白天小睡)。如需白天的隨選 HRV 讀數(包括小睡),請使用「即時」畫面上的「進行一次 HRV 測量」按鈕。" + } } } }, @@ -139688,6 +143733,18 @@ "state": "translated", "value": "Wyślij sondę (tylko do odczytu)" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отправить пробу (только чтение)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "发送探测(只读)" + } } } }, @@ -139722,6 +143779,24 @@ "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. На браслет ничего не записывается, и это никогда не меняет определение ношения или оценки." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "发送只读的 GET_BODY_LOCATION_AND_STATUS(0x54)并显示手环的完整原始回复,在 WHOOP 4.0 上会解码佩戴位置记录(版本/位置/置信度/状态)。不会向手环写入任何内容,也绝不改变佩戴检测或评分。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "傳送唯讀的 GET_BODY_LOCATION_AND_STATUS(0x54)並顯示手環的完整原始回覆,在 WHOOP 4.0 上會解碼佩戴位置記錄(版本/位置/信賴度/狀態)。不會向手環寫入任何內容,也絕不改變佩戴偵測或評分。" + } } } }, @@ -140259,64 +144334,6 @@ } } }, - "Set a wake time for specific days (a lie-in at the weekend, say). Days you leave alone use the time above.": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Lege eine Aufwachzeit für bestimmte Tage fest (etwa Ausschlafen am Wochenende). Tage, die du nicht anfasst, nutzen die Zeit oben." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Define una hora de despertar para días concretos (por ejemplo, dormir hasta tarde el fin de semana). Los días que no toques usan la hora de arriba." - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Définissez une heure de réveil pour des jours spécifiques (une grasse matinée le week-end, par exemple). Les jours que vous ne modifiez pas utilisent l'heure ci-dessus." - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Imposta un orario di sveglia per giorni specifici, ad esempio una dormita nel weekend. I giorni che lasci invariati usano l'orario sopra." - } - }, - "pt-PT": { - "stringUnit": { - "state": "translated", - "value": "Defina um horário para acordar em dias específicos (por exemplo, um descanso ao fim de semana). Nos dias em que sai sozinho, utilize o tempo acima." - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Задайте время пробуждения для конкретных дней (например, для более позднего подъёма на выходных). Для остальных дней используется время, указанное выше." - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "为特定的日子设置起床时间,比如周末睡个懒觉。你未作设置的日子会使用上方的时间。" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "為特定的日子設定起床時間,比如週末睡個懶覺。你未作設定的日子會使用上方的時間。" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Ustaw porę budzenia w określone dni (np. odpoczynek w weekend). Dni, w których zostajesz sam, wykorzystaj czas podany powyżej." - } - } - } - }, "Set custom container size": { "localizations": { "de": { @@ -141586,6 +145603,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 +146414,24 @@ "state": "translated", "value": "Trening pasków polegający na ciszy podczas treningów." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Подсказки браслета во время тренировок — по умолчанию молча." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "锻炼期间以静音为先的手环指导。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "鍛鍊期間以靜音為先的手環指導。" + } } } }, @@ -145029,6 +149070,12 @@ "value": "Stade de sommeil (V2)" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Определение стадий сна (V2)" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -145254,6 +149301,24 @@ "state": "translated", "value": "Tętno podczas snu w nocy" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пульс во сне в течение ночи" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "整夜的睡眠心率" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "整夜的睡眠心率" + } } } }, @@ -145515,6 +149580,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 +150810,12 @@ "value": "Tuiles carrées avec un graphique de tendance sous la barre." } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Более квадратные плитки с графиком тренда под полосой." + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -147769,6 +151846,180 @@ } } }, + "End must be after the start.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Das Ende muss nach dem Beginn liegen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El fin debe ser posterior al inicio." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La fin doit être postérieure au début." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "La fine deve essere successiva all'inizio." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "O fim tem de ser posterior ao início." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Завершение должно быть позже начала." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "结束时间必须晚于开始时间。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "結束時間必須晚於開始時間。" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Koniec musi nastąpić po początku." + } + } + } + }, + "End can't be in the future.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Das Ende kann nicht in der Zukunft liegen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El fin no puede estar en el futuro." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La fin ne peut pas être dans le futur." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "La fine non può essere nel futuro." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "O fim não pode ser no futuro." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Завершение не может быть в будущем." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "结束时间不能在未来。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "結束時間不能在未來。" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Koniec nie może przypadać w przyszłości." + } + } + } + }, + "End date and time": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Enddatum und -uhrzeit" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Fecha y hora de fin" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Date et heure de fin" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Data e ora di fine" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Data e hora de fim" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Дата и время завершения" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "结束日期和时间" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "結束日期和時間" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Data i godzina zakończenia" + } + } + } + }, "Start date and time": { "localizations": { "de": { @@ -150220,6 +154471,24 @@ "state": "translated", "value": "Cel Obciążenie" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Целевая нагрузка" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "强度目标" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "強度目標" + } } } }, @@ -151624,6 +155893,18 @@ "state": "translated", "value": "Pasemko" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Серия" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "连续记录" + } } } }, @@ -151865,6 +156146,134 @@ } } }, + "Stress through the day": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Stress im Tagesverlauf" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Stress through the day" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Estrés a lo largo del día" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Stress au fil de la journée" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Stress durante la giornata" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Stres w ciągu dnia" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Stress ao longo do dia" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Стресс в течение дня" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "全天压力变化" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "全天壓力變化" + } + } + } + }, + "Stress %@ of 3": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Stress %@ von 3" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Stress %@ of 3" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Estrés %@ de 3" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Stress %@ de 3" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Stress %@ di 3" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Stress %@ de 3" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Стресс %@ из 3" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "压力 %@(满分 3)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "壓力 %@(滿分 3)" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Naprężenie %@ równe 3" + } + } + } + }, "Stress %@ of 3, %@": { "localizations": { "de": { @@ -152815,6 +157224,18 @@ "state": "translated", "value": "Suplementy" } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "补充剂" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "補充劑" + } } } }, @@ -153994,14 +158415,560 @@ } }, "pl": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "Synchronizowanie historii paska, %lld fragment" + } + }, + "few": { + "stringUnit": { + "state": "translated", + "value": "Synchronizowanie historii paska, %lld fragmenty" + } + }, + "many": { + "stringUnit": { + "state": "translated", + "value": "Synchronizowanie historii paska, %lld fragmentów" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "Synchronizowanie historii paska, %lld fragmentu" + } + } + } + } + } + } + }, + "%lld pages behind at connect": { + "extractionState": "manual", + "localizations": { + "de": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld Seite Rückstand bei Verbindungsaufbau" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld Seiten Rückstand bei Verbindungsaufbau" + } + } + } + } + }, + "en": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld page behind at connect" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld pages behind at connect" + } + } + } + } + }, + "es": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld página de retraso al conectar" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld páginas de retraso al conectar" + } + } + } + } + }, + "fr": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld page de retard à la connexion" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld pages de retard à la connexion" + } + } + } + } + }, + "it": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld pagina di ritardo alla connessione" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld pagine di ritardo alla connessione" + } + } + } + } + }, + "pl": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld strona zaległości przy połączeniu" + } + }, + "few": { + "stringUnit": { + "state": "translated", + "value": "%lld strony zaległości przy połączeniu" + } + }, + "many": { + "stringUnit": { + "state": "translated", + "value": "%lld stron zaległości przy połączeniu" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld strony zaległości przy połączeniu" + } + } + } + } + }, + "pt-PT": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld página em atraso na ligação" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld páginas em atraso na ligação" + } + } + } + } + }, + "ru": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld страница отставания при подключении" + } + }, + "few": { + "stringUnit": { + "state": "translated", + "value": "%lld страницы отставания при подключении" + } + }, + "many": { + "stringUnit": { + "state": "translated", + "value": "%lld страниц отставания при подключении" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld страницы отставания при подключении" + } + } + } + } + }, + "zh-Hans": { + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "连接时落后 %lld 页" + } + } + } + } + }, + "zh-Hant": { + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "連線時落後 %lld 頁" + } + } + } + } + } + } + }, + "Syncing strap history, %lld chunks, %@": { + "extractionState": "manual", + "localizations": { + "de": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "Verlaufssynchronisierung des Straps läuft, %lld Chunk, %@" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "Verlaufssynchronisierung des Straps läuft, %lld Chunks, %@" + } + } + } + } + }, + "en": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "Syncing strap history, %lld chunk, %@" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "Syncing strap history, %lld chunks, %@" + } + } + } + } + }, + "es": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "Sincronizando el historial de la pulsera, %lld bloque, %@" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "Sincronizando el historial de la pulsera, %lld bloques, %@" + } + } + } + } + }, + "fr": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "Synchronisation de l'historique du bracelet, %lld bloc, %@" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "Synchronisation de l'historique du bracelet, %lld blocs, %@" + } + } + } + } + }, + "it": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "Sincronizzazione della cronologia della fascia, %lld blocco, %@" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "Sincronizzazione della cronologia della fascia, %lld blocchi, %@" + } + } + } + } + }, + "pl": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "Synchronizowanie historii paska, %lld fragment, %@" + } + }, + "few": { + "stringUnit": { + "state": "translated", + "value": "Synchronizowanie historii paska, %lld fragmenty, %@" + } + }, + "many": { + "stringUnit": { + "state": "translated", + "value": "Synchronizowanie historii paska, %lld fragmentów, %@" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "Synchronizowanie historii paska, %lld fragmentu, %@" + } + } + } + } + }, + "pt-PT": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "A sincronizar o histórico da bracelete, %lld pedaço, %@" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "A sincronizar o histórico da bracelete, %lld pedaços, %@" + } + } + } + } + }, + "ru": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "Синхронизация истории браслета, %lld фрагмент, %@" + } + }, + "few": { + "stringUnit": { + "state": "translated", + "value": "Синхронизация истории браслета, %lld фрагмента, %@" + } + }, + "many": { + "stringUnit": { + "state": "translated", + "value": "Синхронизация истории браслета, %lld фрагментов, %@" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "Синхронизация истории браслета, %lld фрагмента, %@" + } + } + } + } + }, + "zh-Hans": { + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "正在同步手环历史记录,%lld 个数据块,%@" + } + } + } + } + }, + "zh-Hant": { + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "正在同步手環歷史記錄,%lld 個資料塊,%@" + } + } + } + } + } + } + }, + "Pending sync": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Sync ausstehend" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Pending sync" + } + }, + "es": { "stringUnit": { "state": "translated", - "value": "Synchronizowanie historii pasków, fragmenty %lld" + "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": "等待同步" } } } }, - "Syncing strap history…": { + "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 +160455,24 @@ "state": "translated", "value": "Kliknij dzień, aby nadrobić zaległości" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нажмите на день, чтобы наверстать" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "点按某一天以补记" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "點按某一天以補記" + } } } }, @@ -155554,7 +160539,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 +160565,24 @@ "state": "translated", "value": "Kliknij etap, aby porównać go z typowym 30-dniowym." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нажмите на стадию, чтобы сравнить с вашим типичным значением за 30 дней." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "点按某个睡眠阶段,与你的 30 天典型值比较。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "點按某個睡眠階段,與你的 30 天典型值比較。" + } } } }, @@ -155989,6 +160992,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 +161653,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 +161679,24 @@ "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, что и у браслета." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这就是为什么仅用手表时,能量一开始显示“校准中”。NOOP 需要约七夜的 HRV 数据来了解你的正常水平。在此之前它宁可不给分数,也不猜测。基线确定后,你的能量会带着置信度显示出来,与手环采用同样的 0-100 量表。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "這就是為什麼僅用手錶時,能量一開始顯示「校準中」。NOOP 需要約七夜的 HRV 資料來了解你的正常水準。在此之前它寧可不給分數,也不猜測。基線確定後,你的能量會帶著信賴度顯示出來,與手環採用同樣的 0-100 量表。" + } } } }, @@ -156742,7 +161821,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 +161847,24 @@ "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 с» означает, что браслет проигнорировал кадр. Строка «связь потеряна» означает, что кадр дошёл до браслета, — но одна лишь потеря связи ещё не перезагрузка: при настоящей перезагрузке датчик браслета также гаснет на несколько секунд, поэтому если подсветка не погасла, это был просто обрыв соединения, а не перезагрузка. Ничего не разрушается — ваши данные сохраняются. Пожалуйста, поделитесь журналом, чтобы мы могли определить настоящий кадр." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "WHOOP 4.0 的重启帧尚未确认——普通的“重新启动”会被忽略(#235)。请逐个发送候选帧,同时观察手环日志和手环本身。“12 秒内未断开”表示手环忽略了该帧。出现“链路断开”一行表示该帧到达了手环——但仅凭链路断开并不等于重启:真正的重启还会让手环的传感器灯熄灭几秒,所以如果灯一直亮着,那只是连接断开,而非重启。此操作无破坏性——你的数据会保留。请分享日志,以便我们确定真正的帧。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "WHOOP 4.0 的重新啟動訊框尚未確認——普通的「重新啟動」會被忽略(#235)。請逐個傳送候選訊框,同時觀察手環日誌和手環本身。「12 秒內未斷線」表示手環忽略了該訊框。出現「連線中斷」一行表示該訊框到達了手環——但僅憑連線中斷並不等於重新啟動:真正的重新啟動還會讓手環的感測器燈熄滅幾秒,所以如果燈一直亮著,那只是連線中斷,而非重新啟動。此操作無破壞性——你的資料會保留。請分享日誌,以便我們確定真正的訊框。" + } } } }, @@ -157034,6 +162131,18 @@ "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": "Изменение относительно вашего пульса в конце нагрузки. Положительные значения означают, что пульс снизился; прочерк означает, что браслет не записал достаточно данных около этой минуты." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "相对于运动结束时心率的变化。正值表示心率下降;短横线表示手环在该分钟前后未记录到足够的数据。" + } } } }, @@ -157158,7 +162267,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 +162293,24 @@ "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 за каждый час бодрствования, оценённый относительно ваших собственных спокойных часов сегодня. Полоса ниже делит ваш день на время спокойного, умеренного и высокого стресса." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "折线是每个清醒小时的 0-3 代理值,以你今天自己的平静时段为基准评分。下方的条形将你的一天划分为平静、中等和高压力时段。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "折線是每個清醒小時的 0-3 代理值,以你今天自己的平靜時段為基準評分。下方的長條將你的一天劃分為平靜、中等和高壓力時段。" + } } } }, @@ -157662,7 +162789,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 +162815,24 @@ "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 (или полностью зарядите его и подключите заново), а до тех пор используйте будильник в «Часах» телефона." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "手环报告的时间与 NOOP 发送的时间始终不一致,因此它的固件闹钟不会在你的起床时间响起——通常是手环的时钟或闹钟被重置了。请在官方 WHOOP 应用中重置手环(或将其充满电后重新连接),在此之前请以手机的“时钟”闹钟作为起床提醒。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "手環回報的時間與 NOOP 傳送的時間始終不一致,因此它的韌體鬧鐘不會在你的起床時間響起——通常是手環的時鐘或鬧鐘被重設了。請在官方 WHOOP 應用程式中重設手環(或將其充滿電後重新連線),在此之前請以手機的「時鐘」鬧鐘作為起床提醒。" + } } } }, @@ -158573,7 +163718,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 +164441,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 +165055,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", @@ -162445,6 +167596,18 @@ "state": "translated", "value": "Dzisiaj %@" } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "今天,%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "今天,%@" + } } } }, @@ -163902,6 +169065,12 @@ "value": "Tableaux de tendances" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Графики трендов" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -164011,6 +169180,24 @@ "state": "translated", "value": "Okno trendu" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Окно тренда" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "趋势窗口" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "趨勢視窗" + } } } }, @@ -164855,7 +170042,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,63 +170068,23 @@ "state": "translated", "value": "Włącz powiadomienia dla NOOP w Ustawieniach, aby otrzymać przypomnienie o odpoczynku." } - } - } - }, - "Turn on the wind-down reminder below to land at your wake time rested.": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Schalte unten die Runterkommen-Erinnerung ein, um ausgeruht bei deiner Aufwachzeit zu landen." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Activa el recordatorio de relajación de abajo para llegar descansado a tu hora de despertar." - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Activez le rappel de détente ci-dessous pour arriver reposé à l'heure de réveil." - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Attiva il promemoria di rilassamento qui sotto per arrivare riposato all'ora del risveglio." - } - }, - "pt-PT": { - "stringUnit": { - "state": "translated", - "value": "Ative o lembrete de abrandamento abaixo para aterrar descansado na hora de acordar." - } }, "ru": { "stringUnit": { "state": "translated", - "value": "Включите напоминание о подготовке ко сну ниже, чтобы проснуться отдохнувшим в нужное время." + "value": "Включите уведомления для NOOP в Настройках, чтобы получать напоминание о подготовке ко сну." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "开启下方的放松提醒,让你在起床时间精神饱满地醒来。" + "value": "在“设置”中为 NOOP 打开通知,以接收放松提醒。" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "開啟下方的放鬆提醒,讓你在起床時間精神飽滿地醒來。" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Włącz przypomnienie o odpoczynku poniżej, aby wylądować o godzinie po przebudzeniu i być wypoczętym." + "value": "在「設定」中為 NOOP 開啟通知,以接收放鬆提醒。" } } } @@ -164973,6 +170120,24 @@ "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 на вашей прошивке; короткая заглушка означает, что вопрос остаётся открытым. На браслет ничего не записывается." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "两份独立的协议表对扩展电池操作码存在分歧(98 与 87)。此操作发送经整理的只读 98 并显示手环的完整原始回复。返回电池格式的负载即可确认你的固件使用 98;若只返回简短存根,则仍无法确定。不会向手环写入任何内容。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "兩份獨立的協定表對擴充電池操作碼存在分歧(98 與 87)。此操作傳送經整理的唯讀 98 並顯示手環的完整原始回覆。返回電池格式的酬載即可確認你的韌體使用 98;若只返回簡短存根,則仍無法確定。不會向手環寫入任何內容。" + } } } }, @@ -165228,6 +170393,12 @@ "value": "Annuler" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отменить" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -168030,6 +173201,12 @@ "value": "Sonde de redémarrage WHOOP 4.0" } }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Проба перезагрузки WHOOP 4.0" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -169828,6 +175005,18 @@ "state": "translated", "value": "Czekam na odpowiedź paska…" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ожидание ответа браслета…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在等待手环回复…" + } } } }, @@ -170944,7 +176133,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 +176159,24 @@ "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 — сильная активация." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "我们将你今天的静息心率和 HRV 与你自己的 30 天基线比较。高于平常的静息心率和低于平常的 HRV 都会推高该分数,这是身体被激活的典型迹象。二者的综合偏移被映射到 0-3 量表:0 表示平静,1.5 位于你的基线,3 表示高度激活。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "我們將你今天的靜息心率和 HRV 與你自己的 30 天基線比較。高於平常的靜息心率和低於平常的 HRV 都會推高該分數,這是身體被啟動的典型跡象。二者的綜合偏移被映射到 0-3 量表:0 表示平靜,1.5 位於你的基線,3 表示高度啟動。" + } } } }, @@ -171480,6 +176687,18 @@ "state": "translated", "value": "Noś pasek przez noc, aby najpierw zdobyć punkty." } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "先整夜佩戴手环,才能为一夜评分。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "先整夜佩戴手環,才能為一夜評分。" + } } } }, @@ -173682,7 +178901,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 +178927,24 @@ "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, по умолчанию выключено." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "当 NOOP 从 WHOOP 5/MG 的 v26 光学波形重建心率时(手环未存储心率的那些秒),用抛物线亚延迟拟合来细化自相关峰值,使估计值在高心率附近不再被量化为约 16 bpm 的台阶。它只填补手环从未上报的秒数,绝不覆盖已存储的心率。仅限 5/MG,默认关闭。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "當 NOOP 從 WHOOP 5/MG 的 v26 光學波形重建心率時(手環未儲存心率的那些秒),用拋物線次延遲擬合來細化自相關峰值,使估計值在高心率附近不再被量化為約 16 bpm 的階梯。它只填補手環從未回報的秒數,絕不覆蓋已儲存的心率。僅限 5/MG,預設關閉。" + } } } }, @@ -174562,6 +179799,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 +179830,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 +179845,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 +183287,24 @@ "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. Отличная работа, ваше восстановление это заслужило." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "你已达成今天 %lld 的最佳强度目标。干得漂亮——这是你的恢复换来的。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "你已達成今天 %lld 的最佳強度目標。做得漂亮——這是你的恢復換來的。" + } } } }, @@ -180663,6 +185930,24 @@ "state": "translated", "value": "Twój pasek nie akceptuje alarmu" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ваш браслет не принимает будильник" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "你的手环未接受该闹钟" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "你的手環未接受該鬧鐘" + } } } }, @@ -181185,6 +186470,24 @@ "state": "translated", "value": "Strefa %lld: %lld-%lld bpm (%lld-%lld%% max HR)" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Зона %lld: %lld–%lld уд/мин (%lld–%lld%% от макс. пульса)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "区间 %lld:%lld-%lld bpm(最大心率的 %lld-%lld%%)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "區間 %lld:%lld-%lld bpm(最大心率的 %lld-%lld%%)" + } } } }, @@ -183628,6 +188931,24 @@ "state": "translated", "value": "dzień z rzędu" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "день подряд" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "连续天数" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "連續天數" + } } } }, @@ -183668,6 +188989,24 @@ "state": "translated", "value": "dni z rzędu" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "дней подряд" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "连续天数" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "連續天數" + } } } }, @@ -191947,6 +197286,24 @@ "state": "translated", "value": "zazwyczaj %@" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "обычно %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "通常 %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "通常 %@" + } } } }, @@ -192451,6 +197808,24 @@ "state": "translated", "value": "Historia v%lld" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "история v%lld" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "v%lld 历史" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "v%lld 歷史" + } } } }, @@ -194950,6 +200325,582 @@ } } }, + "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": "時鐘" + } + } + } + }, + "Archived": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Archiviert" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Archived" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Archivados" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Archivés" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Archiviati" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zarchiwizowane" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Arquivados" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Архив" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已归档" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已封存" + } + } + } + }, + "Current": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktuell" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Current" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Actual" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Actuel" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Attuali" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Bieżące" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Atuais" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Текущие" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "当前" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "目前" + } + } + } + }, + "Scope": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Bereich" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Scope" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ámbito" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Portée" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Ambito" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zakres" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Âmbito" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Область" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "范围" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "範圍" + } + } + } + }, + "A workout must be at least 1 minute.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Ein Workout muss mindestens 1 Minute dauern." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "A workout must be at least 1 minute." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Un entrenamiento debe durar al menos 1 minuto." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Une séance doit durer au moins 1 minute." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Un allenamento deve durare almeno 1 minuto." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Trening musi trwać co najmniej 1 minutę." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Um treino tem de durar pelo menos 1 minuto." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Тренировка должна длиться не менее 1 минуты." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "锻炼时长至少为 1 分钟。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "運動時長至少為 1 分鐘。" + } + } + } + }, + "AI Coach": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "KI-Coach" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "AI Coach" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Coach de IA" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Coach IA" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Coach IA" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Trener AI" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Treinador de IA" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "ИИ-тренер" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "AI 教练" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "AI 教練" + } + } + } + }, + "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 +201377,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 +213332,18 @@ "state": "translated", "value": "Obciążenie treningowe" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Тренировочная нагрузка" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "训练负荷" + } } } }, @@ -207299,6 +213378,18 @@ "state": "translated", "value": "CTL · Kondycja" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "CTL · Форма" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "CTL · 体能" + } } } }, @@ -207333,6 +213424,18 @@ "state": "translated", "value": "ATL · Zmęczenie" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "ATL · Усталость" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "ATL · 疲劳" + } } } }, @@ -207367,6 +213470,18 @@ "state": "translated", "value": "Kondycja 42-dniowa vs zmęczenie 7-dniowe" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "42-дневная форма против 7-дневной усталости" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "42 天体能与 7 天疲劳对比" + } } } }, @@ -207401,6 +213516,18 @@ "state": "translated", "value": "Budowanie — %1$lld z %2$lld dni" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Формируется — %lld из %lld дней" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在建立:%lld / %lld 天" + } } } }, @@ -207435,6 +213562,18 @@ "state": "translated", "value": "Obciążenie przewlekłe vs ostre" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Хроническая против острой нагрузки" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "长期负荷与急性负荷对比" + } } } }, @@ -207469,6 +213608,18 @@ "state": "translated", "value": "Wymaga %1$lld+ kolejnych dni Wysiłku, aby zacząć. Na razie %2$lld." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нужно %lld+ подряд дней с Усилием, чтобы начать. Пока %lld." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "需要连续 %lld 天以上的负荷记录才能开始。目前 %lld 天。" + } } } }, @@ -207503,6 +213654,18 @@ "state": "translated", "value": "Forma" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Форма" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "竞技状态" + } } } }, @@ -207537,6 +213700,24 @@ "state": "translated", "value": "Obciążenie treningowe: przewlekłe vs ostre" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Тренировочная нагрузка: хроническая против острой" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "训练负荷:长期与急性对比" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "訓練負荷:長期與急性對比" + } } } }, @@ -207571,6 +213752,24 @@ "state": "translated", "value": "Rzadsze odświeżanie" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Редкое обновление" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "低频刷新" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "低頻更新" + } } } }, @@ -207605,6 +213804,24 @@ "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. Ничего не теряется: браслет всё накапливает и отдаёт большими порциями. Синхронизация жестом по-прежнему работает сразу, а живой пульс не затрагивается." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "后台同步改为每小时一次,而非每 15 分钟一次,无论手环电量如何——在 WHOOP 4.0 上,减少重连是最大的省电项。不会丢失任何数据:手环会自行存储一切,并以更大的批次交付。下拉同步仍会立即执行,实时心率不受影响。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "背景同步改為每小時一次,而非每 15 分鐘一次,無論手環電量如何——在 WHOOP 4.0 上,減少重新連線是最大的省電項。不會遺失任何資料:手環會自行儲存一切,並以更大的批次交付。下拉同步仍會立即執行,即時心率不受影響。" + } } } }, @@ -207639,6 +213856,24 @@ "state": "translated", "value": "Oszczędzanie energii" } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Энергосбережение" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "省电" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "省電" + } } } }, @@ -207673,6 +213908,24 @@ "state": "translated", "value": "Odciąż opaskę, gdy jej bateria się kończy." } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Снизьте нагрузку на браслет, когда его заряд на исходе." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "当手环电量偏低时减轻其负担。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "當手環電量偏低時減輕其負擔。" + } } } }, @@ -207707,6 +213960,24 @@ "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 просто реже с ним общается, чтобы тот дожил до зарядки." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "手环会自行持续存储数据,因此不会丢失任何内容——NOOP 只是减少与它通信的频率,帮助它撑到你能充电的时候。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "手環會自行持續儲存資料,因此不會遺失任何內容——NOOP 只是減少與它通訊的頻率,幫助它撐到你能充電的時候。" + } } } }, @@ -207834,7 +214105,1919 @@ } }, "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)。僅填充:絕不覆寫已測量的值。結果見腕帶日誌。"}} + } }, + "Opens the trend and readings": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Öffnet den Trend und die Messwerte"}}, + "es": {"stringUnit": {"state": "translated", "value": "Abre la tendencia y las lecturas"}}, + "fr": {"stringUnit": {"state": "translated", "value": "Ouvre la tendance et les relevés"}}, + "it": {"stringUnit": {"state": "translated", "value": "Apre l'andamento e le letture"}}, + "pl": {"stringUnit": {"state": "translated", "value": "Otwiera trend i odczyty"}}, + "pt-PT": {"stringUnit": {"state": "translated", "value": "Abre a tendência e as leituras"}}, + "ru": {"stringUnit": {"state": "translated", "value": "Открывает тренд и показания"}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "打开趋势和读数"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "開啟趨勢與讀數"}} + } }, + "CVA PPG sampler" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "CVA-PPG-Sampler" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Muestreador CVA PPG" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Échantillonneur CVA PPG" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Campionatore CVA PPG" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Próbkownik CVA PPG" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Amostrador CVA PPG" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сэмплер CVA PPG" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "CVA PPG 采样器" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "CVA PPG 取樣器" + } + } + } + }, + "Disable" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deaktivieren" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Desactivar" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Désactiver" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Disattiva" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wyłącz" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Desativar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отключить" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "停用" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "停用" + } + } + } + }, + "DOB (raw)" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geburtsdatum (roh)" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fecha de nacimiento (bruto)" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Date de naissance (brute)" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Data di nascita (grezza)" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Data urodzenia (surowe dane)" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Data de nascimento (em bruto)" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Дата рождения (необработанная)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "出生日期(原始)" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "出生日期(原始)" + } + } + } + }, + "Earlier beats cannot be scored" : { "localizations" : { + "de": {"stringUnit": {"state": "translated", "value": "Frühere Herzschläge nicht auswertbar"}}, + "es": {"stringUnit": {"state": "translated", "value": "Los latidos anteriores no se pueden puntuar"}}, + "fr": {"stringUnit": {"state": "translated", "value": "Battements antérieurs non évaluables"}}, + "it": {"stringUnit": {"state": "translated", "value": "I battiti precedenti non sono valutabili"}}, + "pl": {"stringUnit": {"state": "translated", "value": "Wcześniejszych uderzeń nie można ocenić"}}, + "pt-PT": {"stringUnit": {"state": "translated", "value": "Batimentos anteriores não podem ser pontuados"}}, + "ru": {"stringUnit": {"state": "translated", "value": "Ранние удары невозможно оценить"}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "早期心跳无法评分"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "早期心跳無法評分"}} + } }, + "Enable" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktivieren" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activar" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activer" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Attiva" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Włącz" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ativar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включить" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "啟用" + } + } + } + }, + "Enables or disables SpO2 or real-steps directly via SetFeatureMode, then re-reads the feature's status. Unvalidated on NOOP's own hardware (OURA_PROTOCOL.md §7.5) — a third-party report is what these buttons exist to test." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktiviert oder deaktiviert SpO2 oder Echte Schritte direkt per SetFeatureMode und liest danach den Funktionsstatus erneut. Auf NOOPs eigener Hardware nicht validiert (OURA_PROTOCOL.md §7.5) — diese Buttons dienen dazu, einen Bericht eines Drittanbieters zu testen." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activa o desactiva SpO2 o pasos reales directamente mediante SetFeatureMode y luego vuelve a leer el estado de la función. Sin validar en el hardware propio de NOOP (OURA_PROTOCOL.md §7.5); estos botones existen para probar un informe de terceros." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Active ou désactive la SpO2 ou les pas réels directement via SetFeatureMode, puis relit le statut de la fonctionnalité. Non validé sur le matériel propre de NOOP (OURA_PROTOCOL.md §7.5) — ces boutons servent à tester un rapport d'un tiers." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Attiva o disattiva SpO2 o passi reali direttamente tramite SetFeatureMode, quindi rilegge lo stato della funzione. Non convalidato sull'hardware proprio di NOOP (OURA_PROTOCOL.md §7.5): questi pulsanti servono a verificare una segnalazione di terze parti." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Włącza lub wyłącza SpO2 albo rzeczywiste kroki bezpośrednio przez SetFeatureMode, a następnie ponownie odczytuje stan funkcji. Niezweryfikowane na własnym sprzęcie NOOP (OURA_PROTOCOL.md §7.5) — te przyciski służą do sprawdzenia doniesienia strony trzeciej." + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ativa ou desativa SpO2 ou passos reais diretamente através de SetFeatureMode e depois volta a ler o estado da funcionalidade. Não validado no hardware próprio da NOOP (OURA_PROTOCOL.md §7.5) — estes botões existem para testar um relatório de terceiros." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включает или отключает SpO2 либо реальные шаги напрямую через SetFeatureMode, затем повторно считывает статус функции. Не проверено на собственном оборудовании NOOP (OURA_PROTOCOL.md §7.5) — эти кнопки существуют для проверки стороннего отчёта." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "通过 SetFeatureMode 直接启用或停用血氧(SpO2)或真实步数,然后重新读取该功能的状态。尚未在 NOOP 自己的硬件上验证(OURA_PROTOCOL.md §7.5)——这些按钮的作用就是测试第三方的报告。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "透過 SetFeatureMode 直接啟用或停用血氧(SpO2)或真實步數,然後重新讀取該功能的狀態。尚未在 NOOP 自己的硬體上驗證(OURA_PROTOCOL.md §7.5)——這些按鈕的作用就是測試第三方的報告。" + } + } + } + }, + "Enables or disables SpO2, real-steps, exercise HR, or the CVA PPG sampler directly via SetFeatureMode, then re-reads the feature's status. Unvalidated on NOOP's own hardware (OURA_PROTOCOL.md §7.5) — a third-party report is what these buttons exist to test. Daytime HR and resting HR are deliberately not offered here: daytime HR has its own dedicated live-HR path, and resting HR has no app-level toggle at all." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktiviert oder deaktiviert SpO2, Echte Schritte, Trainings-HF oder den CVA-PPG-Sampler direkt per SetFeatureMode und liest danach den Funktionsstatus erneut. Auf NOOPs eigener Hardware nicht validiert (OURA_PROTOCOL.md §7.5) — diese Buttons dienen dazu, einen Bericht eines Drittanbieters zu testen. Tages-HF und Ruhe-HF werden hier bewusst nicht angeboten: Tages-HF hat einen eigenen Live-HF-Pfad, und Ruhe-HF hat überhaupt keinen App-seitigen Schalter." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activa o desactiva SpO2, pasos reales, FC de ejercicio o el muestreador CVA PPG directamente mediante SetFeatureMode y luego vuelve a leer el estado de la función. Sin validar en el hardware propio de NOOP (OURA_PROTOCOL.md §7.5); estos botones existen para probar un informe de terceros. La FC diurna y la FC en reposo no se ofrecen aquí a propósito: la FC diurna tiene su propia ruta de FC en vivo, y la FC en reposo no tiene ningún interruptor a nivel de la app." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Active ou désactive la SpO2, les pas réels, la FC d'exercice ou l'échantillonneur CVA PPG directement via SetFeatureMode, puis relit le statut de la fonctionnalité. Non validé sur le matériel propre de NOOP (OURA_PROTOCOL.md §7.5) — ces boutons servent à tester un rapport d'un tiers. La FC diurne et la FC au repos ne sont volontairement pas proposées ici : la FC diurne dispose de son propre chemin de FC en direct, et la FC au repos n'a aucun interrupteur côté appli." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Attiva o disattiva SpO2, passi reali, FC da esercizio o il campionatore CVA PPG direttamente tramite SetFeatureMode, quindi rilegge lo stato della funzione. Non convalidato sull'hardware proprio di NOOP (OURA_PROTOCOL.md §7.5): questi pulsanti servono a verificare una segnalazione di terze parti. La FC diurna e la FC a riposo non sono offerte qui di proposito: la FC diurna ha un proprio percorso HR live dedicato, e la FC a riposo non ha alcun interruttore lato app." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Włącza lub wyłącza SpO2, rzeczywiste kroki, tętno wysiłkowe albo próbkownik CVA PPG bezpośrednio przez SetFeatureMode, a następnie ponownie odczytuje stan funkcji. Niezweryfikowane na własnym sprzęcie NOOP (OURA_PROTOCOL.md §7.5) — te przyciski służą do sprawdzenia doniesienia strony trzeciej. Tętno dzienne i spoczynkowe są tutaj celowo pominięte: tętno dzienne ma własną, dedykowaną ścieżkę na żywo, a tętno spoczynkowe nie ma w ogóle przełącznika po stronie aplikacji." + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ativa ou desativa SpO2, passos reais, FC de exercício ou o amostrador CVA PPG diretamente através de SetFeatureMode e depois volta a ler o estado da funcionalidade. Não validado no hardware próprio da NOOP (OURA_PROTOCOL.md §7.5) — estes botões existem para testar um relatório de terceiros. A FC diurna e a FC em repouso não são propositadamente oferecidas aqui: a FC diurna tem o seu próprio percurso de FC em direto, e a FC em repouso não tem qualquer interruptor ao nível da app." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включает или отключает SpO2, реальные шаги, ЧСС при нагрузке или сэмплер CVA PPG напрямую через SetFeatureMode, затем повторно считывает статус функции. Не проверено на собственном оборудовании NOOP (OURA_PROTOCOL.md §7.5) — эти кнопки существуют для проверки стороннего отчёта. Дневной ЧСС и ЧСС покоя здесь намеренно не предлагаются: у дневного ЧСС есть собственный выделенный путь в реальном времени, а у ЧСС покоя вообще нет переключателя на уровне приложения." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "通过 SetFeatureMode 直接启用或停用血氧(SpO2)、真实步数、运动心率或 CVA PPG 采样器,然后重新读取该功能的状态。尚未在 NOOP 自己的硬件上验证(OURA_PROTOCOL.md §7.5)——这些按钮的作用就是测试第三方的报告。此处特意不提供日间心率和静息心率:日间心率有自己专用的实时心率通路,而静息心率完全没有应用层开关。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "透過 SetFeatureMode 直接啟用或停用血氧(SpO2)、真實步數、運動心率或 CVA PPG 取樣器,然後重新讀取該功能的狀態。尚未在 NOOP 自己的硬體上驗證(OURA_PROTOCOL.md §7.5)——這些按鈕的作用就是測試第三方的報告。此處特意不提供日間心率和靜息心率:日間心率有自己專用的即時心率路徑,而靜息心率完全沒有應用程式層級的開關。" + } + } + } + }, + "Exercise HR" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trainings-HF" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "FC de ejercicio" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "FC d'exercice" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "FC da esercizio" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tętno wysiłkowe" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "FC de exercício" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ЧСС при нагрузке" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "运动心率" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "運動心率" + } + } + } + }, + "Field" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Feld" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Campo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Champ" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Campo" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pole" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Campo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поле" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "字段" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "欄位" + } + } + } + }, + "Frame: %@" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Frame: %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trama: %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trame : %@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Frame: %@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rama: %@" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Frame: %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Кадр: %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "帧:%@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "幀:%@" + } + } + } + }, + "Mode" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modus" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mode" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modalità" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tryb" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Режим" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "模式" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "模式" + } + } + } + }, + "OURA" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "OURA" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "OURA" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "OURA" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "OURA" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "OURA" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "OURA" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "OURA" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "OURA" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "OURA" + } + } + } + }, + "Oura feature enable (experimental)" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oura-Funktion aktivieren (experimentell)" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activación de funciones de Oura (experimental)" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activation de fonctionnalité Oura (expérimental)" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Attivazione funzione Oura (sperimentale)" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Włączanie funkcji Oura (eksperymentalne)" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ativação de funcionalidade Oura (experimental)" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включение функции Oura (экспериментально)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oura 功能启用(实验性)" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oura 功能啟用(實驗性)" + } + } + } + }, + "Oura user-info write (experimental)" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oura-Nutzerinfo-Schreiben (experimentell)" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escritura de información de usuario de Oura (experimental)" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Écriture des infos utilisateur Oura (expérimental)" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scrittura informazioni utente Oura (sperimentale)" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zapis danych użytkownika Oura (eksperymentalne)" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escrita de informação de utilizador Oura (experimental)" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Запись данных пользователя Oura (экспериментально)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oura 用户信息写入(实验性)" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oura 使用者資訊寫入(實驗性)" + } + } + } + }, + "Real steps" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Echte Schritte" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pasos reales" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pas réels" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Passi reali" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rzeczywiste kroki" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Passos reais" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Реальные шаги" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "真实步数" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "真實步數" + } + } + } + }, + "Sends %@ to the ring. This changes ring-side config. Restore with the firmware default (height 176, weight 75, sex 2) or write zeros." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sendet %@ an den Ring. Dies ändert die Konfiguration im Ring. Mit dem Firmware-Standard (Größe 176, Gewicht 75, Geschlecht 2) oder durch Schreiben von Nullen wiederherstellen." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Envía %@ al anillo. Esto cambia la configuración del anillo. Restaura con el valor predeterminado de fábrica (altura 176, peso 75, sexo 2) o escribiendo ceros." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Envoie %@ à la bague. Cela modifie la configuration côté bague. Restaurez avec la valeur par défaut du firmware (taille 176, poids 75, sexe 2) ou en écrivant des zéros." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Invia %@ all'anello. Questo modifica la configurazione lato anello. Ripristina con il valore predefinito del firmware (altezza 176, peso 75, sesso 2) oppure scrivendo zeri." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wysyła %@ do pierścienia. Zmienia to konfigurację po stronie pierścienia. Przywróć wartość domyślną z firmware'u (wzrost 176, waga 75, płeć 2) lub zapisz zera." + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Envia %@ para o anel. Isto altera a configuração do anel. Restaure com o valor predefinido do firmware (altura 176, peso 75, sexo 2) ou escrevendo zeros." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправляет %@ на кольцо. Это изменяет конфигурацию на стороне кольца. Восстановите значение по умолчанию из прошивки (рост 176, вес 75, пол 2) или запишите нули." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "向戒指发送 %@。这会更改戒指端的配置。可通过恢复固件默认值(身高 176、体重 75、性别 2)或写入零值来还原。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "向戒指傳送 %@。這會變更戒指端的設定。可透過還原韌體預設值(身高 176、體重 75、性別 2)或寫入零值來復原。" + } + } + } + }, + "Sends %@ to the ring. Unvalidated on NOOP's own hardware (OURA_PROTOCOL.md §7.5). Mode 0x00 always reverts. Watch the strap log for the follow-up feature-status read." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sendet %@ an den Ring. Auf NOOPs eigener Hardware nicht validiert (OURA_PROTOCOL.md §7.5). Modus 0x00 stellt immer den ursprünglichen Zustand wieder her. Achte im Strap-Log auf das anschließende Feature-Status-Lesen." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Envía %@ al anillo. Sin validar en el hardware propio de NOOP (OURA_PROTOCOL.md §7.5). El modo 0x00 siempre revierte el cambio. Consulta el registro de la correa para ver la lectura de estado de función posterior." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Envoie %@ à la bague. Non validé sur le matériel propre de NOOP (OURA_PROTOCOL.md §7.5). Le mode 0x00 revient toujours à l'état initial. Consultez le journal du bracelet pour la lecture de statut de fonctionnalité qui suit." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Invia %@ all'anello. Non convalidato sull'hardware proprio di NOOP (OURA_PROTOCOL.md §7.5). La modalità 0x00 ripristina sempre lo stato precedente. Controlla il log del cinturino per la successiva lettura dello stato della funzione." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wysyła %@ do pierścienia. Niezweryfikowane na własnym sprzęcie NOOP (OURA_PROTOCOL.md §7.5). Tryb 0x00 zawsze przywraca poprzedni stan. Sprawdź dziennik opaski, aby zobaczyć kolejny odczyt stanu funkcji." + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Envia %@ para o anel. Não validado no hardware próprio da NOOP (OURA_PROTOCOL.md §7.5). O modo 0x00 reverte sempre. Consulte o registo da pulseira para a leitura de estado da funcionalidade que se segue." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправляет %@ на кольцо. Не проверено на собственном оборудовании NOOP (OURA_PROTOCOL.md §7.5). Режим 0x00 всегда возвращает исходное состояние. Смотрите журнал ремешка, чтобы увидеть последующее чтение статуса функции." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "向戒指发送 %@。尚未在 NOOP 自己的硬件上验证(OURA_PROTOCOL.md §7.5)。模式 0x00 总是可以还原。请查看表带日志中随后的功能状态读取记录。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "向戒指傳送 %@。尚未在 NOOP 自己的硬體上驗證(OURA_PROTOCOL.md §7.5)。模式 0x00 一律可以復原。請查看錶帶記錄中隨後的功能狀態讀取。" + } + } + } + }, + "Sends %@ to the ring. Unvalidated on NOOP's own hardware (OURA_PROTOCOL.md §7.5). Watch the strap log for the follow-up feature-status read." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sendet %@ an den Ring. Auf NOOPs eigener Hardware nicht validiert (OURA_PROTOCOL.md §7.5). Achte im Strap-Log auf das anschließende Feature-Status-Lesen." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Envía %@ al anillo. Sin validar en el hardware propio de NOOP (OURA_PROTOCOL.md §7.5). Consulta el registro de la correa para ver la lectura de estado de función posterior." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Envoie %@ à la bague. Non validé sur le matériel propre de NOOP (OURA_PROTOCOL.md §7.5). Consultez le journal du bracelet pour la lecture de statut de fonctionnalité qui suit." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Invia %@ all'anello. Non convalidato sull'hardware proprio di NOOP (OURA_PROTOCOL.md §7.5). Controlla il log del cinturino per la successiva lettura dello stato della funzione." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wysyła %@ do pierścienia. Niezweryfikowane na własnym sprzęcie NOOP (OURA_PROTOCOL.md §7.5). Sprawdź dziennik opaski, aby zobaczyć kolejny odczyt stanu funkcji." + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Envia %@ para o anel. Não validado no hardware próprio da NOOP (OURA_PROTOCOL.md §7.5). Consulte o registo da pulseira para a leitura de estado da funcionalidade que se segue." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправляет %@ на кольцо. Не проверено на собственном оборудовании NOOP (OURA_PROTOCOL.md §7.5). Смотрите журнал ремешка, чтобы увидеть последующее чтение статуса функции." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "向戒指发送 %@。尚未在 NOOP 自己的硬件上验证(OURA_PROTOCOL.md §7.5)。请查看表带日志中随后的功能状态读取记录。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "向戒指傳送 %@。尚未在 NOOP 自己的硬體上驗證(OURA_PROTOCOL.md §7.5)。請查看錶帶記錄中隨後的功能狀態讀取。" + } + } + } + }, + "Sends a SetFeatureMode write for SpO2 or real-steps, then re-reads the feature's status. Unvalidated on NOOP's own hardware (OURA_PROTOCOL.md §7.5) — a third-party report is what this button exists to test. Mode “off” always reverts." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sendet einen SetFeatureMode-Schreibbefehl für SpO2 oder Echte Schritte und liest den Funktionsstatus danach erneut. Auf NOOPs eigener Hardware nicht validiert (OURA_PROTOCOL.md §7.5) — dieser Button dient dazu, einen Bericht eines Drittanbieters zu testen. Modus „aus“ stellt immer den ursprünglichen Zustand wieder her." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Envía una escritura SetFeatureMode para SpO2 o pasos reales y vuelve a leer el estado de la función. Sin validar en el hardware propio de NOOP (OURA_PROTOCOL.md §7.5); este botón existe para probar un informe de terceros. El modo \"desactivado\" siempre revierte el cambio." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Envoie une écriture SetFeatureMode pour la SpO2 ou les pas réels, puis relit le statut de la fonctionnalité. Non validé sur le matériel propre de NOOP (OURA_PROTOCOL.md §7.5) — ce bouton sert à tester un rapport d'un tiers. Le mode « désactivé » revient toujours à l'état initial." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Invia una scrittura SetFeatureMode per SpO2 o passi reali, quindi rilegge lo stato della funzione. Non convalidato sull'hardware proprio di NOOP (OURA_PROTOCOL.md §7.5): questo pulsante serve a verificare una segnalazione di terze parti. La modalità \"off\" ripristina sempre lo stato precedente." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wysyła zapis SetFeatureMode dla SpO2 lub rzeczywistych kroków, a następnie ponownie odczytuje stan funkcji. Niezweryfikowane na własnym sprzęcie NOOP (OURA_PROTOCOL.md §7.5) — ten przycisk służy do sprawdzenia doniesienia strony trzeciej. Tryb „off” zawsze przywraca poprzedni stan." + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Envia uma escrita SetFeatureMode para SpO2 ou passos reais e volta a ler o estado da funcionalidade. Não validado no hardware próprio da NOOP (OURA_PROTOCOL.md §7.5) — este botão existe para testar um relatório de terceiros. O modo \"desligado\" reverte sempre." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправляет запись SetFeatureMode для SpO2 или реальных шагов, затем повторно считывает статус функции. Не проверено на собственном оборудовании NOOP (OURA_PROTOCOL.md §7.5) — эта кнопка существует для проверки стороннего отчёта. Режим «выкл.» всегда возвращает исходное состояние." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "发送针对血氧(SpO2)或真实步数的 SetFeatureMode 写入,然后重新读取该功能的状态。尚未在 NOOP 自己的硬件上验证(OURA_PROTOCOL.md §7.5)——此按钮的作用就是测试第三方的报告。“关闭”模式总是可以还原。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "傳送針對血氧(SpO2)或真實步數的 SetFeatureMode 寫入,然後重新讀取該功能的狀態。尚未在 NOOP 自己的硬體上驗證(OURA_PROTOCOL.md §7.5)——此按鈕的作用就是測試第三方的報告。「關閉」模式一律可以復原。" + } + } + } + }, + "This night was recorded before NOOP labelled which WHOOP 5 transport each heartbeat came from, so its intervals mix two different units with nothing stored to tell them apart. The night stays in your history, and nights recorded from now on score normally." : { "localizations" : { + "de": {"stringUnit": {"state": "translated", "value": "Diese Nacht wurde aufgezeichnet, bevor NOOP festhielt, über welchen WHOOP-5-Übertragungsweg jeder Herzschlag kam. Ihre Intervalle mischen deshalb zwei verschiedene Einheiten, und es ist nichts gespeichert, was sie unterscheidbar macht. Die Nacht bleibt in deinem Verlauf, und ab jetzt aufgezeichnete Nächte werden normal bewertet."}}, + "es": {"stringUnit": {"state": "translated", "value": "Esta noche se registró antes de que NOOP etiquetara de qué transporte de WHOOP 5 procedía cada latido, así que sus intervalos mezclan dos unidades distintas y no hay nada guardado que permita distinguirlas. La noche permanece en tu historial y las noches registradas a partir de ahora se puntúan con normalidad."}}, + "fr": {"stringUnit": {"state": "translated", "value": "Cette nuit a été enregistrée avant que NOOP n'indique de quel transport WHOOP 5 provenait chaque battement. Ses intervalles mêlent donc deux unités différentes, sans rien d'enregistré pour les distinguer. La nuit reste dans votre historique, et les nuits enregistrées à partir de maintenant sont évaluées normalement."}}, + "it": {"stringUnit": {"state": "translated", "value": "Questa notte è stata registrata prima che NOOP indicasse da quale trasporto WHOOP 5 provenisse ogni battito, quindi i suoi intervalli mescolano due unità diverse e non è memorizzato nulla che permetta di distinguerle. La notte resta nella tua cronologia e le notti registrate da ora in poi vengono valutate normalmente."}}, + "pl": {"stringUnit": {"state": "translated", "value": "Ta noc została zarejestrowana, zanim NOOP zaczął oznaczać, z którego kanału WHOOP 5 pochodzi każde uderzenie, więc jej odstępy mieszają dwie różne jednostki i nic zapisanego nie pozwala ich rozróżnić. Noc pozostaje w Twojej historii, a noce zarejestrowane od teraz są oceniane normalnie."}}, + "pt-PT": {"stringUnit": {"state": "translated", "value": "Esta noite foi registada antes de o NOOP identificar de que transporte WHOOP 5 vinha cada batimento, por isso os seus intervalos misturam duas unidades diferentes e não há nada guardado que permita distingui-las. A noite mantém-se no teu histórico e as noites registadas a partir de agora são pontuadas normalmente."}}, + "ru": {"stringUnit": {"state": "translated", "value": "Эта ночь была записана до того, как NOOP начал отмечать, по какому каналу WHOOP 5 пришёл каждый удар, поэтому её интервалы смешивают две разные единицы, и ничего не сохранено, что позволило бы их различить. Ночь остаётся в вашей истории, а ночи, записанные с этого момента, оцениваются как обычно."}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "这一夜是在 NOOP 标记每次心跳来自哪条 WHOOP 5 传输通道之前记录的,因此其间隔混合了两种不同的单位,而且没有保存任何可以区分它们的信息。该夜仍保留在你的历史记录中,从现在起记录的夜晚会正常评分。"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "這一夜是在 NOOP 標記每次心跳來自哪條 WHOOP 5 傳輸通道之前記錄的,因此其間隔混合了兩種不同的單位,而且沒有儲存任何可以區分它們的資訊。該夜仍保留在你的歷史記錄中,從現在起記錄的夜晚會正常評分。"}} + } }, + "Paste the corrected key. Your conversation is kept." : { "localizations" : { + "de": {"stringUnit": {"state": "translated", "value": "Füge den korrigierten Schlüssel ein. Deine Unterhaltung bleibt erhalten."}}, + "es": {"stringUnit": {"state": "translated", "value": "Pega la clave corregida. Tu conversación se conserva."}}, + "fr": {"stringUnit": {"state": "translated", "value": "Collez la clé corrigée. Votre conversation est conservée."}}, + "it": {"stringUnit": {"state": "translated", "value": "Incolla la chiave corretta. La tua conversazione viene mantenuta."}}, + "pl": {"stringUnit": {"state": "translated", "value": "Wklej poprawiony klucz. Twoja rozmowa zostanie zachowana."}}, + "pt-PT": {"stringUnit": {"state": "translated", "value": "Cola a chave corrigida. A tua conversa é mantida."}}, + "ru": {"stringUnit": {"state": "translated", "value": "Вставьте исправленный ключ. Ваш разговор сохранится."}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "粘贴更正后的密钥。你的对话会保留。"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "貼上更正後的密鑰。你的對話會保留。"}} + } }, + "Corrected API key" : { "localizations" : { + "de": {"stringUnit": {"state": "translated", "value": "Korrigierter API-Schlüssel"}}, + "es": {"stringUnit": {"state": "translated", "value": "Clave de API corregida"}}, + "fr": {"stringUnit": {"state": "translated", "value": "Clé d'API corrigée"}}, + "it": {"stringUnit": {"state": "translated", "value": "Chiave API corretta"}}, + "pl": {"stringUnit": {"state": "translated", "value": "Poprawiony klucz API"}}, + "pt-PT": {"stringUnit": {"state": "translated", "value": "Chave de API corrigida"}}, + "ru": {"stringUnit": {"state": "translated", "value": "Исправленный ключ API"}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "更正后的 API 密钥"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "更正後的 API 密鑰"}} + } }, + "Open your Charge detail" : { "localizations" : { + "de": {"stringUnit": {"state": "translated", "value": "Ladung im Detail öffnen"}}, + "en": {"stringUnit": {"state": "translated", "value": "Open your Charge detail"}}, + "es": {"stringUnit": {"state": "translated", "value": "Abre el detalle de Carga"}}, + "fr": {"stringUnit": {"state": "translated", "value": "Ouvrir le détail de la charge"}}, + "it": {"stringUnit": {"state": "translated", "value": "Apri il dettaglio della carica"}}, + "pl": {"stringUnit": {"state": "translated", "value": "Otwórz szczegóły Ładunku"}}, + "pt-PT": {"stringUnit": {"state": "translated", "value": "Abrir o detalhe da carga"}}, + "ru": {"stringUnit": {"state": "translated", "value": "Открыть подробности заряда"}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "打开充能详情"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "開啟充能詳情"}} + } }, + "Open your Effort detail" : { "localizations" : { + "de": {"stringUnit": {"state": "translated", "value": "Belastung im Detail öffnen"}}, + "en": {"stringUnit": {"state": "translated", "value": "Open your Effort detail"}}, + "es": {"stringUnit": {"state": "translated", "value": "Abre el detalle de Esfuerzo"}}, + "fr": {"stringUnit": {"state": "translated", "value": "Ouvrir le détail de l'effort"}}, + "it": {"stringUnit": {"state": "translated", "value": "Apri il dettaglio dello sforzo"}}, + "pl": {"stringUnit": {"state": "translated", "value": "Otwórz szczegóły Wysiłku"}}, + "pt-PT": {"stringUnit": {"state": "translated", "value": "Abrir o detalhe do esforço"}}, + "ru": {"stringUnit": {"state": "translated", "value": "Открыть подробности усилия"}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "打开消耗详情"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "開啟消耗詳情"}} + } }, + "Open your Rest detail" : { "localizations" : { + "de": {"stringUnit": {"state": "translated", "value": "Erholung im Detail öffnen"}}, + "en": {"stringUnit": {"state": "translated", "value": "Open your Rest detail"}}, + "es": {"stringUnit": {"state": "translated", "value": "Abre el detalle de Descanso"}}, + "fr": {"stringUnit": {"state": "translated", "value": "Ouvrir le détail du repos"}}, + "it": {"stringUnit": {"state": "translated", "value": "Apri il dettaglio del riposo"}}, + "pl": {"stringUnit": {"state": "translated", "value": "Otwórz szczegóły Odpoczynku"}}, + "pt-PT": {"stringUnit": {"state": "translated", "value": "Abrir o detalhe do descanso"}}, + "ru": {"stringUnit": {"state": "translated", "value": "Открыть подробности отдыха"}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "打开休息详情"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "開啟休息詳情"}} + } }, + "Open your Stress detail" : { "localizations" : { + "de": {"stringUnit": {"state": "translated", "value": "Stress im Detail öffnen"}}, + "en": {"stringUnit": {"state": "translated", "value": "Open your Stress detail"}}, + "es": {"stringUnit": {"state": "translated", "value": "Abre el detalle de Estrés"}}, + "fr": {"stringUnit": {"state": "translated", "value": "Ouvrir le détail du stress"}}, + "it": {"stringUnit": {"state": "translated", "value": "Apri il dettaglio dello stress"}}, + "pl": {"stringUnit": {"state": "translated", "value": "Otwórz szczegóły Stresu"}}, + "pt-PT": {"stringUnit": {"state": "translated", "value": "Abrir o detalhe do stress"}}, + "ru": {"stringUnit": {"state": "translated", "value": "Открыть подробности стресса"}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "打开压力详情"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "開啟壓力詳情"}} + } }, + "Write" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schreiben" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escribir" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Écrire" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scrivi" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zapisz" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escrever" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Записать" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "写入" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "寫入" + } + } + } + }, + "Write to ring" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "An den Ring schreiben" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escribir en el anillo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Écrire dans la bague" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scrivi sull'anello" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zapisz do pierścienia" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escrever no anel" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Записать в кольцо" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "写入戒指" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "寫入戒指" + } + } + } + }, + "Write to the ring?" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "An den Ring schreiben?" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Escribir en el anillo?" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Écrire dans la bague ?" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scrivere sull'anello?" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zapisać do pierścienia?" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escrever no anel?" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Записать в кольцо?" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "写入戒指?" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "寫入戒指?" + } + } + } + }, + "Write zeros" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nullen schreiben" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escribir ceros" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Écrire des zéros" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scrivi zeri" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zapisz zera" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escrever zeros" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Записать нули" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "写入零值" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "寫入零值" + } + } + } + }, + "Writes one 0x20 user-info field to the ring and logs what comes back. The ring reports 0x5c as firmware defaults (40 y, 75 kg, sex unset, 176 cm) even though your Oura app profile is correct, and the Oura app never writes it. Whether 0x20 lands in 0x5c is UNKNOWN, and so is the value encoding: try 175 for cm, then 1750 for mm. Age has no setter, only date-of-birth, whose 9-byte layout is unknown, so it is raw hex only. Tested only on Gen 3 so far — Gen 5 validation is open." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schreibt ein 0x20-Nutzerinfo-Feld an den Ring und protokolliert die Antwort. Der Ring meldet bei 0x5c die Firmware-Standardwerte (40 J, 75 kg, Geschlecht nicht gesetzt, 176 cm), obwohl dein Oura-App-Profil korrekt ist, und die Oura-App schreibt es nie. Ob 0x20 tatsächlich in 0x5c landet, ist UNBEKANNT, ebenso die Werte-Kodierung: versuche 175 für cm, dann 1750 für mm. Für das Alter gibt es keinen Setter, nur das Geburtsdatum, dessen 9-Byte-Layout unbekannt ist, also nur als Roh-Hex. Bisher nur auf Gen 3 getestet — die Validierung für Gen 5 steht noch aus." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escribe un campo de información de usuario 0x20 en el anillo y registra la respuesta. El anillo informa en 0x5c los valores predeterminados de fábrica (40 años, 75 kg, sexo sin definir, 176 cm) aunque el perfil de tu app Oura sea correcto, y la app Oura nunca lo escribe. Se DESCONOCE si 0x20 llega a 0x5c, al igual que la codificación del valor: prueba 175 para cm, luego 1750 para mm. La edad no tiene un método de escritura, solo la fecha de nacimiento, cuyo formato de 9 bytes es desconocido, así que solo se admite en hexadecimal bruto. Probado solo en Gen 3 por ahora; la validación en Gen 5 sigue pendiente." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Écrit un champ d'informations utilisateur 0x20 dans la bague et journalise la réponse. La bague indique dans 0x5c les valeurs par défaut du firmware (40 ans, 75 kg, sexe non défini, 176 cm) même si le profil de votre appli Oura est correct, et l'appli Oura ne l'écrit jamais. On ignore si 0x20 se répercute dans 0x5c, tout comme l'encodage de la valeur : essayez 175 pour les cm, puis 1750 pour les mm. L'âge n'a pas de méthode d'écriture, seulement la date de naissance, dont la structure sur 9 octets est inconnue, donc en hexadécimal brut uniquement. Testé pour l'instant uniquement sur la Gen 3 — la validation sur la Gen 5 reste à faire." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scrive un campo informazioni utente 0x20 sull'anello e registra la risposta. L'anello riporta in 0x5c i valori predefiniti del firmware (40 anni, 75 kg, sesso non impostato, 176 cm) anche se il profilo della tua app Oura è corretto, e l'app Oura non lo scrive mai. Non è NOTO se 0x20 arrivi effettivamente a 0x5c, così come non è nota la codifica del valore: prova 175 per i cm, poi 1750 per i mm. L'età non ha un setter, solo la data di nascita, il cui layout a 9 byte è sconosciuto, quindi va inserita solo come esadecimale grezzo. Finora testato solo su Gen 3 — la convalida su Gen 5 è ancora aperta." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zapisuje jedno pole informacji o użytkowniku 0x20 do pierścienia i rejestruje odpowiedź. Pierścień zgłasza w 0x5c wartości domyślne z firmware'u (40 lat, 75 kg, płeć nieustawiona, 176 cm), mimo że profil w aplikacji Oura jest poprawny, a aplikacja Oura nigdy go nie zapisuje. Czy 0x20 trafia do 0x5c, NIE WIADOMO, podobnie jak kodowanie wartości: spróbuj 175 dla cm, potem 1750 dla mm. Wiek nie ma settera, jest tylko data urodzenia, której 9-bajtowy układ jest nieznany, więc tylko surowy hex. Do tej pory testowane tylko na Gen 3 — walidacja na Gen 5 pozostaje otwarta." + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escreve um campo de informação de utilizador 0x20 no anel e regista o que é devolvido. O anel comunica em 0x5c os valores predefinidos do firmware (40 anos, 75 kg, sexo não definido, 176 cm) mesmo que o perfil da sua app Oura esteja correto, e a app Oura nunca o escreve. Se 0x20 chega a 0x5c é DESCONHECIDO, tal como a codificação do valor: experimente 175 para cm, depois 1750 para mm. A idade não tem escrita própria, apenas a data de nascimento, cujo esquema de 9 bytes é desconhecido, por isso é só em hexadecimal em bruto. Testado apenas em Gen 3 até agora — a validação em Gen 5 está em aberto." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Записывает одно поле пользовательской информации 0x20 на кольцо и регистрирует ответ. Кольцо сообщает в 0x5c значения по умолчанию из прошивки (40 лет, 75 кг, пол не задан, 176 см), даже если профиль в приложении Oura указан верно, а приложение Oura никогда его не записывает. НЕИЗВЕСТНО, попадает ли 0x20 в 0x5c, как и кодировка значения: попробуйте 175 для см, затем 1750 для мм. У возраста нет метода записи, есть только дата рождения, 9-байтовый формат которой неизвестен, поэтому только необработанный hex. Пока проверено только на Gen 3 — проверка на Gen 5 ещё предстоит." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "向戒指写入一个 0x20 用户信息字段并记录返回内容。即使你的 Oura App 中的个人资料是正确的,戒指在 0x5c 中报告的仍是固件默认值(40 岁、75 公斤、性别未设置、176 厘米),且 Oura App 从不写入该字段。0x20 是否真的写入了 0x5c 尚不清楚,数值的编码方式也不清楚:先尝试厘米值 175,再尝试毫米值 1750。年龄没有写入方式,只能通过出生日期写入,其 9 字节的结构尚不清楚,因此只能使用原始十六进制。目前仅在 Gen 3 上测试过——Gen 5 的验证仍待完成。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "向戒指寫入一個 0x20 使用者資訊欄位並記錄傳回內容。即使你的 Oura App 中的個人資料正確無誤,戒指在 0x5c 中回報的仍是韌體預設值(40 歲、75 公斤、性別未設定、176 公分),且 Oura App 從不寫入該欄位。0x20 是否真的寫入了 0x5c 目前尚不清楚,數值的編碼方式也不清楚:先嘗試公分值 175,再嘗試公釐值 1750。年齡沒有寫入方式,只能透過出生日期寫入,其 9 位元組的結構尚不清楚,因此只能使用原始十六進位。目前僅在 Gen 3 上測試過——Gen 5 的驗證仍待完成。" + } + } + } + }, + "On: your charge, rest, HRV and workouts are sent to the provider, each workout with its sport, duration, distance and heart rate." : { "localizations" : { + "de": {"stringUnit": {"state": "translated", "value": "An: Ladung, Erholung, HRV und Workouts gehen an den Anbieter, jedes Workout mit Sportart, Dauer, Distanz und Herzfrequenz."}}, + "en": {"stringUnit": {"state": "translated", "value": "On: your charge, rest, HRV and workouts are sent to the provider, each workout with its sport, duration, distance and heart rate."}}, + "es": {"stringUnit": {"state": "translated", "value": "Activado: tu carga, descanso, HRV y entrenamientos se envían al proveedor, cada entrenamiento con su deporte, duración, distancia y frecuencia cardíaca."}}, + "fr": {"stringUnit": {"state": "translated", "value": "Activé : votre charge, repos, VFC et entraînements sont envoyés au fournisseur, chaque entraînement avec son sport, sa durée, sa distance et sa fréquence cardiaque."}}, + "it": {"stringUnit": {"state": "translated", "value": "Attivo: carica, riposo, HRV e allenamenti vengono inviati al provider, ogni allenamento con sport, durata, distanza e frequenza cardiaca."}}, + "pl": {"stringUnit": {"state": "translated", "value": "Włączone: Twój ładunek, odpoczynek, HRV i treningi trafiają do dostawcy, każdy trening ze sportem, czasem, dystansem i tętnem."}}, + "pt-PT": {"stringUnit": {"state": "translated", "value": "Ligado: a tua carga, descanso, VFC e treinos são enviados ao fornecedor, cada treino com o desporto, duração, distância e frequência cardíaca."}}, + "ru": {"stringUnit": {"state": "translated", "value": "Включено: заряд, отдых, ВСР и тренировки отправляются провайдеру, каждая тренировка с видом спорта, длительностью, дистанцией и пульсом."}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "开启:你的充能、休息、HRV 和训练会发送给服务商,每次训练包含运动类型、时长、距离和心率。"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "開啟:你的充能、休息、HRV 和訓練會傳送給服務商,每次訓練包含運動類型、時長、距離和心率。"}} + } }, + "Strap alarm wake time": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Weckzeit des Strap-Alarms"}}, "es": {"stringUnit": {"state": "translated", "value": "Hora de despertar de la alarma de la banda"}}, "fr": {"stringUnit": {"state": "translated", "value": "Heure de réveil de l'alarme du bracelet"}}, "it": {"stringUnit": {"state": "translated", "value": "Ora di sveglia dell'allarme del braccialetto"}}, "pl": {"stringUnit": {"state": "translated", "value": "Godzina budzenia alarmu opaski"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Hora de despertar do alarme da banda"}}, "ru": {"stringUnit": {"state": "translated", "value": "Время подъёма для будильника ремешка"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "手环闹钟的起床时间"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "手環鬧鐘的起床時間"}} + } }, + "Your usual wake time": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Deine übliche Weckzeit"}}, "es": {"stringUnit": {"state": "translated", "value": "Tu hora habitual de despertar"}}, "fr": {"stringUnit": {"state": "translated", "value": "Votre heure de réveil habituelle"}}, "it": {"stringUnit": {"state": "translated", "value": "La tua solita ora di sveglia"}}, "pl": {"stringUnit": {"state": "translated", "value": "Twoja zwykła godzina pobudki"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "A tua hora habitual de acordar"}}, "ru": {"stringUnit": {"state": "translated", "value": "Ваше обычное время подъёма"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "你通常的起床时间"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "你通常的起床時間"}} + } }, + "This time does not wake you. It only decides when the evening reminder fires.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Diese Zeit weckt dich nicht. Sie legt nur fest, wann die Abenderinnerung kommt."}}, "es": {"stringUnit": {"state": "translated", "value": "Esta hora no te despierta. Solo decide cuándo llega el recordatorio de la noche."}}, "fr": {"stringUnit": {"state": "translated", "value": "Cette heure ne vous réveille pas. Elle décide seulement quand le rappel du soir se déclenche."}}, "it": {"stringUnit": {"state": "translated", "value": "Questa ora non ti sveglia. Decide solo quando arriva il promemoria serale."}}, "pl": {"stringUnit": {"state": "translated", "value": "Ta godzina Cię nie budzi. Decyduje tylko o tym, kiedy pojawi się wieczorne przypomnienie."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Esta hora não te acorda. Só decide quando aparece o lembrete da noite."}}, "ru": {"stringUnit": {"state": "translated", "value": "Это время вас не будит. Оно лишь определяет, когда придёт вечернее напоминание."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "这个时间不会叫醒你,它只决定晚间提醒何时出现。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "這個時間不會叫醒你,它只決定晚間提醒何時出現。"}} + } }, + "Your strap alarm is what wakes you, next on %@.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Geweckt wirst du vom Strap-Alarm, das nächste Mal am %@."}}, "es": {"stringUnit": {"state": "translated", "value": "Quien te despierta es la alarma de la banda, la próxima vez el %@."}}, "fr": {"stringUnit": {"state": "translated", "value": "C'est l'alarme du bracelet qui vous réveille, la prochaine fois le %@."}}, "it": {"stringUnit": {"state": "translated", "value": "A svegliarti è l'allarme del braccialetto, la prossima volta %@."}}, "pl": {"stringUnit": {"state": "translated", "value": "Budzi Cię alarm opaski, następnym razem w %@."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Quem te acorda é o alarme da banda, a próxima vez em %@."}}, "ru": {"stringUnit": {"state": "translated", "value": "Будит вас будильник ремешка, следующий раз в %@."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "叫醒你的是手环闹钟,下一次在 %@。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "叫醒你的是手環鬧鐘,下一次在 %@。"}} + } }, + "Set a wake time for specific days (a lie-in at the weekend, say). These times move your strap alarm AND the evening reminder on those days.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Lege für einzelne Tage eine Weckzeit fest (etwa Ausschlafen am Wochenende). Diese Zeiten verschieben an diesen Tagen sowohl den Strap-Alarm als auch die Abenderinnerung."}}, "es": {"stringUnit": {"state": "translated", "value": "Fija una hora de despertar para días concretos (dormir hasta tarde el fin de semana, por ejemplo). Esas horas mueven tanto la alarma de la banda como el recordatorio de la noche en esos días."}}, "fr": {"stringUnit": {"state": "translated", "value": "Définissez une heure de réveil pour certains jours (une grasse matinée le week-end, par exemple). Ces heures décalent à la fois l'alarme du bracelet et le rappel du soir ces jours-là."}}, "it": {"stringUnit": {"state": "translated", "value": "Imposta un'ora di sveglia per giorni specifici (per esempio dormire fino a tardi nel fine settimana). Quelle ore spostano sia l'allarme del braccialetto sia il promemoria serale in quei giorni."}}, "pl": {"stringUnit": {"state": "translated", "value": "Ustaw godzinę pobudki dla wybranych dni (na przykład dłuższy sen w weekend). Te godziny przesuwają w te dni zarówno alarm opaski, jak i wieczorne przypomnienie."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Define uma hora de acordar para dias específicos (dormir até mais tarde ao fim de semana, por exemplo). Essas horas mudam tanto o alarme da banda como o lembrete da noite nesses dias."}}, "ru": {"stringUnit": {"state": "translated", "value": "Задайте время подъёма для отдельных дней (например, поспать подольше в выходные). В эти дни такое время сдвигает и будильник ремешка, и вечернее напоминание."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "为特定日期设置起床时间(比如周末睡个懒觉)。这些时间会同时改变那几天的手环闹钟和晚间提醒。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "為特定日期設定起床時間(例如週末睡個懶覺)。這些時間會同時改變那幾天的手環鬧鐘和晚間提醒。"}} + } }, + "Days you leave alone use the usual wake time above.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Tage, die du unangetastet lässt, nutzen die übliche Weckzeit oben."}}, "es": {"stringUnit": {"state": "translated", "value": "Los días que no toques usan la hora habitual de despertar de arriba."}}, "fr": {"stringUnit": {"state": "translated", "value": "Les jours que vous ne touchez pas utilisent l'heure de réveil habituelle ci-dessus."}}, "it": {"stringUnit": {"state": "translated", "value": "I giorni che non tocchi usano la solita ora di sveglia qui sopra."}}, "pl": {"stringUnit": {"state": "translated", "value": "Dni, których nie zmienisz, używają zwykłej godziny pobudki powyżej."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Os dias que não mexeres usam a hora habitual de acordar acima."}}, "ru": {"stringUnit": {"state": "translated", "value": "Дни, которые вы не меняли, используют обычное время подъёма выше."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "未改动的日子使用上面的通常起床时间。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "未更動的日子使用上面的通常起床時間。"}} + } }, + "Days you leave alone keep your strap alarm at %@, and time the reminder from %@.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Tage, die du unangetastet lässt, behalten den Strap-Alarm um %1$@ und timen die Erinnerung ab %2$@."}}, "es": {"stringUnit": {"state": "translated", "value": "Los días que no toques mantienen la alarma de la banda a las %1$@ y calculan el recordatorio desde las %2$@."}}, "fr": {"stringUnit": {"state": "translated", "value": "Les jours que vous ne touchez pas gardent l'alarme du bracelet à %1$@ et calculent le rappel à partir de %2$@."}}, "it": {"stringUnit": {"state": "translated", "value": "I giorni che non tocchi mantengono l'allarme del braccialetto alle %1$@ e calcolano il promemoria da %2$@."}}, "pl": {"stringUnit": {"state": "translated", "value": "Dni, których nie zmienisz, zachowują alarm opaski o %1$@, a przypomnienie liczą od %2$@."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Os dias que não mexeres mantêm o alarme da banda às %1$@ e calculam o lembrete a partir das %2$@."}}, "ru": {"stringUnit": {"state": "translated", "value": "В дни, которые вы не меняли, будильник ремешка остаётся на %1$@, а напоминание считается от %2$@."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "未改动的日子,手环闹钟仍为 %1$@,晚间提醒则从 %2$@ 推算。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "未更動的日子,手環鬧鐘仍為 %1$@,晚間提醒則從 %2$@ 推算。"}} + } }, + "Some days have a time of their own, set under the evening reminder below. Next buzz %@.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Manche Tage haben eine eigene Zeit, eingestellt unter der Abenderinnerung weiter unten. Nächster Weckruf %@."}}, "es": {"stringUnit": {"state": "translated", "value": "Algunos días tienen su propia hora, fijada en el recordatorio de la noche más abajo. Próximo aviso %@."}}, "fr": {"stringUnit": {"state": "translated", "value": "Certains jours ont leur propre heure, définie sous le rappel du soir ci-dessous. Prochaine vibration %@."}}, "it": {"stringUnit": {"state": "translated", "value": "Alcuni giorni hanno un orario proprio, impostato sotto il promemoria serale qui sotto. Prossima vibrazione %@."}}, "pl": {"stringUnit": {"state": "translated", "value": "Niektóre dni mają własną godzinę, ustawioną przy wieczornym przypomnieniu poniżej. Następna wibracja %@."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Alguns dias têm hora própria, definida no lembrete da noite abaixo. Próximo aviso %@."}}, "ru": {"stringUnit": {"state": "translated", "value": "У некоторых дней своё время, заданное ниже, у вечернего напоминания. Следующий сигнал %@."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "有些日子有自己的时间,在下面的晚间提醒中设置。下次震动 %@。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "有些日子有自己的時間,在下面的晚間提醒中設定。下次震動 %@。"}} + } }, + "Alarm in %@": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Wecker in %@"}}, "es": {"stringUnit": {"state": "translated", "value": "Alarma en %@"}}, "fr": {"stringUnit": {"state": "translated", "value": "Alarme dans %@"}}, "it": {"stringUnit": {"state": "translated", "value": "Sveglia tra %@"}}, "pl": {"stringUnit": {"state": "translated", "value": "Alarm za %@"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Alarme daqui a %@"}}, "ru": {"stringUnit": {"state": "translated", "value": "Будильник через %@"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "闹钟将在 %@ 后响起"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "鬧鐘將在 %@ 後響起"}} + } }, + "Alarm in less than a minute": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Alarm in weniger als einer Minute"}}, "es": {"stringUnit": {"state": "translated", "value": "Alarma en menos de un minuto"}}, "fr": {"stringUnit": {"state": "translated", "value": "Alarme dans moins d'une minute"}}, "it": {"stringUnit": {"state": "translated", "value": "Sveglia tra meno di un minuto"}}, "pl": {"stringUnit": {"state": "translated", "value": "Alarm za mniej niż minutę"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Alarme daqui a menos de um minuto"}}, "ru": {"stringUnit": {"state": "translated", "value": "Будильник менее чем через минуту"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "闹钟将在一分钟内响起"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "鬧鐘將在一分鐘內響起"}} + } }, + "Usual wake": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Übliches Aufwachen"}}, "es": {"stringUnit": {"state": "translated", "value": "Despertar habitual"}}, "fr": {"stringUnit": {"state": "translated", "value": "Réveil habituel"}}, "it": {"stringUnit": {"state": "translated", "value": "Sveglia abituale"}}, "pl": {"stringUnit": {"state": "translated", "value": "Zwykła pobudka"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Acordar habitual"}}, "ru": {"stringUnit": {"state": "translated", "value": "Обычный подъём"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "通常起床"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "通常起床"}} + } }, + "A calm nudge %lldh %lldm before your usual wake time.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Ein ruhiger Hinweis %1$lldh %2$lldm vor deiner üblichen Weckzeit."}}, "es": {"stringUnit": {"state": "translated", "value": "Un aviso tranquilo %1$lldh %2$lldm antes de tu hora habitual de despertar."}}, "fr": {"stringUnit": {"state": "translated", "value": "Un rappel calme %1$lldh %2$lldm avant votre heure de réveil habituelle."}}, "it": {"stringUnit": {"state": "translated", "value": "Un promemoria tranquillo %1$lldh %2$lldm prima della tua solita ora di sveglia."}}, "pl": {"stringUnit": {"state": "translated", "value": "Spokojne przypomnienie %1$lldh %2$lldm przed Twoją zwykłą godziną pobudki."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Um aviso calmo %1$lldh %2$lldm antes da tua hora habitual de acordar."}}, "ru": {"stringUnit": {"state": "translated", "value": "Спокойное напоминание за %1$lldч %2$lldм до вашего обычного времени подъёма."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "在你通常的起床时间前 %1$lld 小时 %2$lld 分钟的一个平静提醒。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "在你通常的起床時間前 %1$lld 小時 %2$lld 分鐘的一個平靜提醒。"}} + } }, + "Turn on the wind-down reminder below to land at your usual wake time rested.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Schalte unten die Wind-down-Erinnerung ein, um ausgeruht an deiner üblichen Weckzeit anzukommen."}}, "es": {"stringUnit": {"state": "translated", "value": "Activa abajo el recordatorio de descanso para llegar descansado a tu hora habitual de despertar."}}, "fr": {"stringUnit": {"state": "translated", "value": "Activez le rappel de détente ci-dessous pour arriver reposé à votre heure de réveil habituelle."}}, "it": {"stringUnit": {"state": "translated", "value": "Attiva qui sotto il promemoria di rilassamento per arrivare riposato alla tua solita ora di sveglia."}}, "pl": {"stringUnit": {"state": "translated", "value": "Włącz poniżej przypomnienie o wyciszeniu, aby wstać wypoczętym o swojej zwykłej godzinie pobudki."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Ativa abaixo o lembrete de descanso para chegares descansado à tua hora habitual de acordar."}}, "ru": {"stringUnit": {"state": "translated", "value": "Включите ниже напоминание о подготовке ко сну, чтобы встать отдохнувшим в обычное время подъёма."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "开启下面的睡前放松提醒,让你在通常的起床时间精神饱满地醒来。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "開啟下面的睡前放鬆提醒,讓你在通常的起床時間精神飽滿地醒來。"}} + } }, + "no time of its own": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "keine eigene Zeit"}}, "es": {"stringUnit": {"state": "translated", "value": "sin hora propia"}}, "fr": {"stringUnit": {"state": "translated", "value": "pas d'heure propre"}}, "it": {"stringUnit": {"state": "translated", "value": "nessun orario proprio"}}, "pl": {"stringUnit": {"state": "translated", "value": "brak własnej godziny"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "sem hora própria"}}, "ru": {"stringUnit": {"state": "translated", "value": "без своего времени"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "没有单独时间"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "沒有單獨時間"}} + } } }, "version": "1.0" } 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..f6971de330 100644 --- a/Strand/Screens/BackupSyncView.swift +++ b/Strand/Screens/BackupSyncView.swift @@ -186,11 +186,13 @@ struct BackupSyncView: View { #if os(macOS) if FolderBackup.pickFolder() != nil { folderLabel = FolderBackup.folderLabel() } #else - // #1000a: on iOS the folder picker has reportedly refused to enable its Select button, leaving - // the user with only Cancel and NOOP silently doing nothing. We can't tell a deliberate Cancel - // apart from that dead-button dead-end (both come back nil), so when no folder arrives we show - // the screen's normal result alert with a concrete workaround instead of staying silent. Mildly - // chatty on a genuine Cancel; honest and actionable when the picker is actually broken. + // #1000a assumed the iOS picker was refusing to ENABLE its Select button, leaving the user with + // only Cancel. #2356 disproved that for at least one case: a reporter's log shows the delegate + // firing after they picked an iCloud folder and pressed Open, so the button worked and iOS + // declined the grant instead. Both still arrive here as nil, and UIKit gives us nothing to tell + // them apart, which is exactly why the alert below describes the outcome rather than a cause. + // Keep it that way: the previous guess is what sent the last investigation at the wrong failure. + // Mildly chatty on a genuine Cancel; honest and actionable whenever no folder comes back. // `busy` guards against a double-tap stacking a second picker presentation on top of the first. busy = true Task { @@ -270,7 +272,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 +296,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 +366,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/BiofeedbackController.swift b/Strand/Screens/BiofeedbackController.swift index 5659d3446a..adb475718c 100644 --- a/Strand/Screens/BiofeedbackController.swift +++ b/Strand/Screens/BiofeedbackController.swift @@ -115,13 +115,6 @@ final class BiofeedbackController: ObservableObject { model.buzz(loops: UInt8(clamping: loops), gate: HapticPrefs.breathing) } - /// Schedule one cancellable buzz at `offsetMs` from now (the asyncAfter walk, cancellable on stop). - private func scheduleBuzz(loops: Int, afterMs: Int) { - let item = DispatchWorkItem { [weak self] in self?.fireBuzz(loops: loops) } - pending.append(item) - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(afterMs), execute: item) - } - private func startSecondTimer() { elapsedSeconds = 0 secondTimer = Timer.publish(every: 1, on: .main, in: .common) diff --git a/Strand/Screens/BodyClockDialCard.swift b/Strand/Screens/BodyClockDialCard.swift new file mode 100644 index 0000000000..67ece52b50 --- /dev/null +++ b/Strand/Screens/BodyClockDialCard.swift @@ -0,0 +1,328 @@ +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. + /// + /// The radii are NAMED BANDS rather than two numbers because the previous pair collided: the + /// reference arc was stroked at `outer`, the same radius the six-hour ticks occupy, so on a window + /// spanning midnight the dashes ran straight into the ticks (#2350). Each band now owns a radius and + /// a width, and `BodyClockDialLayoutTests` asserts they cannot overlap, which is the part of "does + /// this look right" that arithmetic can actually settle. + private struct DialGeometry { + let centre: CGPoint + let outer: CGFloat + let labelRadius: CGFloat + let reference: CGFloat + let night: CGFloat + let glyphRadius: CGFloat + + /// Tick lengths, the numeral band, and the two arc widths. Shared with the Kotlin twin verbatim. + /// The rim the band offsets were tuned against: a 200 pt card, so `side / 2 - 10`. + static let tunedRim: CGFloat = 90 + static let midnightTick: CGFloat = 6 + static let regularTick: CGFloat = 4 + static let referenceWidth: CGFloat = 7 + static let nightWidth: CGFloat = 9 + static let glyph: CGFloat = 12 + + /// The band offsets are a FRACTION of the available radius, not fixed subtractions. + /// + /// Fixed offsets make the nesting depth constant at 58 pt whatever the canvas is, so a narrow + /// card drives the inner bands through the centre and then negative: below about 136 pt the bed + /// lands on the centre, and below 110 the night arc radius is negative, which is undefined for + /// `addArc` and draws nothing in the Kotlin twin. `side` is `min(width, height)` and the height + /// is pinned, so width alone decides it. Scaling keeps the tuned proportions at the shipping + /// size (the factor is exactly 1 at rim 90) and every band stays positive for any rim, since the + /// deepest is 58/90 of it. + init(size: CGSize) { + let side: CGFloat = min(size.width, size.height) + centre = CGPoint(x: size.width / 2, y: size.height / 2) + let rim: CGFloat = side / 2 - 10 + let scale: CGFloat = rim / DialGeometry.tunedRim + outer = rim + labelRadius = rim - 15 * scale + reference = rim - 31 * scale + night = rim - 45 * scale + glyphRadius = rim - 58 * scale + } + } + + private var dial: some View { + Canvas { ctx, size in + let g = DialGeometry(size: size) + drawTrack(ctx, g) + drawTicks(ctx, g) + drawHourLabels(ctx, g) + drawArcs(ctx, g) + drawOnset(ctx, g) + } + .frame(height: 200) + } + + /// 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 rim hairline is the circle the TICKS + /// belong to; it no longer doubles as the reference arc's circle, because that arc moved inward to + /// its own band (#2350) and sharing a radius with the ticks was the collision being removed. + private func drawTrack(_ ctx: GraphicsContext, _ g: DialGeometry) { + let trackRect = CGRect(x: g.centre.x - g.night, y: g.centre.y - g.night, + width: g.night * 2, height: g.night * 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 ? DialGeometry.midnightTick : DialGeometry.regularTick + 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) + } + } + + /// Hour numerals at 00, 06, 12 and 18. + /// + /// The dial previously carried one longer midnight tick and nothing else, on the reasoning that a + /// single distinguished mark orients the ring without raising a 12-versus-24-hour question. It does + /// orient it, but it leaves no time READABLE: the card's own caption claims a night is "2.6 h later + /// than your body clock" and there was no way to check that against the picture (#2350). Numerals + /// settle the format question by answering it, in the 24-hour form the rest of this card uses. + private func drawHourLabels(_ ctx: GraphicsContext, _ g: DialGeometry) { + for hour in stride(from: 0.0, to: 24.0, by: 6.0) { + let a: Double = angle(hour).radians + let x: CGFloat = g.centre.x + cos(a) * g.labelRadius + let y: CGFloat = g.centre.y + sin(a) * g.labelRadius + // A FIXED point size, not `StrandFont.caption`. That is a text STYLE, so it scales with + // Dynamic Type, and these numerals sit in a fixed-radius canvas with 3.5 pt of clearance to + // the ticks: at an accessibility text size they would grow straight into them, recreating + // the collision this change exists to remove. The dial is a diagram, and its geometry does + // not scale, so neither may its labels. + // Colour via the RESOLVED text's shading, the same way `drawOnset` tints the bed, NOT via + // `.foregroundStyle` on the `Text`. That overload returns `Text` only from macOS 14, and this + // target is macOS 13, so there the expression is a `View` and `resolve` has no matching + // overload. The iOS leg compiled it happily on its iOS 17 floor, which is why only the macOS + // leg caught it, and why `swiftc -parse` could not: it never resolves a symbol. + var numeral = ctx.resolve( + Text(String(format: "%02d", Int(hour))).font(.system(size: 10, design: .rounded))) + numeral.shading = .color(StrandPalette.textTertiary) + ctx.draw(numeral, at: CGPoint(x: x, y: y), anchor: .center) + } + } + + /// 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.reference, from: ideal.bedHour, to: ideal.wakeHour, + colour: hue.opacity(0.55), width: DialGeometry.referenceWidth, dashed: true) + } + strokeArc(ctx, g, radius: g.night, from: actualBedHour, to: actualWakeHour, + colour: hue, width: DialGeometry.nightWidth, 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) + // Each dash must be LONGER than the stroke is wide, or it renders as a radial hash mark rather + // than a dashed line, which is what a [3, 5] dash under a 7 pt stroke produced (#2350). The + // remedy is the DASH, not the weight: the note above records that opacity alone left this arc + // washed out to near-invisible over a custom background on a real device, so the 7 pt stroke is + // legibility someone bought with hardware. Thinning it to satisfy the rule would have paid for + // one fix with another's evidence. + let dash: [CGFloat] = dashed ? [10, 8] : [] + 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 + // INSIDE the night arc, not on it. Centred on the arc itself the bed covered the very onset it + // exists to mark (#2350); set in one band it still points at the start without hiding it. + let glyph: CGFloat = DialGeometry.glyph + let x: CGFloat = g.centre.x + cos(onset) * g.glyphRadius - glyph / 2 + let y: CGFloat = g.centre.y + sin(onset) * g.glyphRadius - 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/BodyClockDialSection.swift b/Strand/Screens/BodyClockDialSection.swift new file mode 100644 index 0000000000..7b9afd0fa8 --- /dev/null +++ b/Strand/Screens/BodyClockDialSection.swift @@ -0,0 +1,24 @@ +import SwiftUI + +/// The 24 h body-clock dial's live-`AppModel` leaf (#1680). +/// +/// PERF (chart-invalidation): `SleepView` used to hold `@EnvironmentObject var appModel: AppModel` +/// directly just to read `circadianPhase` for this one dial. `AppModel` publishes `bpm` at ~1 Hz +/// (AppModel.swift:202), and `@EnvironmentObject` subscribes to the WHOLE object's `objectWillChange` +/// regardless of which properties are actually read — so every tick re-evaluated `SleepView`'s entire +/// ~3000-line body (hero hypnogram, stat-tile sparklines, sleep-debt ledger, 30-day trend chart) even +/// though only this dial's visibility depends on `AppModel`. Isolating it here, mirroring `HealthView`'s +/// live-observing-leaf pattern (HealthView.swift:17-22, 44-46), means a tick re-renders only this leaf. +struct BodyClockDialSection: View { + @EnvironmentObject var appModel: AppModel + let actualBedHour: Double + let actualWakeHour: Double + + /// 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". + var body: some View { + if let phase = appModel.circadianPhase, phase.confidence != .unreadable { + BodyClockDialCard(estimate: phase, actualBedHour: actualBedHour, actualWakeHour: actualWakeHour) + } + } +} diff --git a/Strand/Screens/BreathingView.swift b/Strand/Screens/BreathingView.swift index 52df26c86c..34b3b935e1 100644 --- a/Strand/Screens/BreathingView.swift +++ b/Strand/Screens/BreathingView.swift @@ -230,10 +230,11 @@ private struct BreathingContent: View { on ? tonePlayer.activate() : tonePlayer.deactivate() } .onAppear { + model.startRealtimeHR() controllerBox.prepare(model: model, live: live) if audioCues { tonePlayer.activate() } } - .onDisappear { stop(); controller.stop(); tonePlayer.deactivate() } + .onDisappear { model.stopRealtimeHR(); stop(); controller.stop(); tonePlayer.deactivate() } } // MARK: - Mode switch 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/ChargeBreakdownFormat.swift b/Strand/Screens/ChargeBreakdownFormat.swift index 166c18828f..e82148d025 100644 --- a/Strand/Screens/ChargeBreakdownFormat.swift +++ b/Strand/Screens/ChargeBreakdownFormat.swift @@ -203,6 +203,22 @@ enum ChargeBreakdownFormat { static var chargeDeepWindowGapAccessibility: String { "\(chargeDeepWindowGapTitle). \(chargeDeepWindowGapDetail)" } + + // MARK: - Legacy WHOOP 5 R-R units gap (#1505) + + /// The short title for a night the WHOOP 5 unit policy has to leave unscored. + static let chargeLegacyRRGapTitle = String(localized: "Earlier beats cannot be scored") + + /// The explanatory detail. It names the cause and stops there, deliberately: the beats are already + /// on the phone and the strap has long since trimmed that night from its own flash, so there is no + /// honest "do this and it comes back" step to offer. What it CAN promise is that the night is not + /// lost from history and that the problem does not continue, and it says both. + static let chargeLegacyRRGapDetail = String(localized: "This night was recorded before NOOP labelled which WHOOP 5 transport each heartbeat came from, so its intervals mix two different units with nothing stored to tell them apart. The night stays in your history, and nights recorded from now on score normally.") + + /// VoiceOver plain string (title + detail). + static var chargeLegacyRRGapAccessibility: String { + "\(chargeLegacyRRGapTitle). \(chargeLegacyRRGapDetail)" + } } // MARK: - A3: confidence dot + tier tag pill diff --git a/Strand/Screens/ChargeBreakdownWiring.swift b/Strand/Screens/ChargeBreakdownWiring.swift new file mode 100644 index 0000000000..cc5eab7866 --- /dev/null +++ b/Strand/Screens/ChargeBreakdownWiring.swift @@ -0,0 +1,58 @@ +import Foundation +import StrandAnalytics +import WhoopStore + +// MARK: - Charge breakdown wiring (pure, testable) +// +// The fold-and-score wiring behind the "What shaped it" sheet, lifted out of the two views that had +// byte-identical private copies of it (TodayView.chargeBreakdown / CoupledView.chargeBreakdown). They +// differed only in which row they display and where their sleep-performance number comes from, both of +// which are inputs, so one function serves both. +// +// Extracted so it can be TESTED. Living inside a `View` struct as a private method meant the only way to +// exercise it was to render the view, so the iOS side of this path had no test at all while the Android +// twin did. Kotlin twin: `TodayScoring.recoveryChargeDrivers`. +// +// Pure: no SwiftUI state, no I/O, no store access. Nothing here invents a number. The drivers come from +// `RecoveryScorer.chargeDrivers` and the tier is SURFACED from `ScoreConfidence.charge` against the same +// folded HRV baseline the drivers scored with, so the header and the rows agree by construction. +enum ChargeBreakdownWiring { + + /// The ordered Charge driver rows for `row` plus its confidence tier, folded from the visible `days` + /// history, or nil when the night cannot honestly score (missing HRV or resting HR, or an HRV + /// baseline that is not yet usable) so the sheet hides rather than showing fabricated rows. + /// + /// `sleepPerfPercent` is the Rest composite on a 0-100 scale, divided by 100 here to match + /// `AnalyticsEngine`'s `sleepPerf` form, so the Sleep row scores against the headline's own input. + /// + /// The resting-HR and respiration baselines are passed only when usable. `RecoveryScorer` and + /// `chargeDrivers` both apply that gate themselves since #1990, so these are belt-and-braces rather + /// than load-bearing; they are kept because they say the intent at the call site, which is where a + /// reader looks first. + static func breakdown(days: [DailyMetric], + row: DailyMetric, + sleepPerfPercent: Double?, + hrvBaselineEpoch: Double = 0) -> (drivers: [ChargeDriver], confidence: ScoreConfidence)? { + guard let hrv = row.avgHrv, let rhr = row.restingHr else { return nil } + // PERF: one pass per series. The two private copies this replaces each re-folded the full history + // per body evaluation of the open sheet; the guard above still runs before any fold. + // #2315: fold with the recalibration epoch, exactly as the engine does. Without it these rows and + // the confidence tier scored against the WHOLE history while the headline scored against the + // post-Recalibrate nights, so the Charge page showed two baselines for one metric. `0` (no + // recalibration) delegates to the plain fold, so a user who never recalibrated sees no change. + let hrvBase = Baselines.foldHistory(days.map(\.avgHrv), dayKeys: days.map(\.day), + cfg: Baselines.hrvCfg, baselineEpoch: hrvBaselineEpoch) + guard hrvBase.usable else { return nil } + let rhrBase = Baselines.foldHistory(days.map { $0.restingHr.map(Double.init) }, + cfg: Baselines.restingHRCfg) + let respBase = Baselines.foldHistory(days.map(\.respRateBpm), cfg: Baselines.respCfg) + let drivers = RecoveryScorer.chargeDrivers( + hrv: hrv, rhr: Double(rhr), resp: row.respRateBpm, + hrvBaseline: hrvBase, + rhrBaseline: rhrBase.usable ? rhrBase : nil, + respBaseline: respBase.usable ? respBase : nil, + sleepPerf: sleepPerfPercent.map { $0 / 100.0 }, + skinTempDev: row.skinTempDevC) + return (drivers, ScoreConfidence.charge(recovery: row.recovery, hrvBaseline: hrvBase)) + } +} 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/CoachSettingsView.swift b/Strand/Screens/CoachSettingsView.swift new file mode 100644 index 0000000000..d7d485cb09 --- /dev/null +++ b/Strand/Screens/CoachSettingsView.swift @@ -0,0 +1,342 @@ +import SwiftUI +import StrandDesign + +/// Coach settings, split out of `CoachView` so the coach screen is the conversation and nothing else +/// (#2243). Holds the surfaces that used to stack above the transcript: the data-sharing consent, the +/// two further opt-ins that depend on it, the editable coach instructions, and the morning brief. +/// +/// Connection management (the provider pill, Clear conversation, Disconnect) deliberately stays on +/// `CoachView`. Disconnect is the only route back to the setup card, which is the only place a key can +/// be typed, and #2206 is the record of what happened the last time that control was put somewhere a +/// presentation did not render it. The Kotlin twin `CoachSettingsScreen` splits on the same line. +/// +/// Presented as a sheet rather than pushed. CoachView appears in three places between the two +/// platforms (a macOS route, an iPhone tab root whose navigation bar is hidden, and an iPhone pillar +/// sheet), and a sheet is the one presentation that behaves the same in all three without depending on +/// an enclosing NavigationStack. +struct CoachSettingsView: View { + @EnvironmentObject var coach: AICoachEngine + @Environment(\.dismiss) private var dismiss + + /// Morning-brief settings, read from `CoachBriefScheduler` on init exactly as `CoachView` did + /// before the split. This screen can now be the first to render them. + @State private var briefEnabled: Bool = CoachBriefScheduler.isEnabled + @State private var briefMinutes: Int = CoachBriefScheduler.timeMinutes + @State private var briefGenerating = false + @State private var briefStatus: String? + + /// The coach-instructions editor, collapsed until asked for. + @State private var promptExpanded: Bool = false + @State private var promptDraft: String = "" + + var body: some View { + // Literals, not String(localized:): `title`/`subtitle` are LocalizedStringKey, which converts + // from a string LITERAL only, so a String value does not type-check here. The catalog keys are + // these exact English strings. + // + // Done goes in the scaffold's `trailing` slot rather than a .toolbar. ScreenScaffold is a bare + // ScrollView with no NavigationStack, so a toolbar item presented in a sheet would render + // nowhere, and a macOS sheet has no swipe-to-dismiss: that combination would leave this screen + // with no way out. (#2206 is the same mistake in the other direction.) + ScreenScaffold(title: "Coach settings", + subtitle: "What the coach may read, how it is told to answer, and when it writes to you.", + topBackground: liquidScaffoldSky(), + trailing: { + Button("Done") { dismiss() } + .buttonStyle(.plain) + .font(StrandFont.subhead) + .foregroundStyle(StrandPalette.accent) + .accessibilityLabel("Close coach settings") + }) { + modelBar + consentBar + // 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 + } + // Opening this screen is the moment a stale catalogue is worth refreshing: a key exists here by + // definition, and the picker above is about to be read. Rate-limited and silent on failure. + .task { await coach.refreshModelsIfStale() } + } + + /// Which model answers, and the control that refreshes the list of them. + /// + /// This lives HERE rather than on the setup card because of where a key exists. `setupCard` renders + /// only while `isConfigured` is false, which for a cloud provider means no key is stored, and the + /// Refresh control is `.disabled(!coach.hasKey)` — gated on having a key inside a screen that only + /// appears when there is none. So for OpenAI, Anthropic and Gemini that button was permanently + /// disabled and the live catalogue those three publish was unreachable. A key exists by definition + /// on this screen, so the picker and the refresh both work. + /// + /// The PROVIDER deliberately stays on the setup card. A stored key records which provider it + /// belongs to and is never sent anywhere else, so switching provider here would leave a key that + /// cannot be used and a screen that cannot fix it. Kotlin twin: `CoachModelCard`. + private var modelBar: some View { + NoopCard(padding: 14, tint: StrandPalette.chargeColor) { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("\(coach.provider.displayName) · \(coach.model)") + .font(StrandFont.subhead).foregroundStyle(StrandPalette.textPrimary) + Spacer(minLength: 8) + Button { + Task { await coach.refreshModels() } + } label: { + Label("Refresh models", systemImage: "arrow.clockwise") + .font(StrandFont.footnote) + .labelStyle(.titleAndIcon) + } + .buttonStyle(.plain) + .foregroundStyle(StrandPalette.accent) + .disabled(!coach.hasKey) + .accessibilityLabel("Refresh models from provider") + } + Picker("Model", selection: $coach.model) { + ForEach(coach.availableModels, id: \.self) { m in + Text(m).tag(m) + } + } + .labelsHidden() + .pickerStyle(.menu) + .accessibilityLabel("Model") + } + } + } + + /// Explicit, revocable permission for the coach to read & send the user's data. Off by default. + /// A frosted Charge-tinted card so it reads as part of the green Coach world, not a flat panel. + private var consentBar: some View { + NoopCard(padding: 14, tint: StrandPalette.chargeColor) { + HStack(spacing: 10) { + Image(systemName: coach.dataConsent ? "lock.open.fill" : "lock.fill") + .foregroundStyle(coach.dataConsent ? StrandPalette.accent : StrandPalette.textTertiary) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 1) { + Text("Let the coach use my data") + .font(StrandFont.subhead).foregroundStyle(StrandPalette.textPrimary) + // The ON line NAMES what a session carries rather than saying "workouts" and + // leaving the reader to guess how much that is: the sport, how long, how far and how + // hard, per session. This toggle is the only place someone is asked to agree to it. + // Android says the same sentence (#2033). + Text(coach.dataConsent + ? "On: your charge, rest, HRV and workouts are sent to the provider, each workout with its sport, duration, distance and heart rate." + : "Off: the coach answers generally and sends none of your metrics.") + .font(StrandFont.footnote).foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 8) + Toggle("", isOn: $coach.dataConsent) + .labelsHidden().toggleStyle(.switch).tint(StrandPalette.accent) + .accessibilityLabel("Let the coach use my data") + } + } + } + + /// The v5 second opt-in: include a SUMMARY of the new on-device signals (strongest n-of-1 patterns + + /// Lab Book markers). Summary-only, never raw readings, so the no-raw-egress posture holds. + private var onDeviceSignalsBar: some View { + NoopCard(padding: 14, tint: StrandPalette.chargeColor) { + HStack(spacing: 10) { + Image(systemName: coach.includeOnDeviceSignals ? "checklist.checked" : "checklist") + .foregroundStyle(coach.includeOnDeviceSignals ? StrandPalette.accent : StrandPalette.textTertiary) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 1) { + Text("Also share my patterns & Lab Book") + .font(StrandFont.subhead).foregroundStyle(StrandPalette.textPrimary) + Text(coach.includeOnDeviceSignals + ? "On: a short summary of your strongest patterns and logged health numbers is added. Summaries only, never raw readings." + : "Off: only your core metrics are shared, not your patterns or Lab Book.") + .font(StrandFont.footnote).foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 8) + Toggle("", isOn: $coach.includeOnDeviceSignals) + .labelsHidden().toggleStyle(.switch).tint(StrandPalette.accent) + .accessibilityLabel("Also share my patterns and Lab Book with the coach") + } + } + } + + /// 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. + private var systemPromptBar: some View { + NoopCard(padding: 14, tint: StrandPalette.chargeColor) { + VStack(alignment: .leading, spacing: promptExpanded ? 10 : 0) { + Button { + withAnimation(StrandMotion.fade) { + promptExpanded.toggle() + if promptExpanded { promptDraft = coach.customSystemPrompt } + } + } label: { + HStack(spacing: 10) { + Image(systemName: "text.alignleft") + .foregroundStyle(coach.hasCustomSystemPrompt ? StrandPalette.accent : StrandPalette.textTertiary) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 1) { + Text("Coach instructions") + .font(StrandFont.subhead).foregroundStyle(StrandPalette.textPrimary) + Text(coach.hasCustomSystemPrompt + ? "Customised. Your edited instructions frame every reply." + : "Edit how the coach thinks and talks. Takes effect on your next message.") + .font(StrandFont.footnote).foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 8) + Image(systemName: promptExpanded ? "chevron.up" : "chevron.down") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(StrandPalette.textTertiary) + .accessibilityHidden(true) + } + } + .buttonStyle(.plain) + .accessibilityLabel(promptExpanded ? "Collapse coach instructions" : "Edit coach instructions") + + if promptExpanded { + TextEditor(text: $promptDraft) + .font(StrandFont.body) + .foregroundStyle(StrandPalette.textPrimary) + .scrollContentBackground(.hidden) + .frame(minHeight: 140, maxHeight: 240) + .padding(8) + .background(StrandPalette.surfaceInset, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(StrandPalette.hairline, lineWidth: 1)) + .onChangeCompat(of: promptDraft) { newValue in + coach.customSystemPrompt = newValue + } + .accessibilityLabel("Coach instructions editor") + + HStack { + Spacer() + Button { + coach.resetSystemPrompt() + promptDraft = coach.customSystemPrompt + } label: { + Label("Reset to default", systemImage: "arrow.uturn.backward") + .font(StrandFont.footnote) + .labelStyle(.titleAndIcon) + } + .buttonStyle(.plain) + .foregroundStyle(StrandPalette.accent) + .disabled(!coach.hasCustomSystemPrompt) + .accessibilityLabel("Reset coach instructions to default") + } + } + } + } + } + + /// 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." + } + } + } +} diff --git a/Strand/Screens/CoachView.swift b/Strand/Screens/CoachView.swift index e84e109347..963b43a2c8 100644 --- a/Strand/Screens/CoachView.swift +++ b/Strand/Screens/CoachView.swift @@ -14,32 +14,46 @@ 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 = "" + /// The corrected key, typed into the editor a rejection opens. Separate from `keyDraft` so the + /// setup card's own field is untouched, and cleared on save so a secret does not sit in view state + /// after it has been stored. Twin of the Kotlin `keyFix`. + @State private var keyFix: String = "" /// Whether the model selector is in free-text "Custom…" mode. @State private var customModel: Bool = false /// The id typed in the "Custom…" field. @State private var customModelDraft: String = "" - /// Whether the editable-system-prompt section is expanded. Collapsed by default so the settings - /// stay compact; most users never touch the prompt. - @State private var promptExpanded: Bool = false - /// Working copy of the system prompt while editing, committed to the engine on change so an edit - /// takes effect on the next send. Seeded from the engine when the editor opens. - @State private var promptDraft: String = "" @FocusState private var composerFocused: Bool + /// K2: confirmation gate for the destructive "Clear conversation" toolbar action. + @State private var showClearConfirm = false + /// #2243: the coach settings, presented as a sheet. See `CoachSettingsView` for why a sheet + /// rather than a push. + @State private var showSettings = 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", @@ -50,24 +64,54 @@ struct CoachView: View { topBackground: liquidScaffoldSky()) { if coach.isConfigured { connectedHeader - consentBar - // 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 } - systemPromptBar transcript if let error = coach.errorText, !error.isEmpty { errorBanner(error) + // A rejected key is the one failure the wearer can act on from here, and the + // message already tells them to: "Check the key and the provider you selected". + // Until this, the screen offered nowhere to check it. Rendered INSIDE the error + // branch, never on its own flag, so it cannot outlive the message justifying it. + if coach.keyRejected { keyRepairPanel } + } + // 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 } - 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 } } + // macOS only. On iOS these two live in `connectionMenu` instead, because this bar is hidden for + // a primary tab root and VISIBLE in the pillar sheet, so leaving them here would render nothing + // on the Coach tab and a duplicate of the menu in the sheet. One control per platform, reachable + // in both of iOS's presentations. The `#if` sits on the CHAIN rather than inside the builder: + // `ToolbarContentBuilder` is not relied on to accept an empty body, and the one other + // conditional toolbar here (CoupledView) always yields an item on both platforms. (#2206) + #if os(macOS) .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,127 +124,68 @@ struct CoachView: View { } } } - .task(id: coach.dataConsent) { await coach.startBriefIfNeeded() } - } - - /// Explicit, revocable permission for the coach to read & send the user's data. Off by default. - /// A frosted Charge-tinted card so it reads as part of the green Coach world, not a flat panel. - private var consentBar: some View { - NoopCard(padding: 14, tint: StrandPalette.chargeColor) { - HStack(spacing: 10) { - Image(systemName: coach.dataConsent ? "lock.open.fill" : "lock.fill") - .foregroundStyle(coach.dataConsent ? StrandPalette.accent : StrandPalette.textTertiary) - .accessibilityHidden(true) - VStack(alignment: .leading, spacing: 1) { - Text("Let the coach use my data") - .font(StrandFont.subhead).foregroundStyle(StrandPalette.textPrimary) - Text(coach.dataConsent - ? "On: your charge, rest, HRV and workouts are shared with the provider for tailored coaching." - : "Off: the coach answers generally and sends none of your metrics.") - .font(StrandFont.footnote).foregroundStyle(StrandPalette.textTertiary) - .fixedSize(horizontal: false, vertical: true) - } - Spacer(minLength: 8) - Toggle("", isOn: $coach.dataConsent) - .labelsHidden().toggleStyle(.switch).tint(StrandPalette.accent) - .accessibilityLabel("Let the coach use my data") - } + #endif + // #2243: coach settings. `repo` rides along because the scaffold's environment is not + // inherited by a sheet's own view tree. + .sheet(isPresented: $showSettings) { + CoachSettingsView() + .environmentObject(coach) + .environmentObject(repo) } - } - - /// The v5 second opt-in: include a SUMMARY of the new on-device signals (strongest n-of-1 patterns + - /// Lab Book markers). Summary-only, never raw readings, so the no-raw-egress posture holds. - private var onDeviceSignalsBar: some View { - NoopCard(padding: 14, tint: StrandPalette.chargeColor) { - HStack(spacing: 10) { - Image(systemName: coach.includeOnDeviceSignals ? "checklist.checked" : "checklist") - .foregroundStyle(coach.includeOnDeviceSignals ? StrandPalette.accent : StrandPalette.textTertiary) - .accessibilityHidden(true) - VStack(alignment: .leading, spacing: 1) { - Text("Also share my patterns & Lab Book") - .font(StrandFont.subhead).foregroundStyle(StrandPalette.textPrimary) - Text(coach.includeOnDeviceSignals - ? "On: a short summary of your strongest patterns and logged health numbers is added. Summaries only, never raw readings." - : "Off: only your core metrics are shared, not your patterns or Lab Book.") - .font(StrandFont.footnote).foregroundStyle(StrandPalette.textTertiary) - .fixedSize(horizontal: false, vertical: true) - } - Spacer(minLength: 8) - Toggle("", isOn: $coach.includeOnDeviceSignals) - .labelsHidden().toggleStyle(.switch).tint(StrandPalette.accent) - .accessibilityLabel("Also share my patterns and Lab Book with the coach") + .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() + // Gated on the transcript BEFORE consuming. `consumeStoredBrief()` clears the unconsumed + // flag, and `surfaceScheduledBrief` then drops the text if a transcript exists, so a brief + // that arrived on a day with a conversation already open was consumed and thrown away, gone + // for good. Android checked first and so only ever failed to SHOW it (#2087). + if coach.messages.isEmpty, let stored = CoachBriefScheduler.consumeStoredBrief() { + coach.surfaceScheduledBrief(stored) } + CoachBriefScheduler.activateIfEnabled { await coach.generateBrief() } + await coach.startBriefIfNeeded() } - } - - /// 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. - private var systemPromptBar: some View { - NoopCard(padding: 14, tint: StrandPalette.chargeColor) { - VStack(alignment: .leading, spacing: promptExpanded ? 10 : 0) { - Button { - withAnimation(StrandMotion.fade) { - promptExpanded.toggle() - if promptExpanded { promptDraft = coach.customSystemPrompt } - } - } label: { - HStack(spacing: 10) { - Image(systemName: "text.alignleft") - .foregroundStyle(coach.hasCustomSystemPrompt ? StrandPalette.accent : StrandPalette.textTertiary) - .accessibilityHidden(true) - VStack(alignment: .leading, spacing: 1) { - Text("Coach instructions") - .font(StrandFont.subhead).foregroundStyle(StrandPalette.textPrimary) - Text(coach.hasCustomSystemPrompt - ? "Customised. Your edited instructions frame every reply." - : "Edit how the coach thinks and talks. Takes effect on your next message.") - .font(StrandFont.footnote).foregroundStyle(StrandPalette.textTertiary) - .fixedSize(horizontal: false, vertical: true) - } - Spacer(minLength: 8) - Image(systemName: promptExpanded ? "chevron.up" : "chevron.down") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(StrandPalette.textTertiary) - .accessibilityHidden(true) - } - } - .buttonStyle(.plain) - .accessibilityLabel(promptExpanded ? "Collapse coach instructions" : "Edit coach instructions") - - if promptExpanded { - TextEditor(text: $promptDraft) - .font(StrandFont.body) - .foregroundStyle(StrandPalette.textPrimary) - .scrollContentBackground(.hidden) - .frame(minHeight: 140, maxHeight: 240) - .padding(8) - .background(StrandPalette.surfaceInset, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) - .overlay(RoundedRectangle(cornerRadius: 10, style: .continuous) - .strokeBorder(StrandPalette.hairline, lineWidth: 1)) - .onChangeCompat(of: promptDraft) { newValue in - coach.customSystemPrompt = newValue - } - .accessibilityLabel("Coach instructions editor") - - HStack { - Spacer() - Button { - coach.resetSystemPrompt() - promptDraft = coach.customSystemPrompt - } label: { - Label("Reset to default", systemImage: "arrow.uturn.backward") - .font(StrandFont.footnote) - .labelStyle(.titleAndIcon) - } - .buttonStyle(.plain) - .foregroundStyle(StrandPalette.accent) - .disabled(!coach.hasCustomSystemPrompt) - .accessibilityLabel("Reset coach instructions to default") - } - } + // #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() } + } } // MARK: - Setup (no key yet) @@ -305,6 +290,15 @@ struct CoachView: View { Spacer() } + // Whatever the last attempt from THIS card ran into. The setup card had no error line + // at all, so every way it can fail before a key is committed failed silently: a Refresh + // the provider turned away, a Connect to a server that wants auth. The wearer saw a + // button do nothing. No repair affordance beside it, unlike the chat: the key field is + // already on screen, which is the whole point of the card. + if let error = coach.errorText, !error.isEmpty { + errorBanner(error) + } + Divider().overlay(StrandPalette.hairline) privacyFootnote } @@ -400,8 +394,75 @@ struct CoachView: View { if coach.sending { StatePill("Thinking", tone: .accent, pulsing: true) } + // #2243: the way through to what used to be stacked under this header. + Button { + showSettings = true + } label: { + Image(systemName: "slider.horizontal.3") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(StrandPalette.textSecondary) + } + .buttonStyle(.plain) + .accessibilityLabel(String(localized: "Coach settings")) + #if os(iOS) + connectionMenu + #endif + } + } + + #if os(iOS) + /// #2206: the same two actions the toolbar above carries, drawn where iPhone can reach them. + /// + /// `RootTabView.tab(...)` wraps every primary tab root in a NavigationStack and applies + /// `.toolbar(.hidden, for: .navigationBar)`, because each screen draws its own in-content header. + /// So Clear conversation and Disconnect were being placed into a bar this platform never shows, and + /// rendered nowhere. Disconnect is the ONLY route back to the setup card, which is the only place + /// an API key can be typed: `isConfigured` gates that card away the moment a key is saved. The + /// result was a key that could be set once and then never changed, with reinstalling the app the + /// only way out, which on an offline-first app costs the wearer their entire history. + /// + /// macOS keeps the toolbar and does not get this, so its behaviour is untouched. On iOS the toolbar + /// route is withdrawn rather than kept alongside: CoachView is presented twice on this platform, as + /// a primary tab whose bar is hidden and as a pillar sheet whose bar is NOT (it draws a Done button + /// and only hides the bar's background). Keeping both would render nothing on the tab and two of + /// everything in the sheet. One control, reachable in both presentations. + /// + /// A menu rather than a bare button because it needs two taps to reach a destructive action, + /// matching the protection the toolbar's separation gives, and because both actions belong to the + /// same connection. + /// + /// Worth knowing before changing `disconnect()`: neither `hasKey` nor `isConfigured` is published, + /// since `hasKey` reads the Keychain on each evaluation. The setup card reappears because + /// `disconnect()` ALSO assigns the published `messages`, which is what re-evaluates the body. A + /// future disconnect that stopped clearing the transcript would clear the key and leave this screen + /// showing a chat for a connection that no longer exists. macOS has depended on the same coupling + /// since its toolbar button existed, so this is a latent edge being written down, not a new one. + private var connectionMenu: some View { + Menu { + Button { + showClearConfirm = true + } label: { + Label("Clear conversation", systemImage: "trash") + } + .disabled(coach.messages.isEmpty) + Button(role: .destructive) { + coach.disconnect() + keyDraft = "" + } label: { + Label("Disconnect", systemImage: "gearshape") + } + } label: { + // Same affordance DevicesView uses for its per-device menu, headline size included. The + // size is not decoration here: the report this came from was that the option could not be + // FOUND, so a control that matches the one the wearer has already learned, at a size worth + // aiming at, is doing part of the work. + Image(systemName: "ellipsis.circle") + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.textSecondary) } + .accessibilityLabel("Connection") } + #endif private var transcript: some View { StrandCard(padding: 16) { @@ -478,6 +539,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 +549,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) @@ -526,6 +609,48 @@ struct CoachView: View { .accessibilityLabel("Error: \(message)") } + /// The inline "your key was turned away, here is the field" repair, shown under a rejection. + /// + /// Saving goes through `setKey`, which replaces the stored key and leaves the transcript alone. The + /// existing route was the Disconnect button, which also wipes the conversation and un-commits a + /// custom provider: far more than correcting a typo asks for, and named for an outcome the wearer + /// is trying to avoid. Twin of the Kotlin editor in `CoachChat`. + private var keyRepairPanel: some View { + StrandCard(padding: 14) { + VStack(alignment: .leading, spacing: 8) { + Text("Paste the corrected key. Your conversation is kept.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + SecureField("Paste your \(coach.provider.displayName) API key", text: $keyFix) + .textFieldStyle(.plain) + .font(StrandFont.body) + .foregroundStyle(StrandPalette.textPrimary) + .padding(.horizontal, 12) + .padding(.vertical, 9) + .background(StrandPalette.surfaceInset, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(StrandPalette.hairline, lineWidth: 1)) + .onSubmit(saveRepairedKey) + .accessibilityLabel("Corrected API key") + HStack { + NoopButton("Update key", systemImage: "key.fill", kind: .primary, action: saveRepairedKey) + .disabled(keyFix.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + Spacer() + } + } + } + } + + /// Store the corrected key and drop it from view state. `setKey` clears the error and the rejection + /// flag, which is what closes this panel. + private func saveRepairedKey() { + let trimmed = keyFix.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + coach.setKey(trimmed) + keyFix = "" + } + private var suggestionChips: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { @@ -552,6 +677,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 +748,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 +782,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 +900,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..2560118c7e 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) @@ -417,8 +408,11 @@ struct CoupledView: View { // Left: the SLEEP PERFORMANCE % as the liquid vessel (Rest world), with the score // counting up over the fluid. Empty vessel when there's no scored performance. ZStack { + // heroCard opts its vessel out of hit testing so the Button owns the tap; + // this ring keeps its splash instead and lets the NavigationLink run alongside. LiquidVessel(value: sleepPerformance.map { max(0, min(1, $0 / 100)) }, - tint: StrandPalette.restColor, animated: false) + tint: StrandPalette.restColor, animated: false, + tapPassesThrough: true) .frame(width: 88, height: 88) if let p = sleepPerformance { CountUpText(value: p, @@ -540,21 +534,9 @@ struct CoupledView: View { /// sheet's inline confidence fold re-folded the full `repo.days` history four times per body eval of /// the open sheet; one call now folds each series exactly once, guards before any fold. private func chargeBreakdown() -> (drivers: [ChargeDriver], confidence: ScoreConfidence)? { - guard let row = breakdownRow, let hrv = row.avgHrv, let rhr = row.restingHr else { return nil } - let hrvBase = Baselines.foldHistory(repo.days.map(\.avgHrv), cfg: Baselines.hrvCfg) - guard hrvBase.usable else { return nil } - let rhrBase = Baselines.foldHistory(repo.days.map { $0.restingHr.map(Double.init) }, - cfg: Baselines.restingHRCfg) - let respBase = Baselines.foldHistory(repo.days.map(\.respRateBpm), cfg: Baselines.respCfg) - // Rest-quality term = the same sleep performance the sleep row shows, ÷100 (AnalyticsEngine's form). - let sleepPerf = sleepPerformance.map { $0 / 100.0 } - let drivers = RecoveryScorer.chargeDrivers( - hrv: hrv, rhr: Double(rhr), resp: row.respRateBpm, - hrvBaseline: hrvBase, - rhrBaseline: rhrBase.usable ? rhrBase : nil, - respBaseline: respBase.usable ? respBase : nil, - sleepPerf: sleepPerf, skinTempDev: row.skinTempDevC) - return (drivers, ScoreConfidence.charge(recovery: row.recovery, hrvBaseline: hrvBase)) + guard let row = breakdownRow else { return nil } + return ChargeBreakdownWiring.breakdown(days: repo.days, row: row, sleepPerfPercent: sleepPerformance, + hrvBaselineEpoch: Baselines.hrvBaselineEpoch()) } @ViewBuilder @@ -705,12 +687,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..758da30782 100644 --- a/Strand/Screens/DashboardCards.swift +++ b/Strand/Screens/DashboardCards.swift @@ -22,6 +22,7 @@ enum DashboardCard: String, CaseIterable, Identifiable { case restingHr case respiratory case steps + case stepsAverage30 case stress case fitnessAge case vo2max @@ -37,6 +38,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 @@ -48,6 +56,7 @@ enum DashboardCard: String, CaseIterable, Identifiable { case .restingHr: return String(localized: "Resting HR") case .respiratory: return String(localized: "Respiratory") case .steps: return String(localized: "Steps") + case .stepsAverage30: return String(localized: "30-day step average") case .stress: return String(localized: "Stress") case .fitnessAge: return String(localized: "Fitness Age") case .vo2max: return String(localized: "VO₂ Max") @@ -58,6 +67,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") } } @@ -69,6 +79,7 @@ enum DashboardCard: String, CaseIterable, Identifiable { case .restingHr: return String(localized: "Resting heart rate") case .respiratory: return String(localized: "Breaths per minute") case .steps: return String(localized: "Today") + case .stepsAverage30: return String(localized: "Rolling average over the last 30 days") case .stress: return String(localized: "Autonomic load") case .fitnessAge: return String(localized: "Updated weekly") case .vo2max: return String(localized: "Estimated, updated weekly") @@ -79,6 +90,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.") } } @@ -88,7 +102,7 @@ enum DashboardCard: String, CaseIterable, Identifiable { case .hrv: return "waveform.path.ecg" case .restingHr: return "heart.fill" case .respiratory: return "lungs.fill" - case .steps: return "figure.walk" + case .steps, .stepsAverage30: return "figure.walk" case .stress: return "bolt.heart" case .fitnessAge: return "figure.run" case .vo2max: return "lungs" @@ -99,6 +113,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" } } @@ -108,7 +123,7 @@ enum DashboardCard: String, CaseIterable, Identifiable { case .hrv: return "ms" case .restingHr: return "bpm" case .respiratory: return "rpm" - case .steps: return "" + case .steps, .stepsAverage30: return "" case .stress: return "" case .fitnessAge: return "yrs" case .vo2max: return "" // the estimated VO₂max number alone; ml/kg/min is too long for a tile @@ -119,6 +134,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 } } @@ -140,6 +156,17 @@ enum DashboardCardPrefs { /// UserDefaults key, a JSON array of `DashboardCard` ids in display order. static let selectionKey = "today.dashboardCards" + /// Preserve a prior explicit opt-in once, then let Your Cards own the preference. + static func migrateLegacyStepsAverage(defaults: UserDefaults = .standard) { + let legacy = (defaults.string(forKey: "today.keyMetrics") ?? "") + .split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + guard legacy.contains("stepsAverage30") else { return } + var enabled = decodeEnabled(defaults.string(forKey: selectionKey) ?? "") + if !enabled.contains(.stepsAverage30) { enabled.append(.stepsAverage30) } + defaults.set(encode(enabled), forKey: selectionKey) + defaults.set(legacy.filter { $0 != "stepsAverage30" }.joined(separator: ","), forKey: "today.keyMetrics") + } + /// Encode an ordered list of enabled cards into the stored JSON string. Falls back to a comma-joined /// string if JSON encoding ever fails (it won't for [String]), so the value is always decodable. static func encode(_ cards: [DashboardCard]) -> String { diff --git a/Strand/Screens/DataSourcesView.swift b/Strand/Screens/DataSourcesView.swift index e6ff2390d9..08dc40d5f2 100644 --- a/Strand/Screens/DataSourcesView.swift +++ b/Strand/Screens/DataSourcesView.swift @@ -55,6 +55,7 @@ struct DataSourcesView: View { // LOCAL Bluetooth only — nothing leaves the device. The toggle is persisted; the broadcaster is owned // here (a pure consumer of LiveState, isolated from the WHOOP/central path). @AppStorage(HrBroadcaster.defaultsKey) private var broadcastHrEnabled = false + @AppStorage(PuffinExperiment.broadcastHrKey) private var strapBroadcastHrEnabled = false // The broadcaster's diagnostic sink forwards to THIS box, which `onAppear` points at the screen's // `live`. A reference box lets the `@StateObject` capture a stable target at init even though the @@ -914,15 +915,38 @@ struct DataSourcesView: View { // Three-state, consistent with the Live screen's connection pill — a connected-but- // not-yet-streaming strap (e.g. an experimental WHOOP 5/MG link) no longer reads as // "Not connected" on one screen and "Connected" on another (issue #8). - let (tone, label): (StrandTone, LocalizedStringKey) = - live.bonded ? (.positive, "Bonded, streaming.") - : live.connected ? (.warning, "Connected.") - : (.critical, "Not connected. Open Live to pair.") + // Written as statements rather than a ternary chain: five arms of (StrandTone, + // LocalizedStringKey) tuples is the shape that pushes this expression past the iOS type-check + // budget, and it fails in CI rather than here. + let tone: StrandTone + let label: LocalizedStringKey + if live.encryptedBond { + tone = .positive; label = "Bonded, streaming." + } else if live.bonded { + tone = .warning; label = "Live HR (not fully paired)" + } else if live.connected { + tone = .warning; label = "Connected." + } else { + tone = .critical; label = "Not connected. Open Live to pair." + } return card(title: String(localized: "WHOOP Strap (Live BLE)"), icon: "antenna.radiowaves.left.and.right", tint: StrandPalette.accent, status: StatePill(label, tone: tone, pulsing: live.connected && !live.bonded), subtitle: String(localized: "Pairs directly with your strap over Bluetooth: no WHOOP app, no cloud.")) { - EmptyView() + Toggle(isOn: $strapBroadcastHrEnabled) { + VStack(alignment: .leading, spacing: 2) { + Text("Broadcast heart rate from the strap") + .font(StrandFont.subhead) + .foregroundStyle(StrandPalette.textPrimary) + Text("Broadcasts the strap's own live heart rate over Bluetooth.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + } + } + .toggleStyle(.switch) + .tint(StrandPalette.accent) + .accessibilityLabel("Broadcast heart rate from the strap") + .onChangeCompat(of: strapBroadcastHrEnabled) { model.ble.setBroadcastHr($0) } } } diff --git a/Strand/Screens/DevicesView.swift b/Strand/Screens/DevicesView.swift index 15b7944314..12743f287e 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) } @@ -180,9 +188,16 @@ private struct DevicesContent: View { pairingHint: device.status == .active ? live.pairingHint : nil, // Reboot in flight + link currently down → "Reconnecting…" (#166). isReconnecting: device.status == .active && live.rebootInProgress && !live.connected, - // The live battery belongs to whichever device is ACTIVE + connected (the WHOOP, a - // generic strap, or an FTMS machine all funnel into live.batteryPct). nil otherwise. - liveBatteryPct: (device.status == .active && live.connected) ? live.batteryPct.map { Int($0.rounded()) } : nil, + // The live battery belongs to whichever device is ACTIVE + connected. A WHOOP, a + // generic strap and an FTMS machine all funnel into live.batteryPct, but an Oura ring + // does NOT: it reports its own charge, so an active ring row used to draw the strap's + // stale number under the ring's name (#2075). Asked PER ROW rather than of the active + // device, which is the stronger question and the one this loop can actually answer. + liveBatteryPct: (device.status == .active && live.connected) + ? LiveConsoleReadout.batteryPercent( + activeIsWhoop: SourceCoordinator.isWhoop(device), + whoopPct: live.batteryPct, ringPct: live.ouraBatteryPct) + : nil, liveBatteryMv: (device.status == .active && live.connected) ? live.batteryMv : nil, // Firmware version for the ACTIVE strap. It's a STABLE property (NOOP can't change a // strap's firmware), so prefer the live handshake value but fall back to the last-known @@ -193,10 +208,18 @@ private struct DevicesContent: View { // handshake, so a non-WHOOP active device (Oura) must NOT inherit it. Single last-connected- // strap key, so a not-yet-connected active strap can briefly show the other strap's build on // a multi-WHOOP install until it republishes. Twin of Android. - liveFirmware: device.status == .active - ? (live.strapFirmware - ?? (SourceCoordinator.isWhoop(device) ? UserDefaults.standard.string(forKey: "noop.lastFirmware") : nil)) - : nil, + // #1633 follow-up: resolve against THIS device, never the last strap to connect. The old + // fallback read one global key, so with two straps paired a 5/MG reported the 4.0's firmware. + // The legacy key is honoured only when a single device is paired, where it cannot belong to + // anything else. + liveFirmware: FirmwareAttribution.resolve( + live: device.status == .active ? live.strapFirmware : nil, + perDevice: SourceCoordinator.isWhoop(device) + ? FirmwareAttribution.prefKey(peripheralId: device.peripheralId) + .flatMap { UserDefaults.standard.string(forKey: $0) } : nil, + legacyGlobal: SourceCoordinator.isWhoop(device) + ? UserDefaults.standard.string(forKey: "noop.lastFirmware") : nil, + pairedCount: registry.devices.count), // Historical record layout (v24/v25 on WHOOP 4.0) observed from this connection's // backfill. Distinct from the strap firmware build shown as FW. liveHistoryLayout: (device.status == .active && live.connected) ? live.strapRange?.firmwareLayout : nil, @@ -618,7 +641,7 @@ private struct DeviceSyncStatusCard: View { var body: some View { switch SyncChipState.resolve(live: live) { - case .syncing(let chunks): + case .syncing(let chunks, _): statusCard( systemImage: "arrow.triangle.2.circlepath", detail: chunks > 0 diff --git a/Strand/Screens/FullDayChartView.swift b/Strand/Screens/FullDayChartView.swift index e7b033fc5a..85b567ef17 100644 --- a/Strand/Screens/FullDayChartView.swift +++ b/Strand/Screens/FullDayChartView.swift @@ -45,9 +45,13 @@ struct FullDayChartView: View { let system = UnitSystem(rawValue: unitSystemRaw) ?? .metric return UnitPrefs.resolveTemperature(system: system, override: temperatureRaw) } - /// "Owned only" hides empty non-strap rows; "All sources" surfaces the disclosure (#574). The strap is - /// always the owned source, so this currently scopes the empty-state copy rather than swapping reads. + /// "Owned only" hides empty non-strap rows; "All sources" surfaces the disclosure (#574). The active + /// device is always the owned source, so this currently scopes the empty-state copy rather than swapping reads. @State private var ownedOnly = true + /// What the source row calls the owned source: the active device's registry display name (nickname, + /// else "Brand Model"), so an active Oura ring reads "Oura …" over the ring's own series instead of + /// the strap label this row shipped with. `nil` (no registry row) keeps the legacy "My WHOOP". + @State private var sourceName: String? = nil /// #623: true when the current SpO2/respiration metric is genuinely unsupported on the active strap — /// a 5.0-family strap that has NEVER produced it (4.0-only wire signals) — vs merely an empty window. @State private var metricUnsupported = false @@ -62,8 +66,6 @@ struct FullDayChartView: View { /// The visible window the chart's gestures mutate. nil → full day (the chart falls back to `dayBounds`). @State private var zoomDomain: ClosedRange? = nil @State private var loading = true - /// Bumped on every settled zoom/metric change so the re-read task re-runs at the new resolution. - @State private var reloadTick = 0 /// The full clamp the zoom window can never escape — the selected calendar day. private var dayBounds: ClosedRange { @@ -93,6 +95,7 @@ struct FullDayChartView: View { .task(id: annotationKey) { await reloadAnnotations() } .task { await landOnLatestDayIfNeeded() } .task(id: metric) { await resolveMetricUnsupported() } // #623 + .task(id: repo.deviceId) { sourceName = repo.activeDeviceDisplayName() } } /// #623: a SpO2/respiration track is "unsupported on this strap" only when it's a 5.0-family strap that @@ -139,11 +142,13 @@ struct FullDayChartView: View { Image(systemName: "dot.radiowaves.left.and.right") .font(StrandFont.footnote.weight(.medium)) .foregroundStyle(StrandPalette.textTertiary) - Text("My WHOOP") - .font(StrandFont.footnote) - .foregroundStyle(StrandPalette.textSecondary) + Group { + if let sourceName { Text(verbatim: sourceName) } else { Text("My WHOOP") } + } + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) Spacer() - // #574 — owned-source scope. The strap is the owned source; "All sources" reveals the honest + // #574 — owned-source scope. The active device is the owned source; "All sources" reveals the honest // disclosure that other sources' raw per-second streams aren't offloaded on-device. SegmentedPillControl([true, false], selection: $ownedOnly) { $0 ? String(localized: "Owned") : String(localized: "All") } .fixedSize() 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..6f42c24495 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.") @@ -395,11 +404,19 @@ struct LiveHRSample: Identifiable, Equatable { /// axis-less Sparkline on this hero (#198) — an iPhone user has no hover, so the visible /// clock axis is the fix. Built on Swift Charts; the strict rolling 3-minute window comes /// from the caller's 1 Hz-sampled, 180-capped buffer (HeartRateSection.hrHistory, #941). +/// +/// Hover/tooltip (previously missing): reuses the same `CrosshairRule`/`HighlightDot`/`PositionedTooltip`/ +/// `ChartTooltip` components `TrendChart`'s `chartOverlay` uses — no new mechanism. No downsampling here: +/// the buffer is already capped at 180 samples (#941) and this IS the live, in-progress trace, so every +/// sample stays significant. private struct LiveTimeChart: View { var samples: [LiveHRSample] /// The gradient the line/area is stroked with (the current HR-zone band). var gradient: Gradient + /// The x-position the cursor is hovering, in chart-local coordinates. + @State private var hoverX: CGFloat? = nil + /// Auto-fitted y bounds with a little headroom so the trace never kisses the edges. private var yDomain: ClosedRange { let values = samples.map(\.bpm) @@ -419,6 +436,21 @@ private struct LiveTimeChart: View { StrandPalette.sample(stops: gradient.stops, at: 0.85) } + /// The sample nearest a given chart-local x, matching `TrendChart.nearestPoint`. + private func nearestSample(toX x: CGFloat, proxy: ChartProxy, plot: CGRect) -> LiveHRSample? { + guard !samples.isEmpty else { return nil } + let relX = x - plot.minX + guard let date: Date = proxy.value(atX: relX) else { return nil } + return samples.min(by: { abs($0.date.timeIntervalSince(date)) < abs($1.date.timeIntervalSince(date)) }) + } + + // Map a bpm value onto the unit interval for gradient sampling, same idiom as `TrendChart.unit`. + private func unit(_ value: Double) -> Double { + let lo = yDomain.lowerBound, hi = yDomain.upperBound + guard hi > lo else { return 0 } + return min(max((value - lo) / (hi - lo), 0), 1) + } + var body: some View { Chart(samples) { s in AreaMark( @@ -460,6 +492,47 @@ private struct LiveTimeChart: View { .font(StrandFont.footnote) } } + .chartOverlay { proxy in + GeometryReader { geo in + let plot = proxy.plotRectCompat(in: geo) + ZStack(alignment: .topLeading) { + if let hx = hoverX, + let s = nearestSample(toX: hx, proxy: proxy, plot: plot), + let px = proxy.position(forX: s.date), + let py = proxy.position(forY: s.bpm) { + let cx = px + plot.minX + let cy = py + plot.minY + let color = StrandPalette.sample(stops: gradient.stops, at: unit(s.bpm)) + CrosshairRule(x: cx, height: geo.size.height) + HighlightDot(color: color).position(x: cx, y: cy) + PositionedTooltip( + anchor: CGPoint(x: cx, y: cy), + container: geo.size, + tooltip: ChartTooltip( + value: String(localized: "\(Int(s.bpm.rounded())) bpm"), + label: s.date.formatted(.dateTime.hour().minute().second()), + accent: color + ) + ) + } + } + .animation(StrandMotion.fade, value: hoverX) + .frame(width: geo.size.width, height: geo.size.height, alignment: .topLeading) + .contentShape(Rectangle()) + .onContinuousHover(coordinateSpace: .local) { phase in + // Non-animating transaction: otherwise crossing the plot edge re-runs the line's + // draw-on animation and flickers the curve (mirrors TrendChart #104). + var tx = Transaction() + tx.disablesAnimations = true + withTransaction(tx) { + switch phase { + case .active(let location): hoverX = location.x + case .ended: hoverX = nil + } + } + } + } + } .clipped() } } @@ -771,7 +844,21 @@ private struct FitnessAgeSection: View { /// The younger/older-than-your-age subtitle as whole-phrase variants per count and direction, so /// translators see complete sentences (never a stitched plural or direction fragment). - private func ageDeltaLine(years: Int, younger: Bool) -> String { + private func ageDeltaLine(years: Int, younger: Bool, bound: String = "") -> String { + // A bounded reading can only be stated as a direction, never as a distance: the true age is at + // or beyond the bound, so a plain "N years younger" would present a floor as a measurement. + // "At least N" is the same number said truthfully, and where there is no safe distance to give + // (a chronological age at or inside the bound) the line states the bound on its own. + if bound == "≤" { + if younger && years == 1 { return String(localized: "At least 1 year younger than your age") } + if younger && years > 1 { return String(localized: "At least \(years) years younger than your age") } + return String(localized: "\(Int(FitnessAgeEngine.minAge)) or younger") + } + if bound == "≥" { + if !younger && years == 1 { return String(localized: "At least 1 year older than your age") } + if !younger && years > 1 { return String(localized: "At least \(years) years older than your age") } + return String(localized: "\(Int(FitnessAgeEngine.maxAge)) or older") + } if years == 0 { return String(localized: "About the same as your age") } switch (younger, years == 1) { case (true, true): return String(localized: "1 year younger than your age") @@ -809,6 +896,7 @@ private struct FitnessAgeSection: View { private func heroCard(age: Double) -> some View { let shown = Int(age.rounded()) + let bound = fitnessAgeBoundSymbol(age) let delta = Double(profile.age) - age // +ve = fitness age younger than chronological let years = Int(abs(delta).rounded()) let younger = delta >= 0 @@ -818,18 +906,20 @@ private struct FitnessAgeSection: View { HStack(alignment: .center, spacing: NoopMetrics.space5) { // The signature liquid gauge anchors the hero: a vessel tinted to the Charge world, // filled by how young the fitness age reads (younger = fuller), with the age counting - // up over it. Same HeroScoreCell idiom as Today; taps fall through to the trend button. + // up over it. Same HeroScoreCell idiom as Today. tapPassesThrough is what actually makes + // taps reach the trend Button: a plain splash gesture on the vessel swallows them. ZStack { - LiquidVessel(value: fitnessAgeFraction(age), tint: StrandPalette.chargeColor, animated: true) + LiquidVessel(value: fitnessAgeFraction(age), tint: StrandPalette.chargeColor, + animated: true, tapPassesThrough: true) .frame(width: 96, height: 96) - CountUpNumber(value: Double(shown), font: StrandFont.rounded(30)) + CountUpNumber(value: Double(shown), font: StrandFont.rounded(30), prefix: bound) .foregroundStyle(.white) .shadow(color: .black.opacity(0.5), radius: 6, y: 1) .allowsHitTesting(false) } VStack(alignment: .leading, spacing: NoopMetrics.space1) { Text("Fitness Age").strandOverline() - Text(ageDeltaLine(years: years, younger: younger)) + Text(ageDeltaLine(years: years, younger: younger, bound: bound)) .font(StrandFont.subhead) .foregroundStyle(younger ? StrandPalette.statusPositive : StrandPalette.statusWarning) } @@ -857,7 +947,24 @@ private struct FitnessAgeSection: View { } .buttonStyle(LiquidPressStyle()) .accessibilityElement(children: .ignore) - .accessibilityLabel("Fitness Age \(shown), \(ageDeltaLine(years: years, younger: younger)). Tap to see the trend.") + // The spoken label carries the bound too, so a screen reader is not told a floored reading is exact. + .accessibilityLabel("Fitness Age \(bound)\(shown), \(ageDeltaLine(years: years, younger: younger, bound: bound)). Tap to see the trend.") + + // At a bound the age has stopped carrying information: every model output past the end of + // the scale banks as the same number, so someone still improving sees nothing move (#2184). + // The VO₂max in the row above is NOT clamped and is the same estimate this age derives from, + // so it keeps resolving where the age cannot. Pointing at it asserts nothing the model cannot + // support, which an extended reporting floor could not manage: two more years of range would + // still sit inside the ±5 band the line below states. + // + // FULL WIDTH, beside that band line, rather than inside the VStack holding the vessel and the + // delta: that column shares an HStack with the VO₂max readout, so a 44-character sentence + // would wrap in half a card and crowd the number it explains. + if !bound.isEmpty, vo2max != nil { + Text("Fitness Age stops here. VO₂max keeps moving.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + } Text("± \(Int(FitnessAgeEngine.displayBandYears)) yr · a fitness comparison, not a biological age") .font(StrandFont.footnote) @@ -1227,6 +1334,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 +1352,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)) @@ -1556,3 +1665,21 @@ struct VitalityDemoScreen: View { } } #endif + +/// The symbol a stored Fitness Age needs when it is sitting on a reporting bound (#2173). +/// +/// `FitnessAgeEngine` clamps to [minAge, maxAge], so every model output below 20 is stored as +/// exactly 20.0 and every output above 80 as exactly 80.0. A reader cannot tell either from a +/// genuine 20 or 80, and the number looks as exact as every other number on the screen, which is +/// what makes a floored reading read like a sync or scoring fault rather than the end of the scale. +/// +/// Decided from the value rather than carried out of the engine, matching the Kotlin twin. The +/// clamp returns the bound constant itself, so equality is exact and needs no tolerance, and +/// deciding here covers the weekly rows already banked, which no flag added today could reach. +/// A reading that is genuinely 20.0 is therefore also called "20 or younger", which is true of it. +/// Saying "<20" would need the unclamped value, and that is gone before anything is stored. +func fitnessAgeBoundSymbol(_ value: Double) -> String { + if value <= FitnessAgeEngine.minAge { return "≤" } + if value >= FitnessAgeEngine.maxAge { return "≥" } + return "" +} diff --git a/Strand/Screens/HostedCards.swift b/Strand/Screens/HostedCards.swift index 284d0c7925..2b56a7ef78 100644 --- a/Strand/Screens/HostedCards.swift +++ b/Strand/Screens/HostedCards.swift @@ -48,6 +48,23 @@ enum HostedCard: String, CaseIterable, Identifiable { /// grid; the Today host gives it a standalone card (`ConsistencyCard`) reading the SAME `consistency` /// metric, so the value can't diverge. case consistency = "sleep.consistency" + /// Stress tab · "Stress through the day" — today's autonomic-load curve (#2040 follow-up). The + /// first card hosted from a tab other than Sleep. Read-only like `stages`: the Stress tab keeps the + /// interactive timeline, and the Today host mirrors only the display. + /// + /// The rawValue rides `.noopbak` under `today.hostedCards`, so it is byte-identical to the Android + /// `HostedCard.STRESS_TODAY`. + case stressToday = "stress.today" + /// Trends tab · "HRV" — the trailing-month HRV trend (#today-hosted-cards). The three Trends-origin + /// cards render the SAME `ChartCard` + `TrendChart` pair the tab draws, from the SAME resolved + /// points, parameterised by which `DailyMetric` field they read. + /// + /// These rawValues ride `.noopbak` and are byte-identical to the Android `HostedCard` ids. + case trendHRV = "trends.hrv" + /// Trends tab · "Resting heart rate" — the trailing-month resting-HR trend. + case trendRestingHR = "trends.restingHr" + /// Trends tab · "Effort" — the trailing-month Effort trend, on the wearer's chosen scale (#268). + case trendEffort = "trends.effort" var id: String { rawValue } @@ -62,6 +79,10 @@ enum HostedCard: String, CaseIterable, Identifiable { case .stages: return String(localized: "Stages") case .hoursVsNeeded: return String(localized: "Hours vs Needed") case .consistency: return String(localized: "Consistency") + case .stressToday: return String(localized: "Stress through the day") + case .trendHRV: return String(localized: "Heart rate variability") + case .trendRestingHR: return String(localized: "Resting heart rate") + case .trendEffort: return String(localized: "Effort") } } @@ -70,6 +91,33 @@ enum HostedCard: String, CaseIterable, Identifiable { var origin: String { switch self { case .sleepMarks, .asleepDuration, .stagesVsTypical, .nightDetail, .sleepDebt, .stages, .hoursVsNeeded, .consistency: return String(localized: "Sleep") + case .stressToday: return String(localized: "Stress") + case .trendHRV, .trendRestingHR, .trendEffort: return String(localized: "Trends") + } + } + + /// Where tapping this card sends you: back to the tab it is a copy of. + /// + /// Declared on the CARD rather than inside the view that draws it, so it can be tested. A mapping + /// that lives as a private method on a `View` is unreachable from any test, and a card quietly + /// routing to the wrong tab is not the kind of thing anyone notices in review. + /// + /// `nil` for `sleepMarks`: it is the tap-to-log card, its buttons ARE its purpose, and wrapping it + /// in a navigation target would put a second meaning behind the same press. + /// + /// Listed rather than defaulted, so a card added later cannot silently inherit "opens Sleep": the + /// compiler asks where the new one goes. + var route: TabRoute? { + switch self { + case .sleepMarks: return nil + case .asleepDuration, .stagesVsTypical, .nightDetail, .sleepDebt, .stages, + .hoursVsNeeded, .consistency: return .sleep + case .stressToday: return .stress + // The METRIC's own detail page rather than the Trends tab: closer to what was tapped, and the + // destination the Charge and Effort key tiles already use. Twin of the Kotlin `Metric` destination. + case .trendHRV: return .metricSourced(key: "hrv", source: "my-whoop") + case .trendRestingHR: return .metricSourced(key: "rhr", source: "my-whoop") + case .trendEffort: return .metricSourced(key: "strain", source: "my-whoop") } } @@ -84,6 +132,10 @@ enum HostedCard: String, CaseIterable, Identifiable { case .stages: return "chart.bar.fill" case .hoursVsNeeded: return "gauge.medium" case .consistency: return "repeat" + case .stressToday: return "chart.xyaxis.line" + case .trendHRV: return "waveform.path.ecg" + case .trendRestingHR: return "heart" + case .trendEffort: return "bolt.fill" } } @@ -91,6 +143,10 @@ enum HostedCard: String, CaseIterable, Identifiable { var customizationTint: Color { switch self { case .sleepMarks, .asleepDuration, .stagesVsTypical, .nightDetail, .sleepDebt, .stages, .hoursVsNeeded, .consistency: return StrandPalette.restColor + case .stressToday: return StrandPalette.stressColor + case .trendHRV: return StrandPalette.metricPurple + case .trendRestingHR: return StrandPalette.metricRose + case .trendEffort: return StrandPalette.effortColor } } diff --git a/Strand/Screens/HostedTrendCard.swift b/Strand/Screens/HostedTrendCard.swift new file mode 100644 index 0000000000..99eef12671 --- /dev/null +++ b/Strand/Screens/HostedTrendCard.swift @@ -0,0 +1,140 @@ +import StrandDesign +import SwiftUI +import WhoopStore + +/// Resolving a metric's trend points from banked days. +/// +/// Extracted from `TrendsView` so the Today host cards resolve EXACTLY as the Trends tab does. It is +/// shared rather than copied on purpose: the widening fallback is the part a wearer with two weeks of +/// history actually depends on, and a second implementation of it would drift the moment either side +/// was tuned. `TrendsView.resolve` calls this too. +enum HostedTrendData { + + /// Day strings are banked as `yyyy-MM-dd` in UTC; parsing them any other way shifts every point. + private static let dayParser: DateFormatter = { + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.timeZone = TimeZone(identifier: "UTC") + f.dateFormat = "yyyy-MM-dd" + return f + }() + + /// The metric's points for the smallest window at or wider than `selected` that holds any data, + /// and the window it settled on. + /// + /// Walks the widening order ONCE, keeping the window's points rather than re-filtering to read them + /// back. Falls back to all history when no window held anything, which matches what the tab shows + /// rather than leaving a new wearer with an empty card. + static func resolve(days: [DailyMetric], + selected: TrendsView.Range, + value: (DailyMetric) -> Double?) -> (points: [TrendPoint], effective: TrendsView.Range) { + for r in selected.widening { + let pts = points(window(days, r), value) + if !pts.isEmpty { return (pts, r) } + } + return (points(window(days, .all), value), .all) + } + + /// The trailing window of `days`, anchored on today's local day exactly as the tab anchors it. + private static func window(_ days: [DailyMetric], _ r: TrendsView.Range) -> [DailyMetric] { + guard let n = r.days, n > 0 else { return days } + let cutoff = Repository.localDayKey( + Calendar.current.date(byAdding: .day, value: -(n - 1), to: Date()) ?? Date()) + return days.filter { $0.day >= cutoff } + } + + /// The plotted y-range: the data's own span with a little air, the fixed fallback when there is no + /// data at all, and a unit either side of a single repeated value so one flat line does not map onto + /// a zero-width range. + /// + /// Shared with the tab for the same reason the windowing is. A card that padded by a different + /// fraction would plot the SAME points at a visibly different height from the chart it mirrors, + /// which is exactly the drift hosting is meant to avoid. + static func valueRange(_ pts: [TrendPoint], fallback: ClosedRange, + pad: Double = 0.12) -> ClosedRange { + let vals = pts.map(\.value) + guard let lo = vals.min(), let hi = vals.max() else { return fallback } + if hi <= lo { return (lo - 1)...(hi + 1) } + let span = hi - lo + return (lo - span * pad)...(hi + span * pad) + } + + private static func points(_ days: [DailyMetric], _ value: (DailyMetric) -> Double?) -> [TrendPoint] { + days.compactMap { d in + guard let v = value(d), let dt = dayParser.date(from: d.day) else { return nil } + return TrendPoint(date: dt, value: v) + } + } +} + +/// One Trends metric trend, rendered as a Today host card (#today-hosted-cards). +/// +/// Draws the same `ChartCard` + `TrendChart` pair the Trends tab draws, from the same resolved points, +/// so the hosted copy cannot become a second chart of the same numbers. +/// +/// What it does NOT carry is the range selector. A home-screen card has nowhere to put one and no place +/// to persist a per-card choice, so each takes a fixed trailing month and keeps the tab's widening +/// fallback for a wearer whose history is shorter than that. The Trends tab stays where the window is +/// chosen. Twin of the Kotlin `TrendHostCard`. +struct HostedTrendCard: View { + let card: HostedCard + let days: [DailyMetric] + /// The wearer's Effort scale, resolved by the host rather than read here: only one of the three + /// cards needs it, and Today already has it for its own tiles. + let effortScale: EffortScale + + var body: some View { + switch card { + case .trendHRV: + chart(title: "Heart rate variability", unit: "ms", + colour: StrandPalette.metricPurple, fallback: 20...120, value: { $0.avgHrv }, + fmt: { "\(Int($0.rounded()))" }) + case .trendRestingHR: + chart(title: "Resting heart rate", unit: "bpm", + colour: StrandPalette.metricRose, fallback: 40...90, value: { $0.restingHr.map(Double.init) }, + fmt: { "\(Int($0.rounded()))" }) + case .trendEffort: + // Plotted on the stored 0-100 axis so the line's shape is scale-independent; only the + // printed numbers follow the Effort-scale toggle, converted inside `fmt`, exactly as the + // Trends tab does it (#268). + chart(title: "Effort", unit: "/ \(UnitFormatter.effortScaleMax(effortScale))", + colour: StrandPalette.effortColor, fallback: 0...100, value: { $0.strain }, + fmt: { UnitFormatter.effortDisplay($0, scale: effortScale) }) + default: + EmptyView() + } + } + + @ViewBuilder + private func chart(title: LocalizedStringKey, unit: String, colour: Color, + fallback: ClosedRange, + value: @escaping (DailyMetric) -> Double?, + fmt: @escaping (Double) -> String) -> some View { + let resolved = HostedTrendData.resolve(days: days, selected: .month, value: value) + let pts = resolved.points + let avg = pts.isEmpty ? nil : pts.map(\.value).reduce(0, +) / Double(pts.count) + // The unit rides with the average. The Trends tab carries it in a footer of min/mean/max, which + // a home-screen card has no room for, so without this the number would appear bare. + let trailing = avg.map { "\(fmt($0)) \(unit)" } + ChartCard(title: title, subtitle: nil, trailing: trailing, + height: NoopMetrics.chartHeight, tint: colour) { + TrendChart(points: pts, + gradient: Gradient(colors: [colour.opacity(0.35), colour]), + valueRange: HostedTrendData.valueRange(pts, fallback: fallback), + showsArea: true, + // Hover OFF. The card sits inside a NavigationLink, so a scrubbing gesture here + // would compete with the tap that opens the metric — the same conflict that + // keeps the tap-to-log card out of the navigation map. The Trends tab keeps the + // scrub; the Today host mirrors only the display, as the hosted Stages card does. + showsHover: false) + } + .accessibilityElement(children: .combine) + // The average goes into the spoken label, not just the visible corner. `children: .combine` + // merges what the card renders, and overriding that with the bare title, as this first did, + // silently drops the one number on the card: VoiceOver announced "Heart rate variability" and + // nothing else. Composed rather than interpolated into a localized string, so it needs no new + // catalog entry for what is a title the app already has plus a formatted value. + .accessibilityLabel(trailing.map { Text(title) + Text(verbatim: ", \($0)") } ?? Text(title)) + } + +} 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/IntelligenceView.swift b/Strand/Screens/IntelligenceView.swift index 8710603758..7c89dc7a2f 100644 --- a/Strand/Screens/IntelligenceView.swift +++ b/Strand/Screens/IntelligenceView.swift @@ -309,7 +309,24 @@ struct IntelligenceView: View { private func stat(_ label: String, _ value: String, _ color: Color) -> some View { VStack(alignment: .leading, spacing: 3) { - Text(label.uppercased()).font(StrandFont.footnote).foregroundStyle(StrandPalette.textTertiary) + // `lineLimit` + `minimumScaleFactor` so a long label shrinks instead of running into its + // neighbour. The five stats share the width as equal columns with NO gap between them + // (`HStack(spacing: 0)` above), so a label wide enough to fill its column touches the next + // one: "CHARGE" and "EFFORT" are the two longest and collide first, while REST / HRV / RHR + // still look spaced. Reported from a device screenshot reading "CHARGEEFFORT". + // + // The VALUE below has carried this protection all along; only the label went without. The + // Android twin's label already sets `maxLines = 1` with `TextOverflow.Ellipsis`, so this was + // an iOS-only gap rather than a shared one. + // + // Scaled rather than truncated, matching how the value directly beneath is treated: "CHARG…" + // reads worse than a slightly smaller "CHARGE". Larger Dynamic Type is where this bites, so + // the floor has to leave room for the longest label at the widest text size. + Text(label.uppercased()) + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .lineLimit(1) + .minimumScaleFactor(0.7) Text(value).font(StrandFont.number(20)).foregroundStyle(color).lineLimit(1).minimumScaleFactor(0.6) } .frame(maxWidth: .infinity, alignment: .leading) diff --git a/Strand/Screens/IntervalTimerView.swift b/Strand/Screens/IntervalTimerView.swift index 0386885b6b..f62d3a9086 100644 --- a/Strand/Screens/IntervalTimerView.swift +++ b/Strand/Screens/IntervalTimerView.swift @@ -86,14 +86,6 @@ struct IntervalTimerView: View { } } - private var phaseTone: StrandTone { - switch phase { - case .work: return .accent - case .rest: return .neutral - case .done: return .positive - } - } - private var isFinished: Bool { phase == .done } // MARK: Body @@ -317,8 +309,24 @@ struct IntervalTimerView: View { private func overviewStat(_ label: String, _ value: String, _ color: Color) -> some View { VStack(alignment: .leading, spacing: 3) { - Text(label.uppercased()).font(StrandFont.footnote).foregroundStyle(StrandPalette.textTertiary) - Text(value).font(StrandFont.number(18)).foregroundStyle(color) + // Same shape, and the same hazard, as IntelligenceView's day-row stat: four equal columns + // with NO gap (`HStack(spacing: 0)` above), so a label wide enough to fill its column runs + // into the next. "REMAINING" is the longest label on either screen at nine characters, and + // the value here is a time string that grows to "1:23:45" on a long session, so BOTH are + // protected rather than just the label. + // + // Not yet reported here; found by sweeping for the shape after the Intelligence card was + // seen rendering "CHARGEEFFORT" on a device. Larger Dynamic Type is where either bites. + Text(label.uppercased()) + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .lineLimit(1) + .minimumScaleFactor(0.7) + Text(value) + .font(StrandFont.number(18)) + .foregroundStyle(color) + .lineLimit(1) + .minimumScaleFactor(0.6) } .frame(maxWidth: .infinity, alignment: .leading) } diff --git a/Strand/Screens/KeyboardDismiss.swift b/Strand/Screens/KeyboardDismiss.swift new file mode 100644 index 0000000000..7bee16e7cc --- /dev/null +++ b/Strand/Screens/KeyboardDismiss.swift @@ -0,0 +1,124 @@ +import SwiftUI +#if os(iOS) +import UIKit +#endif + +// Tap outside a field to put the keyboard away. +// +// The Lift Log's entry fields are numeric, so the keyboard has no return key to dismiss with, and +// the only way out was the "Done" toolbar button — which meant the keyboard sat over half the +// workout sheet until you found it. In a gym that is the difference between glancing at your next +// set and fighting the phone. +// +// A tap on ANOTHER FIELD is not a tap outside. The first version cleared focus from a SwiftUI +// `simultaneousGesture` on the whole screen, which also fired for a tap that landed in a field: the +// tapped field took focus and the gesture took it straight back, so moving from the weight to the +// reps took two taps — one to lose the keyboard, one to get it back (gym session, 16 Sep 2026). +// SwiftUI's tap gesture cannot say what it landed on, so the watcher is a UIKit recognizer that +// reads the touched view and stands aside for any text input. +// +// It never swallows a tap: the recognizer runs alongside everything else, so a tap on a button both +// dismisses the keyboard and does whatever it was aimed at — this screen's buttons advance the session. + +extension View { + /// Clear `focus` when the user taps anywhere in this view that isn't a field. + func dismissesKeyboardOnTap(_ focus: FocusState.Binding) -> some View { + #if os(iOS) + self + .background(TapOutsideFields { focus.wrappedValue = nil }) + // Dragging the sheet also puts it away, which is the gesture most people reach for + // first when a keyboard is covering what they want to read. + .scrollDismissesKeyboard(.interactively) + #else + // macOS has a hardware keyboard and no on-screen one to dismiss. + self + #endif + } +} + +#if os(iOS) +/// Reports taps that land in this screen but not in a text input. +/// +/// The recognizer is attached to the WINDOW, because SwiftUI gives a background view no reliable +/// place in the hierarchy the touches pass through. Two filters keep it to this screen: a touch in a +/// text input is ignored (the tap is moving focus, not ending it), and so is a touch outside the view +/// controller this view belongs to — a sheet presented on top has its own fields and its own watcher. +private struct TapOutsideFields: UIViewRepresentable { + let onTap: () -> Void + + func makeUIView(context: Context) -> Probe { + let probe = Probe() + probe.onTap = onTap + return probe + } + + func updateUIView(_ probe: Probe, context: Context) { + probe.onTap = onTap + } + + static func dismantleUIView(_ probe: Probe, coordinator: ()) { + probe.detach() + } + + final class Probe: UIView, UIGestureRecognizerDelegate { + var onTap: () -> Void = {} + private var recognizer: UITapGestureRecognizer? + + override init(frame: CGRect) { + super.init(frame: frame) + // Present only to find the window; it must not take part in hit-testing itself. + isUserInteractionEnabled = false + } + + required init?(coder: NSCoder) { fatalError("init(coder:) is not used") } + + override func didMoveToWindow() { + super.didMoveToWindow() + detach() + guard let window else { return } + let tap = UITapGestureRecognizer(target: self, action: #selector(tapped)) + tap.cancelsTouchesInView = false + tap.delaysTouchesBegan = false + tap.delaysTouchesEnded = false + tap.delegate = self + window.addGestureRecognizer(tap) + recognizer = tap + } + + func detach() { + if let recognizer { recognizer.view?.removeGestureRecognizer(recognizer) } + recognizer = nil + } + + /// The recognizer lives on the WINDOW, which outlives this view, so it has to be taken off by + /// hand. `dismantleUIView` and `didMoveToWindow` cover the teardowns SwiftUI tells us about; + /// this covers the ones it does not. A recognizer left behind is quiet rather than harmful + /// (`UIGestureRecognizer` holds its target weakly, so nothing fires) but it accumulates one per + /// appearance, and with the Probe gone its delegate is nil too, so the "ignore text inputs" + /// filter that makes it safe is no longer being applied. + deinit { detach() } + + @objc private func tapped() { onTap() } + + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, + shouldReceive touch: UITouch) -> Bool { + guard let touched = touch.view else { return false } + let ancestors = sequence(first: touched, next: { $0.superview }) + if ancestors.contains(where: { $0 is UITextInput }) { return false } + guard let screen = owningController?.view else { return false } + return touched.isDescendant(of: screen) + } + + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer) -> Bool { + true + } + + /// The nearest view controller up the responder chain — the sheet or screen hosting this view. + private var owningController: UIViewController? { + sequence(first: self as UIResponder, next: { $0.next }) + .first { $0 is UIViewController } as? UIViewController + } + } +} +#endif diff --git a/Strand/Screens/LabBookView.swift b/Strand/Screens/LabBookView.swift index cecb5bc9a1..a474a1e734 100644 --- a/Strand/Screens/LabBookView.swift +++ b/Strand/Screens/LabBookView.swift @@ -510,12 +510,27 @@ extension LabMarkerCategory { // MARK: - Shared formatting (decimals from the catalog; UTC day labels) enum LabBookFormat { - /// Format a numeric value with the marker's catalog decimals (default 1 for custom markers). + /// Format a numeric value with the marker's catalog decimals. A custom (non-catalog) marker has no + /// declared precision, so it is shown at its own precision via `plain` rather than a fixed 1 decimal, + /// which rounded plateletcrit 0.27 to "0.3" and urine specific gravity 1.020 to "1.0". static func value(_ v: Double, key: String) -> String { - let decimals = MarkerCatalog.definition(for: key)?.decimals ?? 1 + guard v.isFinite else { return "—" } + guard let decimals = MarkerCatalog.definition(for: key)?.decimals else { return plain(v) } return decimals == 0 ? String(Int(v.rounded())) : String(format: "%.\(decimals)f", v) } + /// Up to 3 decimals with trailing zeros dropped ("0.27", "1.02", "140"), always with a "." separator + /// whatever the device locale (`String(format:)` without a locale is POSIX). A value that rounds to + /// zero prints "0", never "-0"; a non-finite value prints "—". Twin: `LabValueFormat.plain` (Android); + /// both are pinned by the same expected strings in `LabBookFormatTests` / `LabValueFormatTest`. + static func plain(_ v: Double) -> String { + guard v.isFinite else { return "—" } + var s = String(format: "%.3f", v) + while s.hasSuffix("0") { s.removeLast() } + if s.hasSuffix(".") { s.removeLast() } + return s == "-0" ? "0" : s + } + private static let dayFormatter: DateFormatter = { let f = DateFormatter() f.locale = Locale(identifier: "en_US_POSIX") diff --git a/Strand/Screens/LiftExercisePicking.swift b/Strand/Screens/LiftExercisePicking.swift new file mode 100644 index 0000000000..228078e08d --- /dev/null +++ b/Strand/Screens/LiftExercisePicking.swift @@ -0,0 +1,163 @@ +import SwiftUI +import StrandDesign +import WhoopStore + +// What the two exercise pickers share: the program line editor (`LiftProgramItemSheet`) and the running +// session's Add exercise sheet (`LiftSessionExerciseSheet`). The user's own exercise names offered back, +// a name remembered with its muscles, and the muscle classification itself — one copy of each, so the two +// pickers cannot drift about what a name is or which muscles it works. + +/// The user's own exercise names (`liftExercise`), as the pickers read and write them. +enum LiftExerciseVocabulary { + + /// Names matching what has been typed so far, minus an exact match (no point suggesting the thing + /// already in the box), most recently used first as the store orders them. Capped — this is a hint, + /// not a browser. + static func suggestions(_ vocabulary: [LiftExerciseRow], matching typed: String, + limit: Int = 6) -> [LiftExerciseRow] { + let query = typed.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !query.isEmpty else { return Array(vocabulary.prefix(limit)) } + return Array(vocabulary + .filter { $0.name.lowercased().contains(query) && $0.name.lowercased() != query } + .prefix(limit)) + } + + /// Remember `name` with its muscles, so it is offered back next time. `upsertLiftExercises` is keyed + /// on (deviceId, name), so a known name is updated — its muscles, and when it was last used — never + /// duplicated. Throws `WhoopStore.LiftExerciseVocabularyFull` for a NEW name once the vocabulary is + /// full, which the caller explains rather than dropping the name silently. + static func remember(_ name: String, primary: LiftMuscle?, secondaries: [LiftMuscle], + known vocabulary: [LiftExerciseRow], deviceId: String, + in store: WhoopStore) async throws { + let now = Int(Date().timeIntervalSince1970) + let existing = vocabulary.first { $0.name == name } + _ = try await store.upsertLiftExercises([LiftExerciseRow( + id: existing?.id ?? UUID().uuidString, + deviceId: deviceId, + name: name, + primaryMuscle: primary, + secondaryMuscles: secondaries, + createdAt: existing?.createdAt ?? now, + lastUsedTs: now)]) + } + + /// Secondaries in the vocabulary's canonical order rather than `Set` iteration order, so the stored + /// list is stable between saves instead of reshuffling on every edit. + static func ordered(_ secondaries: Set, excluding primary: LiftMuscle?) -> [LiftMuscle] { + LiftMuscle.ordered.filter { secondaries.contains($0) && $0 != primary } + } +} + +/// One remembered exercise as a picker lists it: its name, and the muscles it is known by. +struct LiftExerciseSuggestionLabel: View { + let row: LiftExerciseRow + + var body: some View { + HStack(spacing: 8) { + Image(systemName: "arrow.up.left") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(StrandPalette.textTertiary) + VStack(alignment: .leading, spacing: 1) { + Text(row.name) + .font(StrandFont.body) + .foregroundStyle(StrandPalette.textPrimary) + Text(LiftMuscleSummary.line(primary: row.primaryMuscle, secondaries: row.secondaryMuscles)) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + } + Spacer(minLength: 0) + } + .contentShape(Rectangle()) + } +} + +/// The muscle classification: the primary muscle (a direct set) and the muscles an exercise also works +/// (half a set each). Asked once per exercise and remembered with its name; leaving it unset is allowed — +/// an unclassified exercise still counts toward volume and session load, it simply claims no muscle it +/// was never assigned. +struct LiftMusclePicker: View { + @Binding var primary: LiftMuscle? + @Binding var secondaries: Set + + var body: some View { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + SectionHeader("Muscles", overline: "Counted once per exercise") + NoopCard { + VStack(alignment: .leading, spacing: 14) { + VStack(alignment: .leading, spacing: 6) { + Text("Primary").strandOverline() + Menu { + Button("Not classified") { primary = nil } + ForEach(LiftMuscle.Region.allCases, id: \.self) { region in + Section(region.displayName) { + ForEach(LiftMuscle.inRegion(region), id: \.self) { muscle in + Button(muscle.displayName) { select(primary: muscle) } + } + } + } + } label: { + HStack { + Text(primary?.displayName ?? String(localized: "Not classified")) + .font(StrandFont.body) + .foregroundStyle(primary == nil + ? StrandPalette.textTertiary + : StrandPalette.textPrimary) + Spacer(minLength: 0) + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(StrandPalette.textTertiary) + } + .contentShape(Rectangle()) + } + .accessibilityLabel("Primary muscle") + } + + VStack(alignment: .leading, spacing: 6) { + Text("Also works (counted as half a set)").strandOverline() + LazyVGrid(columns: [GridItem(.adaptive(minimum: 104), spacing: 8)], + alignment: .leading, spacing: 8) { + ForEach(LiftMuscle.allCases, id: \.self) { muscle in + if muscle != primary { + secondaryChip(muscle) + } + } + } + } + + Text("Direct sets count once, indirect sets count as a half. That split is what makes the weekly per-muscle figures mean anything.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } + + private func secondaryChip(_ muscle: LiftMuscle) -> some View { + let on = secondaries.contains(muscle) + return Button { + if on { secondaries.remove(muscle) } else { secondaries.insert(muscle) } + } label: { + Text(muscle.displayName) + .font(StrandFont.caption) + .foregroundStyle(on ? StrandPalette.effortColor : StrandPalette.textSecondary) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(on ? StrandPalette.effortColor.opacity(0.14) : StrandPalette.surfaceRaised) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityAddTraits(on ? [.isSelected] : []) + } + + /// Setting a primary that is also ticked as a secondary drops it from the secondaries: one + /// muscle can never be credited twice for the same set. + private func select(primary muscle: LiftMuscle) { + primary = muscle + secondaries.remove(muscle) + } +} diff --git a/Strand/Screens/LiftLiveReadouts.swift b/Strand/Screens/LiftLiveReadouts.swift new file mode 100644 index 0000000000..8fd085044a --- /dev/null +++ b/Strand/Screens/LiftLiveReadouts.swift @@ -0,0 +1,64 @@ +import SwiftUI +import StrandDesign + +// The two numbers on the session's surfaces that change on their own — the running clock and the live heart +// rate — each as its own small view, so a tick or a heartbeat redraws that one number and nothing around it. +// +// They used to be read by the screens that show them: the whole session sheet and the minimised bar watched +// the session's once-a-second tick and every change the app model published, and the sheet also watched the +// strap's live state, which changes with every log line and every beat. So the sheet — a hundred text fields — +// was redrawn several times a second for as long as it was open, even with NOOP off screen, and iOS killed +// NOOP for background CPU four times in one gym session (21 Sep 2026). + +/// A running clock that ticks by itself while it is shown and costs nothing while it is not: a `TimelineView` +/// redraws this one text each second, aligned to the whole second, and SwiftUI runs a timeline only for a view +/// on screen. `seconds` turns the current unix second into what the clock reads; the format is NOOP's +/// `ActiveWorkoutClock.clock`, the one the Lock Screen's clock also reads as. +struct LiftRunningClock: View { + let seconds: (Int) -> Int + + var body: some View { + TimelineView(.periodic(from: Date(timeIntervalSince1970: floor(Date().timeIntervalSince1970)), by: 1)) { + context in + Text(ActiveWorkoutClock.clock(seconds(Int(context.date.timeIntervalSince1970)))) + .monospacedDigit() + } + } +} + +/// The live heart rate: the smoothed, spike-filtered `AppModel.bpm` every screen shows, never the raw per-beat +/// number. ALWAYS shown, a dash when the strap is not reading: an earlier version hid it with no reading, and +/// the first thing that produced was "there is no HR in the minimised tab" — an absent readout is +/// indistinguishable from an absent feature, while a dash says the strap is not reading, which is something to +/// act on. Display only: nothing here feeds a score; Effort stays what the strap measured. +struct LiftHeartRate: View { + enum Style { + /// Heart and number in caption size — the minimised bar. + case compact + /// The number alone in body size, under the sheet's "HR" label. + case plain + } + + let style: Style + @EnvironmentObject private var model: AppModel + + var body: some View { + let tint = model.bpm == nil ? StrandPalette.textTertiary : StrandPalette.metricRose + let value = Text(model.bpm.map(String.init) ?? "—").monospacedDigit() + switch style { + case .compact: + HStack(spacing: 3) { + Image(systemName: "heart.fill") + .font(.system(size: 10, weight: .semibold)) + value.font(StrandFont.captionNumber) + } + .foregroundStyle(tint) + .accessibilityLabel(model.bpm.map { String(localized: "Heart rate \($0)") } + ?? String(localized: "Heart rate")) + case .plain: + value + .font(StrandFont.bodyNumber) + .foregroundStyle(tint) + } + } +} diff --git a/Strand/Screens/LiftLogView.swift b/Strand/Screens/LiftLogView.swift new file mode 100644 index 0000000000..ce72933b6f --- /dev/null +++ b/Strand/Screens/LiftLogView.swift @@ -0,0 +1,392 @@ +import SwiftUI +import StrandDesign +import StrandAnalytics +import WhoopStore + +// The Lift Log: build a program once, then run it in the gym by tapping through it. +// +// This screen is the front door — it lists the saved programs and (from a later phase) the sessions +// run from them. It lives in the Effort colour world, like Workouts, because a finished session +// lands in the `workout` table beside every other workout. +// +// EFFORT IS NEVER MODIFIED HERE (load-bearing). NOOP's Effort is computed from heart rate alone +// (Karvonen %HRR → Edwards TRIMP, `StrainScorer`), and there is no validated public path from typed +// sets/reps/weight to a cardiovascular-strain equivalent — WHOOP's own muscular load runs +// velocity-based algorithms over strap accelerometer/gyroscope data under an unpublished model. +// So the lifting figures are shown BESIDE Effort and never folded into it, matching the choice the +// imported-lifting path already made (`strain: nil, // never a fabricated cardiovascular strain`). + +struct LiftLogView: View { + @EnvironmentObject var repo: Repository + + /// Saved programs, most-recently-touched first. Loaded off the store on appear/refresh. + @State private var programs: [LiftProgramRow] = [] + @State private var loaded = false + + /// The program being created or edited (nil = the editor is closed). + @State private var editing: ProgramEditTarget? + @State private var importing = false + /// The live session, owned at the app root so it survives this screen going away. + @EnvironmentObject private var session: LiftSessionController + /// Recent finished sessions, newest first. + @State private var history: [LiftSessionRow] = [] + /// This week's fractional sets per muscle. + @State private var weekCounts: [LiftMuscle: Double] = [:] + /// The session whose detail sheet is open. + @State private var viewing: SessionDetailTarget? + + @AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue + private var unitSystem: UnitSystem { UnitSystem(rawValue: unitSystemRaw) ?? .metric } + + var body: some View { + ScreenScaffold( + title: "Lift Log", + subtitle: "Build a program once, then tap through it at the gym. Kept on \(Platform.deviceNounPhrase).", + onRefresh: { await load() } + ) { + VStack(alignment: .leading, spacing: NoopMetrics.sectionGap) { + headerCard + programsSection + weekSection + historySection + } + } + // Also on a saved session: the session sheet lives above this screen and cannot tell it + // directly, and a save does not always bump `refreshSeq`. + .task(id: "\(repo.refreshSeq)-\(session.savedSessions)") { await load() } + .sheet(item: $editing) { target in + LiftProgramEditorSheet(program: target.program) { + await load() + } + } + .sheet(isPresented: $importing) { + LiftProgramImportSheet { await load() } + } + .sheet(item: $viewing) { target in + LiftSessionDetailSheet(session: target.session) { await load() } + } + } + + // MARK: - Header + + private var headerCard: some View { + NoopCard(tint: StrandPalette.effortColor) { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + HStack(spacing: NoopMetrics.rowSpacing) { + Image(systemName: "dumbbell.fill") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(StrandPalette.effortColor) + .frame(width: 30, height: 30) + .background(StrandPalette.effortColor.opacity(0.14), + in: RoundedRectangle(cornerRadius: 9, style: .continuous)) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 2) { + Text("Your log book") + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.textPrimary) + Text("Programs, sessions and per-set history") + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textSecondary) + } + Spacer(minLength: 0) + } + Text("Lifting adds volume and set counts. It never changes your Effort, which stays measured from heart rate.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + // MARK: - Programs + + private var programsSection: some View { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + SectionHeader("Programs", overline: "Saved") + + if !loaded { + ComingSoon(what: "Reading your programs…", symbol: "dumbbell") + } else if programs.isEmpty { + emptyState + } else { + ForEach(programs, id: \.id) { program in + programRow(program) + } + } + + HStack(spacing: NoopMetrics.rowSpacing) { + Button { + editing = ProgramEditTarget(id: "new", program: nil) + } label: { + Label("New program", systemImage: "plus") + } + .buttonStyle(NoopButtonStyle(.secondary)) + + // Filling a dozen exercise lines by hand on a phone is the most tedious thing in the + // feature; a spreadsheet on a computer does it in a couple of minutes. + Button { + importing = true + } label: { + Label("Import", systemImage: "tablecells") + } + .buttonStyle(NoopButtonStyle(.secondary)) + } + } + } + + private var emptyState: some View { + NoopCard { + VStack(alignment: .leading, spacing: 8) { + Text("No programs yet") + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.textPrimary) + Text("A program is a name and an ordered list of exercises with your targets — working sets, reps, weight, rest and your own technique note.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + private func programRow(_ program: LiftProgramRow) -> some View { + NoopCard { + HStack(spacing: NoopMetrics.gap) { + Button { + editing = ProgramEditTarget(id: program.id, program: program) + } label: { + VStack(alignment: .leading, spacing: 3) { + Text(program.name) + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.textPrimary) + if let note = program.note, !note.isEmpty { + Text(note) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textSecondary) + .lineLimit(3) + } + Text("Tap to edit") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + Button(session.isActive ? "Running" : "Start") { Task { await start(program) } } + .buttonStyle(.noopPrimary) + .frame(maxWidth: 110) + .accessibilityLabel("Start this program") + } + } + } + + // MARK: - Start a session + + /// Flatten a program into the plan the session runs. The plan is SNAPSHOT at start: editing or + /// deleting the program mid-session cannot change what is being tapped through. + private func start(_ program: LiftProgramRow) async { + guard let store = await repo.storeHandle() else { return } + let items = (try? await store.liftProgramItems(programId: program.id)) ?? [] + guard !items.isEmpty else { return } + let vocabulary = (try? await store.liftExercises(deviceId: repo.deviceId)) ?? [] + + let plan = items.map { item -> LiftPlanItem in + // The classification comes from the exercise vocabulary, which is the one place that owns + // it — the program line deliberately stores no muscle of its own to drift from. + let known = vocabulary.first { $0.name == item.exercise } + return LiftPlanItem(exercise: item.exercise, + primaryMuscle: known?.primaryMuscle, + secondaryMuscles: known?.secondaryMuscles ?? [], + targetSets: item.targetSets, + restSec: item.restSec, + targetRepsLow: item.targetRepsLow, + targetRepsHigh: item.targetRepsHigh, + targetRpe: item.targetRpe, + targetWeightKg: item.targetWeightKg, + note: item.note, + // Carried so a set added or dropped mid-session can be written back + // onto the line it came from, and be there next time. + programItemId: item.id) + } + // Refuse to start a second session over a running one: two live sessions would both claim + // the strap gesture and both write the in-flight snapshot. + guard !session.isActive else { + session.isPresented = true + return + } + session.start(plan: plan, programId: program.id, programName: program.name) + } + + // MARK: - This week, per muscle + + private var weekSection: some View { + let ordered = LiftMuscle.ordered.filter { (weekCounts[$0] ?? 0) > 0 } + return VStack(alignment: .leading, spacing: NoopMetrics.gap) { + SectionHeader("Sets per muscle", overline: "Last 7 days · estimated") + if ordered.isEmpty { + NoopCard { + Text("Once you've logged a session, this shows how many sets each muscle got this week, against what the research associates with growth.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + } else { + NoopCard { + VStack(alignment: .leading, spacing: NoopMetrics.rowSpacing) { + ForEach(ordered, id: \.self) { muscle in + muscleBar(muscle, sets: weekCounts[muscle] ?? 0) + } + // The band is named and sourced, never phrased as a target NOOP sets for + // anyone: this is not a medical device and does not prescribe. + Text("Counted from the muscles you assigned each exercise: direct sets count once, indirect ones half. The tick is about 4 sets a week — below that, studies across GROUPS of people stop reliably detecting growth. It is a research reference, not a target for you, and above it gains continue with strongly diminishing returns and no clear ceiling, so the bar has no \"full\".") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 2) + } + } + } + } + } + + /// The span the weekly bar is drawn across. + /// + /// A DRAWING choice, not a dose. The evidence puts a floor at about 4 sets a week and identifies + /// NO ceiling for hypertrophy — gains continue above it with strongly diminishing returns — so + /// any bar maximum is arbitrary and must never be read as a target. 20 is chosen only because it + /// comfortably contains the range people actually train in, which puts the floor tick early on + /// the bar and makes a normal week read as progress rather than as "finished". + /// + /// The NUMBER beside the bar is the truth. The bar is context for it, and a count past 20 fills + /// the bar while the number keeps counting. + private static let weeklySetsBarSpan = 20.0 + + /// One muscle's week: the count, and where it sits relative to the evidence. + /// + /// This used to scale the bar 0...4 and turn it FULL and GREEN at four sets — so the screen said + /// "done" at the exact point the research says growth merely becomes *detectable*. It was telling + /// the user to stop at the starting line, and it contradicted the caption printed directly below + /// it. Now four sets is a TICK a fifth of the way along, and nothing on the bar ever reads as + /// complete, because nothing about the dose is. + private func muscleBar(_ muscle: LiftMuscle, sets: Double) -> some View { + let floor = LiftMetrics.ReferenceDose.hypertrophyMinimumSetsPerWeek + let atOrAboveFloor = sets >= floor + let fill = min(1.0, sets / Self.weeklySetsBarSpan) + let tick = min(1.0, floor / Self.weeklySetsBarSpan) + + return VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text(muscle.displayName) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textSecondary) + Spacer(minLength: 0) + // Deliberately NOT a success colour. There is no success point to signal, and a + // green number is exactly what made four sets read as an achievement. + Text(LiftFormat.trim(sets)) + .font(StrandFont.captionNumber) + .foregroundStyle(StrandPalette.textPrimary) + } + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule().fill(StrandPalette.surfaceRaised) + Capsule() + // Muted below the floor — below it growth is not reliably detectable, which + // is worth showing — and the ordinary accent above it. Never a "done" colour. + .fill(StrandPalette.effortColor.opacity(atOrAboveFloor ? 1.0 : 0.45)) + .frame(width: max(2, geo.size.width * fill)) + // The floor, marked where it actually falls. + Capsule() + .fill(StrandPalette.textPrimary.opacity(0.45)) + .frame(width: 2) + .offset(x: max(0, geo.size.width * tick - 1)) + .accessibilityHidden(true) + } + } + .frame(height: 6) + .accessibilityElement(children: .combine) + .accessibilityLabel(atOrAboveFloor + ? String(localized: "\(muscle.displayName): \(LiftFormat.trim(sets)) sets, at or above the weekly floor of \(LiftFormat.trim(floor))") + : String(localized: "\(muscle.displayName): \(LiftFormat.trim(sets)) sets, below the weekly floor of \(LiftFormat.trim(floor))")) + } + } + + // MARK: - History + + private var historySection: some View { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + SectionHeader("Sessions", overline: "Recent") + if history.isEmpty { + NoopCard { + Text("Finished sessions land here, with every set you logged.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + } + } else { + ForEach(history, id: \.id) { session in + Button { + viewing = SessionDetailTarget(id: session.id, session: session) + } label: { + historyRow(session) + } + .buttonStyle(.plain) + } + } + } + } + + private func historyRow(_ session: LiftSessionRow) -> some View { + NoopCard { + HStack(spacing: NoopMetrics.gap) { + VStack(alignment: .leading, spacing: 3) { + Text(session.programName ?? String(localized: "Session")) + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.textPrimary) + Text(Date(timeIntervalSince1970: TimeInterval(session.startTs)) + .formatted(date: .abbreviated, time: .shortened)) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textSecondary) + } + Spacer(minLength: 0) + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(StrandPalette.textSecondary) + .accessibilityHidden(true) + } + } + } + + // MARK: - Load + + private func load() async { + guard let store = await repo.storeHandle() else { return } + programs = (try? await store.liftPrograms(deviceId: repo.deviceId)) ?? [] + + let now = Int(Date().timeIntervalSince1970) + history = ((try? await store.liftSessions(deviceId: repo.deviceId, + fromTs: now - 180 * 86_400, + toTs: now)) ?? []) + .filter { $0.endTs != nil } // an abandoned session is not history + .sorted { $0.startTs > $1.startTs } + weekCounts = (try? await store.liftSetCounts(deviceId: repo.deviceId, + fromTs: now - 7 * 86_400, + toTs: now).fractional) ?? [:] + loaded = true + } +} + +/// The session whose detail is being read back. A wrapper rather than a retroactive `Identifiable` +/// on `LiftSessionRow`, keeping the store's row types free of app-layer conformances. +private struct SessionDetailTarget: Identifiable { + let id: String + let session: LiftSessionRow +} + + +/// Identifies what the editor sheet is editing. A wrapper rather than a retroactive `Identifiable` +/// on `LiftProgramRow`, so the store's row types stay free of app-layer conformances — and so +/// "new program" has an identity of its own to present on. +private struct ProgramEditTarget: Identifiable { + let id: String + let program: LiftProgramRow? +} diff --git a/Strand/Screens/LiftProgramEditorSheet.swift b/Strand/Screens/LiftProgramEditorSheet.swift new file mode 100644 index 0000000000..82f1bfc7f2 --- /dev/null +++ b/Strand/Screens/LiftProgramEditorSheet.swift @@ -0,0 +1,346 @@ +import SwiftUI +import StrandDesign +import WhoopStore + +// Build or edit a program: a name and an ordered list of exercise lines carrying the TARGETS — +// working sets, reps, weight, rest and the user's own technique note. +// +// Lines are edited as local drafts and written in one go on Save, through +// `replaceLiftProgramItems`, which swaps the whole list transactionally. Editing a program never +// rewrites history: a session snapshots the program's NAME when it runs, so renaming "Upper A" or +// deleting it entirely leaves every past session reading exactly as it did. + +struct LiftProgramEditorSheet: View { + /// The program being edited, or nil to create a new one. + let program: LiftProgramRow? + /// Called after a successful save or delete, so the caller can reload. + let onSaved: () async -> Void + + @EnvironmentObject var repo: Repository + @Environment(\.dismiss) private var dismiss + + @State private var name: String = "" + @State private var note: String = "" + /// The exercise lines, in display order. `ord` is assigned from the array index on save, so + /// reordering is just moving an element. + @State private var items: [LiftProgramItemRow] = [] + @State private var loaded = false + @State private var saving = false + + /// The line being added or edited (nil = that sheet is closed). + @State private var editingItem: ItemEditTarget? + @State private var confirmingDelete = false + + @AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue + private var unitSystem: UnitSystem { UnitSystem(rawValue: unitSystemRaw) ?? .metric } + + @FocusState private var focused: Field? + private enum Field: Hashable { case name, note } + + private var isNew: Bool { program == nil } + private var canSave: Bool { + !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !saving + } + + var body: some View { + ScreenScaffold( + title: isNew ? "New program" : "Edit program", + subtitle: "Your targets for each exercise. What you actually lift is recorded when you run it." + ) { + VStack(alignment: .leading, spacing: NoopMetrics.sectionGap) { + detailsSection + exercisesSection + if !isNew { deleteSection } + footer + } + } + #if os(iOS) + .presentationDragIndicator(.visible) + #else + // A fixed frame, for the reason the other editor sheets document: a macOS sheet hosting a + // ScrollView needs a definite height or every row collapses to the top. + .frame(width: 520, height: 720) + #endif + .background(StrandPalette.surfaceBase) + .keyboardDoneToolbar($focused) + .dismissesKeyboardOnTap($focused) + .task { await loadIfNeeded() } + .sheet(item: $editingItem) { target in + LiftProgramItemSheet(item: target.item) { saved in + apply(saved, replacing: target.item) + } + } + } + + // MARK: - Name + note + + private var detailsSection: some View { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + SectionHeader("Program", overline: "Details") + NoopCard { + VStack(alignment: .leading, spacing: 14) { + field("Name") { + TextField("Upper A", text: $name) + .textFieldStyle(.plain) + .font(StrandFont.body) + .foregroundStyle(StrandPalette.textPrimary) + .focused($focused, equals: .name) + } + field("Note (optional)") { + TextField("Anything you want to remember", text: $note) + // Capped at what the hub can actually show (two caption lines). A field + // that silently discards the end is worse than one that stops. + .onChange(of: note) { new in + if new.count > WhoopStore.maxProgramNoteLength { + note = String(new.prefix(WhoopStore.maxProgramNoteLength)) + } + } + .textFieldStyle(.plain) + .font(StrandFont.body) + .foregroundStyle(StrandPalette.textPrimary) + .focused($focused, equals: .note) + } + } + } + } + } + + // MARK: - Exercise lines + + private var exercisesSection: some View { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + SectionHeader("Exercises", overline: "In order") + + if items.isEmpty { + NoopCard { + Text("No exercises yet. Add the first one below — you can type any name you like; NOOP remembers it for next time.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + } else { + ForEach(Array(items.enumerated()), id: \.element.id) { index, item in + itemRow(item, index: index) + } + } + + Button { + editingItem = ItemEditTarget(id: "new", item: nil) + } label: { + Label("Add exercise", systemImage: "plus") + } + .buttonStyle(NoopButtonStyle(.secondary)) + } + } + + private func itemRow(_ item: LiftProgramItemRow, index: Int) -> some View { + NoopCard { + HStack(alignment: .top, spacing: NoopMetrics.gap) { + Button { + editingItem = ItemEditTarget(id: item.id, item: item) + } label: { + VStack(alignment: .leading, spacing: 3) { + Text(item.exercise) + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.textPrimary) + Text(targetSummary(item)) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textSecondary) + if let note = item.note, !note.isEmpty { + Text(note) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + .lineLimit(2) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + VStack(spacing: NoopMetrics.rowSpacing) { + Button { + move(from: index, by: -1) + } label: { + Image(systemName: "chevron.up") + } + .disabled(index == 0) + .accessibilityLabel("Move up") + + Button { + move(from: index, by: 1) + } label: { + Image(systemName: "chevron.down") + } + .disabled(index == items.count - 1) + .accessibilityLabel("Move down") + + Button(role: .destructive) { + items.removeAll { $0.id == item.id } + } label: { + Image(systemName: "trash") + } + .accessibilityLabel("Remove exercise") + } + .font(.system(size: 12, weight: .semibold)) + .buttonStyle(.plain) + .foregroundStyle(StrandPalette.textSecondary) + } + } + } + + /// "4 × 8 · 60 kg · 2:00 rest" — only the parts that were actually filled in. + private func targetSummary(_ item: LiftProgramItemRow) -> String { + var parts: [String] = [] + if let sets = item.targetSets { + if let reps = item.targetRepsLow { + parts.append("\(sets) × \(reps)") + } else { + parts.append(String(localized: "\(sets) sets")) + } + } + if let kg = item.targetWeightKg { + parts.append(LiftFormat.weight(kg, system: unitSystem)) + } + if let rpe = item.targetRpe { + parts.append(String(localized: "max RPE \(LiftFormat.trim(rpe))")) + } + if let rest = item.restSec { + parts.append(String(localized: "\(LiftFormat.duration(rest)) rest")) + } + return parts.isEmpty ? String(localized: "No targets set") : parts.joined(separator: " · ") + } + + // MARK: - Delete + + private var deleteSection: some View { + VStack(alignment: .leading, spacing: 8) { + Button(role: .destructive) { + confirmingDelete = true + } label: { + Label("Delete program", systemImage: "trash") + } + .buttonStyle(NoopButtonStyle(.secondary)) + .confirmationDialog("Delete this program?", + isPresented: $confirmingDelete, + titleVisibility: .visible) { + Button("Delete", role: .destructive) { Task { await deleteProgram() } } + Button("Cancel", role: .cancel) { } + } message: { + Text("Sessions you already logged from it are kept.") + } + } + } + + // MARK: - Footer + + private var footer: some View { + HStack { + Button("Cancel") { dismiss() } + .buttonStyle(.plain) + .font(StrandFont.body) + .foregroundStyle(StrandPalette.textSecondary) + Spacer() + Button("Save") { Task { await save() } } + .buttonStyle(.noopPrimary) + .frame(maxWidth: 160) + .disabled(!canSave) + .opacity(canSave ? 1 : NoopButtonMetrics.disabledOpacity) + .accessibilityLabel("Save program") + } + } + + // MARK: - Helpers + + private func field(_ label: LocalizedStringKey, + @ViewBuilder _ content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(label).strandOverline() + content() + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func move(from index: Int, by offset: Int) { + let target = index + offset + guard items.indices.contains(index), items.indices.contains(target) else { return } + items.swapAt(index, target) + } + + /// Insert a new line, or replace an edited one in place so its position is kept. + private func apply(_ saved: LiftProgramItemRow, replacing old: LiftProgramItemRow?) { + guard let old, let index = items.firstIndex(where: { $0.id == old.id }) else { + items.append(saved) + return + } + items[index] = saved + } + + // MARK: - Load / save + + private func loadIfNeeded() async { + guard !loaded else { return } + loaded = true + guard let program else { return } + name = program.name + note = program.note ?? "" + guard let store = await repo.storeHandle() else { return } + items = (try? await store.liftProgramItems(programId: program.id)) ?? [] + } + + private func save() async { + guard canSave, let store = await repo.storeHandle() else { return } + saving = true + defer { saving = false } + + let now = Int(Date().timeIntervalSince1970) + let id = program?.id ?? UUID().uuidString + let trimmedNote = note.trimmingCharacters(in: .whitespacesAndNewlines) + + let row = LiftProgramRow( + id: id, + deviceId: repo.deviceId, + name: name.trimmingCharacters(in: .whitespacesAndNewlines), + note: trimmedNote.isEmpty ? nil : trimmedNote, + createdAt: program?.createdAt ?? now, + updatedAt: now, + archived: program?.archived ?? false + ) + _ = try? await store.upsertLiftPrograms([row]) + + // `ord` is the array index: reordering the list is all it takes to reorder the program. + let ordered = items.enumerated().map { index, item in + LiftProgramItemRow( + id: item.id, + deviceId: repo.deviceId, + programId: id, + ord: index, + exercise: item.exercise, + targetSets: item.targetSets, + targetRepsLow: item.targetRepsLow, + targetRepsHigh: item.targetRepsHigh, + targetRpe: item.targetRpe, + targetWeightKg: item.targetWeightKg, + restSec: item.restSec, + note: item.note + ) + } + _ = try? await store.replaceLiftProgramItems(programId: id, items: ordered) + + await onSaved() + dismiss() + } + + private func deleteProgram() async { + guard let program, let store = await repo.storeHandle() else { return } + _ = try? await store.deleteLiftProgram(id: program.id) + await onSaved() + dismiss() + } +} + +/// What the line editor is editing — a wrapper so "new line" has an identity to present on. +private struct ItemEditTarget: Identifiable { + let id: String + let item: LiftProgramItemRow? +} diff --git a/Strand/Screens/LiftProgramImportSheet.swift b/Strand/Screens/LiftProgramImportSheet.swift new file mode 100644 index 0000000000..b69138102e --- /dev/null +++ b/Strand/Screens/LiftProgramImportSheet.swift @@ -0,0 +1,269 @@ +import SwiftUI +import UniformTypeIdentifiers +import StrandDesign +import StrandImport +import WhoopStore + +// Build a program from a spreadsheet filled in on a computer. +// +// WHY. A program is a name plus an ordered list of exercise lines, each carrying an exercise, a +// muscle classification, sets, reps, a weight, a rest period and a technique note. Typing that for a +// dozen exercises on a phone is the most tedious thing in the feature, and it is exactly the work a +// keyboard and a spreadsheet are good at. `docs/lift-log-program-template.xlsx` is the sheet to fill. +// +// NOTHING IS WRITTEN UNTIL THE USER SAYS SO. The file is parsed, then shown back as what it WILL +// create — programs, their exercises, and every warning — and only then imported. A file picked by +// mistake, or a sheet with the wrong columns, costs a glance rather than a mess to undo. +struct LiftProgramImportSheet: View { + /// Called after programs have been written, so the hub can reload. + var onImported: () async -> Void + + @EnvironmentObject private var repo: Repository + @Environment(\.dismiss) private var dismiss + + @State private var picking = false + @State private var parsed: LiftProgramImportResult? + @State private var failure: String? + @State private var importing = false + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + if let parsed { + preview(parsed) + } else { + intro + } + if let failure { + NoopCard { + Text(failure) + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.statusCritical) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .padding(NoopMetrics.screenPadding) + } + .background(StrandPalette.surfaceBase) + .navigationTitle("Import a program") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + if let parsed, !parsed.programs.isEmpty { + Button("Import") { Task { await performImport(parsed) } } + .disabled(importing) + } + } + } + } + .fileImporter(isPresented: $picking, + allowedContentTypes: Self.acceptedTypes, + allowsMultipleSelection: false) { result in + handle(result) + } + } + + /// What the picker will accept. `.spreadsheet` covers .xlsx and .numbers; CSV and plain text are + /// listed separately because a file exported from a spreadsheet often arrives typed as text. + private static let acceptedTypes: [UTType] = { + var types: [UTType] = [.spreadsheet, .commaSeparatedText, .plainText, .data] + if let xlsx = UTType(filenameExtension: "xlsx") { types.insert(xlsx, at: 0) } + return types + }() + + private var intro: some View { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + NoopCard { + VStack(alignment: .leading, spacing: 8) { + Text("Fill it in on a computer") + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.textPrimary) + Text("Download the template from the NOOP repository, fill in one row per exercise, then bring the file here. Excel, Numbers, Google Sheets and LibreOffice all work — .xlsx or .csv.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + Text("Only the exercise name is required. Anything you leave blank can be filled in later, or during the session.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + Button { + failure = nil + picking = true + } label: { + Label("Choose a file", systemImage: "doc.badge.plus") + .frame(maxWidth: .infinity) + } + .buttonStyle(.noopPrimary) + } + } + + private func preview(_ result: LiftProgramImportResult) -> some View { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + ForEach(Array(result.programs.enumerated()), id: \.offset) { _, program in + NoopCard { + VStack(alignment: .leading, spacing: 6) { + Text(program.name) + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.textPrimary) + Text("\(program.lines.count) exercises") + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + ForEach(Array(program.lines.enumerated()), id: \.offset) { _, line in + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(line.exercise) + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + Spacer(minLength: 8) + Text(summary(line)) + .font(StrandFont.captionNumber) + .foregroundStyle(StrandPalette.textTertiary) + } + } + } + } + } + + if !result.warnings.isEmpty { + NoopCard { + VStack(alignment: .leading, spacing: 6) { + // No count in the heading: the warnings are listed directly beneath it, so + // the number adds nothing — and it dodges plural agreement in ten languages. + Text("Worth checking") + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.metricAmber) + // Shown in full rather than summarised: each one names a row the user can go + // and fix, and a count alone would send them hunting. + ForEach(Array(result.warnings.enumerated()), id: \.offset) { _, w in + Text(w) + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + Text("These lines still import — anything unclassified can be set in the app.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + Button { + failure = nil + parsed = nil + picking = true + } label: { + Label("Choose a different file", systemImage: "arrow.triangle.2.circlepath") + } + .buttonStyle(NoopButtonStyle(.secondary)) + } + } + + /// "3 x 10 · 50 kg · RPE ≤8 · 90s", skipping whatever the sheet left blank. + private func summary(_ line: ImportedProgramLine) -> String { + var parts: [String] = [] + if let sets = line.targetSets, let reps = line.targetReps { parts.append("\(sets) x \(reps)") } + else if let sets = line.targetSets { parts.append("\(sets) x") } + if let kg = line.targetWeightKg { parts.append(LiftFormat.trim(kg) + " kg") } + if let rpe = line.targetMaxRpe { parts.append("RPE ≤" + LiftFormat.trim(rpe)) } + if let rest = line.restSec { parts.append("\(rest)s") } + return parts.joined(separator: " · ") + } + + private func handle(_ result: Result<[URL], Error>) { + switch result { + case .failure(let error): + failure = error.localizedDescription + case .success(let urls): + guard let url = urls.first else { return } + // A file picked from Files/iCloud is security-scoped; without this the read fails with a + // permissions error that looks like a corrupt file. + let scoped = url.startAccessingSecurityScopedResource() + defer { if scoped { url.stopAccessingSecurityScopedResource() } } + do { + // Check the size BEFORE reading. `Data(contentsOf:)` on a picked file would otherwise + // pull the whole thing into memory first, which is exactly what the limit exists to + // prevent — and a phone is where that matters. + let bytes = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0 + guard bytes <= LiftProgramSheetImporter.maxFileBytes else { + parsed = nil + failure = message(for: .tooLarge) + return + } + let data = try Data(contentsOf: url) + parsed = try LiftProgramSheetImporter.parse(data: data) + failure = nil + } catch let error as LiftProgramSheetImporter.ImportError { + parsed = nil + failure = message(for: error) + } catch { + parsed = nil + failure = String(localized: "That file could not be read.") + } + } + } + + private func message(for error: LiftProgramSheetImporter.ImportError) -> String { + switch error { + case .unreadable: + return String(localized: "That does not look like a spreadsheet. Use the template, saved as .xlsx or .csv.") + case .missingColumns: + return String(localized: "That sheet has no Exercise column. Use the template — the import matches on the header names.") + case .empty: + return String(localized: "That sheet has no exercises in it yet.") + case .tooLarge: + return String(localized: "That file is too big to be a program sheet.") + } + } + + private func performImport(_ result: LiftProgramImportResult) async { + guard !importing, let store = await repo.storeHandle() else { return } + importing = true + defer { importing = false } + + let now = Int(Date().timeIntervalSince1970) + for program in result.programs { + let programId = UUID().uuidString + _ = try? await store.upsertLiftPrograms([LiftProgramRow( + id: programId, deviceId: repo.deviceId, name: program.name, note: program.note, + createdAt: now, updatedAt: now, archived: false)]) + + let items = program.lines.enumerated().map { index, line in + LiftProgramItemRow( + id: UUID().uuidString, deviceId: repo.deviceId, programId: programId, + ord: index, exercise: line.exercise, + targetSets: line.targetSets, + // The sheet plans ONE rep count, which is what the editor plans too; the range's + // high end stays nil rather than inventing a spread nobody typed. + targetRepsLow: line.targetReps, targetRepsHigh: nil, targetRpe: line.targetMaxRpe, + targetWeightKg: line.targetWeightKg, + restSec: line.restSec, note: line.note) + } + _ = try? await store.replaceLiftProgramItems(programId: programId, items: items) + + // Remember the exercises too, with the classification the sheet gave them, so the + // picker offers them next time and the per-muscle rollup resolves the same name the + // same way. Best-effort: the vocabulary is capped, and hitting the cap must not fail + // an import of the programs themselves. + let exercises = program.lines.map { line in + LiftExerciseRow( + id: UUID().uuidString, deviceId: repo.deviceId, name: line.exercise, + primaryMuscle: line.primaryMuscle, + secondaryMuscles: line.secondaryMuscles, + createdAt: now, lastUsedTs: nil) + } + _ = try? await store.upsertLiftExercises(exercises) + } + + await onImported() + dismiss() + } +} diff --git a/Strand/Screens/LiftProgramItemSheet.swift b/Strand/Screens/LiftProgramItemSheet.swift new file mode 100644 index 0000000000..c1adedfeda --- /dev/null +++ b/Strand/Screens/LiftProgramItemSheet.swift @@ -0,0 +1,360 @@ +import SwiftUI +import StrandDesign +import WhoopStore + +// Edit ONE exercise line of a program: which exercise, and the targets for it. +// +// NOOP SHIPS NO EXERCISE CATALOGUE — deliberately. The user types whatever they call the movement +// and it is remembered in `liftExercise` with the muscle group they gave it, then offered back next +// time. A shipped mapping of common exercises to muscles would be both a permanent maintenance +// burden and a correctness claim NOOP has no business making about someone else's technique. +// +// Classification is therefore a one-time, few-second action per exercise: pick the primary muscle +// (a direct set) and any secondaries (indirect, counted at half). It is asked once, on first use, +// and remembered thereafter. Leaving it unset is allowed — an unclassified exercise still counts +// toward volume and session load, it simply claims no muscle it was never assigned. + +struct LiftProgramItemSheet: View { + /// The line being edited, or nil to add a new one. + let item: LiftProgramItemRow? + /// Handed the finished line. The parent owns ordering and persistence. + let onSave: (LiftProgramItemRow) -> Void + + @EnvironmentObject var repo: Repository + @Environment(\.dismiss) private var dismiss + + @State private var exercise: String = "" + @State private var primary: LiftMuscle? + @State private var secondaries: Set = [] + + @State private var setsText: String = "" + @State private var repsText: String = "" + @State private var weightText: String = "" + @State private var restText: String = "" + @State private var maxRpeText: String = "" + @State private var note: String = "" + + /// The user's own exercise vocabulary, for suggestions and for adopting a known classification. + @State private var vocabulary: [LiftExerciseRow] = [] + @State private var loaded = false + /// The vocabulary entry the user is about to forget (nil = no confirmation showing). + @State private var forgetting: LiftExerciseRow? + /// Set when the vocabulary is full, so the refusal is explained rather than silent. + @State private var vocabularyFullLimit: Int? + + /// The app's existing metric/imperial preference — the Lift Log never adds a second weight unit + /// setting of its own, so the plan is typed in the same unit the session records in. + @AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue + private var unitSystem: UnitSystem { UnitSystem(rawValue: unitSystemRaw) ?? .metric } + private var weightLabel: LocalizedStringKey { + unitSystem == .imperial ? "Weight (lb)" : "Weight (kg)" + } + + @FocusState private var focused: Field? + private enum Field: Hashable { case exercise, sets, reps, weight, rest, maxRpe, note } + + private var trimmedExercise: String { + exercise.trimmingCharacters(in: .whitespacesAndNewlines) + } + /// The ceiling as typed, when it is one: 1 to 10. A blank field is no ceiling. + private var maxRpe: Double? { + LiftFormat.number(maxRpeText).flatMap { (1...10).contains($0) ? $0 : nil } + } + private var maxRpeInvalid: Bool { + !maxRpeText.trimmingCharacters(in: .whitespaces).isEmpty && maxRpe == nil + } + private var canSave: Bool { !trimmedExercise.isEmpty && !maxRpeInvalid } + + private var suggestions: [LiftExerciseRow] { + LiftExerciseVocabulary.suggestions(vocabulary, matching: exercise) + } + + var body: some View { + ScreenScaffold( + title: item == nil ? "Add exercise" : "Edit exercise", + subtitle: "Type any name you like. NOOP remembers it, with the muscles you give it." + ) { + VStack(alignment: .leading, spacing: NoopMetrics.sectionGap) { + exerciseSection + LiftMusclePicker(primary: $primary, secondaries: $secondaries) + targetsSection + noteSection + footer + } + } + #if os(iOS) + .presentationDragIndicator(.visible) + #else + .frame(width: 520, height: 720) + #endif + .background(StrandPalette.surfaceBase) + .keyboardDoneToolbar($focused) + .dismissesKeyboardOnTap($focused) + .task { await loadIfNeeded() } + .confirmationDialog( + forgetting.map { Text(String(localized: "Forget \($0.name)?")) } ?? Text(""), + isPresented: Binding(get: { forgetting != nil }, + set: { if !$0 { forgetting = nil } }), + titleVisibility: .visible + ) { + Button("Forget", role: .destructive) { Task { await forget() } } + Button("Cancel", role: .cancel) { forgetting = nil } + } message: { + Text("It stops being offered here. Sessions you already logged with it are kept exactly as they are.") + } + .alert("You've saved the most exercises NOOP remembers", + isPresented: Binding(get: { vocabularyFullLimit != nil }, + set: { if !$0 { vocabularyFullLimit = nil } })) { + Button("OK", role: .cancel) { vocabularyFullLimit = nil } + } message: { + Text("Forget one you no longer use and this one will save. Your logged sessions are never affected.") + } + } + + /// Forget an exercise. The logged sets keep their own copy of the name and muscles, so this + /// removes it from the picker WITHOUT touching a single recorded session. + private func forget() async { + guard let row = forgetting, let store = await repo.storeHandle() else { return } + _ = try? await store.deleteLiftExercise(id: row.id) + vocabulary.removeAll { $0.id == row.id } + forgetting = nil + } + + // MARK: - Exercise name + suggestions + + private var exerciseSection: some View { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + SectionHeader("Exercise", overline: "Movement") + NoopCard { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + TextField("Incline dumbbell press", text: $exercise) + .textFieldStyle(.plain) + .font(StrandFont.body) + .foregroundStyle(StrandPalette.textPrimary) + .focused($focused, equals: .exercise) + + if !suggestions.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("Used before").strandOverline() + ForEach(suggestions, id: \.id) { row in + HStack(spacing: 8) { + Button { + adopt(row) + } label: { + LiftExerciseSuggestionLabel(row: row) + } + .buttonStyle(.plain) + + // A typo becomes a permanent picker entry otherwise. Forgetting a + // name is safe by construction: every logged set SNAPSHOTS its + // exercise name and classification, so history is untouched. + Button(role: .destructive) { + forgetting = row + } label: { + Image(systemName: "trash") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(StrandPalette.textTertiary) + } + .buttonStyle(.plain) + .accessibilityLabel("Forget this exercise") + } + } + } + } + } + } + } + } + + // MARK: - Targets + + private var targetsSection: some View { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + SectionHeader("Targets", overline: "What you're aiming for") + NoopCard { + VStack(alignment: .leading, spacing: 14) { + HStack(spacing: NoopMetrics.gap) { + field("Working sets") { + numberInput("4", text: $setsText, field: .sets) + } + field("Reps") { + numberInput("8", text: $repsText, field: .reps) + } + } + HStack(spacing: NoopMetrics.gap) { + field(weightLabel) { + numberInput("60", text: $weightText, field: .weight) + } + field("Rest (seconds)") { + numberInput("120", text: $restText, field: .rest) + } + } + // Max RPE is a CEILING, not effort planned in advance (Utku, 15 Sep 2026): the + // hardest a set should feel, so a lifter knows where to hold back. It is shown grey in + // the session and, like every other grey number, a set left unrated saves it + // (Utku, 16 Sep 2026; RULES 34) — typing a rating always wins. + HStack(spacing: NoopMetrics.gap) { + field("Max RPE (1–10)") { + numberInput("8", text: $maxRpeText, field: .maxRpe) + } + Color.clear.frame(maxWidth: .infinity, maxHeight: 0) + } + if maxRpeInvalid { + Text("Max RPE must be between 1 and 10.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.statusWarning) + } + Text("Max RPE is a ceiling: the hardest a set should feel, where 10 means nothing left. It shows grey during the session, and a set you leave unrated saves it as its rating.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + Text("Every target is optional — this is the plan, not the record. What you actually lift is entered set by set during the session.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } + + private var noteSection: some View { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + SectionHeader("Technique note", overline: "In your words") + NoopCard { + TextField("Slow eccentric, pause at the bottom", text: $note, axis: .vertical) + // A cue read between sets, and it renders directly above the set rows — every + // line pushes them down the screen. + .onChange(of: note) { new in + if new.count > WhoopStore.maxExerciseNoteLength { + note = String(new.prefix(WhoopStore.maxExerciseNoteLength)) + } + } + .textFieldStyle(.plain) + .font(StrandFont.body) + .foregroundStyle(StrandPalette.textPrimary) + .lineLimit(1...4) + .focused($focused, equals: .note) + } + } + } + + // MARK: - Footer + + private var footer: some View { + HStack { + Button("Cancel") { dismiss() } + .buttonStyle(.plain) + .font(StrandFont.body) + .foregroundStyle(StrandPalette.textSecondary) + Spacer() + Button("Save") { Task { await save() } } + .buttonStyle(.noopPrimary) + .frame(maxWidth: 160) + .disabled(!canSave) + .opacity(canSave ? 1 : NoopButtonMetrics.disabledOpacity) + .accessibilityLabel("Save exercise") + } + } + + // MARK: - Field helpers (the house form idiom) + + private func field(_ label: LocalizedStringKey, + @ViewBuilder _ content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(label).strandOverline() + content() + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func numberInput(_ placeholder: LocalizedStringKey, + text: Binding, + field: Field) -> some View { + TextField(placeholder, text: text) + .textFieldStyle(.plain) + .font(StrandFont.bodyNumber) + .foregroundStyle(StrandPalette.textPrimary) + .numericKeyboard() + .focused($focused, equals: field) + } + + // MARK: - Behaviour + + /// Take a known exercise from the vocabulary, including the classification it already carries — + /// so a movement is classified once and never asked about again. + private func adopt(_ row: LiftExerciseRow) { + exercise = row.name + primary = row.primaryMuscle + secondaries = Set(row.secondaryMuscles) + focused = nil + } + + private func loadIfNeeded() async { + guard !loaded else { return } + loaded = true + if let item { + exercise = item.exercise + setsText = item.targetSets.map(String.init) ?? "" + repsText = item.targetRepsLow.map(String.init) ?? "" + weightText = item.targetWeightKg.map { + LiftFormat.trim(LiftFormat.display(fromKilograms: $0, system: unitSystem)) + } ?? "" + restText = item.restSec.map(String.init) ?? "" + maxRpeText = item.targetRpe.map { LiftFormat.trim($0) } ?? "" + note = item.note ?? "" + } + guard let store = await repo.storeHandle() else { return } + vocabulary = (try? await store.liftExercises(deviceId: repo.deviceId)) ?? [] + // An existing line adopts whatever classification its exercise already carries, so editing a + // line shows the muscles the exercise is known by rather than an empty picker. + if let item, let known = vocabulary.first(where: { $0.name == item.exercise }) { + primary = known.primaryMuscle + secondaries = Set(known.secondaryMuscles) + } + } + + private func save() async { + guard canSave else { return } + let name = trimmedExercise + + // Remember the exercise (and its classification) in the vocabulary, so it is offered back + // next time. + if let store = await repo.storeHandle() { + do { + try await LiftExerciseVocabulary.remember( + name, primary: primary, + secondaries: LiftExerciseVocabulary.ordered(secondaries, excluding: primary), + known: vocabulary, deviceId: repo.deviceId, in: store) + } catch let full as WhoopStore.LiftExerciseVocabularyFull { + // Refused rather than silently dropped: the user typed a name and deserves to know + // it was not remembered. + vocabularyFullLimit = full.limit + return + } catch { + return + } + } + + let trimmedNote = note.trimmingCharacters(in: .whitespacesAndNewlines) + onSave(LiftProgramItemRow( + id: item?.id ?? UUID().uuidString, + deviceId: repo.deviceId, + // Assigned properly by the parent on save; a placeholder here would be a second source + // of truth for ordering. + programId: item?.programId ?? "", + ord: item?.ord ?? 0, + exercise: name, + targetSets: Int(setsText.trimmingCharacters(in: .whitespaces)), + // ONE rep count: `targetRepsHigh` stays nil, a schema column the editor no longer fills. + targetRepsLow: Int(repsText.trimmingCharacters(in: .whitespaces)), + targetRepsHigh: nil, + targetRpe: maxRpe, + targetWeightKg: LiftFormat.number(weightText).map { + LiftFormat.kilograms(fromDisplay: $0, system: unitSystem) + }, + restSec: Int(restText.trimmingCharacters(in: .whitespaces)), + note: trimmedNote.isEmpty ? nil : trimmedNote + )) + dismiss() + } +} diff --git a/Strand/Screens/LiftSessionBar.swift b/Strand/Screens/LiftSessionBar.swift new file mode 100644 index 0000000000..a4a01f6bcb --- /dev/null +++ b/Strand/Screens/LiftSessionBar.swift @@ -0,0 +1,113 @@ +import SwiftUI +import StrandDesign + +// The running session, condensed to a bar that sits above the tab bar wherever you are in the app. +// +// WHY IT EXISTS. Swiping the workout sheet down used to dismiss the session outright: the screen +// went away and took the strap handler and the tick with it, so taps and buzzes silently stopped +// working. Now swiping down MINIMISES to this bar. The session is still running — same clock, same +// strap gesture, same buzzes — and tapping the bar brings the full sheet back. +// +// It is deliberately a bar and not a badge: it has to show the one thing you need mid-workout +// without opening anything, which is how long is left of your rest. +// +// COLOUR MATCHES THE SHEET so the two read as one thing: green while working, amber while resting. + +struct LiftSessionBar: View { + @EnvironmentObject var session: LiftSessionController + + @AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue + private var unitSystem: UnitSystem { UnitSystem(rawValue: unitSystemRaw) ?? .metric } + + var body: some View { + // `LiftSessionController.presentation`, the same resolution the Lock Screen renders, so the two + // cannot word the session differently. Resolved once per render; all three lines read it. + if let engine = session.engine, let shown = session.presentation(system: unitSystem) { + Button { + session.isPresented = true + } label: { + // The Lock Screen banner's layout (`LiftLiveActivity`), because this is the same banner + // seen inside the app: the icon and the numbers sit near the edges and the heart rate + // stacks over the clock, so the words get the width (Utku, 21 Sep 2026, with a screenshot + // of the bar: "more place for writings"). Same sizes as before. + HStack(spacing: 10) { + Image(systemName: "dumbbell.fill") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(tint(engine)) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 1) { + Text(shown.exercise) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textPrimary) + .lineLimit(1) + Text(shown.detail.map { "\(shown.status) — \($0)" } ?? shown.status) + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + .lineLimit(1) + // The set coming up, on one line that cuts the exercise name before the + // set number (`LiftSessionController.nextLine`). + Text(shown.next) + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .lineLimit(1) + .truncationMode(.tail) + } + + Spacer(minLength: 6) + + // Heart rate over the clock, both flush right, each its own small view that updates + // itself (`LiftLiveReadouts.swift`), so a beat or a tick redraws one number, not the bar. + // The clock's width comes from a hidden "00:00" in its font — the widest a set or a rest + // shows under an hour — so the words beside it do not shift when it gains a digit. + VStack(alignment: .trailing, spacing: 2) { + LiftHeartRate(style: .compact) + + Text(verbatim: "00:00") + .font(StrandFont.bodyNumber) + .monospacedDigit() + .hidden() + .overlay(alignment: .trailing) { + bigClock(engine) + .font(StrandFont.bodyNumber) + .foregroundStyle(tint(engine)) + .fixedSize() + } + } + + // The same action the sheet's button performs, so a set can be closed out + // without opening anything. + Button { session.advance() } label: { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 26, weight: .semibold)) + .foregroundStyle(tint(engine)) + } + .buttonStyle(.plain) + .accessibilityLabel("Next") + } + .padding(.leading, 12) + .padding(.trailing, 8) + .padding(.vertical, 10) + .background(.ultraThinMaterial, in: Capsule()) + .overlay(Capsule().stroke(tint(engine).opacity(0.35), lineWidth: 1)) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .accessibilityLabel("Open the running session") + } + } + + private func tint(_ engine: LiftSessionEngine) -> Color { + switch engine.stage { + case .working: return StrandPalette.statusPositive + case .resting: return StrandPalette.metricAmber + default: return StrandPalette.effortColor + } + } + + /// Rest counts DOWN (that is the number you act on); everything else counts up. Written as the Lock + /// Screen writes the same clock — "0:45", "0:00", "1:05:00" — through NOOP's one running-clock format. + private func bigClock(_ engine: LiftSessionEngine) -> LiftRunningClock { + LiftRunningClock { now in engine.restRemaining(now: now) ?? now - engine.stageStartedAt } + } +} diff --git a/Strand/Screens/LiftSessionDetailSheet.swift b/Strand/Screens/LiftSessionDetailSheet.swift new file mode 100644 index 0000000000..4871ef0b6e --- /dev/null +++ b/Strand/Screens/LiftSessionDetailSheet.swift @@ -0,0 +1,460 @@ +import SwiftUI +import StrandDesign +import StrandAnalytics +import WhoopStore + +// One finished session, read back in full: every set as performed, the session figures, and how +// each exercise compares with the last time you did it. +// +// This is the screen the whole feature exists to produce. A log book that cannot show you what you +// lifted last week is a diary. +// +// EVERY FIGURE IS ARITHMETIC THE USER CAN REDO BY HAND from the sets listed on the same screen — +// that is the design constraint, and it is why there is no single composite "workout score". The +// maths lives in `LiftMetrics` (pure, unit-tested); this file only lays it out. +// +// Effort is shown BESIDE the lifting figures and is never computed from them: it is whatever NOOP +// measured from heart rate over the session's window, filled in by the engine's own rescore pass. + +struct LiftSessionDetailSheet: View { + let session: LiftSessionRow + /// Called after the session is edited or deleted, so the hub can reload its list. + var onChanged: () async -> Void = {} + + @EnvironmentObject var repo: Repository + @Environment(\.dismiss) private var dismiss + + @State private var sets: [LiftSetRow] = [] + /// The `workout` row this session is pinned to, for the HR-measured figures. + @State private var workout: WorkoutRow? + /// Previous performance per exercise, for the "vs last time" comparison. + @State private var previousVolume: [String: Double] = [:] + @State private var loaded = false + @State private var confirmingDelete = false + @State private var deleting = false + @State private var editing = false + /// Session RPE as stored now. The edit sheet can correct it, and the session load must follow. + @State private var sessionRpe: Double? + + @AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue + private var unitSystem: UnitSystem { UnitSystem(rawValue: unitSystemRaw) ?? .metric } + + private var durationSec: Int { + guard let end = session.endTs else { return 0 } + return max(0, end - session.startTs) + } + + var body: some View { + ScreenScaffold(title: "Session", subtitle: subtitle) { + VStack(alignment: .leading, spacing: NoopMetrics.sectionGap) { + if !loaded { + ComingSoon(what: "Reading the session…", symbol: "dumbbell") + } else { + figuresSection + exercisesSection + if !performed.isEmpty { + muscleSection + rpeSection + } + footnote + NoopButton("Edit sets", systemImage: "pencil", kind: .secondary) { editing = true } + deleteSection + } + } + } + #if os(iOS) + .presentationDragIndicator(.visible) + #else + .frame(width: 560, height: 780) + #endif + .background(StrandPalette.surfaceBase) + .task { await load() } + .sheet(isPresented: $editing) { + LiftSessionEditSheet(session: storedSession, sets: sets) { + await load() + await onChanged() + } + } + } + + /// The session as stored now, with any corrected RPE. + private var storedSession: LiftSessionRow { + var row = session + row.sessionRpe = sessionRpe + return row + } + + /// Remove a session that should not have been recorded — a mis-tap, or a test. + /// + /// Deletes the paired `workout` row TOO. A lift session writes one so the training lands in + /// Workouts and Today like any other, and the analytics engine fills its strain from the heart + /// rate measured over that window. Leaving it behind would keep the day's Effort inflated by a + /// session the user just said did not happen — which is worse than not being able to delete at + /// all, because it would look like the delete worked. + private var deleteSection: some View { + Button(role: .destructive) { + confirmingDelete = true + } label: { + Label("Delete session", systemImage: "trash") + .frame(maxWidth: .infinity) + } + .buttonStyle(.plain) + .font(StrandFont.body) + .foregroundStyle(StrandPalette.statusCritical) + .padding(.top, NoopMetrics.gap) + .disabled(deleting) + .confirmationDialog("Delete this session?", + isPresented: $confirmingDelete, titleVisibility: .visible) { + Button("Delete", role: .destructive) { Task { await deleteSession() } } + Button("Cancel", role: .cancel) { } + } message: { + Text("\(performed.count) recorded sets will be removed, and so will the workout this session created. This cannot be undone.") + } + } + + private func deleteSession() async { + guard !deleting, let store = await repo.storeHandle() else { return } + deleting = true + defer { deleting = false } + + _ = try? await store.deleteLiftSession(id: session.id) // cascades to its sets + if let workout { await repo.deleteWorkout(workout) } + + await onChanged() + dismiss() + } + + private var subtitle: LocalizedStringKey { + let date = Date(timeIntervalSince1970: TimeInterval(session.startTs)) + .formatted(date: .abbreviated, time: .shortened) + if let name = session.programName, !name.isEmpty { + return "\(name) · \(date)" + } + return "\(date)" + } + + // MARK: - The session figures + + private var figuresSection: some View { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + SectionHeader("This session", overline: "Figures") + LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: NoopMetrics.rowSpacing)], + alignment: .leading, spacing: NoopMetrics.rowSpacing) { + // Weight x reps, summed. Exact, but only meaningful against the SAME program run + // again: 100 kg of leg press is not 100 kg of squat, so the total across different + // exercises compares nothing. The per-exercise figure below, with its delta against + // last time, is the form that answers "am I progressing" — this one is the tally. + tile(String(localized: "Volume"), + LiftFormat.weight(LiftMetrics.volumeLoadKg(performed), system: unitSystem), + String(localized: "\(workingSetCount) working sets · compare when you repeat this program")) + + tile(String(localized: "Session load"), + sessionLoadText, + sessionLoadCaption) + + tile(String(localized: "Effort"), + workout?.strain.map { LiftFormat.trim($0) } ?? "—", + String(localized: "measured from heart rate")) + } + } + } + + /// The sets that were performed. A set at zero reps was discarded or skipped: it stays in the store + /// so Edit sets can fill it in, and out of everything this screen shows. + private var performed: [LiftSetRow] { sets.filter { LiftMetrics.isPerformed(reps: $0.reps) } } + + private var workingSetCount: Int { performed.filter { !$0.isWarmup }.count } + + private var sessionLoadText: String { + guard let load = LiftMetrics.sessionLoad(sessionRpe: sessionRpe, + durationSec: durationSec) else { return "—" } + return String(Int(load.rounded())) + } + + /// The caption must show the SAME minutes the load was computed from. + /// + /// Showing whole minutes while computing from exact seconds silently breaks the one promise this + /// screen makes: that every figure is arithmetic you can redo by hand. A 2:40 session captioned + /// "× 2 min" invites the reader to check 8 × 2 = 16 against a displayed 21 and conclude the app + /// is making numbers up. + private var sessionLoadCaption: String { + guard let rpe = sessionRpe else { + return String(localized: "not rated") + } + let minutes = Double(durationSec) / 60.0 + return String(localized: "RPE \(LiftFormat.trim(rpe)) × \(LiftFormat.trim(minutes)) min") + } + + private func tile(_ label: String, _ value: String, _ caption: String) -> some View { + NoopCard(padding: 14) { + VStack(alignment: .leading, spacing: 3) { + Text(label).strandOverline() + Text(value) + .font(StrandFont.title2) + .foregroundStyle(StrandPalette.textPrimary) + .lineLimit(1) + .minimumScaleFactor(0.6) + Text(caption) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + .lineLimit(2) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + // MARK: - Per exercise, with every set + + private var exercisesSection: some View { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + SectionHeader("Exercises", overline: "As performed") + if performed.isEmpty { + NoopCard { + Text("No sets were performed. Discarded sets stay under Edit sets as zeros you can fill in.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + } + ForEach(LiftMetrics.perExercise(performed), id: \.exercise) { summary in + exerciseCard(summary) + } + } + } + + private func exerciseCard(_ summary: LiftMetrics.ExerciseSummary) -> some View { + let rows = performed.filter { $0.exercise == summary.exercise }.sorted { $0.ord < $1.ord } + return NoopCard { + VStack(alignment: .leading, spacing: NoopMetrics.rowSpacing) { + Text(summary.exercise) + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.textPrimary) + if let first = rows.first { + Text(LiftMuscleSummary.line(primary: first.primaryMuscle, + secondaries: first.secondaryMuscles)) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + } + + ForEach(rows, id: \.id) { row in setLine(row) } + + Divider().background(StrandPalette.textTertiary.opacity(0.2)) + + HStack(alignment: .firstTextBaseline, spacing: NoopMetrics.gap) { + VStack(alignment: .leading, spacing: 2) { + Text("Best set").strandOverline() + Text(bestSetText(summary)) + .font(StrandFont.bodyNumber) + .foregroundStyle(StrandPalette.textPrimary) + if let e1rm = summary.bestEstimatedOneRepMaxKg { + // "Estimated" is in the label, not a footnote: it is a formula off one + // set, not a measured maximum, and the word has to travel with it. + Text(String(localized: "≈ \(LiftFormat.weight(e1rm, system: unitSystem)) estimated 1RM")) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + } + } + Spacer(minLength: 0) + VStack(alignment: .trailing, spacing: 2) { + Text("Volume").strandOverline() + Text(LiftFormat.weight(summary.volumeKg, system: unitSystem)) + .font(StrandFont.bodyNumber) + .foregroundStyle(StrandPalette.textPrimary) + if let delta = volumeDeltaText(summary) { + Text(delta) + .font(StrandFont.caption) + .foregroundStyle(deltaColor(summary)) + } + } + } + } + } + } + + private func setLine(_ row: LiftSetRow) -> some View { + HStack(spacing: NoopMetrics.rowSpacing) { + Text(row.isWarmup ? String(localized: "W") : "\(row.setIndex)") + .font(StrandFont.captionNumber) + .foregroundStyle(row.isWarmup ? StrandPalette.textTertiary : StrandPalette.effortColor) + .frame(width: 18, alignment: .leading) + + Text(setValueText(row)) + .font(StrandFont.bodyNumber) + .foregroundStyle(row.isWarmup ? StrandPalette.textSecondary : StrandPalette.textPrimary) + + Spacer(minLength: 0) + + if let rpe = row.rpe { + Text(String(localized: "RPE \(LiftFormat.trim(rpe))")) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textSecondary) + } + if let rest = row.restSec { + Text(LiftFormat.duration(rest)) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + } + } + } + + private func setValueText(_ row: LiftSetRow) -> String { + let reps = row.reps.map(String.init) ?? "—" + guard let kg = row.weightKg else { + // Bodyweight work: reps alone, with no fabricated tonnage behind it. + return String(localized: "\(reps) reps") + } + return "\(LiftFormat.weight(kg, system: unitSystem)) × \(reps)" + } + + private func bestSetText(_ summary: LiftMetrics.ExerciseSummary) -> String { + guard let reps = summary.bestReps else { return "—" } + guard let kg = summary.bestWeightKg else { return String(localized: "\(reps) reps") } + return "\(LiftFormat.weight(kg, system: unitSystem)) × \(reps)" + } + + /// How this exercise's volume compares with the last session that included it. The single most + /// useful line in the screen: progression is a comparison, not a number. + private func volumeDeltaText(_ summary: LiftMetrics.ExerciseSummary) -> String? { + guard let now = summary.volumeKg, let before = previousVolume[summary.exercise], before > 0 + else { return nil } + let delta = now - before + guard abs(delta) >= 0.5 else { return String(localized: "same as last time") } + let sign = delta > 0 ? "+" : "−" + return "\(sign)\(LiftFormat.weight(abs(delta), system: unitSystem)) vs last time" + } + + private func deltaColor(_ summary: LiftMetrics.ExerciseSummary) -> Color { + guard let now = summary.volumeKg, let before = previousVolume[summary.exercise], before > 0 + else { return StrandPalette.textTertiary } + if now > before { return StrandPalette.statusPositive } + if now < before { return StrandPalette.textSecondary } + return StrandPalette.textTertiary + } + + // MARK: - Sets per muscle + + private var muscleSection: some View { + let counts = LiftMetrics.muscleCounts(performed) + let ordered = LiftMuscle.ordered.filter { (counts.fractional[$0] ?? 0) > 0 } + return VStack(alignment: .leading, spacing: NoopMetrics.gap) { + SectionHeader("Sets per muscle", overline: "This session · estimated") + if ordered.isEmpty { + NoopCard { + Text("None of these exercises has a muscle group yet. Add one on the exercise and every future session counts toward it.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + } else { + NoopCard { + VStack(alignment: .leading, spacing: 8) { + ForEach(ordered, id: \.self) { muscle in + HStack(spacing: NoopMetrics.rowSpacing) { + Text(muscle.displayName) + .font(StrandFont.body) + .foregroundStyle(StrandPalette.textPrimary) + Spacer(minLength: 0) + Text(LiftFormat.trim(counts.fractional[muscle] ?? 0)) + .font(StrandFont.bodyNumber) + .foregroundStyle(StrandPalette.effortColor) + Text(componentText(counts, muscle)) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + } + } + Text("Counted from the muscles you assigned each exercise: direct sets count once, indirect ones count as a half — the method the reference figures were derived under. An estimate from your own labels, not something measured off your body.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 4) + } + } + } + } + } + + /// "4 direct · 2 indirect" — so the total above is inspectable rather than asserted. + private func componentText(_ counts: LiftMetrics.MuscleCounts, _ muscle: LiftMuscle) -> String { + let d = counts.direct[muscle] ?? 0 + let i = counts.indirect[muscle] ?? 0 + if d > 0 && i > 0 { return String(localized: "\(d) direct · \(i) indirect") } + if d > 0 { return String(localized: "\(d) direct") } + return String(localized: "\(i) indirect") + } + + // MARK: - RPE profile + + private var rpeSection: some View { + let p = LiftMetrics.rpeProfile(performed) + return VStack(alignment: .leading, spacing: NoopMetrics.gap) { + SectionHeader("How hard it felt", overline: "RPE") + NoopCard { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Mean RPE") + .font(StrandFont.body) + .foregroundStyle(StrandPalette.textSecondary) + Spacer(minLength: 0) + Text(p.mean.map { LiftFormat.trim($0) } ?? "—") + .font(StrandFont.bodyNumber) + .foregroundStyle(StrandPalette.textPrimary) + } + HStack { + Text(String(localized: "Sets at RPE \(LiftFormat.trim(p.threshold)) or above")) + .font(StrandFont.body) + .foregroundStyle(StrandPalette.textSecondary) + Spacer(minLength: 0) + Text("\(p.setsAtOrAboveThreshold)") + .font(StrandFont.bodyNumber) + .foregroundStyle(StrandPalette.textPrimary) + } + if p.unratedSets > 0 { + Text(String(localized: "\(p.unratedSets) working sets weren't rated, so the mean is drawn from \(p.ratedSets).")) + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } + } + + private var footnote: some View { + Text("Lifting figures are worked out from the sets above. Effort stays measured from heart rate and is never derived from weights and reps.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + + // MARK: - Load + + private func load() async { + guard let store = await repo.storeHandle() else { sessionRpe = session.sessionRpe; loaded = true; return } + sets = (try? await store.liftSets(sessionId: session.id)) ?? [] + // Re-read rather than trusting the row this sheet was opened with: the edit sheet can change it. + let stored = try? await store.liftSessions(deviceId: session.deviceId, + fromTs: session.startTs, toTs: session.startTs) + if let row = stored?.first(where: { $0.id == session.id }) { + sessionRpe = row.sessionRpe + } else { + sessionRpe = session.sessionRpe + } + + // The workout row this session is pinned to, by that table's own natural key. + let rows = (try? await store.workouts(deviceId: repo.deviceId, + from: session.startTs - 1, + to: session.startTs + 1, limit: 10)) ?? [] + workout = rows.first { $0.startTs == session.startTs && $0.sport == session.sport } + + // Previous volume per exercise, for the "vs last time" line. + var previous: [String: Double] = [:] + for name in Set(performed.map(\.exercise)) { + let before = (try? await store.lastLiftSets(deviceId: repo.deviceId, + exercise: name, + before: session.startTs)) ?? [] + if let v = LiftMetrics.volumeLoadKg(before) { previous[name] = v } + } + previousVolume = previous + loaded = true + } +} diff --git a/Strand/Screens/LiftSessionEditSheet.swift b/Strand/Screens/LiftSessionEditSheet.swift new file mode 100644 index 0000000000..b3f2a6ab9a --- /dev/null +++ b/Strand/Screens/LiftSessionEditSheet.swift @@ -0,0 +1,367 @@ +import SwiftUI +import StrandDesign +import WhoopStore + +// Correct a finished session: a number missed or mistyped at the gym, a set added or removed, a warm-up +// not marked, or the session's RPE. Only this session changes — never the program it ran from — and +// every figure on the session screen is recomputed from these rows. +// +// Sets saved at 0 reps (discarded at finish, or skipped) appear here and nowhere else, so a discard +// made by mistake can be filled back in: give such a set its reps and it counts again. +// +// Fields hold plain text and are parsed once, on Save, so a field is never rewritten while it is being +// typed into (the bug that stored 45.5 kg as 455). Only a field whose text changed is written back: +// re-parsing an untouched pound value into kilograms would otherwise nudge it by a rounding error. + +struct LiftSessionEditSheet: View { + let session: LiftSessionRow + let sets: [LiftSetRow] + /// Called after the changes are written, so the session screen can reload. + let onSaved: () async -> Void + + @EnvironmentObject var repo: Repository + @Environment(\.dismiss) private var dismiss + + @AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue + private var unitSystem: UnitSystem { UnitSystem(rawValue: unitSystemRaw) ?? .metric } + + /// Exercises in the order they were first performed, each with its rows in set order — and the same + /// as the sheet opened, to tell whether anything changed. + @State private var exercises: [ExerciseRows] = [] + @State private var original: [ExerciseRows] = [] + @State private var sessionRpeText = "" + @State private var originalSessionRpe = "" + @State private var saving = false + /// A weight or reps field whose 0 was emptied when it was focused. + @State private var clearedZero: Field? + + @FocusState private var focused: Field? + private enum Field: Hashable { case weight(String), reps(String), rpe(String), sessionRpe } + + /// One set's fields, as typed. + struct SetForm: Equatable { + var weight: String + var reps: String + var rpe: String + var isWarmup: Bool + } + + /// One row on the sheet: a saved set (its row id) or one added here (a new id). + struct Entry: Equatable { + let id: String + var form: SetForm + } + + /// An exercise and its rows, in set order. + struct ExerciseRows: Equatable { + let name: String + var entries: [Entry] + } + + private var hasChanges: Bool { exercises != original || sessionRpeText != originalSessionRpe } + + private var weightHeading: LocalizedStringKey { + unitSystem == .imperial ? "Lb" : "Kg" + } + + var body: some View { + ScreenScaffold(title: "Edit sets", + subtitle: "Fix numbers, or add and remove sets. Sets left at 0 reps stay out of the figures, and only this session changes — not the program.") { + VStack(alignment: .leading, spacing: NoopMetrics.sectionGap) { + sessionRpeCard + ForEach(exercises.indices, id: \.self) { exerciseCard($0) } + footer + } + } + #if os(iOS) + .presentationDragIndicator(.visible) + #else + .frame(width: 560, height: 780) + #endif + .background(StrandPalette.surfaceBase) + .keyboardDoneToolbar($focused) + .dismissesKeyboardOnTap($focused) + // A field holding 0 (a discarded set) empties when focused, so typing replaces the 0 instead of + // appending to it ("0" then "60" read "600" in the simulator); left empty, it goes back to 0. The + // single-argument form on purpose: the two-argument `onChange` is macOS 14+. + .onChange(of: focused) { now in + restoreClearedZero() + if swapText(now, "0", "") { clearedZero = now } + } + .onAppear(perform: fill) + } + + private var sessionRpeCard: some View { + NoopCard { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + Text("How hard was the whole session? (1–10)").strandOverline() + field(.sessionRpe, text: Binding( + get: { sessionRpeText }, + set: { sessionRpeText = $0.replacingOccurrences(of: ",", with: ".") })) + } + } + } + + private func exerciseCard(_ index: Int) -> some View { + let group = exercises[index] + return NoopCard { + VStack(alignment: .leading, spacing: NoopMetrics.rowSpacing) { + Text(group.name) + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.textPrimary) + HStack(spacing: 8) { + Text("Set").strandOverline() + .frame(width: LiftSessionView.setColumnWidth, alignment: .center) + Text(weightHeading).strandOverline().frame(maxWidth: .infinity, alignment: .leading) + Text("Reps").strandOverline().frame(maxWidth: .infinity, alignment: .leading) + Text("RPE").strandOverline().frame(maxWidth: .infinity, alignment: .leading) + } + .lineLimit(1) + .minimumScaleFactor(0.8) + ForEach(Array(group.entries.enumerated()), id: \.element.id) { position, entry in + setRow(exercise: index, position: position, entry: entry) + } + setCountRow(index) + } + } + } + + /// The set number toggles a warm-up, as on the session sheet. Numbers follow the rows, so removing a + /// set renumbers the ones after it. + private func setRow(exercise: Int, position: Int, entry: Entry) -> some View { + let warmup = entry.form.isWarmup + return HStack(spacing: 8) { + Button { update(exercise, entry.id) { $0.form.isWarmup.toggle() } } label: { + Text(warmup ? String(localized: "W") : "\(position + 1)") + .font(StrandFont.captionNumber) + .foregroundStyle(warmup ? StrandPalette.metricAmber : StrandPalette.textSecondary) + .frame(width: LiftSessionView.setColumnWidth, alignment: .center) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(warmup + ? String(localized: "Warm-up set — tap to make it a working set") + : String(localized: "Set \(position + 1) — tap to mark it a warm-up")) + field(.weight(entry.id), text: binding(exercise, entry.id, \.weight)) + field(.reps(entry.id), text: binding(exercise, entry.id, \.reps)) + field(.rpe(entry.id), text: binding(exercise, entry.id, \.rpe)) + } + .padding(.vertical, 6) + } + + /// Add a set at the end of an exercise, or drop its last one — the same control as the session + /// sheet. An exercise keeps at least one row: set it to 0 reps to take it out of the figures. + private func setCountRow(_ index: Int) -> some View { + let group = exercises[index] + let canAdd = group.entries.count < LiftSessionEngine.maxSetsPerExercise + let canRemove = group.entries.count > 1 + return HStack(spacing: 8) { + Button { addSet(index) } label: { + HStack(spacing: 6) { + Image(systemName: "plus.circle") + .font(.system(size: 17, weight: .semibold)) + Text("Add set").font(StrandFont.caption) + } + .foregroundStyle(canAdd ? StrandPalette.effortColor : StrandPalette.textTertiary) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(!canAdd) + .accessibilityLabel(String(localized: "Add a set to \(group.name)")) + + Button { removeSet(index) } label: { + Image(systemName: "minus.circle") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(canRemove ? StrandPalette.textSecondary + : StrandPalette.textTertiary.opacity(0.4)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(!canRemove) + .accessibilityLabel(String(localized: "Remove the last set from \(group.name)")) + } + .padding(.top, 2) + } + + private func field(_ target: Field, text: Binding) -> some View { + TextField(Self.empty, text: text) + .textFieldStyle(.plain) + .font(StrandFont.bodyNumber) + .foregroundStyle(StrandPalette.textPrimary) + .numericKeyboard() + .focused($focused, equals: target) + .frame(maxWidth: .infinity, alignment: .leading) + } + + /// A typed comma becomes a point, as on the session sheet: iOS labels the decimal key from the + /// device's region, and the field reads back in the notation the app displays. + private func binding(_ exercise: Int, _ id: String, + _ key: WritableKeyPath) -> Binding { + Binding(get: { entry(exercise, id)?.form[keyPath: key] ?? "" }, + set: { text in + update(exercise, id) { $0.form[keyPath: key] = text.replacingOccurrences(of: ",", with: ".") } + }) + } + + private func entry(_ exercise: Int, _ id: String) -> Entry? { + guard exercises.indices.contains(exercise) else { return nil } + return exercises[exercise].entries.first { $0.id == id } + } + + private func update(_ exercise: Int, _ id: String, _ change: (inout Entry) -> Void) { + guard exercises.indices.contains(exercise), + let i = exercises[exercise].entries.firstIndex(where: { $0.id == id }) else { return } + change(&exercises[exercise].entries[i]) + } + + /// Replace a weight or reps field's text with `new` if it is `old`. RPE never holds 0. + @discardableResult + private func swapText(_ field: Field?, _ old: String, _ new: String) -> Bool { + let target: (id: String, key: WritableKeyPath) + switch field { + case .weight(let id)?: target = (id, \.weight) + case .reps(let id)?: target = (id, \.reps) + default: return false + } + guard let exercise = exercises.firstIndex(where: { $0.entries.contains { $0.id == target.id } }), + entry(exercise, target.id)?.form[keyPath: target.key] == old else { return false } + update(exercise, target.id) { $0.form[keyPath: target.key] = new } + return true + } + + /// Put back a 0 that was emptied on focus and left empty, before anything reads the fields: an empty + /// reps field saves as nil, which counts as a performed set. + private func restoreClearedZero() { + swapText(clearedZero, "", "0") + clearedZero = nil + } + + /// A new set starts from the exercise's last one — usually what the extra set was — without its RPE. + private func addSet(_ exercise: Int) { + restoreClearedZero() + guard let last = exercises[exercise].entries.last, + exercises[exercise].entries.count < LiftSessionEngine.maxSetsPerExercise else { return } + exercises[exercise].entries.append(Entry( + id: UUID().uuidString, + form: SetForm(weight: last.form.weight, reps: last.form.reps, rpe: "", isWarmup: false))) + } + + private func removeSet(_ exercise: Int) { + guard exercises[exercise].entries.count > 1 else { return } + exercises[exercise].entries.removeLast() + } + + private var footer: some View { + HStack { + Button("Cancel") { dismiss() } + .buttonStyle(.plain) + .font(StrandFont.body) + .foregroundStyle(StrandPalette.textSecondary) + Spacer() + Button("Save changes") { Task { await save() } } + .buttonStyle(.noopPrimary) + .frame(maxWidth: 180) + .disabled(saving || !hasChanges) + .opacity(saving || !hasChanges ? NoopButtonMetrics.disabledOpacity : 1) + } + } + + private func fill() { + guard exercises.isEmpty else { return } + var order: [String] = [] + for row in sets.sorted(by: { $0.ord < $1.ord }) where !order.contains(row.exercise) { + order.append(row.exercise) + } + exercises = order.map { name in + ExerciseRows(name: name, entries: sets + .filter { $0.exercise == name } + .sorted { ($0.setIndex, $0.ord) < ($1.setIndex, $1.ord) } + .map { Entry(id: $0.id, form: Self.form(for: $0, system: unitSystem)) }) + } + original = exercises + sessionRpeText = session.sessionRpe.map { LiftFormat.trim($0) } ?? "" + originalSessionRpe = sessionRpeText + } + + private func save() async { + guard !saving, let store = await repo.storeHandle() else { return } + saving = true + defer { saving = false } + + restoreClearedZero() + let change = Self.changes(from: sets, to: exercises, system: unitSystem) + if !change.upserts.isEmpty { _ = try? await store.upsertLiftSets(change.upserts) } + if !change.deletedIds.isEmpty { _ = try? await store.deleteLiftSets(ids: change.deletedIds) } + if sessionRpeText != originalSessionRpe { + var row = session + row.sessionRpe = LiftFormat.number(sessionRpeText) + _ = try? await store.upsertLiftSessions([row]) + } + await onSaved() + dismiss() + } + + /// A set's fields as the sheet opens, in the unit the app displays. + static func form(for row: LiftSetRow, system: UnitSystem) -> SetForm { + SetForm(weight: row.weightKg.map { LiftFormat.trim(LiftFormat.display(fromKilograms: $0, system: system)) } ?? "", + reps: row.reps.map(String.init) ?? "", + rpe: row.rpe.map { LiftFormat.trim($0) } ?? "", + isWarmup: row.isWarmup) + } + + /// `row` with every field that differs between `before` and `after` parsed back in. A blank field + /// clears its value. + static func applying(_ after: SetForm, over before: SetForm, to row: LiftSetRow, + system: UnitSystem) -> LiftSetRow { + var edited = row + if after.weight != before.weight { edited.weightKg = kilograms(after.weight, system) } + if after.reps != before.reps { edited.reps = repCount(after.reps) } + if after.rpe != before.rpe { edited.rpe = LiftFormat.number(after.rpe) } + edited.isWarmup = after.isWarmup + return edited + } + + /// What saving writes: every row that changed or was added, and the ids of rows no longer on the + /// sheet. Rows are numbered 1… per exercise in sheet order. A saved row has only its edited fields + /// parsed back in; an added row takes its muscles from the exercise's other sets, has no timing, and + /// is ordered after every set the session already had. + static func changes(from rows: [LiftSetRow], to exercises: [ExerciseRows], + system: UnitSystem) -> (upserts: [LiftSetRow], deletedIds: [String]) { + let byId = Dictionary(uniqueKeysWithValues: rows.map { ($0.id, $0) }) + var nextOrd = (rows.map(\.ord).max() ?? -1) + 1 + var upserts: [LiftSetRow] = [] + var kept = Set() + for group in exercises { + guard let template = rows.first(where: { $0.exercise == group.name }) else { continue } + for (position, entry) in group.entries.enumerated() { + if let row = byId[entry.id] { + kept.insert(row.id) + var edited = applying(entry.form, over: form(for: row, system: system), to: row, system: system) + edited.setIndex = position + 1 + if edited != row { upserts.append(edited) } + } else { + upserts.append(LiftSetRow( + id: entry.id, deviceId: template.deviceId, sessionId: template.sessionId, + ord: nextOrd, exercise: group.name, primaryMuscle: template.primaryMuscle, + secondaryMuscles: template.secondaryMuscles, setIndex: position + 1, + weightKg: kilograms(entry.form.weight, system), reps: repCount(entry.form.reps), + rpe: LiftFormat.number(entry.form.rpe), isWarmup: entry.form.isWarmup, + startTs: nil, endTs: nil, restSec: nil, note: nil)) + nextOrd += 1 + } + } + } + return (upserts, rows.map(\.id).filter { !kept.contains($0) }) + } + + private static func kilograms(_ text: String, _ system: UnitSystem) -> Double? { + LiftFormat.number(text).map { LiftFormat.kilograms(fromDisplay: $0, system: system) } + } + + private static func repCount(_ text: String) -> Int? { + Int(text.trimmingCharacters(in: .whitespaces)) + } + + private static let empty = "—" +} diff --git a/Strand/Screens/LiftSessionExerciseSheet.swift b/Strand/Screens/LiftSessionExerciseSheet.swift new file mode 100644 index 0000000000..f1663a694e --- /dev/null +++ b/Strand/Screens/LiftSessionExerciseSheet.swift @@ -0,0 +1,151 @@ +import SwiftUI +import StrandDesign +import WhoopStore + +// Add an exercise to the running session (Utku, 21 Sep 2026): one done before, picked from the user's own +// exercise names, or a new one, typed and given its muscles — and remembered, like a name typed into the +// program editor. It joins the session at the end of the sheet as one set planned at 0 kg × 0 reps; ⊕/⊖ +// change its sets like any other line, and finishing asks whether the program keeps it. Sets, rest and +// max RPE are not asked here: they belong to the program editor, later. + +struct LiftSessionExerciseSheet: View { + /// Handed the exercise once it is remembered; the session adds it. + let onAdd: (_ name: String, _ primary: LiftMuscle?, _ secondaries: [LiftMuscle]) -> Void + + @EnvironmentObject var repo: Repository + @Environment(\.dismiss) private var dismiss + + @State private var exercise = "" + @State private var primary: LiftMuscle? + @State private var secondaries: Set = [] + /// The user's own exercise names, most recently used first. + @State private var vocabulary: [LiftExerciseRow] = [] + /// Set when the vocabulary is full, so the refusal is explained rather than silent. + @State private var vocabularyFullLimit: Int? + @State private var adding = false + + @FocusState private var focused: Field? + private enum Field: Hashable { case exercise } + + private var trimmedExercise: String { + exercise.trimmingCharacters(in: .whitespacesAndNewlines) + } + private var canAdd: Bool { !trimmedExercise.isEmpty && !adding } + + var body: some View { + ScreenScaffold(title: "Add exercise", + subtitle: "Pick one you have done before, or type a new name.") { + VStack(alignment: .leading, spacing: NoopMetrics.sectionGap) { + exerciseSection + LiftMusclePicker(primary: $primary, secondaries: $secondaries) + Text("It joins this session with one set, its weight and reps at 0 until you type what you lift. Finishing asks whether the program keeps it.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + footer + } + } + #if os(iOS) + .presentationDragIndicator(.visible) + #else + .frame(width: 520, height: 640) + #endif + .background(StrandPalette.surfaceBase) + .keyboardDoneToolbar($focused) + .dismissesKeyboardOnTap($focused) + .task { await load() } + // A name typed out in full that is already known brings its muscles with it, as picking it from + // the list does — unless muscles were already chosen here. + .onChange(of: exercise) { _ in + guard primary == nil, secondaries.isEmpty, + let known = vocabulary.first(where: { $0.name == trimmedExercise }) else { return } + primary = known.primaryMuscle + secondaries = Set(known.secondaryMuscles) + } + .alert("You've saved the most exercises NOOP remembers", + isPresented: Binding(get: { vocabularyFullLimit != nil }, + set: { if !$0 { vocabularyFullLimit = nil } })) { + Button("OK", role: .cancel) { vocabularyFullLimit = nil } + } message: { + Text("Forget one you no longer use and this one will save. Your logged sessions are never affected.") + } + } + + private var exerciseSection: some View { + let suggestions = LiftExerciseVocabulary.suggestions(vocabulary, matching: exercise, limit: 8) + return VStack(alignment: .leading, spacing: NoopMetrics.gap) { + SectionHeader("Exercise", overline: "Movement") + NoopCard { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + TextField("Incline dumbbell press", text: $exercise) + .textFieldStyle(.plain) + .font(StrandFont.body) + .foregroundStyle(StrandPalette.textPrimary) + .focused($focused, equals: .exercise) + if !suggestions.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("Used before").strandOverline() + ForEach(suggestions, id: \.id) { row in + Button { adopt(row) } label: { LiftExerciseSuggestionLabel(row: row) } + .buttonStyle(.plain) + } + } + } + } + } + } + } + + private var footer: some View { + HStack { + Button("Cancel") { dismiss() } + .buttonStyle(.plain) + .font(StrandFont.body) + .foregroundStyle(StrandPalette.textSecondary) + Spacer() + Button("Add to session") { Task { await add() } } + .buttonStyle(.noopPrimary) + .frame(maxWidth: 200) + .disabled(!canAdd) + .opacity(canAdd ? 1 : NoopButtonMetrics.disabledOpacity) + } + } + + /// Take a known exercise, with the muscles it is already known by. + private func adopt(_ row: LiftExerciseRow) { + exercise = row.name + primary = row.primaryMuscle + secondaries = Set(row.secondaryMuscles) + focused = nil + } + + private func load() async { + guard let store = await repo.storeHandle() else { return } + vocabulary = (try? await store.liftExercises(deviceId: repo.deviceId)) ?? [] + } + + /// Remember the exercise — a new name is saved to the vocabulary, a known one is marked used — then + /// hand it to the session. + private func add() async { + guard canAdd else { return } + adding = true + defer { adding = false } + let name = trimmedExercise + let ordered = LiftExerciseVocabulary.ordered(secondaries, excluding: primary) + if let store = await repo.storeHandle() { + do { + try await LiftExerciseVocabulary.remember(name, primary: primary, secondaries: ordered, + known: vocabulary, deviceId: repo.deviceId, + in: store) + } catch let full as WhoopStore.LiftExerciseVocabularyFull { + vocabularyFullLimit = full.limit + return + } catch { + // Still added: every set the session saves carries its own copy of the name and muscles, + // so a name the vocabulary could not take this once costs nothing that is logged. + } + } + onAdd(name, primary, ordered) + dismiss() + } +} diff --git a/Strand/Screens/LiftSessionView.swift b/Strand/Screens/LiftSessionView.swift new file mode 100644 index 0000000000..6c6e78c928 --- /dev/null +++ b/Strand/Screens/LiftSessionView.swift @@ -0,0 +1,917 @@ +import SwiftUI +import StrandDesign +import WhoopStore + +// The workout sheet: every exercise and every set of the session, on one scrollable page. +// +// WHY A SHEET AND NOT A WIZARD. The first version showed one set at a time and walked the plan in +// order. In a real gym that fails twice over: you cannot see what is coming, and you cannot move on +// when a machine is occupied. So every set is a row, any pending row can be started, and finished +// rows stay on screen with what you lifted. +// +// COLOUR CARRIES STATE, so you can find your place at a glance from arm's length: +// green the set you are working now +// amber the rest that follows it +// done a completed set, with a check; numbers nobody typed stay grey +// +// The session itself lives in `LiftSessionController`, ABOVE this view. Swiping this sheet away +// minimises it to the bottom bar; the clock, the strap gesture and the buzzes all keep running, +// because a workout outlives the screen you happen to be looking at. + +struct LiftSessionView: View { + // Only what the sheet draws from. The live heart rate and the running clocks are their own small views + // (`LiftLiveReadouts.swift`): watched from here, every beat, log line and tick redrew the whole sheet. + @EnvironmentObject var repo: Repository + @EnvironmentObject var session: LiftSessionController + @Environment(\.dismiss) private var dismiss + + /// Called once the session has been written, so the hub can reload. + let onFinished: () async -> Void + + @State private var showingFinish = false + @State private var confirmingDiscard = false + @State private var sessionRpeText = "" + @State private var saving = false + /// The two questions finishing can ask. Nil until answered: saving waits for an answer rather than + /// deciding for the user. + @State private var unfinishedChoice: UnfinishedChoice? + @State private var programChoice: ProgramChoice? + /// Program lines whose set count this session changed, read when the finish sheet opens. + @State private var setCountChanges: [LiftSessionController.SetCountChange] = [] + @State private var addingExercise = false + /// The card to bring into view once an exercise has been added — the new one, at the end. + @State private var scrollTarget: Int? + + private enum UnfinishedChoice: Hashable { case complete, discard } + private enum ProgramChoice: Hashable { case update, keep } + + @AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue + private var unitSystem: UnitSystem { UnitSystem(rawValue: unitSystemRaw) ?? .metric } + + @FocusState private var focused: FocusTarget? + private enum FocusTarget: Hashable { + case weight(LiftSlot), reps(LiftSlot), rpe(LiftSlot), sessionRpe + } + + /// What the user has TYPED into a field, held until they leave it. + /// + /// Without this a numeric field cannot accept a decimal at all. Each binding read its text back + /// out of the engine, so every keystroke round-tripped through `LiftFormat` and was replaced by + /// the canonical rendering of the parsed value. Typing "45." parsed to 45, re-rendered as "45", + /// and the point vanished as it was typed — then the next keystroke made "455". A user entering + /// 45.5 kg silently got 455 kg, which is the shape of bug this feature has to stop having. + /// + /// So while a field is focused it shows exactly what was typed; the parsed value still goes to + /// the engine and to disk on every keystroke, so nothing about durability changes. The draft is + /// dropped when focus leaves and the row goes back to the canonical formatting. + @State private var draft: [FocusTarget: String] = [:] + + private var engine: LiftSessionEngine? { session.engine } + + var body: some View { + Group { + if let engine { + VStack(spacing: 0) { + sheet(engine) + // The control bar never scrolls away: at the rack the clock and the one action have to + // be where your thumb already is. + controlBar(engine) + } + } else { + ComingSoon(what: "No session running", symbol: "dumbbell") + } + } + #if os(iOS) + .presentationDragIndicator(.visible) + #else + .frame(width: 560, height: 800) + #endif + .background(StrandPalette.surfaceBase) + .keyboardDoneToolbar($focused) + .dismissesKeyboardOnTap($focused) + // Re-read whenever the session's exercises change, so an exercise added mid-session that was + // done before shows last time's numbers in grey, like every other line. + .task(id: engine?.plan.map(\.exercise)) { await loadLastTime() } + // Release a field's draft once the user leaves it, so the row returns to the canonical + // formatting ("45.50" typed becomes "45.5"). The single-argument form on purpose: the + // two-argument `onChange` is macOS 14+ and this file also builds for macOS 13. + .onChange(of: focused) { now in + draft = draft.filter { $0.key == now } + } + .sheet(isPresented: $showingFinish) { finishSheet } + } + + // MARK: - The scrollable sheet + + private func sheet(_ engine: LiftSessionEngine) -> some View { + ScrollViewReader { proxy in + ScrollView { + VStack(alignment: .leading, spacing: NoopMetrics.sectionGap) { + header(engine) + ForEach(Array(engine.plan.enumerated()), id: \.offset) { index, item in + exerciseCard(engine, index: index, item: item) + } + addExerciseRow(engine) + Color.clear.frame(height: 8) + } + .padding(.horizontal, NoopMetrics.screenPadding) + .padding(.top, 18) + } + .onChange(of: engine.currentSlot) { slot in + // Follow the session down the sheet, but only when it moves on its own — scrolling + // back to read an earlier exercise must not be yanked away from. + guard let slot else { return } + withAnimation { proxy.scrollTo(slot.exerciseIndex, anchor: .top) } + } + .onChange(of: scrollTarget) { target in + guard let target else { return } + withAnimation { proxy.scrollTo(target, anchor: .top) } + scrollTarget = nil + } + .sheet(isPresented: $addingExercise) { + LiftSessionExerciseSheet { name, primary, secondaries in + guard session.addExercise(name, primaryMuscle: primary, + secondaryMuscles: secondaries) else { return } + scrollTarget = (session.engine?.plan.count ?? 1) - 1 + } + } + } + } + + /// Add an exercise the program does not have — at the END of the sheet, after everything planned, + /// because that is where it goes: the program's lines keep their order, and the new one is tapped to + /// start whenever the lifter gets to it (Utku, 21 Sep 2026). Finishing asks whether the program keeps + /// it; until then it changes this session only, like ⊕/⊖. + private func addExerciseRow(_ engine: LiftSessionEngine) -> some View { + let canAdd = engine.plan.count < LiftSessionEngine.maxExercises + return Button { + addingExercise = true + } label: { + HStack(spacing: 8) { + Image(systemName: "plus.circle.fill") + .font(.system(size: 17, weight: .semibold)) + Text("Add exercise").font(StrandFont.body) + } + .foregroundStyle(canAdd ? StrandPalette.effortColor : StrandPalette.textTertiary) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background( + RoundedRectangle(cornerRadius: NoopMetrics.cardRadius, style: .continuous) + .strokeBorder(StrandPalette.textTertiary.opacity(0.35), + style: StrokeStyle(lineWidth: 1, dash: [5, 4]))) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(!canAdd) + } + + private func header(_ engine: LiftSessionEngine) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(session.programName ?? String(localized: "Session")) + .font(StrandFont.title1) + .foregroundStyle(StrandPalette.textPrimary) + Text(String(localized: "\(engine.completedWorkingSets) of \(engine.plannedWorkingSets) sets done")) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textSecondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + // MARK: - One exercise, with all its sets + + private func exerciseCard(_ engine: LiftSessionEngine, index: Int, item: LiftPlanItem) -> some View { + NoopCard { + VStack(alignment: .leading, spacing: NoopMetrics.rowSpacing) { + VStack(alignment: .leading, spacing: 2) { + Text(item.exercise) + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.textPrimary) + Text(LiftMuscleSummary.line(primary: item.primaryMuscle, + secondaries: item.secondaryMuscles)) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + } + if let note = item.note, !note.isEmpty { + Text(note) + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + // Belt and braces with the entry cap: the sets are what this screen is for, + // and a note must never be able to push them off it. + .lineLimit(4) + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(StrandPalette.metricAmber.opacity(0.12), + in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + + columnHeadings + + ForEach(engine.slots(forExercise: index), id: \.self) { slot in + setRow(engine, slot: slot) + // The rest belongs BETWEEN two sets, because that is where it happens. + if isRestingAfter(engine, slot: slot) { restBand(engine) } + } + + setCountRow(engine, index: index, item: item) + } + } + .id(index) + } + + /// Add one more set, or drop the last planned one — at the END of the exercise, because that is + /// where the question comes up: you have done what was written down and have one more in you, or + /// you have not. Until this existed the sheet drew exactly `1...targetSets` and the extra set was + /// performed and then lost. + /// + /// The geometry mirrors a set row: the minus sits in the tick column, under the checks it undoes. + /// + /// **Both buttons change this session only.** Whether the program keeps the new count is asked + /// when the session is finished: a program is a plan for next time, and one extra set on a good + /// day is not always a new plan. + private func setCountRow(_ engine: LiftSessionEngine, index: Int, item: LiftPlanItem) -> some View { + let canAdd = item.targetSets < LiftSessionEngine.maxSetsPerExercise + let canRemove = engine.canRemoveSet(fromExercise: index) + + return HStack(spacing: 8) { + Button { + session.addSet(toExercise: index) + } label: { + HStack(spacing: 6) { + Image(systemName: "plus.circle") + .font(.system(size: 17, weight: .semibold)) + Text("Add set").font(StrandFont.caption) + } + .foregroundStyle(canAdd ? StrandPalette.effortColor : StrandPalette.textTertiary) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(!canAdd) + .accessibilityLabel(String(localized: "Add a set to \(item.exercise)")) + + Button { + session.removeSet(fromExercise: index) + } label: { + Image(systemName: "minus.circle") + .font(.system(size: 17, weight: .semibold)) + // Dimmed rather than gone: the pair reads as one control, and a minus that + // disappears once the last set is done looks like a feature that broke. + .foregroundStyle(canRemove ? StrandPalette.textSecondary + : StrandPalette.textTertiary.opacity(0.4)) + .frame(width: Self.tickColumnWidth) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(!canRemove) + .accessibilityLabel(String(localized: "Remove the last set from \(item.exercise)")) + } + .padding(.top, 2) + .padding(.horizontal, 8) + } + + /// Width of the set-number column, shared by the heading and every row so the number sits + /// directly under its label. + /// + /// 34, not 26. `strandOverline` renders ALL-CAPS with +1.4 tracking, and at 26 the heading wrapped + /// mid-word — a real session photographed it reading "SE / T" over two lines. The headings are + /// also `lineLimit(1)` with a scale floor: this row is four short labels across a phone width in + /// ten languages, and a wrapped heading breaks the column alignment for every row beneath it. + static let setColumnWidth: CGFloat = 34 + + /// Width of the trailing tick column. Mirrored by a clear spacer in the heading row so the four + /// labels sit over the four things they name. + private static let tickColumnWidth: CGFloat = 30 + + private var columnHeadings: some View { + HStack(spacing: 8) { + Text("Set").strandOverline() + .frame(width: Self.setColumnWidth, alignment: .center) + Text(weightHeading).strandOverline().frame(maxWidth: .infinity, alignment: .leading) + Text("Reps").strandOverline().frame(maxWidth: .infinity, alignment: .leading) + Text("RPE").strandOverline().frame(maxWidth: .infinity, alignment: .leading) + Color.clear.frame(width: Self.tickColumnWidth) + } + .lineLimit(1) + .minimumScaleFactor(0.8) + } + + private var weightHeading: LocalizedStringKey { + unitSystem == .imperial ? "Lb" : "Kg" + } + + // MARK: - One set row + + private func setRow(_ engine: LiftSessionEngine, slot: LiftSlot) -> some View { + let recorded = engine.recordedSet(for: slot) + let isWorking = engine.stage == .working(slot) + + return HStack(spacing: 8) { + // The set number IS the warm-up toggle. Warm-ups are excluded from volume and from the + // per-muscle counts, so being unable to mark one silently inflates the single figure the + // whole feature rests on — it has to be reachable in one tap, without leaving the row. + Button { + toggleWarmup(slot) + } label: { + Text(isWarmup(slot) ? String(localized: "W") : "\(slot.setIndex)") + .font(StrandFont.captionNumber) + .foregroundStyle(isWarmup(slot) + ? StrandPalette.metricAmber + : (isWorking ? StrandPalette.textPrimary + : StrandPalette.textSecondary)) + .frame(width: Self.setColumnWidth, alignment: .center) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(isWarmup(slot) + ? String(localized: "Warm-up set — tap to make it a working set") + : String(localized: "Set \(slot.setIndex) — tap to mark it a warm-up")) + + numberField(slot: slot, field: .weight(slot), + text: weightBinding(slot), + ghost: ghostWeight(slot)) + numberField(slot: slot, field: .reps(slot), + text: repsBinding(slot), + ghost: ghostReps(slot)) + numberField(slot: slot, field: .rpe(slot), + text: rpeBinding(slot), + ghost: ghostRpe(engine, slot: slot)) + + // The tick both REPORTS and ACTS: filled when the set is done, and tappable to start + // this set when it is not — which is how you jump to a different exercise. + Button { + if recorded == nil { session.start(slot) } else { session.start(slot) } + } label: { + Image(systemName: recorded == nil ? "circle" : "checkmark.circle.fill") + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(recorded == nil + ? StrandPalette.textTertiary + : StrandPalette.statusPositive) + } + .buttonStyle(.plain) + .frame(width: Self.tickColumnWidth) + .accessibilityLabel(recorded == nil + ? String(localized: "Start this set") + : String(localized: "Redo this set")) + } + .padding(.vertical, 6) + .padding(.horizontal, 8) + .background(rowBackground(isWorking: isWorking, done: recorded != nil), + in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + + /// Warm-up state lives in the controller, so a mark survives the sheet being minimised and + /// applies however the set was closed out — button, strap, or the minimised bar. + private func isWarmup(_ slot: LiftSlot) -> Bool { session.isWarmup(slot) } + + private func toggleWarmup(_ slot: LiftSlot) { + session.setWarmup(slot, !session.isWarmup(slot)) + } + + /// Green = working now, faint = done, clear = still to come. + /// + /// Deliberately no amber case. Tinting the just-finished SET amber said the wrong thing: the set + /// is over, and what is running is the gap after it. The rest is drawn as its own band between + /// the two set rows instead — see `restBand`. + private func rowBackground(isWorking: Bool, done: Bool) -> Color { + if isWorking { return StrandPalette.statusPositive.opacity(0.20) } + if done { return StrandPalette.surfaceRaised.opacity(0.5) } + return .clear + } + + private func isRestingAfter(_ engine: LiftSessionEngine, slot: LiftSlot) -> Bool { + if case .resting(let s, _) = engine.stage { return s == slot } + return false + } + + /// The running rest, drawn as an amber band sitting BETWEEN the set that ended and the set that + /// follows — which is literally where a rest is. + /// + /// It replaces tinting the finished set's row amber. That read as "this set is amber" when the + /// set was already done, and from across a gym floor it was not obvious which gap was running. + /// A band in the gap is unambiguous at a glance, which is the whole requirement: you are looking + /// at this from a bench, not reading it. + /// + /// It carries the countdown as well as the colour. The control bar has the same number, but the + /// control bar is pinned to the bottom and this is where your eyes already are — and once the + /// sheet is scrolled to a later exercise, the band is the only thing that says which rest. + private func restBand(_ engine: LiftSessionEngine) -> some View { + HStack(spacing: 8) { + Text("Rest period").strandOverline() + .foregroundStyle(StrandPalette.metricAmber) + Spacer(minLength: 0) + LiftRunningClock { engine.restRemaining(now: $0) ?? 0 } + .font(StrandFont.captionNumber) + .foregroundStyle(StrandPalette.metricAmber) + } + .lineLimit(1) + .minimumScaleFactor(0.75) + .padding(.horizontal, 10) + .padding(.vertical, 7) + .frame(maxWidth: .infinity) + .background(StrandPalette.metricAmber.opacity(0.22), + in: RoundedRectangle(cornerRadius: 6, style: .continuous)) + .padding(.horizontal, 8) + .accessibilityElement(children: .combine) + } + + private func numberField(slot: LiftSlot, field: FocusTarget, + text: Binding, ghost: String) -> some View { + TextField(ghost, text: text) + .textFieldStyle(.plain) + .font(StrandFont.bodyNumber) + .foregroundStyle(StrandPalette.textPrimary) + .numericKeyboard() + .focused($focused, equals: field) + .frame(maxWidth: .infinity, alignment: .leading) + } + + // MARK: - Ghost values + // + // The grey numbers come from ONE chain, `LiftSessionController.carry(for:)`: this exercise earlier + // in the session (set 2 almost always mirrors set 1), then the same set last session, then the + // program's target. The minimised bar and the Lock Screen read the same chain. + // + // A set keeps its grey numbers after it is done, until something is typed over them — grey means + // "not entered". What they are worth is decided when the session is finished: every set without + // typed numbers is completed with them, or discarded, in one choice. + + private func ghostWeight(_ slot: LiftSlot) -> String { + session.carry(for: slot).weightKg.map { display($0) } ?? "—" + } + + private func ghostReps(_ slot: LiftSlot) -> String { + session.carry(for: slot).reps.map(String.init) ?? "—" + } + + /// Grey RPE is the line's max RPE when the program sets one — and, like every other grey number, it is + /// what the set saves if nothing is typed over it (RULES 34). A previous set's own rating is shown as a + /// reminder when the plan sets no maximum, and that one is never saved: it belongs to another set. + private func ghostRpe(_ engine: LiftSessionEngine, slot: LiftSlot) -> String { + if let planned = engine.planItem(for: slot)?.targetRpe { return LiftFormat.trim(planned) } + return engine.previousSetInSession(for: slot)?.rpe.map { LiftFormat.trim($0) } ?? "—" + } + + private func display(_ kg: Double) -> String { + LiftFormat.trim(LiftFormat.display(fromKilograms: kg, system: unitSystem)) + } + + // MARK: - Field bindings + // + // Each field reads and writes THROUGH the controller, so a keystroke lands in the engine and on + // disk immediately. + // + // TYPING INTO ANY SET, AT ANY TIME. A set that has already been performed is edited in place; one + // that has not is held in `LiftSessionController.pendingValues` and applied the moment it is + // recorded. The two are indistinguishable from the row, which is the requirement: being mid-set + // on one machine is no reason to refuse a correction to another row you are looking at. + // + // This used to be a claim rather than a behaviour — the comment here said the value was "held + // until the set is recorded" while `write` silently dropped it — and a real session found it: + // "when I type something during an active set to other sets it refreshes to the empty". + + /// A text binding that does not fight the user while they type: reads the draft if there is one, + /// otherwise the canonical rendering of what is stored. + /// + /// A typed comma becomes a point on the way in. iOS's `.decimalPad` labels its separator key + /// from the DEVICE's region — a German or French phone offers "," and the app cannot relabel it + /// — so the two would otherwise disagree with the "." this screen displays everywhere else. + /// Normalising here means the field always reads back in the notation it shows, whichever key + /// the keyboard happened to offer. + private func fieldBinding(_ field: FocusTarget, + formatted: @escaping () -> String, + store: @escaping (String) -> Void) -> Binding { + Binding( + get: { draft[field] ?? formatted() }, + set: { typed in + let text = typed.replacingOccurrences(of: ",", with: ".") + draft[field] = text + store(text) + }) + } + + private func weightBinding(_ slot: LiftSlot) -> Binding { + fieldBinding(.weight(slot), + formatted: { session.enteredValues(for: slot).weightKg.map { display($0) } ?? "" }, + store: { text in + let kg = LiftFormat.number(text).map { + LiftFormat.kilograms(fromDisplay: $0, system: unitSystem) + } + write(slot) { $0.weightKg = kg } + }) + } + + private func repsBinding(_ slot: LiftSlot) -> Binding { + fieldBinding(.reps(slot), + formatted: { session.enteredValues(for: slot).reps.map(String.init) ?? "" }, + store: { text in + write(slot) { $0.reps = Int(text.trimmingCharacters(in: .whitespaces)) } + }) + } + + private func rpeBinding(_ slot: LiftSlot) -> Binding { + fieldBinding(.rpe(slot), + formatted: { session.enteredValues(for: slot).rpe.map { LiftFormat.trim($0) } ?? "" }, + store: { text in write(slot) { $0.rpe = LiftFormat.number(text) } }) + } + + /// Apply one field change to a set, leaving its other fields as they were. + /// + /// Works whether or not the set has been performed — the controller decides where the value + /// lands. It reads the CURRENT entered values first, so editing the reps cannot blank a weight + /// that was typed a moment ago into the same pending row. + private func write(_ slot: LiftSlot, _ mutate: (inout LiftRecordedSet) -> Void) { + let entered = session.enteredValues(for: slot) + var row = LiftRecordedSet(exerciseIndex: slot.exerciseIndex, setIndex: slot.setIndex, + weightKg: entered.weightKg, reps: entered.reps, rpe: entered.rpe, + isWarmup: session.isWarmup(slot), startTs: 0, endTs: 0, restSec: nil) + mutate(&row) + session.updateSet(slot, weightKg: row.weightKg, reps: row.reps, + rpe: row.rpe, isWarmup: row.isWarmup) + } + + // MARK: - The control bar + + private func controlBar(_ engine: LiftSessionEngine) -> some View { + VStack(spacing: NoopMetrics.rowSpacing) { + HStack(spacing: 14) { + clock(String(localized: "Session"), tint: StrandPalette.textPrimary) { $0 - engine.startTs } + stageClock(engine) + heartRate() + Spacer(minLength: 0) + Button { session.undo() } label: { + Image(systemName: "arrow.uturn.backward") + .font(.system(size: 15, weight: .semibold)) + } + .buttonStyle(.plain) + .foregroundStyle(engine.canUndo ? StrandPalette.textSecondary : StrandPalette.textTertiary) + .disabled(!engine.canUndo) + .accessibilityLabel("Undo") + } + + HStack(spacing: NoopMetrics.rowSpacing) { + Button { session.advance() } label: { + Text(actionLabel(engine)).frame(maxWidth: .infinity) + } + .buttonStyle(.noopPrimary) + + Button { + unfinishedChoice = nil + programChoice = nil + setCountChanges = [] + showingFinish = true + } label: { + Text("Finish") + } + .buttonStyle(NoopButtonStyle(.secondary)) + } + } + .padding(.horizontal, NoopMetrics.screenPadding) + .padding(.top, 10) + .padding(.bottom, 14) + .background(.ultraThinMaterial) + .overlay(alignment: .top) { + Rectangle().fill(StrandPalette.textTertiary.opacity(0.15)).frame(height: 0.5) + } + } + + /// Live heart rate, beside the clocks that are already pinned above the action button. + /// + /// It belongs here and not in the scrolling sheet: this strip is the part that never scrolls + /// away, and a glance mid-set is the whole use — you are holding a bar, not browsing. Asked for + /// after a real session. Always shown, dash included, and display only (`LiftHeartRate`). + private func heartRate() -> some View { + labelled(String(localized: "HR")) { LiftHeartRate(style: .plain) } + } + + /// A running clock under its label. `seconds` turns the current unix second into what it reads. + private func clock(_ label: String, tint: Color, seconds: @escaping (Int) -> Int) -> some View { + labelled(label) { + LiftRunningClock(seconds: seconds) + .font(StrandFont.bodyNumber) + .foregroundStyle(tint) + } + } + + private func labelled(_ label: String, @ViewBuilder value: () -> Value) -> some View { + VStack(alignment: .leading, spacing: 1) { + Text(label).strandOverline() + .lineLimit(1) + .minimumScaleFactor(0.7) + value() + } + } + + @ViewBuilder + private func stageClock(_ engine: LiftSessionEngine) -> some View { + switch engine.stage { + case .working: + clock(String(localized: "This set"), tint: StrandPalette.statusPositive) { + $0 - engine.stageStartedAt + } + case .resting: + // "Rest period", never "Rest": the catalog's "Rest" key is NOOP's SLEEP metric, so this + // label rendered as "Erholung" (recovery) in German — the exact collision CLAUDE.md and + // the handover brief both warn about. Reintroduced by the workout-sheet rewrite. + clock(String(localized: "Rest period"), tint: StrandPalette.metricAmber) { + engine.restRemaining(now: $0) ?? 0 + } + case .warmup, .finished: + clock(String(localized: "Warm-up"), tint: StrandPalette.textSecondary) { + $0 - engine.stageStartedAt + } + } + } + + private func actionLabel(_ engine: LiftSessionEngine) -> LocalizedStringKey { + switch engine.stage { + case .warmup: return "Start first set" + case .working: return "Set done" + case .resting: return engine.allCompleted ? "All sets done" : "Start next set" + case .finished: return "Saving…" + } + } + + // MARK: - Finish + + private var finishSheet: some View { + let unfinished = session.unfinishedSlots.count + let asksAboutProgram = !setCountChanges.isEmpty || !addedExercises.isEmpty + let answered = (unfinished == 0 || unfinishedChoice != nil) + && (!asksAboutProgram || programChoice != nil) + return ScreenScaffold(title: "Finish session", + subtitle: "One number for the whole session, so a leg day can be compared with a run.") { + VStack(alignment: .leading, spacing: NoopMetrics.sectionGap) { + NoopCard { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + Text("How hard was the whole session? (1–10)").strandOverline() + TextField("7", text: $sessionRpeText) + .textFieldStyle(.plain) + .font(StrandFont.bodyNumber) + .foregroundStyle(StrandPalette.textPrimary) + .numericKeyboard() + .focused($focused, equals: .sessionRpe) + Text("This is session RPE. Multiplied by the session's length it gives session load — the one figure that compares across completely different training.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + if unfinished > 0 { unfinishedCard(count: unfinished) } + if asksAboutProgram { programCard } + + // One way to save. Session RPE above is optional, so an empty field is simply no rating; + // a separate "Skip" saved exactly the same way and read as a second choice. + Button("Save session") { Task { await save() } } + .buttonStyle(.noopPrimary) + .disabled(saving || !answered) + .opacity(saving || !answered ? NoopButtonMetrics.disabledOpacity : 1) + if !answered { + Text("Choose an option above to save.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + } + + // A way OUT that records nothing. Until this existed, the only route off this screen + // saved. A session started by a mis-tap, or to try something out, had to be saved and + // then lived in the history and in that day's Effort for good. + Button(role: .destructive) { + confirmingDiscard = true + } label: { + Label("Discard session", systemImage: "trash") + .frame(maxWidth: .infinity) + } + .buttonStyle(.plain) + .font(StrandFont.body) + .foregroundStyle(StrandPalette.statusCritical) + .padding(.top, 4) + .disabled(saving) + .confirmationDialog("Discard this session?", + isPresented: $confirmingDiscard, titleVisibility: .visible) { + Button("Discard", role: .destructive) { + session.discard() + showingFinish = false + } + Button("Keep going", role: .cancel) { } + } message: { + Text("\(engine?.completedWorkingSets ?? 0) recorded sets will be thrown away. Nothing is saved and no workout is created.") + } + } + } + #if os(iOS) + .presentationDragIndicator(.visible) + #else + .frame(width: 460, height: 420) + #endif + .background(StrandPalette.surfaceBase) + .keyboardDoneToolbar($focused) + .task { await loadSetCountChanges() } + } + + /// Sets never started. One choice covers all of them, because what matters at the end of a session + /// is simply whether they happened: complete them with the numbers the sheet showed, or discard them + /// to zeros that every figure leaves out and Edit sets can still fill in. A set that was done is never + /// asked about — it is complete (`LiftSessionController.setsToSave`). + private func unfinishedCard(count: Int) -> some View { + NoopCard { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + Text("Unfinished sets").strandOverline() + Text("Sets not started: \(count)") + .font(StrandFont.body) + .foregroundStyle(StrandPalette.textPrimary) + .fixedSize(horizontal: false, vertical: true) + Picker("Unfinished sets", selection: $unfinishedChoice) { + Text("Complete them").tag(UnfinishedChoice?.some(.complete)) + Text("Discard them").tag(UnfinishedChoice?.some(.discard)) + } + .pickerStyle(.segmented) + .labelsHidden() + Text("Completing saves them with the grey numbers shown. Discarding keeps them out of every figure; they stay under Edit sets as zeros you can fill in later.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + // Said before Save rather than after: `save` files nothing when no set counts. + if unfinishedChoice == .discard, + !LiftSessionController.anyPerformed(session.setsToSave(completingUnfinished: false)) { + Text("Every set would be a zero, so discarding saves no session and no workout.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.statusWarning) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } + + /// Exercises added during the session, which the program does not have yet. + private var addedExercises: [LiftPlanItem] { + session.engine?.plan.filter(\.addedInSession) ?? [] + } + + /// Set counts changed with ⊕/⊖, and exercises added, during the session. The program keeps them only + /// if asked to — one answer for all of them, listed so the lifter sees what "update" would write. + private var programCard: some View { + let added = addedExercises + return NoopCard { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + Text("Program").strandOverline() + Text(programQuestion(countsChanged: !setCountChanges.isEmpty, exercisesAdded: !added.isEmpty)) + .font(StrandFont.body) + .foregroundStyle(StrandPalette.textPrimary) + .fixedSize(horizontal: false, vertical: true) + ForEach(setCountChanges, id: \.itemId) { change in + Text("\(change.exercise): \(change.from) → \(change.to) sets") + .font(StrandFont.bodyNumber) + .foregroundStyle(StrandPalette.textSecondary) + } + ForEach(Array(added.enumerated()), id: \.offset) { _, line in + Text("New: \(line.exercise) · sets: \(line.targetSets)") + .font(StrandFont.bodyNumber) + .foregroundStyle(StrandPalette.textSecondary) + } + Picker("Program", selection: $programChoice) { + Text("Update program").tag(ProgramChoice?.some(.update)) + Text("Keep as it was").tag(ProgramChoice?.some(.keep)) + } + .pickerStyle(.segmented) + .labelsHidden() + } + } + } + + /// The program question, worded for what actually changed. + private func programQuestion(countsChanged: Bool, exercisesAdded: Bool) -> LocalizedStringKey { + switch (countsChanged, exercisesAdded) { + case (true, true): return "You added exercises and changed the number of sets. Keep these changes in the program for next time?" + case (false, true): return "You added exercises. Add them to the program for next time?" + default: return "You changed the number of sets. Keep the new counts in the program for next time?" + } + } + + // MARK: - Loading and saving + + /// What was lifted for each of this session's exercises LAST time, by set number — the middle + /// layer of the grey numbers, handed to the controller that owns the chain. + private func loadLastTime() async { + guard let engine, let store = await repo.storeHandle() else { return } + var out: [String: [Int: LiftSetCarry]] = [:] + // One query per DISTINCT exercise, not per plan line. A program that programs the same + // movement twice — or an imported one with many lines — would otherwise re-ask the store the + // same question, and this runs when the sheet opens. + for exercise in NSOrderedSet(array: engine.plan.map(\.exercise)).compactMap({ $0 as? String }) { + let rows = (try? await store.lastLiftSets(deviceId: repo.deviceId, + exercise: exercise, + before: engine.startTs)) ?? [] + var bySet: [Int: LiftSetCarry] = [:] + for r in rows where !r.isWarmup { + bySet[r.setIndex] = LiftSetCarry(weightKg: r.weightKg, reps: r.reps) + } + out[exercise] = bySet + } + session.setLastSession(out) + } + + private func save() async { + guard !saving, let store = await repo.storeHandle() else { return } + saving = true + defer { saving = false } + + session.finish() + guard let engine = session.engine else { return } + let endTs = Int(Date().timeIntervalSince1970) + let sessionId = UUID().uuidString + // After `finish`, which closes out the running rest: that set's measured rest belongs to it. + let finished = session.setsToSave(completingUnfinished: unfinishedChoice == .complete) + + // Nothing to file, so file nothing. With no set done, "Discard them" turns every set into a zero. + // Filing that anyway wrote a session with nothing in it AND a manual workout, and the engine fills + // that workout's strain from the heart rate the strap measured — so an hour that recorded nothing + // still read back as a workout. The finish sheet says so before Save. The program's set counts + // are a separate thing the user chose explicitly, so those still apply. + guard LiftSessionController.anyPerformed(finished) else { + await writeProgram(store: store, plan: engine.plan, sets: finished) + await finishAndDismiss() + return + } + + let row = LiftSessionRow( + id: sessionId, deviceId: repo.deviceId, + startTs: engine.startTs, endTs: endTs, sport: LiftSessionView.sport, + programId: session.programId, + // Snapshot the name: renaming or deleting the program never rewrites this session. + programName: session.programName, + sessionRpe: LiftFormat.number(sessionRpeText), + note: session.programName) + _ = try? await store.upsertLiftSessions([row]) + + // `ord` is COMPLETION order, which with out-of-order work is not the plan's order — and it + // is the order that actually happened, which is what a session should read back as. Sets + // completed at finish without being started come last. + let rows = finished.enumerated().map { ord, s -> LiftSetRow in + let item = engine.planItem(for: s.slot) + return LiftSetRow( + id: UUID().uuidString, deviceId: repo.deviceId, sessionId: sessionId, + ord: ord, exercise: item?.exercise ?? "", + // Snapshot the classification AS IT WAS, so reclassifying later never rewrites what + // past weeks were counted as. + primaryMuscle: item?.primaryMuscle, + secondaryMuscles: item?.secondaryMuscles ?? [], + setIndex: s.slot.setIndex, weightKg: s.weightKg, reps: s.reps, rpe: s.rpe, + isWarmup: s.isWarmup, startTs: s.startTs, endTs: s.endTs, + restSec: s.restSec, note: nil) + } + _ = try? await store.upsertLiftSets(rows) + await writeProgram(store: store, plan: engine.plan, sets: finished) + + // Through the SAME path a manual workout takes, so it inherits overlap dedup, the engine's + // HR-derived strain fill and delete/merge. `strain` stays nil deliberately: the engine fills + // it from the heart rate the strap MEASURED, never from typed sets and reps. + let workout = WorkoutRow( + startTs: engine.startTs, endTs: endTs, sport: LiftSessionView.sport, + source: "manual", durationS: Double(max(0, endTs - engine.startTs)), + energyKcal: nil, avgHr: nil, maxHr: nil, strain: nil, + distanceM: nil, zonesJSON: nil, notes: session.programName, steps: nil) + await repo.saveManualWorkout(workout) + + await finishAndDismiss() + } + + /// Close the session down and leave the sheet. Shared by the normal save and the nothing-to-file + /// path above, so the two cannot drift about what ending a session means. + private func finishAndDismiss() async { + session.finishedSaving() + await repo.refresh() + await onFinished() + showingFinish = false + dismiss() + } + + /// The program lines whose set count this session changed, for the finish sheet to ask about. + private func loadSetCountChanges() async { + guard let programId = session.programId, let plan = session.engine?.plan, + let store = await repo.storeHandle(), + let rows = try? await store.liftProgramItems(programId: programId) else { return } + setCountChanges = LiftSessionController.setCountChanges(plan: plan, program: rows) + } + + /// Carry this session onto its program (`LiftSessionController.programAfterSession`): each line's + /// heaviest done set becomes its weight and reps (always, Utku 21 Sep 2026); changed set counts and + /// exercises added during the session reach it only when the user chose to keep them. + /// + /// Re-reads the lines, so a program edited elsewhere while the session ran keeps every other change + /// and a line deleted since is not resurrected. The store call replaces the lines wholesale, so + /// nothing is written when no line differs. + private func writeProgram(store: WhoopStore, plan: [LiftPlanItem], + sets: [LiftSessionController.FinishedSet]) async { + guard let programId = session.programId, + let rows = try? await store.liftProgramItems(programId: programId) else { return } + let edited = LiftSessionController.programAfterSession( + sets, plan: plan, program: rows, keepingChanges: programChoice == .update, + programId: programId, deviceId: repo.deviceId) + guard edited != rows else { return } + _ = try? await store.replaceLiftProgramItems(programId: programId, items: edited) + } + + /// The sport every logged session is filed under — the same token the Hevy/Liftosaur importer + /// uses, so a typed session and an imported one land in one bucket with one icon. + static let sport = "Strength Training" +} diff --git a/Strand/Screens/LiveView.swift b/Strand/Screens/LiveView.swift index aad7ca8e1f..c3017ddeff 100644 --- a/Strand/Screens/LiveView.swift +++ b/Strand/Screens/LiveView.swift @@ -51,7 +51,34 @@ struct LiveView: View { @AppStorage(UnitPrefs.effortScaleKey) private var effortScaleRaw = EffortScale.hundred.rawValue private var effortScale: EffortScale { UnitPrefs.resolveEffortScale(effortScaleRaw) } - private var activeConnection: Bool { live.connected && live.bonded } + /// Whether the ACTIVE registry device is a WHOOP, resolved once and handed to the leaves. + private var activeIsWhoop: Bool { + LiveConsoleReadout.activeIsWhoop( + devices: model.deviceRegistry?.devices ?? [], + activeId: model.deviceRegistry?.activeDeviceId, + ) + } + + /// Whether the ACTIVE registry device is an Oura ring (#2305) — the ring-only affordances below. + private var activeIsOura: Bool { + LiveConsoleReadout.activeIsOura( + devices: model.deviceRegistry?.devices ?? [], + activeId: model.deviceRegistry?.activeDeviceId, + ) + } + + /// The live ring's link phase, mirrored off the Oura source (#2305). Meaningful only under + /// `activeIsOura`; `.disconnected` otherwise. + private var ringPhase: OuraLiveSource.LinkPhase { model.ouraLinkPhase } + + /// A trusted WHOOP link, for the console readouts and the bond-only controls. + /// + /// Gated on the active device actually BEING a WHOOP (#2075). `LiveState` is one object that every + /// live source writes into, so `connected && bonded` stays true for a bonded strap while an Oura + /// ring is the device on screen. That showed the WHOOP pill, the WHOOP charge and live WHOOP-only + /// controls under the ring's name, and made the pill's own ring branch unreachable, because this one + /// is tested first. + private var activeConnection: Bool { activeIsWhoop && live.connected && live.bonded } /// A non-WHOOP live source (the Oura ring) that is connected and actively streaming live HR. It /// authenticates and streams but never reaches a WHOOP encrypted bond, so `bonded` stays false and @@ -99,7 +126,12 @@ struct LiveView: View { // an offline user saw only inert copy up top. Gated purely on `!live.connected`, so it // disappears the instant the radio connects. Shared with macOS — it reuses `scanButton`, // which the wide layout already renders in `controls`. - if !live.connected { offlineConnectCallout } + // WHOOP only (#2305): under a ring this card named the ring over a button that ran a + // WHOOP scan — the #2303 reporter's Re-scan ran a full 5/MG handshake with the ring active. + if activeIsWhoop, !live.connected { offlineConnectCallout } + // The ring's own above-the-fold affordance: its link phase and a reconnect, shown until + // `auth OK` — the same "no link yet" slot the WHOOP callout fills. + if activeIsOura, ringPhase != .authenticated { ringConnectCallout } bodyConsole // Low-bandwidth fallback note (#80): the radio couldn't sustain the WHOOP 4 R10/R11 raw // realtime burst, so live HR is riding the standard BLE Heart-Rate profile instead. Live HR @@ -113,8 +145,17 @@ struct LiveView: View { // Show the strap picker whenever we're not actively streaming, so a user with both a // WHOOP 4 and a 5/MG can switch between them. (It used to hide once `bonded`, which is // sticky across disconnects — so after the first pairing the picker vanished for good.) - if !activeConnection { modelPicker } - controls + // WHOOP only (#2305): `activeConnection` is false for a ring BY CONSTRUCTION, so without + // the brand gate the WHOOP picker was shown MORE readily under a ring than under a strap. + if activeIsWhoop, !activeConnection { modelPicker } + // Scan / Buzz / Disconnect are the WHOOP path (`model.scan` → `BLEManager.connect`, the + // explicit user-connect that bypasses the #1881 active-device gate on purpose). A ring + // gets its own row; any other brand keeps the Devices row alone. + if activeIsWhoop { + controls + } else if activeIsOura { + ringControls + } manageDevicesRow LiveLogCard() } @@ -186,7 +227,8 @@ struct LiveView: View { SourceBadge("SYNCING \(live.syncChunksThisSession)", tint: StrandPalette.metricCyan) } Spacer(minLength: 8) - LiveHeaderStats(activeConnection: activeConnection, deviceName: activeDeviceName) + LiveHeaderStats(activeConnection: activeConnection, activeIsWhoop: activeIsWhoop, + deviceName: activeDeviceName) } VStack(alignment: .leading, spacing: 12) { HStack(spacing: 12) { @@ -199,7 +241,8 @@ struct LiveView: View { } Spacer(minLength: 0) } - LiveHeaderStats(activeConnection: activeConnection, deviceName: activeDeviceName) + LiveHeaderStats(activeConnection: activeConnection, activeIsWhoop: activeIsWhoop, + deviceName: activeDeviceName) } } } @@ -259,16 +302,16 @@ struct LiveView: View { card { ViewThatFits(in: .horizontal) { HStack(alignment: .center, spacing: NoopMetrics.space6) { - LiveHeartReadout(hrMax: model.profile.hrMax) + LiveHeartReadout(activeIsWhoop: activeIsWhoop, activeIsOura: activeIsOura, hrMax: model.profile.hrMax) .frame(minWidth: 260, maxWidth: 340) Divider().overlay(StrandPalette.hairline) - LivePhysiology() + LivePhysiology(activeIsWhoop: activeIsWhoop) .frame(maxWidth: .infinity, alignment: .leading) } VStack(alignment: .leading, spacing: 18) { - LiveHeartReadout(hrMax: model.profile.hrMax) + LiveHeartReadout(activeIsWhoop: activeIsWhoop, activeIsOura: activeIsOura, hrMax: model.profile.hrMax) Divider().overlay(StrandPalette.hairline) - LivePhysiology() + LivePhysiology(activeIsWhoop: activeIsWhoop) } } } @@ -282,7 +325,7 @@ struct LiveView: View { private var signalTrustRail: some View { VStack(alignment: .leading, spacing: NoopMetrics.gap) { SectionHeader("Signal Trust", overline: "Proof that the console is current") - LiveSignalTrustRail(activeConnection: activeConnection) + LiveSignalTrustRail(activeConnection: activeConnection, activeIsWhoop: activeIsWhoop) } } @@ -361,28 +404,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 +460,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") @@ -632,6 +686,58 @@ struct LiveView: View { #endif } + // MARK: - Ring controls (#2305) + + /// The ring's above-the-fold card while it is not yet authenticated: the honest phase line and the + /// only ring reconnect in the app. Same slot and shape as `offlineConnectCallout`, which is WHOOP-only. + @ViewBuilder private var ringConnectCallout: some View { + card { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 10) { + Image(systemName: "antenna.radiowaves.left.and.right") + .foregroundStyle(StrandPalette.accent) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 2) { + Text(LiveRingCopy.status(ringPhase, streaming: false)) + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.textPrimary) + Text("Reconnect drops the current link, if any, and connects to the ring again. To pair or switch bands, open Devices.") + .font(StrandFont.subhead) + .foregroundStyle(StrandPalette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 0) + } + ringReconnectButton + } + } + .overlay(RoundedRectangle(cornerRadius: 22, style: .continuous) + .strokeBorder(StrandPalette.accent.opacity(0.30), lineWidth: 1)) + } + + /// The ring's row in the `controls` slot: one primary action. No Buzz (the ring has no haptic) and no + /// Disconnect (a stopped ring source would not reconnect for the night; Devices is where a ring is + /// deactivated). + private var ringControls: some View { + VStack(alignment: .leading, spacing: NoopMetrics.rowSpacing) { + Text(LiveRingCopy.status(ringPhase, streaming: ringStreaming)) + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + ringReconnectButton + } + } + + /// Drops the current ring link, if any, and connects again — `OuraLiveSource.reconnect()` through the + /// coordinator, so it can only reach the ring that is the live source. Enabled in every phase: a ring + /// parked in `.authenticating` (#2303) is exactly the case this exists for. + private var ringReconnectButton: some View { + NoopButton("Reconnect ring", systemImage: "arrow.clockwise", + kind: .primary, fullWidth: true) { + model.reconnectOuraRing() + } + } + // The connect / buzz / disconnect controls, all routed through the unified NOOP button system: // a filled primary for the lead Scan action, a secondary surface for Buzz, and the destructive // role for Disconnect — sentence-case, single line, optical-centred at controlHeight. @@ -698,12 +804,24 @@ struct LiveView: View { private struct LiveHeaderStats: View { @EnvironmentObject private var live: LiveState let activeConnection: Bool + /// Resolved by the parent (#2075), so the charge below can belong to the device being named. + let activeIsWhoop: Bool let deviceName: String /// #218: an Oura ring streams live HR WITHOUT a WHOOP bond, so `activeConnection` (which needs the bond) /// is false for it and the wear stat read "—" mid-stream. A live HR stream is itself the wear signal /// (Oura only emits PPG HR while worn). `streamingLiveHR` is Oura-only, so this never widens WHOOP. private var ringStreaming: Bool { live.connected && live.streamingLiveHR } private var liveLink: Bool { activeConnection || ringStreaming } + + /// The ACTIVE device's charge (#2075). A non-WHOOP active device never falls back to the strap's + /// number: an em dash says "not reported", where the strap's charge under the ring's name is a + /// confident lie, and was exactly what the report saw. + private var batteryLabel: String { + LiveConsoleReadout.batteryPercent( + activeIsWhoop: activeIsWhoop, whoopPct: live.batteryPct, ringPct: live.ouraBatteryPct, + ).map { "\($0)%" } ?? "—" + } + /// A streaming Oura ring is definitionally worn (PPG needs skin contact), and `worn` isn't reset on a /// source switch — so a stale `worn=false` from a prior WHOOP WRIST_OFF must not read "Off wrist" /// mid-stream. For WHOOP `ringStreaming` is always false, so this is just `live.worn`. #218. @@ -719,7 +837,7 @@ private struct LiveHeaderStats: View { var body: some View { HStack(spacing: 16) { stat(String(localized: "Device"), deviceName) - stat(String(localized: "Battery"), live.batteryPct.map { "\(Int($0))%" } ?? "—") + stat(String(localized: "Battery"), batteryLabel) stat(String(localized: "Worn"), liveLink ? (wornNow ? String(localized: "Yes") : String(localized: "No")) : "—") stat(String(localized: "Last sync"), lastSyncLabel) } @@ -746,13 +864,17 @@ private struct LiveHeaderStats: View { /// re-renders only this leaf. The vessel replaces the old flat pulse-ring, the count-up number replaces /// the CountUpText numeral. private struct LiveHeartReadout: View { + /// Resolved by the parent (#2075); this leaf does not re-derive it. + let activeIsWhoop: Bool + /// Resolved by the parent (#2305), same reason. + let activeIsOura: Bool @EnvironmentObject private var model: AppModel @EnvironmentObject private var live: LiveState let hrMax: Int /// Smoothed, spike-filtered live HR from AppModel (median over a short window). private var displayHR: Int? { model.bpm } - private var activeConnection: Bool { live.connected && live.bonded } + private var activeConnection: Bool { activeIsWhoop && live.connected && live.bonded } /// The live HR zone for the focal readout's colour world (presentation only). 0 = below Zone 1. private var liveZone: Int { @@ -833,6 +955,10 @@ private struct LiveHeartReadout: View { private var signalTrustSummary: String { if activeConnection && live.encryptedBond { return String(localized: "Encrypted stream: deep controls and history sync available.") } if activeConnection { return String(localized: "Live heart rate is flowing; full strap controls need an encrypted bond.") } + // A ring reads its OWN phase (#2305, #2304): `live.connected` here is whichever source last + // wrote it, and under a ring parked in the nonce handshake it read "Connected, waiting for a + // streaming state" for an hour (#2303). The ring's phase is what the console should say. + if activeIsOura { return LiveRingCopy.status(model.ouraLinkPhase, streaming: live.connected && live.streamingLiveHR) } if live.connected { return String(localized: "Connected, waiting for a streaming state.") } // The actionable "Scan and connect…" CTA now lives in `offlineConnectCallout` above the fold, so // this caption stays a calm empty-state descriptor rather than a second, competing CTA. @@ -845,14 +971,14 @@ private struct LiveHeartReadout: View { /// this leaf, never the whole console. private struct LivePhysiology: View { @EnvironmentObject private var live: LiveState + /// Resolved by the parent (#2075). Passed rather than observed so this leaf keeps owning only + /// LiveState, which is what makes the ~1 Hz R-R / frame notifies re-render it alone. + let activeIsWhoop: Bool - private var activeConnection: Bool { live.connected && live.bonded } + private var activeConnection: Bool { activeIsWhoop && live.connected && live.bonded } /// Oura ring actively streaming live HR — trusted stream without a WHOOP bond (see LiveView.ringStreaming). private var ringStreaming: Bool { live.connected && live.streamingLiveHR } - /// The liquid heart pink (matches LiquidThread's default + the mockup #ff6b81). - private let liquidHeart = Color(.sRGB, red: 1, green: 107 / 255, blue: 129 / 255, opacity: 1) - var body: some View { VStack(alignment: .leading, spacing: NoopMetrics.space4) { HStack(alignment: .firstTextBaseline) { @@ -967,6 +1093,15 @@ private struct LiveSignalTrustRail: View { @EnvironmentObject private var model: AppModel @EnvironmentObject private var live: LiveState let activeConnection: Bool + /// Resolved by the parent (#2075): the battery tile below must describe the ACTIVE device. + let activeIsWhoop: Bool + + /// The ACTIVE device's charge, or nil when it has not reported one. + private var activeBatteryPct: Int? { + LiveConsoleReadout.batteryPercent( + activeIsWhoop: activeIsWhoop, whoopPct: live.batteryPct, ringPct: live.ouraBatteryPct, + ) + } private var displayHR: Int? { model.bpm } /// Oura ring actively streaming live HR — trusted stream without a WHOOP bond (see LiveView.ringStreaming). @@ -1003,7 +1138,7 @@ private struct LiveSignalTrustRail: View { } private var batteryTint: Color { - guard let pct = live.batteryPct else { return StrandPalette.textTertiary } + guard let pct = activeBatteryPct.map({ Double($0) }) else { return StrandPalette.textTertiary } if pct <= 15 { return StrandPalette.metricRose } if pct <= 30 { return StrandPalette.statusWarning } return StrandPalette.accent @@ -1050,11 +1185,14 @@ private struct LiveSignalTrustRail: View { tint: live.backfilling ? StrandPalette.metricCyan : StrandPalette.textSecondary, frac: live.backfilling ? 0.6 : (live.lastSyncedAt == nil ? nil : 1)), .init(title: String(localized: "Battery"), - value: live.batteryPct.map { "\(Int($0))%" } ?? String(localized: "Unknown"), - detail: live.charging == true ? String(localized: "Charging") : String(localized: "Last reported by strap"), + value: activeBatteryPct.map { "\($0)%" } ?? String(localized: "Unknown"), + // "by strap" only when a strap is what reported it (#2075). + detail: live.charging == true ? String(localized: "Charging") + : activeIsWhoop ? String(localized: "Last reported by strap") + : String(localized: "Last reported by the ring"), icon: "battery.75percent", tint: batteryTint, - frac: live.batteryPct.map { max(0.02, min(1, $0 / 100)) }), + frac: activeBatteryPct.map { max(0.02, min(1, Double($0) / 100)) }), // Wear is only trustworthy on a live link: `worn` defaults true (LiveState) and is only // updated by WRIST_ON/OFF events, so while OFFLINE it would otherwise read a false-green // "On wrist". Gate the value AND tint on a live link (triage fix for PR#191). @@ -1111,6 +1249,7 @@ private struct ActiveWorkoutLive: View { /// re-render only this card. Wrapped in the liquid frosted card style. private struct LiveLogCard: View { @EnvironmentObject private var live: LiveState + @EnvironmentObject private var model: AppModel @AppStorage(CardAppearancePrefs.opacityKey) private var cardOpacityPercent = CardAppearancePrefs.defaultPercent private var cardOpacity: Double { max(0, min(1, Double(cardOpacityPercent) / 100)) } @@ -1177,8 +1316,14 @@ private struct LiveLogCard: View { } private func saveStrapLog() { - FileExport.exportText(live.exportableLogText(), - suggestedName: FileExport.timestampedName("noop-strap-log", ext: "txt")) + Task { + // Settings (#507) and Test Centre fetch these extras before exporting; without them this + // site wrote a same-named file silently missing the "Strap & data" + funnel sections, so + // which button someone pressed changed what a triager received. + let extra = await DebugDataDiagnostics.dynamicLines(repo: model.repo) + FileExport.exportText(live.exportableLogText(extraHeaderLines: extra), + suggestedName: FileExport.timestampedName("noop-strap-log", ext: "txt")) + } } } @@ -1251,3 +1396,21 @@ private struct SignalTrustTile: View { .accessibilityLabel("\(tile.title): \(tile.value). \(tile.detail)") } } + +// MARK: - Ring status copy (#2305) + +/// One line per ring link phase, shared by the console centrepiece caption, the above-the-fold callout +/// and the controls row so the three never disagree about what the ring is doing. +enum LiveRingCopy { + static func status(_ phase: OuraLiveSource.LinkPhase, streaming: Bool) -> String { + switch phase { + case .disconnected: return String(localized: "Ring not connected.") + case .connecting: return String(localized: "Connecting to the ring…") + case .authenticating: return String(localized: "Connected, authenticating…") + case .authenticated: + return streaming + ? String(localized: "Live heart rate is flowing from the ring.") + : String(localized: "Connected, waiting for live heart rate.") + } + } +} diff --git a/Strand/Screens/LiveWorkoutView.swift b/Strand/Screens/LiveWorkoutView.swift index 1fa83dd943..a57a3364d3 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 { @@ -405,27 +437,14 @@ struct LiveWorkoutView: View { .accessibilityLabel(Text("Delete")) } - private var activeSportName: String { - model.activeWorkout?.sport ?? WorkoutCatalog.defaultSportName - } - - private var workoutTypeGlassButton: some View { - Button { - // Placeholder — sport-type picker lands later; chrome and sizing stay put. - } label: { - WorkoutTypeIcon(workoutType: activeSportName, size: 22, weight: .semibold) - .frame(width: Self.bottomControlDiameter, height: Self.bottomControlDiameter) - .contentShape(Circle()) - } - .nativeLiquidGlassWorkoutControl() - .accessibilityLabel(Text("\(WorkoutSource.displaySport(activeSportName)) workout")) - } - // 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 +482,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 +494,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 +514,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 +552,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 +571,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..a765a1bca3 100644 --- a/Strand/Screens/ManualWorkoutSheet.swift +++ b/Strand/Screens/ManualWorkoutSheet.swift @@ -31,18 +31,24 @@ struct ManualWorkoutSheet: View { @State private var sport: String @State private var start: Date - @State private var durationMin: Int + /// #2034: the span is `start` + `end`. Duration is DERIVED (`durationBinding`), never a second stored + /// copy, so the two cannot drift; the row has always been stored as `startTs`/`endTs` anyway, and + /// routing the save through whole minutes was what silently reshaped an edited session's end. + @State private var end: Date @State private var avgHrText: String @State private var kcalText: String /// 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. @@ -67,14 +73,28 @@ struct ManualWorkoutSheet: View { // Seeds the LOCALE-STABLE editable form, not the localized display: the field's content is // persisted verbatim on save, and a translated word would split cross-source dedup per language. _sport = State(initialValue: e.map { WorkoutSource.editableSport($0.sport) } ?? "") - _start = State(initialValue: e.map { Date(timeIntervalSince1970: TimeInterval($0.startTs)) } ?? Date()) - _durationMin = State(initialValue: e.map { max(1, Int((($0.durationS ?? Double($0.endTs - $0.startTs)) / 60).rounded())) } ?? 45) + // A fresh add opens on a VALID 45 minute session ending now, keeping the long-standing 45 minute + // default length. It used to start at `Date()` with a 45 minute duration, so the implied end was + // always 45 minutes in the future and `buildManualRow` rejected it: the sheet opened with Save + // already disabled. That was invisible while the only complaint was the catch-all "Check the + // values and try again."; now that an end in the future says so by name, it would greet every + // fresh add with a red line. Anchoring to the end is also the truer default for the retroactive + // entry this sheet is for. + let defaultEnd = Date() + _start = State(initialValue: e.map { Date(timeIntervalSince1970: TimeInterval($0.startTs)) } + ?? defaultEnd.addingTimeInterval(-45 * 60)) + _end = State(initialValue: e.map { Date(timeIntervalSince1970: TimeInterval($0.endTs)) } + ?? defaultEnd) _avgHrText = State(initialValue: e?.avgHr.map(String.init) ?? "") _kcalText = State(initialValue: e?.energyKcal.map { String(Int($0.rounded())) } ?? "") // 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) } ?? "") } @@ -135,17 +155,23 @@ struct ManualWorkoutSheet: View { sportPicker } // Raise the Sport field above the following rows so its floating suggestion dropdown - // (an overlay, see `sportPicker`) draws ON TOP of Start / Duration instead of behind them. + // (an overlay, see `sportPicker`) draws ON TOP of Start / End / Duration, not behind them. .zIndex(1) field(String(localized: "Start")) { - DatePicker("", selection: $start, in: ...Date(), + DatePicker("", selection: startBinding, in: ...Date(), displayedComponents: [.date, .hourAndMinute]) .labelsHidden() .accessibilityLabel("Start date and time") } + field(String(localized: "End")) { + DatePicker("", selection: $end, in: ...Date(), + displayedComponents: [.date, .hourAndMinute]) + .labelsHidden() + .accessibilityLabel("End date and time") + } field(String(localized: "Duration")) { HStack(spacing: 12) { - Stepper(value: $durationMin, in: 1...(24 * 60), step: 5) { + Stepper(value: durationBinding, in: 1...(24 * 60), step: 5) { Text(durationLabel) .font(StrandFont.number(16)) .foregroundStyle(StrandPalette.effortBright) @@ -367,7 +393,32 @@ struct ManualWorkoutSheet: View { private var inputShape: RoundedRectangle { RoundedRectangle(cornerRadius: 10, style: .continuous) } + /// Moving the START keeps the workout's LENGTH and carries the end with it, which is what correcting + /// "this began an hour earlier" means. Computed from the old start before it is reassigned. + /// + /// Clamped so the carried end cannot land in the future: dragging the start forward would otherwise + /// push the end past now and invalidate the sheet on a move that looks entirely reasonable. Clamping + /// the START keeps the length the user set, where clamping the end would silently shorten the session. + private var startBinding: Binding { + Binding(get: { start }, + set: { picked in + let span = end.timeIntervalSince(start) + let newStart = min(picked, Date().addingTimeInterval(-span)) + end = WorkoutSource.endAfterStartMove(oldStart: start, oldEnd: end, newStart: newStart) + start = newStart + }) + } + + /// Duration is a VIEW of the span, not a stored copy. Reading clamps into the Stepper's own range so + /// an end that is currently before the start cannot hand it an out-of-range value; the honest verdict + /// on that state comes from `validationNote`, not from this label. Writing moves the end. + private var durationBinding: Binding { + Binding(get: { min(24 * 60, max(1, WorkoutSource.spanDurationMin(start: start, end: end))) }, + set: { end = WorkoutSource.endForDuration(start: start, durationMin: $0) }) + } + private var durationLabel: String { + let durationMin = durationBinding.wrappedValue let h = durationMin / 60, m = durationMin % 60 if h > 0 && m > 0 { return "\(h)h \(m)m" } if h > 0 { return "\(h)h" } @@ -383,7 +434,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 } @@ -394,9 +445,9 @@ struct ManualWorkoutSheet: View { if !avgHrText.trimmingCharacters(in: .whitespaces).isEmpty && avgHr == nil { return nil } if !kcalText.trimmingCharacters(in: .whitespaces).isEmpty && kcal == nil { return nil } if !distanceText.trimmingCharacters(in: .whitespaces).isEmpty && distanceMeters == nil { return nil } - guard let base = WorkoutSource.buildManualRow(start: start, durationMin: durationMin, - sport: sport, avgHr: avgHr, energyKcal: kcal, - distanceM: distanceMeters) + guard let base = WorkoutSource.buildManualRowFromSpan(start: start, end: end, + sport: sport, avgHr: avgHr, energyKcal: kcal, + distanceM: distanceMeters) else { return nil } // Carry over captured-but-unexposed fields when editing an existing strap session. return WorkoutSource.preservingCaptured(base, from: editing) @@ -417,6 +468,15 @@ struct ManualWorkoutSheet: View { guard builtRow == nil else { return nil } if sport.trimmingCharacters(in: .whitespaces).isEmpty { return String(localized: "Enter a sport.") } if start > Date() { return String(localized: "Start can't be in the future.") } + // The failure this feature introduces, so it gets its own line rather than the catch-all below. + if end <= start { return String(localized: "End must be after the start.") } + if end > Date() { return String(localized: "End can't be in the future.") } + // Its own line rather than the catch-all below: "Check the values and try again." gives a wearer + // no way to know a 30-second entry is the thing being refused. Matches the live-session floor, so + // the same session is treated the same whether it was tracked or typed in. + if Int(end.timeIntervalSince1970) - Int(start.timeIntervalSince1970) < WorkoutSource.minManualSpanSeconds { + return String(localized: "A workout must be at least 1 minute.") + } if !avgHrText.trimmingCharacters(in: .whitespaces).isEmpty, avgHr == nil || !(25...250).contains(avgHr ?? -1) { return String(localized: "Average HR must be 25-250 bpm.") } @@ -425,7 +485,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/MarkerEditorView.swift b/Strand/Screens/MarkerEditorView.swift index 3afa0041a1..1b02155abd 100644 --- a/Strand/Screens/MarkerEditorView.swift +++ b/Strand/Screens/MarkerEditorView.swift @@ -514,15 +514,11 @@ enum MarkerUnits { /// A short label for the conversion factor (4 sig figs), e.g. "0.02586". static func factorLabel(_ f: Double) -> String { String(format: "%.5g", f) } - /// A lower-cased, underscored slug for a custom marker name → its stable key. + /// A lower-cased, underscored slug for a custom marker name → its stable key. Delegates to the CSV + /// importer's `customKey` so a hand-added custom marker and an imported one always share one key. static func slug(_ name: String) -> String { - let lowered = name.precomposedStringWithCanonicalMapping - .trimmingCharacters(in: .whitespaces).lowercased() - let mapped = lowered.map { ch -> Character in - (ch.isLetter || ch.isNumber) ? ch : "_" - } - let collapsed = String(mapped).replacingOccurrences(of: "__", with: "_") - return "custom_" + collapsed.trimmingCharacters(in: CharacterSet(charactersIn: "_")) + let key = LabMarkerCsvImport.customKey(name) + return key.isEmpty ? "custom_" : key } } diff --git a/Strand/Screens/MetricExplorerView.swift b/Strand/Screens/MetricExplorerView.swift index fd71b0e2a7..76e5be87a3 100644 --- a/Strand/Screens/MetricExplorerView.swift +++ b/Strand/Screens/MetricExplorerView.swift @@ -30,10 +30,10 @@ private let strandDayParser: DateFormatter = { private func parseDay(_ day: String) -> Date? { strandDayParser.date(from: day) } -/// "9 Jun 2026" — long, locale-stable date for the hero "as of" line. +/// Localized long date for the hero "as of" line, with a fixed calendar-day time zone. private func longDate(_ d: Date) -> String { let f = DateFormatter() - f.locale = Locale(identifier: "en_US_POSIX") + f.locale = AppLanguage.activeLocale f.timeZone = TimeZone(identifier: "UTC") f.dateFormat = "d MMM yyyy" return f.string(from: d) @@ -137,6 +137,154 @@ enum ExploreRange: Int, CaseIterable, Identifiable, Hashable { } } +/// The steps-specific adapter between the shared calendar projection and this screen. Keeping the +/// policy pure makes the renderer consume one authoritative bucket series for its chart, headline, +/// statistics and accessibility text while the readings table can continue to show daily inputs. +enum MetricDetailSteps { + enum Resolution: Equatable { + case daily + case weekly + case monthly + } + + struct Presentation { + let buckets: [StepsDetailBucket] + let resolution: Resolution + + var series: [(day: String, value: Double)] { + buckets.map { (day: $0.displayDay, value: Double($0.mean)) } + } + + var accessibilitySummary: String { + guard let latest = buckets.last else { return String(localized: "Steps chart, no data") } + let noun = buckets.count == 1 ? String(localized: "bar") : String(localized: "bars") + let period = MetricDetailSteps.periodLabel(day: latest.displayDay, resolution: resolution) + switch resolution { + case .daily: + return String(localized: "Steps chart, \(buckets.count) daily \(noun), latest \(latest.mean) steps, \(period)") + case .weekly: + return String(localized: "Steps chart, \(buckets.count) weekly \(noun), latest \(latest.mean) average steps per observed day, \(period)") + case .monthly: + return String(localized: "Steps chart, \(buckets.count) monthly \(noun), latest \(latest.mean) average steps per observed day, \(period)") + } + } + } + + static func isMetric(_ metricKey: String) -> Bool { + metricKey == "steps" || metricKey == "steps_est" + } + + static func range(_ range: ExploreRange) -> StepsDetailRange { + switch range { + case .week: return .week + case .twoWeeks: return .twoWeeks + case .threeWeeks: return .threeWeeks + case .month: return .month + case .quarter: return .threeMonths + case .half: return .sixMonths + case .year: return .year + case .all: return .all + } + } + + static func resolution(for range: ExploreRange) -> Resolution { + switch range { + case .week, .twoWeeks, .threeWeeks, .month: return .daily + case .quarter: return .weekly + case .half, .year, .all: return .monthly + } + } + + static func widening(from range: ExploreRange) -> [ExploreRange] { + let order = ExploreRange.allCases + guard let index = order.firstIndex(of: range) else { return [.all] } + return Array(order[index...]) + } + + static func presentation(readings: [(day: String, value: Double)], range: ExploreRange, + anchorDay: String? = nil) -> Presentation { + let buckets = StepsDetailDensity.project( + readings: readings.map { StepsDetailReading(day: $0.day, value: $0.value) }, + range: self.range(range), anchorDay: anchorDay) + return Presentation(buckets: buckets, resolution: resolution(for: range)) + } + + static func latestValidDay(readings: [(day: String, value: Double)]) -> String? { + StepsDetailDensity.project( + readings: readings.map { StepsDetailReading(day: $0.day, value: $0.value) }, + range: .week).last?.displayDay + } + + /// The finite comparison window ends one day before the current window and has the same number + /// of local calendar days. The shared projector then applies the same daily/weekly/monthly fold. + static func previousPresentation(readings: [(day: String, value: Double)], range: ExploreRange, + currentAnchorDay: String) -> Presentation { + let parts = currentAnchorDay.split(separator: "-") + guard let dayCount = range.days, parts.count == 3, + let year = Int(parts[0]), let month = Int(parts[1]), let day = Int(parts[2]) else { + return Presentation(buckets: [], resolution: resolution(for: range)) + } + let previousAnchor = LocalCalendarDate(year: year, month: month, day: day) + .adding(days: -dayCount).key + return presentation(readings: readings, range: range, + anchorDay: previousAnchor) + } + + static func showsBars(metricKey: String, preferredStyleRaw: String) -> Bool { + isMetric(metricKey) || TrendChartStyle(rawValue: preferredStyleRaw) == .bar + } + + static func requiresFullHistory(metricKey: String, range: ExploreRange) -> Bool { + isMetric(metricKey) && range == .all + } + + static func loadIdentity(metricID: String, refreshSequence: Int, + skinTemperatureStyle: String, range: ExploreRange) -> String { + let metricKey = metricID.split(separator: ":").last.map(String.init) ?? metricID + let rangeIdentity = isMetric(metricKey) + ? "|\(range.rawValue)" : "" + return "\(metricID)|\(refreshSequence)|\(skinTemperatureStyle)\(rangeIdentity)" + } + + static func periodLabel(day: String, resolution: Resolution) -> String { + guard let date = parseDay(day) else { return day } + switch resolution { + case .daily: + return String(localized: "as of \(longDate(date))") + case .weekly: + return String(localized: "week of \(longDate(date))") + case .monthly: + let formatter = DateFormatter() + formatter.locale = AppLanguage.activeLocale + formatter.timeZone = TimeZone(identifier: "UTC") + formatter.dateFormat = "MMMM yyyy" + return formatter.string(from: date) + } + } + + static func countCaption(count: Int, resolution: Resolution, rangeName: String) -> String { + let noun = count == 1 ? String(localized: "bar") : String(localized: "bars") + switch resolution { + case .daily: + return String(localized: "\(count) daily \(noun) · \(rangeName)") + case .weekly: + return String(localized: "\(count) weekly \(noun) · average per observed day · \(rangeName)") + case .monthly: + return String(localized: "\(count) monthly \(noun) · average per observed day · \(rangeName)") + } + } + + static func valueLabel(_ value: Double, resolution: Resolution) -> String { + let formatted = value.formatted(.number.locale(AppLanguage.activeLocale).precision(.fractionLength(0))) + switch resolution { + case .daily: + return String(localized: "\(formatted) steps") + case .weekly, .monthly: + return String(localized: "\(formatted) average steps per observed day") + } + } +} + /// Pure #943 chip-coercion rule, extracted so it can be pinned by a test (the Swift twin of Android's /// `coercedVitalRange` in HealthScreen.kt). Resolves a stored selection NON-DESTRUCTIVELY: an unlocked /// selection is kept verbatim; a LOCKED one renders as the largest unlocked range with a real finite @@ -182,6 +330,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] { @@ -232,7 +392,7 @@ func vitalReadingRows(readings: [VitalReading], unit: String, strapDeviceId: Str } /// "9 Jun" for a "YYYY-MM-DD" reading day (today / yesterday read as words to match the hero "as of" -/// line); the verbatim string if it doesn't parse. UTC-fixed en_US_POSIX, matching this file's other date +/// line); the verbatim string if it doesn't parse. UTC-fixed and localized, matching this file's other date /// labels. Swift twin of Android's `vitalReadingDateLabel`. func vitalReadingDateLabel(_ day: String, now: Date = Date()) -> String { guard let date = parseDay(day) else { return day } @@ -241,17 +401,38 @@ func vitalReadingDateLabel(_ day: String, now: Date = Date()) -> String { if cal.isDate(date, inSameDayAs: now) { return String(localized: "Today") } if let yesterday = cal.date(byAdding: .day, value: -1, to: now), cal.isDate(date, inSameDayAs: yesterday) { return String(localized: "Yesterday") } - return readingShortDateFormatter.string(from: date) + let formatter = DateFormatter() + formatter.locale = AppLanguage.activeLocale + formatter.timeZone = TimeZone(identifier: "UTC") + formatter.dateFormat = "d MMM" + return formatter.string(from: date) } -/// "d MMM" (e.g. "9 Jun"), UTC / en_US_POSIX so the label is locale-stable, matching `longDate`. -private let readingShortDateFormatter: DateFormatter = { - let f = DateFormatter() - f.locale = Locale(identifier: "en_US_POSIX") - f.timeZone = TimeZone(identifier: "UTC") - f.dateFormat = "d MMM" - 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 @@ -526,13 +707,25 @@ 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 + /// Line vs bar for the hero chart, the same preference `TrendsView` reads. This view had never + /// consulted it, so a chosen `bar` drew a line here while the trend chart for the SAME metric drew + /// bars. Display-only: nothing about the plotted series changes. + @AppStorage(UnitPrefs.trendChartStyleKey) private var trendChartStyleRaw = TrendChartStyle.line.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) + if isStepsDetail { return MetricDetailSteps.valueLabel(v, resolution: .daily) } + return metric.format(v, system: unitSystem, temperature: temperatureUnit, effortScale: effortScale) } @State private var range: ExploreRange = .month @@ -557,6 +750,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 +764,10 @@ 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 { + MetricDetailSteps.loadIdentity(metricID: metric.id, refreshSequence: repo.refreshSeq, + skinTemperatureStyle: skinTempDisplayRaw, range: range) + } // MARK: Derived @@ -580,6 +783,15 @@ struct MetricDetailView: View { } } + private var isStepsDetail: Bool { MetricDetailSteps.isMetric(metric.key) } + + /// The one series every visible steps summary consumes. Non-step metrics retain their existing raw + /// daily window unchanged. + private func presentedSeries(for r: ExploreRange) -> [(day: String, value: Double)] { + guard isStepsDetail else { return slice(for: r) } + return MetricDetailSteps.presentation(readings: series, range: r).series + } + /// The range actually shown: the SELECTED range whenever its window holds ≥1 /// point, otherwise the smallest LARGER range that does. So switching ranges is /// always visibly distinct when data allows, and only sparse windows widen. @@ -603,7 +815,10 @@ struct MetricDetailView: View { private var effectiveRange: ExploreRange { guard !series.isEmpty else { return coercedSelection } - for r in coercedSelection.widening where !slice(for: r).isEmpty { return r } + let candidates = isStepsDetail + ? MetricDetailSteps.widening(from: coercedSelection) + : coercedSelection.widening + for r in candidates where !presentedSeries(for: r).isEmpty { return r } return .all } @@ -644,6 +859,10 @@ struct MetricDetailView: View { private func previousWindow(effectiveRange: ExploreRange, windowed: [(day: String, value: Double)]) -> [(day: String, value: Double)] { guard effectiveRange != .all else { return [] } + if isStepsDetail, let anchorDay = MetricDetailSteps.latestValidDay(readings: series) { + return MetricDetailSteps.previousPresentation( + readings: series, range: effectiveRange, currentAnchorDay: anchorDay).series + } let size = windowed.count guard size > 0 else { return [] } // Index of the active window's first row, then step back `size` rows. @@ -664,6 +883,29 @@ struct MetricDetailView: View { } } + /// The personal baseline to annotate the chart with, or nil when there is not one worth drawing. + /// + /// HRV and resting HR only: both are levels whose absolute number means little without the reader's + /// own normal, while the daily scores are already interpretable on their own ranges and skin + /// temperature has its own signed-deviation view. Folded over the FULL history rather than the + /// visible window, because the reference is the reader's normal and does not change because they + /// narrowed the range. Nil until the state is TRUSTED, which is at least fourteen valid nights and + /// not stale: a rule folded from four would be a guess wearing the authority of a reference line. + /// + /// Same fold `VitalBands` bands against, so a reading the grid calls out of range cannot sit on the + /// comfortable side of the rule. Twin of Kotlin `vitalBaseline`. + private var personalBaseline: Double? { + let cfg: MetricCfg? + switch metric.key { + case "hrv": cfg = Baselines.hrvCfg + case "rhr": cfg = Baselines.restingHRCfg + default: cfg = nil + } + guard let cfg else { return nil } + let state = Baselines.foldHistory(series.map { $0.value }, cfg: cfg) + return state.trusted ? state.baseline : nil + } + /// Padded value range so the line never sits flush against an axis. private func valueRange(_ windowValues: [Double]) -> ClosedRange { let v = windowValues @@ -673,7 +915,9 @@ struct MetricDetailView: View { return (lo - span * 0.12)...(hi + span * 0.12) } - private var latest: (day: String, value: Double)? { series.last } + private func latest(in presented: [(day: String, value: Double)]) -> (day: String, value: Double)? { + isStepsDetail ? presented.last : series.last + } // MARK: Body @@ -682,11 +926,12 @@ struct MetricDetailView: View { // the subviews — instead of every subview re-deriving `effectiveRange` / // `windowed` (each of which re-parses + re-filters the full history). let effRange = effectiveRange - let win = slice(for: effRange) + let rawWin = slice(for: effRange) + let win = presentedSeries(for: effRange) let fellBack = effRange != range return ScrollView { VStack(alignment: .leading, spacing: NoopMetrics.sectionGap) { - if loaded && series.isEmpty { + if loaded && win.isEmpty { // No data in the entire history — keep the range bar for context, then the // honest empty state (no scenic hero floating over nothing). Deliberately // NOT gated by the #943 chip locking: with zero data there is no chart for @@ -734,8 +979,27 @@ 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) + // Steps chart summaries are bucketed, but the provenance table deliberately remains + // one row per underlying observed day. + readingsTable(windowed: isStepsDetail ? rawWin : win) correlationCard } } @@ -791,11 +1055,32 @@ struct MetricDetailView: View { /// on it. Same reads, same results, same order; only the gate moved. private func load() async { // Phase 1 — what the screen actually draws. - series = await repo.exploreSeries(key: metric.key, source: metric.source) + let requestedRange = range + let resolution: MetricSeriesResolution + let loadedSeries: [(day: String, value: Double)] + if isStepsDetail { + // Steps have one authoritative daily input for values and provenance. ALL deliberately reads + // the store-backed full-history resolver instead of the bounded in-memory Explore cache. + let fullHistory = MetricDetailSteps.requiresFullHistory( + metricKey: metric.key, range: requestedRange) + if metric.source == MetricCatalog.combinedStepsSource { + resolution = await repo.resolvedSteps(from: "0000-01-01", to: "9999-12-31") + } else { + resolution = await repo.resolvedSeries( + key: metric.key, source: metric.source, fullHistory: fullHistory) + } + loadedSeries = resolution.values + } else { + // Preserve the generic explorer's established value path and load order. + loadedSeries = await repo.exploreSeries(key: metric.key, source: metric.source) + resolution = await repo.resolvedSeries(key: metric.key, source: metric.source) + } + // A range tap changes the task identity. Do not let a cancelled bounded read overwrite a newer + // full-history ALL read (or vice versa) if the underlying store await completes late. + guard !Task.isCancelled, !isStepsDetail || requestedRange == range else { return } + series = loadedSeries // Per-day provenance for the readings table (task #8). resolvedSeries names the source that - // actually supplied each day (imported strap / on-device / Apple Health / Health Connect); the - // chart still rides `series` above, so this only ADDS the source column, never moves the line. - let resolution = await repo.resolvedSeries(key: metric.key, source: metric.source) + // actually supplied each day (imported strap / on-device / Apple Health / Health Connect). if metric.key == "vo2max_est" { var attributed: [String: String] = [:] for point in resolution.points { @@ -840,6 +1125,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 @@ -881,10 +1241,16 @@ struct MetricDetailView: View { windowed: [(day: String, value: Double)], windowFellBack: Bool) -> some View { let domain = metricDomain(metric) - let value = latest?.value - let heroValue = latest.map { fmt($0.value) } ?? "—" + let latestPoint = latest(in: windowed) + let value = latestPoint?.value + let heroValue = latestPoint.map { fmt($0.value) } ?? "—" let asOf: String = { - guard let day = latest?.day, let d = parseDay(day) else { return "—" } + guard let day = latestPoint?.day else { return "—" } + if isStepsDetail { + return MetricDetailSteps.periodLabel( + day: day, resolution: MetricDetailSteps.resolution(for: effectiveRange)) + } + guard let d = parseDay(day) else { return "—" } return String(localized: "as of \(longDate(d))") }() let fraction = value.flatMap { metricGaugeFraction(metric, value: $0) } @@ -896,13 +1262,13 @@ struct MetricDetailView: View { VStack(alignment: .leading, spacing: NoopMetrics.gap) { // Category + title on their OWN full-width row so a long title ("Heart Rate Variability") // is never crushed into a letter-per-line column by the range pill (2026-07-02). - VStack(alignment: .leading, spacing: 2) { + if !isStepsDetail { VStack(alignment: .leading, spacing: 2) { Text(MetricCatalog.categoryDisplayName(metric.category).uppercased()).strandOverline() Text(metric.title) .font(StrandFont.title2) .foregroundStyle(StrandPalette.textPrimary) .fixedSize(horizontal: false, vertical: true) - } + } } // Range control on its own row beneath the title. SegmentedPillControl(ExploreRange.allCases, selection: selectionBinding, adaptsToAvailableWidth: true, @@ -1012,12 +1378,12 @@ struct MetricDetailView: View { windowed: windowed, windowFellBack: windowFellBack) return VStack(alignment: .leading, spacing: 8) { - VStack(alignment: .leading, spacing: 2) { + if !isStepsDetail { VStack(alignment: .leading, spacing: 2) { Text(MetricCatalog.categoryDisplayName(metric.category).uppercased()).strandOverline() Text(metric.title) .font(StrandFont.title2) .foregroundStyle(StrandPalette.textPrimary) - } + } } SegmentedPillControl(ExploreRange.allCases, selection: selectionBinding, adaptsToAvailableWidth: true, isEnabled: isUnlocked) { $0.label } @@ -1034,8 +1400,15 @@ struct MetricDetailView: View { private func rangeCaption(effectiveRange: ExploreRange, windowed: [(day: String, value: Double)], windowFellBack: Bool) -> String { - guard loaded, !series.isEmpty else { return "—" } + guard loaded, !windowed.isEmpty else { return "—" } let n = windowed.count + if isStepsDetail { + let name = windowFellBack + ? String(localized: "sparse, widened to \(effectiveRange.name)") + : effectiveRange.name + return MetricDetailSteps.countCaption( + count: n, resolution: MetricDetailSteps.resolution(for: effectiveRange), rangeName: name) + } if windowFellBack { return n == 1 ? String(localized: "1 reading · sparse, widened to \(effectiveRange.name)") @@ -1051,18 +1424,40 @@ struct MetricDetailView: View { private func heroChart(effectiveRange: ExploreRange, windowed: [(day: String, value: Double)], windowFellBack: Bool) -> some View { + let latestPoint = latest(in: windowed) let asOf: String = { - guard let day = latest?.day, let d = parseDay(day) else { return "—" } + guard let day = latestPoint?.day else { return "—" } + if isStepsDetail { + return MetricDetailSteps.periodLabel( + day: day, resolution: MetricDetailSteps.resolution(for: effectiveRange)) + } + guard let d = parseDay(day) else { return "—" } return String(localized: "as of \(longDate(d))") }() - let heroValue = latest.map { fmt($0.value) } ?? "—" - let subtitle = windowFellBack - ? String(localized: "Sparse, widened to \(effectiveRange.name) · \(windowed.count) readings") - : String(localized: "\(windowed.count) readings · \(range.name)") + let heroValue = latestPoint.map { fmt($0.value) } ?? "—" + let subtitle: String = { + if isStepsDetail { + let name = windowFellBack + ? String(localized: "sparse, widened to \(effectiveRange.name)") + : effectiveRange.name + return MetricDetailSteps.countCaption( + count: windowed.count, + resolution: MetricDetailSteps.resolution(for: effectiveRange), + rangeName: name) + } + return windowFellBack + ? String(localized: "Sparse, widened to \(effectiveRange.name) · \(windowed.count) readings") + : String(localized: "\(windowed.count) readings · \(range.name)") + }() + let stepsAccessibility = isStepsDetail + ? MetricDetailSteps.presentation(readings: series, range: effectiveRange).accessibilitySummary + : nil + let stepsResolution = MetricDetailSteps.resolution(for: effectiveRange) return ChartCard( - title: "\(metric.title)", + title: isStepsDetail ? LocalizedStringKey("Historical trend") : LocalizedStringKey("\(metric.title)"), subtitle: subtitle, trailing: "\(heroValue) · \(asOf)", + height: NoopMetrics.chartHeight + (isStepsDetail ? 70 : 0), tint: metricDomain(metric).color ) { TrendChart( @@ -1070,15 +1465,56 @@ struct MetricDetailView: View { gradient: metricGradient(metric), valueRange: valueRange(windowed.map(\.value)), showsArea: true, + // The chart-style setting, the same one `TrendsView` reads. This view had never consulted + // it, so a chosen `bar` drew a line here while the trend chart for the SAME metric drew + // bars. Steps deliberately override that global setting because their calendar buckets + // are discrete daily/weekly/monthly observations; every other metric still follows it. + showsBars: MetricDetailSteps.showsBars(metricKey: metric.key, + preferredStyleRaw: trendChartStyleRaw), + baselineValue: personalBaseline, height: NoopMetrics.chartHeight, - valueFormat: { fmt($0) } + valueFormat: { value in + isStepsDetail + ? MetricDetailSteps.valueLabel(value, resolution: stepsResolution) + : fmt(value) + }, + dateFormat: { date in + isStepsDetail + ? MetricDetailSteps.periodLabel( + day: strandDayParser.string(from: date), resolution: stepsResolution) + : TrendChart.defaultDateString(date) + }, + accessibilityLabel: stepsAccessibility, + yAxisStep: isStepsDetail ? 5000 : nil, + showsBarValues: isStepsDetail && (effectiveRange == .week || effectiveRange == .twoWeeks), + largeSelection: isStepsDetail ) } 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), + ]) + } } } @@ -1087,6 +1523,7 @@ struct MetricDetailView: View { private func statRow(effectiveRange: ExploreRange, windowed: [(day: String, value: Double)]) -> some View { let windowValues = windowed.map(\.value) + let latestPoint = latest(in: windowed) let s = ComparisonEngine.stat(windowValues) let cmp = ComparisonEngine.compare(current: windowValues, previous: previousWindow(effectiveRange: effectiveRange, @@ -1111,7 +1548,7 @@ struct MetricDetailView: View { // On iOS, Average summarizes the selected range, so it leads at the full // two-column width. StatTile(label: "Average", value: fmt(s.mean), - caption: s.n == 1 ? String(localized: "1 day") : String(localized: "\(s.n) days"), + caption: statisticCountCaption(count: s.n, effectiveRange: effectiveRange), accent: accent, sparkline: windowValues.count > 1 ? windowValues : nil, sparkColor: accent) @@ -1130,8 +1567,8 @@ struct MetricDetailView: View { caption: deltaCaption, accent: StrandPalette.textPrimary, delta: cmp.pctChange.map { "\($0 >= 0 ? "+" : "")\(String(format: "%.1f", $0))%" }, deltaColor: deltaColor) - StatTile(label: "Latest", value: latest.map { fmt($0.value) } ?? "—", - caption: latestCaption, accent: accent) + StatTile(label: "Latest", value: latestPoint.map { fmt($0.value) } ?? "—", + caption: latestCaption(windowed: windowed, effectiveRange: effectiveRange), accent: accent) } } #else @@ -1144,7 +1581,7 @@ struct MetricDetailView: View { spacing: NoopMetrics.gap ) { StatTile(label: "Average", value: fmt(s.mean), - caption: s.n == 1 ? String(localized: "1 day") : String(localized: "\(s.n) days"), + caption: statisticCountCaption(count: s.n, effectiveRange: effectiveRange), accent: accent, sparkline: windowValues.count > 1 ? windowValues : nil, sparkColor: accent) @@ -1152,8 +1589,8 @@ struct MetricDetailView: View { accent: StrandPalette.textPrimary) StatTile(label: "Max", value: fmt(s.max), accent: StrandPalette.textPrimary) - StatTile(label: "Latest", value: latest.map { fmt($0.value) } ?? "—", - caption: latestCaption, accent: accent) + StatTile(label: "Latest", value: latestPoint.map { fmt($0.value) } ?? "—", + caption: latestCaption(windowed: windowed, effectiveRange: effectiveRange), accent: accent) StatTile(label: "Δ vs prev", value: deltaText ?? "—", caption: deltaCaption, accent: StrandPalette.textPrimary, delta: cmp.pctChange.map { "\($0 >= 0 ? "+" : "")\(String(format: "%.1f", $0))%" }, @@ -1162,11 +1599,35 @@ struct MetricDetailView: View { #endif } - private var latestCaption: String? { - guard let day = latest?.day, let d = parseDay(day) else { return nil } + private func latestCaption(windowed: [(day: String, value: Double)], + effectiveRange: ExploreRange) -> String? { + guard let day = latest(in: windowed)?.day else { return nil } + if isStepsDetail { + return MetricDetailSteps.periodLabel( + day: day, resolution: MetricDetailSteps.resolution(for: effectiveRange)) + } + guard let d = parseDay(day) else { return nil } return longDate(d) } + private func statisticCountCaption(count: Int, effectiveRange: ExploreRange) -> String { + guard isStepsDetail else { + return count == 1 ? String(localized: "1 day") : String(localized: "\(count) days") + } + switch MetricDetailSteps.resolution(for: effectiveRange) { + case .daily: + return count == 1 ? String(localized: "1 observed day") : String(localized: "\(count) observed days") + case .weekly: + return count == 1 + ? String(localized: "1 week · averages per observed day") + : String(localized: "\(count) weeks · averages per observed day") + case .monthly: + return count == 1 + ? String(localized: "1 month · averages per observed day") + : String(localized: "\(count) months · averages per observed day") + } + } + // MARK: Readings table (task #8) /// The per-reading breakdown below the stats, so the provenance behind the trend is visible — whether @@ -1179,7 +1640,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 .. Bool { + guard seq != lastSeq else { return false } + lastSeq = seq + return true + } +} diff --git a/Strand/Screens/RawDataCollectorView.swift b/Strand/Screens/RawDataCollectorView.swift new file mode 100644 index 0000000000..f2a378f073 --- /dev/null +++ b/Strand/Screens/RawDataCollectorView.swift @@ -0,0 +1,402 @@ +import SwiftUI +import StrandDesign +import WhoopStore + +/// iOS/macOS parity twin of Android's 5/MG Raw Data Collector screen. +struct RawDataCollectorView: View { + @EnvironmentObject private var model: AppModel + @EnvironmentObject private var live: LiveState + @StateObject private var store = RawDataSessionStore() + + @State private var exportingId: String? + @State private var deleteCandidate: RawDataSessionStore.Session? + @State private var confirmDeleteAll = false + @State private var exportError: String? + @State private var deleteError: String? + @State private var imuCoverage: [String: String] = [:] + @State private var historicalFrom = Date().addingTimeInterval(-3_600) + @State private var historicalTo = Date() + @State private var markerDraft: MarkerDraft? + + private struct MarkerDraft: Identifiable { + let id = UUID() + let sessionId: String + let markerId: String? + var at: Date + var type: String + var text: String + } + + var body: some View { + ScreenScaffold( + title: "5/MG Raw Data Collector", + subtitle: "Record a bounded 100 Hz motion session and export its complete timeline." + ) { + VStack(alignment: .leading, spacing: NoopMetrics.sectionSpacing) { + coverageCard + controls + historicalRangeCard + sessionsSection + } + } + .task { + // Restore a session after navigation/process lifecycle changes. The BLE layer rejects a + // duplicate arm, so this is safe when capture never stopped. + if let active = store.active, live.bonded { _ = model.ble.startGroundTruthRawCapture(sessionId: active.id) } + await refreshImuCoverage() + } + .onChangeCompat(of: live.bonded) { bonded in + if bonded, let active = store.active { _ = model.ble.startGroundTruthRawCapture(sessionId: active.id) } + } + .confirmationDialog("Delete this session?", isPresented: Binding( + get: { deleteCandidate != nil }, set: { if !$0 { deleteCandidate = nil } } + ), titleVisibility: .visible) { + Button("Delete", role: .destructive) { + guard let session = deleteCandidate else { return } + deleteCandidate = nil + Task { await delete(session) } + } + Button("Cancel", role: .cancel) { deleteCandidate = nil } + } message: { + if let session = deleteCandidate { + Text("The session \(Self.range(session)) and its captured raw data will be deleted permanently.") + } + } + .confirmationDialog("Delete all sessions?", isPresented: $confirmDeleteAll, + titleVisibility: .visible) { + Button("Delete all", role: .destructive) { Task { await deleteAll() } } + Button("Cancel", role: .cancel) { } + } message: { + Text("All \(store.sessions.count) recorded sessions and their captured raw data will be deleted permanently.") + } + .alert("Couldn't export session", isPresented: Binding( + get: { exportError != nil }, set: { if !$0 { exportError = nil } } + )) { + Button("OK", role: .cancel) { exportError = nil } + } message: { Text(exportError ?? "Unknown error") } + .alert("Couldn't delete session", isPresented: Binding( + get: { deleteError != nil }, set: { if !$0 { deleteError = nil } } + )) { + Button("OK", role: .cancel) { deleteError = nil } + } message: { Text(deleteError ?? "Unknown error") } + .sheet(item: $markerDraft) { draft in markerSheet(draft) } + } + + private var coverageCard: some View { + StrandCard { + VStack(alignment: .leading, spacing: NoopMetrics.space2) { + Text("Capture coverage").font(StrandFont.headline).foregroundStyle(StrandPalette.textPrimary) + Text(live.connected ? "Band: connected\(live.bonded ? " + paired" : "; pairing")" + : "Band: disconnected") + .foregroundStyle(live.connected ? StrandPalette.statusPositive : StrandPalette.statusCritical) + Text(live.backfilling + ? "History sync: running (\(live.syncChunksThisSession) chunks)" + : "History sync: idle") + .foregroundStyle(StrandPalette.textSecondary) + if let active = store.active { + Text("Realtime IMU: session active since \(Self.time(active.startedAtMs))") + .foregroundStyle(StrandPalette.accent) + } + } + .font(StrandFont.subhead) + } + } + + @ViewBuilder private var controls: some View { + if store.active != nil { + NoopButton("Stop session", systemImage: "stop.fill", kind: .destructive, + fullWidth: true) { Task { await stop() } } + } else { + NoopButton("Start raw-data session", systemImage: "record.circle", kind: .primary, + fullWidth: true) { start() } + .disabled(!live.bonded) + } + } + + private var historicalRangeCard: some View { + StrandCard { + VStack(alignment: .leading, spacing: NoopMetrics.space3) { + Text("Historical export window").font(StrandFont.headline) + Text("Create a session from synchronized history without starting a live capture. 100 Hz coverage is included wherever it still exists in the rolling buffer.") + .font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary) + DatePicker("From", selection: $historicalFrom) + DatePicker("To", selection: $historicalTo, in: historicalFrom...) + NoopButton("Add historical session", systemImage: "clock.arrow.circlepath", + kind: .secondary, fullWidth: true) { + _ = store.createHistorical(deviceId: model.ble.deviceId, + from: historicalFrom, to: historicalTo) + } + .disabled(historicalTo <= historicalFrom || historicalTo.timeIntervalSince(historicalFrom) > 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 ff370f3d8c..e0f0cdf31f 100644 --- a/Strand/Screens/SettingsView.swift +++ b/Strand/Screens/SettingsView.swift @@ -35,12 +35,10 @@ struct SettingsView: View { @State private var backupAlertTitle = "" @State private var backupAlertMessage = "" @State private var showBackupAlert = false - - /// Opt-in WHOOP 5/MG protocol experiments (off by default). See [PuffinExperiment]. - @AppStorage(PuffinExperiment.defaultsKey) private var puffinExperiments = false - - /// Opt-in WHOOP 5/MG raw-frame capture to a file (off by default). See [PuffinFrameRecorder]. - @AppStorage(PuffinFrameRecorder.enabledKey) private var puffinCapture = 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 "R22" deep-data unlock (off by default) — the one probe that writes a /// persistent feature flag to the strap. See [PuffinExperiment.deepDataKey]. (#174) @@ -53,86 +51,16 @@ struct SettingsView: View { /// connected, and a write to bonded hardware is not something a toggle should do unannounced. @State private var confirmingDeepDataDisable = false - /// Opt-in "Broadcast heart rate" (off by default) — makes the strap advertise its HR as a standard - /// BLE sensor for Garmin/Zwift/gym kit. See [PuffinExperiment.broadcastHrKey]. (#181) - @AppStorage(PuffinExperiment.broadcastHrKey) private var broadcastHrEnabled = false - - /// #891 opt-in: writes the device-config key `enable_raw_data_w_ecg` on an attested WHOOP MG. A - /// persistent strap write, so it gets its own deliberate switch like #174 and #181. - /// See [PuffinExperiment.ecgRawDataKey]. - @AppStorage(PuffinExperiment.ecgRawDataKey) private var ecgRawDataEnabled = false - /// #103 opt-in: surfaces the WHOOP 5/MG `spo2_candidate_82` nightly mean in the Blood Oxygen tile /// as a "strap estimate (unverified)" fallback when no calibrated `spo2Pct` exists. Display-only — /// writes nothing to the strap. See [PuffinExperiment.spo2CandidateDisplayKey]. @AppStorage(PuffinExperiment.spo2CandidateDisplayKey) private var spo2CandidateDisplayEnabled = false - /// #463 opt-in: score the intraday stress timeline against a PERSONAL cross-day baseline - /// (`.baselineRelative`) instead of the day's own calm hours. Default off — the r≈0.6 margin is - /// single-subject so far. Display-only; never feeds recovery/illness. See - /// [PuffinExperiment.stressPersonalBaselineKey]. - @AppStorage(PuffinExperiment.stressPersonalBaselineKey) private var stressPersonalBaselineEnabled = false /// #1545 opt-in: score Effort with Banister's exponential TRIMP instead of Edwards' heart-rate zones. /// Default OFF — it re-scores the whole window against a different recipe. See /// [PuffinExperiment.banisterEffortKey]. @AppStorage(PuffinExperiment.banisterEffortKey) private var banisterEffortEnabled = false - /// True when the connected strap has positively attested itself a WHOOP MG. The variant is published as - /// its label string (`LiveState.whoop5Variant`); "MG" is `Whoop5Variant.mg.label`. nil / not-yet- - /// identified / a plain 5.0 is not an MG. - private var ecgVariantIsMG: Bool { live.whoop5Variant == Whoop5Variant.mg.label } - - /// The #891 ECG-gate buttons need the same encrypted bond the R22 writes do (a config write over the - /// live-HR-only link silently fails, #269) AND a strap that has positively attested itself an MG. Not - /// wear-gated: this stores a value, it does not start an on-wrist stream. - private var ecgGateReady: Bool { - #if os(macOS) - return false - #else - return live.encryptedBond && ecgVariantIsMG - #endif - } - - /// The reason line under the #891 buttons. Each case names the ONE thing that is missing. - private var ecgGateReason: String { - #if os(macOS) - return String(localized: "The ECG gate needs an iPhone or Android. A Mac can't form the encrypted bond a 5/MG requires.") - #else - if !live.encryptedBond { - return String(localized: "Needs the full encrypted bond: close the official WHOOP app and pair the strap to NOOP first (a live-HR-only link can't carry a config write).") - } - if !ecgVariantIsMG { - // A nil / non-MG variant lands here too, and deliberately: an unattested strap is not an MG. - return String(localized: "Waiting for your strap to identify itself as an MG. Only a WHOOP MG has ECG electrodes, so NOOP won't write this key to anything else.") - } - return String(localized: "One tap writes the key; NOOP then reads it back off the strap and reports the value it actually stores — the write's own \"success\" is not treated as proof.") - #endif - } - - /// Icon per read-back verdict. Only a confirmed read-back gets the success mark. - private func ecgGateIcon(_ v: EcgRawDataGateReport.Verdict) -> String { - switch v { - case .confirmed: return "checkmark.seal.fill" - case .unchanged: return "xmark.seal.fill" - case .pending: return "ellipsis" - default: return "questionmark.circle" - } - } - - /// Tint per read-back verdict. Anything that isn't a confirmed read-back is never shown as positive. - private func ecgGateTint(_ v: EcgRawDataGateReport.Verdict) -> Color { - switch v { - case .confirmed: return StrandPalette.statusPositive - case .unchanged: return StrandPalette.statusWarning - default: return StrandPalette.textSecondary - } - } - - /// WHOOP MG ECG ("Labrador") experiment. Unlocks the gated, user-initiated ECG probe on the Devices - /// card. Default off; with it off the four ECG opcodes are dropped by the command allowlist, so no - /// ECG byte can reach a strap. See [PuffinExperiment.ecgKey]. - @AppStorage(PuffinExperiment.ecgKey) private var ecgEnabled = false - /// Opt-in "Continuous HRV capture" (off by default) — holds the dense realtime stream armed 24/7 so /// the strap banks beat-to-beat R-R for better overnight HRV/recovery/sleep, at a battery cost. /// See [PuffinExperiment.keepRealtimeForDataKey]. @@ -166,11 +94,23 @@ 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. + /// The Coach master switch, under the same `noop.` key Android writes. Default ON, so nothing changes + /// for an install that never opens this row. Read by `RootTabView` (the tab), Today (the launcher card) + /// and `CoachBriefScheduler` (the daily background brief). + @AppStorage("noop.coachEnabled") private var coachEnabled = true + @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 +118,9 @@ 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 + // Strap-sync Live Activity, iOS only. Separate from the live-HR one on purpose. Default on. + @AppStorage(UnitPrefs.syncLiveActivityKey) private var syncLiveActivityEnabled = 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 @@ -224,6 +167,10 @@ struct SettingsView: View { /// verbatim with the Android twin (SharedPreferences "workoutKeepScreenOn"). @AppStorage("workoutKeepScreenOn") private var workoutKeepScreenOn = false + /// Opt-in "Keep screen on while syncing" (default OFF, iOS only). `SyncKeepAwake` holds the screen awake + /// for as long as a strap history sync runs while this is on. + @AppStorage(ScreenIdle.strapSyncKeepAwakeKey) private var syncKeepScreenOn = false + /// The strap model the user last picked (same key the scan pickers write). Gates the WHOOP 4.0-only /// rename control in the strap card — renaming uses the Harvard command set, which a 5/MG doesn't share. @AppStorage("selectedWhoopModel") private var selectedWhoopModelRaw = WhoopModel.whoop4.rawValue @@ -244,8 +191,11 @@ struct SettingsView: View { } private var unitSystem: UnitSystem { UnitSystem(rawValue: unitSystemRaw) ?? .metric } - private var temperatureUnit: TemperatureUnit { - UnitPrefs.resolveTemperature(system: unitSystem, override: temperatureRaw) + private var distanceUnitSystem: UnitSystem { + UnitPrefs.resolveDistance(system: unitSystem, override: distanceSystemRaw) + } + private var distanceSystemBinding: Binding { + Binding(get: { distanceUnitSystem.rawValue }, set: { distanceSystemRaw = $0 }) } /// Raw-sensor CSV export (experimental diagnostic, #308/#276/#322). Holds the last-written file so @@ -253,12 +203,6 @@ struct SettingsView: View { @State private var rawCsvBusy = false @State private var lastRawCsvURL: URL? - /// #646/#651: the "Export raw + log" button's zip build now runs off the main actor, so a second tap - /// mid-export would fire a second `exportPair` — two staged zips, two save panels / stacked share - /// sheets (see the `present(activityItems:)` #455 comment). Same disable-while-busy guard as - /// `rawCsvBusy` above. - @State private var rawAndLogBusy = false - /// Passive WHOOP 5/MG optical experiment: the picker writes local timestamp markers into the /// durable deep-buffer JSONL. It never calls a BLE write path. @State private var showOpticalPhasePicker = false @@ -294,6 +238,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 & @@ -318,6 +265,9 @@ struct SettingsView: View { strapCard.staggeredAppear(index: 3) streakCard.staggeredAppear(index: 4) featuresCard.staggeredAppear(index: 5) + #if os(iOS) + syncCard.staggeredAppear(index: 6) + #endif // Lower-frequency sections collapse behind a single default-closed disclosure so the // screen opens at ~6 sections instead of 11. Nothing is removed; every section here @@ -345,6 +295,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 +473,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 +918,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 +962,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 +1111,60 @@ 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() } + } + rowDivider + // The Coach master switch. Offered on BOTH platforms, not only where the bottom bar is: it + // does not hide chrome, it turns the AI off, and macOS reaches the same Coach from its + // sidebar. With this off the tab (or sidebar row) goes, the Today launcher card goes, and + // the daily brief is cancelled -- the brief being the surface that would otherwise keep + // calling a provider from the background with no UI to reveal it. The saved provider key is + // kept, so this is a flip rather than a re-setup. + FormRow(label: "AI Coach") { + Toggle("", isOn: $coachEnabled) + .labelsHidden() + .tint(StrandPalette.accent) + .accessibilityLabel("AI Coach") + } + .onChangeCompat(of: coachEnabled) { on in + // Switching the AI off has to TAKE DOWN what the brief already published, not just stop the + // next one: the widget renders the last brief it was given, so without this a wearer would + // still be looking at AI output on their home screen after turning the AI off. + CoachBriefScheduler.applyMasterSwitch(on) + } + #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") { @@ -1417,6 +1475,21 @@ struct SettingsView: View { .font(StrandFont.caption) .foregroundStyle(StrandPalette.textTertiary) .fixedSize(horizontal: false, vertical: true) + + rowDivider + // MARK: Strap-sync Live Activity — its own switch, independent of the live-HR one. + Toggle(isOn: $syncLiveActivityEnabled) { + Text("Strap sync in Dynamic Island") + .font(StrandFont.subhead) + .foregroundStyle(StrandPalette.textPrimary) + } + .toggleStyle(.switch) + .tint(StrandPalette.accent) + .accessibilityHint("Shows sync progress on the Lock Screen and in the Dynamic Island") + Text("Shows Connecting… / Syncing… with the chunk count and elapsed time while NOOP pulls history from your strap, including a sync started by the Sync Strap shortcut. Independent of the live heart rate switch above.") + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) #endif } } @@ -1471,10 +1544,27 @@ struct SettingsView: View { } private var strapStatusDetail: String { - if live.bonded && live.connected { + // encryptedBond, not bonded — see LiveState.connectionStatusLabel. Saying "is paired" for a + // live-HR-only link contradicts both LiveView's pill and the buzz/alarm rows on this same screen, + // which correctly refuse and explain that they need the full encrypted bond. + // + // 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. 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.") @@ -1593,7 +1683,7 @@ struct SettingsView: View { .tint(StrandPalette.accent) .accessibilityHint("Offers to save a workout when it spots sustained elevated heart rate") - Text("After a sync, NOOP looks over your recent heart rate for a sustained, raised stretch that looks like exercise and offers to save it. It only ever suggests. Nothing is saved until you tap Save, and you can dismiss any suggestion. Deliberately conservative, so the odd workout may be missed. On \(Platform.deviceNounPhrase) only.") + Text("After a sync, NOOP looks over your recent heart rate for a sustained, raised stretch that looks like exercise and offers to save it. It only ever suggests. Nothing is saved until you tap Save, and you can dismiss any suggestion. Turning this off stops future suggestions but keeps your existing workout history. Deliberately conservative, so the odd workout may be missed. On \(Platform.deviceNounPhrase) only.") .font(StrandFont.caption) .foregroundStyle(StrandPalette.textTertiary) .fixedSize(horizontal: false, vertical: true) @@ -1633,6 +1723,36 @@ struct SettingsView: View { } } + #if os(iOS) + // MARK: - Sync (iOS) + + /// Behaviour while a strap history sync runs. Its own section rather than a row under Features, which holds + /// optional trackers. `SyncKeepAwake` reads the same key. + private var syncCard: some View { + SettingsSection( + icon: "arrow.triangle.2.circlepath", + title: "Sync", + blurb: "How NOOP behaves while it pulls stored history from your strap." + ) { + VStack(alignment: .leading, spacing: NoopMetrics.space2 + 2) { + Toggle(isOn: $syncKeepScreenOn) { + Text("Keep screen on while syncing") + .font(StrandFont.subhead) + .foregroundStyle(StrandPalette.textPrimary) + } + .toggleStyle(.switch) + .tint(StrandPalette.accent) + .accessibilityHint("Stops the screen locking while your strap's history syncs") + + Text("Holds the screen awake while NOOP pulls stored history from your strap, so you can watch a long sync finish without the phone locking. Only applies while a sync is running and NOOP is open. The screen sleeps normally the rest of the time. It uses a bit more battery while the screen stays on.") + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + #endif + // MARK: - Backup & restore // MARK: - Experimental (WHOOP 5 / MG) @@ -1714,7 +1834,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 @@ -1812,391 +1933,8 @@ struct SettingsView: View { } } - /// The R22 "send enable sequence" button is structurally impossible on macOS — a Mac can't form the - /// encrypted bond a 5/MG needs to accept the write (BLEManager forms a live-HR-only link there), so it - /// stays disabled regardless of bond/wear state (#587). On iOS/Android it gates on the real bond + wear. - private var deepDataButtonDisabled: Bool { - #if os(macOS) - return true - #else - return !live.encryptedBond || !live.worn - #endif - } - - /// The reason line under the R22 button. macOS gets an explicit "needs an iPhone/Android" message - /// rather than the misleading "needs the full encrypted bond" one (a Mac can never get that bond). - private var deepDataButtonReason: String { - #if os(macOS) - return String(localized: "Deep data (R22) needs an iPhone or Android. A Mac can't form the encrypted bond a 5/MG requires.") - #else - if !live.encryptedBond { - return String(localized: "Needs the full encrypted bond: close the official WHOOP app and pair the strap to NOOP first (a live-HR-only link can't carry the unlock).") - } - return live.worn - ? String(localized: "Wear the strap, tap once, then let it sync and share your strap log.") - : String(localized: "Put the strap on first. The deep stream is on-wrist only.") - #endif - } - - /// How many flags the enable sequence actually writes. Read from the sequence rather than restated, so - /// the card cannot drift from it again — it said "15" for the whole life of the 16-flag sequence (#174). - private var r22FlagCount: Int { Whoop5Config.enableR22Sequence.count } - - /// The disable button is gated like the enable one MINUS the wear check: the on-wrist requirement exists - /// because the R22 *stream* is on-wrist gated, and nothing about clearing a stored flag needs the strap - /// worn. Making a user strap it back on to turn a feature off would be the wrong trade. - private var deepDataDisableButtonDisabled: Bool { - #if os(macOS) - return true - #else - return !live.encryptedBond || live.r22DisableReport == BLEManager.deviceConfigProbeWaiting - #endif - } - - /// The reason line under the disable button. Says what the run will do and, crucially, what it can and - /// cannot establish — the off value is inferred from the sibling namespace, so the read-back is the - /// evidence, and a cleared flag is still not the same as observed behaviour reverting. - private var deepDataDisableButtonReason: String { - #if os(macOS) - return String(localized: "Turning deep data back off needs an iPhone or Android. A Mac can't form the encrypted bond a 5/MG requires.") - #else - if !live.encryptedBond { - return String(localized: "Needs the full encrypted bond: close the official WHOOP app and pair the strap to NOOP first (a live-HR-only link can't carry the write).") - } - return String(localized: "Writes the off value to all 16 flags and reads each one back, so you see what the strap stores rather than just that it acked. The off value is inferred from the flag NOOP already turns off this way for Garmin broadcast — it has never been seen on an R22 flag, so NOOP tries one flag first and stops if the strap refuses it. Clearing the flags is not the same as watching the deep records stop: check that by syncing afterwards.") - #endif - } - - private var fiveMGCard: some 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." - ) { - VStack(alignment: .leading, spacing: NoopMetrics.rowSpacing) { - Toggle(isOn: $puffinExperiments) { - Text("Try WHOOP 5/MG protocol probes") - .font(StrandFont.subhead) - .foregroundStyle(StrandPalette.textPrimary) - } - .toggleStyle(.switch) - .tint(StrandPalette.accent) - Text("On a 5/MG connection NOOP will send a puffin realtime-stream request after the handshake, and log what comes back. If you have a 5/MG strap, turning this on and sharing your strap log helps map the protocol. No effect on WHOOP 4.0.") - .font(StrandFont.caption) - .foregroundStyle(StrandPalette.textTertiary) - .fixedSize(horizontal: false, vertical: true) - - rowDivider - - // MARK: R22 deep-data unlock — the one probe that writes to the strap. - Toggle(isOn: $deepDataEnabled) { - Text("Unlock WHOOP 5/MG deep data (R22)") - .font(StrandFont.subhead) - .foregroundStyle(StrandPalette.textPrimary) - } - .toggleStyle(.switch) - .tint(StrandPalette.accent) - // #174: turning the switch OFF used to write nothing — it only hid the enable button, so the - // 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.") - .font(StrandFont.caption) - .foregroundStyle(StrandPalette.textTertiary) - .fixedSize(horizontal: false, vertical: true) - - if deepDataEnabled { - NoopButton("Send enable sequence to strap", systemImage: "bolt.badge.automatic", kind: .primary) { - model.ble.enableWhoop5DeepData() - } - .disabled(deepDataButtonDisabled) - Text(deepDataButtonReason) - .font(StrandFont.caption) - .foregroundStyle(StrandPalette.textTertiary) - - // #174: the undo. Offered whenever the flags may be set — which is any time the opt-in - // has been on, not only right after a send, because the flags persist across launches - // and the app has no record of what a previous install wrote. - NoopButton("Turn deep data back off", systemImage: "bolt.slash", kind: .secondary) { - model.ble.disableWhoop5DeepData() - } - .disabled(deepDataDisableButtonDisabled) - Text(deepDataDisableButtonReason) - .font(StrandFont.caption) - .foregroundStyle(StrandPalette.textTertiary) - - // Live R22 telemetry (#174): proof of what the strap is doing right now. - // The threshold and the number are both driven off the sequence itself — they were - // hardcoded to 15 while the sequence carried 16, so the card declared success one flag - // early and named a count that had already drifted. - if live.r22FlagsAccepted > 0 { - Label(live.r22FlagsAccepted >= r22FlagCount - ? "Strap accepted all \(r22FlagCount) R22 flags" - : "Strap accepted \(live.r22FlagsAccepted)/\(r22FlagCount) R22 flags…", - systemImage: live.r22FlagsAccepted >= r22FlagCount ? "checkmark.seal.fill" : "ellipsis") - .font(StrandFont.caption) - .foregroundStyle(live.r22FlagsAccepted >= r22FlagCount ? StrandPalette.statusPositive : StrandPalette.textSecondary) - } - if live.deepPacketsThisSession > 0 { - Label(live.deepPacketsThisSession == 1 - ? "1 type-0x2F historical-offload frame seen outside our sync. These are history (e.g. another app pulling the strap's backlog), not a live R22 stream (#494)." - : "\(live.deepPacketsThisSession) type-0x2F historical-offload frames seen outside our sync. These are history (e.g. another app pulling the strap's backlog), not a live R22 stream (#494).", - systemImage: "clock.arrow.circlepath") - .font(StrandFont.caption) - .foregroundStyle(StrandPalette.textSecondary) - } else if live.r22FlagsAccepted >= r22FlagCount { - Text("Flags accepted, but the enable sequence doesn't start a separate live stream. The deep records arrive as part of the normal history sync (#494).") - .font(StrandFont.caption) - .foregroundStyle(StrandPalette.textTertiary) - } - - } - - // #174: the disable run's per-key result. Shown verbatim because the interesting part is - // the read-back table, not a green tick — a write that acked SUCCESS but did not move - // the stored value renders here as "unchanged", which is the case worth seeing. - // - // OUTSIDE the `if deepDataEnabled` block on purpose. The commonest way to reach a disable - // run is flipping the switch OFF and confirming, which means the pref is already false while - // the run is walking its plan — so nesting this inside that block hid the progress line and - // the whole read-back table for exactly the run a user is most likely to start. The report - // is about what is on the STRAP, which outlives the app's opt-in: it stays legible (and - // dismissable) whatever the switch says. - if let report = live.r22DisableReport { - if report == BLEManager.deviceConfigProbeWaiting { - Label("Clearing R22 flags and reading each one back\u{2026}", systemImage: "ellipsis") - .font(StrandFont.caption) - .foregroundStyle(StrandPalette.textSecondary) - } else { - Text(report) - .font(StrandFont.caption.monospaced()) - .foregroundStyle(StrandPalette.textSecondary) - .fixedSize(horizontal: false, vertical: true) - .textSelection(.enabled) - NoopButton("Dismiss disable report", systemImage: "xmark", kind: .secondary) { - model.ble.clearR22DisableReport() - } - } - } - - rowDivider - - // MARK: Broadcast HR — make the strap a standard BLE HR sensor (Garmin/Zwift/gym). - Toggle(isOn: $broadcastHrEnabled) { - Text("Broadcast strap HR (Garmin/ANT)") - .font(StrandFont.subhead) - .foregroundStyle(StrandPalette.textPrimary) - } - .toggleStyle(.switch) - .tint(StrandPalette.accent) - .onChangeCompat(of: broadcastHrEnabled) { on in model.ble.setBroadcastHr(on) } - Text("Makes your WHOOP 5.0/MG advertise its heart rate as a standard Bluetooth HR sensor, so a Garmin (Edge/watch), Zwift or gym equipment can use it during a workout. Applied on the next connection (and immediately if connected); writes the strap's whoop_live_hr_in_adv_ind_pkt flag. Reversible. iPhone-side only. A Mac can't write to a 5/MG.") - .font(StrandFont.caption) - .foregroundStyle(StrandPalette.textTertiary) - .fixedSize(horizontal: false, vertical: true) - - // #573: leaving broadcast on keeps the strap radio advertising continuously, which drains - // the strap faster — make that visible and persistent so it isn't left on by accident. - if broadcastHrEnabled { - HStack(alignment: .top, spacing: 8) { - Image(systemName: "antenna.radiowaves.left.and.right") - .foregroundStyle(StrandPalette.statusWarning) - .accessibilityHidden(true) - Text("Broadcast HR is ON. Your strap is advertising its heart rate continuously, which keeps its radio hot and drains the battery faster. Turn it off when you're not using it with another device.") - .font(StrandFont.caption) - .foregroundStyle(StrandPalette.statusWarning) - .fixedSize(horizontal: false, vertical: true) - } - .frame(maxWidth: .infinity, alignment: .leading) - .accessibilityElement(children: .combine) - } - - // MARK: #463 Personal daytime-stress baseline — score today's timeline vs a personal - // cross-day baseline instead of the day's own calm hours. Off by default. - Divider().overlay(StrandPalette.hairline) - - Toggle(isOn: $stressPersonalBaselineEnabled) { - Text("Stress: personal daytime baseline") - .font(StrandFont.subhead) - .foregroundStyle(StrandPalette.textPrimary) - } - .toggleStyle(.switch) - .tint(StrandPalette.accent) - Text("Scores your hour-by-hour stress timeline against YOUR own cross-day baseline (how your days usually run, Oura-style) instead of the day's own calm hours. Needs a few worn days; until then it stays on the default. The high-stress cutoff is tuned from a single-subject reference so far, so it's an alternative lens rather than the default. HR-only, and it never feeds recovery or illness scoring. Off by default.") - .font(StrandFont.caption) - .foregroundStyle(StrandPalette.textTertiary) - .fixedSize(horizontal: false, vertical: true) - - - // MARK: #891 ECG raw-data gate — the second device-config key this app may write, MG-only. - Divider().overlay(StrandPalette.hairline) - - Toggle(isOn: $ecgRawDataEnabled) { - Text("ECG raw-data gate (WHOOP MG only)") - .font(StrandFont.subhead) - .foregroundStyle(StrandPalette.textPrimary) - } - .toggleStyle(.switch) - .tint(StrandPalette.accent) - Text("Your strap listed its own device-config keys, and one of them is enable_raw_data_w_ecg. On an MG with no ECG subscription it reads '0' — while all three ECG commands answer SUCCESS and send no data at all (#891). This is the leading guess for what's holding ECG shut. Nobody knows whether flipping it actually produces ECG data: finding out is the point, and \"still nothing\" is a useful answer worth posting to #891.") - .font(StrandFont.caption) - .foregroundStyle(StrandPalette.textTertiary) - .fixedSize(horizontal: false, vertical: true) - - if ecgRawDataEnabled { - HStack(alignment: .top, spacing: 8) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(StrandPalette.statusWarning) - .accessibilityHidden(true) - Text("This writes a setting that STAYS ON YOUR STRAP until you change it back — it isn't an app preference, and closing NOOP won't undo it. \"Turn gate off\" below writes '0' again, in one tap. Only this one key is ever written; the other six your strap listed are never touched.") - .font(StrandFont.caption) - .foregroundStyle(StrandPalette.statusWarning) - .fixedSize(horizontal: false, vertical: true) - } - .frame(maxWidth: .infinity, alignment: .leading) - .accessibilityElement(children: .combine) - - NoopButton("Turn gate on (write '1')", systemImage: "waveform.path.ecg", kind: .primary) { - model.ble.setEcgRawDataGate(true) - } - .disabled(!ecgGateReady) - NoopButton("Turn gate off (write '0')", systemImage: "arrow.uturn.backward", kind: .secondary) { - model.ble.setEcgRawDataGate(false) - } - .disabled(!ecgGateReady) - Text(ecgGateReason) - .font(StrandFont.caption) - .foregroundStyle(StrandPalette.textTertiary) - .fixedSize(horizontal: false, vertical: true) - - // The read-back — the ONLY thing reported as a result. The write's own ack is in the - // strap log for the record and is deliberately not surfaced as an outcome here. - if let report = live.ecgRawDataGate { - Label(report.summary, systemImage: ecgGateIcon(report.verdict)) - .font(StrandFont.caption) - .foregroundStyle(ecgGateTint(report.verdict)) - .fixedSize(horizontal: false, vertical: true) - } - } - - Divider().overlay(StrandPalette.hairline) - - // MARK: WHOOP MG ECG (Labrador) — MG-only, writes ECG control commands. NOT medical. - Toggle(isOn: $ecgEnabled) { - Text("WHOOP MG ECG capture (experimental)") - .font(StrandFont.subhead) - .foregroundStyle(StrandPalette.textPrimary) - } - .toggleStyle(.switch) - .tint(StrandPalette.accent) - // Turning the switch off also tells the strap to stop, so a stream can't be left running - // by a user who simply flips the toggle back. `ecgStopCapture` is deliberately reachable - // with the opt-in already off (see BLEManager.ecgStopOverride). When the strap isn't a - // connected MG the send can't happen — the Devices "Stop" control then stays offered via - // `ecgMayBeRunning` so there is still a route once the link is back. - // `reportsResult: false`: switching a setting off must not pop the Devices result sheet. - .onChangeCompat(of: ecgEnabled) { on in if !on { model.ecgStopCapture(reportsResult: false) } } - Text("The WHOOP MG has ECG electrodes in its clasp. This unlocks a gated, hand-run probe on the Devices screen that asks the strap to start its ECG subsystem and logs whatever comes back. MG only — a plain WHOOP 5.0 has no electrodes, and NOOP will refuse to send unless your strap identifies itself as an MG. Nobody has confirmed a strap honours these commands, so it may simply do nothing. Turn on “Record puffin frames to a file” below first if you want a complete byte-level capture to share.") - .font(StrandFont.caption) - .foregroundStyle(StrandPalette.textTertiary) - .fixedSize(horizontal: false, vertical: true) - - if ecgEnabled { - HStack(alignment: .top, spacing: 8) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(StrandPalette.statusWarning) - .accessibilityHidden(true) - Text("NOOP is not a medical device and this is not an ECG test. Anything the strap reports here — including any heart-rhythm classification it happens to send — is unvalidated instrumentation for protocol research, not a measurement and not a diagnosis. Never use it to make a decision about your health. If you have symptoms or are worried about your heart, talk to a doctor.") - .font(StrandFont.caption) - .foregroundStyle(StrandPalette.statusWarning) - .fixedSize(horizontal: false, vertical: true) - } - .frame(maxWidth: .infinity, alignment: .leading) - .accessibilityElement(children: .combine) - } - - Toggle(isOn: $puffinCapture) { - Text("Record puffin frames to a file") - .font(StrandFont.subhead) - .foregroundStyle(StrandPalette.textPrimary) - } - .toggleStyle(.switch) - .tint(StrandPalette.accent) - Text("Saves every raw 5/MG frame (with a timestamp and the live heart rate) to a JSON file you can share to help map the biometric layout. This only records frames the strap already sent (it never writes to your strap), so it is safe to leave on. Export the file and attach it to a protocol-mapping issue.") - .font(StrandFont.caption) - .foregroundStyle(StrandPalette.textTertiary) - .fixedSize(horizontal: false, vertical: true) - - if puffinCapture { - rowDivider - Text("Optical block experiment") - .font(StrandFont.subhead) - .foregroundStyle(StrandPalette.textPrimary) - Text("Mark the start of each physical phase while wearing or handling the strap. NOOP aligns the marker to the timestamp inside delayed history buffers, then the offline analyzer compares block activation, header bytes and raw ADC changes. It does not assume a wavelength or calculate SpO₂/BP.") - .font(StrandFont.caption) - .foregroundStyle(StrandPalette.textTertiary) - .fixedSize(horizontal: false, vertical: true) - HStack(spacing: NoopMetrics.space3) { - NoopButton("Mark phase…", systemImage: "flag.fill", kind: .primary) { - showOpticalPhasePicker = true - } - NoopButton("Export experiment…", systemImage: "square.and.arrow.up", kind: .secondary) { - exportOpticalExperiment() - } - Spacer(minLength: 0) - } - if !opticalPhaseStatus.isEmpty { - Text(opticalPhaseStatus) - .font(StrandFont.caption) - .foregroundStyle(StrandPalette.textSecondary) - } - } - - if live.puffinCaptureCount > 0 { - Text(live.puffinCaptureCount == 1 - ? "1 frame captured this session." - : "\(live.puffinCaptureCount) frames captured this session.") - .font(StrandFont.caption) - .foregroundStyle(StrandPalette.textSecondary) - HStack(spacing: NoopMetrics.space3) { - NoopButton("Export frames…", systemImage: "square.and.arrow.up", kind: .primary) { - exportPuffinCaptures() - } - - #if os(macOS) - NoopButton("Reveal in Finder", systemImage: "folder", kind: .secondary) { - revealPuffinCaptures() - } - #endif - Spacer(minLength: 0) - } - // One-tap "matched pair" export (#510): hands a reporter BOTH the raw capture file - // and the strap log together (timestamped, same minute) so a protocol-mapping issue - // arrives with the frames AND the context that produced them. - Button { - exportRawAndLog() - } label: { - if rawAndLogBusy { - HStack(spacing: NoopMetrics.space1 + 2) { - ProgressView().controlSize(.small) - Text("Exporting…") - } - } else { - Label("Export raw + log", systemImage: "square.and.arrow.up.on.square") - } - } - .buttonStyle(NoopButtonStyle(.secondary)) - .disabled(rawAndLogBusy) - Text("Saves the raw capture and the strap log together as a matched pair. Attach both to a protocol-mapping issue.") - .font(StrandFont.caption) - .foregroundStyle(StrandPalette.textTertiary) - .fixedSize(horizontal: false, vertical: true) - } - } - } - } - - /// SpO2 candidate display (#103/queue-11a) — split out of `fiveMGCard` (2026-08-23): the toggle's + /// SpO2 candidate display (#103/queue-11a) — split out of the WHOOP 5/MG research card + /// `fiveMGCard` (2026-08-23; #1709 removed that card's call site and #2417 its body): the toggle's /// own copy has covered Oura since `89c8533b` ("Blood Oxygen: strap estimate (WHOOP 5/MG, Oura)"), /// but it stayed nested inside the WHOOP-5/MG-only card, gated by `showFiveMGControls` — so an /// Oura-only install (no WHOOP 5/MG ever connected) could never reach it. `metricSeries` confirmed @@ -2279,7 +2017,8 @@ struct SettingsView: View { } /// Export the last 24h of decoded sensor streams for the connected strap to a CSV, then save (macOS - /// NSSavePanel) or share (iOS share sheet) — the same pattern as exportPuffinCaptures(). + /// NSSavePanel) or share (iOS share sheet). This is the only exporter left on this screen: the Puffin + /// capture export that shared the shape went with the research card in #2417. /// /// The strap id comes from `repo.deviceId`, NOT `model.deviceId`. The latter is a hardcoded /// `let "my-whoop"`; the former is seeded with it and then re-pointed to the registry's active strap @@ -2339,36 +2078,6 @@ struct SettingsView: View { } } - /// Flush the in-flight capture, then copy it to a user-chosen location (save panel on macOS) or - /// hand it to the system share sheet (iOS). - private func exportPuffinCaptures() { - Task { @MainActor in - await model.ble.flushPuffinCaptures() - guard let src = live.puffinCaptureURL else { return } - // Suggest a friendly, timestamped name so a reporter saving several captures gets sortable, - // non-colliding files (#510) — e.g. noop-raw-capture-260617-1042.json. - let suggested = FileExport.timestampedName("noop-raw-capture", ext: "json") - #if os(macOS) - let panel = NSSavePanel() - panel.allowedContentTypes = [.json] - panel.nameFieldStringValue = suggested - panel.canCreateDirectories = true - guard panel.runModal() == .OK, let dest = panel.url else { return } - let fm = FileManager.default - do { - if fm.fileExists(atPath: dest.path) { try fm.removeItem(at: dest) } - try fm.copyItem(at: src, to: dest) - } catch { - backupAlertTitle = String(localized: "Export failed") - backupAlertMessage = error.localizedDescription - showBackupAlert = true - } - #else - FileExport.exportFile(at: src, suggestedName: suggested) - #endif - } - } - private func markOpticalPhase(_ phase: PuffinOpticalExperimentPhase) { if model.ble.markWhoop5OpticalPhase(phase) { opticalPhaseStatus = String(localized: "Marked: \(phase.displayName)") @@ -2377,61 +2086,7 @@ struct SettingsView: View { } } - /// Export the durable JSONL used by the optical comparison CLI. Closing its append handle first - /// makes the user-selected copy complete; logging reopens lazily on the next buffer or marker. - private func exportOpticalExperiment() { - guard let src = model.ble.whoop5OpticalExperimentURL() else { - backupAlertTitle = String(localized: "Nothing to export") - backupAlertMessage = String(localized: "No WHOOP 5/MG deep buffers or phase markers have been recorded yet.") - showBackupAlert = true - return - } - FileExport.exportFile( - at: src, - suggestedName: FileExport.timestampedName("noop-whoop5-optical-experiment", ext: "jsonl")) - } - - /// One-tap matched-pair export (#510): export the raw puffin capture AND the strap log together, - /// both stamped with the same `yyMMdd-HHmm` minute so they're obviously a pair. Reuses the existing - /// export utilities — `FileExport.exportPair` shares both files in one iOS share sheet, and saves - /// each via its own NSSavePanel on macOS (no new file plumbing). - /// - /// #646/#651: `exportPair` is now async (its file read + zip build run off the main actor), so this - /// launches it in a `Task` — the button action itself stays synchronous from the caller's perspective. - /// `rawAndLogBusy` disables the button for the duration: without it a second tap mid-export fires a - /// second `exportPair` (two staged zips, two save panels / stacked share sheets). - private func exportRawAndLog() { - rawAndLogBusy = true - Task { @MainActor in - // `defer` so the flag is cleared on ANY exit (#961 follow-up), including cancellation. It - // cleared correctly before, but only because `exportPair` is non-throwing — the guard should - // not depend on that. Otherwise the button stays disabled behind a spinner that never stops. - defer { rawAndLogBusy = false } - // #652: `flushPuffinCaptures` is async now (encode+write moved off the main actor), so await - // it here — the file must be current before we read `puffinCaptureURL` to export it. - await model.ble.flushPuffinCaptures() - guard let capture = live.puffinCaptureURL else { - backupAlertTitle = String(localized: "Nothing to export") - backupAlertMessage = String(localized: "No raw capture has been recorded yet this session.") - showBackupAlert = true - return - } - let stamp = FileExport.timestamp() - await FileExport.exportPair( - file: capture, fileSuggestedName: "noop-raw-capture-\(stamp).json", - text: live.exportableLogText(), textSuggestedName: "noop-strap-log-\(stamp).txt") - } - } - #if os(macOS) - /// Flush, then reveal the capture file in Finder so the user can grab it directly. - private func revealPuffinCaptures() { - Task { @MainActor in - await model.ble.flushPuffinCaptures() - guard let url = live.puffinCaptureURL else { return } - NSWorkspace.shared.activateFileViewerSelecting([url]) - } - } #endif private var backupCard: some View { @@ -2555,10 +2210,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) } } @@ -2593,6 +2248,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.") @@ -2610,9 +2278,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( @@ -2795,6 +2461,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) { @@ -3257,6 +2940,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. @@ -3277,7 +2969,7 @@ struct StepsCalibrationSheet: View { ScrollView { VStack(alignment: .leading, spacing: NoopMetrics.sectionSpacing) { explainerCard - if didLoad && sampleMotion == nil { noMotionNote } + if strapHasNoMotion { noMotionNote } currentFitCard comparisonCard manualAdjustCard @@ -3348,7 +3040,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) @@ -3389,7 +3081,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.") } @@ -3397,7 +3089,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.") @@ -3435,6 +3127,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 @@ -3452,6 +3156,7 @@ struct StepsCalibrationSheet: View { .font(StrandFont.footnote) .foregroundStyle(StrandPalette.textTertiary) .fixedSize(horizontal: false, vertical: true) + } } } } @@ -3603,7 +3308,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, @@ -3615,8 +3320,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..8adcabb27b 100644 --- a/Strand/Screens/SleepModel.swift +++ b/Strand/Screens/SleepModel.swift @@ -105,7 +105,18 @@ struct Night { let onsetDay = Date(timeIntervalSince1970: TimeInterval(session.effectiveStartTs)) let wakeDay = Date(timeIntervalSince1970: TimeInterval(session.endTs)) let cal = Calendar.current - if cal.isDate(onsetDay, inSameDayAs: wakeDay) { return Night.dateFmt.string(from: onsetDay) } + // A night that BEGINS after midnight has onset and wake on one calendar date, and naming it + // by that date makes it repeat the date the row above already leads with: the carousel is + // keyed by wake day, so the night before it woke on this same date. Name it by the evening it + // belongs to instead, its wake day minus one, which is what the cross-midnight branch below + // already leads with and what Android prints (#2199). + // + // `byAdding:` rather than subtracting 86_400 seconds: a DST day is 23 or 25 hours long and + // the arithmetic form lands on the wrong calendar date. + if cal.isDate(onsetDay, inSameDayAs: wakeDay) { + let nightDay = cal.date(byAdding: .day, value: -1, to: wakeDay) ?? wakeDay + return Night.dateFmt.string(from: nightDay) + } return "\(Night.spanFmt.string(from: onsetDay)) → \(Night.dateFmt.string(from: wakeDay))" } @@ -118,12 +129,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 +148,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 +214,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 +410,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 +441,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 +452,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 +463,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 +492,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 +521,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..598051b980 100644 --- a/Strand/Screens/SleepView.swift +++ b/Strand/Screens/SleepView.swift @@ -28,11 +28,14 @@ import UIKit struct SleepView: View { @EnvironmentObject var repo: Repository - // 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 - // "Syncing strap history…" note — each own their OWN `@EnvironmentObject var live` in a small - // leaf below (mirrors the Today leaf-scoping pattern), so a tick refreshes only that leaf. + // NOTE: SleepView itself deliberately does NOT observe `LiveState` OR `AppModel`. A connected strap + // publishes at ~1 Hz, and `AppModel` itself publishes `bpm` at that same ~1 Hz (AppModel.swift:202) — + // `@EnvironmentObject` subscribes to the WHOLE object's `objectWillChange` regardless of which + // properties are read, so holding either here would re-evaluate this heavy ~3000-line body on every + // tick. The live dependencies — the "going to sleep / awake" mark card (appends to the strap log), + // the "Syncing strap history…" note, and the body-clock dial's `circadianPhase` (#1680) — each own + // their OWN `@EnvironmentObject var live`/`appModel` in a small leaf below (mirrors the Today + // leaf-scoping pattern and HealthView.swift:17-22), so a tick refreshes only that leaf. @EnvironmentObject var intelligence: IntelligenceEngine /// Memoized snapshot of every expensive derivation (latest Night with its intervals @@ -63,6 +66,15 @@ struct SleepView: View { /// merges each day into one Night, so a split day reads as one correctly-totalled night with the /// gaps preserved. Oldest→newest. Falls back to `repo.sleeps` until loaded. (#170) @State private var allSessions: [CachedSleepSession] = [] + /// `navDays` memoized, rebuilt where `model` is. + /// + /// Grouping calls `Calendar.startOfDay` once per session, and the browsable history is every block + /// ever recorded, so recomputing it per render is a per-frame pass over years of nights. Body reaches + /// it more than once (the wake-timestamp list, and `dayBlocks(at:)` for the source blocks), so a + /// scroll paid it repeatedly. Invalidated by the same two paths that rebuild `model`: `dataKey` + /// covers a `repo.sleeps` change, and the refresh task covers `allSessions` reloading. Nil falls back + /// to computing it, so a first render before either has run is correct rather than empty. + @State private var navDaysCache: [[CachedSleepSession]]? /// The user's LEARNED habitual midsleep (local time-of-day seconds), or nil under the cold-start /// threshold. Loaded from `repo.habitualMidsleepSec()` — the SAME value `AnalyticsEngine.analyzeDay` @@ -72,7 +84,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 +163,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). @@ -174,6 +187,7 @@ struct SleepView: View { // `resolved` already drives THIS frame, so there is no flash and no extra rebuild. .onChangeCompat(of: key) { newKey in modelKey = newKey + navDaysCache = SleepModel.navDays(navSessions: navSessions) model = buildModel() // New data invalidates a navigated offset — the same offset would silently // point at a different session. Snap back to last night. (#160) @@ -205,10 +219,11 @@ 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 + navDaysCache = SleepModel.navDays(navSessions: navSessions) model = buildModel() } .sheet(item: $wakeEdit) { edit in @@ -306,7 +321,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 +372,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 +420,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 +428,27 @@ 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 { + // PERF: `circadianPhase` lives on `AppModel`, isolated into its own leaf (`BodyClockDialSection`) + // rather than read via an `appModel: AppModel` property on this screen — see the NOTE above. + BodyClockDialSection(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 { @@ -789,10 +830,20 @@ struct SleepView: View { // #345 follow-up: when a night was staged on SPARSE motion coverage it can UNDER-detect — the // gravity-only spine fragments and the sub-60-min pieces are dropped, so a real ~8h night can // collapse to a fraction ("slept 8h, app shows 1h"). Say so honestly so the short total isn't - // read as fact. Distinct from the H9 note above (a plausible-duration night with an off split). - if stageStagingIsSparse(night) { + // read as fact — but only when the night ACTUALLY reads short, since the sparse flag alone fires + // on one long motion dropout at any length. The rule lives in `stageSparseNoteApplies`. + // Distinct from the H9 note above (a plausible-duration night with an off split). + if stageShowsIncompleteNote(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). @@ -909,13 +960,55 @@ struct SleepView: View { asleepMin: s.asleep, deepMin: s.deep, remMin: s.rem, efficiency: effPct / 100.0) } - /// True when this night was staged on SPARSE motion coverage — the persisted `stagingSparse` flag the - /// engine sets from `SleepStager.isGravitySparse` (#345). Such a night can UNDER-detect: the gravity-only - /// spine fragments and sub-60-min pieces are dropped, so a real night collapses to a fraction. Reads the - /// day's REAL stored blocks (each carries the day's value), never the synthetic merged `session`; a nil - /// flag (imported / pre-migration night) is never flagged. Mirror in Kotlin. - private func stageStagingIsSparse(_ night: Night) -> Bool { - night.sourceBlocks.contains { $0.stagingSparse == true } + /// True when this night earns the "May be incomplete" caveat: staged on SPARSE motion coverage AND + /// actually reading short (#345). The rule itself lives in `stageSparseNoteApplies` below, which carries + /// the reasoning and is what Kotlin mirrors; this wrapper only supplies the two inputs. Reads the day's + /// REAL stored blocks (each carries the day's value), never the synthetic merged `session`; a nil flag + /// (imported / pre-migration night) is never flagged. + private func stageShowsIncompleteNote(_ night: Night) -> Bool { + SleepView.stageSparseNoteApplies( + stagingSparse: night.sourceBlocks.contains { $0.stagingSparse == true }, + asleepMin: night.stages.asleep) + } + + /// Pure #345 gate (unit-testable without a live view) — whether the "May be incomplete" caveat applies. + /// Mirror EXACTLY in Kotlin. + /// + /// `stagingSparse` alone is NOT the question the note asks. It is a STAGING-MECHANISM verdict: + /// `SleepStager.isGravitySparse` returns true when the gravity span is short against the HR span OR when + /// the LARGEST inter-sample gap exceeds `maxGapMin`, and its own doc calls that second branch "the + /// typical WHOOP 4.0 backfill (#28)" whose only consequence is to ENABLE `buildRuns`' HR-vouched bridge. + /// So a single long motion dropout sets it on a night of ANY length, including a complete twelve-hour + /// one, and the flag is raised precisely where the engine has already applied its own mitigation. + /// + /// The note's copy, though, claims something narrower and checkable: that the night "may be + /// under-detected and the sleep total can read short". So require the total to actually read short. A + /// night at or above the wearer's need cannot honestly be captioned as possibly reading short, whatever + /// the motion trace looked like. + /// + /// A night that staged to NOTHING keeps the caveat: zero asleep is the strongest form of the collapse + /// this note exists to explain, not an exemption from it. + /// + /// `needHours` is a parameter rather than a constant so a personalised need + /// (`AnalyticsEngine.Rest.personalizedNeedHours`) can be threaded in later without moving the rule. It + /// is computed per pass today and not persisted on the row a screen can reach, so the shared default + /// stands in. + static func stageSparseNoteApplies(stagingSparse: Bool, + asleepMin: Double, + needHours: Double = AnalyticsEngine.Rest.defaultNeedHours) -> Bool { + guard stagingSparse else { return false } + return asleepMin < needHours * 60.0 + } + + /// 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: @@ -977,6 +1070,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 +1387,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 @@ -1698,7 +1820,7 @@ struct SleepView: View { /// The browsable DAY list — a thin wrapper over the shared `SleepModel.navDays`, which is the /// source of truth the builder and the ◀/▶ nav both read (no duplicated grouping). (#170) private var navDays: [[CachedSleepSession]] { - SleepModel.navDays(navSessions: navSessions) + navDaysCache ?? SleepModel.navDays(navSessions: navSessions) } /// The device's current UTC offset (seconds east), evaluated once per pick. Feeds the selector's @@ -1976,10 +2098,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 { @@ -2105,14 +2224,6 @@ struct SleepView: View { return stages.total > 0 ? (stages, intervals) : nil } - /// yyyy-MM-dd → Date (en_US_POSIX, UTC), per task spec. - private static let dayParser: DateFormatter = { - let f = DateFormatter() - f.locale = Locale(identifier: "en_US_POSIX") - f.timeZone = TimeZone(identifier: "UTC") - f.dateFormat = "yyyy-MM-dd" - return f - }() } /// Original atmospheric night hero — photographic moonlit lake plus lightweight static depth layers. @@ -2482,10 +2593,79 @@ 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 } + // #2108: a night already in hand outranks .calculating. It used to sit below, so `hasCurrentNight` + // could only silence the missing-night states and a finished night was structurally unable to + // silence this one: the banner said "detecting and staging the night now" directly above that same + // night scored, timed and staged on screen. A note that contradicts the content beside it is worse + // than no note, and one that is always on is read by nobody the day it matters. .syncing stays + // above, because data still arriving can genuinely change what is shown. + if hasCurrentNight { return nil } + if calculating { return .calculating } + if !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() + } } } @@ -2755,9 +2935,12 @@ private struct SleepTimeEditor: View { #if DEBUG #Preview("Sleep") { - SleepView() - .environmentObject(Repository.previewSleep()) + let repo = Repository.previewSleep() + return SleepView() + .environmentObject(repo) .environmentObject(LiveState()) + .environmentObject(AppModel()) + .environmentObject(IntelligenceEngine(repo: repo, profile: ProfileStore(), deviceId: "preview")) .frame(width: 980, height: 1180) .preferredColorScheme(.dark) } diff --git a/Strand/Screens/SmartAlarmView.swift b/Strand/Screens/SmartAlarmView.swift index f69c181abf..bafc0d028a 100644 --- a/Strand/Screens/SmartAlarmView.swift +++ b/Strand/Screens/SmartAlarmView.swift @@ -94,14 +94,53 @@ struct SmartAlarmView: View { .font(.system(size: 13, weight: .semibold)) .foregroundStyle(StrandPalette.textTertiary) .accessibilityHidden(true) - heroTime(label: "Wake", + // "Wake" was the wind-down INPUT wearing the name of the thing that wakes you, in the + // largest type on the screen, above both cards. It is the first figure a reader sees + // and it is not their alarm. Named for what it is instead. + heroTime(label: "Usual wake", time: timeLabel(wakeMinutes), tint: StrandPalette.restBright) + // The alarm is deliberately NOT a third figure on this arrow. The arrow is an + // arithmetic pair (the nudge is derived from the usual wake time) and the alarm is + // not part of that derivation, so chaining it would claim the nudge is timed for the + // alarm. It gets the countdown block below instead, which also keeps the alarm time + // from appearing twice in one hero. Spacer(minLength: 0) } + // How long until it actually goes off. "10:00" does not say whether that is nine hours + // away or thirty-three, which is the thing you want at a glance. It lives HERE rather + // than beside the picker in the card below: that spot is six rows into the second card, + // under a four-line paragraph, so you only meet it once you are already editing the + // alarm. The hero is where the eye lands. Exactly one countdown on the screen, because + // two live ones start the next "which of these is the real one" question, which is the + // question this whole screen exists to stop. + // + // Re-rendered once a minute rather than computed at view build, since a countdown frozen + // at whatever it read when the screen opened is worse than none. The TimelineView wraps + // ONLY this line, so the 60s tick cannot re-render the rest of the screen. + TimelineView(.periodic(from: .now, by: 60)) { tick in + if let countdown = strapAlarmCountdown(from: tick.date) { + VStack(alignment: .leading, spacing: 2) { + Text(countdown) + .font(StrandFont.number(22)) + .foregroundStyle(StrandPalette.accent) + .fixedSize(horizontal: false, vertical: true) + // The absolute companion, the way a phone's clock app pairs them: the + // countdown is the glance, the stamp is what you check when it matters. It + // carries the DATE, which is the half that actually settles "tonight or + // tomorrow" and which a bare time never can. + if let stamp = nextStrapAlarmStamp(from: tick.date) { + Text(stamp) + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } Text(windDownOn - ? "A calm nudge \(WindDownNudge.sleepNeedMinutes / 60)h \(WindDownNudge.leadMinutes)m before your wake time." - : "Turn on the wind-down reminder below to land at your wake time rested.") + ? "A calm nudge \(WindDownNudge.sleepNeedMinutes / 60)h \(WindDownNudge.leadMinutes)m before your usual wake time." + : "Turn on the wind-down reminder below to land at your usual wake time rested.") .font(StrandFont.footnote) .foregroundStyle(StrandPalette.textSecondary) .fixedSize(horizontal: false, vertical: true) @@ -211,9 +250,24 @@ struct SmartAlarmView: View { Spacer() DatePicker("", selection: alarmTimeBinding, displayedComponents: .hourAndMinute) .labelsHidden().datePickerStyle(.compact) - .accessibilityLabel("Wake time") + // Distinct from the wind-down picker's label below. Both were "Wake time", + // so VoiceOver announced the alarm and the reminder's timing input by the + // same name, on the same screen, with different values. + .accessibilityLabel("Strap alarm wake time") } .frame(minHeight: 42) + // The per-day overrides that re-time THIS alarm (#1864) are edited under the + // wind-down card, so read on its own this card can say "10:00" during a week whose + // Saturday fires at 20:30. Checking your alarm is exactly what someone opens this + // card to do, so the qualifier belongs against the number it qualifies. Shown only + // when an override actually exists, because otherwise the picker IS the whole truth. + if !overrides.isEmpty, let next = nextStrapAlarmLabel { + Text("Some days have a time of their own, set under the evening reminder below. Next buzz \(next).") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + } Divider().overlay(StrandPalette.hairline) alarmWeekdayPicker // #864: a WHOOP 5/MG only arms its firmware alarm when Experimental is on (see @@ -242,6 +296,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) } } } @@ -298,21 +373,38 @@ struct SmartAlarmView: View { Divider().overlay(StrandPalette.hairline) HStack { VStack(alignment: .leading, spacing: 2) { - Text("Wake time") + // Was "Wake time", the same words the strap alarm's own picker uses one card + // up. Two pickers called the same thing, holding different times, and only + // one of them wakes anybody: a reporter asked outright which time would wake + // them. This field is an INPUT to the reminder's arithmetic, so it is named + // for what it is rather than for what it sounds like. + Text("Your usual wake time") .font(StrandFont.body) .foregroundStyle(StrandPalette.textPrimary) Text("The nudge fires \(WindDownNudge.sleepNeedMinutes / 60)h \(WindDownNudge.leadMinutes)m before this.") .font(StrandFont.footnote) .foregroundStyle(StrandPalette.textTertiary) + Text("This time does not wake you. It only decides when the evening reminder fires.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) } Spacer() DatePicker("", selection: wakeBinding, displayedComponents: .hourAndMinute) .labelsHidden() - .accessibilityLabel("Wake time") + .accessibilityLabel("Your usual wake time") } Text("You'll be reminded around \(timeLabel(WindDownNudge.nudgeMinuteOfDay())).") .font(StrandFont.footnote) .foregroundStyle(StrandPalette.textSecondary) + // Answers "so what actually wakes me?" in the one place the question gets asked, + // beside the time that does not. Only shown when there IS a strap alarm to name. + if let next = nextStrapAlarmLabel { + Text("Your strap alarm is what wakes you, next on \(next).") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } Divider().overlay(StrandPalette.hairline) perDaySection @@ -330,7 +422,11 @@ struct SmartAlarmView: View { Text("Different wake time per day") .font(StrandFont.body) .foregroundStyle(StrandPalette.textPrimary) - Text("Set a wake time for specific days (a lie-in at the weekend, say). Days you leave alone use the time above.") + // #1864 made these overrides drive the STRAP ALARM as well as the reminder, but the + // copy stayed written as though they only moved the nudge, and the section still sits + // under the wind-down card. A day set here re-times the buzz on your wrist. Saying so + // is the difference between a lie-in and an alarm that goes off on Saturday evening. + Text("Set a wake time for specific days (a lie-in at the weekend, say). These times move your strap alarm AND the evening reminder on those days.") .font(StrandFont.footnote) .foregroundStyle(StrandPalette.textTertiary) .fixedSize(horizontal: false, vertical: true) @@ -345,6 +441,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() } } } @@ -357,24 +456,166 @@ struct SmartAlarmView: View { } } .padding(.top, 4) + Text(untouchedDayExplainer) + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + + /// When the strap alarm will next actually buzz, as a localised weekday and time, or nil when there + /// is no alarm to name. + /// + /// Deliberately the NEXT FIRE rather than `smartAlarmMinutes`. With a per-day override set there is no + /// single alarm time to state, and naming the base one would be wrong on exactly the days the reporter + /// had changed: their Saturday override reads 20:30 while the base reads 10:00. A line that answers + /// "what wakes me" has to be right on every day, or it is one more thing on this screen to misread. + /// `nextSmartAlarmDate` is the same pure resolver `applySmartAlarm` arms the strap from, so this cannot + /// drift from what the strap is actually told. + /// + /// The template formatter also follows the reader's 12/24-hour setting, unlike `timeLabel`. + private var nextStrapAlarmLabel: String? { + guard let next = nextStrapAlarm() else { return nil } + let formatter = DateFormatter() + // `AppLanguage.activeLocale`, not `.current`: the app language is an in-app setting, so a reader + // running NOOP in German on an English device must get German weekday words here, while keeping + // their device's 24-hour convention. The same reason every other formatter in the app uses it. + formatter.locale = AppLanguage.activeLocale + formatter.setLocalizedDateFormatFromTemplate("EEEE jj:mm") + return formatter.string(from: next) + } + + /// Whether switching the alarm on actually arms anything that will go off. + /// + /// A WHOOP 5/MG arms its firmware alarm only with Experimental on: `BLEManager.armStrapAlarm` logs + /// "not armed" and returns otherwise. That is the #864 case, reported by a 5/MG owner whose strap + /// never buzzed while this card told them it was armed, and the card carries a warning for it. + /// + /// A countdown is a promise, so it must not be made for an alarm that will not exist. On macOS there + /// is not even a fallback to fall back to: `scheduleSmartAlarmBackupNotification` is `#if os(iOS)` + /// with no `#else`, so in that state nothing whatsoever happens at the chosen time. Saying "Alarm in + /// 18 hours" directly above the line admitting the strap is NOT armed would reintroduce the exact + /// honesty bug one row up from its own fix. + /// + /// A strap that is merely disconnected is NOT excluded here: `armStrapAlarm` queues and arms on + /// reconnect, so the alarm is real and the countdown to it is true. + private var strapAlarmWillArm: Bool { + !(model.whoop5Detected && !PuffinExperiment.isEnabled) + } + + /// The one moment every alarm readout on this screen is derived from, or nil when nothing will fire. + /// + /// Single funnel on purpose. This resolver was called from three places, each repeating the gate and + /// the argument list, which is three chances for one of them to drift from what the strap is actually + /// armed with. `nextSmartAlarmDate` is the same pure function `applySmartAlarm` arms from, and the + /// overrides go in so a day with its own time resolves to THAT time. + /// + /// `from` is a parameter so the ticking countdown re-resolves against the clock it is given rather + /// than a `Date()` captured somewhere else. + private func nextStrapAlarm(from now: Date = Date()) -> Date? { + guard behavior.smartAlarmEnabled, strapAlarmWillArm else { return nil } + return AppModel.nextSmartAlarmDate(minutes: behavior.smartAlarmMinutes, + weekdays: behavior.smartAlarmWeekdays, + overrides: overrides, + from: now) + } + + /// The next alarm as a weekday, date and time: "Mon 21 Sep 10:28". + /// + /// The absolute companion to the countdown, the way a phone's clock app pairs them. "In 17 hours" is + /// what you want at a glance; the stamp underneath is what you check when the answer matters, and it + /// carries the DATE, which is what actually settles "tonight or tomorrow". + /// + /// Takes the SAME clock the countdown was resolved from. Reading `Date()` here instead would give the + /// two lines two clocks up to a tick apart, and `nextSmartAlarmDate` returns the next strictly-future + /// fire: straddle that moment and the countdown says "less than a minute" while the stamp names + /// tomorrow. Two lines disagreeing about one fact is the exact defect this screen exists to remove. + private func nextStrapAlarmStamp(from now: Date = Date()) -> String? { + nextStrapAlarm(from: now).map { date in + let formatter = DateFormatter() + formatter.locale = AppLanguage.activeLocale + formatter.setLocalizedDateFormatFromTemplate("EEE d MMM jj:mm") + return formatter.string(from: date) } } + /// How long until the strap alarm next buzzes, as "18 hours, 6 minutes", or nil when no alarm is set. + /// + /// A wall-clock time answers "when", but not "is that tonight or tomorrow", which is the question an + /// alarm actually raises when you are looking at it in the evening. Resolved through the same + /// `nextSmartAlarmDate` the strap is armed from, so a per-day override counts down to ITS time. + /// + /// `DateComponentsFormatter` rather than hand-built text because the unit words pluralise differently + /// per language, and an app that ships nine of them should not be writing "1 hours". + private func strapAlarmCountdown(from now: Date) -> String? { + guard let next = nextStrapAlarm(from: now) else { return nil } + let seconds = next.timeIntervalSince(now) + // Under a minute there is no useful number left, and "in 0 minutes" reads like a bug. + guard seconds >= 60 else { return String(localized: "Alarm in less than a minute") } + let formatter = DateComponentsFormatter() + formatter.calendar = { + var cal = Calendar.current + cal.locale = AppLanguage.activeLocale + return cal + }() + formatter.unitsStyle = .full + // Days included because a weekday-restricted alarm can be up to a week out, and "151 hours" is + // not an answer. Leading zero units are dropped, so a same-day alarm stays "18 hours, 6 minutes". + formatter.allowedUnits = [.day, .hour, .minute] + formatter.zeroFormattingBehavior = .dropAll + formatter.maximumUnitCount = 3 + guard let span = formatter.string(from: seconds), !span.isEmpty else { return nil } + return String(localized: "Alarm in \(span)") + } + + /// What a day with no override of its own actually does. + /// + /// There is no single fallback to show: the strap alarm falls back to its OWN time while the reminder + /// falls back to the usual wake time above, and a row can only display one number. It displays the + /// reminder's, so a day the alarm will fire at 10:00 can sit in this list reading 13:00. Naming both + /// beats picking one and being wrong half the time. Hoisted out of the view body to keep this screen + /// clear of the iOS type-check budget. + /// + /// Both base times are real settings, so `timeLabel` is right here and matches the "You'll be reminded + /// around ..." line directly above. `nextStrapAlarmLabel` differs on purpose: it renders a weekday too, + /// so it needs a date formatter regardless, and takes the reader's clock format while it is there. + private var untouchedDayExplainer: String { + guard behavior.smartAlarmEnabled else { + return String(localized: "Days you leave alone use the usual wake time above.") + } + let alarm = timeLabel(behavior.smartAlarmMinutes) + let usual = timeLabel(wakeMinutes) + return String(localized: "Days you leave alone keep your strap alarm at \(alarm), and time the reminder from \(usual).") + } + /// One weekday's override row: the day name, the effective wake time (override or default), a picker to /// set it, and a clear control shown only when an override exists for that day. private func weekdayOverrideRow(_ weekday: Int) -> some View { let effective = overrides[weekday] ?? wakeMinutes let hasOverride = overrides[weekday] != nil return HStack(spacing: 12) { - Text(Self.weekdayName(weekday)) - .font(StrandFont.subhead) - .foregroundStyle(hasOverride ? StrandPalette.textPrimary : StrandPalette.textSecondary) - .frame(width: 96, alignment: .leading) + VStack(alignment: .leading, spacing: 1) { + Text(Self.weekdayName(weekday)) + .font(StrandFont.subhead) + .foregroundStyle(hasOverride ? StrandPalette.textPrimary : StrandPalette.textSecondary) + // Without this a row reading 13:00 looks like a decision someone made for that day, + // when it is just the usual wake time showing through. The strap alarm may well fire + // at a different hour on exactly these days. + if !hasOverride { + Text("no time of its own") + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + } + } + .frame(width: 120, alignment: .leading) Spacer(minLength: 0) if hasOverride { 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 +648,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..d9a23930c4 100644 --- a/Strand/Screens/StagesCard.swift +++ b/Strand/Screens/StagesCard.swift @@ -120,9 +120,15 @@ struct StageDetailView: View { // gravity-only spine fragments and the sub-60-min pieces are dropped, so a real ~8h night can // collapse to a fraction ("slept 8h, app shows 1h"). Say so honestly so the short total isn't // read as fact. Distinct from the H9 note above (a plausible-duration night with an off split). - if stageStagingIsSparse(night) { + if stageShowsIncompleteNote(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). @@ -237,13 +243,27 @@ struct StageDetailView: View { asleepMin: s.asleep, deepMin: s.deep, remMin: s.rem, efficiency: effPct / 100.0) } - /// True when this night was staged on SPARSE motion coverage — the persisted `stagingSparse` flag the - /// engine sets from `SleepStager.isGravitySparse` (#345). Such a night can UNDER-detect: the gravity-only - /// spine fragments and sub-60-min pieces are dropped, so a real night collapses to a fraction. Reads the - /// day's REAL stored blocks (each carries the day's value), never the synthetic merged `session`; a nil - /// flag (imported / pre-migration night) is never flagged. Mirror in Kotlin. - private func stageStagingIsSparse(_ night: Night) -> Bool { - night.sourceBlocks.contains { $0.stagingSparse == true } + /// True when this night earns the "May be incomplete" caveat: staged on SPARSE motion coverage AND + /// actually reading short (#345). `SleepView.stageSparseNoteApplies` carries the reasoning and is the + /// single place the rule lives. Reads the day's REAL stored blocks (each carries the day's value), never + /// the synthetic merged `session`; a nil flag (imported / pre-migration night) is never flagged. + private func stageShowsIncompleteNote(_ night: Night) -> Bool { + // Same shared gate as `SleepView.stageShowsIncompleteNote`, for the same reason this host already + // borrows `isStagingLowConfidence` and `mainNightGroup`: the two screens render the same night and + // must not disagree about whether it earns the caveat. + SleepView.stageSparseNoteApplies( + stagingSparse: night.sourceBlocks.contains { $0.stagingSparse == true }, + asleepMin: night.stages.asleep) + } + + /// 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 @@ -278,6 +298,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 +457,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..1dff8b2899 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 @@ -133,14 +131,45 @@ struct StressView: View { : .dayRelative if case .baselineRelative = mode { daytimeUsesPersonalBaseline = true } else { daytimeUsesPersonalBaseline = false } - daytime = DaytimeStress.analyze(hr: hr, rr: rr, gravity: gravity, tzOffsetSeconds: tz, mode: mode) + // includeTimeline: the SLIDING read, so the screen's line moves in half-hours instead of + // stepping through whole clock hours (#2144). The scored unit is still a full hour; this only + // decides how often that hour is re-read, so a thin ten minutes costs the windows that overlap + // it rather than a whole hour of chart. The Today card and the widget have always asked for + // this; the screen people actually study was the one still stepping. Twin of the Kotlin change. + // #2181: this is pure, database-free computation over a whole local day of samples, and it used + // to run inline on this view's (main) actor. `analyze` memoises behind a lock-guarded + // `AnalyticsMemoCache`, so it is safe off the main actor and the Today card already reads its own + // stress the same way. Moving it here is what lets the timeline be published — and drawn — before + // the advanced readouts below are started. + // + // `runUnescalated`, NOT `await Task.detached(...).value`: awaiting a task from a @MainActor + // caller makes it a child and hands it the caller's priority, so a `.utility` label on a + // detached task is decorative and the work races the UI for cores anyway. StressDayCurve + // learned that on this same issue; the continuation in UnescalatedWork is what keeps the + // priority honest. + daytime = await runUnescalated(priority: .userInitiated) { + DaytimeStress.analyze(hr: hr, rr: rr, gravity: gravity, tzOffsetSeconds: tz, mode: mode, + includeTimeline: true) + } // ADDITIVE advanced readouts, computed on-demand from the SAME `rr` (no extra fetch, no // DB / schema change, and no effect on the 0..3 score above). Each engine returns nil when // its own gate is not met (Baevsky needs >= 20 clean beats; freq-HRV needs >= 60 s span), // in which case its row is simply hidden. - stressIndex = StressIndex.components(rr: rr) - freqHRV = HRVFreqDomain.freqDomain(rr: rr) + // A SECOND hop on purpose (#2181). `HRVFreqDomain` is a Lomb-Scargle periodogram: its cost is + // (clean beats x frequency-grid steps) with a transcendental per step, and it takes whatever beat + // count the day's read returned — the store read above is bounded at 200 000, this is not bounded + // at all. On a live-banked day that is seconds of arithmetic, and run inline it held the main + // thread for all of them, which is why the screen stayed blank rather than drawing the timeline it + // already had. Both engines are pure statics over the same `rr`, so they compute together off the + // main actor and publish when done; their card is hidden until then, exactly as it is when a gate + // is unmet. Same `runUnescalated` reasoning as above, and the default `.utility` is real here + // because nothing escalates it: this is the phase that must yield to the UI. + let advanced = await runUnescalated { + (index: StressIndex.components(rr: rr), freq: HRVFreqDomain.freqDomain(rr: rr)) + } + stressIndex = advanced.index + freqHRV = advanced.freq } /// Trailing local days folded into the personal daytime baselines the `.baselineRelative` mode @@ -162,8 +191,14 @@ struct StressView: View { /// fold itself is O(days). private func daytimeScoringMode(startOfToday: Date) async -> DaytimeStress.ScoringMode { let cal = Calendar.current - var days: [DaytimeStress.DaytimeDayStreams] = [] - days.reserveCapacity(Self.baselineHistoryDays) + // #2107: keep each day's AGGREGATE, never its streams. This used to accumulate 30 x + // DaytimeDayStreams, each holding up to 200,000 HR plus 200,000 R-R samples, and hand the lot to + // the fold. The fold's first act is to reduce a day to two Doubles, so all that was ever wanted + // from thirty days was sixty numbers; holding the samples alive to produce them is what exhausted + // a 256MB heap on the Android twin and crashed it with an OutOfMemoryError. Reducing here lets + // each day's samples be released at the end of its own iteration. + var aggregates: [(hr: Double?, rmssd: Double?)] = [] + aggregates.reserveCapacity(Self.baselineHistoryDays) // Oldest → newest so the EWMA fold replays the history in order. for back in stride(from: Self.baselineHistoryDays, through: 1, by: -1) { guard let dayStart = cal.date(byAdding: .day, value: -back, to: startOfToday), @@ -173,11 +208,12 @@ 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)) ?? [] - days.append(.init(hr: dayHR, rr: dayRR, tzOffsetSeconds: dayTz)) + let dayRR = await repo.rrIntervals(from: from, to: to, limit: 200_000) + aggregates.append( + DaytimeStress.dayDaytimeAggregate(hr: dayHR, rr: dayRR, tzOffsetSeconds: dayTz) + ) } - return DaytimeStress.scoringMode(history: days) + return DaytimeStress.scoringModeFromAggregates(aggregates) } /// Recompute the cached `StressModel` only when (repo.days, storedSeries) @@ -260,8 +296,14 @@ struct StressView: View { HStack { Text("Autonomic load through the day").strandOverline() Spacer() - if let peak = day.peak, let lvl = peak.level { - Text("peak \(String(format: "%.1f", lvl)) · \(hourLabel(peak.hour))") + // The peak of what is DRAWN, not of the whole hours (#2144). A sliding window + // can exceed both hourly neighbours when the busy stretch straddles a boundary, + // so `day.peak` would caption the line with a number below its visible maximum. + // Everything that COUNTS hours still reads `hours`; a maximum is not a count. + let drawnPeak = day.timeline.filter { $0.level != nil } + .max { ($0.level ?? 0) < ($1.level ?? 0) } + if let peak = drawnPeak, let lvl = peak.level { + Text("peak \(StressTrace.formatLevel(lvl)) · \(hourLabel(peak.hour))") .font(StrandFont.captionNumber) .foregroundStyle(StressRamp.color(lvl)) } @@ -269,10 +311,13 @@ struct StressView: View { // README screen-9: the day autonomic-load LINE, drawn with the same // 3-stop blue→green→amber WHOOP gradient as the gauge. - DaytimeLoadLine(hours: day.hours) + // The SLIDING series, not the bare hours (#2144). Everything that COUNTS hours + // keeps reading `hours`: the totals bar's shares still have to sum to the day. + // Only the line and its ruler follow the finer read. + DaytimeLoadLine(hours: day.timeline) // Hour ruler under the line (first / midday / last covered hour). - if let lo = day.hours.first?.hour, let hi = day.hours.last?.hour { + if let lo = day.timeline.first?.hour, let hi = day.timeline.last?.hour { HStack { Text(hourLabel(lo)).font(StrandFont.footnote) .foregroundStyle(StrandPalette.textTertiary) @@ -307,7 +352,7 @@ struct StressView: View { private func timelineTrailing(_ day: DaytimeStress.Result) -> String { let n = day.scored.count guard let mean = day.dayMean else { return String(localized: "\(n)h") } - return String(localized: "avg \(String(format: "%.1f", mean)) · \(n)h") + return String(localized: "avg \(StressTrace.formatLevel(mean)) · \(n)h") } /// The timeline's explanatory line, honest about WHICH reference each hour was scored against — @@ -439,7 +484,7 @@ struct StressView: View { if let ratio = f.lfhf { StatTile( label: "Autonomic balance (LF/HF)", - value: String(format: "%.1f", ratio), + value: StressTrace.formatRatio(ratio), caption: String(localized: "Sympathetic vs parasympathetic tone from frequency-domain HRV. Higher leans sympathetic (stress-ward)."), accent: StressRamp.steady ) @@ -473,7 +518,7 @@ struct StressView: View { // Today's stress value, with its band as the caption. StatTile( label: "Stress", - value: String(format: "%.1f", model.score), + value: StressTrace.formatLevel(model.score), caption: String(localized: "of 3 · \(model.band.title)"), accent: StressRamp.color(model.score), sparkline: model.sparkValues.count > 1 ? model.sparkValues : nil, @@ -510,7 +555,15 @@ struct StressView: View { private func markerTile(label: LocalizedStringKey, value: String, delta: Double?, accent: Color, higherIsStress: Bool) -> some View { let deltaText: String? let deltaColor: Color - if let delta, abs(delta) >= 0.5 { + // NO CHIP for a missing delta, rather than a claim we cannot make (#2145). It is nil when + // today has no reading or there is no 30-day baseline to stand one against, and both fell + // through to the at-baseline chip: a tile with no reading read "— at baseline", and a + // first-week tile put a reading exactly on a baseline that did not exist yet. StatTile draws + // the pill only for a non-nil delta, so nil is already the way to say nothing here. + if delta == nil { + deltaText = nil + deltaColor = StrandPalette.textTertiary + } else if let delta, abs(delta) >= 0.5 { let up = delta > 0 let isStressful = (up == higherIsStress) deltaText = String(localized: "\(up ? "+" : "−")\(Int(abs(delta).rounded())) vs base") @@ -547,7 +600,7 @@ struct StressView: View { ChartCard( title: "Stress · \(range.label)", subtitle: String(localized: "Daily 0-3 proxy"), - trailing: String(localized: "avg \(String(format: "%.1f", avg))"), + trailing: String(localized: "avg \(StressTrace.formatLevel(avg))"), tint: StressRamp.calm ) { TrendChart( @@ -556,14 +609,14 @@ struct StressView: View { valueRange: 0...3, showsArea: true, height: NoopMetrics.chartHeight, - valueFormat: { String(format: "%.1f", $0) }, + valueFormat: { StressTrace.formatLevel($0) }, accessibilityLabel: String(localized: "Stress trend"), yDomain: 0...yTop ) } footer: { ChartFooter([ - ("Today", String(format: "%.1f", model.score)), - ("Average", String(format: "%.1f", avg)), + ("Today", StressTrace.formatLevel(model.score)), + ("Average", StressTrace.formatLevel(avg)), ("Days", "\(points.count)"), ]) } @@ -658,7 +711,7 @@ private struct StressHeroGauge: View { // so the score is passed straight through — no external roll state needed. CountUpText( value: score, - format: { String(format: "%.1f", $0) }, + format: { StressTrace.formatLevel($0) }, font: StrandFont.rounded(34, weight: .bold), color: .white ) @@ -670,7 +723,7 @@ private struct StressHeroGauge: View { .allowsHitTesting(false) // taps fall through to the vessel → splash } .accessibilityElement(children: .ignore) - .accessibilityLabel("Stress \(String(format: "%.1f", score)) of 3") + .accessibilityLabel(String(localized: "Stress \(StressTrace.formatLevel(score)) of 3")) } } @@ -967,15 +1020,14 @@ struct DaytimeLoadLine: View { GeometryReader { geo in let w = geo.size.width let h = geo.size.height - let n = max(hours.count, 1) - // x for an hour index; y maps a 0–3 level into the chart (0 at bottom). - // (closures, not `func` — a `@ViewBuilder` closure can't contain declarations) - let x: (Int) -> CGFloat = { i in n <= 1 ? w / 2 : w * CGFloat(i) / CGFloat(n - 1) } + // y maps a 0–3 level into the chart (0 at bottom), for the baseline rule below. The x + // placement moved into `scoredRuns`, which needs it per point anyway. + // (a closure, not a `func` — a `@ViewBuilder` closure can't contain declarations) let y: (Double) -> CGFloat = { level in h - h * CGFloat(min(max(level / 3.0, 0), 1)) } - let pts: [(CGFloat, CGFloat)] = hours.enumerated().compactMap { i, p in - p.level.map { (x(i), y($0)) } - } + // Contiguous runs of scored hours. Built in a method, not here: this is a + // `@ViewBuilder` closure and cannot hold statements. + let runs = scoredRuns(width: w, height: h) ZStack { // Baseline (1.5 of 3) reference line. @@ -986,31 +1038,44 @@ struct DaytimeLoadLine: View { } .stroke(StrandPalette.hairline, style: StrokeStyle(lineWidth: 1, dash: [3, 3])) - if pts.count >= 2 { - // Soft area fill under the curve — a calm WHOOP-blue wash (no gold). - areaPath(pts, width: w, height: h) - .fill( - LinearGradient( - gradient: Gradient(colors: [ - StressRamp.calm.opacity(0.22), - StressRamp.calm.opacity(0.02), - ]), - startPoint: .top, endPoint: .bottom + // The ramp runs DOWN the chart, not across the day. + // + // It used to be `.leading` to `.trailing`, which painted the stress band colours along + // the x-axis: a calm 9pm hour rendered amber and a tense 7am one blue, so the colour + // said nothing about the score while looking exactly as though it did. Because y maps + // the 0-3 level onto the chart, a vertical ramp makes vertical position the level, which + // is what the Kotlin twin does and what the legend claims. Amber at the top, blue at the + // bottom: `StressRamp.gradient` runs calm-first, so it is reversed here. + let levelRamp = LinearGradient( + gradient: Gradient(colors: Array(StressRamp.stops.map(\.color).reversed())), + startPoint: .top, endPoint: .bottom + ) + ForEach(Array(runs.enumerated()), id: \.offset) { _, seg in + if seg.count >= 2 { + // Closed PER RUN, so the wash cannot spread under an hour that was never scored + // and undo the gap the broken line just drew. + areaPath(seg, width: w, height: h) + .fill( + LinearGradient( + gradient: Gradient(colors: [ + StressRamp.calm.opacity(0.22), + StressRamp.calm.opacity(0.02), + ]), + startPoint: .top, endPoint: .bottom + ) ) - ) - // The gradient line itself (blue→green→amber, left→right). - linePath(pts) - .stroke( - LinearGradient(gradient: StressRamp.gradient, - startPoint: .leading, endPoint: .trailing), - style: StrokeStyle(lineWidth: 2.5, lineCap: .round, lineJoin: .round) - ) - } else if let only = pts.first { - // A single scored hour: a lone dot rather than a line. - Circle() - .fill(StressRamp.color(1.5)) - .frame(width: 6, height: 6) - .position(x: only.0, y: only.1) + linePath(seg) + .stroke(levelRamp, + style: StrokeStyle(lineWidth: 2.5, lineCap: .round, lineJoin: .round)) + } else if let only = seg.first { + // A run of one scored hour: a dot rather than a line. Coloured by the level it + // actually carries — it used to be hardcoded to the mid colour, so a lone HIGH + // hour drew as an ordinary one. + Circle() + .fill(StressRamp.color(level(at: only.1, height: h))) + .frame(width: 6, height: 6) + .position(x: only.0, y: only.1) + } } } } @@ -1020,6 +1085,36 @@ struct DaytimeLoadLine: View { .accessibilityLabel(accessibilitySummary) } + /// CONTIGUOUS RUNS of scored hours, in chart coordinates. + /// + /// The old path `compactMap`-ed the unscored hours away and stroked a smooth curve through whatever + /// was left, which draws a reading straight across an hour that has none — the one thing the caption + /// promises it will not do. Splitting into runs lets each be stroked and filled separately, so a + /// hole in the day stays a hole. The Kotlin twin has always broken the line here. + private func scoredRuns(width w: CGFloat, height h: CGFloat) -> [[(CGFloat, CGFloat)]] { + let n = max(hours.count, 1) + var out: [[(CGFloat, CGFloat)]] = [] + var run: [(CGFloat, CGFloat)] = [] + for (i, p) in hours.enumerated() { + guard let level = p.level else { + if !run.isEmpty { out.append(run); run = [] } + continue + } + let px = n <= 1 ? w / 2 : w * CGFloat(i) / CGFloat(n - 1) + let py = h - h * CGFloat(min(max(level / 3.0, 0), 1)) + run.append((px, py)) + } + if !run.isEmpty { out.append(run) } + return out + } + + /// The 0-3 level a chart y-position represents: the inverse of the `y` mapping above, so a lone + /// point can be coloured by what it actually reads rather than by a fixed guess. + private func level(at yPos: CGFloat, height: CGFloat) -> Double { + guard height > 0 else { return 0 } + return Double((height - yPos) / height) * 3.0 + } + /// A smooth (Catmull-Rom-ish) stroke through the scored points. private func linePath(_ pts: [(CGFloat, CGFloat)]) -> Path { var path = Path() @@ -1051,7 +1146,7 @@ struct DaytimeLoadLine: View { private var accessibilitySummary: String { let scored = hours.compactMap { p in p.level.map { (p.hour, $0) } } guard !scored.isEmpty else { return String(localized: "No intraday stress data yet today.") } - let parts = scored.map { "\($0.0):00 \(String(format: "%.1f", $0.1))" } + let parts = scored.map { "\($0.0):00 \(StressTrace.formatLevel($0.1))" } return String(localized: "Autonomic load today: \(parts.joined(separator: ", "))") } } @@ -1229,7 +1324,7 @@ private struct StressPreviewHarness: View { LazyVGrid(columns: [GridItem(.adaptive(minimum: 168), spacing: NoopMetrics.gap)], alignment: .leading, spacing: NoopMetrics.gap) { - StatTile(label: "Stress", value: String(format: "%.1f", score), + StatTile(label: "Stress", value: StressTrace.formatLevel(score), caption: "of 3 · \(band.title)", accent: StressRamp.color(score)) StatTile(label: "Resting HR", value: "54 bpm", accent: StrandPalette.metricRose, delta: "+3 vs base", deltaColor: StrandPalette.statusWarning) @@ -1242,9 +1337,9 @@ private struct StressPreviewHarness: View { ChartCard(title: "Stress · M", subtitle: "Daily 0-3 proxy", trailing: "avg 1.5") { TrendChart(points: sampleStressTrend(30), gradient: StressRamp.gradient, valueRange: 0...3, showsArea: true, height: NoopMetrics.chartHeight, - valueFormat: { String(format: "%.1f", $0) }) + valueFormat: { StressTrace.formatLevel($0) }) } footer: { - ChartFooter([("Today", String(format: "%.1f", score)), ("Average", "1.5"), ("Days", "30")]) + ChartFooter([("Today", StressTrace.formatLevel(score)), ("Average", "1.5"), ("Days", "30")]) } SegmentedPillControl(ExploreRange.allCases, selection: $range, adaptsToAvailableWidth: true) { $0.label } diff --git a/Strand/Screens/TestCentreView.swift b/Strand/Screens/TestCentreView.swift index ec5a195605..7f5727c839 100644 --- a/Strand/Screens/TestCentreView.swift +++ b/Strand/Screens/TestCentreView.swift @@ -2,8 +2,10 @@ import SwiftUI import StrandDesign import StrandAnalytics import StrandImport +import OuraProtocol import PolarProtocol import WhoopStore +import WhoopProtocol /// Settings -> Test Centre. The single home for every diagnostic, log and test control (spec section 7). /// @@ -29,6 +31,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 +43,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 @@ -52,8 +67,82 @@ struct TestCentreView: View { // #1284 residual 3: experimental Oura 0x49-onset keying, only offered when an Oura ring is paired. @AppStorage(AppModel.ouraOnsetKeyingKey) private var ouraOnsetKeying = false + // Packed-notification A/B: the official app's SetNotification mask `ff` vs NOOP's `3f`, next connect. + @AppStorage(AppModel.ouraNotifyMaskFullKey) private var ouraNotifyMaskFull = false private var ouraPaired: Bool { model.deviceRegistry?.devices.contains { $0.brand == "Oura" } ?? false } + /// The profile the 0x20 write experiment prefills from. Values are OVERRIDABLE in the field below: + /// the encoding is unverified, so the point is to try 175 (cm) and then 1750 (mm) without a rebuild. + @EnvironmentObject var profile: ProfileStore + + // 0x20 user-info write EXPERIMENT (see OuraUserInfoWrite). Nothing here fires automatically. + @State private var userInfoField: OuraUserInfoField = .height + @State private var userInfoValueText = "" + @State private var userInfoRawHex = "" + @State private var showUserInfoConfirm = false + + /// The value bytes the current selection would send, or nil when the entry is not usable yet. + /// Raw-hex mode is how the date-of-birth (age) path is probed: there is no age setter and no capture + /// pins the 9-byte type-5 layout, so a guess must be typed deliberately, never offered as "Age". + private var userInfoValueBytes: [UInt8]? { + if userInfoField == .dateOfBirth { + let cleaned = userInfoRawHex.filter { !$0.isWhitespace } + guard cleaned.count == userInfoField.valueByteCount * 2 else { return nil } + var out: [UInt8] = [] + var idx = cleaned.startIndex + while idx < cleaned.endIndex { + let next = cleaned.index(idx, offsetBy: 2) + guard let b = UInt8(cleaned[idx.. notify_all(ff)\u{201D} and compare notification sizes in the raw capture.") + .font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) } - .tint(StrandPalette.accent) } + .tint(StrandPalette.accent) + + Divider().overlay(StrandPalette.hairline) + ouraUserInfoWriteBlock + + Divider().overlay(StrandPalette.hairline) + ouraEnableFeatureBlock } } } + /// The 0x20 user-info WRITE experiment. EXPERIMENTAL, manual, one field at a time. + /// + /// This is the only control in NOOP that writes user data to a ring. It exists to answer one + /// question: does a 0x20 write change what tag 0x5c reports? Nothing calls it automatically, and + /// deliberately NOT on connect: the value encoding is unverified, an automatic write would destroy + /// the clean before-state the readout depends on, and the connect/bond window is the app's most + /// fragile path (#1635). Read the strap log for the WRITE line, the 0x20 ACK, and the next 0x5c. + @ViewBuilder + private var ouraUserInfoWriteBlock: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Oura user-info write (experimental)").font(StrandFont.body) + Text("Writes one 0x20 user-info field to the ring and logs what comes back. The ring reports 0x5c as firmware defaults (40 y, 75 kg, sex unset, 176 cm) even though your Oura app profile is correct, and the Oura app never writes it. Whether 0x20 lands in 0x5c is UNKNOWN, and so is the value encoding: try 175 for cm, then 1750 for mm. Age has no setter, only date-of-birth, whose 9-byte layout is unknown, so it is raw hex only. Tested only on Gen 3 so far — Gen 5 validation is open.") + .font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + + Picker("Field", selection: $userInfoField) { + Text("Height").tag(OuraUserInfoField.height) + Text("Sex").tag(OuraUserInfoField.gender) + Text("Weight").tag(OuraUserInfoField.weight) + Text("DOB (raw)").tag(OuraUserInfoField.dateOfBirth) + } + .pickerStyle(.segmented) + .onChange(of: userInfoField) { _ in prefillUserInfoValue() } + + if userInfoField == .dateOfBirth { + TextField("18 hex chars (9 bytes)", text: $userInfoRawHex) + .font(StrandFont.body).textFieldStyle(.roundedBorder) + } else { + TextField("value", text: $userInfoValueText) + .font(StrandFont.body).textFieldStyle(.roundedBorder) + } + + Text("Frame: \(userInfoFramePreview)") + .font(StrandFont.caption).foregroundStyle(StrandPalette.textSecondary) + + HStack(spacing: 12) { + Button("Write to ring") { showUserInfoConfirm = true } + .disabled(userInfoValueBytes == nil) + Button("Write zeros") { + _ = model.sourceCoordinator?.ouraSource?.writeUserInfo( + field: userInfoField, + value: [UInt8](repeating: 0, count: userInfoField.valueByteCount)) + } + } + .font(StrandFont.body).tint(StrandPalette.accent) + } + .onAppear { if userInfoValueText.isEmpty { prefillUserInfoValue() } } + .alert("Write to the ring?", isPresented: $showUserInfoConfirm) { + Button("Cancel", role: .cancel) { } + Button("Write", role: .destructive) { + guard let v = userInfoValueBytes else { return } + if model.sourceCoordinator?.ouraSource?.writeUserInfo(field: userInfoField, value: v) != true { + infoTitle = "Not written" + infoMessage = "No connected Oura ring. Connect the ring, then try again." + showInfo = true + } + } + } message: { + Text("Sends \(userInfoFramePreview) to the ring. This changes ring-side config. Restore with the firmware default (height 176, weight 75, sex 2) or write zeros.") + } + } + + /// The `SetFeatureMode` WRITE experiment (`2f 03 22 `) — hardware-CONFIRMED 2026-09-11: + /// disabling then re-enabling SpO2 flipped `mode` `1→0→1`, reproduced both directions on a real + /// Gen 3 ring. `docs/OURA_PROTOCOL.md` s7.5 still marks the account-gate-bypass claim itself + /// unvalidated (this ring was already cloud-entitled), but the write mechanism itself is proven. + /// Scoped to SpO2 / real-steps / exercise-HR / CVA-PPG-sampler, mode off/automatic only — daytime + /// HR and resting HR are deliberately excluded (see the state-var comment above). + /// + /// Each row shows the ring's OWN last-known status live (mirrored via `AppModel.ouraFeatureStatuses` + /// off `OuraLiveSource.featureStatuses`) — originally log-only, changed after the same hardware run + /// made clear that reading the strap log for every check was the wrong tradeoff for a control meant + /// to be poked repeatedly. The strap log still carries the WRITE line and the 0x23 ACK either way. + /// + /// One Enable/Disable button PER feature — see the state-var comment above for why this replaced + /// a shared feature+mode picker pair. + @ViewBuilder + private var ouraEnableFeatureBlock: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Oura feature enable (experimental)").font(StrandFont.body) + Text("Enables or disables SpO2, real-steps, exercise HR, or the CVA PPG sampler directly via SetFeatureMode, then re-reads the feature's status. Unvalidated on NOOP's own hardware (OURA_PROTOCOL.md \u{00A7}7.5) — a third-party report is what these buttons exist to test. Daytime HR and resting HR are deliberately not offered here: daytime HR has its own dedicated live-HR path, and resting HR has no app-level toggle at all.") + .font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + + ouraFeatureEnableRow(label: "SpO2", feature: OuraCommands.featureSpO2) + ouraFeatureEnableRow(label: "Real steps", feature: OuraCommands.featureRealSteps) + ouraFeatureEnableRow(label: "Exercise HR", feature: OuraCommands.featureExerciseHR) + ouraFeatureEnableRow(label: "CVA PPG sampler", feature: OuraCommands.featureCvaPpg) + } + .alert("Write to the ring?", isPresented: Binding( + get: { pendingFeatureWrite != nil }, + set: { if !$0 { pendingFeatureWrite = nil } } + ), presenting: pendingFeatureWrite) { pending in + Button("Cancel", role: .cancel) { } + Button("Write", role: .destructive) { + if model.sourceCoordinator?.ouraSource?.writeFeatureMode(feature: pending.feature, mode: pending.mode) != true { + infoTitle = "Not written" + infoMessage = "No connected Oura ring. Connect the ring, then try again." + showInfo = true + } + } + } message: { pending in + Text("Sends \(pending.framePreview) to the ring. Unvalidated on NOOP's own hardware (OURA_PROTOCOL.md \u{00A7}7.5). Watch the strap log for the follow-up feature-status read.") + } + } + + @ViewBuilder + private func ouraFeatureEnableRow(label: LocalizedStringKey, feature: UInt8) -> some View { + HStack(spacing: 12) { + Text(label).font(StrandFont.body) + Spacer() + Text(ouraFeatureStatusLabel(feature)) + .font(StrandFont.caption) + .foregroundStyle(ouraFeatureStatusIsOn(feature) ? StrandPalette.statusPositive : StrandPalette.textTertiary) + Button("Enable") { pendingFeatureWrite = PendingFeatureWrite(feature: feature, mode: 0x01) } + Button("Disable") { pendingFeatureWrite = PendingFeatureWrite(feature: feature, mode: 0x00) } + } + .tint(StrandPalette.accent) + } + + /// The ring's own last-known status for this feature, mirrored live off `OuraLiveSource` (never a + /// guess from what we last SENT — see the 2026-09-11 hardware run where a self-induced disable had + /// to be told apart from a genuine cloud gate). "Unknown" until the connect-time auto-probe (SpO2/ + /// real-steps) or an explicit Enable/Disable has produced at least one read-back this connection. + /// `mode` is what Enable/Disable actually controls, so it drives BOTH the primary label and the + /// color — a real 2026-09-11 hardware run showed SpO2 correctly enabled (`mode=automatic`) read as + /// "still off" because the label led with `status` instead, and `status=0`'s word ("off") sat right + /// next to the Enable/Disable buttons, reading as a second, contradicting toggle. `status` is real + /// and worth showing (SpO2 samples periodically, not continuously, so `status=0` "idle" is its + /// NORMAL resting state even while enabled) but only as a silent-by-default qualifier, in vocabulary + /// that cannot be mistaken for another on/off switch — "active"/"searching"/etc, never "on"/"off". + private func ouraFeatureStatusLabel(_ feature: UInt8) -> String { + guard let st = model.ouraFeatureStatuses[Int(feature)] else { return String(localized: "Unknown") } + let mode = ouraFeatureModeLabel(st.mode) + guard let qualifier = ouraFeatureStatusQualifier(st.status) else { return mode } + return "\(mode) \u{00B7} \(qualifier)" + } + + private func ouraFeatureModeLabel(_ mode: Int) -> String { + switch mode { + case 0: return String(localized: "Off") + case 1: return String(localized: "Automatic") + case 2: return String(localized: "Requested") + case 3: return String(localized: "Connected live") + default: return String(localized: "Unknown") + } + } + + /// nil for status=0 ("idle") — the expected resting state for an ENABLED feature between samples, + /// not worth a qualifier every time. Non-nil only when the ring reports something beyond that. + private func ouraFeatureStatusQualifier(_ status: Int) -> String? { + switch status { + case 0: return nil + case 1: return String(localized: "active") + case 2: return String(localized: "searching") + case 3: return String(localized: "no PPG") + case 4: return String(localized: "cold") + case 5: return String(localized: "movement") + case 6: return String(localized: "identifying") + default: return String(localized: "unknown") + } + } + + /// Colors the row on `mode` (what Enable/Disable controls), not `status` (a transient sampling + /// detail) — see the doc comment on `ouraFeatureStatusLabel` for why conflating the two misled a + /// real hardware test into reading a correctly-enabled SpO2 as still off. + private func ouraFeatureStatusIsOn(_ feature: UInt8) -> Bool { + model.ouraFeatureStatuses[Int(feature)]?.mode == 1 + } + // MARK: - Section 3: Export and auto-export (manual Report + scheduled export) @ViewBuilder private var exportCard: some View { @@ -417,6 +819,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: { @@ -539,9 +972,12 @@ private struct TestModeRow: View { } HStack { Spacer() - Button("Report") { report.start(mode: mode, live: live, repo: model.repo) } + // "Share", not "Report", to match the strap-log button and the sheet this opens, whose + // own copy already reads "Nothing leaves this phone until you tap Share". The word also + // describes what the tap does: it builds a redacted bundle and hands it to the share + // sheet. Nothing is sent anywhere. + Button("Share") { report.start(mode: mode, live: live, repo: model.repo) } .buttonStyle(.plain).font(StrandFont.mono).foregroundStyle(StrandPalette.accent) - .accessibilityLabel("Report a \(mode.title) bug") } } .onAppear { @@ -643,7 +1079,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..ec2327c6d2 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 } } } @@ -72,10 +74,11 @@ extension DashboardCard { case .vo2max: return StrandPalette.chargeColor case .vitality, .hrv: return StrandPalette.metricPurple case .restingHr: return StrandPalette.metricRose - case .steps, .bloodOxygen, .hydration: return StrandPalette.metricCyan + case .steps, .stepsAverage30, .bloodOxygen, .hydration: return StrandPalette.metricCyan 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..f218821308 100644 --- a/Strand/Screens/TodayView.swift +++ b/Strand/Screens/TodayView.swift @@ -2,6 +2,7 @@ import SwiftUI import StrandDesign import StrandAnalytics import WhoopStore +import WhoopProtocol import Foundation // MARK: - Control Center (the home dashboard), HomeDensity rewrite @@ -65,24 +66,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 +112,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 +188,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 +230,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) } @@ -255,6 +277,9 @@ struct TodayView: View { @State private var customizationDestination: TodayCustomizationDestination? // Hydration tracker (opt-in, default OFF). When off the hydration dashboard card is hidden even if a // user had it in their saved selection, the feature owns its own gate. + /// The Coach master switch (`noop.coachEnabled`, shared by name with Android). Default ON. Gates the + /// Today launcher card here; the tab and the daily brief read the same key. + @AppStorage("noop.coachEnabled") private var coachEnabled = true @AppStorage(HydrationStore.enabledKey) private var hydrationEnabled = false /// Today's hydration total + goal (ml), loaded in loadAll when the feature is on. nil hides the value. @State private var hydrationTotalML: Double? @@ -265,6 +290,10 @@ struct TodayView: View { // It's not in the default selection, so a fresh install never shows it until both are true. DashboardCardPrefs.decodeEnabled(dashboardCardsRaw) .filter { hydrationEnabled || $0 != .hydration } + // Coach off means the AI is off, so the launcher card goes with the tab: leaving it on + // Today would offer a feature the wearer has just switched off. Same gate shape as + // hydration above, so a card they had added keeps its place and returns on re-enable. + .filter { coachEnabled || $0 != .coach } } // #755: a mirror of `LiveState.backfilling` (strap mid history-offload). TodayView must NOT observe @@ -274,6 +303,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 +315,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. @@ -289,6 +330,16 @@ struct TodayView: View { @State private var fitnessAgeToday: Double? @State private var vo2maxToday: Double? // #1391 @State private var vitalityToday: Double? + /// #1505: whether this strap's R-R is read under the WHOOP 5 single-transport unit policy, plus the + /// two local days that bound the era it could not score: the first day it banked ANY beat, and the + /// first day it banked a scorable one (nil when it has banked none). Device-level and cheap, so they + /// are re-read on every load rather than snapshotted, exactly like hydration: the scorable day moves + /// the moment a sync lands labelled beats, and a stale copy would keep telling a wearer their last + /// night could not be scored after it had been. + @State private var whoop5StrictRR = false + @State private var firstRecordedRRDay: String? + @State private var firstScorableRRDay: String? + /// Distinct days + sleep sessions imported from a Mi Band (Mi Fitness), for the Data Sources row. @State private var xiaomiDays = 0 @State private var xiaomiSleeps = 0 @@ -319,6 +370,11 @@ struct TodayView: View { // Today's heart rate as 5-minute bucket means (midnight → now), for the 24h trend chart. @State private var hrPoints: [TrendPoint] = [] + /// The day's true lowest and highest SAMPLES, not the extremes of the plotted five-minute means. + /// `hrPoints` carries only each bucket's mean, so a Min/Max read off it describes the calmest and + /// busiest five minutes rather than the day, which is why a workout's max could exceed it (#2032). + @State private var hrDayMin: Double? + @State private var hrDayMax: Double? // The night's sleep session overlapping the HR window, shaded as a band on the HR chart and // used to anchor the recovery marker at wake time (WHOOP-style Overview HR annotations). @@ -329,6 +385,12 @@ struct TodayView: View { // when a sleep-origin card is hosted. Twin of the LiquidTodayView `hostedSleepModel`. @State private var hostedSleepModel: SleepModel? = nil + // #2040: today's scored stress for the hosted curve card. Loaded only when that card is hosted, the + // same "hosting none pays nothing" rule the sleep model follows. `StressDayCurve` self-gates on a + // cheap heart-rate fingerprint, so a refresh that changed nothing costs one indexed COUNT and no + // rows, and the iOS widget shares the same computation rather than scoring the day twice. + @State private var hostedStressHours: [DaytimeStress.HourPoint] = [] + // TODAY's in-progress Effort (NOOP 0–100 axis), recomputed over the day's HR (local-midnight→now) // each load so the gauge tracks today as it accumulates rather than waiting on the heavy daily pass // to persist, which early in the day would otherwise surface yesterday's completed Effort or a stale @@ -374,6 +436,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 +629,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.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.lastSkinTempDay(days: repo.days, todayKey: displayDay?.day ?? selectedDayKey) + 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 @@ -607,6 +705,20 @@ struct TodayView: View { return min(upper, max(0, current + delta)) } + /// #2378 - the day step a horizontal swipe of `dx` points asks for: +1 OLDER, -1 NEWER. + /// + /// Rightward (dx > 0) is OLDER and leftward is NEWER, which is direct manipulation — dragging the + /// content left brings the page to its right, the later day, into view — and the direction the + /// Kotlin twin already takes (`dayNavSwipeTarget`, pinned by `DayNavTest`). Apple ran the opposite + /// way in both shells, so the same gesture moved the day backwards here and forwards there. + /// + /// Pure and shared by both Apple shells so the direction is pinned by a test rather than living + /// twice inside gesture closures, which is how the two platforms drifted apart unnoticed. + /// Mirror EXACTLY in Kotlin. + static func daySwipeDelta(dx: CGFloat) -> Int { + dx > 0 ? 1 : -1 + } + /// #16 - whole days-back offset for a date chosen in the day-nav picker, measured from the LOGICAL day /// (not raw Date()). Pure + unit-testable so the 00:00-04:00 rollover case is locked: in that window the /// logical day is the PREVIOUS calendar day, so anchoring the offset here (rather than on raw Date()) @@ -664,6 +776,30 @@ struct TodayView: View { return lastValue } + /// #1164/#2012 — should today's Rest be MARKED provisional? When the strap has banked records not yet + /// offloaded, the Rest score is computed from partial data and may change once the full night lands and + /// `analyzeRecent` re-scores it. Saying so reads honestly instead of as a bug when the number moves. + /// + /// True means "caption it as pending", NOT "hide it". #2012: the number used to be withheld on both + /// surfaces while this was true, so a user whose night was scored saw nothing for as long as the strap + /// had anything left to send, which on a continuously banking strap is most of the day. A number that + /// may still move is not the same as no number, and it is the one the screen exists to show. + /// + /// 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 annotates a score; it never fabricates one where 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 @@ -709,26 +845,9 @@ struct TodayView: View { /// fold while actually recomputing it. One call folds each series exactly once (three passes), and the /// sheet reads drivers + confidence out of a single sheet-local `let`. private func chargeBreakdown() -> (drivers: [ChargeDriver], confidence: ScoreConfidence)? { - guard let row = chargeBreakdownRow, - let hrv = row.avgHrv, let rhr = row.restingHr else { return nil } - let hrvBase = Baselines.foldHistory(repo.days.map(\.avgHrv), cfg: Baselines.hrvCfg) - guard hrvBase.usable else { return nil } - let rhrBase = Baselines.foldHistory(repo.days.map { $0.restingHr.map(Double.init) }, - cfg: Baselines.restingHRCfg) - let respBase = Baselines.foldHistory(repo.days.map(\.respRateBpm), cfg: Baselines.respCfg) - // Rest-quality term = the Rest composite ÷100, matching AnalyticsEngine's `sleepPerf`. `restScore` - // is the same merged sleep_performance value the Rest ring reads, so the term stays consistent. - let sleepPerf = restScore.map { $0 / 100.0 } - let drivers = RecoveryScorer.chargeDrivers( - hrv: hrv, rhr: Double(rhr), resp: row.respRateBpm, - hrvBaseline: hrvBase, - rhrBaseline: rhrBase.usable ? rhrBase : nil, - respBaseline: respBase.usable ? respBase : nil, - sleepPerf: sleepPerf, skinTempDev: row.skinTempDevC) - // Confidence tier SURFACED (never recomputed) from the existing `ScoreConfidence.charge` against - // the SAME folded HRV baseline the drivers scored with, so the dot + tier tag in the sheet header - // agree with the breakdown by construction. - return (drivers, ScoreConfidence.charge(recovery: row.recovery, hrvBaseline: hrvBase)) + guard let row = chargeBreakdownRow else { return nil } + return ChargeBreakdownWiring.breakdown(days: repo.days, row: row, sleepPerfPercent: restScore, + hrvBaselineEpoch: Baselines.hrvBaselineEpoch()) } /// The night's relative skin-temp marker for the displayed row (A5), or nil. Surfaced verbatim from @@ -930,7 +1049,12 @@ struct TodayView: View { switch metricKey { case "recovery": // Same HRV-baseline gate the Charge engine uses, fed by the loaded nightly SDNN history. - let hrvBase = Baselines.foldHistory(repo.days.map(\.avgHrv), cfg: Baselines.hrvCfg) + // #2315: with the recalibration epoch, which is what makes the claim above true. Without it this + // gate folded the whole history while the Charge engine folded from the epoch, so the pill could + // read solid off nights the ring is no longer using. + let hrvBase = Baselines.foldHistory(repo.days.map(\.avgHrv), dayKeys: repo.days.map(\.day), + cfg: Baselines.hrvCfg, + baselineEpoch: Baselines.hrvBaselineEpoch()) conf = ScoreConfidence.charge(recovery: displayDay?.recovery, hrvBaseline: hrvBase) case "sleep_performance": // A watch night with a Rest score reads as built; without one it's still calibrating. @@ -1043,6 +1167,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." } @@ -1131,8 +1265,8 @@ struct TodayView: View { let dy = value.translation.height // Horizontal-dominant and far enough to count as a deliberate day flip. guard abs(dx) > abs(dy) * 1.5, abs(dx) > 50 else { return } - // Swipe LEFT (dx < 0) -> OLDER day (+1 offset); swipe RIGHT -> NEWER day (-1 offset). - let delta = dx < 0 ? 1 : -1 + // Swipe RIGHT (dx > 0) -> OLDER day (+1 offset); swipe LEFT -> NEWER day (-1 offset). + let delta = Self.daySwipeDelta(dx: dx) let next = Self.clampedDayOffset(current: selectedDayOffset, delta: delta, maxOffset: earliestDayOffset) guard next != selectedDayOffset else { return } @@ -1415,11 +1549,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. @@ -1442,6 +1578,7 @@ struct TodayView: View { derivedKey = newKey } .onAppear { + DashboardCardPrefs.migrateLegacyStepsAverage() if derivedKey != todayInputKey { derived = buildDerived() derivedKey = todayInputKey @@ -1468,6 +1605,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 +1633,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 }) @@ -1557,69 +1711,6 @@ struct TodayView: View { // MARK: First-run scoring-guide card (one-time, dismissible) - /// "New here?", a single, dismissible card that points first-time users at the guide. Tapping the - /// card opens the guide; the ✕ closes it. Either action sets `scoringGuideCardSeen`, so it shows - /// once and never again. Follows the in-flow, never-modal card pattern. - private var scoringGuideFirstRunCard: some View { - NoopCard { - HStack(alignment: .top, spacing: 14) { - Image(systemName: "sparkles") - .font(.system(size: 18)) - .foregroundStyle(StrandPalette.accent) - .accessibilityHidden(true) - VStack(alignment: .leading, spacing: 4) { - Text("New here?") - .font(StrandFont.headline) - .foregroundStyle(StrandPalette.textPrimary) - Text("See how Charge, Effort and Rest are calculated, and how they differ from WHOOP.") - .font(StrandFont.subhead) - .foregroundStyle(StrandPalette.textSecondary) - .fixedSize(horizontal: false, vertical: true) - Button { - scoringGuideCardSeen = true - showGuideTop = true - } label: { - Label("How your scores work", systemImage: "arrow.right") - .font(StrandFont.subhead) - } - .buttonStyle(.plain) - .foregroundStyle(StrandPalette.accent) - .padding(.top, 2) - } - Spacer(minLength: 0) - Button { - // Dismiss INTO the Updates inbox (restorable), rather than permanently hiding. - withAnimation(StrandMotion.interactive) { - dismissTodayCard( - id: "newHere", - title: String(localized: "New here?"), - message: String(localized: "How Charge, Effort and Rest are calculated, and how they differ from WHOOP.") - ) - } - } label: { - Image(systemName: "xmark") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(StrandPalette.textTertiary) - .padding(6) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityLabel("Dismiss") - } - // The whole card is tappable as the primary action; the ✕ stops the tap from also firing. - .contentShape(Rectangle()) - .onTapGesture { - #if os(iOS) - StrandHaptic.selection.play() - #endif - scoringGuideCardSeen = true - showGuideTop = true - } - } - // Press-down feedback for the tappable card surface. - .strandPressable() - } - // MARK: Readiness, on-device training-readiness synthesis (HRV / resting-HR / load). /// S4: Readiness now lives behind the Charge-ring tap (in `chargeBreakdownSheet`), not as a standalone @@ -1926,7 +2017,12 @@ struct TodayView: View { // BEFORE the generic Component-2 note (and on every day, not just today): unlike an ordinary // "missing data" gap, here the exact cause and the fix are known, so a past day gets the same // honest explanation rather than the usual silent bare ring. - if chargeDeepWindowGap { + // #1505 is checked BEFORE #233 because it is the stronger claim: when the night has no + // scorable beats at all, the Deep-window note would name a window that was never reached and + // send the wearer to a setting that cannot help. + if chargeLegacyRRGap { + chargeLegacyRRGapNote + } else if chargeDeepWindowGap { chargeDeepWindowGapNote } else if selectedDayOffset == 0 && !chargeScoreState.isCalibrating { // Component 2, when Charge has no real today value, an explained state with its detail + @@ -1963,6 +2059,43 @@ struct TodayView: View { } } + /// #1505: whether the SELECTED day's empty Charge is explained by the WHOOP 5 unit policy having no + /// scorable beats for that night. Reads two device-level facts loaded once (`whoop5StrictRR`, + /// `firstScorableRRDay`) plus fields `displayDay` already carries; the judgement itself is the pure + /// `Whoop5RR.legacyUnscorableNight`, shared byte for byte with Android. + private var chargeLegacyRRGap: Bool { + guard let d = displayDay, d.recovery == nil else { return false } + return Whoop5RR.legacyUnscorableNight(strictWhoop5: whoop5StrictRR, day: d.day, + firstRecordedDay: firstRecordedRRDay, + firstScorableDay: firstScorableRRDay, + avgHrv: d.avgHrv, totalSleepMin: d.totalSleepMin) + } + + /// #1505: the note shown instead of a bare "-" when this night's beats predate transport labelling. + /// Same card shape as the #233 note it sits beside, on today AND a navigated past day alike, since a + /// past day is where this one is almost always read. + private var chargeLegacyRRGapNote: some View { + NoopCard(padding: 14, tint: StrandPalette.chargeColor) { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "waveform.path.ecg") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(StrandPalette.chargeColor) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 3) { + Text(ChargeBreakdownFormat.chargeLegacyRRGapTitle) + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.textPrimary) + Text(ChargeBreakdownFormat.chargeLegacyRRGapDetail) + .font(StrandFont.subhead) + .foregroundStyle(StrandPalette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(ChargeBreakdownFormat.chargeLegacyRRGapAccessibility) + } + /// #233: whether the SELECTED day's empty Charge is explained by the Deep-sleep HRV window finding no /// deep-stage sleep that night (see `ChargeBreakdownFormat.chargeDeepWindowGap`). Reads only fields /// `displayDay` already carries (`avgHrv`, `deepMin`) — no recompute, no new analytics. @@ -2066,9 +2199,13 @@ struct TodayView: View { skinTempRel: chargeSkinTempRel) } } else { - // #233: a night with no deep sleep under the Deep HRV window has a known, specific - // cause, so it tap-throughs to that explanation rather than the generic empty note. - if chargeDeepWindowGap { + // #1505 / #233: a night whose beats predate transport labelling, and a night with no + // deep sleep under the Deep HRV window, both have a known specific cause, so each + // taps through to its own explanation rather than the generic empty note. Same + // precedence as the note above the rings: no scorable beats outranks no deep sleep. + if chargeLegacyRRGap { + chargeLegacyRRGapNote + } else if chargeDeepWindowGap { chargeDeepWindowGapNote } else if let banked = recoveryCalibration { // A calibrating / cold-start night has no contributions to attribute: tap through @@ -2352,7 +2489,12 @@ struct TodayView: View { if selectedDayOffset == 0 && !cards.isEmpty { VStack(alignment: .leading, spacing: NoopMetrics.sectionGap) { ForEach(cards) { card in - hostedCard(for: card) + if let route = card.route { + NavigationLink(value: route) { hostedCard(for: card) } + .buttonStyle(.plain) + } else { + hostedCard(for: card) + } } } } @@ -2363,6 +2505,30 @@ struct TodayView: View { private func hostedCard(for card: HostedCard) -> some View { switch card { case .sleepMarks: SleepMarkCard() + case .trendHRV, .trendRestingHR, .trendEffort: + // The Trends charts, drawn by the tab's own ChartCard + TrendChart from the SAME resolved + // points. `HostedTrendData` walks the `days` already in hand, so unlike the sleep model and + // the stress curve there is no read behind these and nothing to gate. + HostedTrendCard(card: card, days: repo.days, effortScale: effortScale) + case .stressToday: + // READ-ONLY, like `stages`: the Stress tab keeps the interactive timeline and this mirrors + // only the display. `DaytimeLoadLine` is the tab's OWN line, so the host cannot drift into + // a second drawing of the same day. + NoopCard(tint: StressRamp.calm) { + VStack(alignment: .leading, spacing: 14) { + Text("Stress through the day").strandOverline() + if hostedStressHours.contains(where: { $0.level != nil }) { + DaytimeLoadLine(hours: hostedStressHours) + } else { + // The honest blank: only waking hours score and an hour needs enough heart + // rate, so early morning is empty by construction rather than by failure. + Text("Calibrating") + .font(StrandFont.subhead) + .foregroundStyle(StrandPalette.textTertiary) + .frame(maxWidth: .infinity, minHeight: 60, alignment: .center) + } + } + } case .asleepDuration: AsleepDurationCard(data: AsleepDurationData.build(days: repo.days)) case .stagesVsTypical: // Renders from the shared SleepModel built in loadAll() (same inputs as the Sleep tab). Until the @@ -2469,6 +2635,8 @@ struct TodayView: View { private func dashboardCardRow(_ card: DashboardCard) -> some View { let tint = dashboardTint(card) switch card { + case .stepsAverage30: + RollingStepsAverageCard(day: selectedDayKey) case .stress: pinnedCardRow(icon: card.icon, tint: tint, title: card.title, subtitle: card.subtitle, value: dashboardValue(card), route: .stress) @@ -2495,6 +2663,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 } } } @@ -2512,10 +2685,11 @@ struct TodayView: View { case .bloodOxygen: return StrandPalette.metricCyan case .skinTemp: return StrandPalette.metricAmber case .sleep: return StrandPalette.restColor - case .steps: return StrandPalette.metricCyan + case .steps, .stepsAverage30: return StrandPalette.metricCyan case .calories: return StrandPalette.metricAmber case .hydration: return StrandPalette.metricCyan case .coupled: return StrandPalette.chargeColor + case .coach: return StrandPalette.accent } } @@ -2530,16 +2704,24 @@ struct TodayView: View { return card.unit.isEmpty ? s : "\(s) \(card.unit)" } switch card { + case .stepsAverage30: + return "" // The self-loading card owns its result and coverage together. case .hrv: #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 +2760,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: @@ -2604,7 +2786,8 @@ struct TodayView: View { // copy and the owner's reply on #706. return stressToday.map { "\(Int($0.rounded()))" } ?? Self.calibratingPlaceholder case .fitnessAge: - return withUnit(fitnessAgeToday.map { "\(Int($0.rounded()))" } ?? "—") + // Bound symbol as on the Health hero (#2173). + return withUnit(fitnessAgeToday.map { "\(fitnessAgeBoundSymbol($0))\(Int($0.rounded()))" } ?? "—") case .vo2max: return vo2maxToday.map { "\(Int($0.rounded()))" } ?? "—" case .vitality: @@ -2618,6 +2801,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 +2816,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 +2959,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 +2991,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 +3003,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 +3029,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 +3072,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) @@ -2864,14 +3105,6 @@ struct TodayView: View { return hrvInsightDetail(d, score: score) } - /// The Synthesis status colour, keyed on the carried prior recovery when carrying, else today's. - private func synthesisCardColor(score: Double?) -> Color { - if let rec = lastScoredRecoveryDay?.recovery { - return StrandPalette.recoveryColor(rec) - } - return score.map { StrandPalette.recoveryColor($0) } ?? StrandPalette.textTertiary - } - /// Screen-4 insight headline, when the HRV baseline is established, the gold "primed" read /// keyed on how far today's HRV sits above/below the learned baseline ("HRV 12% over baseline"); /// otherwise the recovery-state word. Purely a re-presentation of the existing recovery + HRV @@ -2976,12 +3209,28 @@ struct TodayView: View { // edge, INSIDE the ring frame so it adds no stacked height, keeping the #762 self-sizing row // untouched). It opens the Charge breakdown sheet (the existing ChargeBreakdownSection), built // lazily on tap. No new badge/dot/tier sits under the ring (that would re-load the #762 stack). + // A ring opens the RICHEST explanation this shell has for its score, which is the rule + // Android states outright: "Charge keeps its breakdown sheet, which is richer than a trend and + // has no twin on the iOS liquid Today". That clause is why the three surfaces differ, and it + // is not an oversight. The Liquid Today sends Charge to the trend because it has no breakdown + // to offer; THIS shell has one, so its Charge ring keeps it and matches Android. + // + // Effort and Rest have no breakdown on any platform, so the trend is the richest thing they + // have and both rings open it, exactly as Android's do. The keys are the ones + // `HeroRingDetailRouteTests` pins against `MetricCatalog`; `TabRoute.metric` falls back to the + // Health screen on an unknown key rather than failing, which is why they are pinned. heroRingColumn(section: .charge, domain: .charge, provenanceKey: "recovery", - onRingTap: { showChargeBreakdown = true }) { + onOpenBreakdown: { showChargeBreakdown = true }) { chargeRing(score: score, d: d, diameter: ring) } - heroRingColumn(section: .effort, domain: .effort) { effortRing(d: d, diameter: ring) } - heroRingColumn(section: .rest, domain: .rest, provenanceKey: "sleep_performance") { restRing(diameter: ring) } + heroRingColumn(section: .effort, domain: .effort, + detailRoute: .metric(HeroRingMetric.effort)) { effortRing(d: d, diameter: ring) } + // `provenanceKey` spells the same string the route does and stays a literal on purpose: it + // asks which SOURCE won this day, not which catalog entry to open. See `HeroRingMetric`. + heroRingColumn(section: .rest, domain: .rest, provenanceKey: "sleep_performance", + detailRoute: .metric(HeroRingMetric.rest), + caption: restIsPendingSync ? "Pending sync" : nil, + captionWidth: ring) { restRing(diameter: ring) } } .frame(maxWidth: .infinity, alignment: .center) // Zero-impact width reader: a clear background that publishes the row's width up via preference. It @@ -3011,6 +3260,16 @@ struct TodayView: View { /// The VoiceOver label for a hero ring's "how this score is calculated" button, with the domain word /// interpolated from a localized literal (so the spoken sentence is translated, not half-English). + private static func domainDetailAccessibilityLabel(_ domain: DomainTheme) -> LocalizedStringKey { + switch domain { + case .charge: return "Open your Charge detail" + case .effort: return "Open your Effort detail" + case .rest: return "Open your Rest detail" + case .stress: return "Open your Stress detail" + } + } + + /// The VoiceOver label for a hero ring's "how this score is calculated" chevron. private static func domainGuideAccessibilityLabel(_ domain: DomainTheme) -> LocalizedStringKey { switch domain { case .charge: return "How Charge is calculated" @@ -3025,18 +3284,43 @@ struct TodayView: View { /// intrinsically diameter×diameter, so the column just centres it and stretches to an equal share /// of the row width. @ViewBuilder + /// `caption` is an optional one-line note under the domain label — currently Rest's "Pending sync". + /// + /// It lives HERE, under the label, rather than over the ring, for two reasons. It cannot cover the + /// score, which is what made the old overlay hide a number the user had every right to see. And it is + /// laid out at the COLUMN's width rather than the ring's, so it has room to render: the overlay was + /// measured against the circle and ellipsised its own explanation mid-word while spilling past the + /// ring's edge. Mirrors Android's `HeroRingColumn(caption:)`. private func heroRingColumn( section: ScoreSection, domain: DomainTheme, provenanceKey: String? = nil, - onRingTap: (() -> Void)? = nil, + onOpenBreakdown: (() -> Void)? = nil, detailRoute: TabRoute? = nil, caption: String? = nil, + captionWidth: CGFloat = 98, @ViewBuilder ring: () -> RingBody ) -> some View { VStack(spacing: 8) { - // A1: when the column is tappable (Charge), wrap the ring in a button (the body is just the ring - // with a contentShape so the whole disc is hittable). The tappable ring carries NO in-ring cue: - // the single affordance is the label chevron below it (see the comment near the Button below). - // The non-tappable rings render unchanged. - if let onRingTap { - Button(action: onRingTap) { + // The RING opens this score's own detail, which is what the Liquid Today has done since + // #1995 and what Android does through its own hero keys. Here it used to be Charge alone, + // wired to the breakdown sheet, while Effort and Rest were not tappable at all: two of the + // three rings did nothing and the third went somewhere else. The chevron below keeps whatever + // it already opened, so this adds a destination rather than moving one. + // + // A1: the body is the ring plus a contentShape so the whole disc is hittable, and the ring + // carries NO in-ring cue. `.plain` is load-bearing: a bare NavigationLink applies the default + // link chrome and would tint the ring, the same reason `metricRow` carries a button style. + // A column supplies EITHER a route (Effort, Rest) or a breakdown (Charge, whose richer + // explanation is a sheet rather than a destination), never both. `onOpenBreakdown` drives the + // ring AND the chevron, because for that score both lead to the same sheet and two arguments + // holding one closure would be two things to keep in step. A column with neither renders a + // plain ring, which is what a future domain with nothing richer to open should get. + if let detailRoute { + NavigationLink(value: detailRoute) { + ring().contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(Self.domainDetailAccessibilityLabel(domain)) + .accessibilityAddTraits(.isButton) + } else if let onOpenBreakdown { + Button(action: onOpenBreakdown) { ring().contentShape(Rectangle()) } .buttonStyle(.plain) @@ -3049,7 +3333,7 @@ struct TodayView: View { // ONE chevron affordance under every ring, so the row reads uniformly (no second cue on the // Charge ring). Charge's chevron opens the "what shaped it" breakdown (its richest explanation); // Effort / Rest open their scoring-guide section. - Button { if let onRingTap { onRingTap() } else { guideSection = section } } label: { + Button { if let onOpenBreakdown { onOpenBreakdown() } else { guideSection = section } } label: { HStack(spacing: 3) { // #937: an invisible LEADING twin of the trailing chevron. The word + chevron used to // centre as ONE block, which pushed the word visibly off the ring's axis (worst on short @@ -3077,8 +3361,8 @@ struct TodayView: View { .contentShape(Rectangle()) } .buttonStyle(.plain) - .accessibilityLabel(onRingTap == nil ? Self.domainGuideAccessibilityLabel(domain) - : "See what shaped your Charge") + .accessibilityLabel(onOpenBreakdown == nil ? Self.domainGuideAccessibilityLabel(domain) + : "See what shaped your Charge") // Component 4, the real per-day source under the ring (only when this score has a value for // the day AND we resolved its winner; a calibrating / empty ring shows no provenance badge). // Apple Watch (M1): a watch-sourced score reads "Apple Watch" with its confidence bound to the @@ -3100,6 +3384,24 @@ struct TodayView: View { .accessibilityLabel("Source: \(label)") } } + // LAST in the column, below the provenance badge rather than above it. The badges sit at the + // same height across the three columns and a caption on one of them must not push that + // column's badge a line lower than its neighbours'. The row is top-aligned and self-sizing + // (#762), so a caption grows the row and leaves every ring where it was. + if let caption { + // Bounded to the RING's width, not left to size itself. Unlike Android, whose three hero + // columns are laid out at a fixed `col` width, these columns take the width of what is in + // them — so an unbounded caption would widen this one on a longer translation and tip the + // trio off centre. Two lines at the ring's width fits the longest of them; the shrink is + // the same allowance the domain label above it already uses. + Text(caption) + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .lineLimit(2) + .minimumScaleFactor(0.7) + .multilineTextAlignment(.center) + .frame(maxWidth: captionWidth) + } } } @@ -3119,14 +3421,14 @@ struct TodayView: View { private func chargeRing(score: Double?, d: DailyMetric?, diameter: CGFloat) -> some View { if let s = score { GlowRing(fraction: s / 100, value: s, format: { "\(Int($0.rounded()))" }, - color: StrandPalette.chargeColor, diameter: diameter, lineWidth: diameter * 0.10) + color: StrandPalette.recoveryColor(s), diameter: diameter, lineWidth: diameter * 0.10) } else if recoveryCalibration == nil, let carried = lastScoredCharge { // #802: a CARRIED last-night Charge draws as a real (dimmed) ring, matching the Rest ring, rather // than a bare number on a faint track, which read as broken next to Rest's filled ring. Same // diameter, so the #762 self-sizing hero row is untouched; the dim + the row-level "Last night" // caption already beneath the rings mark it as carried, not today's fresh score. GlowRing(fraction: carried.value / 100, value: carried.value, format: { "\(Int($0.rounded()))" }, - color: StrandPalette.chargeColor, diameter: diameter, lineWidth: diameter * 0.10) + color: StrandPalette.recoveryColor(carried.value), diameter: diameter, lineWidth: diameter * 0.10) .opacity(0.8) } else { emptyHeroRing(diameter: diameter) { ringEmptyOverlay(d: d, diameter: diameter) } @@ -3146,9 +3448,21 @@ struct TodayView: View { } } + /// Whether today's Rest is provisional because the strap still has records to send. Resolved once and + /// read by both surfaces that say so — the hero column's caption and the Rest tile's — so the two can + /// never disagree about the same moment. + private var restIsPendingSync: Bool { + Self.restPendingSync(restScore: restScore, backfilling: liveBackfillingFlag, + historyPendingSync: liveHistoryPendingSyncFlag, + isTodaySelected: selectedDayOffset == 0) + } + /// Rest (sleep composite 0–100) hero ring. @ViewBuilder private func restRing(diameter: CGFloat) -> some View { + // #1164/#2012: when the strap has banked records not yet offloaded, today's Rest is provisional — + // it may change once the full night lands and `analyzeRecent` re-scores it. That is now SAID, in + // the column's caption, rather than shown by withholding the number. Past days are final. if let s = restScore { GlowRing(fraction: s / 100, value: s, format: { "\(Int($0.rounded()))" }, color: StrandPalette.restColor, diameter: diameter, lineWidth: diameter * 0.10) @@ -3244,8 +3558,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) } @@ -3311,9 +3631,12 @@ struct TodayView: View { ) } footer: { ChartFooter([ - ("Min", "\(Int((v.min() ?? 0).rounded()))"), + // #2032: Min and Max come from the SAMPLES, not from the mean curve above them. + // They fall back to the curve only if the extremes are somehow absent, which + // cannot happen on this branch since the buckets that built `v` carry them. + ("Min", "\(Int((hrDayMin ?? v.min() ?? 0).rounded()))"), ("Avg", "\(Int((v.reduce(0, +) / Double(v.count)).rounded()))"), - ("Max", "\(Int((v.max() ?? 0).rounded()))"), + ("Max", "\(Int((hrDayMax ?? v.max() ?? 0).rounded()))"), ]) } // #829 - pinch/drag hint + Reset, OUTSIDE the card (the card force-fits its chart() closure @@ -3641,16 +3964,23 @@ 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. + // #1164/#2012: a provisional Rest is SAID to be provisional, in the caption, rather than + // withheld. Blanking the number too left a user who had slept, and whose score was computed, + // looking at "—" for as long as the strap had anything left to send, which on a continuously + // banking strap is most of the day. Past days are final, so the state is today-only. + // + // 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), + caption: restIsPendingSync + ? String(localized: "Pending sync · strap history still offloading") + : (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"], @@ -3808,27 +4138,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 +4483,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 } @@ -4165,6 +4554,7 @@ struct TodayView: View { // (before the cache-restore short-circuit below), so the card survives a tab-away/return; the gate // inside makes it a no-op unless a sleep card is actually hosted. await loadHostedSleepModel() + await loadHostedStress() // #849: a bare Today RE-MOUNT (tab-away + return, or an Apple-Health import that recreates the view) // re-fires this task with TodayView's `@State` reset, so the heavy history-wide pass re-ran in full // every time even when NOTHING in the data had changed: hundreds of redundant reads (incl. the @@ -4186,6 +4576,7 @@ struct TodayView: View { // #989: hydration is excluded from the snapshot (a drink logged since would be stale), so a // restore re-reads it live, one cheap row. await reloadHydration() + await reloadRRUnitPolicy() loadedHistoryWideOnce = true announceNewDaysIfNeeded() return @@ -4210,6 +4601,22 @@ struct TodayView: View { /// SleepView does (`allSleepSessions` / `habitualMidsleepSec` / `sessionMotions`) and hands them to the /// SAME pure `SleepModel.build`, so a hosted card's numbers match the Sleep tab. Twin of the /// LiquidTodayView hostedSleepModel build. + /// #2040: today's scored stress for the hosted curve card, ONLY when that card is hosted. + /// + /// The same "hosting none pays nothing" rule the sleep model above follows. `StressDayCurve` does + /// the gating: it reads nothing until a cheap heart-rate fingerprint says today's heart rate moved, + /// and it memoises, so the iOS widget publishing from the same producer shares this computation + /// rather than scoring the day a second time. + private func loadHostedStress() async { + guard HostedCardPrefs.decodeEnabled(hostedCardsRaw).contains(.stressToday) else { + hostedStressHours = [] + return + } + // `timeline`, not `hours`: the half-step display series, so the curve tracks the day rather + // than stepping through it, matching the widget and the Android card. + hostedStressHours = await StressDayCurve.today(repo: repo)?.result.timeline ?? [] + } + private func loadHostedSleepModel() async { let sleepOrigin = String(localized: "Sleep") guard HostedCardPrefs.decodeEnabled(hostedCardsRaw).contains(where: { $0.origin == sleepOrigin }) else { @@ -4218,7 +4625,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 +4663,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 +4684,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), @@ -4335,6 +4747,7 @@ struct TodayView: View { // Hydration card (opt-in): today's stored total + the sex/Effort goal. Only loaded when the // feature is on, so a disabled feature does zero work and the card stays hidden. await reloadHydration() + await reloadRRUnitPolicy() if let store = await repo.storeHandle() { let farFuture = Int(Date.distantFuture.timeIntervalSince1970) xiaomiSleeps = ((try? await store.sleepSessions(deviceId: "xiaomi-band", from: 0, to: farFuture, limit: 4000))?.count) ?? 0 @@ -4382,6 +4795,29 @@ struct TodayView: View { // refreshSeq, so a restored total could be stale. It is re-read live instead (see loadAll). } + /// #1505: the device-level R-R policy facts behind the "cannot be scored" note. Three cheap reads + /// (a registry row, two indexed MINs), run on the same passes as hydration and for the same reason: + /// they are deliberately outside the history-wide snapshot, so a restore re-reads them live. + /// A read that throws leaves the flags off, which hides the note rather than showing a guessed one. + private func reloadRRUnitPolicy() async { + guard let store = await repo.storeHandle() else { + whoop5StrictRR = false + firstRecordedRRDay = nil + firstScorableRRDay = nil + return + } + // The ACTIVE strap, which is the id every other read on this screen threads. A re-pair leaves + // history under the canonical alias, and the store's own policy resolves that; asking about the + // alias here would answer for whichever strap it inherited from. + let owner = repo.deviceId + func dayKey(_ ts: Int?) -> String? { + ts.map { Repository.localDayKey(Date(timeIntervalSince1970: TimeInterval($0))) } + } + whoop5StrictRR = (try? await store.isWhoop5RRSource(deviceId: owner)) ?? false + firstRecordedRRDay = dayKey((try? await store.firstRecordedRRTimestamp(deviceId: owner)) ?? nil) + firstScorableRRDay = dayKey((try? await store.firstScorableWhoop5RRTimestamp(deviceId: owner)) ?? nil) + } + /// #989: today's hydration total + goal, re-read wherever staleness could show: the history-wide load, /// the same-seq cache restore, a hydration mutation (`repo.hydrationSeq`), and the feature toggle. /// Two metricSeries rows (hand-logged + imported, #949) and a UserDefaults read — still cheap @@ -4479,6 +4915,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 +4924,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,13 +4974,32 @@ 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) - .map { TrendPoint(date: Date(timeIntervalSince1970: TimeInterval($0.ts)), value: $0.bpm) } + 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 hrBucketsLocal = await repo.hrBuckets(from: windowStart, to: windowEndInclusive, bucketSeconds: 300) + // A bucket with no samples is absent from the aggregate, so without a segment break the line + // joins its two neighbours and draws a steady climb across hours the strap recorded nothing. + let hrSegments = hrGapSegments(bucketTs: hrBucketsLocal.map(\.ts), bucketSeconds: 300) + let hrPointsLocal = hrBucketsLocal.enumerated() + .map { i, b in + TrendPoint(date: Date(timeIntervalSince1970: TimeInterval(b.ts)), value: b.bpm, + segment: hrSegments[i]) + } hrPoints = hrPointsLocal + // The chart keeps plotting means; only the footer reads the samples behind them (#2032). + hrDayMin = hrBucketsLocal.map(\.minBpm).min() + hrDayMax = hrBucketsLocal.map(\.maxBpm).max() // #316 / @63, the selected day's representative activity class for the Steps tile icon. Reads the // day's step samples (now carrying `activityClass` after the v19 column) and takes the LAST non-nil @@ -4549,7 +5007,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 +5024,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 +5047,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 +5065,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 @@ -4736,27 +5205,6 @@ struct TodayView: View { } } - private var dateLine: String { - // The selected day's date when navigated; today's banked-row date (or today) at offset 0. - if selectedDayOffset == 0, let day = repo.today?.day, let date = Self.dayParser.date(from: day) { - return date.formatted( - .dateTime.weekday(.wide).day().month(.wide).locale(AppLanguage.activeLocale) - ) - } - return selectedLogicalDay.formatted( - .dateTime.weekday(.wide).day().month(.wide).locale(AppLanguage.activeLocale) - ) - } - - /// Hero title that names the selected day, "Today's"/"Yesterday's"/"Day's" Synthesis. - private var synthesisTitle: LocalizedStringKey { - switch selectedDayOffset { - case 0: return "Today’s Synthesis" - case 1: return "Yesterday’s Synthesis" - default: return "Synthesis" - } - } - /// Section overline naming the selected day, "Today"/"Yesterday"/"EEE d MMM". private var selectedDayOverline: String { switch selectedDayOffset { @@ -4817,12 +5265,6 @@ struct TodayView: View { } } - private func ringSupporting(_ d: DailyMetric?) -> String { - let hrv = d?.avgHrv.map { String(localized: "\(Int($0.rounded())) ms") } ?? " - ms" - let rhr = d?.restingHr.map { "\($0)" } ?? "—" - return String(localized: "HRV \(hrv) · RHR \(rhr)") - } - private func sleepValue(_ d: DailyMetric?) -> String { guard let m = d?.totalSleepMin else { return "—" } let h = Int(m) / 60, mm = Int(m) % 60 @@ -4882,6 +5324,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 +5404,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 +5414,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 + @@ -5029,14 +5478,26 @@ struct TodayDayScopedCache { /// yet) → `✓ live`. `.hidden` only on a true cold start (the building-scores note owns that case). Twin /// of Android `SyncStatusChip`. enum SyncChipState: Equatable { - case syncing(chunks: Int) + /// #689/#815 follow-up: `pagesBehind` is the strap's GET_DATA_RANGE ring backlog, sampled once at + /// connect (`LiveState.pagesBehindAtConnect`) and never re-polled, so the copy reports it "at + /// connect" rather than as a live figure. nil when no reply has landed this session, when the frame + /// did not decode, AND when the backlog is zero: a chip that is actively syncing while claiming + /// "0 pages behind" contradicts itself, and a zero sample carries nothing a reader can act on. + /// `resolve` applies that rule so both platforms drop the same case. Twin of Android + /// `SyncChipState.Syncing`. + case syncing(chunks: Int, pagesBehind: Int?) case synced(agoText: String) case experimentalLive case hidden @MainActor static func resolve(live: LiveState) -> SyncChipState { - if live.backfilling { return .syncing(chunks: live.syncChunksThisSession) } + if live.backfilling { + // The zero rule above. Negative cannot come off the wire (the decoder returns a ring delta), + // but the bound reads the same either way. Android spells this `?.takeIf { it > 0 }`. + return .syncing(chunks: live.syncChunksThisSession, + pagesBehind: live.pagesBehindAtConnect.flatMap { $0 > 0 ? $0 : nil }) + } if let ts = live.lastSyncedAt { return .synced(agoText: shortAgo(ts)) } if live.historySyncExperimental { return .experimentalLive } return .hidden @@ -5162,12 +5623,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 } } } } @@ -5251,7 +5721,8 @@ private struct StrapBatteryRow: View { } var body: some View { - if live.connected, let pct = live.batteryPct { + // #2208: the strap's charge only when the strap is the active device. + if live.connected, live.activeIsWhoop, let pct = live.batteryPct { Divider().overlay(StrandPalette.hairline) HStack(spacing: 10) { SourceBadge("Strap battery", tint: tint(pct)) diff --git a/Strand/Screens/TrainingLoadCard.swift b/Strand/Screens/TrainingLoadCard.swift index a6c885af15..09f58b9697 100644 --- a/Strand/Screens/TrainingLoadCard.swift +++ b/Strand/Screens/TrainingLoadCard.swift @@ -30,7 +30,8 @@ struct TrainingLoadCard: View { return f }() - private struct Row: Identifiable { + /// `internal` (not `private`): `TrainingLoadChart` (its own file) plots these. + struct Row: Identifiable { let date: Date let ctl: Double let atl: Double @@ -38,20 +39,60 @@ struct TrainingLoadCard: View { } /// One modelled point per day of the contiguous suffix the engine returned. - private var rows: [Row] { + private func rows(from result: TrainingLoadEngine.Result) -> [Row] { result.points.compactMap { p in guard let d = Self.dayParser.date(from: p.day) else { return nil } return Row(date: d, ctl: p.chronicLoad, atl: p.acuteLoad) } } + /// PERF: `result` used to be a plain computed property re-running `TrainingLoadEngine.evaluate` + /// (a full-history EWMA scan) from scratch on every access — and `body` hit it TWICE per render + /// (`let tl = result` here, then again via the old `rows`/`chart` computed properties), so opening + /// this card ran the model twice per render, including on animation/hover frames and, before the + /// `WorkoutsView`/`HealthView` AppModel-isolation fixes elsewhere, on every ~1 Hz live-HR tick. + /// Mirrors the `modelCache`/`modelCacheKey` idiom `CompareView` already uses for its own memoized + /// chart model (CompareView.swift:741-742): cached value + fingerprint, refreshed via `onAppear`/ + /// `onChange` rather than mutated mid-body. + @State private var resultCache: TrainingLoadEngine.Result = Self.computeResult(days: []) + @State private var resultCacheKey: String = "" + + /// Fingerprint of `days`. `days` is `Repository.days` (TrendsView.swift) — the SAME live-updating + /// array `HealthView`/`TrendsView` observe, whose latest entry keeps accumulating strain through the + /// day. That's why this can't copy `CompareView.modelKey`'s count+endpoints idiom verbatim (a fixed + /// count/date range with a changing LAST value would never invalidate the cache): the key covers the + /// total count (so a backfill/import that lengthens history always invalidates) plus every + /// `(day, strain)` pair in the trailing `establishedDays` window the model actually weighs at anything + /// more than a couple of percent (42-day EWMA — older days are exponentially near-zero weight). + /// `internal` (not `private`), matching the MotionTrace-peak precedent (#2288) of the minimum a test + /// can reach, so `StrandTests` can pin it without rendering the chart. + static func modelKey(for days: [DailyMetric]) -> String { + let tail = days.suffix(TrainingLoadEngine.Configuration.standard.establishedDays) + return "\(days.count)|" + tail.map { "\($0.day):\($0.strain ?? -1)" }.joined(separator: ",") + } + /// Model straight from the training-load engine — NOT the paired `evaluateWithTrainingLoad`, which /// would also run the full Readiness synthesis this card never uses. `DailyMetric.strain` is the load. - private var result: TrainingLoadEngine.Result { + private static func computeResult(days: [DailyMetric]) -> TrainingLoadEngine.Result { let loads = days.map { TrainingLoadEngine.DailyLoad(day: $0.day, load: $0.strain) } return TrainingLoadEngine.evaluate(days: loads) } + /// Cached accessor used by `body`. Mirrors `CompareView.currentModel`: returns the memoized result + /// when the inputs match, else computes for THIS render (without mutating state mid-body); the + /// matching `.onAppear`/`.onChange` then persist it so subsequent hover/animation frames hit the cache. + private var result: TrainingLoadEngine.Result { + Self.modelKey(for: days) == resultCacheKey ? resultCache : Self.computeResult(days: days) + } + + /// Rebuild the result cache if (and only if) the fingerprint changed. + private func refreshResult() { + let key = Self.modelKey(for: days) + guard key != resultCacheKey else { return } + resultCacheKey = key + resultCache = Self.computeResult(days: days) + } + private static let established = TrainingLoadEngine.Configuration.standard.establishedDays private static let minimum = TrainingLoadEngine.Configuration.standard.minimumDays @@ -60,55 +101,39 @@ struct TrainingLoadCard: View { var body: some View { let tl = result - if !tl.isAvailable { - unavailableCard(contiguousDays: tl.contiguousDays) - } else { - let latest = tl.points.last - ChartCard( - title: "Training Load", - subtitle: subtitle(for: tl), - trailing: latest.map { signed($0.balance) }, - height: NoopMetrics.chartHeight, - chart: { - VStack(alignment: .leading, spacing: NoopMetrics.space2) { - legend - chart + Group { + if !tl.isAvailable { + unavailableCard(contiguousDays: tl.contiguousDays) + } else { + let latest = tl.points.last + let rows = rows(from: tl) + ChartCard( + title: "Training Load", + subtitle: subtitle(for: tl), + trailing: latest.map { signed($0.balance) }, + height: NoopMetrics.chartHeight, + chart: { + VStack(alignment: .leading, spacing: NoopMetrics.space2) { + legend + TrainingLoadChart(rows: rows) + } + }, + footer: { + ChartFooter([ + ("CTL", latest.map { fmt($0.chronicLoad) } ?? "—"), + ("ATL", latest.map { fmt($0.acuteLoad) } ?? "—"), + ("Form", latest.map { signed($0.balance) } ?? "—"), + ("Days", "\(tl.contiguousDays)"), + ]) } - }, - footer: { - ChartFooter([ - ("CTL", latest.map { fmt($0.chronicLoad) } ?? "—"), - ("ATL", latest.map { fmt($0.acuteLoad) } ?? "—"), - ("Form", latest.map { signed($0.balance) } ?? "—"), - ("Days", "\(tl.contiguousDays)"), - ]) - } - ) - } - } - - // Two overlaid lines: CTL (fitness) and ATL (fatigue). The vertical gap between them is the form. - private var chart: some View { - // Floor at 1 (matching the Android `fold(1.0)` twin): an all-rest window of zero loads would - // otherwise make the y-domain `0...0`, which Swift Charts renders as a degenerate/empty scale. - let maxY = max(rows.map { max($0.ctl, $0.atl) }.max() ?? 1, 1) - return Chart { - ForEach(rows) { r in - LineMark(x: .value("Day", r.date), y: .value("CTL", r.ctl), - series: .value("Series", "CTL")) - .foregroundStyle(StrandPalette.gold) - .interpolationMethod(.catmullRom) - } - ForEach(rows) { r in - LineMark(x: .value("Day", r.date), y: .value("ATL", r.atl), - series: .value("Series", "ATL")) - .foregroundStyle(StrandPalette.strain100) - .interpolationMethod(.catmullRom) + ) } } - .chartYScale(domain: 0...(maxY * 1.08)) - .chartYAxis { AxisMarks(position: .leading) } - .accessibilityLabel(Text("Training load: chronic vs acute")) + // Persist the memoized result into `@State` (mirrors `CompareView`'s `.onAppear { refreshModel() }` + // / `.onChangeCompat(of: modelKey)` pair) so the NEXT render's `result` access hits the cache + // instead of recomputing — `body` itself never mutates `@State` mid-evaluation. + .onAppear { refreshResult() } + .onChangeCompat(of: Self.modelKey(for: days)) { _ in refreshResult() } } private var legend: some View { diff --git a/Strand/Screens/TrainingLoadChart.swift b/Strand/Screens/TrainingLoadChart.swift new file mode 100644 index 0000000000..3f2f84bc57 --- /dev/null +++ b/Strand/Screens/TrainingLoadChart.swift @@ -0,0 +1,122 @@ +import SwiftUI +import Charts +import StrandDesign + +/// The CTL/ATL line-overlay chart for `TrainingLoadCard`, split into its own file (design-system rule: +/// don't let a screen file bloat) so the downsampling/hover code below doesn't grow `TrainingLoadCard`'s +/// already-documented model/memoization logic. +/// +/// PERF: `TrainingLoadCard.days` is the full app history (the card's own doc comment: "models the full +/// history rather than the Trends range window"), so a multi-year user feeds two full-resolution EWMA +/// lines (CTL/ATL) — far more vertices than a ~360pt plot can resolve. Each series is independently +/// min/max-bucketed with the SAME `ChartDownsample.minMaxBucketed` helper `TrendChart`/`OverviewHRChart` +/// use (StrandDesign's generic overload, added so an app-target chart type outside that package can call +/// it instead of hand-duplicating the algorithm the way `CompareView.Model.minMaxBucketed` had to). +/// Downsampling CTL and ATL SEPARATELY — rather than bucketing once and reusing the choice for both — +/// keeps each curve's own peaks/troughs even though the two diverge in shape; hover always reads the +/// full-resolution `rows`, never the downsampled draw set. +/// +/// Also adds the hover/tooltip readout this chart never had, reusing the exact `CrosshairRule` / +/// `HighlightDot` / `PositionedTooltip` / `ChartTooltip` components `TrendChart`'s `chartOverlay` uses — +/// no new hover mechanism invented. +struct TrainingLoadChart: View { + let rows: [TrainingLoadCard.Row] + + /// The x-position the cursor is hovering, in chart-local coordinates. + @State private var hoverX: CGFloat? = nil + + private var ctlRows: [TrainingLoadCard.Row] { + ChartDownsample.minMaxBucketed(rows, threshold: ChartDownsample.markThreshold, + targetCount: ChartDownsample.targetVertices, + date: { $0.date }, value: { $0.ctl }) + } + + private var atlRows: [TrainingLoadCard.Row] { + ChartDownsample.minMaxBucketed(rows, threshold: ChartDownsample.markThreshold, + targetCount: ChartDownsample.targetVertices, + date: { $0.date }, value: { $0.atl }) + } + + /// The full-resolution row nearest a given chart-local x. Deliberately reads `rows` (not + /// `ctlRows`/`atlRows`), matching `TrendChart.nearestPoint`: hover always names the real datum, never + /// a downsampled stand-in. + private func nearestRow(toX x: CGFloat, proxy: ChartProxy, plot: CGRect) -> TrainingLoadCard.Row? { + guard !rows.isEmpty else { return nil } + let relX = x - plot.minX + guard let date: Date = proxy.value(atX: relX) else { return nil } + return rows.min(by: { abs($0.date.timeIntervalSince(date)) < abs($1.date.timeIntervalSince(date)) }) + } + + private func fmt(_ v: Double) -> String { String(format: "%.1f", v) } + + private static let tooltipDateFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "d MMM yyyy" + return f + }() + + var body: some View { + // Floor at 1 (matching the Android `fold(1.0)` twin): an all-rest window of zero loads would + // otherwise make the y-domain `0...0`, which Swift Charts renders as a degenerate/empty scale. + let maxY = max(rows.map { max($0.ctl, $0.atl) }.max() ?? 1, 1) + Chart { + ForEach(ctlRows) { r in + LineMark(x: .value("Day", r.date), y: .value("CTL", r.ctl), + series: .value("Series", "CTL")) + .foregroundStyle(StrandPalette.gold) + .interpolationMethod(.catmullRom) + } + ForEach(atlRows) { r in + LineMark(x: .value("Day", r.date), y: .value("ATL", r.atl), + series: .value("Series", "ATL")) + .foregroundStyle(StrandPalette.strain100) + .interpolationMethod(.catmullRom) + } + } + .chartYScale(domain: 0...(maxY * 1.08)) + .chartYAxis { AxisMarks(position: .leading) } + .chartOverlay { proxy in + GeometryReader { geo in + let plot = proxy.plotRectCompat(in: geo) + ZStack(alignment: .topLeading) { + if let hx = hoverX, + let p = nearestRow(toX: hx, proxy: proxy, plot: plot), + let px = proxy.position(forX: p.date), + let pyCTL = proxy.position(forY: p.ctl), + let pyATL = proxy.position(forY: p.atl) { + let cx = px + plot.minX + CrosshairRule(x: cx, height: geo.size.height) + HighlightDot(color: StrandPalette.gold) + .position(x: cx, y: pyCTL + plot.minY) + HighlightDot(color: StrandPalette.strain100) + .position(x: cx, y: pyATL + plot.minY) + PositionedTooltip( + anchor: CGPoint(x: cx, y: min(pyCTL, pyATL) + plot.minY), + container: geo.size, + tooltip: ChartTooltip( + value: String(localized: "CTL \(fmt(p.ctl)) · ATL \(fmt(p.atl))"), + label: Self.tooltipDateFormatter.string(from: p.date) + ) + ) + } + } + .animation(StrandMotion.fade, value: hoverX) + .frame(width: geo.size.width, height: geo.size.height, alignment: .topLeading) + .contentShape(Rectangle()) + .onContinuousHover(coordinateSpace: .local) { phase in + // Non-animating transaction: otherwise crossing the plot edge re-runs the line's + // draw-on animation and flickers the curve (mirrors TrendChart #104). + var tx = Transaction() + tx.disablesAnimations = true + withTransaction(tx) { + switch phase { + case .active(let location): hoverX = location.x + case .ended: hoverX = nil + } + } + } + } + } + .accessibilityLabel(Text("Training load: chronic vs acute")) + } +} diff --git a/Strand/Screens/TrendsReportView.swift b/Strand/Screens/TrendsReportView.swift index d24ea54b50..0d83f567f4 100644 --- a/Strand/Screens/TrendsReportView.swift +++ b/Strand/Screens/TrendsReportView.swift @@ -134,10 +134,11 @@ enum TrendsReportData { /// Build the full report for a range from a DailyMetric history (+ the stored daily /// stress series, for the Stress row — #457). static func report(for range: ReportRange, days: [DailyMetric], - today: String, stressByDay: [String: Double] = [:]) -> RangeReport { + today: String, stressByDay: [String: Double] = [:], + units: ReportDisplayUnits = .stored) -> RangeReport { let (start, end) = window(for: range, days: days, today: today) return RangeReportEngine.build(metrics: metricMaps(from: days, stressByDay: stressByDay), - start: start, end: end) + start: start, end: end, units: units) } /// The in-range sparkline series (chronological values) for one metric — the same @@ -190,6 +191,11 @@ struct TrendsReportPage: View { let series: [ReportMetric: [Double]] /// Generated-on label (e.g. "Jun 15, 2026"). let generatedOn: String + /// The user's resolved °C/°F + Effort-axis preferences (#1637). Passed in rather than read from + /// `@AppStorage` because the page is rendered OFF-SCREEN by `ImageRenderer` for the PDF, where a + /// SwiftUI environment lookup is not guaranteed to carry the live value. Defaults to `.stored` + /// so a preview or a caller that has not resolved them still renders. + var units: ReportDisplayUnits = .stored /// The fixed page width used for the PDF render. ~ A4 portrait at 72dpi-ish density. static let pageWidth: CGFloat = 612 @@ -317,6 +323,14 @@ struct TrendsReportPage: View { } /// A trend chip coloured good/bad for the metric (neutral when flat or valence-free). + /// + /// The chip sits directly beside the mean read-out, so its magnitude must be on the SAME axis as + /// the numbers around it (#1637) — a °C delta next to °F values reads as a contradiction. Both + /// conversions are pure multiplications, so `displayValue` is correct for a delta as well as a + /// level (a °F offset would NOT be, which is why this metric never adds one). + /// + /// The steady/moving decision and the good/bad colour stay on the STORED delta, matching the + /// trend verdict itself — a cosmetic toggle must not turn a "steady" chip into a moving one. @ViewBuilder private func trendChip(_ stat: MetricRangeStat) -> some View { let d = stat.halfDelta @@ -329,7 +343,8 @@ struct TrendsReportPage: View { ? (up == stat.metric.higherIsBetter ? StrandPalette.statusPositive : StrandPalette.metricRose) : StrandPalette.textTertiary let sign = up ? "+" : "−" - TrendChip(text: "\(sign)\(round1Text(abs(d)))", color: color) + let shown = abs(RangeReportEngine.displayValue(d, metric: stat.metric, units: units)) + TrendChip(text: "\(sign)\(round1Text(shown))", color: color) } } @@ -381,12 +396,20 @@ struct TrendsReportPage: View { /// hours, respiratory rate, skin-temp Δ and the 0–3 stress score (`metric.usesOneDecimal`). /// Skin-temp is a signed deviation from baseline, so a positive reading gets an explicit /// "+" to keep it from reading as an absolute temp. - private func valueText(_ v: Double, _ metric: ReportMetric) -> String { - let unit = metric.unit + /// + /// Both the value and its unit go through `RangeReportEngine` (#1637) — the same conversion the + /// headline sentences above use — so the exported page cannot print a unit the app disagrees + /// with. Effort gains a decimal once rescaled to the 0–21 axis, where a whole number would throw + /// away most of the resolution the 0–100 value carried. + func valueText(_ v: Double, _ metric: ReportMetric) -> String { + let unit = RangeReportEngine.displayUnit(metric, units: units) + let shown = RangeReportEngine.displayValue(v, metric: metric, units: units) + let oneDecimal = metric.usesOneDecimal + || (metric == .strain && units.effortFactor != 1.0) // Workouts is a fractional rate in the AVG read-out but a whole count at min/max; one // decimal keeps the averaged cadence honest (e.g. "0.4 /day") without faking precision. - var num = metric.usesOneDecimal ? round1Text(v) : "\(Int(v.rounded()))" - if metric == .skinTempDev && v > 0 { num = "+\(num)" } + var num = oneDecimal ? round1Text(shown) : "\(Int(shown.rounded()))" + if metric == .skinTempDev && shown > 0 { num = "+\(num)" } return unit.isEmpty ? num : "\(num) \(unit)" } @@ -394,7 +417,7 @@ struct TrendsReportPage: View { valueText(stat.mean, stat.metric) } - private func round1Text(_ x: Double) -> String { + func round1Text(_ x: Double) -> String { String(format: "%.1f", (x * 10).rounded() / 10) } @@ -424,11 +447,32 @@ struct TrendsReportSheet: View { /// Stored daily stress series ("yyyy-MM-dd" → 0–3), for the Stress row (#457). Loaded /// once from the same "my-whoop" series the Stress screen reads; empty until it arrives. @State private var stressByDay: [String: Double] = [:] + // The same three keys every other screen reads, so the exported page agrees with what the user + // has been looking at all month (#1637). Temperature has its own override on top of the + // length/mass system, which is why both are needed to resolve it. + @AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue + @AppStorage(UnitPrefs.temperatureKey) private var temperatureRaw = "" + @AppStorage(UnitPrefs.effortScaleKey) private var effortScaleRaw = EffortScale.hundred.rawValue private var today: String { Repository.localDayKey(Date()) } + /// The user's display preferences, resolved once for both the headline sentences (built inside + /// the engine) and the metric cards (rendered by the page). Both surfaces must be handed the + /// SAME value or the document contradicts itself. + private var units: ReportDisplayUnits { + let system = UnitSystem(rawValue: unitSystemRaw) ?? .metric + let temp = UnitPrefs.resolveTemperature(system: system, override: temperatureRaw) + let scale = UnitPrefs.resolveEffortScale(effortScaleRaw) + // Name the canonical constant rather than deriving it from `effortValue(1.0,)`: the report + // multiplies by this factor, which is only equivalent while the mapping stays linear. + return ReportDisplayUnits( + fahrenheit: temp == .fahrenheit, + effortFactor: scale == .whoop ? UnitFormatter.effortScaleFactor : 1.0) + } + private var report: RangeReport { - TrendsReportData.report(for: range, days: days, today: today, stressByDay: stressByDay) + TrendsReportData.report(for: range, days: days, today: today, + stressByDay: stressByDay, units: units) } private func seriesMap(start: String, end: String) -> [ReportMetric: [Double]] { @@ -450,7 +494,7 @@ struct TrendsReportSheet: View { private func page(for report: RangeReport) -> TrendsReportPage { TrendsReportPage(report: report, range: range, series: seriesMap(start: report.start, end: report.end), - generatedOn: generatedOn) + generatedOn: generatedOn, units: units) } var body: some View { diff --git a/Strand/Screens/TrendsView.swift b/Strand/Screens/TrendsView.swift index 7ce0f23695..8f68bb601b 100644 --- a/Strand/Screens/TrendsView.swift +++ b/Strand/Screens/TrendsView.swift @@ -83,33 +83,11 @@ struct TrendsView: View { // MARK: Window selection (relative to the LATEST day, with auto-expand) - /// The latest recorded day across all history (anchors every window). - private var latestDay: Date? { - guard let d = repo.days.last?.day else { return nil } - return date(d) - } - /// Days for a given range, taken RELATIVE TO TODAY (the phone's local date) — not the latest /// recorded day, which on a stale import anchored W/M/3M to months-old data so it looked current /// (issue #23). Empty short windows auto-widen (see `resolve`), so old imports surface under a /// wider range / All history instead of masquerading as recent. `.all` returns everything. /// ISO yyyy-MM-dd compares chronologically. - private func days(for r: Range) -> [DailyMetric] { - guard let n = r.days else { return repo.days } - let cutoffKey = Repository.localDayKey(Calendar.current.date(byAdding: .day, value: -(n - 1), to: Date()) ?? Date()) - return repo.days.filter { $0.day >= cutoffKey } - } - - /// Build trend points from a metric accessor over a day slice. - private func points(_ days: ArraySlice, _ value: (DailyMetric) -> Double?) -> [TrendPoint] { - days.compactMap { d in - guard let v = value(d), let dt = date(d.day) else { return nil } - return TrendPoint(date: dt, value: v) - } - } - private func points(_ days: [DailyMetric], _ value: (DailyMetric) -> Double?) -> [TrendPoint] { - points(days[...], value) - } // MARK: Resolved metric (memoized per body) // @@ -132,17 +110,13 @@ struct TrendsView: View { private func resolve(_ value: (DailyMetric) -> Double?) -> ResolvedMetric { // Find the smallest range ≥ selected whose window has ≥1 point, keeping // that window's points so we don't re-filter to read them back. - for r in range.widening { - let pts = points(days(for: r), value) - if !pts.isEmpty { - return ResolvedMetric(points: pts, effective: r, - widened: r != range, caption: caption(count: pts.count, eff: r)) - } - } - // No range held data: fall back to ALL (matches effectiveRange()). - let pts = points(days(for: .all), value) - return ResolvedMetric(points: pts, effective: .all, - widened: .all != range, caption: caption(count: pts.count, eff: .all)) + // The windowing lives in `HostedTrendData` so the Today host cards resolve EXACTLY as this tab + // does. Shared rather than copied: the widening fallback is what a wearer with two weeks of + // history depends on, and a second implementation would drift the moment either side was tuned. + let r = HostedTrendData.resolve(days: repo.days, selected: range, value: value) + return ResolvedMetric(points: r.points, effective: r.effective, + widened: r.effective != range, + caption: caption(count: r.points.count, eff: r.effective)) } /// Caption text from an already-resolved count + effective range. Mirrors @@ -160,11 +134,7 @@ struct TrendsView: View { /// A padded value range for a series so the line isn't flat against the axis. private func valueRange(_ pts: [TrendPoint], fallback: ClosedRange, pad: Double = 0.12) -> ClosedRange { - let vals = pts.map(\.value) - guard let lo = vals.min(), let hi = vals.max() else { return fallback } - if hi <= lo { return (lo - 1)...(hi + 1) } - let span = hi - lo - return (lo - span * pad)...(hi + span * pad) + HostedTrendData.valueRange(pts, fallback: fallback, pad: pad) } private func mean(_ pts: [TrendPoint]) -> Double? { 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..b8ed7c81e7 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 @@ -176,13 +189,20 @@ enum BodyVitalSigns { : [] // WHOOP 4.0 raw SpO₂: the (red + IR) / 2 ADC mean per night, present only when both channels // decoded for the day. On-device only, so this resolves to the NOOP-computed row. (#93) + // `i > 0`: an Oura night scored before `nightlySpo2RawMeans` went two-channel-only stored the + // ring's single channel as red ≈ 97 beside ir = 0, which this mean read as "~49 ADC". A ring has + // no red/IR ADC pair, so such a row is not a raw reading. Twin of Android `twoChannelRawSpo2Mean`. let spo2rawPoints = points(key: "spo2raw") { m in - guard let r = m.spo2Red, let i = m.spo2Ir else { return nil } + guard let r = m.spo2Red, let i = m.spo2Ir, i > 0 else { return nil } return (Double(r) + Double(i)) / 2.0 } 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 +213,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 @@ -203,6 +223,15 @@ enum BodyVitalSigns { let hrvCaveat: String? = (hrvRow.map { (hrvOverCountByDay[$0.day] ?? 0) >= 0.5 } ?? false) ? String(localized: "unverified · over-reports R-R") : nil + // #2335: the caveat above can only ever decorate a value that IS shown, and the over-count + // verdict is the very thing that makes `SleepStager.sessionAvgHRV` return nil. So on the night + // this was written for, there is no row to attach it to and the tile falls through to its + // missing caption, which said only "No HRV value". The wearer was told nothing, on the one + // failure NOOP can explain precisely. Say it in the slot that is actually reached. + let hrvMissingCaption = Self.hrvBlankedByOverCount(hrvOverCountByDay: hrvOverCountByDay, + todayKey: logicalDay) + ? String(localized: "Over-reports R-R, so no value is shown") + : String(localized: "No HRV value") // Trailing values (oldest → newest) feeding each tile's sparkline trail. A 2+ point series // draws; the tile hides the trail otherwise. Presentation-only — built from the same resolved @@ -214,13 +243,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 +289,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( @@ -352,7 +411,7 @@ enum BodyVitalSigns { metricColor: StrandPalette.metricPurple, day: hrvRow?.day, source: hrvRow?.source, - missingCaption: String(localized: "No HRV value"), + missingCaption: hrvMissingCaption, // #2335 sparkline: trail(hrvPoints), caveat: hrvCaveat // #1118 ), @@ -371,12 +430,45 @@ 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 ), ] } /// The newest day any resolved reading was sourced from — drives the section's "Latest" trailing label. + /// #2335: was the most recent night that could have produced an HRV refused for over-counting? + /// + /// The `#1118` caveat beside the HRV value can only decorate a value that IS shown, and the + /// over-count verdict is precisely what makes `SleepStager.sessionAvgHRV` return nil. The two + /// conditions are therefore near mutually exclusive: on the night the caveat was written for, the + /// tile is blank and the caveat has nothing to attach to. This answers the blank case instead. + /// + /// Keyed off the MAP, not off a resolved row, because a refused night leaves no row to key on. + /// `hrvOverCountByDay` carries an entry only for nights that had in-sleep R-R (the engine writes + /// nil when there were none), so its newest key is the most recent night that could have produced + /// an HRV at all. Day keys are `yyyy-MM-dd`, where lexicographic order IS chronological order. + /// + /// Bounded to the SAME carry window the tile is (`Baselines.vitalCarryDays`, via `cutoffKey`). Past + /// that the tile is blank because the reading went stale, not because it was refused, and blaming an + /// over-count there points at the wrong thing. The bound also has to live here rather than fall out of + /// the loaded range: Apple loads 14 days of this series and Android loads RECENT_DAYS_CAP, so a helper + /// keyed on "whatever was loaded" would answer differently on the two platforms for the same wearer. + /// + /// `>= 0.5` rather than `== 1`: the flag round-trips through `metricSeries` as a Double. + /// + /// Returns false on an empty map, which is the "no night yet" case (a fresh install, or a wearer + /// who has not slept in the strap). That blank is not an over-count and must not claim to be one. + /// Twin of the Kotlin `hrvBlankedByOverCount`. + static func hrvBlankedByOverCount(hrvOverCountByDay: [String: Double], + todayKey: String) -> Bool { + guard let newest = hrvOverCountByDay.keys.max() else { return false } + guard newest >= Baselines.cutoffKey(todayKey: todayKey) else { return false } + return (hrvOverCountByDay[newest] ?? 0) >= 0.5 + } + static func latestDayLabel(_ readings: [BodyVitalReading]) -> String? { readings.compactMap(\.day).max().map(BodyVitalReading.dayLabel) } diff --git a/Strand/Screens/WeeklyDigestView.swift b/Strand/Screens/WeeklyDigestView.swift index c64962391d..10d97e777f 100644 --- a/Strand/Screens/WeeklyDigestView.swift +++ b/Strand/Screens/WeeklyDigestView.swift @@ -146,8 +146,6 @@ struct WeeklyDigestContent: View { @Environment(\.dynamicTypeSize) private var dynamicTypeSize #endif - /// Display order: the two daily scores first, then the nightly signals. - private static let order: [WeeklyMetric] = [.charge, .effort, .rest, .hrv, .rhr] /// The three headline 0–100 scores shown as domain summaries. private static let scoreOrder: [WeeklyMetric] = [.charge, .effort, .rest] 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/WorkoutStartControl.swift b/Strand/Screens/WorkoutStartControl.swift new file mode 100644 index 0000000000..603bd976df --- /dev/null +++ b/Strand/Screens/WorkoutStartControl.swift @@ -0,0 +1,50 @@ +import SwiftUI +import StrandDesign + +/// #459: "Start Workout" used to live ONLY on the Live screen, so a user reaching Workouts (via the +/// Quick-action FAB or the tab) had no way to begin one from the obvious place. This button starts a live +/// session and presents the in-exercise view directly. +/// +/// PERF (chart-invalidation): this is the ONE place `WorkoutsView` needs live `AppModel` state +/// (`activeWorkout`) — everything else it needs (`hrMax`, `analyzeRecent()`) lives on sub-objects that +/// don't publish at live-tick frequency. `AppModel` publishes `bpm` at ~1 Hz (AppModel.swift:202), and +/// `@EnvironmentObject` subscribes to the WHOLE object's `objectWillChange`, so if `WorkoutsView` itself +/// held `model: AppModel`, every tick would re-evaluate its entire ~1900-line body (chart + grids + +/// sorting) even though only this button's label and its two sheets read `model`. Isolating it here +/// (mirroring `HealthView`'s live-observing-leaf pattern, HealthView.swift:17-22, 44-46) means a tick +/// re-renders only this small leaf. Owns its own sheet-presentation state so nothing about it needs to +/// live on the parent either. +struct WorkoutStartControl: View { + @EnvironmentObject var model: AppModel + @State private var showLiveWorkout = false + @State private var showStartSport = false + + var body: some View { + NoopButton(model.activeWorkout == nil ? "Start workout" : "View active workout", + systemImage: model.activeWorkout == nil ? "figure.run" : "timer", + kind: .primary, + fullWidth: true) { + // No active session → pick a named sport first (#519), then the sheet's onStart begins it + // and opens the in-exercise view. Already active → jump straight back into the live view. + if model.activeWorkout == nil { showStartSport = true } + else { showLiveWorkout = true } + } + .accessibilityLabel(model.activeWorkout == nil ? "Start a workout" : "View the active workout") + // #459: the in-exercise view, presented when Start Workout is tapped here (same screen LiveView + // shows). activeWorkout is global on AppModel, so ending it from either surface stays in sync. + .sheet(isPresented: $showLiveWorkout) { + LiveWorkoutView(onClose: { showLiveWorkout = false }) + // Inject the shared live snapshot so the in-exercise sensor readout (speed/cadence/power) + // resolves here too, matching how LiveView presents the same screen. + .environmentObject(model.live) + } + // #519: name the sport before a live session starts, then open the in-exercise view directly + // (same direct present as the button's already-active path — no cross-view auto-present race). + .workoutSelectionCover(isPresented: $showStartSport) { + StartWorkoutSheet { name in + model.startWorkout(sport: name) + showLiveWorkout = true + } + } + } +} diff --git a/Strand/Screens/WorkoutsView.swift b/Strand/Screens/WorkoutsView.swift index 84f3bbd289..6d7443da5e 100644 --- a/Strand/Screens/WorkoutsView.swift +++ b/Strand/Screens/WorkoutsView.swift @@ -26,17 +26,26 @@ private struct WorkoutRecoveryTrendPoint: Identifiable, Equatable { struct WorkoutsView: View { @EnvironmentObject var repo: Repository - /// #459: "Start Workout" used to live ONLY on the Live screen, so a user reaching Workouts (via the - /// Quick-action FAB or the tab) had no way to begin one from the obvious place. Injected here so the - /// header/empty-state can start a live session and present the in-exercise view directly. - @EnvironmentObject var model: AppModel - @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. + /// PERF (chart-invalidation): `AppModel` publishes `bpm` at ~1 Hz (AppModel.swift:202) via + /// `@Published`, and `@EnvironmentObject` subscribes to the WHOLE object's `objectWillChange` — + /// regardless of which properties `body` actually reads. Holding `model: AppModel` here re-ran this + /// screen's entire ~1900-line body (chart + grids + sorting) every tick. `hrMax` and `analyzeRecent()` + /// are the only two things this screen needs, and both live on sub-objects (`ProfileStore`, + /// `IntelligenceEngine`) injected separately at the app root (StrandApp.swift) — neither publishes at + /// live-tick frequency. The one genuinely `AppModel`-dependent piece ("Start Workout" / active-session + /// state, #459) is isolated into `WorkoutStartControl`, mirroring `HealthView`'s live-observing-leaf + /// pattern (HealthView.swift:17-22, 44-46), so a tick re-renders only that small leaf. + @EnvironmentObject var profile: ProfileStore + @EnvironmentObject var intelligence: IntelligenceEngine + + // 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 @@ -50,6 +59,10 @@ struct WorkoutsView: View { @State private var allRows: [WorkoutRow] @State private var loaded: Bool @State private var seededInitialRange = false + /// Current (the most recent sessions) or Archived (everything older). A view split only: archived rows + /// stay in the database and are one tap away. + @State private var scope: Scope = .current + @State private var range: Range = .all /// #797: how many trailing days of workouts are currently LOADED into `allRows`. First paint loads /// `Self.firstPaintWindowDays`; picking "All" (or a range wider than this) pages the full history in on @@ -156,7 +169,15 @@ struct WorkoutsView: View { } var body: some View { - ScreenScaffold(title: "Workouts", subtitle: "Every session, threaded together.", + // Compute the windowed (unscoped) rows ONCE per body evaluation and thread them into both the + // session list below AND the HR-recovery trend `.task(id:)` further down this modifier chain. + // SwiftUI re-runs `body` on hover/animation/1Hz HR ticks; `sessions(for:)` was independently + // re-derived by `windowRows` here AND by `recoveryTrendRows` (via `recoveryTrendInputKey`, read on + // every body pass as the `.task(id:)` argument) — the same unscoped filter run twice per pass. + let resolved = effectiveRange + let unscopedRows = sessions(for: resolved) + let trendRows = recoveryTrendRows(from: unscopedRows) + return ScreenScaffold(title: "Workouts", subtitle: "Every session, threaded together.", onRefresh: { await repo.refresh() }, // PERF: the column ends in the full "All Sessions" log (the breakdown grid, the // zones card, and a row-per-session table). On a large imported history the eager @@ -176,18 +197,18 @@ struct WorkoutsView: View { } } } else { - // Compute the windowed rows and per-sport groups ONCE per body - // evaluation, then thread them into every section. SwiftUI re-runs - // `body` on hover/animation/1Hz HR ticks; the previous computed- - // property fan-out (rows → effectiveRange → sessions(_:), and - // sportGroups → rows → …) rebuilt the same filters/aggregations - // several times per render. Same windowing, same results. - let resolved = effectiveRange - let windowRows = sessions(for: resolved) + // Compute the per-sport groups ONCE per body evaluation, then thread them into every + // section — same idea as `unscopedRows` above, applied to the rest of the fan-out + // (rows → sportGroups → …) that used to rebuild several times per render. + // Current / Archived applies to what the LIST and its summaries show. `unscopedRows` + // itself stays unscoped so the HR-recovery trend and the auto-widen probe keep seeing the + // whole window. + let windowRows = Self.scopedRows(unscopedRows, scope: scope) let groups = sportGroups(from: windowRows) let zonesSummary = WorkoutZones.summary(from: windowRows) workoutActionRow + scopeBar rangeBar(rows: windowRows, effectiveRange: resolved) if let postLogNote { postLogBanner(postLogNote) } effortHero(rows: windowRows, effectiveRange: resolved, groups: groups) @@ -235,8 +256,8 @@ struct WorkoutsView: View { .onChange(of: range) { newRange in Task { await expandWindowIfNeeded(for: newRange == .all ? .all : effectiveRange) } } - .task(id: recoveryTrendInputKey) { - await loadRecoveryTrend() + .task(id: recoveryTrendInputKey(rows: trendRows)) { + await loadRecoveryTrend(rows: trendRows) } .sheet(item: $sheet) { target in ManualWorkoutSheet(editing: target.editing) { row, replacing in @@ -247,7 +268,7 @@ struct WorkoutsView: View { // average / peak HR, strain and calories appear immediately (from your own strap data) // instead of waiting up to 15 minutes for the next analyze tick. No-ops when the strap // had no HR for that window, and never overrides a value you typed yourself. - await model.intelligence.analyzeRecent() + await intelligence.analyzeRecent() await reload() // Post-log note (#439): if this sport now has a solid/building recovery-cost // entry, surface its personal-pattern sentence as a transient caption. @@ -269,22 +290,9 @@ struct WorkoutsView: View { .frame(width: 620, height: 720) #endif } - // #459: the in-exercise view, presented when Start Workout is tapped here (same screen LiveView - // shows). activeWorkout is global on AppModel, so ending it from either surface stays in sync. - .sheet(isPresented: $showLiveWorkout) { - LiveWorkoutView(onClose: { showLiveWorkout = false }) - // Inject the shared live snapshot so the in-exercise sensor readout (speed/cadence/power) - // resolves here too, matching how LiveView presents the same screen. - .environmentObject(model.live) - } - // #519: name the sport before a live session starts, then open the in-exercise view directly - // (same direct present as the button's already-active path — no cross-view auto-present race). - .workoutSelectionCover(isPresented: $showStartSport) { - StartWorkoutSheet { name in - model.startWorkout(sport: name) - showLiveWorkout = true - } - } + // #459 / PERF: the "Start Workout" button, its active-session sheet and the sport-picker cover all + // moved to `WorkoutStartControl`, which owns `AppModel` itself — see the comment on this screen's + // `profile`/`intelligence` properties above for why `model` can't live here. // #64: name the merged session when every selected row is a bare detected bout (there's no sport // to inherit). Reuses the "Start a workout" named-sport picker. .workoutSelectionCover(item: $mergeSportPrompt) { target in @@ -309,19 +317,21 @@ struct WorkoutsView: View { // MARK: - Heart-rate recovery trend (#516) - /// Apply the screen's active filter + range, capped to the latest 90 days as promised by the feature. - private var recoveryTrendRows: [WorkoutRow] { - let visible = sessions(for: effectiveRange) + /// Apply the screen's 90-day cap to the already-`sessions(for:)`-filtered `unscopedRows` `body` + /// computes once. PERF: this used to re-derive `sessions(for: effectiveRange)` from scratch (the SAME + /// unscoped filter `body`'s `windowRows` already ran), so a body pass — routinely once per second on + /// a ~1 Hz HR tick before the `WorkoutStartControl` isolation above — filtered `allRows` twice. + /// Reusing the caller's rows removes the second pass; taking them as a parameter (rather than a + /// computed property reading `effectiveRange` again) is what makes the reuse possible. + private func recoveryTrendRows(from unscopedRows: [WorkoutRow]) -> [WorkoutRow] { guard let last = latestTs else { return [] } let cutoff = last - 90 * 86_400 - return visible.filter { $0.startTs >= cutoff }.sorted { $0.startTs < $1.startTs } + return unscopedRows.filter { $0.startTs >= cutoff }.sorted { $0.startTs < $1.startTs } } /// Stable task identity: changing the range/filter/rows or HRmax cancels and rebuilds the trend. - private var recoveryTrendInputKey: String { - let rows = recoveryTrendRows - return "\(repo.refreshSeq)|\(model.profile.hrMax)|" - + rows.map { "\($0.startTs):\($0.endTs)" }.joined(separator: ",") + private func recoveryTrendInputKey(rows: [WorkoutRow]) -> String { + "\(repo.refreshSeq)|\(profile.hrMax)|" + rows.map { "\($0.startTs):\($0.endTs)" }.joined(separator: ",") } private var recoveryTrendCaption: String { @@ -329,13 +339,13 @@ struct WorkoutsView: View { return String(localized: "last 90 days") } - private func loadRecoveryTrend() async { + private func loadRecoveryTrend(rows: [WorkoutRow]) async { guard !usesPreviewRows else { recoveryTrend = []; return } var built: [WorkoutRecoveryTrendPoint] = [] - for row in recoveryTrendRows { + for row in rows { if Task.isCancelled { return } if let result = await repo.workoutHeartRateRecovery( - from: row.startTs, to: row.endTs, maxHR: Double(model.profile.hrMax), + from: row.startTs, to: row.endTs, maxHR: Double(profile.hrMax), source: row.source) { built.append(WorkoutRecoveryTrendPoint(startTs: row.startTs, result: result)) } @@ -467,6 +477,20 @@ struct WorkoutsView: View { // MARK: - Range control + /// Current / Archived. Sits above the range bar because it is the coarser cut: it decides WHICH rows + /// the range then narrows. + /// + /// Always shown, including when everything still fits in Current. A segment that appeared only once a + /// wearer crossed ten sessions would shift the whole screen down the first time it did, and an empty + /// Archived tab answers "where did my older workouts go" plainly: nothing is hidden yet. + private var scopeBar: some View { + Picker("Scope", selection: $scope) { + ForEach(Scope.allCases) { s in Text(s.label).tag(s) } + } + .pickerStyle(.segmented) + .padding(.horizontal, 2) + } + private func rangeBar(rows: [WorkoutRow], effectiveRange: Range) -> some View { let fellBack = effectiveRange != range let caption = rangeCaption(rows: rows, effectiveRange: effectiveRange, fellBack: fellBack) @@ -591,26 +615,12 @@ struct WorkoutsView: View { .accessibilityLabel("Add a workout") } - /// #459: begin (or jump back into) a live, manually-tracked workout straight from Workouts — the - /// place people instinctively look — instead of only from the Live screen. Starts the session and - /// presents the in-exercise view directly (no cross-view auto-present race with LiveView's sheet). - private var startLiveWorkoutButton: some View { - NoopButton(model.activeWorkout == nil ? "Start workout" : "View active workout", - systemImage: model.activeWorkout == nil ? "figure.run" : "timer", - kind: .primary, - fullWidth: true) { - // No active session → pick a named sport first (#519), then the sheet's onStart begins it - // and opens the in-exercise view. Already active → jump straight back into the live view. - if model.activeWorkout == nil { showStartSport = true } - else { showLiveWorkout = true } - } - .accessibilityLabel(model.activeWorkout == nil ? "Start a workout" : "View the active workout") - } - /// Equal-width primary actions share the same content width as every card below them. + /// #459 / PERF: the live-workout button is `WorkoutStartControl`, a leaf that owns `AppModel` itself + /// so this screen doesn't have to — see the comment on `profile`/`intelligence` above. private var workoutActionRow: some View { HStack(spacing: NoopMetrics.rowSpacing) { - startLiveWorkoutButton + WorkoutStartControl() .frame(maxWidth: .infinity) addWorkoutButton .frame(maxWidth: .infinity) @@ -631,6 +641,48 @@ struct WorkoutsView: View { /// Sessions inside a given range, RELATIVE TO THE LATEST session, then passed through the active /// filter. `.all` = all. The window anchor (`latestTs`) is the newest of ALL loaded rows so the /// window doesn't shift when a filter narrows the set. + /// Which slice of the history the list is showing. + /// + /// A VIEW split, never a delete. Archived rows stay in the database untouched and are one tap away, + /// which is the whole reason the request for "keep the last 10 and auto-delete the rest" is answered + /// this way instead: NOOP has no server and no cloud copy, so pruning real training history would be + /// irreversible, and hiding it costs nothing. + enum Scope: String, CaseIterable, Identifiable { + case current, archived + var id: String { rawValue } + /// `LocalizedStringKey` rather than `String`, because this is only ever handed to `Text`, so the + /// resolution belongs to the view environment. + /// + /// The sibling enums on other screens return `String(localized:)` instead, which is equally correct + /// for a value that has to be a String. What is NOT correct, and is what this property shipped as + /// first, is a BARE literal returned as a String: it renders in English forever, and the i18n gate + /// does not catch it, because a literal in that position is not somewhere the scanner looks. The + /// gate flagged the Picker's "Scope" key and said nothing about these two, which are the words + /// actually printed on the tabs. + var label: LocalizedStringKey { self == .current ? "Current" : "Archived" } + } + + /// How many of the most recent sessions "Current" holds. + static let currentScopeCount = 10 + + /// Split rows into the most recent `currentCount` and everything older. + /// + /// Pure and order-preserving: membership is decided by ranking on `startTs`, but the rows come back in + /// the order they arrived, so the caller's sort still decides what the screen shows. Ranking rather + /// than comparing against a cutoff timestamp is what makes ties safe: two sessions that start in the + /// same second cannot both sneak past a threshold and hand "Current" an eleventh row. + /// + /// Applied AFTER the range and sport filters, so each tab means "the 10 most recent of what you are + /// currently looking at" rather than silently showing an empty Current when a filter excludes the + /// newest sessions. + nonisolated static func scopedRows(_ rows: [WorkoutRow], scope: Scope, + currentCount: Int = currentScopeCount) -> [WorkoutRow] { + guard rows.count > currentCount else { return scope == .current ? rows : [] } + let key: (WorkoutRow) -> String = { "\($0.startTs)|\($0.sport)" } + let newest = Set(rows.sorted { $0.startTs > $1.startTs }.prefix(currentCount).map(key)) + return rows.filter { scope == .current ? newest.contains(key($0)) : !newest.contains(key($0)) } + } + private func sessions(for r: Range) -> [WorkoutRow] { let windowed: [WorkoutRow] if let days = r.days { @@ -640,6 +692,9 @@ struct WorkoutsView: View { } else { windowed = allRows } + // Deliberately NOT scoped. This feeds the HR-recovery trend (a 90-day analysis) and the + // auto-widen probe as well as the list, and cutting those to the ten most recent sessions would + // quietly change what they measure. The Current/Archived split is applied to the LIST rows only. return filter.apply(windowed) } @@ -972,7 +1027,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", @@ -1270,7 +1325,7 @@ struct WorkoutsView: View { selectionMode = false; selected.removeAll(); mergeSportPrompt = nil Task { await repo.mergeWorkouts(chosen, into: merged) - await model.intelligence.analyzeRecent() + await intelligence.analyzeRecent() await reload() } } @@ -1562,9 +1617,9 @@ struct WorkoutsView: View { .fixedSize() } - /// Right-click actions per row. A DETECTED bout can be re-labelled (becomes a real manual session - /// that survives re-detection) or dismissed (durably hidden). A MANUAL session can be edited or - /// deleted. Imported WHOOP / Apple rows are read-only (we never rewrite imported history). + /// Right-click actions per row. A grandfathered DETECTED bout can be re-labelled as a real manual + /// session or dismissed with its legacy marker retained. A MANUAL session can be edited or deleted. + /// Imported WHOOP / Apple rows are read-only (we never rewrite imported history). @ViewBuilder private func rowMenu(_ row: WorkoutRow) -> some View { switch WorkoutSource.classify(row.source) { @@ -1735,12 +1790,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 +1824,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) } @@ -1816,9 +1868,21 @@ struct WorkoutsView: View { /// Three raw-bpm HRR lines on one shared axis (#516). Unlike Compare's normalized overlay, these values /// share a unit and scale, so their vertical distance remains meaningful. Point marks keep a single eligible /// workout visible even when there is not yet enough history to draw a line. +/// +/// PERF: an active period (multiple workouts/day over the 90-day window `recoveryTrendRows` caps to) can +/// put several hundred points on this chart — up to 3 (1/2/5-min) per workout. `displayPlots` downsamples +/// EACH interval's line independently with StrandDesign's `ChartDownsample.minMaxBucketed` (the same +/// helper TrendChart/OverviewHRChart use, generalized to a date/value key-path form so it can be called +/// from here); hover still reads the full-resolution `points`, never the downsampled draw set. +/// +/// Hover/tooltip (previously missing): reuses the same `CrosshairRule`/`HighlightDot`/`PositionedTooltip`/ +/// `ChartTooltip` components `TrendChart`'s `chartOverlay` uses — no new mechanism. private struct WorkoutRecoveryTrendChart: View { let points: [WorkoutRecoveryTrendPoint] + /// The x-position the cursor is hovering, in chart-local coordinates. + @State private var hoverX: CGFloat? = nil + private struct Plot: Identifiable { let startTs: Int let interval: String @@ -1827,27 +1891,66 @@ private struct WorkoutRecoveryTrendChart: View { var date: Date { Date(timeIntervalSince1970: TimeInterval(startTs)) } } + private static let oneLabel = String(localized: "1 min") + private static let twoLabel = String(localized: "2 min") + private static let fiveLabel = String(localized: "5 min") + private var plots: [Plot] { points.flatMap { point in var out: [Plot] = [] if let value = point.result.after1Minute { - out.append(Plot(startTs: point.startTs, interval: String(localized: "1 min"), value: value)) + out.append(Plot(startTs: point.startTs, interval: Self.oneLabel, value: value)) } if let value = point.result.after2Minutes { - out.append(Plot(startTs: point.startTs, interval: String(localized: "2 min"), value: value)) + out.append(Plot(startTs: point.startTs, interval: Self.twoLabel, value: value)) } if let value = point.result.after5Minutes { - out.append(Plot(startTs: point.startTs, interval: String(localized: "5 min"), value: value)) + out.append(Plot(startTs: point.startTs, interval: Self.fiveLabel, value: value)) } return out } } + /// `plots`, min/max-bucketed per interval so a dense line downsamples on its OWN shape rather than + /// having one series' bucket choice clip another's peaks. + private var displayPlots: [Plot] { + let byInterval = Dictionary(grouping: plots, by: \.interval) + return [Self.oneLabel, Self.twoLabel, Self.fiveLabel].flatMap { key -> [Plot] in + let series = (byInterval[key] ?? []).sorted { $0.startTs < $1.startTs } + return ChartDownsample.minMaxBucketed(series, threshold: ChartDownsample.markThreshold, + targetCount: ChartDownsample.targetVertices, + date: { $0.date }, value: { Double($0.value) }) + } + } + + /// The full-resolution workout nearest a given chart-local x (not per-interval — one workout can carry + /// up to 3 values at the SAME x, so the tooltip names whichever are available together). + private func nearestPoint(toX x: CGFloat, proxy: ChartProxy, plot: CGRect) -> WorkoutRecoveryTrendPoint? { + guard !points.isEmpty else { return nil } + let relX = x - plot.minX + guard let date: Date = proxy.value(atX: relX) else { return nil } + return points.min(by: { + abs(TimeInterval($0.startTs) - date.timeIntervalSince1970) + < abs(TimeInterval($1.startTs) - date.timeIntervalSince1970) + }) + } + + private static let tooltipDateFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "d MMM yyyy" + return f + }() + + private func tooltipValue(for point: WorkoutRecoveryTrendPoint) -> String { + var parts: [String] = [] + if let v = point.result.after1Minute { parts.append(String(localized: "1m \(v)")) } + if let v = point.result.after2Minutes { parts.append(String(localized: "2m \(v)")) } + if let v = point.result.after5Minutes { parts.append(String(localized: "5m \(v)")) } + return parts.joined(separator: " · ") + } + var body: some View { - let one = String(localized: "1 min") - let two = String(localized: "2 min") - let five = String(localized: "5 min") - Chart(plots) { point in + Chart(displayPlots) { point in LineMark( x: .value("Workout", point.date), y: .value("Recovery", point.value) @@ -1862,7 +1965,7 @@ private struct WorkoutRecoveryTrendChart: View { .symbolSize(28) } .chartForegroundStyleScale( - domain: [one, two, five], + domain: [Self.oneLabel, Self.twoLabel, Self.fiveLabel], range: [StrandPalette.metricRose, StrandPalette.metricCyan, StrandPalette.metricPurple] ) .chartLegend(.hidden) @@ -1882,6 +1985,43 @@ private struct WorkoutRecoveryTrendChart: View { .foregroundStyle(StrandPalette.textTertiary) } } + .chartOverlay { proxy in + GeometryReader { geo in + let plot = proxy.plotRectCompat(in: geo) + ZStack(alignment: .topLeading) { + if let hx = hoverX, + let p = nearestPoint(toX: hx, proxy: proxy, plot: plot), + let px = proxy.position(forX: Date(timeIntervalSince1970: TimeInterval(p.startTs))) { + let cx = px + plot.minX + CrosshairRule(x: cx, height: geo.size.height) + PositionedTooltip( + anchor: CGPoint(x: cx, y: plot.minY + 8), + container: geo.size, + tooltip: ChartTooltip( + value: tooltipValue(for: p), + label: Self.tooltipDateFormatter.string( + from: Date(timeIntervalSince1970: TimeInterval(p.startTs))) + ) + ) + } + } + .animation(StrandMotion.fade, value: hoverX) + .frame(width: geo.size.width, height: geo.size.height, alignment: .topLeading) + .contentShape(Rectangle()) + .onContinuousHover(coordinateSpace: .local) { phase in + // Non-animating transaction: otherwise crossing the plot edge re-runs the line's + // draw-on animation and flickers the curve (mirrors TrendChart #104). + var tx = Transaction() + tx.disablesAnimations = true + withTransaction(tx) { + switch phase { + case .active(let location): hoverX = location.x + case .ended: hoverX = nil + } + } + } + } + } .accessibilityLabel("Heart-rate recovery trend in beats per minute") } } @@ -1921,15 +2061,23 @@ private func previewWorkoutRows() -> [WorkoutRow] { } #Preview("Workouts") { - WorkoutsView(previewRows: previewWorkoutRows()) - .environmentObject(Repository(deviceId: "preview")) + let repo = Repository(deviceId: "preview") + return WorkoutsView(previewRows: previewWorkoutRows()) + .environmentObject(repo) + .environmentObject(ProfileStore()) + .environmentObject(AppModel()) + .environmentObject(IntelligenceEngine(repo: repo, profile: ProfileStore(), deviceId: "preview")) .frame(width: 1040, height: 940) .preferredColorScheme(.dark) } #Preview("Workouts — empty") { - WorkoutsView(previewRows: []) - .environmentObject(Repository(deviceId: "preview")) + let repo = Repository(deviceId: "preview") + return WorkoutsView(previewRows: []) + .environmentObject(repo) + .environmentObject(ProfileStore()) + .environmentObject(AppModel()) + .environmentObject(IntelligenceEngine(repo: repo, profile: ProfileStore(), deviceId: "preview")) .frame(width: 1040, height: 600) .preferredColorScheme(.dark) } 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..72451b3568 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.8.0" struct Release: Identifiable { let version: String @@ -19,6 +19,104 @@ enum AppChangelog { /// Newest first. static let releases: [Release] = [ + Release( + version: "11.8.0", + title: "A gym log book on your wrist, a Coach you can switch off, and a Sync Strap shortcut", + date: "September 2026", + items: [ + "**A lift log you advance from the strap (#2098, #2099, #2232, thanks @UtkuDenizAltiok).** An on-device gym log book on iPhone and Mac: build a session, then move between sets with a double-tap on the strap instead of reaching for the phone. Android gets the groundwork this release, the new schema with its Room twin and the same set-metrics engine, but not the log book itself yet (#2327).", + "**A Coach you can turn off completely (#2269, #2254, #2222, #2207).** One master switch retires the AI Coach: the tab, the generated brief, the widget and the tray notification, and every request that would leave the device. Its settings moved to their own screen, Android gained the bottom-bar tab iPhone already had, and Disconnect is now somewhere an iPhone can actually reach.", + "**Sync the strap from a shortcut, and watch it work (#2272, thanks @npapatheodorou).** A Sync Strap App Intent runs a sync from Shortcuts or the Lock Screen, a Live Activity shows the offload as it goes, and an optional setting keeps the screen awake while it runs. Reconnecting off-screen now asks for the strap by name rather than scanning for it, which iOS throttles hard in the background.", + "**Workouts that are easier to keep tidy (#2287, #2286, #2260, #2220, #2213, thanks @tigercraft4).** Sessions under a minute are discarded, and the list splits into Current and Archived. Deleting one now clears it from every place the list reads, including the copy Apple Health kept. The WHOOP sports that were missing are selectable, their icons are fixed, and auto-detection only ever asks rather than saving on its own.", + "**An Oura ring that reads its own packets correctly (#2237, #2240, #2253, thanks @pipiche38).** A notification that tiles exactly into several packets is read in full instead of stopping at the first. A young ring no longer adopts the wrong anchor unit, settled by adjacency rather than by guessing, and a diagnostic reports which ring epoch each row believes in.", + "**WHOOP 5 readings that admit when they failed (#2193, #2223, #1985, #2230, thanks @Trillient, @bhelm and @kavemang).** A failed flag read is no longer reported as though it were a value, on either platform. The frame integrity verdict reaches every consumer rather than being narrowed on the way, and the strap family recorded is the one actually established.", + "**Charts that stop redrawing the whole screen (#2295, #2288, #2263, #2258, thanks @Iskrata and @kavemang).** Workouts and Sleep no longer re-evaluate their entire bodies on every live heart-rate tick. Three charts that had no tooltip gained one, two uncapped series are downsampled, the sleep motion trace computes its peak once, and the Apple stress level rounds the way Android's does.", + "**Steps charts, and an optional 30-day average (thanks @bhelm).** The steps charts are clearer, with a 30-day average card you can turn on.", + "**Apple Health that keeps up (#2279, #2291, #2296, #2251, #2271, thanks @Iskrata).** A deferred re-score now publishes once it lands rather than being missed, workouts are observed for live delivery, and a backgrounded pass paces itself under the CPU limit iOS enforces. A CSV import no longer turns a correct SDNN into RMSSD on the way back out.", + "**Smaller corrections.** The morning recap waits until 05:00 rather than arriving overnight (#2290). Stat labels no longer run into their neighbour (#2292). Battery says whose charge it is on all seven surfaces, not three (#2216). A night is named by the evening it belongs to (#2235). The Deep Timeline source row names the active device (#2276, thanks @pipiche38).", + "**Localization.** Chinese is complete in both scripts across the app and the watch complications (#2226, #2227, #2228, #2244), the French recovery and strain words are repaired (#2247), the theme picker says light rather than lightweight (#2249), and a gap that let UI copy ship English in every locale is closed, taking the Home screen and the terms you agree to with it (#2299, #2250).", + ] + ), + Release( + version: "11.7.0", + title: "A stress screen that keeps up, WHOOP 5 readings in the units the strap sends, and a ring that stops repeating itself", + date: "September 2026", + items: [ + "**A stress screen that keeps up (#2191, #2194, #2202, #2116).** Scoring a day of samples no longer happens on the thread that is drawing the screen, so Today and Stress stay responsive while they load. The baseline is reduced a day at a time instead of holding a month of readings at once, and the unprompted rescore now yields to whatever you are doing rather than competing with it.", + "**A stress widget that fills on its own (#2186, #2121, #2177).** The home-screen curve refreshes without waiting for the app to be opened, retries a rescore that produced nothing instead of spending the whole interval on it, and the Today curve stays as fresh as the screen it links to.", + "**Stress readings that agree with each other (#2165, #2168, #2182, #2190, #2124).** The 0 to 3 level has one spelling across the widget and the card, and is no longer rounded a second time on the way to the widget. The screen draws its line from the same sliding read the number comes from, movement is drawn as the stretches it covers rather than dots on the axis, and an early morning no longer reports Calibrating when the day has simply not started yet.", + "**WHOOP 5 readings in the units the strap actually sends (#2195, #2192, #2196, #2197, thanks @Trillient).** R-R intervals from the standard heart-rate profile were being converted as though they were in the spec's units, leaving them 2.3% low and feeding that error into HRV. The console log's sequence byte was also being read as half of a counter it is not. Both are decoded correctly now, on both platforms.", + "**An Oura ring that stops repeating itself (#2100, #2146, #2147, #2153, #2155, #2170, thanks @pipiche38).** A replayed burst the ring's own clock disproves no longer resets the history cursor or announces a reboot that did not happen. A freshly offloaded night is scored immediately rather than at the next periodic tick, history is fetched every five minutes instead of fifteen, and the anchor check judges a ten second gap rather than only a thirty second one.", + "**Scores that survive a re-score (#2115, #2141, thanks @bhelm).** WHOOP 5 HRV and Recovery, and the vitals derived from R-R, are preserved across a re-score instead of being dropped by a pass that could not recompute them.", + "**Scan and connect where you can reach them (#2178, #2180).** Both sit on the Today header, and the scan control appears only while the strap is actually away rather than occupying the header permanently.", + "**Readings that say when they are at a limit (#2176, #2189, #2122, #2201).** A Fitness Age sitting at the end of its scale says so rather than looking like a measurement, and points at the number that still moves. On Sleep, a night already in hand silences the calculating banner, and browsing to a night with no stages no longer shows the most recent night's date beneath it.", + "**Diagnostics that name the cause (#2118, #2129, #2136, #2160, #2138).** A device with no R-R now says whether the beats were never banked or were refused by the unit policy, and the nightly HRV summary says why it reported nothing. A retired probe tells its reader which switch to turn on, a re-arm clears the refusal latch it could never reach before, and the sync chip surfaces the strap's backlog.", + "**Smaller corrections.** A tile value now shrinks to fit instead of truncating, which it was meant to do all along (#2203, #2204, thanks @kavemang). Illness baselines are trusted per signal on Android (#2157, thanks @kavemang), stress scoring is skipped without a widget to draw it (#2111), sleep session cache upserts are guarded (#2112), and the strain banner is gated on the baseline the score already uses (#2132).", + ] + ), + Release( + version: "11.6.0", + title: "A Today screen you arrange yourself, stress on the home screen, and backups that check themselves", + date: "September 2026", + items: [ + "**Stress on your home screen (#2044, #2045).** A widget showing today's stress curve, on both platforms. It fills without needing the app opened, and when it has nothing to draw it says why instead of sitting blank (#2074).", + "**A Today screen you arrange yourself (#2047, #2051, #2053, #2054).** Today's stress curve and the Trends charts can now be placed on Today as cards, on both platforms. Tapping a hosted card opens the tab it came from rather than stranding you (#2052), and a hero ring on the classic Today opens its own detail (#2060).", + "**Charts that break where the strap did (#2064, #2083).** A heart-rate line no longer draws straight through hours the strap never recorded, on every surface that draws one. A gap now looks like a gap.", + "**WHOOP 5 readings corrected (#2046, #2042, #2056, thanks @Trillient).** R-R intervals are read in the units the strap actually sends, which feeds HRV. Skin temperature now reaches recovery scoring before the score is computed rather than after. Both platforms carry tests covering the gaps these came from.", + "**Backups that check themselves (#2086).** Every export path now verifies the file it actually wrote, so a truncated backup is caught when it is made instead of when you need it. When SQLite does complain, the message is shown in full and can be copied (#2084).", + "**A coach that holds on to your conversation (#2058, #2061).** A rejected API key can be corrected without losing the thread you were in. The coach is told what the workout was rather than only that one happened, the consent screen says what it actually sends (#2063), and the morning brief reaches the screen it was generated for (#2088).", + "**Diagnostics that say why, not just how many (#2080, #2094).** A night that missed the re-score cache now names the setting that dropped it. A dropped link is always recorded (#2065), the Live Console reads the device you actually selected (#2076), and the Rest card says why it is waiting on a sync (#2077).", + "**Your Oura serial stays out of the logs (#2095, #2090, thanks @pipiche38).** Serial numbers are masked in the Oura redactors on both platforms, and product-info replies no longer land in the raw diagnostics sidecar.", + "**Sharing a Test Centre report (#2096).** The Test Centre now hands you the bundle through the same share sheet the strap log uses, and stops there, instead of steering you into opening an issue.", + "**Smaller corrections.** Live heart rate stays armed while the Breathe screen is open (#2036, thanks @kiesstein). Workout actions stay readable over the daytime scene (#2050, thanks @kavemang). A silently dead duplicate string key is gone and the drifted Info.plist is regenerated (#2029, thanks @UtkuDenizAltiok). A manual workout can be entered by its start and end rather than only a duration (#2070), the strap picker seeds from the family actually recorded (#2067), and Today's heart-rate high and low read the samples rather than the mean curve (#2038).", + ] + ), + Release( + version: "11.5.0", + title: "A coach that keeps the thread, your heart rate on the home screen, and charts that claim less", + date: "September 2026", + items: [ + "**A coach you can hold a conversation with (#1862, thanks @kggreco11).** Replies stream in as they are written rather than arriving in one lump, the conversation is kept between sessions, and it can speak and be spoken to. A morning brief sums up the night behind you. There are widgets and Siri shortcuts, and an optional Today launcher card that stays off until you turn it on.", + "**Your heart rate on the home screen (#1957).** A widget showing live bpm with the recent trace behind it, on both platforms, laid out by your launcher rather than fighting it.", + "**Charts that claim less (#2007, #2011, #2027, #2028).** Points are spaced by date, so a week you did not wear the strap takes the width it actually spans instead of closing up. Daily scores draw as bars, because a line between two days asserts the value travelled through everything in between and it did not. The Line and Bars setting now applies to the detail charts as well as Trends, and HRV and Resting HR carry a dashed rule at your own baseline, so a number means something without you having to remember what is normal for you.", + "**Skin temperature reads as a temperature (#1845, #622 thanks @bartmuskala, #111 thanks @whisp0).** The Health screen and the explorer now lead with the measured value, with a Settings choice if you prefer the difference from your baseline, and it applies across the whole window rather than only the newest night. A Fahrenheit import is converted on the way in instead of being filed as Celsius.", + "**Sleep that survives a broken night (#1937 thanks @bartmuskala, #1855 thanks @AussieFries).** A night chopped into fragments too short to count on their own is bridged into the night it was, instead of scoring as nothing. Drag across the filled hypnogram to read the clock time under your finger. A night measured from heart rate alone now reports the HRV it actually measured, and the night label counts from today rather than from the newest record you happen to hold.", + "**Steps that know when you were asleep (#1572).** The step count and the day boundary now follow your own sleep rather than midnight, so a late night stops splitting one day's activity across two.", + "**A wake time for each day of the week (#1859).** Set Saturday later than Tuesday. The per-day override drives the strap alarm and the backup notification, not just the phone.", + "**Re-scores that take a fraction of the time (#1538 thanks @justinjor-bit).** The steps calibration no longer re-reads sixty days of movement on every pass, and what it learns now survives a restart. On a worn library that phase went from roughly thirty-three seconds to under three.", + "**A strap log that reports instead of assuming (#1997 thanks @semoi, #1881 thanks @pipiche38).** A connection your phone is already holding is no longer read as the strap refusing to pair, the strap you selected is the one that gets connected and credited, and a bond that ends now says why in language that reads the same in every locale. Serial numbers are masked wherever a log can be shared.", + "**Russian, German that speaks to you as du, and smaller corrections.** Routes now import from Apple Health and Health Connect (#1205). The battery pack reports its own charge without overriding the strap gauge (#1935, thanks @Zebsi235). Body measurements and exercise distance carry separate unit preferences (#1913, thanks @kavemang), and the readings table prints each unit once (#1942, thanks @Geg0r).", + ] + ), + 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..b2dea57254 --- /dev/null +++ b/Strand/System/CoachBriefScheduler.swift @@ -0,0 +1,418 @@ +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-" + + /// The Coach master switch, under the same `noop.` key Android writes and `RootTabView` binds. + /// + /// Read through `object(forKey:)` rather than `bool(forKey:)` because this pref defaults to TRUE and + /// `bool(forKey:)` answers FALSE for a key that was never written. Taking the shorter spelling here + /// would have disabled the Coach for every install that had never opened the toggle, which is exactly + /// the population whose behaviour must not change. + static var coachMasterEnabled: Bool { + guard UserDefaults.standard.object(forKey: "noop.coachEnabled") != nil else { return true } + return UserDefaults.standard.bool(forKey: "noop.coachEnabled") + } + + /// Tear down whatever the brief has left lying around when the Coach master switch goes off. + /// + /// Gating `isEnabled` alone stops the NEXT brief being generated, which is the network half, but it + /// leaves the LAST one on the wearer's home screen: the widget renders whatever was last published, + /// so AI output would go on being displayed after the AI was switched off. Mirrors what + /// `setEnabled(false)` already does for the brief's own flag, and what Android's `reschedule` does + /// when it sees the master switch is off. + /// + /// Deliberately one-directional. Re-arming is not done here because this type is armed by + /// `activateIfEnabled`, which `CoachView` calls when it appears -- a route that is reachable again the + /// moment the switch goes back on, since the tab returns with it. Android re-arms on the flip instead, + /// because there the brief is armed from app start rather than from the Coach screen. + static func applyMasterSwitch(_ on: Bool) { + guard !on else { return } + cancel() + // A brief already DELIVERED sits in Notification Centre until the wearer clears it, and tapping it + // routes to Coach. Same leak as the widget, one surface over: AI output still on display, and in + // this case still interactive, after the AI was switched off. Pending requests go too, so nothing + // already queued can arrive afterwards. + let centre = UNUserNotificationCenter.current() + centre.getDeliveredNotifications { delivered in + let ids = delivered.map(\.request.identifier).filter { $0.hasPrefix(requestIdPrefix) } + guard !ids.isEmpty else { return } + centre.removeDeliveredNotifications(withIdentifiers: ids) + } + centre.getPendingNotificationRequests { pending in + let ids = pending.map(\.identifier).filter { $0.hasPrefix(requestIdPrefix) } + guard !ids.isEmpty else { return } + centre.removePendingNotificationRequests(withIdentifiers: ids) + } + #if os(iOS) + publishToWidget(nil) + #endif + } + + /// Whether the daily brief should run. Both gates, so the brief's own default-off flag keeps its + /// meaning while the master switch can veto it outright. + static var isEnabled: Bool { coachMasterEnabled && 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 61b67eed3f..58a6af6645 100644 --- a/Strand/System/DebugDataDiagnostics.swift +++ b/Strand/System/DebugDataDiagnostics.swift @@ -22,6 +22,49 @@ 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, + deviceId: String) -> String { + func mark(_ b: Bool) -> String { b ? "yes" : "NO" } + // The DEVICE rides the value beside the window, for the same reason the window does. This asks + // ONE id, the active one, while every scorer reads the union of the active, canonical and + // computed ids. Those disagree on a re-added strap, an archived spine, or a Health Connect + // import, and the line then reads as "this install has no heart rate" when it means "the active + // strap id delivered none". That misreading cost real triage time on #2012. + return "Provides: HR \(mark(hr)) · R-R \(mark(rr)) · motion \(mark(motion)) · steps \(mark(steps))" + + " (\(deviceId), last 48h)" + } + + /// The note that says the funnel did NOT analyse the latest night, and which one it skipped. + /// + /// The funnel deliberately walks back to the most recent night carrying skin temperature, because a + /// night without it reports "skin=0" and teaches nothing. That fallback is right; printing its result + /// under a heading that says "latest night" is not. On #2012 it reported a night four days older than + /// the export with no indication, and reading it as the latest night is what a careful reader does. + /// + /// Empty when the funnel really did take the newest session, so the common case stays unchanged. + /// Byte-identical to the Kotlin `AndroidDiagnostics.funnelFallbackNote`. + static func funnelFallbackNote(chosenDay: String, newestDay: String) -> String { + chosenDay == newestDay ? "" : " (NOT the latest night: \(newestDay) carried no skin temperature)" + } + static func strapStateLines() -> [String] { var lines: [String] = [] lines.append(String(repeating: "─", count: 40)) @@ -37,30 +80,70 @@ enum DebugDataDiagnostics { let model = WhoopModel(rawValue: d.string(forKey: "selectedWhoopModel") ?? "")?.displayName ?? "unknown (never paired)" lines.append("Model: \(model)") + // NOTE: still the legacy GLOBAL key, and therefore the last strap to connect rather than this + // device's own firmware. strapStateLines is sync and prefs-only by contract (the scheduled export + // calls it with no store), and the per-device rule needs a registry read for pairedCount. The + // Devices block further down IS resolved per device, so a multi-strap export carries the correct + // 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); + // this pins the failure stage: "closed without a folder"/"never used" ⇒ no URL came back; // "picked" + a FAILED flag ⇒ a returned folder failed to bookmark HERE (our bug). + // + // #2356: the no-URL case used to read "cancelled", which asserts an intent UIKit never tells us. + // It calls the same delegate method when someone taps Cancel and when someone picks a folder and + // taps Open that iOS then declines, and a reporter hit the second while the log claimed the first, + // sending the investigation after a disabled Open button that was never the problem. The two facts + // below are what let a reader tell them apart: a tap on Cancel closes in a second or two, whereas + // navigating into iCloud Drive and choosing a folder takes far longer. let pickEvent = d.string(forKey: "backupPicker.lastEvent") ?? "never used" let pickAt = d.double(forKey: "backupPicker.lastEventAt") lines.append("Folder picker: \(pickEvent)\(pickAt > 0 ? " (\(relTime(now - pickAt)))" : "")") + let openFor = d.double(forKey: "backupPicker.lastOpenSeconds") + if openFor > 0 { + let startedIn = d.string(forKey: "backupPicker.lastStart") ?? "" + lines.append(" open for \(String(format: "%.1f", openFor))s" + + (startedIn.isEmpty ? "" : ", opened on \(startedIn)")) + } if pickEvent == "picked" { let scoped = d.bool(forKey: "backupPicker.lastScopedOpen") let bmOk = d.bool(forKey: "backupPicker.lastBookmarkOk") @@ -90,6 +173,25 @@ 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, + deviceId: repo.deviceId)) + } + // Data state from the preloaded day spine. let days = repo.days lines.append("History: \(days.count) day rows") @@ -99,6 +201,41 @@ enum DebugDataDiagnostics { if let r = days.last(where: { $0.recovery != nil }) { lines.append("Last recov.: \(r.day) · \(Int(r.recovery ?? 0))%") } else { lines.append("Last recov.: none") } + // #1300 follow-up: the header above describes ONE device because it reads the last-connected + // prefs, not the registry — so a two-strap install produced a log that never mentioned the + // second strap, leaving `dayOwner readId=` and the funnel's orphan check with nothing to be + // checked against. Name the whole set instead. + // `store` is fetched further down for the funnels; take a handle here rather than moving this + // block below the funnel header, so the inventory prints beside the strap identity it qualifies — + // the same position it holds on Android. + if let invStore = await repo.storeHandle() { + let invRegistry = DeviceRegistryStore(dbQueue: invStore.registryWriter) + // Bound before the map so the rule below has a count to test. Reading `.all()` inline left + // nothing to reference, which app-build caught and no local check could — this file needs + // macOS to compile. + let invDevices = (try? invRegistry.all()) ?? [] + let invRows = invDevices.map { + // Firmware resolved by the same rule as the Devices card: this device's own persisted + // value when there is one, and the LEGACY global key only when a single device is paired + // (it cannot have come from anything else). Apple does not yet write the per-device key — + // the write site has no peripheral identity to key on — so today this yields the global + // value for a single-strap install and "unknown" for a multi-strap one, which is honest + // rather than another strap's number. + InventoryRow(id: $0.id, brand: $0.brand, model: $0.model, + status: $0.status.rawValue, lastSeenAt: $0.lastSeenAt, + firmware: FirmwareAttribution.resolve( + live: nil, + perDevice: FirmwareAttribution.prefKey(peripheralId: $0.peripheralId) + .flatMap { UserDefaults.standard.string(forKey: $0) }, + legacyGlobal: UserDefaults.standard.string(forKey: "noop.lastFirmware"), + pairedCount: invDevices.count)) + } + let invActive = (try? invRegistry.activeDeviceId()) ?? nil + lines.append(contentsOf: deviceInventoryLines(rows: invRows, + activeId: invActive, + nowSec: Int(Date().timeIntervalSince1970), + relTime: { relTime($0) })) + } // Workout & imported-activity source breakdown (#28/#29 "counted but not shown" class). Runs BEFORE // the funnels since those can early-return, so this always lands in the export. @@ -146,7 +283,10 @@ enum DebugDataDiagnostics { let hr = await repo.hrSamples(from: cs.startTs, to: cs.endTs, limit: 200_000) let rr = (try? await store.rrIntervals(deviceId: did, from: cs.startTs, to: cs.endTs, limit: 200_000)) ?? [] let resp = (try? await store.respSamples(deviceId: did, from: cs.startTs, to: cs.endTs, limit: 200_000)) ?? [] - lines.append("Night \(dayStamp(cs.startTs)): grav=\(grav.count) hr=\(hr.count) rr=\(rr.count) resp=\(resp.count) skin=\(skin.count)") + lines.append("Night \(dayStamp(cs.startTs))" + + funnelFallbackNote(chosenDay: dayStamp(cs.startTs), + newestDay: dayStamp(newest.startTs)) + + ": grav=\(grav.count) hr=\(hr.count) rr=\(rr.count) resp=\(resp.count) skin=\(skin.count)") if grav.isEmpty && hr.isEmpty { // #1617 follow-up: do NOT assert "freshly re-added" without testing the other explanation. // Several ids can hold one physical strap's data (#1193/#740), and when the history spine and @@ -158,11 +298,28 @@ 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) { - lines.append(rem.summary) + // The funnel replays the V1 classifier, but the shipped hypnogram is staged by V2 whenever + // the default-on flag says so — name both, or the two totals read as one fact disagreeing. + // On a 5/MG the gap is maximal: V1's primary REM gate needs the raw resp channel that + // hardware never emits, while V2 recovers respiration from R-R, so the funnel can report + // ~46min REM against a 231min screen for the same night. + let screenStager = PuffinExperiment.experimentalSleepV2Enabled ? "V2" : "V1" + var summary = rem.summary + " · funnel replays V1; screen staged by \(screenStager)" + if screenStager != "V1" { + summary += " — totals can differ" + } + lines.append(summary) } else { lines.append("REM funnel: insufficient motion data (<2 gravity samples)") } @@ -343,13 +500,19 @@ 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 { - var line = "Last arm: sent \(alarmStamp(sent))" - if let at = d.object(forKey: "alarm.lastArmAt") as? Double { - line += " · \(relTime(Date().timeIntervalSince1970 - at))" + // #2322: "for · sent ". The old shape put both clocks on one line as + // "sent · ", which reads as "we sent that time, that long ago" — but the + // stamp is the FUTURE instant armed and the relative time is when the command went out. + var line = "Last arm: for \(alarmStamp(sent))" + let armedAt = d.object(forKey: "alarm.lastArmAt") as? Double + if let armedAt { + line += " · sent \(relTime(Date().timeIntervalSince1970 - armedAt))" } if !d.bool(forKey: "alarm.lastArmConnected") { line += " · strap NOT connected (queued)" } // #34: the strap-clock skew AT ARM. Skew ~0 but the strap still rejects ⇒ a corrupted alarm @@ -365,14 +528,32 @@ 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. + // #2322: and only when the readback ANSWERED this arm. A readback that failed to decode + // leaves the previous one standing, so without the arrival stamps this line compared two + // different arms and blamed the strap for the difference. + let readAt = d.object(forKey: "alarm.lastReportedAt") as? Double + let verdict = AlarmReadback.verdict( + sentEpoch: sent, + reportedEpoch: reported, + sentDeviceId: d.string(forKey: "alarm.lastArmDeviceId"), + reportedDeviceId: d.string(forKey: "alarm.lastReportedDeviceId"), + sentAt: armedAt, + reportedAt: readAt) + var rline = "Strap reports: \(alarmStamp(reported))" + AlarmReadback.suffix(verdict) + // When the readback landed, so a reader can see the provenance of both halves rather than + // having to trust that they belong together. + if let readAt { rline += " · read \(relTime(Date().timeIntervalSince1970 - readAt))" } // #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)") } @@ -435,11 +616,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/DeviceInventory.swift b/Strand/System/DeviceInventory.swift new file mode 100644 index 0000000000..6c72a662ad --- /dev/null +++ b/Strand/System/DeviceInventory.swift @@ -0,0 +1,52 @@ +import Foundation + +/// One registry row, reduced to what the inventory prints — see `deviceInventoryLines`. +struct InventoryRow { + let id: String + let brand: String + let model: String + let status: String + let lastSeenAt: Int + let firmware: String? +} + +/// The strap log's paired-device inventory: every registry row, which one is ACTIVE, and when each was +/// last seen. +/// +/// The header above it describes a single device because it reads the last-connected PREFS rather than +/// the registry, so a two-strap install produces a log that never mentions the second strap. That makes +/// the id-bearing lines elsewhere — `dayOwner readId=…`, the funnel's orphan check — impossible to +/// cross-check: a reader can see which id a day was read from but not which ids exist, nor which of them +/// actually synced. Naming the set is what turns those lines into evidence. +/// +/// Sorted ACTIVE first, then by most-recently-seen, then by id: a stable order that puts the row a reader +/// wants first at the top, and never depends on registry iteration order. +/// +/// Device ids embed a BLE address for a re-added strap, and the export's redaction masks the middle four +/// octets while keeping the first and last — enough to tell two straps apart in a shared log without +/// publishing an address. This line carries no data the log did not already carry. +/// +/// `nowSec` is passed in rather than read, so the output is a pure function of its inputs. Kotlin twin: +/// `com.noop.testcentre.deviceInventoryLines`. +func deviceInventoryLines(rows: [InventoryRow], + activeId: String?, + nowSec: Int, + relTime: (Double) -> String) -> [String] { + if rows.isEmpty { return ["Devices: none registered"] } + let active = rows.filter { $0.status == "active" }.count + let paired = rows.filter { $0.status == "paired" }.count + let archived = rows.filter { $0.status == "archived" }.count + let head = "Devices: \(rows.count) registered (\(active) active, \(paired) paired, \(archived) archived)" + let ordered = rows.sorted { a, b in + let aActive = a.id == activeId, bActive = b.id == activeId + if aActive != bActive { return aActive } + if a.lastSeenAt != b.lastSeenAt { return a.lastSeenAt > b.lastSeenAt } + return a.id < b.id + } + return [head] + ordered.map { r in + let marker = r.id == activeId ? "ACTIVE" : r.status + let seen = r.lastSeenAt > 0 ? relTime(Double(nowSec - r.lastSeenAt)) : "never" + return " device id=\(r.id) status=\(marker) brand=\(r.brand) model=\(r.model) lastSeen=\(seen)" + + " fw=\(r.firmware ?? "unknown")" + } +} diff --git a/Strand/System/DocumentPicker.swift b/Strand/System/DocumentPicker.swift index 7393ddcbf0..5234d66849 100644 --- a/Strand/System/DocumentPicker.swift +++ b/Strand/System/DocumentPicker.swift @@ -103,18 +103,33 @@ enum DocumentPicker { static var assocKey = 0 private let continuation: CheckedContinuation private var resumed = false + /// When the sheet went up, so a dismissal can report how long it was open. + private let presentedAt = Date() init(continuation: CheckedContinuation) { self.continuation = continuation } func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { - DocumentPicker.recordEvent(urls.isEmpty ? "picked-empty" : "picked", url: urls.first) + DocumentPicker.recordEvent(urls.isEmpty ? "picked-empty" : "picked", url: urls.first, + openSeconds: Date().timeIntervalSince(presentedAt), + startedIn: BackupPickerStart.category(controller.directoryURL)) finish(urls.first) } + /// UIKit calls this both when the user taps Cancel AND, as #2356 showed, when the user picks a + /// folder and taps Open but iOS declines to grant it. Nothing in the API distinguishes them, so + /// this must NOT report "cancelled": that asserts an intent we did not observe, and it sent the + /// last investigation after a disabled Open button that was never the problem. + /// + /// What we DO know is that the sheet closed and handed back no URL. That is what it says, plus the + /// two facts that let a reader tell the cases apart: how long the picker was open (a tap on Cancel + /// is a couple of seconds; navigating into iCloud Drive and choosing a folder is far longer) and + /// which directory it opened on. func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { - DocumentPicker.recordEvent("cancelled", url: nil) + DocumentPicker.recordEvent("closed without a folder", url: nil, + openSeconds: Date().timeIntervalSince(presentedAt), + startedIn: BackupPickerStart.category(controller.directoryURL)) finish(nil) } @@ -132,11 +147,46 @@ enum DocumentPicker { /// issue the internal-folder fallback sidesteps) from "it returned a URL we then failed to bookmark" /// (our bug, see `FolderBackup.saveFolder`'s scoped/bookmark flags). Shared by all three pickers, so /// the export labels it "last picker event"; the folder pick is the one under investigation. - static func recordEvent(_ kind: String, url: URL?) { + /// `startedIn` is a CATEGORY, never a path or a folder name. + /// + /// This string is printed into the debug export, which people attach to public issues, and that export + /// is not passed through a redactor. A folder someone chose can carry a person's name as easily as a + /// strap can (#2337), so the name never leaves the device: note which KIND of folder the picker opened + /// on, which is the whole diagnostic value, and drop the rest. The picked folder's own name has always + /// been stored and never printed, for the same reason. + static func recordEvent(_ kind: String, url: URL?, openSeconds: Double? = nil, startedIn: String? = nil) { let d = UserDefaults.standard d.set(kind, forKey: "backupPicker.lastEvent") d.set(Date().timeIntervalSince1970, forKey: "backupPicker.lastEventAt") d.set(url?.lastPathComponent ?? "", forKey: "backupPicker.lastName") + // Written UNCONDITIONALLY, including the nil case. Writing only when present would leave the + // duration and start folder from an earlier pick sitting beside a newer event, so the export + // would show two facts about two different sessions as though they described one. + d.set(openSeconds ?? 0, forKey: "backupPicker.lastOpenSeconds") + d.set(startedIn ?? "", forKey: "backupPicker.lastStart") } } + #endif + +import Foundation + +/// Which KIND of directory the backup folder picker opened on, with no name attached. +/// +/// #1000a made the starting directory a variable (the last-used folder when there is one, else our own +/// Documents), so which of those it landed on is worth knowing when a pick fails. The NAME is not, and +/// must not travel: this string is printed into the debug export people attach to public issues, and a +/// folder someone chose carries a person's name as easily as a strap does (#2337). +/// +/// Deliberately OUTSIDE the `#if os(iOS)` above, even though only the iOS picker calls it. It is pure +/// (a URL and `FileManager`, no UIKit), and behind the platform gate the macOS test bundle could only +/// match its call site as text and never actually run it. Out here the behaviour itself is testable, +/// which is the difference between pinning that a line exists and pinning that it is right. +enum BackupPickerStart { + static func category(_ directory: URL?) -> String { + guard let directory else { return "the picker default" } + let ourDocuments = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first + return directory.standardizedFileURL == ourDocuments?.standardizedFileURL + ? "NOOP's own folder" : "a folder you chose" + } +} diff --git a/Strand/System/FileExport.swift b/Strand/System/FileExport.swift index f2cb25cd0d..c581b96e96 100644 --- a/Strand/System/FileExport.swift +++ b/Strand/System/FileExport.swift @@ -131,8 +131,10 @@ enum FileExport { let vc = UIActivityViewController(activityItems: activityItems, applicationActivities: nil) if !cleanup.isEmpty || completion != nil { // Fires after the share sheet is dismissed (saved or cancelled). We clean up staged files and - // then run `completion` (M1/#812: the Test Centre report opens its prefilled issue here, once - // the share sheet is gone, so the in-app SafariVC presents with nothing else on screen). + // then run `completion`. That hook existed for the Test Centre report, which opened a prefilled + // GitHub issue once the sheet was gone; that step has been removed and nothing passes a + // completion today. Kept because it is the only point at which "the sheet has closed" is + // observable, which any future caller needing to follow a share will want. vc.completionWithItemsHandler = { _, _, _, _ in let fm = FileManager.default for url in cleanup where fm.fileExists(atPath: url.path) { 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/Platform.swift b/Strand/System/Platform.swift index 126f809a0c..2259ff3f70 100644 --- a/Strand/System/Platform.swift +++ b/Strand/System/Platform.swift @@ -78,10 +78,35 @@ enum PlatformOpen { /// orb, the HIIT interval timer). iOS-only; a no-op on macOS, which has no idle-lock /// concern for these screens. Apple guidance: set `true` only while genuinely needed and /// reset to `false` the moment the session ends so the system idle timer resumes normally. +/// +/// `isIdleTimerDisabled` is one process-wide flag, so independent holders are tracked by reason: a strap +/// sync finishing must not let the screen lock under a breathing session that is still running. enum ScreenIdle { + enum Reason: Hashable { + /// A watched on-screen session (breathing, intervals, workout, HRV snapshot) via `keepAwake`. + case session + /// A strap history sync, while Settings → "Keep screen on while syncing" is on (iOS). + case strapSync + } + + /// UserDefaults key for the "Keep screen on while syncing" toggle (default OFF). iOS-only behaviour; + /// declared here so the shared SettingsView and the iOS observer read one spelling. + static let strapSyncKeepAwakeKey = "syncKeepScreenOn" + + @MainActor private static var holds: Set = [] + + /// True while any reason holds the screen awake. Read by tests; the platform flag mirrors it. + @MainActor static var isHeld: Bool { !holds.isEmpty } + + /// Existing single-session API: last writer wins among sessions, exactly as before reasons existed. @MainActor static func keepAwake(_ on: Bool) { + hold(.session, on) + } + + @MainActor static func hold(_ reason: Reason, _ on: Bool) { + if on { holds.insert(reason) } else { holds.remove(reason) } #if os(iOS) - UIApplication.shared.isIdleTimerDisabled = on + UIApplication.shared.isIdleTimerDisabled = !holds.isEmpty #endif } } diff --git a/Strand/System/RescoreBackgroundScheduler.swift b/Strand/System/RescoreBackgroundScheduler.swift index 590cbda71d..4c8b158f24 100644 --- a/Strand/System/RescoreBackgroundScheduler.swift +++ b/Strand/System/RescoreBackgroundScheduler.swift @@ -36,28 +36,117 @@ 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" + + /// Whether the outstanding debt was left by a pass that COMPLETED but could not settle, as opposed to + /// one that was killed partway. + /// + /// #2238: the two are not the same question, and `isRescoreOwed` alone cannot tell them apart. A killed + /// pass deliberately never advanced `analyzeWatermarkKey`, so its resume must force. A pass that + /// completed and was merely out-voted by a newer token DID advance the watermark on its way out, so its + /// resume can ask the fingerprint whether anything actually changed and stand down when nothing did. + /// + /// Forcing both is what lets an Oura ring draining every 5 minutes re-score 21 nights back to back for + /// as long as the app is awake: each pass outlives its own debt, the resume forces a fresh one, and the + /// chain never reaches a quiet interval it can stop at. + static let owedAfterCompletedPassKey = "noop.rescoreOwedAfterCompletedPass" + static var isRescoreOwed: Bool { UserDefaults.standard.bool(forKey: owedKey) } + /// True only while the outstanding debt came from a completed-but-unsettled pass. Cleared by the next + /// `markRescoreOwed()`, so a debt recorded by a pass that then dies reverts to forcing. + static var isOwedAfterCompletedPass: Bool { + UserDefaults.standard.bool(forKey: owedAfterCompletedPassKey) + } + + 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) return value.isFinite && value > 0 ? value : nil } + /// When the last pass started (unix seconds), written only by a pass that is about to work, never by + /// the deferral path, so repeated deferrals cannot keep a stale debt looking fresh. + static let lastAttemptStartedAtKey = "noop.rescoreLastAttemptStartedAt" + + /// Seconds since the last pass started, nil if none was ever recorded. + static var secondsSinceLastAttempt: Double? { + let started = UserDefaults.standard.double(forKey: lastAttemptStartedAtKey) + return started > 0 ? Date().timeIntervalSince1970 - started : nil + } + /// 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. + /// - Parameter passStarting: the caller is a pass about to work (not the deferral path), so the attempt + /// time is recorded for `RescoreBackgroundPolicy.interruptedRetryCooldownSeconds`. + @discardableResult + static func markRescoreOwed(passStarting: Bool = false) -> String { + let token = UUID().uuidString + if passStarting { + UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: lastAttemptStartedAtKey) + } UserDefaults.standard.set(true, forKey: owedKey) + UserDefaults.standard.set(token, forKey: owedTokenKey) + // A fresh debt is unproven until the pass that owns it finishes: if THIS pass is killed, the + // watermark never advances and its resume must force. Cleared here rather than at completion so + // the flag describes the CURRENT debt, never the previous one (#2238). + UserDefaults.standard.set(false, forKey: owedAfterCompletedPassKey) + 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) + } else { + // #2238: this pass finished and advanced the watermark; only a newer token outvoted it. Record + // that, so the resume can gate on the fingerprint instead of forcing a pass whose inputs may be + // byte-identical to the one that just ran. + UserDefaults.standard.set(true, forKey: owedAfterCompletedPassKey) + } 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. @@ -90,14 +179,19 @@ enum RescoreBackgroundScheduler { /// would conjure a forced full pass for a processing task to run when very likely nothing changed, /// which is the churn #1146 exists to avoid. A debt an earlier real pass already recorded is /// untouched either way. + /// - Parameter passInProgress: a pass is already running in this process; see + /// `RescoreBackgroundPolicy.decide`. static func run(isBackground: Bool? = nil, owesOnDefer: Bool = true, + passInProgress: Bool = false, log: @escaping (String) -> Void, work: () async -> Void) async { let decision = RescoreBackgroundPolicy.decide( isBackground: isBackground ?? isBackgrounded, + isRealUpdate: owesOnDefer, rescoreAlreadyOwed: isRescoreOwed, - lastCompletedPassSeconds: lastCompletedPassSeconds) + passInProgress: passInProgress, + secondsSinceLastAttempt: secondsSinceLastAttempt) switch decision { case .deferToBackgroundTask(let reason): @@ -119,6 +213,47 @@ enum RescoreBackgroundScheduler { } } + /// How many times the re-score execution assertion has expired in this process. A pass reads it at its + /// start and end, so its cost line can say whether it outlived its background grant. + private(set) static var assertionExpiries = 0 + + /// CPU seconds this process has used, user plus system, across all threads. Process-wide, so it includes + /// the BLE and UI work beside a pass; nil if the kernel refuses the read. + nonisolated static func processCPUSeconds() -> Double? { + var usage = rusage() + guard getrusage(RUSAGE_SELF, &usage) == 0 else { return nil } + func seconds(_ t: timeval) -> Double { Double(t.tv_sec) + Double(t.tv_usec) / 1_000_000 } + return seconds(usage.ru_utime) + seconds(usage.ru_stime) + } + + /// What a completed pass cost, and whether it outlived its background grant. + /// + /// The `re-score: done` duration is uptime, which keeps running while the process is suspended, so on + /// its own it cannot separate a pass that was suspended and resumed across wakes from one that ran on + /// past an expired assertion. CPU time beside it can: a pass that spends a small fraction of its elapsed + /// time on CPU was mostly suspended, and `expired` says whether the assertion ran out on the way. + nonisolated static func passCostLogLine(cpuSeconds: Double?, elapsedSeconds: Double, + assertionExpiries: Int, backgroundedAtEnd: Bool) -> String { + let cpu = cpuSeconds.map { String(format: "%.1fs", max(0, $0)) } ?? "n/a" + let share = cpuSeconds.flatMap { elapsedSeconds > 0 ? Int((max(0, $0) / elapsedSeconds * 100).rounded()) : nil } + return "re-score: cost cpu=\(cpu) elapsed=\(String(format: "%.1f", elapsedSeconds))s" + + " cpuShare=\(share.map { "\($0)%" } ?? "n/a") assertionExpired=\(assertionExpiries)" + + " backgrounded=\(backgroundedAtEnd)" + } + + /// Rest between units of re-score work when backgrounded, so the pass stays under iOS's background CPU + /// limit instead of being killed by it (`RescoreBackgroundPolicy.backgroundRestPerWorkSecond`). `mark` is + /// the uptime the work since the last rest started at, in nanoseconds. It is left alone until a quantum of + /// work has built up (`backgroundWorkQuantumSeconds`), so short units run back to back, and it is reset + /// after a rest or in the foreground. + nonisolated static func paceIfBackgrounded(since mark: inout UInt64) async { + let workSeconds = Double(DispatchTime.now().uptimeNanoseconds &- mark) / 1_000_000_000 + let background = await MainActor.run { isBackgrounded } + let rest = RescoreBackgroundPolicy.restSeconds(afterWorkSeconds: workSeconds, isBackground: background) + if rest > 0 { try? await Task.sleep(nanoseconds: UInt64(rest * 1_000_000_000)) } + if rest > 0 || !background { mark = DispatchTime.now().uptimeNanoseconds } + } + /// Hold an execution assertion for the duration of `work` so a SHORT pass is not suspended halfway. /// A long one still outlives the grant; the assertion's expiry handler is where that becomes visible /// in the log and where the work is escalated, rather than the process simply vanishing. @@ -132,7 +267,8 @@ enum RescoreBackgroundScheduler { // the owed mark is still set (only a completed pass clears it) and that is what the next // decision reads. MainActor.assumeIsolated { - log("re-score: background time expired before the pass finished — escalating (#1538)") + assertionExpiries += 1 + log("re-score: background time expired mid-pass — it resumes on the next wake (#1538)") schedule() assertion.end() } @@ -155,7 +291,10 @@ enum RescoreBackgroundScheduler { /// Register the handler. MUST be called from `StrandiOSApp.init()` before launch finishes, and the /// identifier MUST be listed in `BGTaskSchedulerPermittedIdentifiers`, or iOS never delivers the task. /// Safe to leave uncalled: `schedule()` fails gracefully and the foreground path still scores. - static func register(perform operation: @escaping @MainActor () async -> Void) { + /// `onExpire` reports iOS reclaiming the processing time before the pass finished. The pass keeps no + /// record of it otherwise, so a strap log that simply stops mid-night cannot say why. + static func register(perform operation: @escaping @MainActor () async -> Void, + onExpire: @escaping @MainActor () -> Void = {}) { BGTaskScheduler.shared.register(forTaskWithIdentifier: taskIdentifier, using: nil) { task in let completion = TaskCompletionGuard(task: task) let worker = Task { @MainActor in @@ -169,6 +308,7 @@ enum RescoreBackgroundScheduler { } task.expirationHandler = { worker.cancel() + Task { @MainActor in onExpire() } // The pass did not finish inside the processing budget either. Ask for another rather // than dropping the work, and report the failure so iOS's own scheduling heuristics see // it honestly instead of being told this succeeded. diff --git a/Strand/System/SyncActivityCopy.swift b/Strand/System/SyncActivityCopy.swift new file mode 100644 index 0000000000..44ca2efdd7 --- /dev/null +++ b/Strand/System/SyncActivityCopy.swift @@ -0,0 +1,47 @@ +import Foundation + +/// The words and phase the strap-sync Live Activity shows, resolved here — platform-neutral, no +/// ActivityKit — so the mapping is covered by `StrandTests` (which runs on the macOS leg only). The iOS +/// controller turns a `Line` into the activity's content state; the widget renders the strings as-is. +/// +/// Same honesty rules as `SyncChipState.resolve`: a chunk COUNT rather than a percent (the strap never +/// says how much is pending), a zero backlog dropped rather than rendered, and "interrupted" only when +/// `LiveState.lastSyncError` says the idle watchdog ended the offload — every other end completed. +enum SyncActivityCopy { + enum Phase: String { + case connecting, syncing, done, interrupted + } + + struct Line: Equatable { + let phase: Phase + let chunks: Int + let status: String + let detail: String? + } + + static func connecting() -> Line { + Line(phase: .connecting, chunks: 0, status: String(localized: "Connecting…"), detail: nil) + } + + static func syncing(chunks: Int, pagesBehind: Int?) -> Line { + let behind = pagesBehind.flatMap { $0 > 0 ? $0 : nil } + return Line(phase: .syncing, chunks: chunks, + status: chunks > 0 ? String(localized: "Syncing… \(chunks) chunks") : String(localized: "Syncing…"), + detail: behind.map { String(localized: "\($0) pages behind at connect") }) + } + + static func final(lastSyncError: String?, chunks: Int) -> Line { + if lastSyncError != nil { + return Line(phase: .interrupted, chunks: chunks, + status: String(localized: "Sync interrupted"), + detail: chunks > 0 ? String(localized: "\(chunks) chunks pulled") : nil) + } + return Line(phase: .done, chunks: chunks, + status: chunks > 0 ? String(localized: "Synced · \(chunks) chunks") : String(localized: "Synced"), + detail: nil) + } + + static func notStarted() -> Line { + Line(phase: .interrupted, chunks: 0, status: String(localized: "Sync didn't start"), detail: nil) + } +} diff --git a/Strand/System/TestBundleAssembler.swift b/Strand/System/TestBundleAssembler.swift index 14fb2c8beb..43d342aef7 100644 --- a/Strand/System/TestBundleAssembler.swift +++ b/Strand/System/TestBundleAssembler.swift @@ -1,5 +1,6 @@ import Foundation import StrandAnalytics +import WhoopStore /// Assembles the Test Centre export bundle: gathers report.txt, meta.json, raw-capture and last-crash, /// runs the redaction pass over EVERY file, applies the 20 MB cap, and hands the entries to @@ -14,14 +15,25 @@ enum TestBundleAssembler { /// The redaction stamp written into meta.json so a maintainer knows the whole-bundle scrub ran. static let redactionVersion = "v2" + /// The bundle's hard byte cap (spec section 5.4; under GitHub's 25 MB attachment limit). Shared by + /// `capEntries` (what finally ships) and `ouraDiagnosticEntries` (how much it is worth reading), so the + /// read ceiling can never drift above the budget it feeds. + static let defaultCapBytes = 20 * 1024 * 1024 + + /// The Oura Diagnostics-dir sidecar kinds the bundle attaches — the SINGLE source of truth + /// `normalizedOuraEntryName`/`ouraDiagnosticEntries`/`trimmableNames`/`ouraSidecarNames` all derive + /// from, so a new dump writer (`Strand/BLE/Oura*Dump.swift`) needs exactly one line added here to be + /// picked up everywhere instead of silently missing the export bundle. (Landed after `cva-ppg` and + /// `real-steps` shipped their writers but not their kind entry — both wrote real files on-device that + /// the bundler's hardcoded 3-kind list then dropped from every export.) + static let ouraSidecarKinds: [String] = ["raw", "ibihr", "activity", "cva-ppg", "motion", "real-steps"] + /// The bundle files that may be trimmed to fit the cap (newest-tail kept). The strap-log tail and /// meta.json are already bounded, so only these raw research streams can blow the budget: the WHOOP - /// frame capture plus the Oura ring's Tier-B JSONL sidecars (raw notifications / IBI-HR / activity MET). - /// Everything NOT listed here is kept whole and its bytes are reserved before the trimmable group is - /// given the remainder. These are the NORMALIZED entry names (the ring id is dropped from the filename). - static let trimmableNames: Set = [ - "raw-capture.jsonl", "oura-raw.jsonl", "oura-ibihr.jsonl", "oura-activity.jsonl", - ] + /// frame capture plus the Oura ring's Tier-B JSONL sidecars (raw notifications / IBI-HR / activity MET / + /// CVA-PPG / motion / real-steps). Everything NOT listed here is kept whole and its bytes are reserved + /// before the trimmable group is given the remainder. NORMALIZED entry names (ring id dropped). + static let trimmableNames: Set = Set(["raw-capture.jsonl"] + ouraSidecarKinds.map { "oura-\($0).jsonl" }) /// #572 follow-up: the Oura Tier-B sidecars carry the ring id in a `"deviceId"` JSON field. The /// whole-bundle UUID scrub (`LiveState.redactPii`) masks it only when it is a CANONICAL dashed @@ -30,18 +42,35 @@ enum TestBundleAssembler { /// covered, and (unlike broadening the UUID regex to dashless hex) it CANNOT touch the raw `hex` capture /// field, which a greedier rule would shred. Scoped to the sidecars so a non-PII logical `deviceId` /// (e.g. "my-whoop") elsewhere in the bundle stays readable. NORMALIZED names (ring id already dropped). - static let ouraSidecarNames: Set = [ - "oura-raw.jsonl", "oura-ibihr.jsonl", "oura-activity.jsonl", - ] + static let ouraSidecarNames: Set = Set(ouraSidecarKinds.map { "oura-\($0).jsonl" }) + + /// `oura-raw.jsonl` is the ONE sidecar whose payload is nothing but hex (`OuraRawDumpLine.encode`) — + /// `oura-ibihr.jsonl`/`oura-activity.jsonl` encode DECODED numeric fields, so `LiveState.redactHexDump`'s + /// byte-run heuristic below never sees a hex blob long enough to false-positive on. Only `raw` needs the + /// exemption `redactEntries` applies via this set. + private static let rawHexSidecarNames: Set = ["oura-raw.jsonl"] /// Re-run the redaction sink over every entry. Text entries are decoded as UTF-8, scrubbed via the same /// LiveState.redactPii used by the live sink, and re-encoded. A non-UTF-8 entry (none today) passes /// through untouched rather than risk corrupting binary. meta.json and report.txt have no PII shapes so /// they pass through byte-identical; raw-capture is where the embedded serials live. + /// + /// `oura-raw.jsonl` skips the `redactPii` sweep entirely (see `rawHexSidecarNames`) — its `hex` field is + /// the ring's UNDECODED TLV bytes, and `redactPii`'s `redactHexDump` helper (built for #1833: a WHOOP + /// serial hiding as ASCII inside a hex-dumped console line) decodes hex back to bytes and masks any 9+ + /// byte run that spells alnum ASCII starting with a letter. Every Oura record is nothing but such runs, + /// so that heuristic fires on ordinary sensor bytes and on the `0x43`/`0x61` debug-text channel (ASCII by + /// design) — found 2026-09-11 via a real user capture: 38 of 3112 lines had bytes silently replaced with + /// `•` placeholders that are not even valid hex, breaking any offline tool that reframes the file. The + /// actual identity leak this heuristic was hoped to also catch here — the ring's SERIAL, arriving in + /// plain digits via a `0x18`/`0x19` GetProductInfo reply — is invisible to it anyway (no letter in an + /// all-numeric run), and is instead filtered at the SOURCE by `OuraLiveSource.rawDumpBytes` before the + /// frame ever reaches this file. The `deviceId` field mask below still runs unconditionally, so the one + /// piece of identity `oura-raw.jsonl`'s JSON envelope carries is still scrubbed. static func redactEntries(_ entries: [FileExport.BundleEntry]) -> [FileExport.BundleEntry] { entries.map { entry in guard let text = String(data: entry.data, encoding: .utf8) else { return entry } - var scrubbed = LiveState.redactPii(text) + var scrubbed = rawHexSidecarNames.contains(entry.name) ? text : LiveState.redactPii(text) // #572 follow-up: field-aware deviceId mask for the Oura sidecars (see `ouraSidecarNames`). Runs // AFTER redactPii, so it catches a non-canonical id that the dash-anchored UUID rule misses; on an // already-canonical id redactPii turned into ``, this is a no-op. Key-anchored to @@ -57,35 +86,83 @@ enum TestBundleAssembler { /// Hard cap the bundle at `capBytes` (20 MB default, under GitHub's 25 MB; spec section 5.4). The /// strap-log tail and meta.json are already bounded, so only the `trimmableNames` research streams can - /// exceed. We reserve the whole size of every non-trimmable file, then split the remaining budget across - /// the trimmable files IN PROPORTION to their size, keeping the MOST-RECENT tail of each (newest data is - /// the most diagnostic) and trimming from the front. With a single trimmable file this is identical to - /// the prior raw-capture-only behaviour (its share is the whole remainder). Returns the capped entries - /// plus whether any truncation happened, which the caller writes to meta.truncated. + /// exceed. We reserve the whole size of every non-trimmable file, then share the remaining budget across + /// the trimmable files, keeping the MOST-RECENT tail of each (newest data is the most diagnostic) and + /// trimming from the front. Returns the capped entries plus whether any truncation happened, which the + /// caller writes to meta.truncated. + /// + /// ALLOCATION: **max-min fair** (water-filling), NOT proportional-to-size. Each stream is offered an + /// equal share of what is left; a stream smaller than its share is kept WHOLE and its unused remainder + /// flows to the streams that are still over. See `fairAllowances` for why, and for the measured export + /// that motivated it. With a single trimmable file this is still identical to the original + /// raw-capture-only behaviour: its share is the whole remainder. static func capEntries(_ entries: [FileExport.BundleEntry], - capBytes: Int = 20 * 1024 * 1024) -> (entries: [FileExport.BundleEntry], truncated: Bool) { + capBytes: Int = defaultCapBytes) -> (entries: [FileExport.BundleEntry], truncated: Bool) { let total = entries.reduce(0) { $0 + $1.data.count } guard total > capBytes else { return (entries, false) } // Reserve the non-trimmable files (kept whole), then share the remainder across the trimmable ones. let nonTrimmable = entries.filter { !trimmableNames.contains($0.name) }.reduce(0) { $0 + $1.data.count } let budget = max(0, capBytes - nonTrimmable) - let trimmableTotal = entries.filter { trimmableNames.contains($0.name) }.reduce(0) { $0 + $1.data.count } + let allowance = fairAllowances( + sizes: entries.filter { trimmableNames.contains($0.name) }.map { ($0.name, $0.data.count) }, + budget: budget) var truncated = false let capped = entries.map { entry -> FileExport.BundleEntry in guard trimmableNames.contains(entry.name) else { return entry } - // Each trimmable file gets a byte share proportional to its size (integer floor keeps the sum - // under budget, so the bundle never breaches the cap). Kept whole if already within its share. - let share = trimmableTotal > 0 - ? Int(Double(budget) * Double(entry.data.count) / Double(trimmableTotal)) - : 0 + let share = allowance[entry.name] ?? 0 guard entry.data.count > share else { return entry } truncated = true - // Keep the tail (most recent): the last `share` bytes. - return FileExport.BundleEntry(name: entry.name, data: Data(entry.data.suffix(share))) + // Keep the tail (most recent): the last `share` bytes, then snap forward to the next full + // JSONL line. A raw byte-count tail can (and in production did - a real export shipped + // oura-cva-ppg.jsonl/oura-real-steps.jsonl/oura-motion.jsonl each with a corrupted first + // line) land mid-record; every trimmable name is newline-delimited JSONL, so dropping the + // partial line at the front keeps every kept line honest. + return FileExport.BundleEntry(name: entry.name, + data: Self.trimToLineBoundary(entry.data.suffix(share))) } return (capped, truncated) } + /// Split `budget` bytes across the named streams **max-min fairly**: walk them smallest-first, offering + /// each an equal share of whatever budget remains, and let a stream that fits under its share take only + /// what it needs so the surplus rolls forward to the larger ones. Integer floor keeps the sum at or under + /// `budget`, so the bundle can never breach the cap. Pure; `sizes` order does not affect the result. + /// + /// WHY NOT PROPORTIONAL-TO-SIZE (the original rule): it hands the budget to the BULKIEST stream, and for + /// these sidecars bulk is close to inversely correlated with diagnostic value. Measured on a real export + /// (`noop-master-iOS-v9.3.1-260809-0716.zip`, six trimmable sidecars over a 20,863,648-byte budget): + /// `oura-spo2.jsonl` — a per-sample dump whose values are also in the SQLite the same bundle ships — + /// took **53.9 %**, while `oura-raw.jsonl` got **1.9 %** (388,064 bytes: 8 min 43 s of an 8.4 h night). + /// The raw sidecar is the UNDECODED wire bytes, the one file in the bundle from which a protocol fact can + /// be re-derived independently of our own decoder, so starving it costs a night of verification that + /// nothing else can supply. Under this rule the ~3.47 MB fair share keeps it whole and the surplus goes + /// where it is merely bulk. Same 20 MB bundle, no editorial ranking of the streams. + static func fairAllowances(sizes: [(name: String, bytes: Int)], budget: Int) -> [String: Int] { + // Smallest-first, name-tiebroken so the result is deterministic regardless of input order. + let ordered = sizes.sorted { $0.bytes != $1.bytes ? $0.bytes < $1.bytes : $0.name < $1.name } + var allowance: [String: Int] = [:] + var left = max(0, budget) + var unserved = ordered.count + for stream in ordered { + let share = unserved > 0 ? left / unserved : 0 + let give = min(max(0, stream.bytes), share) + allowance[stream.name] = give + left -= give + unserved -= 1 + } + return allowance + } + + /// Drop bytes up to and including the first newline in `data`, so a raw byte-count tail (from + /// `capEntries`'s fair-share trim) never starts mid-line for a newline-delimited JSONL sidecar. + /// Returns `data` unchanged when it contains no newline (a single very-long line, or already empty) + /// - never worse than the untrimmed behaviour, just not improved. Pure/testable. + static func trimToLineBoundary(_ data: Data) -> Data { + guard let newline = data.firstIndex(of: 0x0A) else { return data } + let start = data.index(after: newline) + return data.subdata(in: start..; the ENTRY name is normalized (id dropped) since redaction never // touches names. Trimmed to the cap alongside raw-capture via `trimmableNames`. - let ouraDiagnostics = ouraDiagnosticEntries() + // (collected above, so the ring-epoch universal line could be derived from it) // 2. Redact the TEXT files (report.txt, raw-capture.jsonl, last-crash.txt, oura-*.jsonl), then cap. The screenshot // is included in the cap input (NOT the redact input) so its bytes COUNT against the 20 MB cap: @@ -228,7 +319,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) @@ -245,6 +336,80 @@ enum TestBundleAssembler { /// this session (no strap reply yet) OR only a firmware-only snapshot exists (newest==0, no real window). /// Pure; delegates the format to UniversalTrace so the line can never silently drift. `now` is injectable /// so the line is unit-testable without a live clock. + /// The universal R-R transport line (#2117): what the device banked versus what its unit policy can + /// score. nil when nothing has been resolved this session, or for a device the policy does not govern. + /// The judgement is `UniversalTrace.rrTransportLine`, shared byte for byte with Android; this is the + /// hand-off. + /// #2252: one `[universal]` line when the Oura sidecars disagree about where the ring's clock started, + /// or nil when they agree, which is the healthy case and the common one. + /// + /// The ticks×10 defect (#2239) filed whole sessions in the past, and the store keeps no ring-time, so + /// nothing in it can say which rows those were. The decoded sidecars keep both axes, so the epoch each + /// row implies (`utc - ringTs/10`) separates a mis-anchored session from an honest one. Derived from the + /// entries the bundle already collected rather than re-reading the directory: a ring that was never used + /// contributes no files, so the line is simply absent. + /// + /// `oura-raw.jsonl` has no `ringTs` and contributes nothing, which is correct; it is undecoded bytes. + /// + /// DESCRIBES, does not classify: a ring that genuinely restarted also starts a new epoch, and the + /// reader has the registration date this code does not. + static func universalOuraRingEpochLine(entries: [FileExport.BundleEntry]) -> String? { + // `oura-raw.jsonl` is skipped by NAME rather than left to fall out of the parse: it is the + // undecoded byte capture, it carries no `ringTs` by construction (`OuraRawDumpLine` encodes + // deviceId/utc/bytes and nothing else), and it is routinely the LARGEST sidecar. Scanning it + // costs a full pass over the biggest file in the bundle to find a field that cannot be there. + // Every other kind stays eligible, so a sidecar that does carry ring-times still contributes + // even if it is one this build does not know about. + let rows = entries + .filter { $0.name.hasPrefix("oura-") && $0.name != "oura-raw.jsonl" } + .flatMap { ouraSidecarRows(String(decoding: $0.data, as: UTF8.self)) } + guard !rows.isEmpty else { return nil } + return OuraRingEpochScan.summaryLine(OuraRingEpochScan.cluster(rows)) + } + + /// Pull `(ringTs, utc)` out of a decoded Oura sidecar's JSONL text. + /// + /// App-layer rather than beside `OuraRingEpochScan` in WhoopStore, which is where the epoch + /// MATHEMATICS lives and is kept in lockstep with Android. Reading these files is not shared work: + /// Android's own `TestBundleAssembler` collects no Oura sidecars, so a twin of this would be dead code + /// on that side, and dead code in a twin is worse than platform-local parsing that can be twinned the + /// day Android grows the same surface. + /// + /// Scans for the two fields rather than decoding each line as JSON: the sidecars are megabytes of + /// hand-built objects, and a line that is truncated or from an unknown schema is skipped rather than + /// failing the whole read. A sidecar's last line is routinely a partial write. `oura-raw.jsonl` has no + /// `ringTs` at all and so contributes nothing, which is correct: it is undecoded bytes. + static func ouraSidecarRows(_ text: String) -> [(ringTs: UInt32, utc: Int)] { + var out: [(ringTs: UInt32, utc: Int)] = [] + for line in text.split(separator: "\n", omittingEmptySubsequences: true) { + guard let rt = ouraSidecarInt("ringTs", in: line), let utc = ouraSidecarInt("utc", in: line), + rt > 0, rt <= Int(UInt32.max) else { continue } + out.append((UInt32(rt), utc)) + } + return out + } + + /// The integer value of `"name":` in `line`, or nil. Matches the QUOTED key so a field whose + /// name contains another ("utc" inside "utcOffset") cannot be read as it. + static func ouraSidecarInt(_ name: String, in line: Substring) -> Int? { + guard let keyRange = line.range(of: "\"\(name)\":") else { return nil } + var digits = "" + var i = keyRange.upperBound + if i < line.endIndex, line[i] == "-" { digits.append("-"); i = line.index(after: i) } + while i < line.endIndex, line[i].isNumber { + digits.append(line[i]) + i = line.index(after: i) + } + return Int(digits) + } + + static func universalRRTransportLine(transport: LiveState.RRTransport?) -> String? { + guard let transport else { return nil } + return UniversalTrace.rrTransportLine(strictWhoop5: transport.strictWhoop5, + firstRecordedUnix: transport.firstRecordedUnix, + firstScorableUnix: transport.firstScorableUnix) + } + static func universalClockDriftLine(range: LiveState.StrapRange?, now: Int = Int(Date().timeIntervalSince1970)) -> String? { guard let range, range.newestUnix > 0 else { return nil } @@ -281,36 +446,71 @@ enum TestBundleAssembler { } /// Map a diagnostics filename to a NORMALIZED bundle entry name that drops the ring id, or nil when the - /// file is not one of the three Oura sidecars. `oura--.jsonl` → `oura-.jsonl`, which - /// (a) keeps the ring id out of the bundle's filenames (redaction scrubs content, never names) and - /// (b) lands on the `trimmableNames` set so the cap can trim it. Pure/string-only for unit testing. + /// file is not one of the Oura sidecars in `ouraSidecarKinds`. `oura--.jsonl` → + /// `oura-.jsonl`, which (a) keeps the ring id out of the bundle's filenames (redaction scrubs + /// content, never names) and (b) lands on the `trimmableNames` set so the cap can trim it. + /// + /// A ROLLED GENERATION (`oura--.jsonl.`, written by `OuraRawDump`'s session ring) maps + /// to the SAME normalized name on purpose — `ouraDiagnosticEntries` merges the generations behind that + /// one name rather than choosing between them. Before generations were recognised at all, the `.jsonl` + /// suffix test rejected every one of them and the bundle could only ever ship the live file — which on + /// 2026-08-10 was a 30-second morning session while the night's 4 MB sat in a generation the export + /// never looked at. + /// + /// Thin wrapper over `WhoopStore.OuraSidecarGenerations`, where the rule is pure and unit-tested under + /// `swift test`; this file is app-target Swift that no default CI job compiles. static func normalizedOuraEntryName(forFile filename: String) -> String? { - for kind in ["raw", "ibihr", "activity"] { - if filename.hasPrefix("oura-\(kind)-"), filename.hasSuffix(".jsonl") { - return "oura-\(kind).jsonl" - } - } - return nil + guard let hit = OuraSidecarGenerations.classify(filename: filename, kinds: ouraSidecarKinds) + else { return nil } + return OuraSidecarGenerations.entryName(kind: hit.kind) } /// Gather the Oura ring's Tier-B JSONL sidecars as bundle entries, normalized names and RAW bytes (the /// caller redacts + caps). Enumerates the Diagnostics dir rather than reconstructing per-ring filenames, - /// so it needs no active-ring id and picks up whatever was captured. If two rings produced the same kind - /// (rare), the entry names would collide; we keep the LARGEST file per normalized name (the fuller - /// capture is the more useful one) so the bundle never carries duplicate names. - static func ouraDiagnosticEntries() -> [FileExport.BundleEntry] { - guard let dir = ouraDiagnosticsDir(), - let files = try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) + /// so it needs no active-ring id and picks up whatever was captured. + /// + /// Several files map to one entry name — the live session plus its rolled generations, and (rarely) two + /// rings that wrote the same kind. We **concatenate them oldest → newest** rather than choosing one, so + /// that the cap's existing keep-the-tail trim decides what ships. `OuraSidecarGenerations.mergePlan` + /// holds that rule, pure and unit-tested; see its doc comment for the measured export that motivated it. + /// + /// ⚠️ THIS REPLACES `largest-wins`, which was not a buggy pick but the wrong criterion: the wake drain + /// flushes the night's whole bank at once, so the biggest generation is whichever session drained most + /// — routinely not the one holding the night. It cost 11 of 31 nights in the `Sleep Nights` corpus. + /// + /// Reads are bounded by `capBytes`: an entry can never keep more than the whole bundle cap, so pulling + /// older generations past that point would be wasted memory. A kind whose newest file already fills the + /// cap therefore reads exactly one file, as it did before. + /// + /// `directory` is injectable so the merge is testable against a temp dir laid out like a real ring; + /// production passes nil and gets `/OpenWhoop/Diagnostics`. + static func ouraDiagnosticEntries(capBytes: Int = defaultCapBytes, + directory: URL? = nil) -> [FileExport.BundleEntry] { + guard let dir = directory ?? ouraDiagnosticsDir(), + let files = try? FileManager.default.contentsOfDirectory( + at: dir, includingPropertiesForKeys: [.fileSizeKey]) else { return [] } - var byName: [String: FileExport.BundleEntry] = [:] - for url in files.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) { - guard let name = normalizedOuraEntryName(forFile: url.lastPathComponent), - let entry = fileEntry(at: url, name: name) else { continue } - if let existing = byName[name], existing.data.count >= entry.data.count { continue } - byName[name] = entry + let sizes: [(name: String, bytes: Int)] = files.map { url in + // Size from the directory entry, so planning never reads a file it will not ship. + let bytes = (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize ?? 0 + return (url.lastPathComponent, bytes) + } + let plans = OuraSidecarGenerations.mergePlan(files: sizes, kinds: ouraSidecarKinds, + ceilingBytes: capBytes) + return plans.compactMap { plan in + var merged = Data() + for filename in plan.files { + guard let part = try? Data(contentsOf: dir.appendingPathComponent(filename)), + !part.isEmpty else { continue } + // Every sidecar is newline-delimited JSONL. A file whose last write did not end in a + // newline would otherwise splice its final record onto the next generation's first one, + // producing a line that parses as neither. Cheap to guarantee, silent to get wrong. + if let last = merged.last, last != 0x0A { merged.append(0x0A) } + merged.append(part) + } + guard !merged.isEmpty else { return nil } + return FileExport.BundleEntry(name: plan.entryName, data: merged) } - // Stable order (raw, ibihr, activity) so the bundle listing is deterministic. - return ["oura-raw.jsonl", "oura-ibihr.jsonl", "oura-activity.jsonl"].compactMap { byName[$0] } } /// The on-disk path a crash report would live at, when a crash handler is wired. There is no producer 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..bdabf82125 100644 --- a/Strand/System/TestCentreReport.swift +++ b/Strand/System/TestCentreReport.swift @@ -9,7 +9,7 @@ import StrandAnalytics /// issue, and toasts). No network of our own, no cloud. /// /// This is the thin orchestrator that ties Group D's UI to the Group B/C contracts (TestBundleAssembler, -/// FileExport.exportBundle, ReportReviewGate, TestReportLink, TestReportFlow). It is an ObservableObject +/// FileExport.exportBundle, ReportReviewGate, TestReportFlow). It is an ObservableObject /// so the screen can present the review sheet off `pendingReview`. @MainActor final class TestCentreReport: ObservableObject { @@ -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. @@ -105,12 +116,6 @@ final class TestCentreReport: ObservableObject { let platform = "macOS" #endif let osVersion = ProcessInfo.processInfo.operatingSystemVersionString - // CAPTURE-A (#812): seed the issue's what_happens box from the tester's own questionnaire answers so - // a report submitted without the .zip still opens with their words. The log tail is prefilled inside - // TestReportFlow from the redacted report.txt entry. - let seed = TestModeRegistry.mode(p.profile).flatMap { - TestReportLink.whatHappensSeed(questionnaire: $0.questionnaire, answers: TestCentre.answers(p.profile)) - } // Launched (#646/#651): TestReportFlow.run is now async since it awaits FileExport.exportBundle's // off-main zip build. Task { @@ -122,8 +127,7 @@ final class TestCentreReport: ObservableObject { showToast: { [weak self] msg in self?.lastStatus = msg }, // M3: prime the clipboard AND surface the report for a visible "Copy report.txt" button so // the documented mobile fallback is reachable, not just silently on the pasteboard. - copyToPasteboard: { [weak self] text in PlatformPasteboard.copy(text); self?.copyableReport = text }, - whatHappensSeed: seed) + copyToPasteboard: { [weak self] text in PlatformPasteboard.copy(text); self?.copyableReport = text }) } pending = nil } diff --git a/Strand/System/TestReportFlow.swift b/Strand/System/TestReportFlow.swift index 4bcaa81dfa..70d024fa6a 100644 --- a/Strand/System/TestReportFlow.swift +++ b/Strand/System/TestReportFlow.swift @@ -1,20 +1,18 @@ import Foundation import StrandAnalytics -#if canImport(UIKit) -import UIKit -import SafariServices -#elseif canImport(AppKit) -import AppKit -#endif - -/// Drives the in-app "Report" action (spec section 5.2): build plus save the redacted .zip, open the -/// prefilled GitHub issue, then toast the user to attach the file. The decision logic lives in `Plan` -/// (pure, unit-tested); `run` performs the side effects over the shipped share path. The bundle is -/// already redacted by TestBundleAssembler (meta.redaction="v2"); this flow never re-scrubs. +/// Drives the in-app "Share" action (spec section 5.2): build plus save the redacted .zip and hand it to +/// the share sheet. The decision logic lives in `Plan` (pure, unit-tested); `run` performs the side +/// effects over the shipped share path. The bundle is already redacted by TestBundleAssembler +/// (meta.redaction="v2"); this flow never re-scrubs. +/// +/// It used to open a prefilled GitHub issue after sharing, which is why the button said "Report". That +/// step is gone: the app takes nothing off the device and names no destination, and this button now does +/// what the strap-log Share button beside it does. Filing an issue, and attaching the saved file to it, +/// is the reporter's own step. /// /// Group B owns the assembler primitives (redactEntries, capEntries) and FileExport.exportBundle; -/// Group C owns the deep-link and this flow. The caller assembles the already-redacted, already-capped +/// Group C owns this flow. The caller assembles the already-redacted, already-capped /// entries (the Group D orchestrator composes redactEntries + capEntries + meta.json) and hands them /// here, so this file depends only on the Group A/B/C contracts and stays compilable on its own. enum TestReportFlow { @@ -28,13 +26,18 @@ enum TestReportFlow { FileExport.bundleName(profile: profile.id, platform: platform, version: version, date: date) } - /// The toast shown after the issue page opens, naming the exact saved file to attach. + /// The toast shown after the share sheet, naming the exact saved file. + /// + /// It used to read "On the next screen tap the paperclip and pick it", which described the GitHub + /// issue composer this flow opened. Nothing opens a composer now, so the line says where the file + /// went and stops there rather than instructing the user through a screen they will not see. static func attachToast(savedName: String) -> String { - "Saved as \(savedName). On the next screen tap the paperclip and pick it." + "Saved as \(savedName). Attach it to your bug report." } - /// GitHub's mobile composer can't reliably attach a .zip, so iOS also offers "Copy report.txt". - /// macOS Finder drag-drop works, so the fallback is mobile-only. + /// Mobile share sheets make a .zip awkward to paste into a web form, so iOS also offers + /// "Copy report.txt" for a reporter writing an issue by hand. macOS Finder drag-drop works, so the + /// fallback stays mobile-only. static func offersCopyFallback(platform: String) -> Bool { platform.lowercased() == "ios" } @@ -58,63 +61,21 @@ enum TestReportFlow { gate: ReportReviewGate, entries: [FileExport.BundleEntry], showToast: @escaping (String) -> Void, - copyToPasteboard: @escaping (String) -> Void, - // CAPTURE-A (#812): the questionnaire-derived one-liner seeding the form's what_happens - // box. nil leaves that required field for the user. The report.txt tail is read from - // `entries` below, so a report submitted without the .zip still carries the trace. - whatHappensSeed: String? = nil) async { + copyToPasteboard: @escaping (String) -> Void) async { // Review-before-share is mandatory: do nothing until the user has confirmed. guard shouldProceed(gate: gate) else { return } let name = Plan.bundleName(profile: profile, platform: platform, version: version) - // The redacted report.txt (already scrubbed by TestBundleAssembler) prefills the issue's log block - // so a forgotten attachment doesn't yield an empty report (#812). - let reportText = entries.first(where: { $0.name == "report.txt" }) - .flatMap { String(data: $0.data, encoding: .utf8) } - let issueURL = TestReportLink.reportURL(profile: profile, title: title, - version: version, platform: platform, osVersion: osVersion, - reportText: reportText, whatHappensSeed: whatHappensSeed) - // Save/share the .zip first, THEN open the prefilled issue. M1 (#812): on iOS the issue MUST open - // in an in-app SFSafariViewController, never UIApplication.open, a bare github.com universal link - // is handed to the installed GitHub app, which ignores the web prefill and just foregrounds itself - // ("opens GitHub and nothing happens"). Opening it in the share sheet's completion (after the sheet - // is dismissed) keeps the SafariVC from racing the share sheet and lets the user attach the .zip - // they just saved. macOS opens in the default browser via NSWorkspace (no hijack there). - #if canImport(UIKit) - _ = await FileExport.exportBundle(entries: entries, suggestedName: name, completion: { - if let issueURL { presentInSafari(issueURL) } - }) - #elseif canImport(AppKit) + // Share the .zip and stop. Nothing is opened, navigated to, or sent: the destination is whatever + // the user picks in the sheet, and the app never names one. _ = await FileExport.exportBundle(entries: entries, suggestedName: name) - if let issueURL { NSWorkspace.shared.open(issueURL) } - #endif showToast(Plan.attachToast(savedName: name)) if Plan.offersCopyFallback(platform: platform), let report = entries.first(where: { $0.name == "report.txt" }), let text = String(data: report.data, encoding: .utf8) { // Offer the copy fallback by priming the pasteboard closure; the UI exposes a "Copy report.txt" - // button bound to this same text so a mobile user who can't attach can paste a

block. + // button bound to this same text, so a reporter writing an issue by hand can paste a + //
block instead of wrestling a .zip into a web form on a phone. copyToPasteboard(text) } } - - #if canImport(UIKit) - /// M1 (#812): present the prefilled new-issue URL in an in-app Safari view so the installed GitHub app - /// cannot hijack the github.com universal link (which it does for a bare UIApplication.open, ignoring - /// the web prefill). Climbs to the top-most view controller, the same presenter pattern FileExport uses. - @MainActor - private static func presentInSafari(_ url: URL) { - guard let scene = UIApplication.shared.connectedScenes - .compactMap({ $0 as? UIWindowScene }) - .first(where: { $0.activationState == .foregroundActive }) - ?? UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }).first, - let root = scene.windows.first(where: { $0.isKeyWindow })?.rootViewController - ?? scene.windows.first?.rootViewController else { - UIApplication.shared.open(url) // last-resort fallback if no window is reachable - return - } - var presenter = root - while let next = presenter.presentedViewController, !next.isBeingDismissed { presenter = next } - presenter.present(SFSafariViewController(url: url), animated: true) - } - #endif } diff --git a/Strand/System/TestReportLink.swift b/Strand/System/TestReportLink.swift deleted file mode 100644 index 590635e46b..0000000000 --- a/Strand/System/TestReportLink.swift +++ /dev/null @@ -1,108 +0,0 @@ -import Foundation -import StrandAnalytics - -/// Builds the prefilled GitHub new-issue URL for a Test Centre report (spec section 5.2). It binds -/// the bug form's existing id fields (version, platform, os_version, test_profile, title) and -/// self-applies the "bug,test:" labels so a submission lands pre-labelled on the right cluster. -/// No network, no cloud: this only composes a URL the caller opens in the browser. Repo is -/// ryanbr/noop (confirmed in bug_report.yml). -/// -/// CAPTURE-A (#812): a report submitted WITHOUT the .zip attached used to land empty, because the form's -/// `log` / `what_happens` textareas were blank and the user often forgot the paperclip. We now PREFILL -/// those two id'd textareas from the already-redacted report.txt: the `log` field gets the last ~150 -/// lines wrapped in a
block, and `what_happens` is seeded so the body is never empty even when -/// nothing is attached. The field ids `log` and `what_happens` match bug_report.yml exactly. -enum TestReportLink { - - /// How many trailing lines of the redacted report.txt to prefill into the `log` textarea. Kept SHORT - /// (the recent killer-trace tokens: the universal `dayOwner` line, the clock-drift line, the - /// per-domain emits) because the prefill rides INSIDE the URL: GitHub silently drops a new-issue - /// prefill past ~8 KB (the user lands on an empty form), so a long tail defeats the whole point. The - /// full trace travels in the attached/shared .zip, not the URL. A hard `maxURLLength` guard below - /// drops the `log` param entirely if the URL would still be too long. - static let logTailLines = 40 - - /// Hard ceiling on the composed new-issue URL. GitHub starts dropping/emptying the prefill near 8 KB; - /// we stay well under so the form always renders prefilled. If adding the `log` block would breach - /// this, the URL is rebuilt WITHOUT `log` (the .zip still carries the full trace), never truncated - /// mid-token into a broken
block. - static let maxURLLength = 6000 - - /// Percent-encodes a query value with a strict allowed set: alphanumerics plus the few chars we - /// want to stay literal (colon, dot, hyphen, underscore). Crucially the comma in "bug,test:id" is - /// NOT in the set so it encodes to %2C, byte-matching the Kotlin twin (URLComponents would leave - /// the comma literal and diverge). Spaces and brackets in the title encode to %20 / %5B / %5D. - private static func enc(_ v: String) -> String { - var allowed = CharacterSet.alphanumerics - allowed.insert(charactersIn: ":.-_") - return v.addingPercentEncoding(withAllowedCharacters: allowed) ?? v - } - - /// The last `logTailLines` lines of `reportText`, wrapped in a GitHub
block so the prefilled - /// log collapses by default and doesn't dominate the issue. Returns nil when there is no usable text, - /// so an empty report contributes no `log` param at all (rather than an empty
). PURE. - static func logDetailsBlock(reportText: String, tailLines: Int = logTailLines) -> String? { - // Split on newlines, drop a trailing empty line from a final "\n", take the last `tailLines`. - var lines = reportText.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) - if lines.last == "" { lines.removeLast() } - guard !lines.isEmpty else { return nil } - let tail = lines.suffix(tailLines).joined(separator: "\n") - guard !tail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil } - // A fenced code block inside
keeps the strap log monospaced and unparsed by Markdown. - return "
Strap log (last \(min(tailLines, lines.count)) lines, redacted)\n\n" - + "```\n" + tail + "\n```\n
" - } - - /// Seed text for the `what_happens` textarea from the mode's questionnaire answers (#812). Joins each - /// answered prompt as ": " so the body opens with the tester's own words instead of a - /// blank box. Empty/blank answers are skipped; returns nil when nothing was answered (no `what_happens` - /// param is then added, leaving the form's required field for the user to fill). PURE + deterministic - /// (questions iterate in their declared order, not the dictionary's). The questionnaire prompts here - /// carry no PII; the strap log (the only PII-shaped surface) is the already-redacted `log` block. - static func whatHappensSeed(questionnaire: [Question], answers: [String: String]) -> String? { - let parts: [String] = questionnaire.compactMap { q in - guard let a = answers[q.id]?.trimmingCharacters(in: .whitespacesAndNewlines), !a.isEmpty else { return nil } - return "\(q.prompt): \(a)" - } - guard !parts.isEmpty else { return nil } - return parts.joined(separator: "\n") - } - - /// The prefilled new-issue URL, or nil if it cannot form. `profile.id` is the wire id - /// (dataImport -> "import"); `profile.githubLabel` is "test:" (master -> "test:all"). - /// - /// When `reportText` is supplied (the in-app Report flow passes the redacted report.txt), the form's - /// `log` textarea is prefilled with its
-wrapped tail and `what_happens` is seeded with - /// `whatHappensSeed` (the questionnaire-derived one-liner), so a submitted report carries the - /// diagnostic trace even if the user never attaches the .zip (#812). Both default nil/empty so existing - /// callers compose the same URL as before. - static func reportURL(profile: TestDomain, title: String, - version: String, platform: String, osVersion: String, - reportText: String? = nil, - whatHappensSeed: String? = nil) -> URL? { - var query = [ - "template=bug_report.yml", - "labels=" + enc("bug,\(profile.githubLabel)"), - "version=" + enc(version), - "platform=" + enc(platform), - "os_version=" + enc(osVersion), - "test_profile=" + enc(profile.id), - "title=" + enc("[\(profile.id)] \(title)"), - ] - // CAPTURE-A: seed what_happens so the body is never empty, and prefill the log tail (#812). - if let seed = whatHappensSeed, !seed.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - query.append("what_happens=" + enc(seed)) - } - let base = "https://github.com/ryanbr/noop/issues/new?" - // Add the log block ONLY if the whole URL stays under the GitHub prefill ceiling. If it would - // breach maxURLLength, drop `log` entirely (never truncate it into a broken
); the full - // trace is in the attached .zip. The seed + id fields alone keep the body non-empty (#812). - if let reportText, let block = logDetailsBlock(reportText: reportText) { - let withLog = query + ["log=" + enc(block)] - if (base + withLog.joined(separator: "&")).count <= maxURLLength { - query = withLog - } - } - return URL(string: base + query.joined(separator: "&")) - } -} 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/AICoachRateLimitTests.swift b/StrandTests/AICoachRateLimitTests.swift new file mode 100644 index 0000000000..6ec3d20c6a --- /dev/null +++ b/StrandTests/AICoachRateLimitTests.swift @@ -0,0 +1,74 @@ +import Foundation +import XCTest +@testable import Strand + +private final class AICoachURLProtocolStub: URLProtocol { + struct Stubbed { let status: Int; let body: Data } + nonisolated(unsafe) static var response = Stubbed(status: 500, body: Data()) + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + let stub = Self.response + let response = HTTPURLResponse( + url: request.url!, statusCode: stub.status, httpVersion: nil, headerFields: nil + )! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: stub.body) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} + + static func session(status: Int, body: String) -> URLSession { + response = Stubbed(status: status, body: Data(body.utf8)) + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [AICoachURLProtocolStub.self] + return URLSession(configuration: configuration) + } +} + +/// HTTP 429 keeps its stable explanation while surfacing any actionable detail the provider sent. +final class AICoachRateLimitTests: XCTestCase { + private let request = URLRequest(url: URL(string: "https://provider.invalid/coach")!) + private let base = "The provider is rate-limiting requests right now. Wait a moment and try again." + + func testRegularRequestIncludesNestedProviderMessage() async { + let session = AICoachURLProtocolStub.session( + status: 429, + body: #"{"error":{"message":"Daily request quota exhausted"}}"# + ) + + do { + _ = try await performRequest(request, session: session) + XCTFail("expected rateLimited") + } catch let error as AICoachError { + XCTAssertEqual(error.errorDescription, base + " (Daily request quota exhausted)") + } catch { + XCTFail("unexpected error: \(error)") + } + } + + func testStreamingRequestIncludesTopLevelProviderMessage() async { + let session = AICoachURLProtocolStub.session( + status: 429, + body: #"{"message":"Tokens per minute exceeded"}"# + ) + + do { + try await performStreamingRequest(request, session: session) { _ in + XCTFail("an error response must not emit a stream delta") + } + XCTFail("expected rateLimited") + } catch let error as AICoachError { + XCTAssertEqual(error.errorDescription, base + " (Tokens per minute exceeded)") + } catch { + XCTFail("unexpected error: \(error)") + } + } + + func testMissingProviderMessageKeepsExistingCopy() { + XCTAssertEqual(AICoachError.rateLimited("").errorDescription, base) + } +} 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/AIKeyRejectionTests.swift b/StrandTests/AIKeyRejectionTests.swift new file mode 100644 index 0000000000..280921341d --- /dev/null +++ b/StrandTests/AIKeyRejectionTests.swift @@ -0,0 +1,40 @@ +import XCTest +@testable import Strand + +/// The coach shows the chat as soon as ANY key is stored, and a wrong key is still a stored key, so a +/// rejection is the one failure whose repair the wearer cannot reach on their own. The view opens a key +/// field on exactly this classification, which makes it load-bearing rather than cosmetic: widen it and +/// a rate limit starts demanding a new key, narrow it and the trap comes back. +/// +/// The same table is asserted in Kotlin `AiKeyRejectionTest`, so the two platforms cannot start +/// disagreeing about which failures are the wearer's to fix. Pure logic, no network. +final class AIKeyRejectionTests: XCTestCase { + + func testIsKeyRejection() { + let cases: [(Int, Bool)] = [ + // Unauthorized and Forbidden are the two the providers use for a bad or revoked key. + (401, true), + (403, true), + // Success is not a failure at all. + (200, false), + // The request's problem, not the key's: a model that does not exist reaches here. + (400, false), + // Payment and quota read like an account problem, and a new key does not answer either. + (402, false), + (404, false), + (408, false), + (429, false), + // The provider's problem. Retyping a key would send the wearer chasing the wrong thing. + (500, false), + (502, false), + (503, false), + (599, false), + // Nothing outside the range gets a free pass either. + (0, false), + (-1, false), + ] + for (status, want) in cases { + XCTAssertEqual(AICoachError.isKeyRejection(status), want, "HTTP \(status)") + } + } +} 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/AlarmWakeTimeLabellingTests.swift b/StrandTests/AlarmWakeTimeLabellingTests.swift new file mode 100644 index 0000000000..2ea210da28 --- /dev/null +++ b/StrandTests/AlarmWakeTimeLabellingTests.swift @@ -0,0 +1,258 @@ +import XCTest +@testable import Strand + +/// The Alarms screen carries two time pickers and only one of them wakes anybody. +/// +/// The strap wake-alarm's "Wake at" arms the strap's firmware alarm. The wind-down card's picker is an +/// INPUT to the reminder's arithmetic: nudge = wake - sleep need - lead. Both were labelled "Wake time" +/// for VoiceOver, both sat on the same screen, and the two values are stored independently +/// (`behavior.smartAlarmMinutes` vs `windDown.wakeMinutes`) so they routinely disagree. A reporter asked +/// outright which of the two times would wake them, which is the question this screen must never provoke. +/// +/// Source-asserted because SwiftUI labels have no unit seam, the same reason `PiiRedactionTests` reads +/// source. These pin the wording contract, not the layout. +final class AlarmWakeTimeLabellingTests: XCTestCase { + + /// No two accessibility labels on this screen may be the same word for different things. The exact + /// failure: VoiceOver announced "Wake time" for the alarm and "Wake time" for the reminder input. + func testTheTwoWakePickersDoNotShareAnAccessibilityLabel() throws { + let src = try Self.alarmViewSource() + let labels = Self.accessibilityLabels(in: src) + XCTAssertFalse(labels.isEmpty, "no accessibilityLabel calls found, the scan is broken") + let duplicates = Dictionary(grouping: labels, by: { $0 }).filter { $0.value.count > 1 } + XCTAssertTrue(duplicates.isEmpty, + "these accessibility labels are used for more than one control: \(duplicates.keys.sorted())") + } + + /// The bare phrase "Wake time" must not come back as a field label, on either card. It is the one + /// wording that cannot distinguish the alarm from the reminder's input. + func testNeitherPickerIsLabelledJustWakeTime() throws { + let src = try Self.alarmViewSource() + XCTAssertFalse(Self.accessibilityLabels(in: src).contains("Wake time"), + "\"Wake time\" is ambiguous on this screen: say whose wake time it is") + } + + /// The wind-down field must say, in its own card, that it does not wake anyone. Without this the + /// only disclaimer is "It's a suggestion, not an alarm" in tertiary footnote text above the toggle, + /// several rows away from the picker people actually read. + func testTheWindDownFieldSaysItDoesNotWakeYou() throws { + let src = try Self.alarmViewSource() + XCTAssertTrue(src.contains("This time does not wake you."), + "the wind-down wake field must disclaim waking, next to the field itself") + } + + /// #1864 made the per-day overrides drive the STRAP ALARM as well as the nudge, but the copy still + /// read as though they only moved the evening reminder, while the editor sat under the wind-down + /// card. A day set there re-times the buzz on the wrist, and the screen has to say so. + func testThePerDayCopySaysItMovesTheStrapAlarmToo() throws { + let src = try Self.alarmViewSource() + XCTAssertTrue(src.contains("These times move your strap alarm AND the evening reminder"), + "per-day overrides drive applySmartAlarm(), so the copy must name the strap alarm") + } + + /// A day with no override of its own has no single fallback to show: the alarm falls back to its own + /// time, the reminder to the usual wake time. The row can only display one number and displays the + /// reminder's, so both have to be named underneath rather than one silently standing for both. + func testUntouchedDaysNameBothFallbacks() throws { + let src = try Self.alarmViewSource() + XCTAssertTrue(src.contains("Days you leave alone keep your strap alarm at"), + "the untouched-day explainer must name the strap alarm's own fallback") + XCTAssertTrue(src.contains("and time the reminder from"), + "the untouched-day explainer must name the reminder's fallback too") + } + + /// The line naming what wakes you must resolve the NEXT fire, not print `smartAlarmMinutes`. + /// + /// The first draft of this very change printed the base time, which is wrong on exactly the days the + /// reporter had changed: their Saturday override is 20:30 while the base is 10:00. A screen built to + /// stop people misreading which time wakes them cannot itself state a time that will not. + func testTheWakesYouLineResolvesTheNextFireRatherThanTheBaseTime() throws { + let src = try Self.alarmViewSource() + XCTAssertTrue(src.contains("AppModel.nextSmartAlarmDate("), + "the answer line must come from the same resolver applySmartAlarm arms the strap from") + guard let range = src.range(of: "private var nextStrapAlarmLabel: String? {") else { + return XCTFail("nextStrapAlarmLabel not found") + } + // Bounded at the property's own closing brace, not a character budget: a fixed prefix would run + // on into the next declaration and scan code this test is not talking about. + let after = src[range.upperBound...] + let close = try XCTUnwrap(after.range(of: "\n }"), "nextStrapAlarmLabel's closing brace not found") + let body = String(after[.. Date? {") else { + return XCTFail("nextStrapAlarm funnel not found") + } + let after = src[funnel.upperBound...] + let close = try XCTUnwrap(after.range(of: "\n }"), "nextStrapAlarm's closing brace not found") + let body = String(after[.. String? {") else { + return XCTFail("strapAlarmCountdown not found") + } + let after = src[range.upperBound...] + let close = try XCTUnwrap(after.range(of: "\n }"), "strapAlarmCountdown's closing brace not found") + let body = String(after[.. [String] { + var out: [String] = [] + var rest = Substring(src) + while let hit = rest.range(of: ".accessibilityLabel(\"") { + let after = rest[hit.upperBound...] + guard let close = after.range(of: "\"") else { break } + let literal = String(after[.. String { + guard let start = src.range(of: "private var windowHero: some View {") else { + throw SourceNotReachable(path: "windowHero not found in SmartAlarmView.swift") + } + let after = src[start.upperBound...] + guard let end = after.range(of: "\n }") else { + throw SourceNotReachable(path: "windowHero's closing brace not found") + } + return String(after[.. String { + var dir = URL(fileURLWithPath: "\(file)").deletingLastPathComponent() + for _ in 0..<4 { + let candidate = dir.appendingPathComponent("Strand/Screens/SmartAlarmView.swift") + if FileManager.default.fileExists(atPath: candidate.path) { + return try String(contentsOf: candidate, encoding: .utf8) + } + dir = dir.deletingLastPathComponent() + } + throw SourceNotReachable(path: "\(file)") + } +} 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/BackfillerHexDumpBudgetTests.swift b/StrandTests/BackfillerHexDumpBudgetTests.swift new file mode 100644 index 0000000000..2bef0f82e5 --- /dev/null +++ b/StrandTests/BackfillerHexDumpBudgetTests.swift @@ -0,0 +1,135 @@ +import XCTest +@testable import Strand +import WhoopProtocol +import WhoopStore + +/// #1992: the reject hex dump spends ONE budget for the connection, not a fresh per-chunk allowance. +/// +/// The dump is the only channel that carries an unmapped layout's raw bytes to someone who can map it. +/// Bounded per chunk with no session cap, it defeated itself on exactly the straps it exists for: ~25 +/// rejects per chunk, many chunks, many sessions per connection, 8 long hex lines each time, flooding a +/// 2000-line rolling log and evicting its own earlier dumps. +/// +/// Kotlin twin: `RejectHexDumpBudgetTest`, which mirrors the six arithmetic cases. The scope test at +/// the end has NO Kotlin counterpart on purpose: constructing an Android `Backfiller` in a plain JVM +/// test needs a repository over a 152-method DAO with no fake in the tree, whereas Swift's store is a +/// protocol with existing test fakes. The invariant is identical on both sides; only one platform can +/// currently assert it. +final class BackfillerHexDumpBudgetTests: XCTestCase { + + private let cap = 8 + + @MainActor func testAChunkNeverDumpsMoreThanThePerChunkCap() { + XCTAssertEqual(Backfiller.hexDumpAllowance(50, 24), cap) + } + + @MainActor func testAChunkNeverDumpsMoreThanItRejected() { + XCTAssertEqual(Backfiller.hexDumpAllowance(3, 24), 3) + } + + /// The point of the change: successive chunks drain one budget rather than each getting a fresh 8. + @MainActor func testSuccessiveChunksDrainTheOneBudget() { + var budget = Backfiller.rejectHexDumpBudget + var dumped = 0 + for _ in 0 ..< 10 { + let n = Backfiller.hexDumpAllowance(25, budget) + dumped += n + budget -= n + } + XCTAssertEqual(dumped, Backfiller.rejectHexDumpBudget, + "ten chunks must not exceed the one budget") + XCTAssertEqual(budget, 0) + } + + /// Once spent, later chunks dump nothing, which is what stops the flood. + @MainActor func testAnExhaustedBudgetDumpsNothing() { + XCTAssertEqual(Backfiller.hexDumpAllowance(25, 0), 0) + } + + /// Never negative, however the counters are driven. + @MainActor func testTheAllowanceIsNeverNegative() { + XCTAssertEqual(Backfiller.hexDumpAllowance(0, 24), 0) + XCTAssertEqual(Backfiller.hexDumpAllowance(25, -5), 0) + } + + /// The budget must clear more than one chunk, or the sample cannot span an offload. + @MainActor func testTheBudgetIsWorthMoreThanOneChunk() { + XCTAssertGreaterThan(Backfiller.rejectHexDumpBudget, cap) + } + + // MARK: - The budget's SCOPE, which the arithmetic above cannot pin + + /// A minimal store, mirroring the fakes the other Backfiller suites use. The protocol has no default + /// implementations, so every requirement is stubbed here. + private final class NoopStore: BackfillStoreWriting { + @discardableResult + func insert(_ streams: Streams, deviceId: String) async throws + -> (hr: Int, rr: Int, events: Int, battery: Int, + spo2: Int, skinTemp: Int, resp: Int, gravity: Int) { + (0, 0, 0, 0, 0, 0, 0, 0) + } + func enqueueRawBatch(_ meta: RawBatchMeta, frames: [[UInt8]]) async throws {} + func setCursor(_ name: String, _ value: Int) async throws {} + func cursor(_ name: String) async throws -> Int? { nil } + } + + /// The budget must NOT reset when a session begins. + /// + /// This is the half of the change the pure arithmetic cannot see: `hexDumpAllowance` behaves + /// identically whether the budget is per session or per connection, so a test of it alone passes + /// either way. The auto-continue re-kicks up to 24 sessions per connection, so a per-session budget + /// would permit 24 x 24 frames and flood the rolling log exactly as before, which is the shape the + /// reporter hit. Same reasoning as `lastAckedTrim`, which is likewise not reset in `begin()`. + @MainActor func testBeginDoesNotRefillTheBudget() { + let backfiller = Backfiller(store: NoopStore(), deviceId: "test", ackTrim: { _, _ in }, log: { _ in }) + XCTAssertEqual(backfiller.rejectHexBudget, Backfiller.rejectHexDumpBudget) + + backfiller.begin(family: .whoop4) + XCTAssertEqual(backfiller.rejectHexBudget, Backfiller.rejectHexDumpBudget, + "a fresh Backfiller starts full, so this only shows begin() did not zero it") + + // A second session must inherit whatever the first spent, not start over. + backfiller.begin(family: .whoop4) + XCTAssertEqual(backfiller.rejectHexBudget, Backfiller.rejectHexDumpBudget, + "begin() must never refill the budget: the rolling log it protects belongs to " + + "the process, not to one offload session") + } + + // MARK: - #891: the unmapped-type dump line + + /// The census names a type; this has to find the bytes that earned the name, and the byte count is + /// derived from those bytes so the number and the payload beside it cannot disagree. + @MainActor func testUnmappedTypeDumpLineSelectsTheMatchingFrame() { + let frames: [[UInt8]] = [[0x01, 0x02], [0xaa, 0xbb, 0xcc, 0xdd]] + let names = ["HISTORICAL_DATA", "type53"] + let line = Backfiller.unmappedTypeDumpLine(typeName: "type53", frames: frames, typeNames: names) + XCTAssertEqual(line, "Backfill: unmapped type type53 first frame 4B: aabbccdd") + } + + /// The FIRST frame of that type, not the last: a long offload of one unmapped type costs one dump. + @MainActor func testUnmappedTypeDumpLineTakesTheFirstMatch() { + let frames: [[UInt8]] = [[0x11], [0x22]] + let names = ["type53", "type53"] + let line = Backfiller.unmappedTypeDumpLine(typeName: "type53", frames: frames, typeNames: names) + XCTAssertEqual(line, "Backfill: unmapped type type53 first frame 1B: 11") + } + + /// No frame of that type in this chunk means no line, rather than an empty dump that reads like the + /// strap sent nothing. This is the case that would have shipped silently: reading `ParsedFrame.rawHex` + /// here returns "" on the ingest fast path (`collectFields: false`), so every real offload would have + /// logged a dump with no bytes in it while any test building its own ParsedFrame passed. + @MainActor func testUnmappedTypeDumpLineIsNilWhenNoFrameMatches() { + let frames: [[UInt8]] = [[0x01, 0x02]] + XCTAssertNil(Backfiller.unmappedTypeDumpLine(typeName: "type53", frames: frames, + typeNames: ["HISTORICAL_DATA"])) + } + + /// The full frame rides the line - no prefix cap, for the reason the reject dump has none: an unmapped + /// layout's fields are as likely to sit in the tail as the head. + @MainActor func testUnmappedTypeDumpLineDoesNotTruncate() { + let raw = [UInt8](repeating: 0xab, count: 600) + let line = Backfiller.unmappedTypeDumpLine(typeName: "type53", frames: [raw], typeNames: ["type53"]) + XCTAssertTrue(line?.hasSuffix(String(repeating: "ab", count: 600)) == true) + XCTAssertTrue(line?.contains("600B") == true) + } +} 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/BackupWriteIntegrityTests.swift b/StrandTests/BackupWriteIntegrityTests.swift new file mode 100644 index 0000000000..b2e9074770 --- /dev/null +++ b/StrandTests/BackupWriteIntegrityTests.swift @@ -0,0 +1,118 @@ +import XCTest +import ZIPFoundation +@testable import Strand + +/// The WRITE side of #1014: what an export checks about the file it just produced. +/// +/// `writeVerifiedBackupZip` has re-opened every `.noopbak` it writes since #1014 and refused to leave a +/// torn one behind, and until now nothing tested that it does. These are those tests, driven against +/// hand-built archives through the extracted `DataBackup.verifyWrittenBackup(at:)`. +/// +/// Twin of the Android `DataBackupWriteIntegrityTest`. The two platforms answer the same question by +/// different means, which is the point worth pinning: opening a ZIPFoundation `Archive` parses the +/// central directory and so fails on a torn file for free, whereas Android's `ZipInputStream` walks +/// local headers front to back and cannot see truncation at all, and has to look for the end record +/// itself. +final class BackupWriteIntegrityTests: XCTestCase { + + private var tmp: URL! + + override func setUpWithError() throws { + tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("noop-write-integrity-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: tmp) + } + + /// A body that will not deflate away, so the archive has a realistic size to truncate. + private func incompressible(_ n: Int) -> Data { + Data((0.. URL { + let db = tmp.appendingPathComponent("source.sqlite") + var bytes = Data("SQLite format 3".utf8) + bytes.append(0x00) + bytes.append(incompressible(8_192)) + try (dbBody ?? bytes).write(to: db) + let dest = tmp.appendingPathComponent("good.noopbak") + try DataBackup.writeBackupForTesting(databaseAt: db, to: dest) + return dest + } + + func testAWholeBackupIsIntact() throws { + XCTAssertEqual(DataBackup.verifyWrittenBackup(at: try makeBackup()), .intact) + } + + /// The shape the check exists for: a write cut short by a full disk or a dropped upload. The index + /// a ZIP keeps at its END goes with it, so the archive will not open at all. + func testATruncatedBackupIsNotIntact() throws { + let backup = try makeBackup() + let whole = try Data(contentsOf: backup) + XCTAssertGreaterThan(whole.count, 600, "precondition: enough bytes that a third is a real cut") + try whole.prefix(whole.count / 3).write(to: backup) + XCTAssertEqual(DataBackup.verifyWrittenBackup(at: backup), .torn) + } + + /// Even one byte short: the central directory no longer adds up, so the archive does not open. + func testABackupMissingItsLastByteIsNotIntact() throws { + let backup = try makeBackup() + let whole = try Data(contentsOf: backup) + try whole.prefix(whole.count - 1).write(to: backup) + XCTAssertEqual(DataBackup.verifyWrittenBackup(at: backup), .torn) + } + + /// A perfectly valid archive that simply does not carry a database. The container is fine; the + /// contents are not what a restore needs, and calling it a good backup would be the same lie. + func testAnArchiveWithoutTheDatabaseEntryIsNotIntact() throws { + let dest = tmp.appendingPathComponent("no-db.noopbak") + let archive = try Archive(url: dest, accessMode: .create) + let payload = tmp.appendingPathComponent("settings.json") + try Data("{}".utf8).write(to: payload) + try archive.addEntry(with: "settings.json", fileURL: payload, compressionMethod: .deflate) + XCTAssertEqual(DataBackup.verifyWrittenBackup(at: dest), .torn) + } + + /// Shorter than SQLite's own file header, so it cannot be a database whatever else it looks like. + /// Guards the size floor specifically: the entry IS present and IS named correctly here. + func testADatabaseEntryBelowTheHeaderSizeIsNotIntact() throws { + let dest = tmp.appendingPathComponent("tiny.noopbak") + let archive = try Archive(url: dest, accessMode: .create) + let payload = tmp.appendingPathComponent("tiny.sqlite") + try Data("SQLite format 3\u{0}".utf8).write(to: payload) + XCTAssertLessThan(16, Int(DataBackup.minimumBackupEntryBytes), + "precondition: the magic alone is shorter than the floor, so this tests the floor") + try archive.addEntry(with: "noop-backup.sqlite", fileURL: payload, compressionMethod: .deflate) + XCTAssertEqual(DataBackup.verifyWrittenBackup(at: dest), .torn) + } + + /// Never an archive at all: answered with a verdict rather than thrown, which is the contract. + func testSomethingThatIsNotAnArchiveIsNotIntact() throws { + let junk = tmp.appendingPathComponent("junk.noopbak") + // Readable, so we DID look; it simply is not an archive. That is a torn verdict, not an + // unverifiable one, and the distinction decides whether the caller deletes it. + try Data(repeating: 0x41, count: 4_096).write(to: junk) + XCTAssertEqual(DataBackup.verifyWrittenBackup(at: junk), .torn) + } + + /// The distinction the verdict exists for, and the Swift twin of the Android verdict-table test. + /// + /// A file we cannot read tells us NOTHING about its contents, and the caller's answer to `torn` is to + /// delete. Before this the check returned a plain Bool, so a transient read failure and a genuinely + /// cut-short archive were the same answer, and the destructive one. + func testAFileThatCannotBeReadIsUnverifiableRatherThanTorn() { + XCTAssertEqual(DataBackup.verifyWrittenBackup(at: tmp.appendingPathComponent("absent.noopbak")), + .unverifiable) + } + + /// The convenience wrapper keeps agreeing with the verdict it delegates to. + func testTheBooleanWrapperTracksTheVerdict() throws { + let good = try makeBackup() + XCTAssertTrue(DataBackup.writtenBackupIsIntact(at: good)) + XCTAssertFalse(DataBackup.writtenBackupIsIntact(at: tmp.appendingPathComponent("absent.noopbak"))) + } +} diff --git a/StrandTests/BodyClockDialLayoutTests.swift b/StrandTests/BodyClockDialLayoutTests.swift new file mode 100644 index 0000000000..c4b174c751 --- /dev/null +++ b/StrandTests/BodyClockDialLayoutTests.swift @@ -0,0 +1,138 @@ +import XCTest +@testable import Strand + +/// The body clock dial's radial bands may not overlap (#2350). +/// +/// The reported symptom was that the "your clock" arc read as radial hash marks running into the six-hour +/// ticks. Two separate causes: the arc was stroked at the SAME radius the ticks occupy, and its dashes +/// were shorter than the stroke was wide, so each one rendered as a stubby rectangle pointing outwards. +/// +/// Neither is catchable by compiling, and neither is visible in a unit test either. What IS checkable is +/// the arithmetic: given the named radii and widths, no two bands may share space, and a dash must be +/// longer than its stroke is wide. That is the part of "does this look right" a test can honestly own. +/// Whether the result READS well still needs eyes on a device. +/// +/// Twin of the Kotlin `BodyClockDialLayoutTest`, same numbers, since the two dials are drawn to one spec. +final class BodyClockDialLayoutTests: XCTestCase { + + /// A band is the radial span a drawn element occupies, from the dial centre outwards. + private struct Band { + let name: String + let from: Double + let to: Double + } + + private let tunedRim = 90.0 // side/2 - 10 at the card's 200 pt height + private let midnightTick = 6.0 + private let labelHeight = 11.0 // caption numerals, generously rounded up + private let referenceWidth = 7.0 + private let nightWidth = 9.0 + private let glyph = 12.0 + + /// Mirrors `DialGeometry`: the offsets are a fraction of the radius, so the bands scale with it. + private func bands(rim: Double) -> [Band] { + let k = rim / tunedRim + return [ + Band(name: "ticks", from: rim - midnightTick * k, to: rim), + Band(name: "labels", + from: (rim - 15 * k) - labelHeight * k / 2, to: (rim - 15 * k) + labelHeight * k / 2), + Band(name: "reference arc", + from: (rim - 31 * k) - referenceWidth * k / 2, to: (rim - 31 * k) + referenceWidth * k / 2), + Band(name: "night arc", + from: (rim - 45 * k) - nightWidth * k / 2, to: (rim - 45 * k) + nightWidth * k / 2), + Band(name: "bed glyph", + from: (rim - 58 * k) - glyph * k / 2, to: (rim - 58 * k) + glyph * k / 2), + ] + } + + /// Rims a real card can actually be handed, from a narrow split pane up to the tuned size. + private let rimsToCheck = [15.0, 25.0, 40.0, 50.0, 70.0, 90.0] + + func testNoTwoBandsOverlapAtAnySize() { + for rim in rimsToCheck { + let ordered = bands(rim: rim) + for (outerBand, innerBand) in zip(ordered, ordered.dropFirst()) { + let gap = outerBand.from - innerBand.to + XCTAssertGreaterThan( + gap, 0, + "at rim \(rim), \(innerBand.name) runs into \(outerBand.name): gap is \(gap)") + } + } + } + + /// The innermost element must clear the centre AT EVERY SIZE, not only the tuned one. + /// + /// With fixed offsets the nesting depth stayed 58 pt however small the card got, so a narrow one + /// drove the bed through the centre and the night arc to a negative radius, which is undefined for + /// `addArc` and draws nothing in the Kotlin twin. Checking a single rim asserted the design was + /// sound at one size and said nothing about the range, which is the shape of test that lets it by. + func testTheInnermostBandClearsTheCentreAtAnySize() { + for rim in rimsToCheck { + let innermost = bands(rim: rim)[bands(rim: rim).count - 1] + XCTAssertGreaterThan( + innermost.from, 0, + "at rim \(rim), \(innermost.name) reaches the centre (from \(innermost.from))") + } + } + + /// A dash shorter than the stroke is wide is not a dash, it is a hash mark pointing outwards. The + /// first attempt at this dial used a 3 pt dash under a 7 pt stroke; an earlier one used a round cap, + /// which adds width/2 of ink at EACH end of EVERY dash and closed the gaps entirely. The invariant + /// that survives both is simply that the dash must be the longer of the two. + func testTheReferenceDashIsLongerThanItIsWide() { + let dashLength = 10.0 + XCTAssertGreaterThan( + dashLength, referenceWidth, + "a \(dashLength) pt dash under a \(referenceWidth) pt stroke reads as a hash mark") + } + + /// The bed marks onset, so it must sit off the night arc rather than on top of the end it marks. + func testTheBedGlyphSitsOffTheNightArc() { + for rim in rimsToCheck { + let night = bands(rim: rim).first { $0.name == "night arc" }! + let bed = bands(rim: rim).first { $0.name == "bed glyph" }! + XCTAssertLessThan(bed.to, night.from, "at rim \(rim) the bed overlaps the arc it marks") + } + } + + /// The numerals must be a FIXED size, not a scaling one. + /// + /// The band they sit in has 3.5 pt of clearance to the ticks, and the dial's radii do not scale with + /// the reader's Dynamic Type setting. `StrandFont.caption` is a text STYLE and would grow at + /// accessibility sizes straight into the ticks, which is the collision this whole change removes. + func testTheHourNumeralsDoNotScaleWithDynamicType() throws { + let src = try Self.cardSource() + XCTAssertTrue(src.contains(".font(.system(size: 10, design: .rounded))"), + "dial numerals need a fixed point size, not a Dynamic Type text style") + // CODE only. The first cut scanned the whole file, so the comment ABOVE the numerals, which + // names `StrandFont.caption` to explain why it is not used, failed the assertion that it is not + // used. An assertion whose scope is wider than the claim it makes will eventually be tripped by + // something describing the claim. + XCTAssertFalse(Self.codeLines(of: src).contains { $0.contains(".font(StrandFont.caption)") }, + "a text style in a fixed-radius canvas grows into the ticks at large text sizes") + } + + /// Source lines with comment-only lines dropped, so prose about the rule cannot trip the rule. + private static func codeLines(of src: String) -> [String] { + src.split(separator: "\n", omittingEmptySubsequences: false) + .map(String.init) + .filter { !$0.trimmingCharacters(in: .whitespaces).hasPrefix("//") } + } + + private struct SourceNotReachable: Error, CustomStringConvertible { + let path: String + var description: String { "BodyClockDialCard.swift not reachable from \(path)" } + } + + private static func cardSource(file: StaticString = #filePath) throws -> String { + var dir = URL(fileURLWithPath: "\(file)").deletingLastPathComponent() + for _ in 0..<4 { + let candidate = dir.appendingPathComponent("Strand/Screens/BodyClockDialCard.swift") + if FileManager.default.fileExists(atPath: candidate.path) { + return try String(contentsOf: candidate, encoding: .utf8) + } + dir = dir.deletingLastPathComponent() + } + throw SourceNotReachable(path: "\(file)") + } +} diff --git a/StrandTests/BondErrorTokenTests.swift b/StrandTests/BondErrorTokenTests.swift new file mode 100644 index 0000000000..91f99fd7b3 --- /dev/null +++ b/StrandTests/BondErrorTokenTests.swift @@ -0,0 +1,52 @@ +import XCTest +import CoreBluetooth +@testable import Strand + +/// #1635 / #78 hole-1: a BLE failure line has to carry something a reader in ANY locale can match. +/// +/// Foundation localizes CoreBluetooth error strings. `isInsufficientAuthError` already documents what +/// that costs: the old `localizedDescription.contains("encryption")` classification silently never +/// matched on a non-English device, for the entire localized user base. The classification was made +/// code-first then; the LOGGING still said only what the phone's locale said, so a shared strap log from +/// a French phone carried a refusal nobody triaging could match. +/// +/// Android was always immune because it matches GATT status ints, which is why this exists: the tokens +/// line up with `gattWriteStatusLabel`'s 5 and 15 by construction. +final class BondErrorTokenTests: XCTestCase { + + /// The two codes that mean "the strap refused the encrypted bond" are exactly Android's 5 and 15. + /// Pinned as numbers, because the whole point is that the two platforms' logs can be read the same way. + func testRefusalCodesMatchTheAndroidGattStatuses() { + XCTAssertEqual(CBATTError.Code.insufficientAuthentication.rawValue, 5) + XCTAssertEqual(CBATTError.Code.insufficientEncryption.rawValue, 15) + XCTAssertEqual(BLEManager.bleErrorToken(CBATTError(.insufficientAuthentication)), "cbAttError5") + XCTAssertEqual(BLEManager.bleErrorToken(CBATTError(.insufficientEncryption)), "cbAttError15") + } + + /// The suffix is additive and bracketed, so it can be spotted in a line whose other half is localized. + func testSuffixIsBracketedAndAppendable() { + XCTAssertEqual(BLEManager.bleErrorSuffix(CBATTError(.insufficientAuthentication)), " [cbAttError5]") + } + + /// No error means no suffix: a clean disconnect line is unchanged, so this adds nothing to the + /// overwhelmingly common healthy path. + func testNoErrorAddsNothing() { + XCTAssertEqual(BLEManager.bleErrorSuffix(nil), "") + } + + /// An error from neither CoreBluetooth domain reads `code?` rather than leaking free text. Some + /// CoreBluetooth paths do surface plain NSErrors, which is why the description is kept alongside + /// rather than replaced. + func testForeignErrorDomainCarriesNoFreeText() { + let foreign = NSError(domain: "com.example.other", code: 42, + userInfo: [NSLocalizedDescriptionKey: "some localized text"]) + XCTAssertEqual(BLEManager.bleErrorSuffix(foreign), " [code?]") + XCTAssertFalse(BLEManager.bleErrorSuffix(foreign).contains("localized")) + } + + /// The CBError domain is tokenised too, so a connect failure is as matchable as a write failure. + func testConnectionDomainErrorsAreTokenised() { + XCTAssertEqual(BLEManager.bleErrorToken(CBError(.connectionTimeout)), + "cbError\(CBError.Code.connectionTimeout.rawValue)") + } +} 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/BroadcastHrCommandTests.swift b/StrandTests/BroadcastHrCommandTests.swift new file mode 100644 index 0000000000..8c38128a9b --- /dev/null +++ b/StrandTests/BroadcastHrCommandTests.swift @@ -0,0 +1,23 @@ +import Foundation +import XCTest +@testable import Strand + +final class BroadcastHrCommandTests: XCTestCase { + func testWhoop4EnableFrameMatchesExpectedBytes() { + XCTAssertEqual( + WhoopCommand.toggleGenericHRProfile.frame(seq: 8, payload: [1]).hex, + "aa0800a823080e016c935474" + ) + } + + func testWhoop4DisableFrameMatchesExpectedBytes() { + XCTAssertEqual( + WhoopCommand.toggleGenericHRProfile.frame(seq: 7, payload: [0]).hex, + "aa0800a823070e00c7e40f08" + ) + } +} + +private extension Array where Element == UInt8 { + var hex: String { map { String(format: "%02x", $0) }.joined() } +} diff --git a/StrandTests/ChargeBreakdownWiringTests.swift b/StrandTests/ChargeBreakdownWiringTests.swift new file mode 100644 index 0000000000..fb038774ea --- /dev/null +++ b/StrandTests/ChargeBreakdownWiringTests.swift @@ -0,0 +1,115 @@ +import XCTest +import StrandAnalytics +import WhoopStore +@testable import Strand + +/// Swift twin of the Android `RecoveryDriversUiTest` wiring cases. +/// +/// This suite could not exist before: the derivation lived as a private method inside `TodayView` (and a +/// byte-identical copy inside `CoupledView`), reachable only by rendering the view, so the iOS end of the +/// "What shaped it" path had no test at all while Android's did. `ChargeBreakdownWiring.breakdown` is that +/// derivation lifted out unchanged. +/// +/// Case mapping against the Kotlin suite, stated so the gap is legible rather than implied: +/// * `rhrRowIsAbsentWhenTheRestingHrBaselineIsNotUsable` - twinned below. +/// * `coldStartHistoryProducesNoRows` - twinned below. +/// * `scoredDayProducesDriverRows` - twinned below, and it carries the confidence assertion too, since +/// this API returns the tier in the same tuple where Kotlin has a separate `chargeConfidenceTier`. +/// * `nullDayProducesNoRows` - deliberately NOT twinned. This API takes a non-optional row, so the +/// nil-row guard stays in the view where it belongs; there is nothing here to assert. +final class ChargeBreakdownWiringTests: XCTestCase { + + private func day(_ d: String, hrv: Double? = 55, rhr: Int? = 55, recovery: Double? = nil) -> DailyMetric { + DailyMetric(day: d, totalSleepMin: 450, efficiency: 0.9, deepMin: nil, remMin: nil, lightMin: nil, + disturbances: nil, restingHr: rhr, avgHrv: hrv, recovery: recovery, strain: nil, + exerciseCount: nil) + } + + /// A history with no banked resting HR folds to `foldHistory`'s synthetic midpoint (about 75 bpm), + /// which is nobody's resting HR, so the row must be absent. The HRV row still stands, since that + /// baseline is real. The display day itself carries a reading, so this pins the BASELINE being + /// unusable rather than the reading being missing. + /// + /// The gate is not in this file: `RecoveryScorer.chargeDrivers` applies it (#1990). This pins the end + /// of the path, the surface a user actually sees. + func testRhrRowIsAbsentWhenTheRestingHrBaselineIsNotUsable() { + let history = (1...6).map { day(String(format: "2026-01-%02d", $0), rhr: nil) } + let today = day("2026-01-07", rhr: 55) + let out = ChargeBreakdownWiring.breakdown(days: history + [today], row: today, sleepPerfPercent: 85) + let labels = (out?.drivers ?? []).map(\.label) + XCTAssertTrue(labels.contains("Heart rate variability"), + "the HRV baseline is real, so its row must still be there: \(labels)") + XCTAssertFalse(labels.contains("Resting heart rate"), + "no usable resting-HR baseline, so no RHR row: \(labels)") + } + + /// Two nights only: the HRV baseline is not usable yet, so there are no honest drivers and the sheet + /// hides rather than showing fabricated rows. + func testColdStartHistoryProducesNoBreakdown() { + let days = [day("2026-01-01"), day("2026-01-02")] + XCTAssertNil(ChargeBreakdownWiring.breakdown(days: days, row: days[1], sleepPerfPercent: 85)) + } + + /// The usable-baseline half of the pair: a real history banks both baselines, so both rows appear and + /// the surfaced tier is past calibrating. + func testAScoredDayProducesDriverRowsAndATier() { + let past = (1...10).map { day(String(format: "2026-01-%02d", $0), hrv: 50 + Double($0 % 3)) } + let today = day("2026-01-20", hrv: 62, rhr: 51, recovery: 64) + let out = ChargeBreakdownWiring.breakdown(days: past + [today], row: today, sleepPerfPercent: 85) + XCTAssertNotNil(out) + let labels = (out?.drivers ?? []).map(\.label) + XCTAssertTrue(labels.contains("Heart rate variability"), "\(labels)") + XCTAssertTrue(labels.contains("Resting heart rate"), "\(labels)") + XCTAssertNotEqual(out?.confidence, .calibrating) + } + + /// Epoch for a `yyyy-MM-dd` key at UTC midnight, matching what Recalibrate writes. + private func epochOf(_ day: String) -> Double { + var cal = Calendar(identifier: .gregorian) + cal.timeZone = TimeZone(secondsFromGMT: 0)! + let parts = day.split(separator: "-").compactMap { Int($0) } + let date = cal.date(from: DateComponents(year: parts[0], month: parts[1], day: parts[2]))! + return date.timeIntervalSince1970 + } + + func testDriverBaselineHonoursTheRecalibrationEpoch() { + // The reported shape: low-HRV history, Recalibrate, then higher nights. Folding the WHOLE history + // gives one baseline and folding from the epoch gives another, and the headline uses the second. + // These rows have to agree with the headline rather than with history. + // + // Asserted on `baselineText` because that is what a Swift ChargeDriver carries: it pre-formats for + // display where the Kotlin twin keeps a numeric `baseline`. Different text is the observable proof + // that a different history was folded. + let old = (1...10).map { day(String(format: "2026-01-%02d", $0), hrv: 40) } + let since = (11...20).map { day(String(format: "2026-01-%02d", $0), hrv: 70) } + let today = day("2026-01-21", hrv: 72, recovery: 70) + let days = old + since + [today] + + let whole = ChargeBreakdownWiring.breakdown(days: days, row: today, sleepPerfPercent: 85, + hrvBaselineEpoch: 0) + let fromEpoch = ChargeBreakdownWiring.breakdown(days: days, row: today, sleepPerfPercent: 85, + hrvBaselineEpoch: epochOf("2026-01-11")) + let hrvRow: ((drivers: [ChargeDriver], confidence: ScoreConfidence)?) -> ChargeDriver? = { out in + out?.drivers.first(where: { $0.label == "Heart rate variability" }) + } + guard let w = hrvRow(whole), let r = hrvRow(fromEpoch) else { + return XCTFail("both folds must produce an HRV row") + } + XCTAssertNotEqual(w.baselineText, r.baselineText, + "the epoch fold must discard the pre-recalibration nights, so the baseline it " + + "reports differs from the whole-history one (whole=\(w.baselineText), " + + "fromEpoch=\(r.baselineText))") + } + + func testAnAbsentEpochLeavesTheBaselineExactlyAsItWas() { + // The common case: nobody who never recalibrated may see any change from this. + let past = (1...10).map { day(String(format: "2026-01-%02d", $0), hrv: 50 + Double($0 % 3)) } + let today = day("2026-01-20", hrv: 62, rhr: 51, recovery: 64) + let implicit = ChargeBreakdownWiring.breakdown(days: past + [today], row: today, sleepPerfPercent: 85) + let explicitZero = ChargeBreakdownWiring.breakdown(days: past + [today], row: today, + sleepPerfPercent: 85, hrvBaselineEpoch: 0) + XCTAssertEqual(implicit?.drivers.map(\.label), explicitZero?.drivers.map(\.label)) + XCTAssertEqual(implicit?.drivers.map(\.baselineText), explicitZero?.drivers.map(\.baselineText)) + XCTAssertEqual(implicit?.drivers.map(\.deltaPoints), explicitZero?.drivers.map(\.deltaPoints)) + } +} diff --git a/StrandTests/ClientHelloOutcomeTests.swift b/StrandTests/ClientHelloOutcomeTests.swift new file mode 100644 index 0000000000..215b42e2a2 --- /dev/null +++ b/StrandTests/ClientHelloOutcomeTests.swift @@ -0,0 +1,106 @@ +import XCTest +@testable import Strand + +/// Pins the CLIENT_HELLO outcome line (#1635). Kotlin twin: `ClientHelloOutcomeTest`. +final class ClientHelloOutcomeTests: XCTestCase { + + private let hello = "fd4b0002-cce1-4033-93ce-002d5875f58a" + + func testACompletionFromTheHelloCharacteristicIsARealAck() { + XCTAssertEqual( + ClientHelloOutcome.line(isHelloChar: true, charUuid: hello, elapsedMs: 120, + status: "status=SUCCESS(0)"), + "CLIENT_HELLO outcome: acked by \(hello) after 120ms status=SUCCESS(0)") + } + + func testACompletionFromAnotherCharacteristicIsNamedNotCountedAsAnAck() { + let line = ClientHelloOutcome.line(isHelloChar: false, + charUuid: "00002a19-0000-1000-8000-00805f9b34fb", + elapsedMs: 40, status: "status=SUCCESS(0)") + XCTAssertTrue(line.contains("DIFFERENT characteristic 00002a19-0000-1000-8000-00805f9b34fb"), line) + XCTAssertTrue(line.contains("NOT a CLIENT_HELLO ack"), line) + XCTAssertFalse(line.contains("outcome: acked"), line) + } + + func testNoCallbackAtAllIsReportedWithTheElapsedTime() { + XCTAssertEqual( + ClientHelloOutcome.line(isHelloChar: false, charUuid: nil, elapsedMs: 3200, status: nil), + "CLIENT_HELLO outcome: NO write callback after 3200ms — the link dropped before the stack" + + " reported, so the strap may never have seen it") + } + + func testANilCharacteristicWinsOverTheIsHelloCharFlag() { + XCTAssertTrue(ClientHelloOutcome.line(isHelloChar: true, charUuid: nil, elapsedMs: 10, + status: "status=SUCCESS(0)").contains("NO write callback")) + } + + func testABlankCharacteristicAndStatusDegradeWithoutPunctuationDebris() { + XCTAssertEqual( + ClientHelloOutcome.line(isHelloChar: true, charUuid: " ", elapsedMs: 5, status: " "), + "CLIENT_HELLO outcome: acked by unknown after 5ms") + } + + // MARK: - the bond gate (#1635) + + /// 15:24:13 in the field capture: DISABLE_ALARM was in flight, the CLIENT_HELLO write was rejected so + /// nothing was owed, and DISABLE_ALARM's completion then arrived on fd4b0002 — the SAME characteristic + /// the hello uses — and the link was declared bonded. `isHelloChar` is true here, which is exactly why + /// a uuid check on its own would not have caught it. + func testQueuedCommandOnTheHelloCharacteristicIsNotABond() { + XCTAssertFalse(ClientHelloOutcome.isAck( + isHelloChar: true, helloOutstanding: false, alreadyBonded: false, isWhoop5: true)) + } + + func testForeignCharacteristicInsideTheWindowIsNotABond() { + XCTAssertFalse(ClientHelloOutcome.isAck( + isHelloChar: false, helloOutstanding: true, alreadyBonded: false, isWhoop5: true)) + } + + /// The regression that matters most: the gate must not cost a real bond. + func testGenuineAckStillBonds() { + XCTAssertTrue(ClientHelloOutcome.isAck( + isHelloChar: true, helloOutstanding: true, alreadyBonded: false, isWhoop5: true)) + } + + func testAlreadyBondedDoesNotReBondAndWhoop4NeverTakesThisPath() { + XCTAssertFalse(ClientHelloOutcome.isAck( + isHelloChar: true, helloOutstanding: true, alreadyBonded: true, isWhoop5: true)) + 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..a7f8c85291 --- /dev/null +++ b/StrandTests/CoachConversationDayTests.swift @@ -0,0 +1,160 @@ +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) + } + + // MARK: - #2087: recovering a RESTORED transcript's day from its rows + + /// `conversationDay` lives in memory, so a process restart brought it back nil, and + /// `isStaleConversation` treats nil as "never stale" BY DESIGN (nothing sent yet cannot be). So a + /// conversation restored from any previous day was never retired, by `send` or by anything else, + /// and the scheduled brief, which only surfaces onto an EMPTY transcript, could never appear again + /// after the first day. The day has to come back from the stored rows' `createdAt`. + /// + /// Twin of the Kotlin `a transcript written last night is stale after local midnight`. + func testATranscriptWrittenLastNightIsStaleAfterLocalMidnight() { + // A row written at 23:00 UTC on the 10th, the way a late-evening turn is stored. + var comps = DateComponents() + comps.year = 2026; comps.month = 9; comps.day = 10; comps.hour = 23 + let createdAt = Int(utc.date(from: comps)!.timeIntervalSince1970) + + let lastDay = AICoachEngine.localEpochDay(Date(timeIntervalSince1970: TimeInterval(createdAt)), + calendar: utc) + XCTAssertEqual(lastDay, day(2026, 9, 10)) + XCTAssertTrue(AICoachEngine.isStaleConversation(lastEpochDay: lastDay, + todayEpochDay: day(2026, 9, 11))) + XCTAssertFalse(AICoachEngine.isStaleConversation(lastEpochDay: lastDay, + todayEpochDay: day(2026, 9, 10))) + } + + /// The newest row decides the day, not the oldest: a conversation started yesterday and continued + /// past midnight belongs to today, and must NOT be retired out from under the user. + func testTheNewestRowDecidesTheTranscriptDay() { + func at(_ y: Int, _ m: Int, _ d: Int, _ h: Int) -> Int { + var c = DateComponents(); c.year = y; c.month = m; c.day = d; c.hour = h + return Int(utc.date(from: c)!.timeIntervalSince1970) + } + let rows = [at(2026, 9, 10, 23), at(2026, 9, 11, 0)] + let newest = rows.max()! + let lastDay = AICoachEngine.localEpochDay(Date(timeIntervalSince1970: TimeInterval(newest)), + calendar: utc) + XCTAssertEqual(lastDay, day(2026, 9, 11)) + XCTAssertFalse(AICoachEngine.isStaleConversation(lastEpochDay: lastDay, + todayEpochDay: day(2026, 9, 11))) + } +} diff --git a/StrandTests/CollectorStandardHRContactTests.swift b/StrandTests/CollectorStandardHRContactTests.swift new file mode 100644 index 0000000000..e8746365fd --- /dev/null +++ b/StrandTests/CollectorStandardHRContactTests.swift @@ -0,0 +1,124 @@ +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")] + ) + ]) + } + + func testRrFamilyIsCapturedAtIngressAndSurvivesRetry() async { + let store = CaptureStore() + let collector = Collector(store: store, deviceId: "strap") + collector.ingestStandardHR(hr: 60, rr: [1000], family: .whoop5, at: 100) + collector.ingestStandardHR(hr: 60, rr: [1001], family: .whoop4, at: 101) + collector.ingestStandardHR(hr: 60, rr: [1002], at: 102) + store.failNextInsert = true + await collector.flushStandardHR() + XCTAssertTrue(store.inserted.isEmpty) + await collector.flushStandardHR() + XCTAssertEqual(store.inserted.flatMap(\.rr).map(\.srcChannel), [.whoop5Standard, nil, nil]) + XCTAssertEqual(store.inserted.flatMap(\.rr).map(\.rrMs), [1000, 1001, 1002]) + } +} diff --git a/StrandTests/DailySkinTempAbsoluteCarryTests.swift b/StrandTests/DailySkinTempAbsoluteCarryTests.swift new file mode 100644 index 0000000000..6ac4faf08d --- /dev/null +++ b/StrandTests/DailySkinTempAbsoluteCarryTests.swift @@ -0,0 +1,137 @@ +import XCTest +import WhoopStore +@testable import Strand + +/// The nightly absolute (#1636) must survive every path that REBUILDS a `DailyMetric`. +/// +/// The column is written once, on the scoring pass, and then carried by separate merges before it +/// reaches a screen. Each of those spells its fields out by name, so a new column is dropped by +/// omission rather than by a compile error — a value that persists correctly and then disappears on the +/// way to being read, which no migration test would catch. +/// +/// Twin of the Kotlin `DailySkinTempAbsoluteCarryTest`, with two tests that have no Kotlin counterpart: +/// the `with(...)` rebuild helpers are a Swift-only shape (Kotlin's data-class `copy` carries every field +/// 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, + 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. + func testCoalesceTakesTheStrapAbsoluteWhenTheWinnerHasNone() throws { + let winner = row(skinTempDevC: 0.2, avgHrv: 44.0) // an import: deviation only + let filler = row(skinTempC: 34.6, skinTempDevC: 0.2) // the strap's own scored night + XCTAssertEqual(try XCTUnwrap(Repository.coalesceDay(winner, filler).skinTempC), + 34.6, accuracy: 0.001) + } + + /// A winner that HAS one keeps it — the filler must never overwrite a measured value. + func testCoalesceKeepsTheWinnersOwnAbsolute() throws { + let winner = row(skinTempC: 34.6) + let filler = row(skinTempC: 30.1) + XCTAssertEqual(try XCTUnwrap(Repository.coalesceDay(winner, filler).skinTempC), + 34.6, accuracy: 0.001) + } + + func testCoalesceLeavesItNilWhenNeitherSideMeasuredOne() { + XCTAssertNil(Repository.coalesceDay(row(), row()).skinTempC) + } + + /// The sleep-edit rebuild replaces only sleep-derived fields; a thermal column must ride through. + func testASleepEditKeepsTheNightsAbsolute() throws { + let scored = row(skinTempC: 34.6, skinTempDevC: 0.2) + let edited = scored.with(totalSleepMin: 400, efficiency: 0.93, + deepMin: 80, remMin: 100, lightMin: 220) + XCTAssertEqual(try XCTUnwrap(edited.skinTempC), 34.6, accuracy: 0.001, + "editing the sleep window must not discard the night's temperature") + } + + /// Cross-bucket: imports win the row, but the absolute is on-device only, so the computed value + /// has to come through or a user with any WHOOP-export history would never see one. + func testMergeFillsTheAbsoluteFromTheComputedRow() throws { + let imported = [row(skinTempDevC: 0.2)] + let computed = [row(skinTempC: 34.6, skinTempDevC: 0.2)] + let merged = Repository.mergeDaily(imported: imported, computed: computed) + XCTAssertEqual(merged.count, 1) + XCTAssertEqual(try XCTUnwrap(merged.first?.skinTempC), 34.6, accuracy: 0.001) + // And the deviation every downstream gate reads is untouched by carrying the absolute. + XCTAssertEqual(try XCTUnwrap(merged.first?.skinTempDevC), 0.2, accuracy: 0.001) + } + + /// Re-scoring writes both thermal values together; neither may clobber the other. + func testScoringWritesTheAbsoluteAndDeviationTogether() throws { + let scored = row().with(recovery: 0.71, skinTempDevC: 0.52, skinTempC: 34.6) + XCTAssertEqual(try XCTUnwrap(scored.skinTempC), 34.6, accuracy: 0.001) + 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/DayCacheConfigFieldTests.swift b/StrandTests/DayCacheConfigFieldTests.swift new file mode 100644 index 0000000000..483c2bfc4f --- /dev/null +++ b/StrandTests/DayCacheConfigFieldTests.swift @@ -0,0 +1,81 @@ +import XCTest +@testable import Strand + +/// Swift twin of the Kotlin `DayCacheConfigFieldTest`: which config field dropped the day cache. +/// +/// #2073 made a wholesale drop visible (`configDropped`), and a field log on build 487 showed it firing +/// for real: `reused=0/21 missBy=absent:21,configDropped:1`, against a healthy pass that reuses 20 of 21 +/// and misses only today. The expensive passes cost up to 35 s of prep and 22 s of scoring, so which +/// field moved is the difference between a config the user actually changed and a value that drifts on +/// its own. +/// +/// The signature is a plain "|"-joined value list, and the field NAMES live beside the reader rather than +/// the construction, which on the Kotlin side keeps them off a bytecode ratchet. That split is the risk +/// pinned here: a reader that guessed an index would name the wrong field and send someone hunting a bug +/// that is not there, which is worse than saying nothing. +final class DayCacheConfigFieldTests: XCTestCase { + + /// A signature with one value per known field, so a changed index has a name to resolve to. + private func fullSig(_ mutate: (inout [String]) -> Void = { _ in }) -> String { + var v = (0.. BaselineState { + BaselineState(baseline: mean, spread: spread, + nValid: status == .calibrating ? 3 : 14, nightsSinceUpdate: status == .stale ? 15 : 0, + status: status) + } + + private func recoveryDailyFixture() -> DailyMetric { + DailyMetric(day: "2026-09-09", totalSleepMin: 420, efficiency: 0.85, + deepMin: 80, remMin: 90, lightMin: 250, disturbances: 2, + restingHr: 58, avgHrv: 48, recovery: 99, strain: 61, exerciseCount: 2, + spo2Pct: 97, skinTempDevC: nil, respRateBpm: 15, steps: 42, activeKcalEst: 1_840, + spo2Red: 100, spo2Ir: 200, avgSdnn: 44, skinTempC: nil, sleepHrOnly: true) + } + + private func expectedRecovery(_ daily: DailyMetric, baselines: AnalyticsEngine.ProfileBaselines, + skinDev: Double?) -> Double? { + RecoveryScorer.recovery( + hrv: 48, rhr: 58, resp: 15, hrvBaseline: baselines.hrv!, rhrBaseline: nil, + respBaseline: nil, + sleepPerf: AnalyticsEngine.Rest.composite(daily: daily).map { $0 / 100.0 } ?? daily.efficiency, + skinTempDev: skinDev) + } +} diff --git a/StrandTests/DetectedWorkoutReconciliationTests.swift b/StrandTests/DetectedWorkoutReconciliationTests.swift new file mode 100644 index 0000000000..dbb14d7646 --- /dev/null +++ b/StrandTests/DetectedWorkoutReconciliationTests.swift @@ -0,0 +1,263 @@ +import XCTest +import Foundation +import WhoopProtocol +import WhoopStore +import StrandAnalytics +@testable import Strand + +/// #1735/#2187: the analytics detector may enrich workouts the user already logged, but the opt-in +/// confirmation card is the only path that creates a new visible automatic workout. A scoring pass must +/// therefore preserve legacy `sport="detected"` history verbatim, avoid inserting a fresh generic row, +/// and keep the useful overlap backfill for real manual/imported sessions. +@MainActor +final class DetectedWorkoutReconciliationTests: XCTestCase { + private let deviceId = "my-whoop" + + private func withPreferences(_ body: () async throws -> Void) async throws { + let defaults = UserDefaults.standard + let keys = [ + "profile.dateOfBirth", "profile.age", "profile.sex", "profile.weightKg", + "profile.heightCm", "profile.hrMaxOverride", "profile.stepTicksPerStep", + "noop.analyzeWatermark", "analyzeRecent.stepsMotionCache.v1", + "noop.hrvBaselineEpoch", "noop.recoveryBaselineEpoch", UnitPrefs.hrvWindowKey, + RescoreBackgroundScheduler.owedKey, RescoreBackgroundScheduler.owedTokenKey, + RescoreBackgroundScheduler.lastPassSecondsKey, DayCycleMode.storageKey, + PuffinExperiment.experimentalSleepV2Key, PuffinExperiment.motionAwareWakeKey, + PuffinExperiment.autoDetectWorkoutsKey, + WorkoutSource.dismissedDefaultsKey, + "testcentre.active.workouts", "testcentre.active.master", + ] + let saved = keys.map { ($0, defaults.object(forKey: $0)) } + defer { + for (key, value) in saved { + if let value { defaults.set(value, forKey: key) } + else { defaults.removeObject(forKey: key) } + } + } + for key in keys { defaults.removeObject(forKey: key) } + defaults.set(DayCycleMode.midnight.rawValue, forKey: DayCycleMode.storageKey) + defaults.set(true, forKey: PuffinExperiment.experimentalSleepV2Key) + defaults.set(false, forKey: PuffinExperiment.motionAwareWakeKey) + defaults.set(false, forKey: PuffinExperiment.autoDetectWorkoutsKey) + TestCentre.activate(.workouts) + try await body() + } + + private func activeBlock(start: Int, seconds: Int) -> (hr: [HRSample], gravity: [GravitySample]) { + let hr = (0.. String = { sec in + if sec < 60 { return "just now" } + let m = Int(sec / 60) + if m < 60 { return "\(m)m ago" } + if m < 1440 { return "\(m / 60)h \(m % 60)m ago" } + return "\(m / 1440)d ago" + } + + private func row(_ id: String, _ status: String, _ seen: Int, + model: String = "WHOOP 4.0", fw: String? = nil) -> InventoryRow { + InventoryRow(id: id, brand: "WHOOP", model: model, status: status, lastSeenAt: seen, firmware: fw) + } + + func testAnEmptyRegistrySaysSoRatherThanPrintingABareHeader() { + XCTAssertEqual(deviceInventoryLines(rows: [], activeId: nil, nowSec: 1000, relTime: rel), + ["Devices: none registered"]) + } + + func testTheActiveStrapIsMarkedAndSortedFirstEvenWhenSeenLongestAgo() { + // The whole point: the ACTIVE row is the one a reader wants first, and it is NOT necessarily the + // most recently seen — switching straps leaves the old one with a fresher lastSeen. + let now = 100_000 + let lines = deviceInventoryLines( + rows: [row("whoop-B", "paired", now - 600), + row("my-whoop", "active", now - 11_400, model: "WHOOP 5.0 / MG")], + activeId: "my-whoop", nowSec: now, relTime: rel) + XCTAssertEqual(lines, [ + "Devices: 2 registered (1 active, 1 paired, 0 archived)", + " device id=my-whoop status=ACTIVE brand=WHOOP model=WHOOP 5.0 / MG lastSeen=3h 10m ago fw=unknown", + " device id=whoop-B status=paired brand=WHOOP model=WHOOP 4.0 lastSeen=10m ago fw=unknown", + ]) + } + + func testANeverSeenRowReadsNeverRatherThanAHugeDuration() { + let lines = deviceInventoryLines(rows: [row("whoop-C", "paired", 0)], + activeId: nil, nowSec: 100_000, relTime: rel) + XCTAssertEqual(lines[1], + " device id=whoop-C status=paired brand=WHOOP model=WHOOP 4.0 lastSeen=never fw=unknown") + } + + func testCountsSplitActivePairedAndArchived() { + let now = 100_000 + let lines = deviceInventoryLines( + rows: [row("a", "active", now - 60), row("b", "paired", now - 60), + row("c", "archived", now - 60), row("d", "archived", now - 60)], + activeId: "a", nowSec: now, relTime: rel) + XCTAssertEqual(lines[0], "Devices: 4 registered (1 active, 1 paired, 2 archived)") + } + + func testTiesBreakOnIdSoTheOrderNeverDependsOnRegistryIteration() { + let now = 100_000 + let lines = deviceInventoryLines( + rows: [row("zzz", "paired", now - 60), row("aaa", "paired", now - 60)], + activeId: nil, nowSec: now, relTime: rel) + let ids = lines.dropFirst().map { String($0.split(separator: "=")[1].split(separator: " ")[0]) } + XCTAssertEqual(ids, ["aaa", "zzz"]) + } +} 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/DocumentPickerEventTests.swift b/StrandTests/DocumentPickerEventTests.swift new file mode 100644 index 0000000000..1150aa460e --- /dev/null +++ b/StrandTests/DocumentPickerEventTests.swift @@ -0,0 +1,141 @@ +import XCTest +@testable import Strand + +/// What the backup folder picker is allowed to claim (#2356). +/// +/// UIKit calls `documentPickerWasCancelled` both when someone taps Cancel and when someone picks a +/// folder, taps Open, and iOS declines to grant it. Nothing in the API separates them. Reporting +/// "cancelled" asserts an intent that was never observed, and it cost a real investigation: a reporter +/// hit the second case while the log claimed the first, so the previous work went after an Open button +/// that stays disabled, which was never the problem. +/// +/// Source-asserted because the delegate needs UIKit and a presented sheet, the same reason +/// `PiiRedactionTests` reads source. These pin the wording contract, not the picker's behaviour. +final class DocumentPickerEventTests: XCTestCase { + + /// The no-URL outcome must describe what happened, not why someone did it. + func testTheNoUrlOutcomeDoesNotClaimTheUserCancelled() throws { + let code = Self.codeLines(of: try Self.pickerSource()) + XCTAssertTrue(code.contains { $0.contains("\"closed without a folder\"") }, + "the no-URL event must say the sheet closed with nothing, which is what we observe") + XCTAssertFalse(code.contains { $0.contains("recordEvent(\"cancelled\"") }, + "\"cancelled\" asserts an intent UIKit never reports") + } + + /// The two facts that let a reader separate a Cancel tap from a refused grant. + /// + /// A tap on Cancel closes the sheet in a second or two; navigating into iCloud Drive and choosing a + /// folder takes far longer. Without the duration the log cannot distinguish them at all. + func testTheOutcomeCarriesHowLongThePickerWasOpen() throws { + let src = try Self.pickerSource() + XCTAssertTrue(src.contains("private let presentedAt = Date()"), + "the coordinator must note when the sheet went up") + XCTAssertTrue(src.contains("openSeconds: Date().timeIntervalSince(presentedAt)"), + "both delegate outcomes must report how long the picker was open") + } + + /// The companions must be written even when absent, or they outlive the event they describe. + /// + /// Writing them only when present would leave the duration and start folder from an earlier pick + /// sitting beside a newer event, and the export would read as though the two belonged together. + func testTheCompanionFieldsCannotGoStaleAgainstTheEvent() throws { + let code = Self.codeLines(of: try Self.pickerSource()) + XCTAssertTrue(code.contains { $0.contains("d.set(openSeconds ?? 0, forKey:") }, + "the duration must be written unconditionally, including the nil case") + XCTAssertTrue(code.contains { $0.contains("d.set(startedIn ?? \"\", forKey:") }, + "the start category must be written unconditionally too") + XCTAssertFalse(code.contains { $0.contains("if let openSeconds {") }, + "a conditional write leaves the previous pick's duration beside a newer event") + } + + /// The export must never carry the NAME of a folder someone chose. + /// + /// The debug export is what people attach to public issues and it is not redacted. A folder name can + /// carry a person's name as easily as a strap can (#2337). The picked folder's name has always been + /// stored and never printed; the starting directory must follow the same rule, so what is recorded is + /// which KIND of folder it opened on, never the folder itself. + func testTheStartingFolderIsACategoryNotAName() throws { + let code = Self.codeLines(of: try Self.pickerSource()) + XCTAssertTrue(code.contains { $0.contains("startedIn: BackupPickerStart.category(") }, + "the delegate must record a category, not the directory URL") + XCTAssertFalse(code.contains { $0.contains("startedAt: controller.directoryURL") }, + "a picked directory URL must not reach the export") + let export = try Self.exportSource() + XCTAssertFalse(Self.codeLines(of: export).contains { $0.contains("backupPicker.lastName") }, + "the picked folder's own name must stay off the export, as it always has") + } + + private static func exportSource(file: StaticString = #filePath) throws -> String { + var dir = URL(fileURLWithPath: "\(file)").deletingLastPathComponent() + for _ in 0..<4 { + let candidate = dir.appendingPathComponent("Strand/System/DebugDataDiagnostics.swift") + if FileManager.default.fileExists(atPath: candidate.path) { + return try String(contentsOf: candidate, encoding: .utf8) + } + dir = dir.deletingLastPathComponent() + } + throw SourceNotReachable(path: "\(file)") + } + + // MARK: - The category itself, run rather than matched + + /// No directory set means the picker opened wherever it liked. + func testNoDirectoryIsThePickerDefault() { + XCTAssertEqual(BackupPickerStart.category(nil), "the picker default") + } + + /// Our own Documents is the fallback #1000a passes when there is no last-used folder. + func testOurOwnDocumentsIsNamedAsSuch() { + let ours = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first + let resolved = try? XCTUnwrap(ours) + XCTAssertEqual(BackupPickerStart.category(resolved), "NOOP's own folder") + } + + /// Anything else is the user's last-used folder, and is described WITHOUT naming it. + /// + /// The assertion that matters is the second one: whatever the folder is called, its name must not + /// appear in the string that reaches the export. + func testAnyOtherDirectoryIsDescribedWithoutItsName() { + let personal = URL(fileURLWithPath: "/tmp/Ryan's Backups") + let category = BackupPickerStart.category(personal) + XCTAssertEqual(category, "a folder you chose") + XCTAssertFalse(category.contains("Ryan"), "a folder name must never reach the debug export") + XCTAssertFalse(category.contains("Backups"), "nor any part of it") + } + + /// A trailing slash is the same folder, so it must not read as somebody else's. + func testTrailingSlashesDoNotChangeTheAnswer() { + let ours = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first + guard let ours else { return XCTFail("no Documents directory") } + let withSlash = URL(fileURLWithPath: ours.path + "/") + XCTAssertEqual(BackupPickerStart.category(withSlash), "NOOP's own folder") + } + + // MARK: - Source access + + /// Source lines with comment-only lines dropped, so prose about a rule cannot trip the rule. That + /// exact shape failed `BodyClockDialLayoutTests` on CI, where a comment naming the banned call + /// tripped the assertion that the call was absent. + private static func codeLines(of src: String) -> [String] { + src.split(separator: "\n", omittingEmptySubsequences: false) + .map(String.init) + .filter { !$0.trimmingCharacters(in: .whitespaces).hasPrefix("//") } + } + + private struct SourceNotReachable: Error, CustomStringConvertible { + let path: String + var description: String { "DocumentPicker.swift not reachable from \(path)" } + } + + private static func pickerSource(file: StaticString = #filePath) throws -> String { + var dir = URL(fileURLWithPath: "\(file)").deletingLastPathComponent() + for _ in 0..<4 { + let candidate = dir.appendingPathComponent("Strand/System/DocumentPicker.swift") + if FileManager.default.fileExists(atPath: candidate.path) { + return try String(contentsOf: candidate, encoding: .utf8) + } + dir = dir.deletingLastPathComponent() + } + throw SourceNotReachable(path: "\(file)") + } +} diff --git a/StrandTests/FirmwareAttributionTests.swift b/StrandTests/FirmwareAttributionTests.swift new file mode 100644 index 0000000000..16e24353f6 --- /dev/null +++ b/StrandTests/FirmwareAttributionTests.swift @@ -0,0 +1,49 @@ +import XCTest +@testable import Strand + +/// Pins firmware attribution. Kotlin twin: `FirmwareAttributionTest`. +/// +/// The reported bug: a WHOOP 5/MG showed 41.17.6.0 — the 4.0's firmware — because the persisted value +/// lived under ONE global key, written on any WHOOP connect and read back for whichever strap was active. +final class FirmwareAttributionTests: XCTestCase { + + func testLiveWinsOverEverything() { + XCTAssertEqual(FirmwareAttribution.resolve(live: "50.1.0.0", perDevice: "49.0.0.0", + legacyGlobal: "41.17.6.0", pairedCount: 2), "50.1.0.0") + } + + func testThisDevicesOwnPersistedValueBeatsTheLegacyGlobal() { + XCTAssertEqual(FirmwareAttribution.resolve(live: nil, perDevice: "50.1.0.0", + legacyGlobal: "41.17.6.0", pairedCount: 2), "50.1.0.0") + } + + func testTheLegacyGlobalIsRefusedWhenMoreThanOneDeviceIsPaired() { + // The exact reported bug: two straps, no per-device value yet for the active one, and the global + // key holds the other strap's firmware. nil is correct; 41.17.6.0 is not. + XCTAssertNil(FirmwareAttribution.resolve(live: nil, perDevice: nil, + legacyGlobal: "41.17.6.0", pairedCount: 2)) + } + + func testTheLegacyGlobalIsHonouredForASingleDeviceInstall() { + XCTAssertEqual(FirmwareAttribution.resolve(live: nil, perDevice: nil, + legacyGlobal: "41.17.6.0", pairedCount: 1), "41.17.6.0") + } + + func testBlankValuesAreTreatedAsAbsentNotAsAnAnswer() { + XCTAssertEqual(FirmwareAttribution.resolve(live: "", perDevice: " ", + legacyGlobal: "41.17.6.0", pairedCount: 1), "41.17.6.0") + XCTAssertNil(FirmwareAttribution.resolve(live: "", perDevice: "", legacyGlobal: "", pairedCount: 1)) + } + + func testThePrefKeyIsPerDeviceAndCaseInsensitiveOnTheAddress() { + XCTAssertEqual(FirmwareAttribution.prefKey(peripheralId: "F1:D4:F7:24:53:DE"), + "noop.lastFirmware.f1:d4:f7:24:53:de") + XCTAssertEqual(FirmwareAttribution.prefKey(peripheralId: "f1:d4:f7:24:53:de"), + FirmwareAttribution.prefKey(peripheralId: "F1:D4:F7:24:53:DE")) + } + + func testNoAddressMeansNoKey() { + XCTAssertNil(FirmwareAttribution.prefKey(peripheralId: nil)) + XCTAssertNil(FirmwareAttribution.prefKey(peripheralId: " ")) + } +} 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/FrameRouterDoubleTapDedupTests.swift b/StrandTests/FrameRouterDoubleTapDedupTests.swift new file mode 100644 index 0000000000..64386435d0 --- /dev/null +++ b/StrandTests/FrameRouterDoubleTapDedupTests.swift @@ -0,0 +1,223 @@ +import XCTest +@testable import Strand +import WhoopProtocol + +/// ONE physical double-tap must reach the app ONCE. +/// +/// The strap's gesture arrives twice on a busy link: live through `handle(frame:)`, and again when +/// the strap offloads its banked event log — `dispatchLiveGestureIfFresh` runs over every offload +/// frame and accepts any event timestamped within 45 s of now, which a gesture from moments ago +/// obviously is. `AppModel.handleDoubleTap`'s 1.2 s debounce cannot catch that, because the replay +/// can land many seconds later. +/// +/// Reported from a real gym session as "sometimes two double taps when I only did one". With the +/// Lift Log claiming the gesture, a phantom one silently advances the session and costs a logged +/// set — which is why this is pinned rather than left to the debounce. +final class FrameRouterDoubleTapDedupTests: XCTestCase { + + /// A real captured WHOOP 5 DOUBLE_TAP(14) frame; `event_timestamp` = 1780910464. + private let doubleTapHex = "aa0110000100208130340e008089266a3d2a000030b8df92" + private let doubleTapEventTs = 1_780_910_464 + + private func bytes(_ hex: String) -> [UInt8] { + stride(from: 0, to: hex.count, by: 2).compactMap { + let i = hex.index(hex.startIndex, offsetBy: $0) + let j = hex.index(i, offsetBy: 2) + return UInt8(hex[i.. FrameRouter { + let r = FrameRouter(state: live) + r.family = .whoop5 + return r + } + + @MainActor + func testTheSameGestureArrivingLiveThenOnTheOffloadPathFiresOnce() { + let live = LiveState() + var fired = 0 + live.onDoubleTap = { fired += 1 } + let r = router(live) + let frame = bytes(doubleTapHex) + + r.handle(frame: frame) // live + // The strap offloads its banked log seconds later; the SAME event is still "fresh". + r.dispatchLiveGestureIfFresh(frame: frame, now: doubleTapEventTs + 10) + + XCTAssertEqual(fired, 1, "one gesture, one advance — the replay must be suppressed") + } + + @MainActor + func testARepeatedOffloadOfTheSameEventNeverFiresAgain() { + let live = LiveState() + var fired = 0 + live.onDoubleTap = { fired += 1 } + let r = router(live) + let frame = bytes(doubleTapHex) + + // A multi-minute offload re-walks the same records more than once. + for _ in 0..<5 { + r.dispatchLiveGestureIfFresh(frame: frame, now: doubleTapEventTs + 5) + } + XCTAssertEqual(fired, 1) + } + + @MainActor + func testAGenuineSecondTapStillFires() { + let live = LiveState() + var fired = 0 + live.onDoubleTap = { fired += 1 } + let r = router(live) + + r.handle(frame: bytes(doubleTapHex)) + // De-duplication keys on the event's OWN timestamp, so a real later gesture — which carries + // a different one — must not be swallowed. Guarding on "have we seen a double-tap at all" + // would break the feature entirely. + r.dispatchLiveGestureIfFresh(frame: bytes(doubleTapHex), now: doubleTapEventTs + 30) + XCTAssertEqual(fired, 1, "sanity: the same timestamp is still one gesture") + + // A frame with a DIFFERENT event timestamp is a different gesture. + live.onDoubleTap = { fired += 1 } + let second = FrameRouter(state: live) + second.family = .whoop5 + second.handle(frame: bytes(doubleTapHex)) + XCTAssertEqual(fired, 2, "a fresh router (a fresh gesture) still dispatches") + } + + @MainActor + func testAStaleReplayIsStillRejectedByTheFreshnessWindow() { + let live = LiveState() + var fired = 0 + live.onDoubleTap = { fired += 1 } + let r = router(live) + // Far outside `liveGestureWindowSeconds`: a historical replay, not a live gesture. This was + // already correct; pinned so the dedup change cannot be mistaken for the only guard. + r.dispatchLiveGestureIfFresh(frame: bytes(doubleTapHex), now: doubleTapEventTs + 5_000) + XCTAssertEqual(fired, 0) + } + + /// A second gesture: the captured frame with a DIFFERENT `event_timestamp` and a recomputed CRC. + /// + /// Minted rather than captured because the interleaved case needs two gestures reaching ONE + /// router, and the fixture carries a single timestamp. WHOOP 5 envelope: payload is + /// `frame[8..<20]`, its CRC32 is the trailing four bytes little-endian, and `event_timestamp` + /// sits at payload offset 4. The CRC16 header covers `frame[0..<6]` and is untouched. + private func doubleTapFrame(eventTs: Int) -> [UInt8] { + var f = bytes(doubleTapHex) + let ts = UInt32(eventTs) + for i in 0..<4 { f[12 + i] = UInt8((ts >> (8 * UInt32(i))) & 0xFF) } + let crc = crc32(f, 8, 20) + for i in 0..<4 { f[20 + i] = UInt8((crc >> (8 * UInt32(i))) & 0xFF) } + return f + } + + /// The mint has to produce a frame the parser accepts, or a test built on it proves nothing. + func testTheMintedSecondGestureIsAValidFrame() { + let f = doubleTapFrame(eventTs: doubleTapEventTs + 12) + let check = verifyFrame(f, family: .whoop5) + XCTAssertTrue(check.ok, "minted frame must pass both CRCs or the dedup tests are meaningless") + } + + /// TWO genuine taps, then an offload replaying BOTH — the case a single-slot memory cannot cover. + /// + /// Keeping only the last dispatched timestamp catches a replay solely when the replayed event is + /// the most recent one dispatched. Interleave them and each replay looks new: + /// + /// tap A live -> last = A + /// tap B live -> last = B + /// replay A -> A != B, dispatches again + /// replay B -> B != A, dispatches again + /// + /// Two phantom advances, which in a session is two sets silently lost — the exact failure this + /// de-duplication exists to prevent, surviving inside it. + @MainActor + func testTwoTapsReplayedTogetherStillFireOnlyTwice() { + let live = LiveState() + var fired = 0 + live.onDoubleTap = { fired += 1 } + let r = router(live) + + let tsA = doubleTapEventTs + let tsB = doubleTapEventTs + 12 + r.handle(frame: doubleTapFrame(eventTs: tsA)) + r.handle(frame: doubleTapFrame(eventTs: tsB)) + XCTAssertEqual(fired, 2, "sanity: two genuine taps are two gestures") + + // The strap offloads its banked log a few seconds later, carrying both events. + r.dispatchLiveGestureIfFresh(frame: doubleTapFrame(eventTs: tsA), now: tsB + 5) + r.dispatchLiveGestureIfFresh(frame: doubleTapFrame(eventTs: tsB), now: tsB + 5) + + XCTAssertEqual(fired, 2, "a replay of EITHER tap must be suppressed, not just the most recent") + } + + /// Three taps and a full re-walk, the shape a multi-minute offload actually has. + @MainActor + func testAWholeBatchReplayOfSeveralTapsAddsNothing() { + let live = LiveState() + var fired = 0 + live.onDoubleTap = { fired += 1 } + let r = router(live) + + let stamps = [doubleTapEventTs, doubleTapEventTs + 7, doubleTapEventTs + 19] + for ts in stamps { r.handle(frame: doubleTapFrame(eventTs: ts)) } + XCTAssertEqual(fired, 3) + + for _ in 0..<3 { + for ts in stamps { + r.dispatchLiveGestureIfFresh(frame: doubleTapFrame(eventTs: ts), now: stamps[2] + 3) + } + } + XCTAssertEqual(fired, 3, "re-walking the banked log adds no gestures") + } + + // MARK: - The confirming buzz + + /// The double-tap is handed on before the same event kicks a sync. Whatever the tap triggers — + /// the Lift Log's confirming buzz — is then written to the strap ahead of the sync request, which + /// the strap would otherwise answer with a history transfer before playing the buzz (strap log, + /// 16 Sep 2026: those taps buzzed 1.0–2.8 s late). + @MainActor + func testADoubleTapIsHandedOnBeforeItsEventKicksASync() { + let live = LiveState() + var order: [String] = [] + live.onDoubleTap = { order.append("double-tap") } + let r = router(live) + r.onSyncTrigger = { order.append("sync") } + + r.handle(frame: bytes(doubleTapHex)) + + XCTAssertEqual(order, ["double-tap", "sync"]) + } + + // MARK: - Evidence for a tap that "did not register" + + /// A held-back replay leaves a line, so a missed tap can be told apart from a suppressed replay. + @MainActor + func testASuppressedReplayLeavesALogLine() { + let live = LiveState() + let r = router(live) + let frame = bytes(doubleTapHex) + r.handle(frame: frame) + let before = live.log.count + r.dispatchLiveGestureIfFresh(frame: frame, now: doubleTapEventTs + 10) + XCTAssertTrue(live.log.dropFirst(before).contains { + $0.contains("\(doubleTapEventTs)") && $0.contains("already handled") + }) + } + + /// A recent double-tap that only reaches the app through a sync is logged; old history is not. + @MainActor + func testALateDoubleTapIsLoggedButOldHistoryIsNot() { + let live = LiveState() + let r = router(live) + let before = live.log.count + r.dispatchLiveGestureIfFresh(frame: bytes(doubleTapHex), now: doubleTapEventTs + 120) + XCTAssertTrue(live.log.dropFirst(before).contains { $0.contains("arrived 120 s late") }) + + let count = live.log.count + r.dispatchLiveGestureIfFresh(frame: bytes(doubleTapHex), now: doubleTapEventTs + 5_000) + XCTAssertEqual(live.log.count, count, "a replay from hours ago is history, not a missed tap") + } +} diff --git a/StrandTests/FrameRouterWristEventTests.swift b/StrandTests/FrameRouterWristEventTests.swift new file mode 100644 index 0000000000..3964e76d7d --- /dev/null +++ b/StrandTests/FrameRouterWristEventTests.swift @@ -0,0 +1,74 @@ +import XCTest +@testable import Strand +import WhoopProtocol + +/// A strap's WRIST_ON / WRIST_OFF leaves a line in the strap log, on both routes a live event takes. Nothing named +/// them before, so whether a WHOOP 5.0 sends WRIST_OFF when it leaves the wrist — which decides how soon the Live HR +/// banner can show the dash — had to be read from the silence that followed it (24 Sep 2026). +final class FrameRouterWristEventTests: XCTestCase { + + /// The captured WHOOP 5 DOUBLE_TAP(14) frame of `FrameRouterDoubleTapDedupTests`; `event_timestamp` = 1780910464. + private let doubleTapHex = "aa0110000100208130340e008089266a3d2a000030b8df92" + private let eventTs = 1_780_910_464 + + /// That frame carrying another event: its event byte replaced and its payload CRC32 recomputed. + private func wristEventFrame(_ event: UInt8) -> [UInt8] { + var f = stride(from: 0, to: doubleTapHex.count, by: 2).map { + let i = doubleTapHex.index(doubleTapHex.startIndex, offsetBy: $0) + return UInt8(doubleTapHex[i.. (LiveState, FrameRouter) { + let live = LiveState() + live.heartRate = heartRate + let router = FrameRouter(state: live) + router.family = .whoop5 + return (live, router) + } + + @MainActor + private func wristLines(_ live: LiveState) -> [String] { + live.log.filter { $0.contains("Strap: WRIST_") } + } + + @MainActor + func testALiveWristOffClearsTheHeartRateAndSaysSo() { + let (live, router) = wristRouter(heartRate: 91) + router.handle(frame: wristEventFrame(wristOff)) + XCTAssertNil(live.heartRate) + XCTAssertFalse(live.worn) + XCTAssertEqual(wristLines(live).count, 1) + XCTAssertTrue(wristLines(live).last?.hasSuffix("Strap: WRIST_OFF; live heart rate cleared") == true, + "\(live.log)") + + router.handle(frame: wristEventFrame(wristOff)) // already off: said, nothing to clear + XCTAssertTrue(wristLines(live).last?.hasSuffix("Strap: WRIST_OFF") == true, "\(wristLines(live))") + + router.handle(frame: wristEventFrame(wristOn)) + XCTAssertTrue(live.worn) + XCTAssertTrue(wristLines(live).last?.hasSuffix("Strap: WRIST_ON") == true, "\(wristLines(live))") + } + + /// During a sync a live event reaches the router through the offload path; it is said to have come that way. One + /// that is history being offloaded stays silent, as before. + @MainActor + func testAWristEventDuringASyncIsSaidOnlyWhenItIsLive() { + let (live, router) = wristRouter(heartRate: 91) + router.dispatchLiveGestureIfFresh(frame: wristEventFrame(wristOff), now: eventTs + 600) + XCTAssertEqual(wristLines(live), []) + XCTAssertEqual(live.heartRate, 91) + + router.dispatchLiveGestureIfFresh(frame: wristEventFrame(wristOff), now: eventTs + 2) + XCTAssertNil(live.heartRate) + let line = wristLines(live).last ?? "" + XCTAssertTrue(line.hasSuffix("Strap: WRIST_OFF during a sync; live heart rate cleared"), line) + } +} 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/HabitualSleepFinishedNightsTests.swift b/StrandTests/HabitualSleepFinishedNightsTests.swift new file mode 100644 index 0000000000..a2b0d57b28 --- /dev/null +++ b/StrandTests/HabitualSleepFinishedNightsTests.swift @@ -0,0 +1,36 @@ +import XCTest +import WhoopStore +@testable import Strand + +/// The sleep habits every day is scored against are learned from finished nights only, so the night still +/// being synced cannot change them pass after pass and drop the whole day cache. +@MainActor +final class HabitualSleepFinishedNightsTests: XCTestCase { + func testTonightsGrowingSessionIsNotLearnedFrom() async throws { + let store = try await WhoopStore.inMemory() + let midnight = 1_789_603_200 // 2026-09-17 00:00 UTC + let nights = (1...5).map { back in + CachedSleepSession(startTs: midnight - back * 86_400 + 3_600, endTs: midnight - back * 86_400 + 30_600, + efficiency: 0.9, restingHr: 55, avgHrv: 80, stagesJSON: nil) + } + _ = try await store.upsertSleepSessions(nights, deviceId: "my-whoop-noop") + func learn() async -> (Int?, [Double]) { + await IntelligenceEngine.computeHabitualSleep( + store: store, importedId: "my-whoop", computedId: "my-whoop-noop", + windowStart: midnight - 30 * 86_400, windowEnd: midnight + 86_400, + finishedBefore: midnight, offsetSec: 0) + } + let before = await learn() + + // Tonight, first synced to 04:00, then again once it reached 09:30. + for end in [midnight + 14_400, midnight + 34_200] { + _ = try await store.upsertSleepSessions( + [CachedSleepSession(startTs: midnight + 1_800, endTs: end, efficiency: 0.95, restingHr: 54, + avgHrv: 85, stagesJSON: nil)], deviceId: "my-whoop-noop") + let now = await learn() + XCTAssertEqual(now.0, before.0) + XCTAssertEqual(now.1, before.1) + } + XCTAssertEqual(before.1.count, 5) + } +} diff --git a/StrandTests/HeaderRedactionTests.swift b/StrandTests/HeaderRedactionTests.swift new file mode 100644 index 0000000000..d94861a7c7 --- /dev/null +++ b/StrandTests/HeaderRedactionTests.swift @@ -0,0 +1,88 @@ +import XCTest +@testable import Strand + +/// #453: the strap-log HEADER is built from diagnostics lines that never pass through the scrub the log +/// BODY gets as it is appended, so the export sites must redact them themselves. +/// +/// Pins the property that matters — a device id embedding a BLE address does not survive into a +/// shareable header — rather than the call sites, which a refactor may legitimately move. It matters now +/// that the header enumerates EVERY paired device: a single-strap install is "my-whoop" and carries no +/// address, but a re-added or second strap is "whoop-". Kotlin twin: `HeaderRedactionTest`. +final class HeaderRedactionTests: XCTestCase { + + private let rawMac = try! NSRegularExpression(pattern: "[0-9A-Fa-f]{2}(:[0-9A-Fa-f]{2}){5}") + + private func containsRawMac(_ s: String) -> Bool { + rawMac.firstMatch(in: s, range: NSRange(s.startIndex..., in: s)) != nil + } + + func testADeviceIdLineCarryingABLEAddressIsMasked() { + let line = " device id=whoop-F1:D4:F7:24:53:DE status=paired brand=WHOOP model=WHOOP 4.0 lastSeen=10m ago" + let safe = LiveState.redactPii(line) + XCTAssertFalse(containsRawMac(safe), "raw MAC survived: \(safe)") + } + + func testTheFunnelsOrphanLineIsMaskedToo() { + // The line #1620 added prints ids for every id holding samples — the same exposure. + let line = "(no raw biometric samples under the ACTIVE id 'my-whoop' for this night — they are under " + + "'whoop-F1:D4:F7:24:53:DE' (4213 rows) instead." + 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) + } + + /// #2092: the same gap as #1303 above, for the OTHER brand. An Oura ring's adopted id is + /// `oura-`, and none of the WHOOP-shaped rules match a different literal prefix, so this + /// header line leaked the ring's serial verbatim until the twin `oura-` rule was added. + func testAnAdoptedOuraSerialIdIsMasked() { + let line = " device id=oura-2H3B2405003655 status=ACTIVE brand=Oura model=Oura Ring 3 lastSeen=1h ago" + let safe = LiveState.redactPii(line) + XCTAssertFalse(safe.contains("2405003655"), "serial survived: \(safe)") + XCTAssertTrue(safe.contains("oura-2H3…"), "three-character prefix should remain: \(safe)") + } + + /// The `-noop` computed-sibling suffix is brand-agnostic (`DeviceRegistryStore.computedSuffix`), so + /// it must survive the Oura mask exactly as it does the WHOOP one. + func testTheComputedSiblingMarkerSurvivesForOuraToo() { + XCTAssertEqual(LiveState.redactPii("Days: oura-2H3B2405003655-noop=25"), "Days: oura-2H3…-noop=25") + } + + /// An all-digit serial (this repo's own #2075/#2090 evidence: "2038082631034041") must mask the same + /// as an alphanumeric one — the rule keys on the "oura-" prefix, not on the serial containing a letter. + func testAnAllDigitOuraSerialIsMaskedToo() { + let line = "adopted stable serial id oura-2038082631034041 (was oura-5C4C0BF8-2DF6-1B3A-18D0-3DF0B3590148)" + let safe = LiveState.redactPii(line) + XCTAssertFalse(safe.contains("2038082631034041"), "serial survived: \(safe)") + XCTAssertTrue(safe.contains("oura-203…"), "three-character prefix should remain: \(safe)") + // The PROVISIONAL id in the same line is a CB-UUID, already caught by the earlier UUID rule. + XCTAssertFalse(safe.contains("5C4C0BF8-2DF6-1B3A-18D0-3DF0B3590148"), "provisional UUID survived: \(safe)") + } + + func testAnIdTooShortToBeAnOuraSerialIsUntouched() { + XCTAssertEqual(LiveState.redactPii("id=oura-ABCDE"), "id=oura-ABCDE") + } +} diff --git a/StrandTests/HealthExportMergeTests.swift b/StrandTests/HealthExportMergeTests.swift new file mode 100644 index 0000000000..e9f1dc5637 --- /dev/null +++ b/StrandTests/HealthExportMergeTests.swift @@ -0,0 +1,88 @@ +import XCTest +import WhoopStore +@testable import Strand + +/// The Apple Health export's row precedence (#2264). +/// +/// Why this exists: the write-back unions computed dailies with imported ones and lets imported win. That +/// is correct, but it was written as a wholesale replacement, and `WhoopImporter` cannot populate +/// `avgSdnn` because a CSV carries no raw R-R to derive it from. So a night that had been re-scored with a +/// correct SDNN silently began exporting `avgHrv` instead, which for a strap row is RMSSD, under +/// `heartRateVariabilitySDNN`. That is not a missing value, it is a right value replaced by a wrong one, +/// and only on the days an import happened to cover. +final class HealthExportMergeTests: XCTestCase { + + /// Every field carries a DISTINCT non-nil value on purpose. + /// + /// `DailyMetric.init` defaults everything from `spo2Pct` onward to nil, so a field the merge forgets + /// to pass still compiles and silently blanks. A fixture built from nils cannot catch that: the + /// assertion passes because nil equals nil. Distinct values turn a dropped field into a failure. + private func row(day: String = "2026-09-16", avgHrv: Double? = 42, avgSdnn: Double? = nil, + restingHr: Int? = 51, recovery: Double? = 70) -> DailyMetric { + DailyMetric(day: day, totalSleepMin: 420, efficiency: 0.91, deepMin: 90, remMin: 100, + lightMin: 230, disturbances: 3, restingHr: restingHr, avgHrv: avgHrv, + recovery: recovery, strain: 12.5, exerciseCount: 2, spo2Pct: 96.5, + skinTempDevC: -0.4, respRateBpm: 14.2, steps: 8123, activeKcalEst: 512.5, + spo2Red: 1234, spo2Ir: 5678, avgSdnn: avgSdnn, skinTempC: 33.2, + sleepHrOnly: true) + } + + /// Assert the merge changed `avgSdnn` and NOTHING else, field by field. + private func assertOnlySdnnDiffers(_ merged: DailyMetric, from imported: DailyMetric, + expectedSdnn: Double?, file: StaticString = #filePath, + line: UInt = #line) { + XCTAssertEqual(merged.avgSdnn, expectedSdnn, "avgSdnn", file: file, line: line) + XCTAssertEqual(merged.day, imported.day, "day", file: file, line: line) + XCTAssertEqual(merged.totalSleepMin, imported.totalSleepMin, "totalSleepMin", file: file, line: line) + XCTAssertEqual(merged.efficiency, imported.efficiency, "efficiency", file: file, line: line) + XCTAssertEqual(merged.deepMin, imported.deepMin, "deepMin", file: file, line: line) + XCTAssertEqual(merged.remMin, imported.remMin, "remMin", file: file, line: line) + XCTAssertEqual(merged.lightMin, imported.lightMin, "lightMin", file: file, line: line) + XCTAssertEqual(merged.disturbances, imported.disturbances, "disturbances", file: file, line: line) + XCTAssertEqual(merged.restingHr, imported.restingHr, "restingHr", file: file, line: line) + XCTAssertEqual(merged.avgHrv, imported.avgHrv, "avgHrv", file: file, line: line) + XCTAssertEqual(merged.recovery, imported.recovery, "recovery", file: file, line: line) + XCTAssertEqual(merged.strain, imported.strain, "strain", file: file, line: line) + XCTAssertEqual(merged.exerciseCount, imported.exerciseCount, "exerciseCount", file: file, line: line) + XCTAssertEqual(merged.spo2Pct, imported.spo2Pct, "spo2Pct", file: file, line: line) + XCTAssertEqual(merged.skinTempDevC, imported.skinTempDevC, "skinTempDevC", file: file, line: line) + XCTAssertEqual(merged.respRateBpm, imported.respRateBpm, "respRateBpm", file: file, line: line) + XCTAssertEqual(merged.steps, imported.steps, "steps", file: file, line: line) + XCTAssertEqual(merged.activeKcalEst, imported.activeKcalEst, "activeKcalEst", file: file, line: line) + XCTAssertEqual(merged.spo2Red, imported.spo2Red, "spo2Red", file: file, line: line) + XCTAssertEqual(merged.spo2Ir, imported.spo2Ir, "spo2Ir", file: file, line: line) + XCTAssertEqual(merged.skinTempC, imported.skinTempC, "skinTempC", file: file, line: line) + XCTAssertEqual(merged.sleepHrOnly, imported.sleepHrOnly, "sleepHrOnly", file: file, line: line) + } + + func testComputedSdnnSurvivesAnImportThatHasNone() { + let computed = row(avgHrv: 42, avgSdnn: 61) + let imported = row(avgHrv: 44) // a CSV row: RMSSD, never an SDNN + let merged = HealthExportMerge.merged(computed: computed, imported: imported) + XCTAssertEqual(merged.avgSdnn, 61, "the computed SDNN must not be dropped by the import") + XCTAssertEqual(merged.avgHrv, 44, "every other field still comes from the imported row") + } + + func testImportedSdnnWinsWhenItHasOne() { + let merged = HealthExportMerge.merged(computed: row(avgSdnn: 61), imported: row(avgSdnn: 70)) + XCTAssertEqual(merged.avgSdnn, 70, "precedence is unchanged where the importer does supply a value") + } + + func testNoComputedRowLeavesTheImportedRowUntouched() { + let imported = row(avgHrv: 44) + XCTAssertEqual(HealthExportMerge.merged(computed: nil, imported: imported), imported) + } + + func testNeitherSideHavingSdnnIsStillNil() { + let merged = HealthExportMerge.merged(computed: row(avgHrv: 42), imported: row(avgHrv: 44)) + XCTAssertNil(merged.avgSdnn, "nothing is invented when neither side has a value") + } + + func testEveryOtherImportedFieldSurvivesTheCarry() { + // The carry rebuilds the struct, and ten of its parameters default to nil, so a forgotten one + // compiles and blanks data silently. Every field is checked, not a sample. + let imported = row(avgHrv: 44, restingHr: 52, recovery: 71) + let merged = HealthExportMerge.merged(computed: row(avgSdnn: 61), imported: imported) + assertOnlySdnnDiffers(merged, from: imported, expectedSdnn: 61) + } +} diff --git a/StrandTests/HelloSuppressionTests.swift b/StrandTests/HelloSuppressionTests.swift new file mode 100644 index 0000000000..2b036527cc --- /dev/null +++ b/StrandTests/HelloSuppressionTests.swift @@ -0,0 +1,236 @@ +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) + } + } + + + /// #2332: the band that decides whether a link RSSI reading reaches the epitaph. Twin of the Kotlin + /// `RssiReadingBandTest`. The value this guards against is 127, the BLE spec's "RSSI is not + /// available": recording it would put an extremely strong reading on a link that died, which argues + /// the radio was fine and sends the next reader of that log looking anywhere but at range. + func testRssiReadingBand() { + // The field report behind #2332: median -92, min -96, best -48. + for rssi in [-96, -93, -92, -87, -76, -63, -48] { + XCTAssertTrue(rssiReadingIsUsable(rssi), "\(rssi) dBm is a real reading") + } + XCTAssertFalse(rssiReadingIsUsable(127), "127 is the spec's 'not available'") + // A band, not `!= 127`: anything outside the spec's range is rejected too. + XCTAssertFalse(rssiReadingIsUsable(21)) + XCTAssertFalse(rssiReadingIsUsable(100)) + XCTAssertFalse(rssiReadingIsUsable(Int.max)) + XCTAssertFalse(rssiReadingIsUsable(-128)) + XCTAssertFalse(rssiReadingIsUsable(Int.min)) + // Bounds are inclusive, pinned so a later tightening cannot quietly move an edge. + XCTAssertTrue(rssiReadingIsUsable(-127)) + XCTAssertTrue(rssiReadingIsUsable(20)) + // 0 is not reachable on a real link, so it is a plausible sentinel for a stack to invent, but the + // spec permits it: KEPT rather than discarding a spec-valid reading on a guess. + XCTAssertTrue(rssiReadingIsUsable(0), "0 is inside the spec's range, so it is kept") + } +} diff --git a/StrandTests/HeroRingDetailRouteTests.swift b/StrandTests/HeroRingDetailRouteTests.swift new file mode 100644 index 0000000000..6738e28bf7 --- /dev/null +++ b/StrandTests/HeroRingDetailRouteTests.swift @@ -0,0 +1,56 @@ +import XCTest +@testable import Strand + +/// #1995: a hero ring taps through to its metric dossier, so the ring and the Key-Metrics tile for the +/// same score reach the identical screen. +/// +/// Charge is the exception and it is deliberate: a ring opens the richest explanation its shell has, so +/// Charge keeps its breakdown sheet on the classic Today and on Android and only takes a route on the +/// Liquid Today, which has no breakdown. Its key is still pinned here because the Liquid hero and both +/// shells' tiles resolve it. +/// +/// `TabRoute.metric(key)` resolves through `MetricCatalog.all` and, when a key does not match, falls back +/// to the Health screen rather than failing. That fallback is the reason these assertions exist: rename a +/// catalog key and the ring would keep working, keep animating, and quietly land on the wrong screen. No +/// crash, no log, nothing to notice. Pinning the three keys here turns that into a build failure. +/// +/// The Liquid Key-Metrics tiles below the rings resolve their own destination through the same catalog +/// on the same three keys, so this guards the pair from drifting apart. They read the constants rather +/// than their own copies, which is what makes that true rather than merely intended: a private list here +/// would have kept passing while either shell was renamed out from under it. +final class HeroRingDetailRouteTests: XCTestCase { + + /// Charge, Effort and Rest, in the order the hero row renders them. + /// + /// Read from the production constants rather than restated here. A private copy would have kept + /// passing while a shell was renamed out from under it, which is the one failure this file exists to + /// catch; the literals below pin that the constants themselves still say what the catalog expects. + private let heroKeys = HeroRingMetric.all + + /// The constants' VALUES, pinned separately from their resolution. Reading the production list is + /// what ties the test to the shells; this is what stops the list itself drifting silently, and it is + /// where the Apple/Android divergence is recorded: Rest routes on `sleep_performance` here and on + /// `rest` there, because the two platforms' detail screens resolve different key spaces. + func testHeroRingKeysAreTheExpectedThree() { + XCTAssertEqual(HeroRingMetric.charge, "recovery") + XCTAssertEqual(HeroRingMetric.effort, "strain") + XCTAssertEqual(HeroRingMetric.rest, "sleep_performance") + XCTAssertEqual(HeroRingMetric.all, ["recovery", "strain", "sleep_performance"]) + } + + func testEveryHeroRingKeyResolvesToACatalogMetric() { + for key in heroKeys { + XCTAssertNotNil(MetricCatalog.all.first(where: { $0.key == key }), + "hero ring key '\(key)' is not in MetricCatalog, so its ring would silently " + + "fall back to the Health screen instead of the metric") + } + } + + /// Each key must name exactly one metric: two entries and the ring's destination depends on ordering. + func testEveryHeroRingKeyIsUnambiguous() { + for key in heroKeys { + XCTAssertEqual(MetricCatalog.all.filter { $0.key == key }.count, 1, + "hero ring key '\(key)' must match exactly one catalog entry") + } + } +} 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/HostedCardPrefsTests.swift b/StrandTests/HostedCardPrefsTests.swift index 7a87114469..d1b3a0130f 100644 --- a/StrandTests/HostedCardPrefsTests.swift +++ b/StrandTests/HostedCardPrefsTests.swift @@ -11,6 +11,32 @@ final class HostedCardPrefsTests: XCTestCase { func testRawValuesAreTheFrozenNamespacedContract() { XCTAssertEqual(HostedCard.sleepMarks.rawValue, "sleep.sleepMarks") XCTAssertEqual(HostedCard.asleepDuration.rawValue, "sleep.asleepDuration") + // Byte-identical to the Kotlin `HostedCard.STRESS_TODAY`. This id rides .noopbak, so a + // difference of one character means an Android backup restored here silently drops the card. + XCTAssertEqual(HostedCard.stressToday.rawValue, "stress.today") + XCTAssertEqual(HostedCard.trendHRV.rawValue, "trends.hrv") + XCTAssertEqual(HostedCard.trendRestingHR.rawValue, "trends.restingHr") + XCTAssertEqual(HostedCard.trendEffort.rawValue, "trends.effort") + } + + /// The two local-day counters have to agree, and nothing but this test can check them. + /// + /// `StressDayCurve` returns the day the widget then STORES, and the widget's read path compares + /// that against `WidgetSnapshot.localDayNumber` to decide whether the stored curve is still today's. + /// They are separate copies because no module sees both: the macOS app does not compile the widget + /// sources, and the widget extension links no packages. This target sees both, so it is the only + /// place a divergence can be caught, and a divergence would silently drop a valid curve. + func testTheTwoLocalDayCountersAgree() { + var cal = Calendar(identifier: .gregorian) + for zone in ["Europe/London", "America/New_York", "Australia/Lord_Howe", "UTC"] { + cal.timeZone = TimeZone(identifier: zone)! + for offset in stride(from: 0, to: 400, by: 7) { + let d = Date(timeIntervalSince1970: 1_735_732_800 + Double(offset) * 86_400 + 43_200) + XCTAssertEqual(StressDayCurve.localDayNumber(d, calendar: cal), + WidgetSnapshot.localDayNumber(d, calendar: cal), + "\(zone) at \(d)") + } + } // Every id must be origin-namespaced so it routes to the right provider and can't collide with a // Today DashboardCard id. for card in HostedCard.allCases { @@ -18,6 +44,32 @@ final class HostedCardPrefsTests: XCTestCase { } } + /// Every hosted card opens the tab it mirrors, and the tap-to-log card opens nothing. + /// + /// Worth pinning because the failure is silent: a card wired to the wrong route still renders, still + /// taps, and simply lands somewhere else. Nothing about the screen looks wrong. + func testEachHostedCardOpensItsOwnTab() { + // The tap-to-log card must NOT navigate: its buttons are its purpose, and a wrapper would put a + // second meaning behind the same press. + XCTAssertNil(HostedCard.sleepMarks.route) + for card in HostedCard.allCases where card != .sleepMarks { + XCTAssertNotNil(card.route, "\(card.rawValue) taps through to nothing") + } + // Sleep-origin cards land on the Sleep tab. + let sleepOrigin = String(localized: "Sleep") + for card in HostedCard.allCases where card.origin == sleepOrigin && card != .sleepMarks { + XCTAssertEqual(card.route, .sleep, "\(card.rawValue) should open Sleep") + } + // Named explicitly, as the Kotlin twin names it: the generic checks above would still pass if + // this card were quietly pointed at the wrong tab. + XCTAssertEqual(HostedCard.stressToday.route, .stress) + // The metric's own page, with the SOURCE pinned: `rhr` exists under both my-whoop and + // xiaomi-band, so a bare key would resolve by catalog declaration order. + XCTAssertEqual(HostedCard.trendHRV.route, .metricSourced(key: "hrv", source: "my-whoop")) + XCTAssertEqual(HostedCard.trendRestingHR.route, .metricSourced(key: "rhr", source: "my-whoop")) + XCTAssertEqual(HostedCard.trendEffort.route, .metricSourced(key: "strain", source: "my-whoop")) + } + /// Opt-in surface: nothing is hosted until the user adds a card. func testDefaultIsEmpty() { XCTAssertEqual(HostedCard.defaultSelection, []) diff --git a/StrandTests/HrTraceTests.swift b/StrandTests/HrTraceTests.swift new file mode 100644 index 0000000000..2f4532ed6b --- /dev/null +++ b/StrandTests/HrTraceTests.swift @@ -0,0 +1,227 @@ +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) + } + + // MARK: gaps + + /// The gap rule, at the trace's own bucket width: a step of MORE than one bucket means the strap + /// recorded nothing in between, so the pen lifts. x is mapped by time, so the gap already occupied + /// its true width on screen; the line drawn across it was the invented part. + func testAGapSplitsTheTraceIntoRuns() { + // A 90-minute disconnect, which is the shape actually reported. + let pts = HrTrace.points(series([(0, 60), (60, 61), (5_460, 62), (5_520, 63)]), width: 100, height: 50) + XCTAssertEqual(pts.map { $0.startsRun }, [false, false, true, false]) + XCTAssertEqual(HrTrace.runs(pts), [0...1, 2...3]) + } + + /// The threshold from both sides. A minute is the normal cadence, and a few skipped minutes are + /// ordinary background jitter rather than a disconnect, so neither breaks the line. Getting this + /// wrong the other way would shatter every trace into dots. + func testJitterIsNotAGapButSilencePastTheThresholdIs() { + func startsRun(_ stepSec: Int64) -> Bool { + HrTrace.points(series([(0, 60), (stepSec, 61)]), width: 100, height: 50)[1].startsRun + } + XCTAssertFalse(startsRun(HrTrace.bucketSec)) + XCTAssertFalse(startsRun(3 * HrTrace.bucketSec)) + XCTAssertFalse(startsRun(HrTrace.gapSec)) + XCTAssertTrue(startsRun(HrTrace.gapSec + 1)) + } + + /// A reading marooned between two gaps is a run of one. It gets a dot rather than being dropped, + /// which is the same call the single-point series already makes. + func testAMaroonedReadingIsItsOwnRun() { + let pts = HrTrace.points(series([(0, 60), (5_400, 61), (10_800, 62)]), width: 100, height: 50) + XCTAssertEqual(HrTrace.runs(pts), [0...0, 1...1, 2...2]) + } + + /// The threshold is the widget's OWN definition of too-old rather than a number picked in the + /// renderer, so the line breaks exactly where the headline reading would already have been dropped. + /// Pinned, because the two drifting apart would be silent. + func testTheGapThresholdMatchesTheStaleCap() { + XCTAssertEqual(Int64(HrDisplay.staleCap), HrTrace.gapSec) + } + + /// The common case is unchanged: a trace with no gaps is one run, and draws exactly as before. + func testAContiguousTraceIsOneRunAndAnEmptyOneHasNone() { + let pts = HrTrace.points(series([(0, 60), (60, 61), (120, 62)]), width: 100, height: 50) + XCTAssertEqual(HrTrace.runs(pts), [0...2]) + XCTAssertEqual(HrTrace.runs([]), []) + } +} diff --git a/StrandTests/HrvBlankOverCountTests.swift b/StrandTests/HrvBlankOverCountTests.swift new file mode 100644 index 0000000000..4db9fb73bf --- /dev/null +++ b/StrandTests/HrvBlankOverCountTests.swift @@ -0,0 +1,62 @@ +import XCTest +@testable import Strand + +/// #2335: the HRV tile is blank and NOOP knows exactly why, so it has to say so. +/// +/// The #1118 caveat cannot answer this. It decorates a value that IS shown, and the over-count verdict is +/// the very thing that makes `SleepStager.sessionAvgHRV` return nil, so on the night the caveat was +/// written for there is no row left to attach it to. The reported log carries that exact pair +/// (`rrIntegrity=crossSecondOverCount` with `avgHrv=nil`), which is the case pinned first below. +/// Twin of the Kotlin `HrvBlankOverCountTest`, case for case. +final class HrvBlankOverCountTests: XCTestCase { + + private let today = "2026-09-19" + + private func blanked(_ map: [String: Double], todayKey: String? = nil) -> Bool { + BodyVitalSigns.hrvBlankedByOverCount(hrvOverCountByDay: map, todayKey: todayKey ?? today) + } + + func testTheReportedShapeIsExplained() { + XCTAssertTrue(blanked(["2026-09-19": 1])) + } + + func testNoNightYetIsNotAnOverCount() { + // A fresh install, or a wearer who has not slept in the strap. The blank is real but it is NOT + // this cause, and claiming it would send them chasing a fault that is not there (see #2302, + // which reports the same blank from having no staged night at all). + XCTAssertFalse(blanked([:])) + } + + func testACleanLatestNightIsNotExplainedAway() { + // Older nights over-counted, the most recent one fine: the caption must not blame an over-count + // that has since stopped happening. + XCTAssertFalse(blanked(["2026-09-17": 1, "2026-09-18": 1, "2026-09-19": 0])) + } + + func testOnlyTheNewestNightDecides() { + XCTAssertTrue(blanked(["2026-09-17": 0, "2026-09-18": 0, "2026-09-19": 1])) + } + + func testDayKeysCompareChronologicallyAcrossMonthAndYearEnds() { + // The whole helper rests on `yyyy-MM-dd` sorting lexicographically the way it sorts in time. + XCTAssertTrue(blanked(["2026-09-30": 0, "2026-10-01": 1], todayKey: "2026-10-01")) + XCTAssertTrue(blanked(["2026-12-31": 0, "2027-01-01": 1], todayKey: "2027-01-01")) + XCTAssertFalse(blanked(["2026-09-30": 1, "2026-10-01": 0], todayKey: "2026-10-01")) + } + + /// Past `Baselines.vitalCarryDays` (7) the tile blanks because the reading went STALE, not because it + /// was refused, so the over-count must not take the blame. This is also where the two platforms would + /// drift: Apple loads 14 days of this series and Android loads RECENT_DAYS_CAP, so a helper keyed on + /// "whatever was loaded" would answer differently for the same wearer. + func testAStaleOverCountedNightIsNotTheReasonTheTileIsBlank() { + XCTAssertTrue(blanked(["2026-09-12": 1]), "inside the carry window") + XCTAssertFalse(blanked(["2026-09-11": 1]), "older than the carry window") + XCTAssertFalse(blanked(["2026-08-01": 1]), "far older") + } + + func testTheFlagIsADoubleSoTheGateIsAThreshold() { + // It round-trips through metricSeries as a Double, so the gate is `>= 0.5`, not `== 1`. + XCTAssertTrue(blanked(["2026-09-19": 0.5])) + XCTAssertFalse(blanked(["2026-09-19": 0.49])) + } +} diff --git a/StrandTests/ImportInboxCleanupTests.swift b/StrandTests/ImportInboxCleanupTests.swift index b066a55d5d..4bccd0eadc 100644 --- a/StrandTests/ImportInboxCleanupTests.swift +++ b/StrandTests/ImportInboxCleanupTests.swift @@ -77,8 +77,9 @@ final class ImportInboxCleanupTests: XCTestCase { } func testPurgeRemovesStaleInboxDropsButKeepsFreshOnes() throws { - // Only meaningful on iOS (purgeImportInbox is a no-op elsewhere); on macOS this asserts the - // no-op leaves files alone, which is also correct. + // Both platforms assert, differently: on iOS the stale drop must go, off iOS `purgeImportInbox()` + // is a no-op and BOTH drops must survive. The second is not a formality, because this test writes + // into the real `Documents/Inbox` that the production function scans. let stale = try makeFile(at: inbox.appendingPathComponent("stale-\(UUID()).zip")) // Age it well past the 60 s in-flight guard. try FileManager.default.setAttributes([.modificationDate: Date(timeIntervalSinceNow: -3600)], @@ -93,6 +94,15 @@ final class ImportInboxCleanupTests: XCTestCase { #if os(iOS) XCTAssertFalse(FileManager.default.fileExists(atPath: stale.path), "a stale Inbox drop must be purged on launch (#590)") + #else + // StrandTests runs on the macOS scheme, so the branch above is the one we almost never execute. + // Assert the OTHER half rather than compiling the interesting case away: `purgeImportInbox()` is + // wholly `#if os(iOS)`, so off iOS the stale drop must SURVIVE. That makes the platform gate on + // the production function a tested property instead of an assumed one — drop the `#if` there and + // this run deletes a file out of the user's real Documents/Inbox, which is the accident worth + // catching. Without it the macOS run only ever confirmed that a no-op left `fresh` alone. + XCTAssertTrue(FileManager.default.fileExists(atPath: stale.path), + "purgeImportInbox() is iOS-only; off iOS a stale Inbox drop must be left alone") #endif } } diff --git a/StrandTests/IntelligenceRRSourceTests.swift b/StrandTests/IntelligenceRRSourceTests.swift new file mode 100644 index 0000000000..e0d01bfecb --- /dev/null +++ b/StrandTests/IntelligenceRRSourceTests.swift @@ -0,0 +1,326 @@ +import XCTest +import Foundation +import WhoopProtocol +import WhoopStore +import StrandAnalytics +@testable import Strand + +@MainActor +final class IntelligenceRRSourceTests: XCTestCase { + private let canonical = "my-whoop" + private let active = "new-five" + + private func withPreferences(_ body: () async throws -> Void) async throws { + let defaults = UserDefaults.standard + let keys = [ + "profile.dateOfBirth", "profile.age", "profile.sex", "profile.weightKg", + "profile.heightCm", "profile.waistCm", "profile.hrMaxOverride", "profile.stepTicksPerStep", + "profile.stepsCalibrationCoefficient", "profile.stepsCalibrationSampleDays", + "profile.stepsCalibrationConfidence", "profile.stepsCalibrationManual", + "profile.stepsManualCoefficient", "profile.stepsHasBankedMotion", + "noop.analyzeWatermark", "analyzeRecent.stepsMotionCache.v1", + "noop.hrvBaselineEpoch", "noop.recoveryBaselineEpoch", UnitPrefs.hrvWindowKey, + RescoreBackgroundScheduler.owedKey, RescoreBackgroundScheduler.owedTokenKey, + RescoreBackgroundScheduler.lastPassSecondsKey, DayCycleMode.storageKey, + PuffinExperiment.experimentalSleepV2Key, PuffinExperiment.motionAwareWakeKey, + ] + let saved = keys.map { ($0, defaults.object(forKey: $0)) } + defer { + for (key, value) in saved { + if let value { defaults.set(value, forKey: key) } + else { defaults.removeObject(forKey: key) } + } + } + for key in keys { defaults.removeObject(forKey: key) } + defaults.set(DayCycleMode.midnight.rawValue, forKey: DayCycleMode.storageKey) + defaults.set(true, forKey: PuffinExperiment.experimentalSleepV2Key) + defaults.set(false, forKey: PuffinExperiment.motionAwareWakeKey) + try await body() + } + + private func register(_ registry: DeviceRegistryStore, canonicalModel: String) throws { + try registry.add(PairedDevice(id: canonical, brand: "WHOOP", model: canonicalModel, + sourceKind: .liveBLE, capabilities: [.hr, .hrv], status: .paired, addedAt: 1, lastSeenAt: 1)) + try registry.add(PairedDevice(id: active, brand: "WHOOP", model: "5.0", + sourceKind: .liveBLE, capabilities: [.hr, .hrv], status: .active, addedAt: 2, lastSeenAt: 2)) + } + + private func seedBaseline(_ store: WhoopStore, before day: String) async throws { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + let date = try XCTUnwrap(formatter.date(from: day)) + let history = (1...8).map { offset in + DailyMetric(day: formatter.string(from: date.addingTimeInterval(-Double(offset) * 86_400)), + totalSleepMin: 480, efficiency: 0.9, deepMin: 90, remMin: 90, lightMin: 300, + disturbances: 0, restingHr: 60, avgHrv: 32 + Double(offset % 3), recovery: 60, + strain: nil, exerciseCount: nil) + } + _ = try await store.upsertDailyMetrics(history, deviceId: canonical) + } + + // The same persisted row and store queries the Today explanation consumes. + private func showsLegacyGap(_ day: DailyMetric, store: WhoopStore, owner: String) async throws -> Bool { + func dayKey(_ ts: Int?) -> String? { + ts.map { Repository.localDayKey(Date(timeIntervalSince1970: TimeInterval($0))) } + } + let strict = try await store.isWhoop5RRSource(deviceId: owner) + let firstRecorded = try await store.firstRecordedRRTimestamp(deviceId: owner) + let firstScorable = try await store.firstScorableWhoop5RRTimestamp(deviceId: owner) + return day.recovery == nil && Whoop5RR.legacyUnscorableNight( + strictWhoop5: strict, day: day.day, firstRecordedDay: dayKey(firstRecorded), + firstScorableDay: dayKey(firstScorable), avgHrv: day.avgHrv, totalSleepMin: day.totalSleepMin) + } + + func testLegacySnapshotSurvivesAndClearsAfterSourcePromotion() async throws { + try await withPreferences { + let store = try await WhoopStore.inMemory() + try register(DeviceRegistryStore(dbQueue: store.registryWriter), canonicalModel: "WHOOP") + let input = night() + try await seedBaseline(store, before: input.day) + _ = try await store.insert(Streams(hr: input.hr), deviceId: active) + let repo = Repository(deviceId: active) + repo.setStoreForTesting(store) + let engine = IntelligenceEngine(repo: repo, profile: ProfileStore(), deviceId: canonical) + func score() async throws -> DailyMetric { + await engine.analyzeRecent(maxDays: 2, force: true) + let rows = try await store.dailyMetrics(deviceId: canonical + "-noop", from: input.day, to: input.day) + return try XCTUnwrap(rows.first) + } + + let noBeats = try await score() + XCTAssertGreaterThan(noBeats.totalSleepMin ?? 0, 0) + XCTAssertNil(noBeats.avgHrv) + XCTAssertNil(noBeats.recovery) + let noBeatsGap = try await showsLegacyGap(noBeats, store: store, owner: active) + XCTAssertFalse(noBeatsGap, "ordinary missing beats must not be blamed on legacy units") + + // Restore the exact 11.5-era computed cells before the 11.6 source-policy re-score. The + // fixture deliberately gives every unrelated field stale values: only the RR-derived snapshot + // (HRV + Charge + respiration + SDNN) may survive. + let legacySnapshot = DailyMetric(day: input.day, totalSleepMin: 1, efficiency: 0.01, + deepMin: 1, remMin: 1, lightMin: 1, disturbances: 99, restingHr: 199, + avgHrv: 77.25, recovery: 0.42, strain: 99, exerciseCount: 99, + respRateBpm: 14.25, avgSdnn: 63.5) + try await store.persistComputedScores( + dailyMetrics: [legacySnapshot], metricPoints: [], + provenance: [ScoreInputProvenanceRow(day: input.day, key: "recovery", sourceId: "legacy-owner")], + deviceId: canonical + "-noop", from: input.day, to: input.day) + _ = try await store.insert(Streams(rr: input.rr), deviceId: active) + let legacy = try await score() + XCTAssertEqual(legacy.avgHrv, legacySnapshot.avgHrv, "the restored HRV cell must survive") + XCTAssertEqual(legacy.recovery, legacySnapshot.recovery, "HRV and Charge survive as one snapshot") + XCTAssertEqual(legacy.respRateBpm, legacySnapshot.respRateBpm, + "RR-derived respiration must survive with the snapshot") + XCTAssertEqual(legacy.avgSdnn, legacySnapshot.avgSdnn, + "the other persisted RR-only aggregate must survive with the snapshot") + XCTAssertNotEqual(legacy.totalSleepMin, legacySnapshot.totalSleepMin, + "sleep is freshly scored rather than copied from the snapshot") + XCTAssertNotEqual(legacy.restingHr, legacySnapshot.restingHr, + "only the R-R-derived snapshot receives legacy protection") + let preservedSource = try await store.scoreInputSource(deviceId: canonical + "-noop", + day: input.day, key: "recovery") + XCTAssertEqual(preservedSource, "legacy-owner", "snapshot provenance must survive") + let legacyGap = try await showsLegacyGap(legacy, store: store, owner: active) + XCTAssertFalse(legacyGap, "a protected snapshot is no longer an empty-score diagnostic") + + let stable = try await score() + XCTAssertEqual(stable.avgHrv, legacySnapshot.avgHrv) + XCTAssertEqual(stable.recovery, legacySnapshot.recovery) + XCTAssertEqual(stable.respRateBpm, legacySnapshot.respRateBpm) + XCTAssertEqual(stable.avgSdnn, legacySnapshot.avgSdnn) + let stableSource = try await store.scoreInputSource(deviceId: canonical + "-noop", + day: input.day, key: "recovery") + XCTAssertEqual(stableSource, "legacy-owner") + + // Keep the unlabelled-era bounds unchanged: valid displayed HRV alone must rule out this cause. + let calibrating = DailyMetric(day: legacy.day, totalSleepMin: legacy.totalSleepMin, + efficiency: legacy.efficiency, deepMin: legacy.deepMin, remMin: legacy.remMin, + lightMin: legacy.lightMin, disturbances: legacy.disturbances, restingHr: legacy.restingHr, + avgHrv: 40, recovery: nil, strain: legacy.strain, exerciseCount: legacy.exerciseCount) + let calibrationGap = try await showsLegacyGap(calibrating, store: store, owner: active) + XCTAssertFalse(calibrationGap, "valid HRV without Charge is ordinary calibration") + + // A marked but too-thin transport is a current measurement verdict, not a legacy gap. It must + // clear the old cells rather than letting the protection disguise insufficient new input. + let thin = try XCTUnwrap(input.rr.first) + _ = try await store.insert(Streams(rr: [RRInterval(ts: thin.ts, rrMs: thin.rrMs, + srcChannel: .whoop5Standard)]), deviceId: active) + let insufficient = try await score() + XCTAssertNil(insufficient.avgHrv) + XCTAssertNil(insufficient.recovery) + XCTAssertNil(insufficient.respRateBpm) + XCTAssertNil(insufficient.avgSdnn) + + // Recreate the pre-upgrade snapshot, then prove a complete canonical transport supersedes it. + try await store.persistComputedScores( + dailyMetrics: [legacySnapshot], metricPoints: [], + provenance: [ScoreInputProvenanceRow(day: input.day, key: "recovery", sourceId: "legacy-owner")], + deviceId: canonical + "-noop", from: input.day, to: input.day) + let tagged = input.rr.map { RRInterval(ts: $0.ts, rrMs: $0.rrMs, srcChannel: .whoop5Historical) } + let inserted = try await store.insert(Streams(rr: tagged), deviceId: active) + XCTAssertEqual(inserted.rr, 0, "re-offload changes only source provenance, not row count") + let restored = try await score() + XCTAssertGreaterThan(try XCTUnwrap(restored.avgHrv), 0) + XCTAssertNotEqual(restored.avgHrv, legacySnapshot.avgHrv) + XCTAssertNotNil(restored.recovery) + XCTAssertNotEqual(restored.respRateBpm, legacySnapshot.respRateBpm) + XCTAssertNotEqual(restored.avgSdnn, legacySnapshot.avgSdnn) + let promotedSource = try await store.scoreInputSource(deviceId: canonical + "-noop", + day: input.day, key: "recovery") + XCTAssertEqual(promotedSource, active, "freshly scored provenance replaces the snapshot") + let restoredGap = try await showsLegacyGap(restored, store: store, owner: active) + XCTAssertFalse(restoredGap) + let idle = try await score() + XCTAssertEqual(idle.avgHrv, restored.avgHrv) + XCTAssertEqual(idle.recovery, restored.recovery) + let idleGap = try await showsLegacyGap(idle, store: store, owner: active) + XCTAssertFalse(idleGap, "a cache hit must not revive the explanation") + + } + } + + // A completed night relative to the test's local day, using the established HR-only sleep fixture. + private func night() -> (day: String, hr: [HRSample], rr: [RRInterval]) { + let start = Int(Calendar.current.startOfDay(for: Date()).timeIntervalSince1970) - 86_400 + let day = Repository.localDayKey(Date(timeIntervalSince1970: Double(start))) + var hr: [HRSample] = [] + var rr: [RRInterval] = [] + for i in 0..<(24 * 3_600) { + let asleep = i >= 16 * 3_600 + let phase = asleep ? i - 16 * 3_600 : i + let bpm = asleep ? 64 + Int(sin(Double(phase) / 900) * 5) + : 74 + Int(sin(Double(phase) / 500) * 11) + let ts = start - 16 * 3_600 + i + hr.append(HRSample(ts: ts, bpm: bpm)) + rr.append(RRInterval(ts: ts, rrMs: 900 + (i.isMultiple(of: 2) ? 16 : -16))) + } + return (day, hr, rr) + } + + func testNightlyScoringRejectsLegacyAliasAndRecomputesAfterZeroInsertPromotion() async throws { + try await withPreferences { + let store = try await WhoopStore.inMemory() + let registry = DeviceRegistryStore(dbQueue: store.registryWriter) + try register(registry, canonicalModel: "WHOOP") + let input = night() + _ = try await store.insert(Streams(hr: input.hr, rr: input.rr), deviceId: canonical) + let repo = Repository(deviceId: canonical) + repo.setStoreForTesting(store) + repo.adoptActiveDeviceId(active) + let engine = IntelligenceEngine(repo: repo, profile: ProfileStore(), deviceId: canonical) + var log: [String] = [] + engine.diagnosticSink = { line, _ in log.append(line) } + + await engine.analyzeRecent(maxDays: 2, force: true) + let before = try await store.dailyMetrics(deviceId: canonical + "-noop", + from: input.day, to: input.day) + let first = try XCTUnwrap(before.first) + XCTAssertGreaterThan(first.totalSleepMin ?? 0, 0, "the fixture must actually score a night") + XCTAssertNil(first.avgHrv, "legacy alias R-R has unproven units after WHOOP 5 re-pairing") + + let tagged = input.rr.map { RRInterval(ts: $0.ts, rrMs: $0.rrMs, srcChannel: .whoop5Historical) } + let inserted = try await store.insert(Streams(rr: tagged), deviceId: canonical) + XCTAssertEqual(inserted.rr, 0, "only provenance changes; the existing interval keys are identical") + log.removeAll() + await engine.analyzeRecent(maxDays: 2, force: true) + let after = try await store.dailyMetrics(deviceId: canonical + "-noop", + from: input.day, to: input.day) + let promoted = try XCTUnwrap(after.first?.avgHrv) + XCTAssertGreaterThan(promoted, 0, "the same engine must replace its cached R-R-less result") + + log.removeAll() + await engine.analyzeRecent(maxDays: 2, force: true) + let idle = try await store.dailyMetrics(deviceId: canonical + "-noop", + from: input.day, to: input.day) + XCTAssertEqual(idle.first?.avgHrv, promoted) + XCTAssertTrue(log.contains { $0.contains("dayCache reused=2/2") }, + "an unchanged pass should reuse both previously scored windows: \(log)") + } + } + + func testNightlyScoringKeepsConfirmedWhoop4LegacyIntervalsAfterRePairing() async throws { + try await withPreferences { + let store = try await WhoopStore.inMemory() + let registry = DeviceRegistryStore(dbQueue: store.registryWriter) + try register(registry, canonicalModel: "4.0") + let input = night() + try await seedBaseline(store, before: input.day) + _ = try await store.insert(Streams(hr: input.hr), deviceId: canonical) + let repo = Repository(deviceId: active) + repo.setStoreForTesting(store) + let engine = IntelligenceEngine(repo: repo, profile: ProfileStore(), deviceId: canonical) + await engine.analyzeRecent(maxDays: 2, force: true) + let emptyRows = try await store.dailyMetrics(deviceId: canonical + "-noop", from: input.day, to: input.day) + let empty = try XCTUnwrap(emptyRows.first) + XCTAssertNil(empty.avgHrv) + XCTAssertNil(empty.recovery) + let emptyGap = try await showsLegacyGap(empty, store: store, owner: canonical) + XCTAssertFalse(emptyGap) + _ = try await store.insert(Streams(rr: input.rr), deviceId: canonical) + await engine.analyzeRecent(maxDays: 2, force: true) + let rows = try await store.dailyMetrics(deviceId: canonical + "-noop", + from: input.day, to: input.day) + let scored = try XCTUnwrap(rows.first) + XCTAssertGreaterThan(try XCTUnwrap(scored.avgHrv), 0) + XCTAssertNotNil(scored.recovery) + let gap = try await showsLegacyGap(scored, store: store, owner: canonical) + XCTAssertFalse(gap, "WHOOP 4 legacy beats remain supported") + } + } + + func testNightlySlidingWindowSelectsOneTransportForTheWholeOlderWindow() async throws { + try await withPreferences { + let store = try await WhoopStore.inMemory() + let registry = DeviceRegistryStore(dbQueue: store.registryWriter) + try register(registry, canonicalModel: "5.0") + let input = night() + let standard = input.rr.map { RRInterval(ts: $0.ts, rrMs: $0.rrMs, srcChannel: .whoop5Standard) } + _ = try await store.insert(Streams(hr: input.hr, rr: standard), deviceId: canonical) + // Today's read begins 30h before midnight. Only the older day's extension sees history; + // that single history record must select history for its ENTIRE window, including overlap. + let midnight = Int(Calendar.current.startOfDay(for: Date()).timeIntervalSince1970) + _ = try await store.insert(Streams(rr: [RRInterval(ts: midnight - 31 * 3_600, + rrMs: 900, srcChannel: .whoop5Historical)]), deviceId: canonical) + let repo = Repository(deviceId: canonical) + repo.setStoreForTesting(store) + let engine = IntelligenceEngine(repo: repo, profile: ProfileStore(), deviceId: canonical) + await engine.analyzeRecent(maxDays: 2, force: true) + let rows = try await store.dailyMetrics(deviceId: canonical + "-noop", from: input.day, to: input.day) + let scored = try XCTUnwrap(rows.first) + XCTAssertGreaterThan(scored.totalSleepMin ?? 0, 0) + XCTAssertNotNil(scored.restingHr) + XCTAssertNil(scored.avgHrv, "the history-only window has no beats inside the sleep; cached standard beats must not leak in") + } + } + + func testManualNapAndSelfHealGuardAliasBeforeRepositoryAdoptsActiveDevice() async throws { + try await withPreferences { + let store = try await WhoopStore.inMemory() + let registry = DeviceRegistryStore(dbQueue: store.registryWriter) + try register(registry, canonicalModel: "WHOOP") + let start = 1_700_000_000 + let duration = 6 * 3_600 + let hr = (0..=200-HR gate yet the @@ -18,25 +19,135 @@ 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 with provided=0: no gravity AND the HR-only spine (#1801) yielded + // nothing either. The bare reason is only correct because nothing was provided; see + // `testNoMotionButSessionsWereProvided` for the case where it is not. + + "grav=0 skin=0 steps=12 provided=0 window=54h reason=no-motion") + } + + /// The case a 5/MG overnight capture actually hits, and the one no fixture covered. + /// + /// Since #1801 a no-gravity day still runs the HR-only spine. On that capture it kept four sessions, + /// the longest 311 minutes, and handed them over — and this line still said `no-motion`, which reads + /// as "nothing could stage a night" while the log above showed something had. With sessions provided + /// and the night still empty, the question is what dropped them, not what the strap can do. + /// + /// The fixture asserts on PROVIDED sessions, not on their source: with no gravity they may be the + /// HR-only spine's output or a stored hypnogram, and the line cannot tell. Claiming otherwise would + /// be the same over-claim this split removes. + func testNoMotionButSessionsWereProvided() { + let line = IE.sleepDetectNoNightLogLine( + day: "2026-09-21", hrCount: 106144, rrCount: 73959, respCount: 0, + gravCount: 0, stepCount: 0, providedCount: 3, windowHours: 30, skinCount: 0) + // EXACT match on the parsed token, not `contains`. `no-motion` is a PREFIX of + // `no-motion-provided-unused`, so a contains check matches both and cannot catch a regression to + // the bare value. The first version of this assertion used `contains("reason=no-motion ")` with a + // trailing space, which is false for BOTH values whenever nothing is at its read cap, so it could + // never fail at all. + XCTAssertEqual(Self.reasonToken(of: line), "no-motion-provided-unused", line) + } + + /// No gravity AND nothing provided keeps the plain reason: HR alone yielded nothing either. + func testNoMotionAndNothingProvidedKeepsThePlainReason() { + let line = IE.sleepDetectNoNightLogLine( + day: "2026-09-21", hrCount: 106144, rrCount: 73959, respCount: 0, + gravCount: 0, stepCount: 0, providedCount: 0, windowHours: 30, skinCount: 0) + XCTAssertEqual(Self.reasonToken(of: line), "no-motion", line) + } + + /// Motion present still outranks everything: the inputs were there and staging produced nothing. + func testMotionPresentStaysStagedNoneEvenWithProvidedSessions() { + let line = IE.sleepDetectNoNightLogLine( + day: "2026-09-21", hrCount: 5000, rrCount: 900, respCount: 300, + gravCount: 4, stepCount: 0, providedCount: 3, windowHours: 48, skinCount: 0) + XCTAssertEqual(Self.reasonToken(of: line), "staged-none", line) } 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) + } + + /// The `reason=` value alone, stopping at the space before any `atCap=` marker. + /// + /// Comparing the whole line with `contains` cannot separate `no-motion` from + /// `no-motion-provided-unused`, since the first is a prefix of the second. + private static func reasonToken(of line: String) -> String { + guard let r = line.range(of: "reason=") else { return "" } + return String(line[r.upperBound...].prefix { $0 != " " }) + } } 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/LabBookFormatTests.swift b/StrandTests/LabBookFormatTests.swift new file mode 100644 index 0000000000..16e0f25279 --- /dev/null +++ b/StrandTests/LabBookFormatTests.swift @@ -0,0 +1,46 @@ +import XCTest +@testable import Strand + +/// Lab Book value formatting. The expected lists are the same literals pinned in the Android twin's +/// `LabValueFormatTest`, so both platforms print the same string for every value. +final class LabBookFormatTests: XCTestCase { + private let inputs: [Double] = [ + 0.27, 1.020, 1.02, 140, 0, -0.0, -0.0001, 0.0004, 0.0005, 1.0005, 0.0625, 2.675, 12.5, 0.125, + 3.14159, 1234567.891, 5.2, 0.1, 0.30000000000000004, 99.9995, -3.25, 1e-7, + ] + + func testCustomMarkerKeepsItsOwnPrecision() { + XCTAssertEqual(LabBookFormat.value(0.27, key: "custom_plateletcrit"), "0.27") + XCTAssertEqual(LabBookFormat.value(1.020, key: "custom_urine_specific_gravity"), "1.02") + XCTAssertEqual(LabBookFormat.value(140, key: "custom_platelets"), "140") + } + + func testPlainIsPinned() { + let expected = [ + "0.27", "1.02", "1.02", "140", "0", "0", "0", "0", "0.001", "1", "0.062", "2.675", "12.5", "0.125", + "3.142", "1234567.891", "5.2", "0.1", "0.3", "99.999", "-3.25", "0", + ] + XCTAssertEqual(inputs.map(LabBookFormat.plain), expected) + XCTAssertEqual(LabBookFormat.plain(.nan), "—") + XCTAssertEqual(LabBookFormat.plain(.infinity), "—") + } + + func testCatalogDecimalsArePinned() { + // ferritin = 0 decimals, weight = 1, tsh = 2 (MarkerCatalog.builtIn). + let expected0 = [ + "0", "1", "1", "140", "0", "0", "0", "0", "0", "1", "0", "3", "13", "0", "3", "1234568", "5", "0", + "0", "100", "-3", "0", + ] + let expected1 = [ + "0.3", "1.0", "1.0", "140.0", "0.0", "-0.0", "-0.0", "0.0", "0.0", "1.0", "0.1", "2.7", "12.5", "0.1", + "3.1", "1234567.9", "5.2", "0.1", "0.3", "100.0", "-3.2", "0.0", + ] + let expected2 = [ + "0.27", "1.02", "1.02", "140.00", "0.00", "-0.00", "-0.00", "0.00", "0.00", "1.00", "0.06", "2.67", + "12.50", "0.12", "3.14", "1234567.89", "5.20", "0.10", "0.30", "100.00", "-3.25", "0.00", + ] + XCTAssertEqual(inputs.map { LabBookFormat.value($0, key: "ferritin") }, expected0) + XCTAssertEqual(inputs.map { LabBookFormat.value($0, key: "weight") }, expected1) + XCTAssertEqual(inputs.map { LabBookFormat.value($0, key: "tsh") }, expected2) + } +} 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/LiftBannerPushPolicyTests.swift b/StrandTests/LiftBannerPushPolicyTests.swift new file mode 100644 index 0000000000..0f3dc12f5c --- /dev/null +++ b/StrandTests/LiftBannerPushPolicyTests.swift @@ -0,0 +1,53 @@ +import XCTest +@testable import Strand + +/// What the gym banner may spend on a heart rate. The session of 22 Sep 2026 pushed it about 450 times in 75 +/// minutes for a number nobody was reading, and the light-ups a strap double-tap asks for ran 5–10 s late in +/// the middle of it. +final class LiftBannerPushPolicyTests: XCTestCase { + + func testTheSameReadingNeverPushes() { + XCTAssertFalse(LiftBannerPushPolicy.heartRateDue(shown: 128, latest: 128, sinceLastPush: 600)) + XCTAssertFalse(LiftBannerPushPolicy.heartRateDue(shown: nil, latest: nil, sinceLastPush: 600)) + } + + func testNoiseNeverPushes() { + // One beat apart is the same reading with noise on it, however long ago the last push was. + XCTAssertFalse(LiftBannerPushPolicy.heartRateDue(shown: 128, latest: 129, sinceLastPush: 600)) + XCTAssertFalse(LiftBannerPushPolicy.heartRateDue(shown: 128, latest: 127, sinceLastPush: 600)) + } + + func testARealChangePushesOnlyOnceTheIntervalHasPassed() { + XCTAssertFalse(LiftBannerPushPolicy.heartRateDue(shown: 120, latest: 145, sinceLastPush: 29)) + XCTAssertTrue(LiftBannerPushPolicy.heartRateDue(shown: 120, latest: 145, sinceLastPush: 30)) + XCTAssertTrue(LiftBannerPushPolicy.heartRateDue(shown: 145, latest: 120, sinceLastPush: 30)) + } + + /// A strap dropping (or coming back) changes the banner from a number to a dash: a visible change of + /// state, not a moving number, so it is allowed sooner — but still not on every tick. + func testTheStrapAppearingOrDisappearingIsAllowedSooner() { + XCTAssertFalse(LiftBannerPushPolicy.heartRateDue(shown: 128, latest: nil, sinceLastPush: 4)) + XCTAssertTrue(LiftBannerPushPolicy.heartRateDue(shown: 128, latest: nil, sinceLastPush: 5)) + XCTAssertTrue(LiftBannerPushPolicy.heartRateDue(shown: nil, latest: 128, sinceLastPush: 5)) + } + + /// The cost this exists for: a strap streaming once a second through a 75-minute session. + func testASessionOfTicksBecomesAHandfulOfPushes() { + var since: TimeInterval = 0 + var shown: Int? = 100 + var pushes = 0 + for second in 0..<(75 * 60) { + // A heart rate that drifts across a wide range, as a working set does. + let latest = 100 + Int((sin(Double(second) / 40) * 35).rounded()) + if LiftBannerPushPolicy.heartRateDue(shown: shown, latest: latest, sinceLastPush: since) { + pushes += 1 + shown = latest + since = 0 + } else { + since += 1 + } + } + XCTAssertLessThanOrEqual(pushes, 150, "a heart rate alone must not push more than twice a minute") + XCTAssertGreaterThan(pushes, 40, "it still follows a moving heart rate") + } +} diff --git a/StrandTests/LiftFormatNumberTests.swift b/StrandTests/LiftFormatNumberTests.swift new file mode 100644 index 0000000000..fe7adef5de --- /dev/null +++ b/StrandTests/LiftFormatNumberTests.swift @@ -0,0 +1,87 @@ +import XCTest +@testable import Strand + +/// Formatting and parsing for the numbers a user types at the rack. +/// +/// These exist because a real session could not enter a decimal weight at all. Two separate defects +/// combined: `trim` rendered one decimal place, and the entry fields read their text back through it +/// on every keystroke — so "45.5" became "45", then "455". The rounding half is pinned here; the +/// field half is pinned by `LiftSessionView`'s draft (there is no view test target for it). +final class LiftFormatNumberTests: XCTestCase { + + // MARK: - trim + + func testAWholeNumberLosesItsDecimalPoint() { + XCTAssertEqual(LiftFormat.trim(8), "8") + XCTAssertEqual(LiftFormat.trim(60.0), "60") + XCTAssertEqual(LiftFormat.trim(0), "0") + } + + func testOneDecimalSurvives() { + XCTAssertEqual(LiftFormat.trim(7.5), "7.5") + XCTAssertEqual(LiftFormat.trim(45.5), "45.5") + } + + /// The defect: gym plates come in quarter-kilos, and microplates in smaller steps, so 12.25 is a + /// weight people actually lift. One decimal turned it into "12.3" — and because the entry field + /// read that back, the rounded value replaced what was typed. + func testTwoDecimalsSurvive() { + XCTAssertEqual(LiftFormat.trim(12.25), "12.25") + XCTAssertEqual(LiftFormat.trim(2.75), "2.75") + XCTAssertEqual(LiftFormat.trim(102.05), "102.05") + } + + func testTrailingZeroesAreDropped() { + XCTAssertEqual(LiftFormat.trim(45.50), "45.5", "not 45.50") + XCTAssertEqual(LiftFormat.trim(45.00), "45", "not 45.00") + } + + /// Beyond two decimals is rounded, not truncated, and never renders a third digit. + func testBeyondTwoDecimalsRounds() { + XCTAssertEqual(LiftFormat.trim(12.256), "12.26") + XCTAssertEqual(LiftFormat.trim(12.254), "12.25") + XCTAssertEqual(LiftFormat.trim(1.0 / 3.0), "0.33") + } + + /// Always a point, never the device's separator: this is what the whole screen displays, and the + /// entry fields normalise a typed comma to match it. + func testTheSeparatorIsAlwaysAPoint() { + XCTAssertFalse(LiftFormat.trim(7.5).contains(",")) + XCTAssertTrue(LiftFormat.trim(7.5).contains(".")) + } + + // MARK: - number + + func testBothSeparatorsParse() { + XCTAssertEqual(LiftFormat.number("45.5"), 45.5) + XCTAssertEqual(LiftFormat.number("45,5"), 45.5, "a German or French keyboard offers the comma") + XCTAssertEqual(LiftFormat.number("12.25"), 12.25) + XCTAssertEqual(LiftFormat.number("12,25"), 12.25) + } + + /// A half-typed decimal must parse, or the value would vanish the instant the point is typed. + func testAPartiallyTypedDecimalParses() { + XCTAssertEqual(LiftFormat.number("45."), 45) + XCTAssertEqual(LiftFormat.number("45,"), 45) + } + + func testNonsenseIsNilRatherThanZero() { + XCTAssertNil(LiftFormat.number("")) + XCTAssertNil(LiftFormat.number(" ")) + XCTAssertNil(LiftFormat.number("kg")) + XCTAssertNil(LiftFormat.number(".")) + } + + /// The round trip the entry field performs on every keystroke. Whatever a user types must come + /// back as the same number — this is what silently produced 455 from 45.5. + func testTypedTextSurvivesTheRoundTripThroughTheField() { + for typed in ["45.5", "12.25", "60", "7.5", "102.05", "45,5"] { + guard let value = LiftFormat.number(typed) else { + XCTFail("\(typed) did not parse"); continue + } + let shown = LiftFormat.trim(value) + XCTAssertEqual(LiftFormat.number(shown), value, + "\(typed) rendered as \(shown), which no longer reads back as \(value)") + } + } +} diff --git a/StrandTests/LiftLogLineStampTests.swift b/StrandTests/LiftLogLineStampTests.swift new file mode 100644 index 0000000000..8215e7c008 --- /dev/null +++ b/StrandTests/LiftLogLineStampTests.swift @@ -0,0 +1,26 @@ +import XCTest +@testable import Strand + +/// Every line the Lift Log writes into NOOP's strap log carries its own time. +/// +/// NOOP's log takes each line's time from whoever writes it. The Lift Log's lines did not: all 98 of them in +/// Utku's 22 Sep session — every double-tap, every light-up, every session picked up after a restart — so the +/// moment one happened had to be inferred from the neighbouring lines, which is exactly what a strap log is read +/// for. +@MainActor +final class LiftLogLineStampTests: XCTestCase { + + func testAStampedLineLeadsWithItsOwnClock() { + let line = AppModel.stamped("Double-tap → Lift Log: next") + XCTAssertNotNil(line.range(of: #"^\[\d\d:\d\d:\d\d\] Double-tap → Lift Log: next$"#, options: .regularExpression), + line) + } + + /// The same shape as the lines it sits between — `BLEManager`'s own `HH:mm:ss` — so a reader and + /// `dist/tools/strap-log.py` see one format, not two. + func testItMatchesTheShapeOfTheLinesAroundIt() { + let stamp = AppModel.logTimeFormatter.string(from: Date()) + XCTAssertNotNil(stamp.range(of: #"^\d\d:\d\d:\d\d$"#, options: .regularExpression), stamp) + XCTAssertTrue(AppModel.stamped("x").hasPrefix("[\(stamp.prefix(2))")) + } +} diff --git a/StrandTests/LiftSessionAddExerciseTests.swift b/StrandTests/LiftSessionAddExerciseTests.swift new file mode 100644 index 0000000000..f6debe71fb --- /dev/null +++ b/StrandTests/LiftSessionAddExerciseTests.swift @@ -0,0 +1,268 @@ +import XCTest +@testable import Strand +import WhoopStore + +/// An exercise added while the session runs (Utku, 21 Sep 2026): it joins at the end as one set planned at +/// 0 kg × 0 reps, works like any other line, survives a relaunch, and reaches the program only when +/// finishing is told to keep the changes. +@MainActor +final class LiftSessionAddExerciseTests: XCTestCase { + + private let t0 = 1_700_000_000 + + private func plan() -> [LiftPlanItem] { + [LiftPlanItem(exercise: "Bench press", primaryMuscle: .chest, targetSets: 2, + restSec: 60, targetRepsLow: 10, targetWeightKg: 50, programItemId: "bench")] + } + + private func slot(_ e: Int, _ s: Int) -> LiftSlot { LiftSlot(exerciseIndex: e, setIndex: s) } + + private func controller() -> LiftSessionController { + LiftSessionController(buzz: { _ in }, setStrapHandler: { _ in }) + } + + /// The program behind `plan()`: one line, id "bench". + private func programRows() -> [LiftProgramItemRow] { + [LiftProgramItemRow(id: "bench", deviceId: "d", programId: "p", ord: 0, exercise: "Bench press", + targetSets: 2, targetRepsLow: 10, targetRepsHigh: nil, targetRpe: 8, + targetWeightKg: 50, restSec: 60, note: "Pause")] + } + + override func tearDown() { + LiftSessionPersistence.clear() + super.tearDown() + } + + // MARK: - The engine + + func testAnAddedExerciseJoinsAtTheEndWithOneSet() { + var engine = LiftSessionEngine(plan: plan(), startTs: t0) + XCTAssertTrue(engine.addExercise(LiftPlanItem(exercise: "Cable fly", targetSets: 4))) + XCTAssertEqual(engine.plan.map(\.exercise), ["Bench press", "Cable fly"]) + XCTAssertEqual(engine.plan[1].targetSets, 1, "a line starts with one set, whatever it was given") + XCTAssertEqual(engine.slots(forExercise: 1), [slot(1, 1)]) + XCTAssertEqual(engine.unperformedSlots.count, 3) + } + + /// With everything planned done, the session waits at 0:00 — and the added set is where the next tap goes. + func testTheNextTapReachesAnExerciseAddedAfterEverythingElseWasDone() { + var engine = LiftSessionEngine(plan: plan(), startTs: t0) + for i in 1...5 { engine.advance(now: t0 + i * 100) } // both bench sets, both rests, then on + XCTAssertTrue(engine.allCompleted) + XCTAssertEqual(engine.stage, .resting(slot(0, 2), endsAt: t0 + 500), "sheet complete: waiting") + + engine.addExercise(LiftPlanItem(exercise: "Cable fly")) + XCTAssertFalse(engine.allCompleted) + XCTAssertEqual(engine.upcomingSlot, slot(1, 1)) + engine.advance(now: t0 + 600) + XCTAssertEqual(engine.stage, .working(slot(1, 1))) + } + + func testAddingAnExerciseCanBeUndone() { + var engine = LiftSessionEngine(plan: plan(), startTs: t0) + engine.addExercise(LiftPlanItem(exercise: "Cable fly")) + engine.undo() + XCTAssertEqual(engine.plan.map(\.exercise), ["Bench press"]) + } + + func testNothingIsAddedToAFinishedSessionOrPastTheBound() { + var finished = LiftSessionEngine(plan: plan(), startTs: t0) + finished.finish(now: t0 + 10) + XCTAssertFalse(finished.addExercise(LiftPlanItem(exercise: "Cable fly"))) + XCTAssertEqual(finished.plan.count, 1) + + let full = (0.. LiftSetRow { + LiftSetRow(id: id, deviceId: "d", sessionId: "x", ord: ord, exercise: "Squat", + primaryMuscle: .quads, secondaryMuscles: [.glutes], setIndex: setIndex, + weightKg: weightKg, reps: reps, rpe: rpe, + isWarmup: false, startTs: 100, endTs: 140, restSec: 90, note: nil) + } + + private func entries(_ rows: [LiftSetRow]) -> [Sheet.Entry] { + rows.map { Sheet.Entry(id: $0.id, form: Sheet.form(for: $0, system: .metric)) } + } + + // MARK: - Editing a set's fields + + /// Opening and saving an untouched pound value must not nudge the stored kilograms. + func testAnUntouchedFieldIsNotWrittenBack() { + let before = Sheet.form(for: row(), system: .imperial) + var after = before + after.reps = "10" + let edited = Sheet.applying(after, over: before, to: row(), system: .imperial) + XCTAssertEqual(edited.weightKg, 60, "exactly the stored kilograms, not a round trip through pounds") + XCTAssertEqual(edited.reps, 10) + } + + func testAChangedWeightIsReadInTheDisplayUnit() { + let before = Sheet.form(for: row(), system: .imperial) + var after = before + after.weight = "135" + let edited = Sheet.applying(after, over: before, to: row(), system: .imperial) + XCTAssertEqual(edited.weightKg ?? 0, LiftFormat.kilograms(fromDisplay: 135, system: .imperial), + accuracy: 1e-9) + } + + func testADecimalCommaAndAClearedFieldAreHonoured() { + let before = Sheet.form(for: row(rpe: 8), system: .metric) + var after = before + after.weight = "62,5" + after.rpe = "" + let edited = Sheet.applying(after, over: before, to: row(rpe: 8), system: .metric) + XCTAssertEqual(edited.weightKg, 62.5) + XCTAssertNil(edited.rpe, "a cleared field clears the value rather than keeping the old one") + } + + func testMarkingAWarmUpAfterTheFactApplies() { + let before = Sheet.form(for: row(), system: .metric) + var after = before + after.isWarmup = true + XCTAssertTrue(Sheet.applying(after, over: before, to: row(), system: .metric).isWarmup) + } + + // MARK: - Adding and removing sets + + func testAnUntouchedSessionWritesNothing() { + let rows = [row("a", ord: 0, setIndex: 1), row("b", ord: 1, setIndex: 2)] + let change = Sheet.changes(from: rows, to: [.init(name: "Squat", entries: entries(rows))], system: .metric) + XCTAssertTrue(change.upserts.isEmpty) + XCTAssertTrue(change.deletedIds.isEmpty) + } + + /// An added set is a new row, numbered after the exercise's others and ordered after every set the + /// session had, with the exercise's muscles and no timing to invent. + func testAnAddedSetBecomesANewRow() { + let rows = [row("a", ord: 0, setIndex: 1), row("b", ord: 1, setIndex: 2)] + var list = entries(rows) + list.append(.init(id: "new", form: .init(weight: "62.5", reps: "6", rpe: "", isWarmup: false))) + let change = Sheet.changes(from: rows, to: [.init(name: "Squat", entries: list)], system: .metric) + + XCTAssertEqual(change.upserts.count, 1) + let added = change.upserts[0] + XCTAssertEqual(added.id, "new") + XCTAssertEqual(added.setIndex, 3) + XCTAssertEqual(added.ord, 2) + XCTAssertEqual(added.weightKg, 62.5) + XCTAssertEqual(added.reps, 6) + XCTAssertEqual(added.primaryMuscle, .quads) + XCTAssertEqual(added.secondaryMuscles, [.glutes]) + XCTAssertNil(added.startTs) + XCTAssertTrue(change.deletedIds.isEmpty) + } + + /// Removing a set deletes its row and closes the gap, so the session still reads 1, 2. + func testARemovedSetIsDeletedAndTheRestRenumbered() { + let rows = [row("a", ord: 0, setIndex: 1), row("b", ord: 1, setIndex: 2), row("c", ord: 2, setIndex: 3)] + let list = entries(rows).filter { $0.id != "b" } + let change = Sheet.changes(from: rows, to: [.init(name: "Squat", entries: list)], system: .metric) + + XCTAssertEqual(change.deletedIds, ["b"]) + XCTAssertEqual(change.upserts.map(\.id), ["c"], "only the set whose number moved is rewritten") + XCTAssertEqual(change.upserts.first?.setIndex, 2) + } + + /// A set discarded at finish is saved at 0 × 0 and shows only here; typing its numbers makes it a + /// performed set again, which every figure then counts. + func testFillingInADiscardedSetMakesItCountAgain() { + let discarded = row("z", weightKg: 0, reps: 0) + XCTAssertFalse(LiftMetrics.isPerformed(reps: discarded.reps)) + let before = Sheet.form(for: discarded, system: .metric) + XCTAssertEqual(before.reps, "0") + var after = before + after.weight = "70" + after.reps = "8" + let edited = Sheet.applying(after, over: before, to: discarded, system: .metric) + XCTAssertEqual(edited.weightKg, 70) + XCTAssertTrue(LiftMetrics.isPerformed(reps: edited.reps)) + } +} diff --git a/StrandTests/LiftSessionEngineTests.swift b/StrandTests/LiftSessionEngineTests.swift new file mode 100644 index 0000000000..6776305b2c --- /dev/null +++ b/StrandTests/LiftSessionEngineTests.swift @@ -0,0 +1,688 @@ +import XCTest +@testable import Strand +import WhoopStore + +/// The Lift Log session state machine. Pure, so a whole gym session can be driven through a known +/// timeline with no strap, no database and no simulator — which is the point of keeping it pure. +final class LiftSessionEngineTests: XCTestCase { + + private let t0 = 1_700_000_000 + + /// Two exercises: 2 sets then 1 set. Small enough to assert every transition by hand. + private func twoExercisePlan() -> [LiftPlanItem] { + [ + LiftPlanItem(exercise: "Incline dumbbell press", + primaryMuscle: .chest, secondaryMuscles: [.frontDelts, .triceps], + targetSets: 2, restSec: 90), + LiftPlanItem(exercise: "Lat pulldown", + primaryMuscle: .lats, secondaryMuscles: [.biceps], + targetSets: 1, restSec: 60), + ] + } + + /// Three exercises, so an EARLIER one can be left pending while a later one is worked. + private func threeExercisePlan() -> [LiftPlanItem] { + [ + LiftPlanItem(exercise: "Leg press", primaryMuscle: .quads, targetSets: 3, restSec: 90), + LiftPlanItem(exercise: "Lying leg curl", primaryMuscle: .hamstrings, targetSets: 3, restSec: 90), + LiftPlanItem(exercise: "Leg extension", primaryMuscle: .quads, targetSets: 2, restSec: 60), + ] + } + + /// One line carrying the targets a program actually plans. + private func targetedPlanItem() -> LiftPlanItem { + LiftPlanItem(exercise: "Leg press", primaryMuscle: .quads, targetSets: 3, + restSec: 60, targetRepsLow: 10, targetWeightKg: 50) + } + + private func slot(_ e: Int, _ s: Int) -> LiftSlot { LiftSlot(exerciseIndex: e, setIndex: s) } + + // MARK: - The sheet + + func testTheSheetListsEverySetOfEveryExercise() { + let e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + XCTAssertEqual(e.slots(forExercise: 0), [slot(0, 1), slot(0, 2)]) + XCTAssertEqual(e.slots(forExercise: 1), [slot(1, 1)]) + XCTAssertEqual(e.allSlots.count, 3) + XCTAssertEqual(e.plannedWorkingSets, 3) + } + + func testASessionStartsInTheWarmUpWithNothingCompleted() { + let e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + XCTAssertEqual(e.stage, .warmup) + XCTAssertTrue(e.sets.isEmpty) + XCTAssertFalse(e.canUndo) + XCTAssertEqual(e.nextPendingSlot, slot(0, 1)) + } + + // MARK: - The occupied-machine path (reported from a real session) + + /// Skipping an exercise because its machine is busy must not drag the session back to it after + /// every set. Reported from the gym: "I switched to a different move because the machine was + /// occupied. When I double-tap for the next set, it reverts to the first set of the exercise I + /// couldn't do earlier." + func testFinishingASetStaysOnTheSameExerciseEvenWithAnEarlierOneSkipped() { + var e = LiftSessionEngine(plan: threeExercisePlan(), startTs: t0) + + // Exercise 0's machine is busy — start exercise 2 instead. + e.start(slot(2, 1), now: t0 + 60) + e.advance(now: t0 + 100) // set done -> rest + XCTAssertEqual(e.stage, .resting(slot(2, 1), endsAt: t0 + 100 + 60)) + + e.advance(now: t0 + 160) // rest done -> next set + XCTAssertEqual(e.stage, .working(slot(2, 2)), + "must continue on the machine the user is standing at, not jump back to 0") + } + + /// And once that exercise IS finished, the skipped one is exactly what comes next — it was + /// deferred, not abandoned. + func testTheSkippedExerciseIsWhatComesNextOnceTheCurrentOneIsDone() { + var e = LiftSessionEngine(plan: threeExercisePlan(), startTs: t0) + e.start(slot(2, 1), now: t0 + 60) + e.advance(now: t0 + 100); e.advance(now: t0 + 160) // set 1 done, on to set 2 + e.advance(now: t0 + 200) // set 2 done -> rest + e.advance(now: t0 + 260) // rest done -> exercise 2 finished + + XCTAssertEqual(e.stage, .working(slot(0, 1)), + "with exercise 2 complete, the deferred exercise 0 is next") + } + + func testSlotAfterPrefersTheSameExerciseThenFallsBackToPlanOrder() { + var e = LiftSessionEngine(plan: threeExercisePlan(), startTs: t0) + XCTAssertEqual(e.slotAfter(slot(2, 1)), slot(2, 1), "its own set is still pending") + + e.start(slot(2, 1), now: t0); e.advance(now: t0 + 40) + XCTAssertEqual(e.slotAfter(slot(2, 1)), slot(2, 2)) + + e.advance(now: t0 + 100); e.advance(now: t0 + 140) // finish exercise 2 entirely + XCTAssertEqual(e.slotAfter(slot(2, 2)), slot(0, 1), "exhausted -> first pending in plan order") + } + + // MARK: - What comes next (the bar and the Lock Screen) + + /// "Next" names the set the taps will actually reach — never the rest in between, and never a + /// guess of its own. Walked through a whole session with an exercise skipped and come back to, + /// the set named during a set and during its rest is the one `advance` then lands on. + func testTheNextSetNamedIsTheOneTheTapsReach() { + var e = LiftSessionEngine(plan: threeExercisePlan(), startTs: t0) + XCTAssertEqual(e.upcomingSlot, slot(0, 1), "the warm-up names the set the first tap starts") + + e.start(slot(2, 1), now: t0 + 10) // exercise 0's machine is busy + var now = t0 + 10 + while !e.allCompleted { + guard case .working(let current) = e.stage else { return XCTFail("expected a set, got \(e.stage)") } + let namedWhileWorking = e.upcomingSlot + now += 30; e.advance(now: now) // set done -> rest + XCTAssertEqual(e.upcomingSlot, namedWhileWorking, "the rest names what the set named") + now += 60; e.advance(now: now) // rest done -> next set + if e.allCompleted { + XCTAssertNil(namedWhileWorking, "\(current) was the last set, so nothing was next") + } else { + XCTAssertEqual(e.stage, .working(namedWhileWorking!), "after \(current)") + } + } + XCTAssertNil(e.upcomingSlot, "a complete sheet has nothing next") + } + + /// The last set of an exercise names the next exercise, and the order is the one a gym forces: + /// the rest of the machine you are at first, then the skipped exercise. + func testTheLastSetOfAnExerciseNamesTheNextExercise() { + var e = LiftSessionEngine(plan: threeExercisePlan(), startTs: t0) + e.start(slot(2, 1), now: t0) + XCTAssertEqual(e.upcomingSlot, slot(2, 2), "the same machine first") + e.advance(now: t0 + 30); e.advance(now: t0 + 90) + XCTAssertEqual(e.stage, .working(slot(2, 2))) + XCTAssertEqual(e.upcomingSlot, slot(0, 1), "exercise 2 is done after this set: the skipped one") + } + + // MARK: - Grey numbers + // + // A finished set records its timing only. Its numbers stay grey until typed, and what a set without + // typed numbers saves is decided when the session is finished (`LiftSessionFinishTests`). + + /// Finishing a set without typing writes nothing into it: the program's target stays a grey + /// suggestion the user can type straight over, and still counts as the numbers the sheet showed. + func testFinishingASetWithoutTypingLeavesItsNumbersGrey() { + var e = LiftSessionEngine(plan: [targetedPlanItem()], startTs: t0) + e.advance(now: t0 + 10) // warm-up -> working set 1 + e.advance(now: t0 + 70) // set done + + let row = e.recordedSet(for: slot(0, 1)) + XCTAssertNil(row?.weightKg, "a grey number is not an entry") + XCTAssertNil(row?.reps) + XCTAssertEqual(e.values(of: slot(0, 1), lastSession: [:]), LiftSetCarry(weightKg: 50, reps: 10)) + XCTAssertFalse(e.unperformedSlots.contains(slot(0, 1)), "done, so complete even with nothing typed") + } + + /// The second set follows what the FIRST set counts as — if you dropped to 45 kg, set 2 follows + /// you down rather than snapping back to the program. + func testGreyNumbersFollowWhatTheExerciseDidEarlierInTheSession() { + var e = LiftSessionEngine(plan: [targetedPlanItem()], startTs: t0) + e.advance(now: t0 + 10) + e.advance(now: t0 + 70) + e.updateSet(slot(0, 1), weightKg: 45, reps: 8, rpe: 9, isWarmup: false) + + XCTAssertEqual(e.carry(for: slot(0, 2), lastSession: [:]), LiftSetCarry(weightKg: 45, reps: 8), + "the session's own history outranks the program's plan") + } + + /// An untyped set 1 still leads set 2, and correcting set 1 later moves set 2's grey numbers with it: + /// nothing was written into set 2 that would have to be typed over. + func testCorrectingAnEarlierSetMovesTheGreyNumbersAfterIt() { + var e = LiftSessionEngine(plan: [targetedPlanItem()], startTs: t0) + e.advance(now: t0 + 10) + e.advance(now: t0 + 70) // set 1 done, untyped + e.advance(now: t0 + 130) + e.advance(now: t0 + 190) // set 2 done, untyped + XCTAssertEqual(e.values(of: slot(0, 2), lastSession: [:]).weightKg, 50) + + e.updateSet(slot(0, 1), weightKg: 42.5, reps: nil, rpe: nil, isWarmup: false) + XCTAssertEqual(e.values(of: slot(0, 2), lastSession: [:]).weightKg, 42.5) + XCTAssertEqual(e.values(of: slot(0, 2), lastSession: [:]).reps, 10, + "a field left alone keeps following its own chain") + } + + /// The store's answer sits between this session and the program target. + func testLastSessionIsUsedWhenTheSessionHasNoEarlierSetForTheExercise() { + let e = LiftSessionEngine(plan: [targetedPlanItem()], startTs: t0) + let last = [1: LiftSetCarry(weightKg: 52.5, reps: 9)] + XCTAssertEqual(e.carry(for: slot(0, 1), lastSession: last), LiftSetCarry(weightKg: 52.5, reps: 9), + "last session beats the program's target") + XCTAssertEqual(e.carry(for: slot(0, 3), lastSession: last), LiftSetCarry(weightKg: 50, reps: 10), + "a set number last session did not have falls through to the target") + } + + /// RPE is never carried: it is how hard a set FELT, which nothing can know in advance, and + /// inventing it would make the RPE card report full coverage for sets nobody rated. + func testRpeIsNeverCarried() { + var e = LiftSessionEngine(plan: [targetedPlanItem()], startTs: t0) + e.advance(now: t0 + 10) + e.advance(now: t0 + 70) + e.updateSet(slot(0, 1), weightKg: 50, reps: 10, rpe: 8.5, isWarmup: false) + e.advance(now: t0 + 130) + e.advance(now: t0 + 190) + + XCTAssertEqual(e.values(of: slot(0, 2), lastSession: [:]).weightKg, 50, "weight carries") + XCTAssertNil(e.recordedSet(for: slot(0, 2))?.rpe, "the felt effort of a set does not") + } + + /// Nothing to carry stays nil rather than inventing a zero — a set with no plan, no history and + /// nothing typed genuinely has no measurement, and 0 kg would be a false one. + func testASetWithNothingToCarryStaysEmpty() { + var e = LiftSessionEngine(plan: [LiftPlanItem(exercise: "Curl", targetSets: 1)], startTs: t0) + e.advance(now: t0 + 10) + e.advance(now: t0 + 70) + + XCTAssertEqual(e.values(of: slot(0, 1), lastSession: [:]), LiftSetCarry.none) + } + + /// A typed value beats the grey one, including a 0 for a set that was planned but not performed. + func testTypingZeroOverAGreyValueSticks() { + var e = LiftSessionEngine(plan: [targetedPlanItem()], startTs: t0) + e.advance(now: t0 + 10) + e.advance(now: t0 + 70) + e.updateSet(slot(0, 1), weightKg: 0, reps: 0, rpe: nil, isWarmup: false) + XCTAssertEqual(e.values(of: slot(0, 1), lastSession: [:]), LiftSetCarry(weightKg: 0, reps: 0)) + } + + /// Only a set never started is unperformed. A set that was done counts as done whether or not + /// anything was typed into it (Utku, 21 Sep 2026). + func testUnperformedSlotsAreTheOnesNeverStarted() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.start(slot(0, 1), now: t0); e.advance(now: t0 + 40) // done, untyped + e.start(slot(0, 2), now: t0 + 100); e.advance(now: t0 + 140) + e.updateSet(slot(0, 2), weightKg: nil, reps: nil, rpe: 8, isWarmup: false) + XCTAssertEqual(e.unperformedSlots, [slot(1, 1)], "only the set nobody started") + } + + // MARK: - The default in-order path + + func testTheFullTapThroughRecordsEverySetInOrder() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + + e.advance(now: t0 + 300) // warm-up → set 1 + XCTAssertEqual(e.stage, .working(slot(0, 1))) + + e.advance(now: t0 + 340) // done → rest (90s) + XCTAssertEqual(e.stage, .resting(slot(0, 1), endsAt: t0 + 430)) + e.updateSet(slot(0, 1), weightKg: 30, reps: 10, rpe: 8, isWarmup: false) + + e.advance(now: t0 + 440) // rest → set 2 + XCTAssertEqual(e.stage, .working(slot(0, 2))) + + e.advance(now: t0 + 480) + e.updateSet(slot(0, 2), weightKg: 30, reps: 8, rpe: 9, isWarmup: false) + + e.advance(now: t0 + 580) // rest → next exercise + XCTAssertEqual(e.stage, .working(slot(1, 1))) + + e.advance(now: t0 + 620) + e.updateSet(slot(1, 1), weightKg: 55, reps: 12, rpe: 7, isWarmup: false) + + XCTAssertTrue(e.allCompleted) + XCTAssertEqual(e.sets.count, 3) + XCTAssertEqual(e.sets.map(\.reps), [10, 8, 12]) + } + + func testFinishClosesTheRunningRestSoItsDurationIsNotLost() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.advance(now: t0) + e.advance(now: t0 + 40) // resting from t0+40 + e.finish(now: t0 + 160) + XCTAssertEqual(e.stage, .finished) + XCTAssertEqual(e.sets[0].restSec, 120, "the rest that was running still happened") + } + + // MARK: - Out of order: the reason this model exists + + func testAnyPendingSetCanBeStartedWhenAMachineIsBusy() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.start(slot(1, 1), now: t0 + 60) // skip straight to the second exercise + XCTAssertEqual(e.stage, .working(slot(1, 1))) + + e.advance(now: t0 + 100) + e.updateSet(slot(1, 1), weightKg: 55, reps: 12, rpe: nil, isWarmup: false) + XCTAssertTrue(e.isCompleted(slot(1, 1))) + XCTAssertEqual(e.nextPendingSlot, slot(0, 1), + "the skipped sets are still outstanding and come back round") + } + + func testAdvancingFromRestGoesToTheFirstOUTSTANDINGSetNotTheNextInLine() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.start(slot(0, 2), now: t0) // did set 2 first + e.advance(now: t0 + 40) // → resting + e.advance(now: t0 + 140) // → next OUTSTANDING + XCTAssertEqual(e.stage, .working(slot(0, 1)), + "set 1 was never done, so it is what comes next") + } + + func testStartingAnAlreadyCompletedSetRedoesItRatherThanDoubleCounting() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.advance(now: t0) + e.advance(now: t0 + 40) + e.updateSet(slot(0, 1), weightKg: 30, reps: 10, rpe: nil, isWarmup: false) + XCTAssertEqual(e.sets.count, 1) + + e.start(slot(0, 1), now: t0 + 200) // redo it + XCTAssertEqual(e.sets.count, 0, "the old record is dropped, not duplicated") + XCTAssertEqual(e.stage, .working(slot(0, 1))) + } + + func testStartingAnotherSetMidSetRecordsNothingForTheAbandonedOne() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.advance(now: t0) // working set 1 + e.start(slot(1, 1), now: t0 + 20) // changed mind + XCTAssertTrue(e.sets.isEmpty, "an unfinished set is not a set") + XCTAssertEqual(e.stage, .working(slot(1, 1))) + } + + func testAdvancingWhenEverythingIsDoneDoesNotInventASet() { + var e = LiftSessionEngine(plan: [LiftPlanItem(exercise: "Curl", targetSets: 1)], startTs: t0) + e.advance(now: t0) + e.advance(now: t0 + 30) // → resting, all done + e.advance(now: t0 + 120) // nothing left to start + XCTAssertEqual(e.sets.count, 1) + XCTAssertTrue(e.allCompleted) + } + + // MARK: - Time + + func testRestIsAnchoredToAnAbsoluteInstantNotACountdown() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.advance(now: t0) + e.advance(now: t0 + 10) // rest ends at t0+100 + + XCTAssertEqual(e.restRemaining(now: t0 + 10), 90) + XCTAssertEqual(e.restRemaining(now: t0 + 55), 45) + // A phone sleeping through a rest must not "pause" it — the answer depends only on the clock. + XCTAssertEqual(e.restRemaining(now: t0 + 100), 0) + } + + func testAnOverrunRestFloorsAtZeroAndNeverAutoAdvances() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.advance(now: t0) + e.advance(now: t0 + 10) + XCTAssertEqual(e.restRemaining(now: t0 + 5_000), 0, "an overrun rest reads 0:00, never negative") + XCTAssertEqual(e.stage, .resting(slot(0, 1), endsAt: t0 + 100), + "rest waits for the user; nothing starts a set on its own") + } + + func testRestRecordedIsWhatWasActuallyTakenNotWhatWasPlanned() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.advance(now: t0) + e.advance(now: t0 + 10) // planned 90s + e.advance(now: t0 + 210) // actually rested 200s + XCTAssertEqual(e.sets[0].restSec, 200, + "rest is measured from the taps, not assumed from the plan") + } + + func testASetCarriesTheDurationItWasPerformedOver() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.advance(now: t0 + 300) + e.advance(now: t0 + 345) + XCTAssertEqual(e.sets[0].startTs, t0 + 300) + XCTAssertEqual(e.sets[0].endTs, t0 + 345) + } + + // MARK: - Entering what you lifted + + func testASetIsRecordedWithItsTimingBeforeAnyNumbersAreTyped() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.advance(now: t0 + 300) + e.advance(now: t0 + 345) + XCTAssertEqual(e.sets.count, 1) + XCTAssertNil(e.sets[0].weightKg, "numbers are typed during the rest, not while lifting") + XCTAssertNil(e.sets[0].reps) + } + + func testTypingEditsTheSetRatherThanAddingOne() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.advance(now: t0) + e.advance(now: t0 + 40) + e.updateSet(slot(0, 1), weightKg: 30, reps: 10, rpe: nil, isWarmup: false) + e.updateSet(slot(0, 1), weightKg: 32.5, reps: 9, rpe: 8, isWarmup: false) + XCTAssertEqual(e.sets.count, 1) + XCTAssertEqual(e.sets[0].weightKg, 32.5) + XCTAssertEqual(e.sets[0].rpe, 8) + } + + func testTypingIntoASetThatWasNeverPerformedInventsNothing() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.updateSet(slot(0, 1), weightKg: 100, reps: 5, rpe: 10, isWarmup: false) + XCTAssertTrue(e.sets.isEmpty, "a number nobody performed must never become a set") + } + + func testAWarmUpSetIsRecordedButDoesNotCountAsAWorkingSet() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.advance(now: t0) + e.advance(now: t0 + 40) + e.updateSet(slot(0, 1), weightKg: 20, reps: 12, rpe: nil, isWarmup: true) + XCTAssertEqual(e.sets.count, 1) + XCTAssertEqual(e.completedWorkingSets, 0, + "studies count working sets; a warm-up must not inflate the tally") + } + + // MARK: - Warm-ups + + func testAWarmUpMarkedBeforeTheSetIsPerformedSurvivesOntoIt() { + // You know a set is a warm-up on the way IN. The mark is held until the set exists, then + // applied — the regression this guards against silently counted every warm-up as a working + // set, inflating the one figure the whole feature rests on. + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.advance(now: t0) // working set 1 + e.advance(now: t0 + 40) // recorded + e.updateSet(slot(0, 1), weightKg: 20, reps: 12, rpe: nil, isWarmup: true) + + XCTAssertTrue(e.sets[0].isWarmup) + XCTAssertEqual(e.completedWorkingSets, 0, + "a warm-up must not count toward the working-set tally") + } + + func testAWarmUpCanBeUnmarkedBackToAWorkingSet() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.advance(now: t0) + e.advance(now: t0 + 40) + e.updateSet(slot(0, 1), weightKg: 20, reps: 12, rpe: nil, isWarmup: true) + XCTAssertEqual(e.completedWorkingSets, 0) + + e.updateSet(slot(0, 1), weightKg: 20, reps: 12, rpe: nil, isWarmup: false) + XCTAssertEqual(e.completedWorkingSets, 1, "un-marking restores it to a working set") + } + + func testAWarmUpStillCarriesItsWeightAndRepsForTheRecord() { + // Excluded from the COUNTS, but still logged: what you warmed up with is worth keeping. + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.advance(now: t0) + e.advance(now: t0 + 40) + e.updateSet(slot(0, 1), weightKg: 20, reps: 12, rpe: nil, isWarmup: true) + XCTAssertEqual(e.sets[0].weightKg, 20) + XCTAssertEqual(e.sets[0].reps, 12) + } + + // MARK: - Ghost values + + func testThePreviousSetInThisSessionIsWhatASetGhostsFrom() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.advance(now: t0) + e.advance(now: t0 + 40) + e.updateSet(slot(0, 1), weightKg: 30, reps: 10, rpe: 8, isWarmup: false) + + let ghost = e.previousSetInSession(for: slot(0, 2)) + XCTAssertEqual(ghost?.weightKg, 30, "set 2 ghosts from set 1 of the same exercise") + XCTAssertEqual(ghost?.reps, 10) + } + + func testTheFirstSetOfAnExerciseHasNothingToGhostFrom() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.advance(now: t0) + e.advance(now: t0 + 40) + e.updateSet(slot(0, 1), weightKg: 30, reps: 10, rpe: 8, isWarmup: false) + XCTAssertNil(e.previousSetInSession(for: slot(0, 1))) + XCTAssertNil(e.previousSetInSession(for: slot(1, 1)), + "a different exercise never ghosts from this one") + } + + // MARK: - Undo + + func testUndoRestoresTheStageAndRemovesTheRecordedSet() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.advance(now: t0) + e.advance(now: t0 + 40) + XCTAssertEqual(e.sets.count, 1) + e.undo() + XCTAssertEqual(e.stage, .working(slot(0, 1))) + XCTAssertTrue(e.sets.isEmpty, "undoing a mis-tap must take the set back with it") + } + + func testUndoWalksAllTheWayBackToTheWarmUp() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.advance(now: t0) + e.advance(now: t0 + 40) + e.advance(now: t0 + 140) + while e.canUndo { e.undo() } + XCTAssertEqual(e.stage, .warmup) + XCTAssertTrue(e.sets.isEmpty) + } + + func testUndoIsGlobalAndWalksBackACROSSExercises() { + // Undo is one stack for the WHOLE session, not a per-exercise one: from the last set of the + // last exercise you can walk all the way back to the first set of the first. + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.advance(now: t0) // ex0 set1 + e.advance(now: t0 + 40) + e.updateSet(slot(0, 1), weightKg: 30, reps: 10, rpe: nil, isWarmup: false) + e.advance(now: t0 + 140) // ex0 set2 + e.advance(now: t0 + 180) + e.advance(now: t0 + 280) // ex1 set1 + e.advance(now: t0 + 320) + XCTAssertEqual(e.sets.count, 3) + XCTAssertEqual(e.currentSlot?.exerciseIndex, 1) + + // One undo steps back out of the SECOND exercise into the first — no boundary in the way. + e.undo() + XCTAssertEqual(e.sets.count, 2) + e.undo() + XCTAssertEqual(e.currentSlot?.exerciseIndex, 0, + "undo crosses from one exercise back into the previous one") + + while e.canUndo { e.undo() } + XCTAssertEqual(e.stage, .warmup) + XCTAssertTrue(e.sets.isEmpty, "the whole session unwinds, set by set, with no cap") + } + + func testUndoHasNoDepthLimit() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + for i in 0..<60 { e.advance(now: t0 + i * 10) } // far more steps than the plan has + var undone = 0 + while e.canUndo { e.undo(); undone += 1 } + XCTAssertGreaterThan(undone, 10, "undo depth is not capped") + XCTAssertEqual(e.stage, .warmup) + } + + func testUndoOnAFreshSessionIsHarmless() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.undo() + XCTAssertEqual(e.stage, .warmup) + } + + func testUndoTakesBackARedoRestoringTheSetItDropped() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.advance(now: t0) + e.advance(now: t0 + 40) + e.updateSet(slot(0, 1), weightKg: 30, reps: 10, rpe: nil, isWarmup: false) + e.start(slot(0, 1), now: t0 + 200) // redo drops it + XCTAssertTrue(e.sets.isEmpty) + e.undo() + XCTAssertEqual(e.sets.count, 1, "a redo started by accident is recoverable") + XCTAssertEqual(e.sets[0].weightKg, 30) + } + + // MARK: - Adding and dropping a set mid-session + // + // A program is what you INTENDED. Five sets when it says four is ordinary, and so is stopping at + // three — and before this the fifth set was performed and then simply lost, because the sheet + // drew exactly `1...targetSets`. + + func testAnAddedSetBecomesATappableRowAndCountsTowardThePlan() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + XCTAssertEqual(e.plannedWorkingSets, 3) + XCTAssertTrue(e.addSet(toExercise: 0)) + XCTAssertEqual(e.slots(forExercise: 0), [slot(0, 1), slot(0, 2), slot(0, 3)]) + XCTAssertEqual(e.plannedWorkingSets, 4) + XCTAssertFalse(e.allCompleted) + } + + /// The whole point: the extra set has to be recordable, with its own numbers. + func testAnExtraSetIsWhereTheSessionGoesNextAndRecordsWhatItWasDoing() { + var e = LiftSessionEngine(plan: [LiftPlanItem(exercise: "Curl", targetSets: 1, restSec: 60)], + startTs: t0) + e.advance(now: t0) // set 1 + e.advance(now: t0 + 40) // set 1 done, resting + XCTAssertTrue(e.allCompleted, "the plan is finished, as written") + + e.addSet(toExercise: 0) + XCTAssertFalse(e.allCompleted, "and now it is not — there is one more to do") + e.advance(now: t0 + 160) // out of the rest, into set 2 + XCTAssertEqual(e.stage, .working(slot(0, 2))) + e.advance(now: t0 + 200) + XCTAssertEqual(e.sets.count, 2) + XCTAssertEqual(e.sets.last?.setIndex, 2) + XCTAssertEqual(e.values(of: slot(0, 2), lastSession: [2: LiftSetCarry(weightKg: 20, reps: 12)]).reps, 12, + "an added set shows grey numbers like any other") + } + + func testAddingSetsStopsAtTheBound() { + var e = LiftSessionEngine(plan: [LiftPlanItem(exercise: "Curl", targetSets: 1)], startTs: t0) + while e.addSet(toExercise: 0) { } + XCTAssertEqual(e.slots(forExercise: 0).count, LiftSessionEngine.maxSetsPerExercise) + XCTAssertFalse(e.addSet(toExercise: 0), "a stuck finger cannot grow the sheet without end") + } + + func testAddingASetToALineThatIsNotThereDoesNothing() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + XCTAssertFalse(e.addSet(toExercise: 99)) + XCTAssertEqual(e.plannedWorkingSets, 3) + } + + func testDroppingTheLastSetTakesItOffTheSheet() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + XCTAssertTrue(e.canRemoveSet(fromExercise: 0)) + XCTAssertTrue(e.removeSet(fromExercise: 0)) + XCTAssertEqual(e.slots(forExercise: 0), [slot(0, 1)]) + XCTAssertEqual(e.plannedWorkingSets, 2) + } + + /// The minus edits a PLAN. A completed set is data — deleting it from here would throw away a + /// set that was actually performed. + func testACompletedSetIsNeverDroppedByTheMinus() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.start(slot(0, 2), now: t0) + e.advance(now: t0 + 40) // set 2 recorded + // Move the session OFF that set, so the only thing protecting it is that it was performed. + e.start(slot(1, 1), now: t0 + 100) + XCTAssertNotEqual(e.currentSlot, slot(0, 2)) + XCTAssertTrue(e.isCompleted(slot(0, 2))) + + XCTAssertFalse(e.canRemoveSet(fromExercise: 0)) + XCTAssertFalse(e.removeSet(fromExercise: 0)) + XCTAssertEqual(e.slots(forExercise: 0).count, 2, "what was logged stays on the sheet") + XCTAssertEqual(e.sets.count, 1, "and stays recorded") + } + + func testTheSetTheSessionIsStandingOnIsNeverDropped() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.start(slot(0, 2), now: t0) + XCTAssertEqual(e.stage, .working(slot(0, 2))) + XCTAssertFalse(e.canRemoveSet(fromExercise: 0)) + XCTAssertEqual(e.slots(forExercise: 0).count, 2) + } + + func testTheLastRemainingSetIsNeverDropped() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + XCTAssertFalse(e.canRemoveSet(fromExercise: 1), "a line with one set has nothing to give up") + XCTAssertFalse(e.removeSet(fromExercise: 1)) + XCTAssertEqual(e.slots(forExercise: 1), [slot(1, 1)]) + } + + func testUndoTakesBackAnAddedSet() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.addSet(toExercise: 0) + XCTAssertEqual(e.plannedWorkingSets, 4) + e.undo() + XCTAssertEqual(e.plannedWorkingSets, 3, "undo takes back the plan change, not only sets") + XCTAssertEqual(e.slots(forExercise: 0), [slot(0, 1), slot(0, 2)]) + } + + func testUndoPutsADroppedSetBack() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.removeSet(fromExercise: 0) + e.undo() + XCTAssertEqual(e.slots(forExercise: 0), [slot(0, 1), slot(0, 2)]) + } + + /// The reason the plan travels in the undo snapshot at all. + /// + /// Start the last set, walk away to another exercise (which leaves it pending), drop it, then + /// undo back past the drop. Without the plan in the snapshot the stage would be restored onto a + /// slot the sheet no longer draws — and completing it would write a set nobody could see. + func testUndoingPastADroppedSetCannotStrandTheSessionOnASlotThatIsGone() { + var e = LiftSessionEngine(plan: threeExercisePlan(), startTs: t0) + e.start(slot(0, 3), now: t0) // the last set of exercise 0 + e.start(slot(1, 1), now: t0 + 30) // machine busy: move on, 0/3 pending + XCTAssertTrue(e.removeSet(fromExercise: 0)) + XCTAssertEqual(e.slots(forExercise: 0).count, 2) + + e.undo() // back past the drop + XCTAssertEqual(e.slots(forExercise: 0).count, 3, "the slot the stage refers to is back") + e.undo() // back onto that very slot + XCTAssertEqual(e.stage, .working(slot(0, 3))) + XCTAssertTrue(e.allSlots.contains(slot(0, 3)), + "the session is never left working a set the sheet does not draw") + } + + // MARK: - Degenerate plans + + func testALineWithNoTargetStillGetsOneTappableSet() { + XCTAssertEqual(LiftPlanItem(exercise: "Face pull", targetSets: nil).targetSets, 1) + } + + func testAMissingRestFallsBackToTheDefault() { + XCTAssertEqual(LiftPlanItem(exercise: "Face pull", restSec: nil).restSec, + LiftPlanItem.defaultRestSec) + } + + func testAnEmptyPlanHasNothingToStartAndCannotTrap() { + var e = LiftSessionEngine(plan: [], startTs: t0) + XCTAssertNil(e.nextPendingSlot) + e.advance(now: t0 + 10) + XCTAssertEqual(e.stage, .warmup, "with no sets there is nothing to advance into") + e.finish(now: t0 + 20) + XCTAssertEqual(e.stage, .finished, "and it can still be ended") + } + + func testStartingASlotOutsideThePlanIsIgnored() { + var e = LiftSessionEngine(plan: twoExercisePlan(), startTs: t0) + e.start(slot(99, 1), now: t0) + XCTAssertEqual(e.stage, .warmup) + } +} diff --git a/StrandTests/LiftSessionFinishTests.swift b/StrandTests/LiftSessionFinishTests.swift new file mode 100644 index 0000000000..d0ddb500bf --- /dev/null +++ b/StrandTests/LiftSessionFinishTests.swift @@ -0,0 +1,285 @@ +import XCTest +@testable import Strand +import WhoopStore + +/// Finishing a session: what saves, and what reaches the program. +/// +/// From real sessions. 15 Sep 2026: grey numbers stay grey during the session, and a set count changed +/// with ⊕/⊖ reaches the program only if the user says so. 21 Sep 2026: a set that was done is complete — +/// typed numbers, else its grey ones — with no question at finish; only sets never started are asked +/// about ("the user might not complete all the workout"); and each program line takes its heaviest done +/// set's numbers, without asking. +@MainActor +final class LiftSessionFinishTests: XCTestCase { + + private func controller() -> LiftSessionController { + LiftSessionController(buzz: { _ in }, setStrapHandler: { _ in }) + } + + /// Bench 3 sets and rows 2 sets, both with targets and program lines behind them. + private func plan() -> [LiftPlanItem] { + [LiftPlanItem(exercise: "Bench press", primaryMuscle: .chest, targetSets: 3, + restSec: 60, targetRepsLow: 10, targetWeightKg: 50, programItemId: "bench"), + LiftPlanItem(exercise: "Row", primaryMuscle: .lats, targetSets: 2, + restSec: 60, targetRepsLow: 12, targetWeightKg: 40, programItemId: "row")] + } + + private func slot(_ e: Int, _ s: Int) -> LiftSlot { LiftSlot(exerciseIndex: e, setIndex: s) } + + override func tearDown() { + LiftSessionPersistence.clear() + super.tearDown() + } + + /// Bench set 1 done with only its weight typed, bench set 2 done untyped, the rest never started. + private func halfDoneSession() -> LiftSessionController { + let c = controller() + c.start(plan: plan(), programId: "p", programName: "Upper A") + c.advance() // bench 1 working + c.updateSet(slot(0, 1), weightKg: 55, reps: nil, rpe: nil, isWarmup: false) + c.advance() // bench 1 done, resting + c.advance() // bench 2 working + c.advance() // bench 2 done, untyped + return c + } + + /// A session run face-down, every set done on the strap and nothing typed: every set is complete + /// with the grey numbers the sheet showed, and there is nothing to ask. + func testSetsDoneOnTheStrapAreCompleteWithoutAsking() { + let c = controller() + c.start(plan: plan(), programId: "p", programName: "Upper A") + for _ in 0..<10 { c.advance() } // every set worked, none typed + XCTAssertTrue(c.unfinishedSlots.isEmpty, "every set was done, so nothing is unfinished") + let saved = c.setsToSave(completingUnfinished: false) + XCTAssertEqual(saved.map(\.weightKg), [50, 50, 50, 40, 40]) + XCTAssertEqual(saved.map(\.reps), [10, 10, 10, 12, 12]) + XCTAssertTrue(saved.allSatisfy { $0.endTs != nil }) + } + + /// With no set done and the rest discarded, no set counts: the precondition `LiftSessionView.save` + /// guards on, so an hour that recorded nothing never reads back as a workout with strain. + func testNothingDoneAndTheRestDiscardedLeavesNothingToFile() { + let c = controller() + c.start(plan: plan(), programId: "p", programName: "Upper A") + XCTAssertEqual(c.unfinishedSlots.count, 5) + XCTAssertFalse(LiftSessionController.anyPerformed(c.setsToSave(completingUnfinished: false))) + XCTAssertTrue(LiftSessionController.anyPerformed(c.setsToSave(completingUnfinished: true)), + "completing them still files every set with its grey numbers") + } + + func testUnfinishedSetsAreOnlyTheOnesNeverStarted() { + XCTAssertEqual(halfDoneSession().unfinishedSlots, [slot(0, 3), slot(1, 1), slot(1, 2)], + "bench 2 was done untyped, so it is complete, not unfinished") + } + + /// A set with anything typed always saves, and a blank field takes its grey number rather than + /// saving empty. + func testATypedSetSavesEitherWayWithItsBlanksFilled() { + let c = halfDoneSession() + for completing in [false, true] { + let bench1 = c.setsToSave(completingUnfinished: completing).first { $0.slot == slot(0, 1) } + XCTAssertEqual(bench1?.weightKg, 55) + XCTAssertEqual(bench1?.reps, 10, "the untyped reps take the grey target, not nil") + XCTAssertNotNil(bench1?.startTs) + } + } + + /// Discarding keeps every set never started as 0 kg × 0 reps: out of every figure, but still there to + /// fill in under Edit sets if the discard was a mistake. The sets that were done are not touched. + func testDiscardingSavesOnlyTheNeverStartedSetsAsZeros() { + let saved = halfDoneSession().setsToSave(completingUnfinished: false) + XCTAssertEqual(saved.map(\.slot), [slot(0, 1), slot(0, 2), slot(0, 3), slot(1, 1), slot(1, 2)]) + XCTAssertEqual(saved[0].weightKg, 55, "the typed set is untouched") + XCTAssertEqual(saved[1].weightKg, 55, "bench 2 was done untyped: it keeps its grey numbers") + XCTAssertEqual(saved[1].reps, 10) + XCTAssertNotNil(saved[1].endTs) + for set in saved.dropFirst(2) { + XCTAssertEqual(set.weightKg, 0) + XCTAssertEqual(set.reps, 0) + XCTAssertNil(set.rpe) + XCTAssertNil(set.startTs, "never started") + } + } + + /// Completing saves the untyped and the never-started sets with the grey numbers the sheet showed: + /// bench follows bench set 1, rows take their target. Performed sets keep their order and timing; + /// the never-started ones follow, with no timing to invent. + func testCompletingSavesThemWithTheirGreyNumbers() { + let saved = halfDoneSession().setsToSave(completingUnfinished: true) + XCTAssertEqual(saved.map(\.slot), [slot(0, 1), slot(0, 2), slot(0, 3), slot(1, 1), slot(1, 2)]) + + let bench2 = saved[1] + XCTAssertEqual(bench2.weightKg, 55, "set 2 follows what set 1 counts as") + XCTAssertEqual(bench2.reps, 10) + XCTAssertNotNil(bench2.endTs, "it was performed, so its timing is real") + + let row1 = saved[3] + XCTAssertEqual(row1.weightKg, 40) + XCTAssertEqual(row1.reps, 12) + XCTAssertNil(row1.startTs, "never started: no moment to record") + XCTAssertNil(row1.restSec) + XCTAssertNil(row1.rpe, "this plan sets no max RPE, so there is nothing to fill") + } + + /// Numbers typed in advance and a warm-up mark still count for a set completed at finish. + func testCompletingUsesWhatWasTypedAndMarkedInAdvance() { + let c = halfDoneSession() + c.updateSet(slot(1, 1), weightKg: 42.5, reps: nil, rpe: 7, isWarmup: false) + c.setWarmup(slot(1, 2), true) + + let saved = c.setsToSave(completingUnfinished: true) + let row1 = saved.first { $0.slot == slot(1, 1) } + XCTAssertEqual(row1?.weightKg, 42.5) + XCTAssertEqual(row1?.reps, 12, "the field left alone is still grey") + XCTAssertEqual(row1?.rpe, 7) + XCTAssertEqual(saved.first { $0.slot == slot(1, 2) }?.isWarmup, true) + } + + /// A program's max RPE is grey in the session, and grey numbers are what a set saves when nothing is + /// typed over them — RPE included (Utku, 16 Sep 2026). A typed rating always wins. + func testAMaxRpeFillsAnEmptyRatingLikeEveryOtherGreyNumber() { + let c = controller() + c.start(plan: [LiftPlanItem(exercise: "Squat", targetSets: 2, targetRepsLow: 5, targetRpe: 8)], + programId: nil, programName: nil) + c.advance() // set 1 working + c.updateSet(slot(0, 1), weightKg: 60, reps: nil, rpe: nil, isWarmup: false) + c.advance() // set 1 done: a weight typed, no rating + + let kept = c.setsToSave(completingUnfinished: false)[0] + XCTAssertEqual(kept.weightKg, 60) + XCTAssertEqual(kept.rpe, 8, "an unrated set saves the plan's max RPE, as its grey number") + + let completed = c.setsToSave(completingUnfinished: true) + XCTAssertEqual(completed.count, 2) + XCTAssertTrue(completed.allSatisfy { $0.rpe == 8 }, "completing fills RPE the same way") + + c.updateSet(slot(0, 1), weightKg: 60, reps: nil, rpe: 6, isWarmup: false) + XCTAssertEqual(c.setsToSave(completingUnfinished: true)[0].rpe, 6, "a typed rating wins") + } + + /// Discarding saves zeros and no rating: the plan's number fills a blank only on a set the session + /// keeps, so a discarded set cannot arrive carrying an effort nobody made. A set that was done keeps + /// the plan's rating either way. + func testADiscardedSetTakesNoMaxRpe() { + let c = controller() + c.start(plan: [LiftPlanItem(exercise: "Squat", targetSets: 2, targetRepsLow: 5, targetRpe: 8)], + programId: nil, programName: nil) + c.advance() + c.advance() // set 1 done, nothing typed at all + let saved = c.setsToSave(completingUnfinished: false) + XCTAssertEqual(saved[0].rpe, 8, "done: complete, with the plan's max RPE") + XCTAssertNil(saved[1].rpe, "never started and discarded") + XCTAssertEqual(saved[1].reps, 0) + } + + /// Nothing unfinished means nothing to ask, and every set saves. + func testAFullyTypedSessionHasNothingUnfinished() { + let c = controller() + c.start(plan: [LiftPlanItem(exercise: "Curl", targetSets: 1)], programId: nil, programName: nil) + c.advance() + c.updateSet(slot(0, 1), weightKg: 12, reps: 12, rpe: nil, isWarmup: false) + c.advance() + XCTAssertTrue(c.unfinishedSlots.isEmpty) + XCTAssertEqual(c.setsToSave(completingUnfinished: false).count, 1) + } + + /// The minimised bar and the Lock Screen show a finished, untyped set's grey numbers, not a blank. + func testTheBarShowsGreyNumbersForAnUntypedSet() { + let c = halfDoneSession() + XCTAssertEqual(c.setNumbers(for: slot(0, 2), system: .metric), "10 x 55 kg") + } + + // MARK: - The program's set counts + + private func item(_ id: String, _ exercise: String, sets: Int?) -> LiftProgramItemRow { + LiftProgramItemRow(id: id, deviceId: "d", programId: "p", ord: 0, exercise: exercise, + targetSets: sets, targetRepsLow: 10, targetRepsHigh: nil, targetRpe: nil, + targetWeightKg: 50, restSec: 60, note: "keep me") + } + + func testOnlyLinesWhoseCountMovedAreOffered() { + var lines = plan() + lines[0].targetSets = 4 // bench 3 -> 4 + let rows = [item("bench", "Bench press", sets: 3), item("row", "Row", sets: 2)] + XCTAssertEqual(LiftSessionController.setCountChanges(plan: lines, program: rows), + [.init(itemId: "bench", exercise: "Bench press", from: 3, to: 4)]) + } + + /// A line with no count starts a session with one set, so one set is no change; a line deleted + /// from the program since the session began is never offered back. + func testAMissingCountIsOneAndADeletedLineIsSkipped() { + let lines = [LiftPlanItem(exercise: "Curl", targetSets: nil, programItemId: "curl"), + LiftPlanItem(exercise: "Gone", targetSets: 5, programItemId: "gone")] + XCTAssertTrue(LiftSessionController.setCountChanges( + plan: lines, program: [item("curl", "Curl", sets: nil)]).isEmpty) + } + + func testApplyingMovesOnlyTheSetCount() { + let rows = [item("bench", "Bench press", sets: 3), item("row", "Row", sets: 2)] + let changed = LiftSessionController.applying( + [.init(itemId: "bench", exercise: "Bench press", from: 3, to: 5)], to: rows) + XCTAssertEqual(changed[0].targetSets, 5) + XCTAssertEqual(changed[0].note, "keep me") + XCTAssertEqual(changed[0].targetWeightKg, 50) + XCTAssertEqual(changed[1].targetSets, 2, "an unchanged line stays as it was") + } + + // MARK: - The program's numbers + + private func done(_ e: Int, _ s: Int, _ kg: Double?, _ reps: Int?, warmup: Bool = false, + started: Bool = true) -> LiftSessionController.FinishedSet { + .init(slot: slot(e, s), weightKg: kg, reps: reps, rpe: nil, isWarmup: warmup, + startTs: started ? 100 : nil, endTs: started ? 160 : nil, restSec: nil) + } + + /// Each line takes its heaviest done set — more weight first, then more reps — and nothing else moves. + func testEachProgramLineTakesItsHeaviestDoneSet() { + let rows = [item("bench", "Bench press", sets: 3), item("row", "Row", sets: 2)] + let sets = [done(0, 1, 60, 8), done(0, 2, 65, 6), done(0, 3, 65, 7), + done(1, 1, 45, 10), done(1, 2, 42.5, 12)] + let edited = LiftSessionController.applyingHeaviestSets(sets, plan: plan(), to: rows) + XCTAssertEqual(edited[0].targetWeightKg, 65) + XCTAssertEqual(edited[0].targetRepsLow, 7, "equal weight: the set with more reps") + XCTAssertEqual(edited[1].targetWeightKg, 45, "a lighter back-off set does not pull the program down") + XCTAssertEqual(edited[1].targetRepsLow, 10) + XCTAssertEqual(edited[0].targetSets, 3) + XCTAssertEqual(edited[0].note, "keep me") + } + + /// Only sets actually done carry new numbers: a warm-up, a discarded zero and a set completed at + /// finish without being started (grey numbers, not new ones) leave the program as it was. + func testSetsThatWereNotDoneLeaveTheProgramAlone() { + let rows = [item("bench", "Bench press", sets: 3), item("row", "Row", sets: 2)] + let sets = [done(0, 1, 80, 5, warmup: true), done(0, 2, 0, 0), + done(1, 1, 90, 3, started: false)] + XCTAssertEqual(LiftSessionController.applyingHeaviestSets(sets, plan: plan(), to: rows), rows) + } + + /// A bodyweight set keeps the line's weight; a leftover rep-range top below the new count is dropped; + /// a line with no program behind it, or deleted since, is skipped. + func testMissingNumbersAndMissingLinesAreLeftAlone() { + var rows = [item("bench", "Bench press", sets: 3)] + rows[0].targetRepsHigh = 12 + let edited = LiftSessionController.applyingHeaviestSets([done(0, 1, nil, 15)], plan: plan(), to: rows) + XCTAssertEqual(edited[0].targetWeightKg, 50, "no weight on the set, so the line's weight stays") + XCTAssertEqual(edited[0].targetRepsLow, 15) + XCTAssertNil(edited[0].targetRepsHigh, "12 would sit below the new 15") + + let unowned = [LiftPlanItem(exercise: "Curl", targetSets: 1)] + XCTAssertEqual(LiftSessionController.applyingHeaviestSets([done(0, 1, 20, 10)], plan: unowned, to: rows), + rows) + XCTAssertEqual(LiftSessionController.applyingHeaviestSets([done(1, 1, 20, 10)], plan: plan(), to: rows), + rows, "the row line was deleted from the program: nothing to write") + } + + /// The hub reloads on this: a saved session must announce itself, and a discarded one must not. + func testSavingASessionIsAnnouncedButDiscardingIsNot() { + let c = controller() + c.start(plan: plan(), programId: nil, programName: nil) + c.discard() + XCTAssertEqual(c.savedSessions, 0) + c.start(plan: plan(), programId: nil, programName: nil) + c.finishedSaving() + XCTAssertEqual(c.savedSessions, 1) + } +} diff --git a/StrandTests/LiftSessionPendingInputTests.swift b/StrandTests/LiftSessionPendingInputTests.swift new file mode 100644 index 0000000000..435e57e898 --- /dev/null +++ b/StrandTests/LiftSessionPendingInputTests.swift @@ -0,0 +1,195 @@ +import XCTest +@testable import Strand +import WhoopStore + +/// Typing numbers into a set you are NOT currently doing. +/// +/// Reported from a real session, 11 Sep 2026: *"you can't edit the numbers of other sets while in an +/// active set — when I type something during an active set to other sets it refreshes to the empty."* +/// It did. `LiftSessionView.write` could only edit a set that already had a RECORD, so a keystroke +/// into a pending row went nowhere; the draft made the field look like it had taken until focus left. +/// +/// The engine rule underneath is not the bug and does not move: typing must never append a set, or a +/// set nobody performed becomes data. So the numbers are held in the controller and applied the +/// instant the set is recorded — the same mechanism a warm-up marked in advance already used. +@MainActor +final class LiftSessionPendingInputTests: XCTestCase { + + private func controller() -> LiftSessionController { + LiftSessionController(buzz: { _ in }, setStrapHandler: { _ in }) + } + + /// Three sets, with targets, so there is a carried plan for a typed value to beat. + private func plan() -> [LiftPlanItem] { + [LiftPlanItem(exercise: "Bench press", primaryMuscle: .chest, targetSets: 3, + restSec: 60, targetRepsLow: 10, targetWeightKg: 50)] + } + + private func slot(_ e: Int, _ s: Int) -> LiftSlot { LiftSlot(exerciseIndex: e, setIndex: s) } + + override func tearDown() { + LiftSessionPersistence.clear() + super.tearDown() + } + + // MARK: - The reported bug + + /// The exact report: set 1 is active, the user types into set 3, and it must still be there. + func testTypingIntoAnotherSetWhileOneIsActiveIsKept() { + let c = controller() + c.start(plan: plan(), programId: nil, programName: "Upper A") + c.advance() // set 1 is now active + XCTAssertEqual(c.engine?.stage, .working(slot(0, 1))) + + c.updateSet(slot(0, 3), weightKg: 62.5, reps: 6, rpe: nil, isWarmup: false) + + XCTAssertEqual(c.enteredValues(for: slot(0, 3)).weightKg, 62.5, + "a number typed into another set must not evaporate") + XCTAssertEqual(c.enteredValues(for: slot(0, 3)).reps, 6) + XCTAssertEqual(c.engine?.stage, .working(slot(0, 1)), + "and typing elsewhere must not disturb the set being worked") + XCTAssertEqual(c.engine?.sets.count, 0, "nor invent a set nobody has performed") + } + + /// What was typed in advance is what gets recorded — it beats the carried plan, which is only + /// the sheet's guess (this exercise earlier, last session, the program's target). + func testWhatWasTypedInAdvanceIsWhatTheSetRecords() { + let c = controller() + c.start(plan: plan(), programId: nil, programName: "Upper A") + c.setLastSession(["Bench press": [1: LiftSetCarry(weightKg: 55, reps: 9)]]) + c.advance() // set 1 active + c.updateSet(slot(0, 1), weightKg: 70, reps: 5, rpe: nil, isWarmup: false) + c.advance() // "Set done" + + let recorded = c.engine?.recordedSet(for: slot(0, 1)) + XCTAssertEqual(recorded?.weightKg, 70, "the typed weight wins over the carried 55") + XCTAssertEqual(recorded?.reps, 5) + XCTAssertTrue(c.pendingValues.isEmpty, "and the held entry is consumed once applied") + } + + /// Typing only one field must not blank the others — they keep carrying. + func testAFieldLeftAloneStillCarries() { + let c = controller() + c.start(plan: plan(), programId: nil, programName: "Upper A") + c.setLastSession(["Bench press": [1: LiftSetCarry(weightKg: 55, reps: 9)]]) + c.advance() + c.updateSet(slot(0, 1), weightKg: 70, reps: nil, rpe: nil, isWarmup: false) + c.advance() + + XCTAssertEqual(c.engine?.recordedSet(for: slot(0, 1))?.weightKg, 70) + XCTAssertNil(c.engine?.recordedSet(for: slot(0, 1))?.reps, "reps nobody typed stay grey") + XCTAssertEqual(c.values(of: slot(0, 1)).reps, 9, + "and still count as the carried value, not as nothing") + } + + /// The bar and the Lock Screen show the set being lifted with the numbers its row shows, typed + /// ones included — not the grey plan behind them (simulator, 16 Sep 2026: 70 kg × 9 typed into the + /// running set, "8 x 60 kg" on the bar). A field left alone still shows its grey value. + func testTheBarShowsNumbersTypedIntoTheSetBeingLifted() { + let c = controller() + c.start(plan: plan(), programId: nil, programName: "Upper A") + c.advance() // set 1 active + XCTAssertEqual(c.setNumbers(for: slot(0, 1), system: .metric), "10 x 50 kg", "the plan, grey") + + c.updateSet(slot(0, 1), weightKg: 70, reps: nil, rpe: nil, isWarmup: false) + XCTAssertEqual(c.setNumbers(for: slot(0, 1), system: .metric), "10 x 70 kg") + c.updateSet(slot(0, 1), weightKg: 70, reps: 9, rpe: nil, isWarmup: false) + XCTAssertEqual(c.presentation(system: .metric)?.detail, "9 x 70 kg") + } + + /// Clearing the field puts the row back to showing the plan's grey ghost, rather than pinning an + /// empty entry that would record a blank set. + func testClearingTheFieldDropsTheHeldEntry() { + let c = controller() + c.start(plan: plan(), programId: nil, programName: "Upper A") + c.updateSet(slot(0, 2), weightKg: 80, reps: nil, rpe: nil, isWarmup: false) + XCTAssertFalse(c.pendingValues.isEmpty) + + c.updateSet(slot(0, 2), weightKg: nil, reps: nil, rpe: nil, isWarmup: false) + XCTAssertTrue(c.pendingValues.isEmpty) + XCTAssertNil(c.enteredValues(for: slot(0, 2)).weightKg) + } + + // MARK: - What must not change + + /// Editing a set that IS recorded still edits it in the engine, from any stage. + func testACompletedSetIsStillEditedInPlace() { + let c = controller() + c.start(plan: plan(), programId: nil, programName: "Upper A") + c.advance() + c.advance() // set 1 recorded, resting + c.advance() // set 2 active + + c.updateSet(slot(0, 1), weightKg: 47.5, reps: 12, rpe: 8, isWarmup: false) + + XCTAssertEqual(c.engine?.recordedSet(for: slot(0, 1))?.weightKg, 47.5) + XCTAssertEqual(c.engine?.recordedSet(for: slot(0, 1))?.rpe, 8) + XCTAssertTrue(c.pendingValues.isEmpty, + "a set with a record is edited, never shadowed by a held entry") + } + + /// The engine's rule is untouched: no amount of typing creates a set. + func testTypingNeverInventsASet() { + let c = controller() + c.start(plan: plan(), programId: nil, programName: "Upper A") + c.updateSet(slot(0, 1), weightKg: 60, reps: 10, rpe: 9, isWarmup: false) + c.updateSet(slot(0, 3), weightKg: 60, reps: 10, rpe: 9, isWarmup: false) + XCTAssertEqual(c.engine?.sets.count, 0) + XCTAssertEqual(c.engine?.completedWorkingSets, 0) + } + + /// Typing into a slot the plan does not have is ignored rather than held forever. + func testTypingIntoASlotOutsideThePlanIsIgnored() { + let c = controller() + c.start(plan: plan(), programId: nil, programName: "Upper A") + c.updateSet(slot(9, 1), weightKg: 60, reps: 10, rpe: nil, isWarmup: false) + XCTAssertTrue(c.pendingValues.isEmpty) + } + + /// A warm-up marked in advance still lands, and now travels with the numbers. + func testAWarmUpMarkedInAdvanceStillApplies() { + let c = controller() + c.start(plan: plan(), programId: nil, programName: "Upper A") + c.advance() + c.setWarmup(slot(0, 1), true) + c.updateSet(slot(0, 1), weightKg: 20, reps: 15, rpe: nil, isWarmup: true) + c.advance() + + let recorded = c.engine?.recordedSet(for: slot(0, 1)) + XCTAssertEqual(recorded?.isWarmup, true) + XCTAssertEqual(recorded?.weightKg, 20) + XCTAssertEqual(c.engine?.completedWorkingSets, 0, "a warm-up is still not a working set") + } + + /// A redo drops the record and should show the ghosts again — the consumed entry must not come + /// back and re-fill the row with numbers the user is in the middle of redoing. + func testARedoDoesNotResurrectTheConsumedEntry() { + let c = controller() + c.start(plan: plan(), programId: nil, programName: "Upper A") + c.advance() + c.updateSet(slot(0, 1), weightKg: 70, reps: 5, rpe: nil, isWarmup: false) + c.advance() // recorded with 70 x 5 + c.start(slot(0, 1)) // redo: record dropped + + XCTAssertNil(c.engine?.recordedSet(for: slot(0, 1))) + XCTAssertNil(c.enteredValues(for: slot(0, 1)).weightKg, + "the row is back to its ghosts, as it was before this existed") + } + + /// Removing a set takes what was entered for it along: adding the set back starts it fresh + /// rather than returning numbers and a warm-up mark given to a set the user dropped. + func testRemovingASetDropsWhatWasEnteredForIt() { + let c = controller() + c.start(plan: plan(), programId: nil, programName: "Upper A") + c.updateSet(slot(0, 3), weightKg: 80, reps: 4, rpe: nil, isWarmup: false) + c.setWarmup(slot(0, 3), true) + + XCTAssertTrue(c.removeSet(fromExercise: 0)) + XCTAssertTrue(c.addSet(toExercise: 0)) + + XCTAssertNil(c.enteredValues(for: slot(0, 3)).weightKg, + "the re-added set shows its ghosts, not the dropped set's numbers") + XCTAssertFalse(c.isWarmup(slot(0, 3))) + XCTAssertTrue(c.pendingValues.isEmpty) + } +} diff --git a/StrandTests/LiftSessionPersistenceTests.swift b/StrandTests/LiftSessionPersistenceTests.swift new file mode 100644 index 0000000000..a8f332fea3 --- /dev/null +++ b/StrandTests/LiftSessionPersistenceTests.swift @@ -0,0 +1,148 @@ +import XCTest +@testable import Strand +import WhoopStore + +/// The crash-safe snapshot of an in-flight session, and the one question that decides whether an +/// update can be installed OVER the previous build or needs a wipe: **does data written by the +/// previous build still read?** +/// +/// The session snapshot is the only thing this feature persists outside SQLite (UserDefaults, +/// `noop.activeLiftSession`), so it is the only place an app-layer change can strand something the +/// old build wrote. A schema change is the other half of that question and is answered by the +/// migration, not here. +final class LiftSessionPersistenceTests: XCTestCase { + + private let t0 = 1_700_000_000 + + /// A snapshot exactly as the PREVIOUS build wrote it: no `programItemId`, because the field did + /// not exist until the set count could be changed mid-session. + /// + /// Written as literal JSON on purpose. Encoding it with today's `Snapshot` would only prove that + /// today's build can read itself — the whole point is to read bytes the old build produced. + private func snapshotJSONWithoutProgramItemId() -> Data { + Data(""" + { + "startSec": \(t0), + "programId": "p1", + "programName": "Upper A", + "plan": [ + {"exercise": "Bench press", "secondaryMuscles": ["triceps"], "primaryMuscle": "chest", + "targetSets": 3, "restSec": 90, "targetRepsLow": 8, "targetWeightKg": 60} + ], + "stage": {"kind": "resting", "item": 0, "set": 1, "endsAt": \(t0 + 130)}, + "sets": [ + {"exerciseIndex": 0, "setIndex": 1, "weightKg": 60, "reps": 8, "isWarmup": false, + "startTs": \(t0 + 10), "endTs": \(t0 + 40)} + ], + "stageStartedAt": \(t0 + 40) + } + """.utf8) + } + + /// iOS closes NOOP in the background and relaunches it — four times in one gym session (21 Sep 2026). + /// The session is picked up as the process starts, before any screen, and says so in the strap log. + @MainActor + func testASessionIsPickedUpAgainWhenNOOPStartsAndSaysSo() throws { + let defaults = try XCTUnwrap(UserDefaults(suiteName: "LiftSessionPersistenceTests.resume")) + defer { defaults.removePersistentDomain(forName: "LiftSessionPersistenceTests.resume") } + var lines: [String] = [] + var claimed = false + func controller() -> LiftSessionController { + LiftSessionController(buzz: { _ in }, setStrapHandler: { claimed = $0 != nil }, + log: { lines.append($0) }) + } + + controller().resumeSaved(from: defaults) + XCTAssertTrue(lines.isEmpty, "nothing saved, nothing picked up") + + let snapshot = try XCTUnwrap(LiftSessionPersistence.decode(snapshotJSONWithoutProgramItemId())) + LiftSessionPersistence.store(snapshot, into: defaults) + let c = controller() + c.resumeSaved(from: defaults) + XCTAssertTrue(c.isActive) + XCTAssertFalse(c.isPresented, "it comes back as the bar, not as a sheet") + XCTAssertTrue(claimed, "the strap's double-tap is the session's again") + XCTAssertEqual(lines, ["Lift Log: session picked up again after NOOP restarted"]) + + c.resumeSaved(from: defaults) + XCTAssertEqual(lines.count, 1, "a running session is not picked up twice") + } + + /// A session started on the previous build and still running when the update is installed must + /// come back whole. It is the reason today's change can go on as a plain update rather than a wipe. + func testASessionWrittenByThePreviousBuildStillResumes() throws { + let decoded = try XCTUnwrap(LiftSessionPersistence.decode(snapshotJSONWithoutProgramItemId()), + "a snapshot from the previous build must not read as 'no session'") + let engine = LiftSessionPersistence.engine(from: decoded) + + XCTAssertEqual(engine.startTs, t0) + XCTAssertEqual(engine.plan.count, 1) + XCTAssertEqual(engine.plan[0].exercise, "Bench press") + XCTAssertEqual(engine.plan[0].targetSets, 3) + XCTAssertEqual(engine.plan[0].primaryMuscle, .chest) + XCTAssertEqual(engine.stage, .resting(LiftSlot(exerciseIndex: 0, setIndex: 1), + endsAt: t0 + 130)) + XCTAssertEqual(engine.sets.count, 1, "the set already logged survives the update") + XCTAssertEqual(engine.sets[0].weightKg, 60) + } + + /// The new field is simply absent, not garbage — so the write-back is skipped for that session + /// rather than aimed at a program line that was never identified. + func testAResumedOldSessionCarriesNoProgramLineAndIsStillFullyUsable() throws { + let decoded = try XCTUnwrap(LiftSessionPersistence.decode(snapshotJSONWithoutProgramItemId())) + var engine = LiftSessionPersistence.engine(from: decoded) + XCTAssertNil(engine.plan[0].programItemId) + + // And the set count can still be changed — the session works, the program just is not rewritten. + XCTAssertTrue(engine.addSet(toExercise: 0)) + XCTAssertEqual(engine.plan[0].targetSets, 4) + } + + /// Numbers typed for a set that has not happened yet must survive a crash. Losing them to a + /// relaunch would be the same bug as losing them to a blur, with extra steps. + func testNumbersTypedForASetNotYetPerformedSurviveARelaunch() throws { + let plan = [LiftPlanItem(exercise: "Row", targetSets: 3, restSec: 60)] + let engine = LiftSessionEngine(plan: plan, startTs: t0) + let slot = LiftSlot(exerciseIndex: 0, setIndex: 3) + let typed = LiftSessionController.PendingSetValues(weightKg: 72.5, reps: 6, rpe: nil) + + let encoded = try XCTUnwrap(LiftSessionPersistence.encode( + LiftSessionPersistence.snapshot(engine: engine, programId: nil, programName: nil, + pendingValues: [slot: typed], + pendingWarmups: [LiftSlot(exerciseIndex: 0, setIndex: 1)]))) + let back = try XCTUnwrap(LiftSessionPersistence.decode(encoded)) + + XCTAssertEqual(LiftSessionPersistence.pendingValues(from: back), [slot: typed]) + XCTAssertEqual(LiftSessionPersistence.pendingWarmups(from: back), + [LiftSlot(exerciseIndex: 0, setIndex: 1)]) + } + + /// A snapshot written before any of this existed carries neither, and must resume with nothing + /// pending rather than failing to read at all. + func testAnOldSnapshotResumesWithNothingPending() throws { + let decoded = try XCTUnwrap(LiftSessionPersistence.decode(snapshotJSONWithoutProgramItemId())) + XCTAssertTrue(LiftSessionPersistence.pendingValues(from: decoded).isEmpty) + XCTAssertTrue(LiftSessionPersistence.pendingWarmups(from: decoded).isEmpty) + } + + /// The round trip today's build performs on itself, including the new field. + func testTodaysSnapshotRoundTripsWithTheProgramLine() throws { + let plan = [LiftPlanItem(exercise: "Lat pulldown", primaryMuscle: .lats, + targetSets: 2, restSec: 60, programItemId: "line-7")] + var engine = LiftSessionEngine(plan: plan, startTs: t0) + engine.advance(now: t0) + engine.advance(now: t0 + 30) + engine.addSet(toExercise: 0) + + let encoded = try XCTUnwrap(LiftSessionPersistence.encode( + LiftSessionPersistence.snapshot(engine: engine, programId: "p1", programName: "Pull", + pendingValues: [:], pendingWarmups: []))) + let back = try XCTUnwrap(LiftSessionPersistence.decode(encoded)) + let rebuilt = LiftSessionPersistence.engine(from: back) + + XCTAssertEqual(rebuilt.plan[0].programItemId, "line-7", + "without this the added set could not be written back after a relaunch") + XCTAssertEqual(rebuilt.plan[0].targetSets, 3, "the added set survives a crash") + XCTAssertEqual(rebuilt.sets.count, 1) + } +} diff --git a/StrandTests/LiftSessionStrapTapTests.swift b/StrandTests/LiftSessionStrapTapTests.swift new file mode 100644 index 0000000000..fdfc8ddfdc --- /dev/null +++ b/StrandTests/LiftSessionStrapTapTests.swift @@ -0,0 +1,123 @@ +import XCTest +import Combine +@testable import Strand +import WhoopStore + +/// A knock is not a double-tap. +/// +/// In the 16 Sep 2026 gym session the strap's own sensor log recorded two double-taps 3 s and 4 s +/// after one that had just started a set. Both were genuine detections with their own timestamps — +/// the arm going onto the bar — so nothing downstream could tell them from a tap, and each finished +/// a set seconds old: "it skipped two things when it should have done only one". The session now +/// holds back a strap tap that comes too soon after the last one it acted on, without a buzz, and +/// says so in the strap log. +@MainActor +final class LiftSessionStrapTapTests: XCTestCase { + + private var buzzes: [UInt8] = [] + private var logged: [String] = [] + private var strapHandler: (@MainActor () -> Void)? + + private func controller() -> LiftSessionController { + LiftSessionController(buzz: { [unowned self] in buzzes.append($0) }, + setStrapHandler: { [unowned self] in strapHandler = $0 }, + log: { [unowned self] in logged.append($0) }) + } + + private func plan(restSec: Int = 90) -> [LiftPlanItem] { + [LiftPlanItem(exercise: "Lat pulldown", primaryMuscle: .lats, targetSets: 3, restSec: restSec)] + } + + private func slot(_ e: Int, _ s: Int) -> LiftSlot { LiftSlot(exerciseIndex: e, setIndex: s) } + + override func tearDown() { + LiftSessionPersistence.clear() + buzzes = []; logged = []; strapHandler = nil + super.tearDown() + } + + /// The reported case: a tap starts the set, and a knock straight after must not finish it. + func testADoubleTapRightAfterTheOneThatStartedASetDoesNotFinishIt() { + let c = controller() + c.start(plan: plan(), programId: nil, programName: "Pull") + + strapHandler?() // the tap: set 1 starts + XCTAssertEqual(c.engine?.stage, .working(slot(0, 1))) + strapHandler?() // the knock, a moment later + + XCTAssertEqual(c.engine?.stage, .working(slot(0, 1)), "the set is still running") + XCTAssertTrue(c.engine?.sets.isEmpty ?? false, "nothing was recorded") + XCTAssertEqual(buzzes, [LiftSessionController.advanceConfirmBuzzes], "one buzz, for the tap only") + XCTAssertEqual(logged.count, 1) + XCTAssertTrue(logged[0].contains("not acted on") && logged[0].contains("knock"), logged[0]) + } + + /// The handler the strap calls acts at once, so the buzz is written before anything that follows + /// the tap in the same frame handling (see `FrameRouterDoubleTapDedupTests`). + func testTheStrapHandlerBuzzesBeforeItReturns() { + let c = controller() + c.start(plan: plan(), programId: nil, programName: "Pull") + strapHandler?() + XCTAssertEqual(buzzes, [LiftSessionController.advanceConfirmBuzzes]) + XCTAssertEqual(c.engine?.stage, .working(slot(0, 1))) + } + + /// The button on the screen is pressed on purpose; only strap taps are judged. + func testTheOnScreenButtonIsNeverHeldBack() { + let c = controller() + c.start(plan: plan(), programId: nil, programName: "Pull") + strapHandler?() + c.advance() + guard case .resting(let s, _) = c.engine?.stage else { return XCTFail("expected a rest") } + XCTAssertEqual(s, slot(0, 1)) + XCTAssertTrue(logged.isEmpty) + } + + /// A line planned with no rest goes straight on: the rest is over the moment it starts. + func testWithNoRestPlannedTheNextSetStartsOnTheNextTap() { + let c = controller() + c.start(plan: plan(restSec: 0), programId: nil, programName: "Pull") + c.advance() // set 1 starts (on screen) + strapHandler?() // set 1 done: a rest of zero + strapHandler?() // straight into set 2 + XCTAssertEqual(c.engine?.stage, .working(slot(0, 2))) + XCTAssertTrue(logged.isEmpty) + } + + /// The Lock Screen lights on a strap step, once, with the new stage already in place, so that one + /// update shows where the lifter is now. A held-back knock and the on-screen button light nothing. + func testAStrapStepSignalsTheLockScreenOnceWithTheNewStage() { + let c = controller() + var seen: [LiftSessionEngine.Stage?] = [] + let watch = c.strapStepTaken.sink { [unowned c] in seen.append(c.engine?.stage) } + defer { watch.cancel() } + c.start(plan: plan(), programId: nil, programName: "Pull") + + strapHandler?() // set 1 starts + XCTAssertEqual(seen, [.working(slot(0, 1))]) + strapHandler?() // a knock: held back + c.advance() // the on-screen button + XCTAssertEqual(seen.count, 1, "neither a knock nor the button lights the screen") + } + + func testAKnockIsJudgedByTimeAndByWhetherTheRestIsOver() { + let now = 1_800_000_000 + let working = LiftSessionEngine.Stage.working(slot(0, 1)) + let resting = LiftSessionEngine.Stage.resting(slot(0, 1), endsAt: now + 60) + let restOver = LiftSessionEngine.Stage.resting(slot(0, 1), endsAt: now) + let window = LiftSessionController.strapKnockWindowSec + + XCTAssertTrue(LiftSessionController.isKnock(secondsSinceLastStep: 0, stage: working, now: now)) + XCTAssertTrue(LiftSessionController.isKnock(secondsSinceLastStep: 3, stage: working, now: now)) + XCTAssertTrue(LiftSessionController.isKnock(secondsSinceLastStep: 4, stage: resting, now: now)) + XCTAssertTrue(LiftSessionController.isKnock(secondsSinceLastStep: window - 1, stage: working, now: now)) + XCTAssertFalse(LiftSessionController.isKnock(secondsSinceLastStep: window, stage: working, now: now)) + XCTAssertFalse(LiftSessionController.isKnock(secondsSinceLastStep: 6, stage: working, now: now), + "8 s was too long to wait for a deliberate tap (Utku, 21 Sep 2026)") + XCTAssertFalse(LiftSessionController.isKnock(secondsSinceLastStep: 21, stage: working, now: now), + "the shortest real set in that session was 21 s") + XCTAssertFalse(LiftSessionController.isKnock(secondsSinceLastStep: 2, stage: restOver, now: now)) + XCTAssertFalse(LiftSessionController.isKnock(secondsSinceLastStep: -5, stage: working, now: now), + "a clock that stepped back is not evidence of a knock") + } +} diff --git a/StrandTests/LiftSessionTimingTests.swift b/StrandTests/LiftSessionTimingTests.swift new file mode 100644 index 0000000000..192fde3831 --- /dev/null +++ b/StrandTests/LiftSessionTimingTests.swift @@ -0,0 +1,84 @@ +import XCTest +import Combine +@testable import Strand +import WhoopStore + +/// A running session does no work between taps: no once-a-second tick, only a rest's two moments. +/// +/// From Utku's crash reports of 21 Sep 2026: iOS killed NOOP four times in one gym session for background CPU +/// (over 80% for 60 s, redrawing SwiftUI views). The session published a tick every second to every screen +/// watching it, so each of them was redrawn every second, on screen or not. +@MainActor +final class LiftSessionTimingTests: XCTestCase { + + private func plan(restSec: Int) -> [LiftPlanItem] { + [LiftPlanItem(exercise: "Bench press", primaryMuscle: .chest, targetSets: 2, restSec: restSec)] + } + + override func tearDown() { + LiftSessionPersistence.clear() + super.tearDown() + } + + /// Nothing is published while nothing happens — the property that stops a session from redrawing every + /// screen that watches it once a second. + func testASessionPublishesNothingBetweenTaps() { + let c = LiftSessionController(buzz: { _ in }, setStrapHandler: { _ in }) + c.start(plan: plan(restSec: 90), programId: "p", programName: "Upper A") + c.advance() // a set being worked: its clock runs on screen + var changes = 0 + let watching = c.objectWillChange.sink { changes += 1 } + RunLoop.main.run(until: Date().addingTimeInterval(2.5)) + watching.cancel() + XCTAssertEqual(changes, 0, "a running set changes nothing the session publishes; its clock ticks by itself") + } + + /// A rest's end is the one moment the words change with time alone, and the warning buzz still comes. + func testARestsEndIsPublishedOnceAndItsWarningBuzzes() { + var buzzes: [UInt8] = [] + let c = LiftSessionController(buzz: { buzzes.append($0) }, setStrapHandler: { _ in }) + c.start(plan: plan(restSec: 2), programId: "p", programName: "Upper A") + c.advance() // set 1 working + c.advance() // set 1 done: a 2 s rest + XCTAssertEqual(c.presentation(system: .metric)?.status, "Resting after set 1") + var changes = 0 + let watching = c.objectWillChange.sink { changes += 1 } + RunLoop.main.run(until: Date().addingTimeInterval(3.2)) + watching.cancel() + XCTAssertEqual(changes, 1, "the rest's end, once") + XCTAssertEqual(buzzes, [LiftSessionController.restWarningBuzzes], "the warning, once") + XCTAssertEqual(c.presentation(system: .metric)?.status, "Ready for the next set") + } + + /// When the warning and the end fire. A rest inside the warning window warns a second from now, clear of + /// the tap's own buzz; a rest already over has no end to wait for. + func testRestEventTimes() { + let now = 1_700_000_000 + let lead = LiftSessionController.restWarningLeadSec + let long = LiftSessionController.restEventTimes(endsAt: now + 90, now: now) + XCTAssertEqual(long.warning, now + 90 - lead) + XCTAssertEqual(long.end, now + 90) + let short = LiftSessionController.restEventTimes(endsAt: now + 3, now: now) + XCTAssertEqual(short.warning, now + 1) + XCTAssertEqual(short.end, now + 3) + let over = LiftSessionController.restEventTimes(endsAt: now, now: now) + XCTAssertEqual(over.warning, now + 1) + XCTAssertNil(over.end) + } + + /// Undoing out of a rest cancels its timers: no warning, no end, for a rest that no longer runs. + func testARestUndoneFiresNothing() { + var buzzes: [UInt8] = [] + let c = LiftSessionController(buzz: { buzzes.append($0) }, setStrapHandler: { _ in }) + c.start(plan: plan(restSec: 2), programId: "p", programName: "Upper A") + c.advance() + c.advance() // resting, 2 s + c.undo() // back to working set 1 + var changes = 0 + let watching = c.objectWillChange.sink { changes += 1 } + RunLoop.main.run(until: Date().addingTimeInterval(3.2)) + watching.cancel() + XCTAssertEqual(changes, 0) + XCTAssertEqual(buzzes, []) + } +} diff --git a/StrandTests/LiquidBatteryDisplayTests.swift b/StrandTests/LiquidBatteryDisplayTests.swift index bd13f7a1d8..c2f067b56e 100644 --- a/StrandTests/LiquidBatteryDisplayTests.swift +++ b/StrandTests/LiquidBatteryDisplayTests.swift @@ -19,7 +19,7 @@ final class LiquidBatteryDisplayTests: XCTestCase { /// ever) telling us a number. The old view nested the bolt inside `if let pct`, making this state /// unrenderable — it drew `bolt.slash` at a wearer who was sitting on the charger. func testChargingIsReportedEvenWithNoChargeReadingYet() { - let d = Display.resolve(connected: true, batteryPct: nil, charging: true) + let d = Display.resolve(activeIsWhoop: true, connected: true, batteryPct: nil, charging: true, ringPct: nil, ringCharging: false) XCTAssertEqual(d, .pending(charging: true), "a known charging state must survive a missing % — it is the wearer's live question") } @@ -28,7 +28,7 @@ final class LiquidBatteryDisplayTests: XCTestCase { /// `.pending(charging: false)` and `.offline` must stay distinguishable so the view can render one as /// a pending ellipsis and the other as a crossed-out bolt. func testConnectedWithNoReadingIsPendingNotOffline() { - let d = Display.resolve(connected: true, batteryPct: nil, charging: nil) + let d = Display.resolve(activeIsWhoop: true, connected: true, batteryPct: nil, charging: nil, ringPct: nil, ringCharging: false) XCTAssertEqual(d, .pending(charging: false)) XCTAssertNotEqual(d, .offline, "connected-but-silent is not the same claim as no link") } @@ -40,28 +40,103 @@ final class LiquidBatteryDisplayTests: XCTestCase { /// forever, and a view keying off `batteryPct` alone shows a dead strap's stale charge as if live. /// During the incident that rendered a 21 h old 11% identically to a fresh one. func testStaleChargeIsNotShownOnceTheLinkIsGone() { - let d = Display.resolve(connected: false, batteryPct: 11, charging: false) + let d = Display.resolve(activeIsWhoop: true, connected: false, batteryPct: 11, charging: false, ringPct: nil, ringCharging: false) XCTAssertEqual(d, .offline, "a % with no link behind it must not render as a live reading") } /// Disconnect must also drop a charging bit — nothing about the old link is still true. func testStaleChargingFlagIsNotShownOnceTheLinkIsGone() { - XCTAssertEqual(Display.resolve(connected: false, batteryPct: nil, charging: true), .offline) + XCTAssertEqual(Display.resolve(activeIsWhoop: true, connected: false, batteryPct: nil, charging: true, ringPct: nil, ringCharging: false), .offline) } // MARK: - The normal path still reads normally func testConnectedReadingCarriesPctAndChargingThrough() { - XCTAssertEqual(Display.resolve(connected: true, batteryPct: 87.4, charging: true), - .charge(pct: 87.4, charging: true)) - XCTAssertEqual(Display.resolve(connected: true, batteryPct: 87.4, charging: false), - .charge(pct: 87.4, charging: false)) + XCTAssertEqual(Display.resolve(activeIsWhoop: true, connected: true, batteryPct: 87.4, charging: true, ringPct: nil, ringCharging: false), + .charge(pct: 87.4, charging: true, isRing: false)) + XCTAssertEqual(Display.resolve(activeIsWhoop: true, connected: true, batteryPct: 87.4, charging: false, ringPct: nil, ringCharging: false), + .charge(pct: 87.4, charging: false, isRing: false)) } /// `charging` is `Bool?` — nil means "the strap hasn't said" (no BATTERY_LEVEL event this session), /// which must read as not-charging, never as charging. Same `== true` posture as the rest of the app. func testUnknownChargingReadsAsNotCharging() { - XCTAssertEqual(Display.resolve(connected: true, batteryPct: 50, charging: nil), - .charge(pct: 50, charging: false)) + XCTAssertEqual(Display.resolve(activeIsWhoop: true, connected: true, batteryPct: 50, charging: nil, ringPct: nil, ringCharging: false), + .charge(pct: 50, charging: false, isRing: false)) + } + + // MARK: - #2208 whose charge is this + + /// The reported bug. `connected` goes true the moment ANY source streams, and the strap's percentage + /// is never cleared, so under an active ring both halves of the old gate passed and Today drew the + /// strap's charge. A non-WHOOP active device must show nothing rather than someone else's number. + func testRingActiveShowsNothingEvenWithAStalestrapCharge() { + XCTAssertEqual(Display.resolve(activeIsWhoop: false, connected: true, batteryPct: 72.4, charging: false, ringPct: nil, ringCharging: false), + .notActiveDevice) + } + + /// Charging is the strap's BATTERY_LEVEL event, so it is strap-only for the same reason. A ring must + /// not inherit the strap's charger state either. + func testRingActiveShowsNothingWhileTheStrapCharges() { + XCTAssertEqual(Display.resolve(activeIsWhoop: false, connected: true, batteryPct: 88, charging: true, ringPct: nil, ringCharging: false), + .notActiveDevice) + } + + /// A ring active with no strap charge ever recorded is the same answer, reached the other way. + func testRingActiveWithNoStrapChargeIsAlsoNotDrawn() { + XCTAssertEqual(Display.resolve(activeIsWhoop: false, connected: true, batteryPct: nil, charging: nil, ringPct: nil, ringCharging: false), + .notActiveDevice) + } + + /// The distinction @pipiche38 asked for on #2216. A strap that IS active and disconnected is offline, + /// a real claim worth making. A strap that is not the active device is a different answer entirely, + /// and collapsing the two told a wearer with a streaming ring that their strap was not connected. + func testNotActiveIsNotTheSameAnswerAsOffline() { + XCTAssertNotEqual(Display.resolve(activeIsWhoop: false, connected: true, batteryPct: 72, charging: false, ringPct: nil, ringCharging: false), + Display.resolve(activeIsWhoop: true, connected: false, batteryPct: 72, charging: false, ringPct: nil, ringCharging: false)) + XCTAssertEqual(Display.resolve(activeIsWhoop: true, connected: false, batteryPct: 72, charging: false, ringPct: nil, ringCharging: false), + .offline) + } + + /// The WHOOP path is unchanged: this fix must not blank a strap that IS the active device. + func testWhoopActiveStillReportsItsCharge() { + XCTAssertEqual(Display.resolve(activeIsWhoop: true, connected: true, batteryPct: 61, charging: false, ringPct: nil, ringCharging: false), + .charge(pct: 61, charging: false, isRing: false)) + } + + // MARK: - The ring's own charge + + /// The other half of #2208. Hiding the strap's number under a ring was right; hiding the RING's was + /// only ever a limitation of the control, which could not say whose number it held. A ring that has + /// reported its charge this link is the active device's own reading, and is drawn as the ring's. + func testRingActiveDrawsTheRingsOwnCharge() { + XCTAssertEqual(Display.resolve(activeIsWhoop: false, connected: true, batteryPct: 72.4, charging: true, + ringPct: 93, ringCharging: false), + .charge(pct: 93, charging: false, isRing: true), + "the strap's 72 % and its charger bit must not leak into the ring's reading") + } + + /// The ring's charging evidence is its own charger state, never the strap's BATTERY_LEVEL bit. + func testRingChargingComesFromTheRingsChargerState() { + XCTAssertEqual(Display.resolve(activeIsWhoop: false, connected: true, batteryPct: nil, charging: nil, + ringPct: 40, ringCharging: true), + .charge(pct: 40, charging: true, isRing: true)) + } + + /// `LiveState.ouraBatteryPct` is cleared with the link (#2075), so under a non-WHOOP active device a + /// nil ring charge means "no ring is live, or it has not reported yet" — and a generic HR strap or a + /// machine, which never write it, keep the control off the header exactly as before. + func testRingActiveWithNoRingChargeYetIsStillNotDrawn() { + XCTAssertEqual(Display.resolve(activeIsWhoop: false, connected: true, batteryPct: 72, charging: false, + ringPct: nil, ringCharging: false), + .notActiveDevice) + } + + /// A ring charge that somehow sits beside an active STRAP is not the strap's reading and must not + /// replace it: `activeIsWhoop` decides whose number is shown, the same way `batteryPercent` does. + func testStrapActiveIgnoresARingCharge() { + XCTAssertEqual(Display.resolve(activeIsWhoop: true, connected: true, batteryPct: 61, charging: false, + ringPct: 93, ringCharging: true), + .charge(pct: 61, charging: false, isRing: false)) } } 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/LiveConsoleReadoutTests.swift b/StrandTests/LiveConsoleReadoutTests.swift new file mode 100644 index 0000000000..6a5108ed30 --- /dev/null +++ b/StrandTests/LiveConsoleReadoutTests.swift @@ -0,0 +1,124 @@ +import XCTest +import WhoopStore +@testable import Strand + +/// #2075: the Live Console must read out the ACTIVE device, not whichever field happens to be populated. +/// +/// `LiveState` is one object every live source writes into, so a bonded WHOOP sitting beside a streaming +/// Oura ring leaves every WHOOP-only field truthful-looking while the console is naming the ring. The +/// report was a ring on 93% displaying the strap's 72% under "Oura Ring 5", with a WHOOP pairing pill. +/// +/// Mirrors Android `LiveConsoleReadoutTest` case-for-case. +final class LiveConsoleReadoutTests: XCTestCase { + + private func device(_ id: String, _ brand: String) -> PairedDevice { + PairedDevice(id: id, brand: brand, model: "m", nickname: nil, peripheralId: nil, + sourceKind: .liveBLE, capabilities: [.hr], + status: .active, addedAt: 1000, lastSeenAt: 1000) + } + + // MARK: - activeIsWhoop + + func testAnOuraActiveDeviceIsNotAWhoop() { + let rows = [device("my-whoop", "WHOOP"), device("oura-123", "Oura")] + XCTAssertFalse(LiveConsoleReadout.activeIsWhoop(devices: rows, activeId: "oura-123")) + XCTAssertTrue(LiveConsoleReadout.activeIsWhoop(devices: rows, activeId: "my-whoop")) + } + + func testTheLegacySeededRowIsAWhoopWhateverItsBrandSays() { + // SourceIdentity's rule: the seeded id counts even if the brand column is blank. + XCTAssertTrue(LiveConsoleReadout.activeIsWhoop(devices: [device("my-whoop", "")], activeId: "my-whoop")) + } + + func testAnUnresolvableActiveDeviceStaysWhoopFirst() { + // Before the registry opens, the console already names "WHOOP"; the gate must agree rather than + // blanking a working strap's readouts on a cold start. + XCTAssertTrue(LiveConsoleReadout.activeIsWhoop(devices: [], activeId: nil)) + XCTAssertTrue(LiveConsoleReadout.activeIsWhoop(devices: [], activeId: "oura-123")) + } + + func testBrandMatchingIsCaseInsensitive() { + XCTAssertTrue(LiveConsoleReadout.activeIsWhoop(devices: [device("w1", "whoop")], activeId: "w1")) + XCTAssertFalse(LiveConsoleReadout.activeIsWhoop(devices: [device("o1", "OURA")], activeId: "o1")) + } + + // MARK: - activeIsOura (#2305) + + func testAnOuraActiveDeviceIsARing() { + let rows = [device("my-whoop", "WHOOP"), device("oura-123", "Oura")] + XCTAssertTrue(LiveConsoleReadout.activeIsOura(devices: rows, activeId: "oura-123")) + XCTAssertFalse(LiveConsoleReadout.activeIsOura(devices: rows, activeId: "my-whoop")) + } + + func testAnUnresolvableActiveDeviceIsNotARing() { + // The opposite default to activeIsWhoop: a ring-only affordance must not appear for a device the + // registry cannot name — there would be nothing for "Reconnect ring" to reach. + XCTAssertFalse(LiveConsoleReadout.activeIsOura(devices: [], activeId: nil)) + XCTAssertFalse(LiveConsoleReadout.activeIsOura(devices: [], activeId: "oura-123")) + } + + func testAThirdBrandIsNeitherWhoopNorRing() { + let rows = [device("polar-1", "Polar")] + XCTAssertFalse(LiveConsoleReadout.activeIsWhoop(devices: rows, activeId: "polar-1")) + XCTAssertFalse(LiveConsoleReadout.activeIsOura(devices: rows, activeId: "polar-1")) + } + + func testRingBrandMatchingIsCaseInsensitive() { + XCTAssertTrue(LiveConsoleReadout.activeIsOura(devices: [device("o1", "OURA")], activeId: "o1")) + XCTAssertTrue(LiveConsoleReadout.activeIsOura(devices: [device("o2", "oura")], activeId: "o2")) + } + + // MARK: - batteryPercent + + func testAWhoopActiveDeviceShowsTheStrapCharge() { + XCTAssertEqual( + LiveConsoleReadout.batteryPercent(activeIsWhoop: true, whoopPct: 72.4, ringPct: 93), 72) + } + + /// ROUNDS, it does not truncate. The surfaces this seam replaced disagreed: Devices and the widget + /// rounded, the Live Console truncated. Folding them onto a truncating seam would have quietly moved + /// five readouts down by a point, which is the kind of change nobody reports and everyone notices. + func testTheChargeIsRoundedNotTruncated() { + XCTAssertEqual(LiveConsoleReadout.batteryPercent(activeIsWhoop: true, whoopPct: 72.6, ringPct: nil), 73) + XCTAssertEqual(LiveConsoleReadout.batteryPercent(activeIsWhoop: true, whoopPct: 72.4, ringPct: nil), 72) + // The exact half goes up, matching Kotlin's Math.round over a positive percentage. + XCTAssertEqual(LiveConsoleReadout.batteryPercent(activeIsWhoop: true, whoopPct: 72.5, ringPct: nil), 73) + XCTAssertEqual(LiveConsoleReadout.batteryPercent(activeIsWhoop: true, whoopPct: 99.7, ringPct: nil), 100) + } + + func testARingActiveDeviceShowsTheRingCharge() { + // The reported numbers exactly: ring 93, strap 72.40, console showed 72. + XCTAssertEqual( + LiveConsoleReadout.batteryPercent(activeIsWhoop: false, whoopPct: 72.4, ringPct: 93), 93) + } + + func testARingActiveDeviceNeverFallsBackToTheStrapCharge() { + // The heart of it. Nothing is the honest answer; the strap's number under the ring's name is a + // confident lie, and is the bug. + XCTAssertNil( + LiveConsoleReadout.batteryPercent(activeIsWhoop: false, whoopPct: 72.4, ringPct: nil)) + } + + /// The Devices list asks this PER ROW rather than of the active + /// device, which is the stronger question there because the row itself is in hand. Composed here so + /// the two halves are pinned together the way the screen actually uses them. + /// Calls `SourceIdentity.isWhoop` directly, where the screens call `SourceCoordinator.isWhoop`. + /// That wrapper is main-actor isolated, so a synchronous test cannot reach it, and it delegates to + /// this verbatim with no logic of its own — so the behaviour under test is the same one. + func testARingRowShowsTheRingChargeEvenWhenTheStrapReportedOne() { + let ring = device("oura-123", "Oura") + let strap = device("my-whoop", "WHOOP") + XCTAssertEqual( + LiveConsoleReadout.batteryPercent(activeIsWhoop: SourceIdentity.isWhoop(ring), + whoopPct: 72.4, ringPct: 93), 93) + XCTAssertEqual( + LiveConsoleReadout.batteryPercent(activeIsWhoop: SourceIdentity.isWhoop(strap), + whoopPct: 72.4, ringPct: 93), 72) + } + + func testAWhoopActiveDeviceIgnoresAnyRingCharge() { + // Symmetry: a ring that reported earlier must not leak into a strap's readout either. + XCTAssertNil( + LiveConsoleReadout.batteryPercent(activeIsWhoop: true, whoopPct: nil, ringPct: 93)) + } +} diff --git a/StrandTests/LiveHRBannerLifecycleTests.swift b/StrandTests/LiveHRBannerLifecycleTests.swift new file mode 100644 index 0000000000..339fc20b22 --- /dev/null +++ b/StrandTests/LiveHRBannerLifecycleTests.swift @@ -0,0 +1,57 @@ +import XCTest +@testable import Strand + +/// The live heart rate banner is kept, and shows what is true, until its switch turns it off. iOS lets an app start one +/// only while it is on screen, so every end NOOP made on its own left the Lock Screen without it until NOOP was opened. +final class LiveHRBannerLifecycleTests: XCTestCase { + + private func step(switchOn: Bool = true, standsAside: Bool = false, linkUp: Bool = true, + showing: Bool = true, age: TimeInterval? = 60, + appActive: Bool = false) -> LiveHRBannerLifecycle.Step { + LiveHRBannerLifecycle.step(switchOn: switchOn, standsAside: standsAside, linkUp: linkUp, + showing: showing, age: age, appActive: appActive) + } + + /// A dropped link of any length and a strap that is not measuring show the dash, on screen or not; the number comes + /// back by itself. What the banner shows is not a reason to end it: the lifecycle does not read the heart rate. + func testNothingButItsSwitchAndTheGymBannerEndsIt() { + for linkUp in [true, false] { + for appActive in [true, false] { + XCTAssertEqual(step(linkUp: linkUp, appActive: appActive), .push, + "link up \(linkUp), on screen \(appActive)") + } + } + } + + func testTheSwitchEndsIt() { + XCTAssertEqual(step(switchOn: false), .end) + XCTAssertEqual(step(switchOn: false, showing: false, appActive: true), .nothing) + } + + /// A background sync shows no sync banner, so there is nothing to make room for. + func testOnlyTheGymBannerTakesItsPlace() { + XCTAssertEqual(step(standsAside: true), .end) + XCTAssertEqual(step(standsAside: true, showing: false, appActive: true), .nothing) + } + + /// iOS refuses a new banner to an app in the background: it is not asked for one there. On screen it starts with + /// the strap connected, before a heart rate arrives (the dash), so a strap put on later with NOOP in the background + /// finds a banner to fill. + func testANewBannerIsAskedForOnlyOnScreenWithTheStrapConnected() { + XCTAssertEqual(step(showing: false, appActive: false), .nothing) + XCTAssertEqual(step(showing: false, appActive: true), .start) + XCTAssertEqual(step(linkUp: false, showing: false, appActive: true), .nothing) + } + + /// iOS ends a banner about eight hours after it started. Opened after the first hour, NOOP starts a fresh one, so + /// the banner has most of those hours ahead of it again; in the background it cannot, and does not ask. + func testAnOldBannerIsRenewedWhenNOOPIsOnScreen() { + let renewAfter = LiveHRBannerLifecycle.renewAfter + XCTAssertEqual(step(age: renewAfter - 1, appActive: true), .push) + XCTAssertEqual(step(age: renewAfter, appActive: true), .renew) + XCTAssertEqual(step(age: nil, appActive: true), .renew) // started by an earlier build: unknown age + XCTAssertEqual(step(linkUp: false, age: renewAfter, appActive: true), .renew) + XCTAssertEqual(step(age: 7 * 60 * 60, appActive: false), .push) + XCTAssertEqual(step(switchOn: false, age: renewAfter, appActive: true), .end) + } +} diff --git a/StrandTests/LiveHRBannerPushPolicyTests.swift b/StrandTests/LiveHRBannerPushPolicyTests.swift new file mode 100644 index 0000000000..153a5257a7 --- /dev/null +++ b/StrandTests/LiveHRBannerPushPolicyTests.swift @@ -0,0 +1,76 @@ +import XCTest +@testable import Strand + +/// When the Live HR banner is pushed. It shows three numbers and used to be pushed on the first heart-rate tick more +/// than 2 s after the last push — every ~3 s while a strap was connected — whether or not any of them had moved. +final class LiveHRBannerPushPolicyTests: XCTestCase { + + private struct Shown: Equatable { var bpm: Int?; var recovery: Int?; var effort: Int? } + private let staleAfter: TimeInterval = 120 + + func testTheFirstPushAlwaysGoesOut() { + XCTAssertTrue(LiveHRBannerPushPolicy.due(shown: nil as Shown?, next: Shown(bpm: 62), + reading: \.bpm, sinceLastPush: .infinity, staleAfter: staleAfter)) + } + + func testAnUnchangedBannerIsNotPushedAgain() { + let same = Shown(bpm: 62, recovery: 71, effort: 4) + for since: TimeInterval in [3, 10, 30, 59] { + XCTAssertFalse(LiveHRBannerPushPolicy.due(shown: same, next: same, reading: \.bpm, sinceLastPush: since, + staleAfter: staleAfter), "\(since) s") + } + } + + /// Each push carries a stale date `staleAfter` ahead; an unchanged banner is re-pushed at half of that, so a + /// connected strap never lets it go stale. + func testAnUnchangedBannerIsRepushedBeforeItGoesStale() { + let same = Shown(bpm: 62, recovery: 71, effort: 4) + XCTAssertTrue(LiveHRBannerPushPolicy.due(shown: same, next: same, reading: \.bpm, sinceLastPush: 60, + staleAfter: staleAfter)) + } + + /// A change reaches the banner as soon as before: any field, after the same 2 s spacing. + func testAChangeIsPushedAfterTheSameSpacingAsBefore() { + let shown = Shown(bpm: 62, recovery: 71, effort: 4) + for next in [Shown(bpm: 63, recovery: 71, effort: 4), Shown(bpm: 62, recovery: 72, effort: 4), + Shown(bpm: 62, recovery: 71, effort: 5)] { + XCTAssertFalse(LiveHRBannerPushPolicy.due(shown: shown, next: next, reading: \.bpm, sinceLastPush: 2, + staleAfter: staleAfter)) + XCTAssertTrue(LiveHRBannerPushPolicy.due(shown: shown, next: next, reading: \.bpm, sinceLastPush: 2.5, + staleAfter: staleAfter)) + } + } + + /// The strap taken off the wrist sends WRIST_OFF and then nothing: a dash held back for the spacing would never be + /// retried, and the last number would stand until iOS's stale date. It goes out at once, and so does the number + /// coming back; everything else still waits for the spacing. + func testTheNumberGivingWayToTheDashOrComingBackIsPushedAtOnce() { + let number = Shown(bpm: 91, recovery: 71, effort: 4) + let dash = Shown(bpm: nil, recovery: 71, effort: 4) + for since: TimeInterval in [0, 0.5, 2] { + XCTAssertTrue(LiveHRBannerPushPolicy.due(shown: number, next: dash, reading: \.bpm, sinceLastPush: since, + staleAfter: staleAfter), "number -> dash after \(since) s") + XCTAssertTrue(LiveHRBannerPushPolicy.due(shown: dash, next: number, reading: \.bpm, sinceLastPush: since, + staleAfter: staleAfter), "dash -> number after \(since) s") + XCTAssertFalse(LiveHRBannerPushPolicy.due(shown: dash, next: Shown(bpm: nil, recovery: 72, effort: 4), + reading: \.bpm, sinceLastPush: since, staleAfter: staleAfter)) + } + } + + /// The cost this exists for: an hour of ticks once a second with a heart rate that holds still. The old rule + /// (any tick more than 2 s after the last push) made that 1,200 pushes of the same banner; now it is 60. + func testAnHourOfASteadyReadingIsOnePushAMinute() { + let steady = Shown(bpm: 58, recovery: 80, effort: 2) + var shown: Shown? + var lastPush = -TimeInterval.infinity + var pushes = 0 + for second in 0..<3600 { + let now = TimeInterval(second) + if LiveHRBannerPushPolicy.due(shown: shown, next: steady, reading: \.bpm, sinceLastPush: now - lastPush, + staleAfter: staleAfter) { + pushes += 1; shown = steady; lastPush = now + } + } + XCTAssertEqual(pushes, 60) + } +} diff --git a/StrandTests/LiveHeartRateReadabilityTests.swift b/StrandTests/LiveHeartRateReadabilityTests.swift new file mode 100644 index 0000000000..a634cab96d --- /dev/null +++ b/StrandTests/LiveHeartRateReadabilityTests.swift @@ -0,0 +1,82 @@ +import XCTest +import Combine +import WhoopProtocol +@testable import Strand + +/// The live heart rate NOOP shows is cleared when the strap stops measuring, instead of the last reading standing on +/// every surface for as long as the link lasts. +@MainActor +final class LiveHeartRateReadabilityTests: XCTestCase { + + func testWhatCountsAsReadable() { + XCTAssertTrue(LiveHeartRateReadability.isReadable(bpm: 62, contact: .supportedDetected)) + XCTAssertTrue(LiveHeartRateReadability.isReadable(bpm: 62, contact: .unsupported)) // no contact flag at all + XCTAssertFalse(LiveHeartRateReadability.isReadable(bpm: 62, contact: .supportedNotDetected)) + XCTAssertFalse(LiveHeartRateReadability.isReadable(bpm: 0, contact: .unsupported)) + XCTAssertFalse(LiveHeartRateReadability.isReadable(bpm: 29, contact: .supportedDetected)) + XCTAssertFalse(LiveHeartRateReadability.isReadable(bpm: 221, contact: .supportedDetected)) + } + + /// One or two unreadable samples while worn clear nothing; a third in a row does, once. + func testOnlyARunOfUnreadableSamplesClears() { + var gate = LiveHeartRateReadability() + XCTAssertFalse(gate.clearsShownHeartRate(bpm: 0, contact: .unsupported)) + XCTAssertFalse(gate.clearsShownHeartRate(bpm: 0, contact: .unsupported)) + XCTAssertFalse(gate.clearsShownHeartRate(bpm: 64, contact: .supportedDetected)) // a glitch, then back + XCTAssertFalse(gate.clearsShownHeartRate(bpm: 0, contact: .unsupported)) + XCTAssertFalse(gate.clearsShownHeartRate(bpm: 60, contact: .supportedNotDetected)) + XCTAssertTrue(gate.clearsShownHeartRate(bpm: 0, contact: .unsupported)) + XCTAssertFalse(gate.clearsShownHeartRate(bpm: 0, contact: .unsupported)) // still off: cleared already + } + + /// Surfaces listen to the heart rate; by the time it is cleared, the R-R packet must be gone too, or the median + /// rebuilt from it and the banner, handed the old values, went on showing a number. + func testClearingLeavesNothingForAHeartRateListener() { + let live = LiveState() + live.setRRIntervals([800]) + live.heartRate = 70 + let seqBefore = live.rrSeq + var seen: [(hr: Int?, rr: [Int])] = [] + var subscriptions = Set() + live.$heartRate.dropFirst().sink { hr in seen.append((hr, live.rr)) }.store(in: &subscriptions) + + live.clearLiveHeartRate() + + XCTAssertEqual(seen.count, 1) + XCTAssertNil(seen.first?.hr) + XCTAssertEqual(seen.first?.rr, []) + XCTAssertNil(live.heartRate) + XCTAssertEqual(live.rrSeq, seqBefore) // no new packet: packet consumers are not woken + } + + /// A WHOOP 5.0 off the wrist can go quiet with the link up: with no readable sample for the silence wait, the live + /// heart rate is cleared (the 23 Sep log: the Lock Screen stayed at 93 without this). + func testSilenceClearsTheHeartRate() { + let live = LiveState() + live.heartRateSilence = 0.3 + live.heartRate = 93 + live.noteReadableHeartRate() + let cleared = expectation(description: "cleared") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { + XCTAssertNil(live.heartRate) + cleared.fulfill() + } + wait(for: [cleared], timeout: 3) + } + + /// Samples arriving inside the wait keep it standing. + func testSamplesKeepTheHeartRate() { + let live = LiveState() + live.heartRateSilence = 0.5 + live.heartRate = 93 + for i in 0..<10 { + DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.1) { live.noteReadableHeartRate() } + } + let held = expectation(description: "held") + DispatchQueue.main.asyncAfter(deadline: .now() + 1.1) { + XCTAssertEqual(live.heartRate, 93) + held.fulfill() + } + wait(for: [held], timeout: 3) + } +} diff --git a/StrandTests/LiveStateLogGenerationsTests.swift b/StrandTests/LiveStateLogGenerationsTests.swift deleted file mode 100644 index 33c04d7ec7..0000000000 --- a/StrandTests/LiveStateLogGenerationsTests.swift +++ /dev/null @@ -1,143 +0,0 @@ -import XCTest -@testable import Strand - -/// The durable strap-log tail used to be a SINGLE slot: a fresh process began logging and, 32 lines in, -/// `persistTail` overwrote it — so the lines that would explain an unexplained restart were destroyed by -/// the restart itself. Three consecutive overnight Oura captures were lost exactly that way. These pin the -/// generation ring that rescues the tail at first append. -@MainActor -final class LiveStateLogGenerationsTests: XCTestCase { - private let tailKey = "strapLog.tail" - private let gensKey = "strapLog.generations" - - override func setUp() { - super.setUp() - UserDefaults.standard.removeObject(forKey: tailKey) - UserDefaults.standard.removeObject(forKey: gensKey) - LiveState.resetGenerationRollLatchForTesting() - } - - override func tearDown() { - UserDefaults.standard.removeObject(forKey: tailKey) - UserDefaults.standard.removeObject(forKey: gensKey) - LiveState.resetGenerationRollLatchForTesting() - super.tearDown() - } - - /// THE ONE THAT MATTERS: a surviving tail is moved into a generation, and the live slot is cleared so - /// the next `persistTail` cannot destroy it. - func testRollMovesTheSurvivingTailIntoAGeneration() { - UserDefaults.standard.set(["22:01 connected", "22:02 drain done"], forKey: tailKey) - - LiveState.rollLogGenerationsIfNeeded() - - let gens = LiveState.persistedLogGenerations() - XCTAssertEqual(gens.count, 1) - XCTAssertEqual(gens[0].dropFirst(), ["22:01 connected", "22:02 drain done"]) - XCTAssertTrue(gens[0][0].contains("previous app session"), "generation must carry its own header") - XCTAssertEqual(LiveState.persistedLogTail(), [], "the live slot must be cleared, not left to duplicate") - } - - /// Idempotent per process: a second call must NOT push this process's own partial tail in as a - /// "previous" session (which would evict a real one and mislabel the current lines). - func testRollIsOncePerProcess() { - UserDefaults.standard.set(["old line"], forKey: tailKey) - LiveState.rollLogGenerationsIfNeeded() - UserDefaults.standard.set(["a line this process just wrote"], forKey: tailKey) - - LiveState.rollLogGenerationsIfNeeded() - - XCTAssertEqual(LiveState.persistedLogGenerations().count, 1) - XCTAssertEqual(LiveState.persistedLogTail(), ["a line this process just wrote"]) - } - - /// A launch that logs nothing (or one right after a roll) must not push an empty generation — that - /// would evict a real one, which is the opposite of the point. - func testEmptyTailRollsNothing() { - LiveState.rollLogGenerationsIfNeeded() - XCTAssertEqual(LiveState.persistedLogGenerations().count, 0) - } - - /// The ring is bounded and drops the OLDEST, keeping the most recent restarts. - func testRingKeepsTheMostRecentGenerations() { - for i in 1...(LiveState.maxLogGenerations + 2) { - UserDefaults.standard.set(["session \(i)"], forKey: tailKey) - LiveState.resetGenerationRollLatchForTesting() // stands in for a fresh process - LiveState.rollLogGenerationsIfNeeded() - } - let gens = LiveState.persistedLogGenerations() - XCTAssertEqual(gens.count, LiveState.maxLogGenerations) - XCTAssertEqual(gens.first?.dropFirst().first, "session 3", "oldest generations are evicted first") - XCTAssertEqual(gens.last?.dropFirst().first, "session \(LiveState.maxLogGenerations + 2)") - } - - /// Each generation is clipped to its own cap so the ring cannot grow UserDefaults without bound, and - /// the clip keeps the TAIL — what explains a stop is the end of the previous session, not its start. - func testGenerationIsClippedToItsTail() { - let many = (0..<(LiveState.generationTailLimit + 50)).map { "line \($0)" } - UserDefaults.standard.set(many, forKey: tailKey) - - LiveState.rollLogGenerationsIfNeeded() - - let body = Array(LiveState.persistedLogGenerations()[0].dropFirst()) - XCTAssertEqual(body.count, LiveState.generationTailLimit) - XCTAssertEqual(body.last, "line \(LiveState.generationTailLimit + 49)", "the newest line must survive") - } - - /// A clipped generation must SAY it lost its head. The header reported the pre-clip total, so a - /// generation holding 1,000 of 2,000 lines announced "2000 line(s)" and read as a complete session — - /// and the dropped head then measures as silence in the log tools. - func testClippedGenerationHeaderSaysWhatWasKept() { - let many = (0..<(LiveState.generationTailLimit + 50)).map { "line \($0)" } - UserDefaults.standard.set(many, forKey: tailKey) - - LiveState.rollLogGenerationsIfNeeded() - - let header = LiveState.persistedLogGenerations()[0][0] - XCTAssertTrue(header.contains("\(LiveState.generationTailLimit) of \(many.count) line(s)"), - "header must carry both the kept and the pre-clip count: \(header)") - XCTAssertTrue(header.contains("head clipped"), "a clipped generation must say so: \(header)") - } - - /// ...and an UNCLIPPED one must not cry wolf: no "head clipped", just the count. - func testUnclippedGenerationHeaderClaimsNoLoss() { - UserDefaults.standard.set(["22:01 connected", "22:02 drain done"], forKey: tailKey) - - LiveState.rollLogGenerationsIfNeeded() - - let header = LiveState.persistedLogGenerations()[0][0] - XCTAssertTrue(header.contains("2 line(s)"), header) - XCTAssertFalse(header.contains("clipped"), header) - } - - /// The export puts previous sessions AHEAD of the current one, so `report.txt` stays chronological and - /// the log-parsing tools keep working on it unchanged. - func testPreviousSessionsTextPrecedesTheCurrentMarker() { - UserDefaults.standard.set(["last night 03:00 disconnected"], forKey: tailKey) - LiveState.rollLogGenerationsIfNeeded() - - let text = LiveState.previousSessionsText() - let prevIdx = try? XCTUnwrap(text.range(of: "last night 03:00 disconnected")?.lowerBound) - let curIdx = try? XCTUnwrap(text.range(of: "current app session")?.lowerBound) - XCTAssertNotNil(prevIdx) - XCTAssertNotNil(curIdx) - if let p = prevIdx, let c = curIdx { XCTAssertTrue(p < c) } - } - - func testNoGenerationsRendersNothing() { - XCTAssertEqual(LiveState.previousSessionsText(), "") - } - - /// #1263: a manual export taken BEFORE this process's first append must still carry the previous - /// session. `exportableLogText()` rolls the surviving tail itself (not only `append`), so an - /// instant post-restart Report is not empty — the exact case the generation ring exists for. - func testExportBeforeFirstAppendStillCarriesThePreviousSession() { - UserDefaults.standard.set(["last night 03:14 reconnect storm", "03:15 gave up"], forKey: tailKey) - // Fresh process: setUp reset the roll latch and the in-memory `log` is empty (no append yet). - let text = LiveState().exportableLogText() - XCTAssertTrue(text.contains("last night 03:14 reconnect storm"), - "export before the first append must roll + include the previous session") - XCTAssertTrue(text.contains("previous app session"), "the previous session keeps its header") - XCTAssertEqual(LiveState.persistedLogTail(), [], "the roll clears the live slot") - } -} diff --git a/StrandTests/MarkerUnitsUnicodeTests.swift b/StrandTests/MarkerUnitsUnicodeTests.swift index 21541a71af..3d6b7ec662 100644 --- a/StrandTests/MarkerUnitsUnicodeTests.swift +++ b/StrandTests/MarkerUnitsUnicodeTests.swift @@ -1,5 +1,6 @@ import XCTest @testable import Strand +import StrandImport final class MarkerUnitsUnicodeTests: XCTestCase { func testSlugCanonicalizesNFCAndNFDBeforeSlugging() { @@ -13,4 +14,11 @@ final class MarkerUnitsUnicodeTests: XCTestCase { XCTAssertEqual(Array(MarkerUnits.slug("Cafe\u{301} Marker").utf8), expected) XCTAssertEqual(MarkerUnits.slug("Apo B"), "custom_apo_b") } + + func testSlugKeepsCountAndPercentApartAndMatchesTheCsvImporter() { + XCTAssertEqual(MarkerUnits.slug("LYMPH"), "custom_lymph") + XCTAssertEqual(MarkerUnits.slug("LYMPH %"), "custom_lymph_pct") + XCTAssertEqual(MarkerUnits.slug("LYMPH %"), LabMarkerCsvImport.customKey("LYMPH %")) + XCTAssertEqual(MarkerUnits.slug(" ??? "), "custom_") + } } 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/OuraLiveSourceProductInfoLogRedactionTests.swift b/StrandTests/OuraLiveSourceProductInfoLogRedactionTests.swift new file mode 100644 index 0000000000..d20b360476 --- /dev/null +++ b/StrandTests/OuraLiveSourceProductInfoLogRedactionTests.swift @@ -0,0 +1,47 @@ +import XCTest +@testable import Strand + +/// #2092, route 1: the product-info reply log line printed `ascii: ` in the clear — neither +/// `LiveState.redactPii` rule matches a bare, unprefixed serial (by design: a shape-only rule would mask +/// ordinary prose), and the hex half was only masked by `redactHexDump`'s accident of matching a +/// letter-led alnum run, which an all-digit serial (this repo's own #2075/#2090 evidence) never +/// triggers. `OuraLiveSource.logSafeProductInfo` is the fix: computed right where the code already knows +/// (via `isPlausibleSerial`) whether a decoded string IS a serial, so it can mask deliberately instead of +/// relying on an unrelated heuristic to get lucky. +final class OuraLiveSourceProductInfoLogRedactionTests: XCTestCase { + + func testASerialPageIsMaskedInBothHexAndAscii() { + let serial = "2H3B2405003655" + let hex = Array(serial.utf8).map { String(format: "%02x", $0) }.joined(separator: " ") + let safe = OuraLiveSource.logSafeProductInfo(hex: hex, ascii: serial, decoded: serial) + XCTAssertEqual(safe.hex, "") + XCTAssertEqual(safe.ascii, "2H3…") + XCTAssertFalse(safe.ascii.contains("2405003655")) + } + + /// This repo's own evidence (#2075's attachment): an all-digit serial with no letter to anchor a + /// letter-required heuristic on. Must mask the same as an alphanumeric one. + func testAnAllDigitSerialPageIsMaskedToo() { + let serial = "2038082631034041" + let hex = Array(serial.utf8).map { String(format: "%02x", $0) }.joined(separator: " ") + let safe = OuraLiveSource.logSafeProductInfo(hex: hex, ascii: serial, decoded: serial) + XCTAssertEqual(safe.hex, "") + XCTAssertEqual(safe.ascii, "203…") + } + + /// A hardware-generation page ("BLB_03") identifies no one - never `isPlausibleSerial` because of the + /// underscore - and must survive in full: masking it would throw away a real diagnostic for nothing. + func testAHardwarePageIsLoggedInFull() { + let hex = Array("BLB_03".utf8).map { String(format: "%02x", $0) }.joined(separator: " ") + let safe = OuraLiveSource.logSafeProductInfo(hex: hex, ascii: "BLB_03", decoded: "BLB_03") + XCTAssertEqual(safe.hex, hex) + XCTAssertEqual(safe.ascii, "BLB_03") + } + + /// A frame that failed to decode at all (nil) has nothing to mask and nothing to leak; pass through. + func testANilDecodeIsLeftAlone() { + let safe = OuraLiveSource.logSafeProductInfo(hex: "00 01", ascii: "..", decoded: nil) + XCTAssertEqual(safe.hex, "00 01") + XCTAssertEqual(safe.ascii, "..") + } +} diff --git a/StrandTests/OuraLiveSourceRawDumpRedactionTests.swift b/StrandTests/OuraLiveSourceRawDumpRedactionTests.swift new file mode 100644 index 0000000000..e0ec3f3a26 --- /dev/null +++ b/StrandTests/OuraLiveSourceRawDumpRedactionTests.swift @@ -0,0 +1,77 @@ +import XCTest +import OuraProtocol +@testable import Strand + +/// `OuraLiveSource.rawDumpBytes` is what actually reaches `oura-raw.jsonl` — the sidecar's own +/// redaction pass only ever masks the JSON envelope's `deviceId`, never bytes inside a frame body. +/// A `0x18`/`0x19` GetProductInfo reply's body IS the ring's serial/hardware string in plain ASCII +/// (found via a user's own capture, which carried a stable 16-digit identifier into a public GitHub +/// issue attachment this way). These pin that the raw sidecar never sees that frame, while every +/// other frame — including ones that happen to share a notification with it — is untouched. +final class OuraLiveSourceRawDumpRedactionTests: XCTestCase { + private func encoded(_ frames: [OuraOuterFrame]) -> [UInt8] { + frames.flatMap { [$0.op, UInt8($0.body.count)] + $0.body } + } + + func testProductInfoRequestAndResponseAreBothDropped() { + let serial = Array("2038082631034041".utf8) + let frames = [OuraOuterFrame(op: 0x18, body: [0x08, 0x00, 0x10]), + OuraOuterFrame(op: 0x19, body: serial)] + XCTAssertEqual(OuraLiveSource.rawDumpBytes(frames), []) + } + + func testAnOrdinaryFrameIsUnaffected() { + let frames = [OuraOuterFrame(op: 0x5D, body: [0x01, 0x02, 0x03])] + XCTAssertEqual(OuraLiveSource.rawDumpBytes(frames), encoded(frames)) + } + + func testOnlyTheProductInfoFrameIsStrippedOutOfAMixedNotification() { + let hr = OuraOuterFrame(op: 0x80, body: [0x42]) + let productInfo = OuraOuterFrame(op: 0x19, body: Array("COR_08".utf8)) + let battery = OuraOuterFrame(op: 0x0D, body: [0x64]) + XCTAssertEqual(OuraLiveSource.rawDumpBytes([hr, productInfo, battery]), encoded([hr, battery])) + } + + /// PR #2090 review (ryanbr): `parseOuterFrames` silently drops an incomplete trailing frame + /// (`guard i + total <= bytes.count else { break }`) — ordinary, not rare, since the Reassembler + /// exists precisely because notifications split mid-frame. A caller reconstructing the sidecar from + /// `frames` alone therefore loses that unconsumed remainder. Reproduces the exact worked example from + /// the review: `41 02 AA BB 42 05 01 02` parses ONE complete frame (`41 02 AA BB`, 4 bytes) and leaves + /// `42 05 01 02` (4 of the claimed 7 body bytes) unconsumed - `rawDumpBytes(fromNotification:frames:)` + /// must still carry it into the sidecar, verbatim, appended after the (filtered) complete frames. + func testIncompleteTrailingFrameSurvivesAsATailNotAsALoss() { + let bytes: [UInt8] = [0x41, 0x02, 0xAA, 0xBB, 0x42, 0x05, 0x01, 0x02] + let frames = OuraFraming.parseOuterFrames(bytes) + XCTAssertEqual(frames, [OuraOuterFrame(op: 0x41, body: [0xAA, 0xBB])], "sanity: only one frame parses") + let dump = OuraLiveSource.rawDumpBytes(fromNotification: bytes, frames: frames) + XCTAssertEqual(dump, bytes, "the unconsumed tail must survive verbatim, not be dropped") + } + + /// The same convenience function must still drop a product-info frame when the notification parses + /// cleanly with nothing left over (the ordinary case, no tail to preserve). + func testNotificationConvenienceStillStripsProductInfoWithNoTrailingPartial() { + let serial = Array("2038082631034041".utf8) + let bytes: [UInt8] = [0x19, UInt8(serial.count)] + serial + let frames = OuraFraming.parseOuterFrames(bytes) + XCTAssertEqual(OuraLiveSource.rawDumpBytes(fromNotification: bytes, frames: frames), []) + } + + func testSplitProductInfoFrameTailIsDroppedNotAppended() { + let partialSerial = Array("2H3B2405003655".utf8) // 14 of the 20 bytes the header claims + let bytes: [UInt8] = [0x41, 0x02, 0xAA, 0xBB, 0x19, 0x14] + partialSerial + let frames = OuraFraming.parseOuterFrames(bytes) + XCTAssertEqual(frames, [OuraOuterFrame(op: 0x41, body: [0xAA, 0xBB])], "sanity: only one frame parses") + let dump = OuraLiveSource.rawDumpBytes(fromNotification: bytes, frames: frames) + XCTAssertEqual(dump, [0x41, 0x02, 0xAA, 0xBB], "the split product-info tail must be dropped whole") + XCTAssertFalse(String(decoding: dump, as: UTF8.self).contains("2H3B2405003655")) + } + + /// The same guard applies to a bare one-byte remainder (just the op, not even a length byte yet) - + /// the review's "a one-byte remainder is fine to test the same way" note. + func testOneByteProductInfoOpRemainderIsDropped() { + let bytes: [UInt8] = [0x41, 0x02, 0xAA, 0xBB, 0x18] + let frames = OuraFraming.parseOuterFrames(bytes) + let dump = OuraLiveSource.rawDumpBytes(fromNotification: bytes, frames: frames) + XCTAssertEqual(dump, [0x41, 0x02, 0xAA, 0xBB]) + } +} diff --git a/StrandTests/OuraRawDumpSessionTests.swift b/StrandTests/OuraRawDumpSessionTests.swift new file mode 100644 index 0000000000..49eb91f53d --- /dev/null +++ b/StrandTests/OuraRawDumpSessionTests.swift @@ -0,0 +1,167 @@ +import XCTest +@testable import Strand + +/// The raw Oura sidecar is scoped to ONE capture session (option 1 of the 2026-08-09 sidecar decision), +/// and completed sessions are kept in a numbered generation ring (the 2026-08-10 follow-up). +/// +/// These pin two properties, each bought with a lost night of raw evidence: +/// * the live file is never rolled part-way through a session — a 25 MB threshold fired mid-drain at +/// 07:06:58 on 2026-08-09 and carried 71 of the night's 75 `0x5D` records out of the export; +/// * a session boundary does not evict a real capture — one-deep retention lost the 2026-08-10 night +/// because the app relaunched twice after wake (07:45, 08:17), filling both slots with sessions of +/// 87 KB and 44 KB. +final class OuraRawDumpSessionTests: XCTestCase { + private var dir: URL! + + override func setUpWithError() throws { + dir = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("oura-raw-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: dir) + } + + private func live() -> URL { dir.appendingPathComponent("oura-raw-ring1.jsonl") } + private func previous() -> URL { generation(1) } + private func generation(_ index: Int) -> URL { + dir.appendingPathComponent("oura-raw-ring1.jsonl.\(index)") + } + private func lines(_ url: URL) -> [String] { + guard let s = try? String(contentsOf: url, encoding: .utf8) else { return [] } + return s.split(separator: "\n").map(String.init) + } + + private func size(_ url: URL) -> Int { + ((try? FileManager.default.attributesOfItem(atPath: url.path))?[.size] as? Int) ?? 0 + } + + /// A session big enough to be worth a generation slot (>= `minRolledBytes`), tagged so it can be + /// identified after rolling. `repeat` is load-bearing: a fresh `OuraRawDump` has not resolved its file + /// yet, so before the FIRST record the live file still holds the previous session — a leading size + /// check would see it already "full" and write nothing at all. + private func writeSubstantialSession(tag: UInt8) { + let dump = OuraRawDump(deviceId: "ring1", log: { _ in }, directory: dir) + let payload = [tag] + [UInt8](repeating: 0x00, count: 512) + repeat { dump.record(bytes: payload) } while size(live()) < OuraRawDump.minRolledBytes + } + + /// The `hex` field of a file's first record. Read the FIELD, never the whole line: the line also carries + /// a unix `utc` and an ISO stamp, and a substring search for a tag like "22" hits those constantly. + private func firstHex(_ url: URL) -> String? { + guard let line = lines(url).first, + let range = line.range(of: "\"hex\":\"") else { return nil } + return line[range.upperBound...].prefix(while: { $0 != "\"" }).description + } + + func testOneSessionKeepsEveryRecordInTheLiveFile() { + let dump = OuraRawDump(deviceId: "ring1", log: { _ in }, directory: dir) + for i in 0..<500 { dump.record(bytes: [0x5D, UInt8(i % 256), 0x01, 0x02]) } + // The whole session is in ONE file, and nothing was rolled aside part-way through. + XCTAssertEqual(lines(live()).count, 500) + XCTAssertFalse(FileManager.default.fileExists(atPath: previous().path)) + } + + func testANewSessionRollsThePreviousOneAsideAndStartsEmpty() { + writeSubstantialSession(tag: 0xAA) + let rolledLineCount = lines(live()).count + XCTAssertGreaterThan(rolledLineCount, 0) + + // A new object is a new session (one is built per OuraLiveSource). + let second = OuraRawDump(deviceId: "ring1", log: { _ in }, directory: dir) + second.record(bytes: [0xBB]) + + XCTAssertEqual(lines(live()).count, 1, "the live file is THIS session only") + XCTAssertEqual(lines(previous()).count, rolledLineCount, "the previous session is retained as .1") + XCTAssertTrue(lines(live())[0].contains("bb"), "the live file holds the new session's bytes") + XCTAssertEqual(firstHex(previous())?.prefix(2), "aa") + } + + /// THE 2026-08-10 REGRESSION TEST. Two ordinary post-wake app relaunches must not be able to reach back + /// and destroy the night: with one-deep retention this is exactly what happened. + func testAnOvernightSessionSurvivesTwoRestartsAfterWake() { + writeSubstantialSession(tag: 0x11) // "the night" + writeSubstantialSession(tag: 0x22) // 07:45 relaunch + writeSubstantialSession(tag: 0x33) // 08:17 relaunch + let export = OuraRawDump(deviceId: "ring1", log: { _ in }, directory: dir) + export.record(bytes: [0xFF]) // the session the export is taken during + + XCTAssertEqual(firstHex(live()), "ff") + XCTAssertEqual(firstHex(generation(1))?.prefix(2), "33") + XCTAssertEqual(firstHex(generation(2))?.prefix(2), "22") + XCTAssertEqual(firstHex(generation(3))?.prefix(2), "11", "the night is still on disk") + } + + func testTheGenerationRingIsBoundedAtRetainedGenerations() { + for _ in 0..<(OuraRawDump.retainedGenerations + 4) { writeSubstantialSession(tag: 0x55) } + let files = (try? FileManager.default.contentsOfDirectory(atPath: dir.path)) ?? [] + let ours = files.filter { $0.hasPrefix("oura-raw-ring1.jsonl") } + XCTAssertEqual(ours.count, OuraRawDump.retainedGenerations + 1, + "the live file plus exactly \(OuraRawDump.retainedGenerations) generations") + XCTAssertFalse(FileManager.default.fileExists( + atPath: generation(OuraRawDump.retainedGenerations + 1).path)) + } + + /// The rule that makes the ring worth having: a connect that captured essentially nothing is DELETED, + /// not rolled, so a burst of trivial reconnect sessions cannot flush a real capture out of the ring. + func testATrivialSessionIsDiscardedRatherThanSpendingAGeneration() { + writeSubstantialSession(tag: 0xAA) // the capture worth keeping + + for _ in 0..<4 { // four near-empty sessions + let tiny = OuraRawDump(deviceId: "ring1", log: { _ in }, directory: dir) + tiny.record(bytes: [0xBB]) + } + let final = OuraRawDump(deviceId: "ring1", log: { _ in }, directory: dir) + final.record(bytes: [0xCC]) + + XCTAssertEqual(firstHex(live()), "cc") + XCTAssertEqual(firstHex(previous())?.prefix(2), "aa", + "the real session is still .1 — no trivial session took a slot") + XCTAssertFalse(FileManager.default.fileExists(atPath: generation(2).path)) + } + + func testAnEmptyLeftoverIsReusedRatherThanSpendingARetainedGeneration() { + // Build the state a killed session leaves behind: `.1` holds a REAL session, and the live file was + // created but never written to (0 bytes). Rolling that empty file would push the real capture down + // the ring for nothing. + writeSubstantialSession(tag: 0xAA) + let sessionB = OuraRawDump(deviceId: "ring1", log: { _ in }, directory: dir) + sessionB.record(bytes: [0xBB]) // rolls A aside + XCTAssertEqual(firstHex(previous())?.prefix(2), "aa") + FileManager.default.createFile(atPath: live().path, contents: Data()) // B left nothing behind + + let sessionC = OuraRawDump(deviceId: "ring1", log: { _ in }, directory: dir) + sessionC.record(bytes: [0xCC]) + + XCTAssertEqual(lines(live()).count, 1) + XCTAssertTrue(lines(live())[0].contains("cc")) + XCTAssertEqual(firstHex(previous())?.prefix(2), "aa", + "the real previous session survived an empty leftover") + } + + func testASessionThatReceivesNothingTouchesNoFilesAtAll() { + let sessionA = OuraRawDump(deviceId: "ring1", log: { _ in }, directory: dir) + sessionA.record(bytes: [0xAA]) + // A source that comes up and never gets a record never resolves, so it cannot roll anything. + let silent = OuraRawDump(deviceId: "ring1", log: { _ in }, directory: dir) + silent.record(bytes: []) // empty input is a no-op + XCTAssertEqual(lines(live()).count, 1) + XCTAssertTrue(lines(live())[0].contains("aa")) + XCTAssertFalse(FileManager.default.fileExists(atPath: previous().path)) + } + + func testCeilingStopsAppendingAndNeverRollsMidSession() { + let dump = OuraRawDump(deviceId: "ring1", log: { _ in }, directory: dir) + // One record is far under the ceiling, so drive the counter with a payload sized to cross it. + let big = [UInt8](repeating: 0xEE, count: 64 * 1024) + for _ in 0..<(OuraRawDump.maxSessionBytes / (128 * 1024) + 8) { dump.record(bytes: big) } + + let size = (try? FileManager.default.attributesOfItem(atPath: live().path))?[.size] as? Int ?? 0 + XCTAssertLessThanOrEqual(size, OuraRawDump.maxSessionBytes, "the ceiling bounds the session") + XCTAssertGreaterThan(size, 0) + // The critical property: hitting the ceiling must NOT roll the file, or we recreate the 2026-08-09 + // bug at a different threshold. + XCTAssertFalse(FileManager.default.fileExists(atPath: previous().path)) + } +} diff --git a/StrandTests/PendingConnectReplayTests.swift b/StrandTests/PendingConnectReplayTests.swift new file mode 100644 index 0000000000..ea802b9d2c --- /dev/null +++ b/StrandTests/PendingConnectReplayTests.swift @@ -0,0 +1,120 @@ +import XCTest +@testable import Strand + +/// A connect asked for before the radio is up is replayed against a FRESH retrieve, so a bonded device +/// is connected by identifier instead of being left to a background scan (#2433). +/// +/// The reported shape: NOOP is relaunched into the background, `connect(_:)` runs while the source's +/// central is still `.unknown`, `retrievePeripherals` answers nothing because of that, and the empty +/// answer is read as "this ring has never been seen here". The id is parked, a scan is armed, and the +/// replay on `.poweredOn` looks only in `seenPeripherals` — empty for the same reason — so it starts the +/// scan. iOS throttles background scanning hard: the report has a ring bonded for weeks found 29 min +/// 54 s after the relaunch, beside a WHOOP central that attached immediately. +/// +/// The decision is a pure function so it can be pinned without a radio. What it cannot prove is the +/// hardware half: a background relaunch followed within seconds by a connect line rather than a scan. +final class PendingConnectReplayTests: XCTestCase { + + /// The regression. `isHeld` false with `didRetrieve` true is exactly the state a bonded device is in + /// on `.poweredOn`: never discovered this launch, but retrievable now the radio answers. This used to + /// fall through to the scan branch, and it is the whole reason the retrieve is re-issued. + func testARetrievableDeviceConnectsEvenThoughItWasNeverDiscoveredThisLaunch() { + XCTAssertEqual( + PendingConnect.replay(isPending: true, isHeld: false, didRetrieve: true, isScanning: true), + .connect, + "a device the radio can now resolve must be connected by identifier, not scanned for" + ) + } + + /// A scan being armed must not win over a peripheral we can reach. `connect(_:)` used to arm one on + /// its way past, so the losing case had `isScanning: true` set by the very call that parked the id. + func testAnArmedScanDoesNotBeatAReachablePeripheral() { + for held in [true, false] { + XCTAssertEqual( + PendingConnect.replay(isPending: true, isHeld: held, didRetrieve: !held, isScanning: true), + .connect + ) + } + } + + /// Already holding the peripheral is the path that always worked; it must keep working. + func testAHeldPeripheralConnects() { + XCTAssertEqual( + PendingConnect.replay(isPending: true, isHeld: true, didRetrieve: false, isScanning: false), + .connect + ) + } + + /// Genuinely unknown, with a scan already armed and deferred: resume it rather than re-arming, which + /// would wipe `seenPeripherals` for no reason. This is the case the old code applied to everything. + func testAnUnknownDeviceResumesAnAlreadyArmedScan() { + XCTAssertEqual( + PendingConnect.replay(isPending: true, isHeld: false, didRetrieve: false, isScanning: true), + .scan + ) + } + + /// Unknown with nothing armed: discovery has to be STARTED, not skipped. + /// + /// This is the case a first-ever pairing arrives in when the connect is asked for before the radio is + /// up. Parking the intent before the retrieve means nothing called `scan()` on the way past, so there + /// is no deferred scan for the replay to resume. Answering `.idle` here would drop the connect in + /// silence, which is how the first cut of this change behaved, and how its first test said it should. + func testAnUnknownDeviceWithNothingArmedStartsDiscovery() { + XCTAssertEqual( + PendingConnect.replay(isPending: true, isHeld: false, didRetrieve: false, isScanning: false), + .discover, + "a parked connect for an unresolvable device must arm discovery, not evaporate" + ) + } + + /// The whole space at once. A parked connect is never dropped, and nothing is ever started for a + /// caller who asked for nothing. These two are the invariants; the cases above are the examples. + func testNoParkedConnectIsEverDroppedAndNothingStartsUnasked() { + for isPending in [true, false] { + for isHeld in [true, false] { + for didRetrieve in [true, false] { + for isScanning in [true, false] { + let out = PendingConnect.replay(isPending: isPending, isHeld: isHeld, + didRetrieve: didRetrieve, isScanning: isScanning) + let where_ = "held=\(isHeld) retrieved=\(didRetrieve) scanning=\(isScanning)" + if isPending { + XCTAssertNotEqual(out, .idle, "parked connect dropped: \(where_)") + } else { + XCTAssertNotEqual(out, .connect, "connected unasked: \(where_)") + XCTAssertNotEqual(out, .discover, "discovery armed unasked: \(where_)") + } + } + } + } + } + } + + /// No parked connect: the transition is only about resuming a scan that was deferred. + func testWithNothingParkedOnlyADeferredScanResumes() { + XCTAssertEqual( + PendingConnect.replay(isPending: false, isHeld: false, didRetrieve: false, isScanning: true), + .scan + ) + XCTAssertEqual( + PendingConnect.replay(isPending: false, isHeld: false, didRetrieve: false, isScanning: false), + .idle + ) + } + + /// A peripheral is never connected when nothing asked for it, whatever the lookups say. Guards the + /// `isPending` gate itself: dropping it would connect on any `.poweredOn` with a stale handle around. + func testNothingParkedNeverConnects() { + for held in [true, false] { + for retrieved in [true, false] { + for scanning in [true, false] { + XCTAssertNotEqual( + PendingConnect.replay(isPending: false, isHeld: held, + didRetrieve: retrieved, isScanning: scanning), + .connect + ) + } + } + } + } +} diff --git a/StrandTests/PendingManualSyncTests.swift b/StrandTests/PendingManualSyncTests.swift new file mode 100644 index 0000000000..9da31adfee --- /dev/null +++ b/StrandTests/PendingManualSyncTests.swift @@ -0,0 +1,41 @@ +import XCTest +@testable import Strand + +/// The iOS "Sync Strap" shortcut can arrive while NOOP is still launching and reconnecting in the background, +/// before any link can serve. The request is parked and the connect handshake's on-connect kick consumes it: +/// a fresh request upgrades that kick to the un-floored `.manual` tier; a stale or absent one leaves the +/// ordinary `.connect` kick alone. Pure value logic, no CoreBluetooth seam. +final class PendingManualSyncTests: XCTestCase { + private let now = Date(timeIntervalSince1970: 1_700_000_000) + + func testNoPendingRequestIsAnOrdinaryConnectKick() { + XCTAssertEqual(BLEManager.connectSyncTrigger(pendingManualRequestedAt: nil, now: now), .connect) + } + + func testFreshRequestUpgradesToManual() { + let at = now.addingTimeInterval(-30) + XCTAssertEqual(BLEManager.connectSyncTrigger(pendingManualRequestedAt: at, now: now), .manual) + } + + func testRequestOlderThanTTLIsIgnored() { + let at = now.addingTimeInterval(-(BLEManager.pendingManualSyncTTL + 1)) + XCTAssertEqual(BLEManager.connectSyncTrigger(pendingManualRequestedAt: at, now: now), .connect) + } + + func testRequestExactlyAtTTLIsIgnored() { + let at = now.addingTimeInterval(-BLEManager.pendingManualSyncTTL) + XCTAssertEqual(BLEManager.connectSyncTrigger(pendingManualRequestedAt: at, now: now), .connect) + } + + func testFutureDatedRequestIsIgnored() { + // A clock that jumped backwards must not turn a request into an indefinitely fresh one. + let at = now.addingTimeInterval(60) + XCTAssertEqual(BLEManager.connectSyncTrigger(pendingManualRequestedAt: at, now: now), .connect) + } + + /// The request must outlive the process: iOS can end the background-launched app and relaunch it through + /// state restoration when the strap reconnects, and that relaunch is where the connect completes. + func testRequestIsPersistedUnderAStableKey() { + XCTAssertEqual(BLEManager.pendingManualSyncKey, "sync.pendingManualRequestedAt") + } +} diff --git a/StrandTests/PendingSyncDiagnosticTests.swift b/StrandTests/PendingSyncDiagnosticTests.swift new file mode 100644 index 0000000000..8dd6871624 --- /dev/null +++ b/StrandTests/PendingSyncDiagnosticTests.swift @@ -0,0 +1,77 @@ +import XCTest +@testable import Strand + +/// #2012: the Rest "Pending sync" state is `backfilling || historyPendingSync`, and only the first half +/// left a trace. A reporter's log arrived showing the note in the afternoon, and it could only be read by +/// inferring from what else was happening at that timestamp, which settles nothing. +/// +/// These pin that the line names the DECIDING input, because the four things that can decide it mean four +/// different bugs. Mirrors Android `PendingSyncDiagnosticTest` case-for-case. +final class PendingSyncDiagnosticTests: XCTestCase { + private let threshold = 300 + + func testBehindTheStrapNamesTheGapAndTheThreshold() { + let s = PendingSyncDiagnostic.line( + pending: true, site: PendingSyncDiagnostic.sitePostOffload, + newestUnix: 1_788_896_010, frontierUnix: 1_788_861_800, + futureDated: false, persistedRows: true, thresholdSec: threshold) + XCTAssertTrue(s.hasPrefix("pending-sync ON (post-offload):"), s) + XCTAssertTrue(s.contains("34210s ahead of our frontier"), s) + XCTAssertTrue(s.contains("over the 300s threshold"), s) + XCTAssertTrue(s.contains("gap=34210s"), s) + } + + func testCaughtUpSaysSoRatherThanJustOff() { + let s = PendingSyncDiagnostic.line( + pending: false, site: PendingSyncDiagnostic.sitePostOffload, + newestUnix: 1_788_896_010, frontierUnix: 1_788_896_000, + futureDated: false, persistedRows: true, thresholdSec: threshold) + XCTAssertTrue(s.hasPrefix("pending-sync OFF (post-offload):"), s) + XCTAssertTrue(s.contains("caught up"), s) + } + + func testAFutureDatedStrapClockOutranksTheGap() { + // #928/#1012: this latches the flag on forever, so it must be named and not read as "behind". + let s = PendingSyncDiagnostic.line( + pending: false, site: PendingSyncDiagnostic.sitePostOffload, + newestUnix: 2_000_000_000, frontierUnix: 1_788_896_000, + futureDated: true, persistedRows: true, thresholdSec: threshold) + XCTAssertTrue(s.contains("clock reads ahead of now"), s) + XCTAssertFalse(s.contains("ahead of our frontier"), s) + } + + func testAPhantomGapIsNamedAsSuchNotAsBeingBehind() { + // #1144: the strap advertises newer records and banks none, so the frontier cannot advance. + // Reading that as "behind" would send the next reader chasing an offload that will never help. + let s = PendingSyncDiagnostic.line( + pending: false, site: PendingSyncDiagnostic.sitePostOffload, + newestUnix: 1_788_896_010, frontierUnix: 1_788_861_800, + futureDated: false, persistedRows: false, thresholdSec: threshold) + XCTAssertTrue(s.contains("phantom gap"), s) + XCTAssertTrue(s.contains("rowsBanked=no"), s) + } + + func testAMissingRangeStillProducesALineRatherThanASilentFlip() { + // The Android twin flips the flag to false when either input is missing, and an unanswered + // GET_DATA_RANGE is a real way to get there. Guarding the log on non-nil inputs would have left + // exactly that transition silent, which is the hole this closes. + let s = PendingSyncDiagnostic.line( + pending: false, site: PendingSyncDiagnostic.sitePostOffload, + newestUnix: nil, frontierUnix: 1_788_861_800, + futureDated: false, persistedRows: true, thresholdSec: threshold) + XCTAssertTrue(s.hasPrefix("pending-sync OFF (post-offload):"), s) + XCTAssertTrue(s.contains("no range to compare"), s) + XCTAssertTrue(s.contains("newest=unknown"), s) + } + + func testTheConnectSiteSaysRowEvidenceIsUnavailableRatherThanAbsent() { + // No offload has run there, so "no" would be a lie that reads as a phantom gap. + let s = PendingSyncDiagnostic.line( + pending: true, site: PendingSyncDiagnostic.siteConnect, + newestUnix: 1_788_896_010, frontierUnix: 1_788_861_800, + futureDated: false, persistedRows: nil, thresholdSec: threshold) + XCTAssertTrue(s.contains("(connect)"), s) + XCTAssertTrue(s.contains("rowsBanked=n/a at connect"), s) + XCTAssertFalse(s.contains("phantom gap"), s) + } +} diff --git a/StrandTests/PiiRedactionTests.swift b/StrandTests/PiiRedactionTests.swift new file mode 100644 index 0000000000..c76605c075 --- /dev/null +++ b/StrandTests/PiiRedactionTests.swift @@ -0,0 +1,203 @@ +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") + // #2337: the model token FIRST, which every other case here has last. Second-hand straps arrive + // named after the previous owner in whatever word order that language uses, and a real report + // arrived as exactly this shape. Synthetic given name on purpose: this file is public. + XCTAssertEqual(LiveState.logSafeDeviceName("Whoop von Beispiel"), " 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"), "") + } + + /// #2337: the rename path must hand the USER-CHOSEN name to `logSafeDeviceName` before it reaches the + /// log, and must never interpolate the raw value. + /// + /// This is the one string in that path that can carry a person's name. The redactor cannot save it + /// after the fact: it masks MACs, WHOOP serials and hex dumps, and a name is none of those, as the + /// bare-serial case above already records. The discovery path routes the very same value through the + /// helper, so before this the identical data was handled two different ways depending on which line + /// printed it. + /// + /// Asserted against the SOURCE because `renameStrap` needs a bonded link and has no unit seam. + /// Twin of the Kotlin `the rename log redacts the chosen name`. + func testTheRenameLogRedactsTheChosenName() throws { + // The WHOLE function body, not just the one line that carries the name today. Pinning a single + // line by its text would let a NEW log added beside it leak freely while this still passed, and + // the leak does not care which line it rides on. Twin of the Kotlin case. + let body = try Self.renameStrapBody() + XCTAssertTrue(body.contains("Strap rename: wrote advertising name="), + "the rename log line was not found in renameStrap") + XCTAssertTrue(body.contains("logSafeDeviceName("), + "the rename log must pass the name through logSafeDeviceName:\n\(body)") + // Strip the helper calls, then NO log line in the body may still interpolate a raw name. An + // earlier version keyed on one line's `name=` slot, and a mutation logging BOTH forms walked + // straight through it on the Kotlin side. + let offenders = body + .split(separator: "\n", omittingEmptySubsequences: false) + .filter { $0.contains("log(") } + .map { + String($0).replacingOccurrences( + of: #"logSafeDeviceName\([^)]*\)"#, with: "", options: .regularExpression) + } + .filter { line in + line.contains("\\(name") || line.contains("\\(clamped") || line.contains("\\(rawName") + || line.contains("name.debugDescription") + } + XCTAssertTrue(offenders.isEmpty, "no log in renameStrap may carry the raw name: \(offenders)") + } + + /// The source of `renameStrap`, signature to its closing brace. Fails loudly if either end moves. + private static func renameStrapBody() throws -> String { + let src = try bleManagerSource() + guard let start = src.range(of: "public func renameStrap(_ rawName: String)") else { + throw SourceNotReachable(path: "renameStrap not found in BLEManager.swift") + } + let rest = src[start.lowerBound...] + guard let end = rest.range(of: "\n }") else { + throw SourceNotReachable(path: "renameStrap's closing brace not found") + } + return String(rest[.. String { + var dir = URL(fileURLWithPath: "\(file)").deletingLastPathComponent() + for _ in 0..<4 { + let candidate = dir.appendingPathComponent("Strand/BLE/BLEManager.swift") + if FileManager.default.fileExists(atPath: candidate.path) { + return try String(contentsOf: candidate, encoding: .utf8) + } + dir = dir.deletingLastPathComponent() + } + throw SourceNotReachable(path: "\(file)") + } + +} diff --git a/StrandTests/RRPacketCursorTests.swift b/StrandTests/RRPacketCursorTests.swift new file mode 100644 index 0000000000..cf376d8b28 --- /dev/null +++ b/StrandTests/RRPacketCursorTests.swift @@ -0,0 +1,51 @@ +import XCTest +import Combine +@testable import Strand + +/// A handler reached from `LiveState`'s heart-rate AND R-R sinks — as `AppModel.ingestHR`, and through it the stress +/// check-in, is — takes each R-R packet once when it asks `RRPacketCursor`. +@MainActor +final class RRPacketCursorTests: XCTestCase { + + func testASequenceIsNewOnlyTheFirstTime() { + var cursor = RRPacketCursor() + XCTAssertFalse(cursor.isNew(0)) // nothing has arrived yet + XCTAssertTrue(cursor.isNew(1)) + XCTAssertFalse(cursor.isNew(1)) + XCTAssertTrue(cursor.isNew(2)) + } + + /// Writes a real `LiveState` the way BLEManager's standard-HR path does — the packet's intervals first, then the + /// heart rate only when it changed — and collects `live.rr` from both sinks, as AppModel wires them. + private func feedStrap(_ packets: [(bpm: Int, rr: Int)], throughCursor: Bool) -> [Int] { + let live = LiveState() + var cursor = RRPacketCursor() + var taken: [Int] = [] + let handler = { + if throughCursor, !cursor.isNew(live.rrSeq) { return } + taken.append(contentsOf: live.rr) + } + var subscriptions = Set() + live.$heartRate.sink { _ in handler() }.store(in: &subscriptions) + live.$rr.sink { _ in handler() }.store(in: &subscriptions) + for packet in packets { + live.setRRIntervals([packet.rr]) + if live.heartRate != packet.bpm { live.heartRate = packet.bpm } + } + return taken + } + + /// 21 packets, the heart rate moving every second one (and on the last, so it too is handed over). + private let packets = (0...20).map { (bpm: 70 + $0 / 2, rr: 800 + $0) } + + func testEachPacketIsTakenOnceAndInOrder() { + XCTAssertEqual(feedStrap(packets, throughCursor: true), packets.map(\.rr)) + } + + /// What the stress check-in did before: the same packets, read from both sinks with no cursor. + func testWithoutTheCursorPacketsAreTakenMoreThanOnce() { + let taken = feedStrap(packets, throughCursor: false) + XCTAssertGreaterThan(taken.count, packets.count) + XCTAssertEqual(Array(Set(taken)).sorted(), packets.map(\.rr)) + } +} 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/RawHistoryArchiveReplayTests.swift b/StrandTests/RawHistoryArchiveReplayTests.swift index 47d2799a16..4698633a43 100644 --- a/StrandTests/RawHistoryArchiveReplayTests.swift +++ b/StrandTests/RawHistoryArchiveReplayTests.swift @@ -99,15 +99,26 @@ final class RawHistoryArchiveReplayTests: XCTestCase { } } - #if os(iOS) /// #649: locked/background BLE must be able to write rejects before acknowledging the strap trim, so the /// archive directory + file carry after-first-unlock protection (matching the primary SQLite store), /// not iOS's default complete protection which would fail a locked write. /// + /// The chain this guards, as @tigercraft4 set it out in the original report: the reject archive lives + /// outside the `OpenWhoop` App Support tree, so it never inherited the `StorePaths` downgrade from #222; + /// `archiveRejectedFrames` has to succeed BEFORE the backfiller acks a historical-data trim; a write that + /// throws because the phone is locked holds the ack, so the strap re-sends the same chunk indefinitely. + /// There is no user-visible symptom beyond an offload that never seems to finish, which is why the + /// assertion is worth keeping rather than trusting the attribute to stay set. + /// /// The iOS Simulator does not enforce data protection and may not report `.protectionKey` at all; when /// it doesn't, the write-succeeded check above is all we can assert here, so skip rather than fail on a /// platform that can't answer. On a real device (or a sim that does report it) the attribute is checked. + /// + /// The iOS gate is INSIDE the test rather than around it. `StrandTests` runs on the macOS scheme, so an + /// `#if os(iOS)` wrapper deleted this from the macOS report entirely and the missing iOS coverage looked + /// like no coverage at all. Skipping with a reason leaves a visible row instead. (@tigercraft4, #956) func testArchiveAndDirectoryUseBackgroundReadableProtection() throws { + #if os(iOS) let dir = URL(fileURLWithPath: NSTemporaryDirectory()) .appendingPathComponent("noop-protection-\(UUID().uuidString)", isDirectory: true) defer { try? FileManager.default.removeItem(at: dir) } @@ -126,6 +137,8 @@ final class RawHistoryArchiveReplayTests: XCTestCase { "Data protection attributes are not reported on this platform (iOS Simulator).") XCTAssertEqual(directoryProtection, expected) XCTAssertEqual(fileProtection, expected) + #else + throw XCTSkip("File Data Protection is iOS-only; StrandTests runs on the macOS scheme.") + #endif } - #endif } diff --git a/StrandTests/RawPhysiologyUnionTests.swift b/StrandTests/RawPhysiologyUnionTests.swift new file mode 100644 index 0000000000..90a88c8169 --- /dev/null +++ b/StrandTests/RawPhysiologyUnionTests.swift @@ -0,0 +1,229 @@ +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 testHrvTimelineRejectsAmbiguousLegacyAfterWhoop5RePairing() async throws { + let store = try await WhoopStore.inMemory() + let registry = DeviceRegistryStore(dbQueue: store.registryWriter) + try registry.add(PairedDevice(id: "new-five", brand: "WHOOP", model: "WHOOP 5.0", + sourceKind: .liveBLE, capabilities: [.hrv], status: .active, addedAt: 1, lastSeenAt: 1)) + let base = 1_780_000_000 + _ = try await store.insert(Streams(rr: (0..<600).map { + RRInterval(ts: base + $0, rrMs: 900 + ($0 % 2 == 0 ? 8 : -8)) + }), deviceId: "my-whoop") + let repo = Repository(deviceId: "new-five") + repo.setStoreForTesting(store) + let guarded = await repo.timelineSeries(metric: .hrv, from: base, to: base + 600, targetPoints: 600) + XCTAssertTrue(guarded.points.isEmpty) + try registry.setModel("my-whoop", model: "4.0") + let knownFour = await repo.timelineSeries(metric: .hrv, from: base, to: base + 600, targetPoints: 600) + XCTAssertFalse(knownFour.points.isEmpty) + } + + @MainActor + func testWhoop5SwitchKeepsOlderPhysicalOwnersAndCapturedBeatOrder() async throws { + let store = try await WhoopStore.inMemory() + let registry = DeviceRegistryStore(dbQueue: store.registryWriter) + for (id, model) in [("my-whoop", "WHOOP"), ("old-four", "4.0"), ("old-five", "5.0"), ("new-five", "5.0")] { + try registry.add(PairedDevice(id: id, brand: "WHOOP", model: model, + sourceKind: .liveBLE, capabilities: [.hrv], status: .archived, addedAt: 1, lastSeenAt: 1)) + } + _ = try await store.insert(Streams(rr: [RRInterval(ts: 90, rrMs: 1024)]), deviceId: "my-whoop") + _ = try await store.insert(Streams(rr: [RRInterval(ts: 100, rrMs: 810)]), deviceId: "old-four") + _ = try await store.insert(Streams(rr: [900, 700, 900, 800].map { + RRInterval(ts: 200, rrMs: $0, srcChannel: .whoop5Historical) + }), deviceId: "old-five") + _ = try await store.insert(Streams(rr: [RRInterval(ts: 300, rrMs: 850, srcChannel: .whoop5Standard)]), + deviceId: "new-five") + let repo = Repository(deviceId: "new-five") + repo.setStoreForTesting(store) + var rows = await repo.rrIntervals(from: 0, to: 1000) + XCTAssertEqual(rows.map(\.rrMs), [810, 900, 700, 900, 800, 850]) + // A confirmed WHOOP 4 canonical history keeps its original millisecond policy. + try registry.setModel("my-whoop", model: "4.0") + rows = await repo.rrIntervals(from: 0, to: 1000) + XCTAssertEqual(rows.first?.rrMs, 1024) + } + + @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]) + } + + /// The batched read resolves EVERY session, not just the first. + /// + /// The per-session shape this replaced asked the store once per session per candidate device; it now + /// takes one windowed read per device and resolves each block against that. The failure a batched + /// version can have is a partial map: a span or page bound that covers the first night and quietly + /// drops the rest, which looks exactly like "those nights have no motion" rather than like a bug. + /// Three nights spread across the window, each with its own series, so a truncation is visible. + @MainActor + func testSessionMotionsResolvesEveryNightInOneBatch() async throws { + let store = try await WhoopStore.inMemory() + let starts = [1_000, 200_000, 900_000] + let sessions = starts.map { + CachedSleepSession(startTs: $0, endTs: $0 + 4_000, efficiency: 0.9, + restingHr: 52, avgHrv: 60, stagesJSON: nil) + } + _ = try await store.upsertSleepSessions(sessions, deviceId: "my-whoop") + _ = try await store.upsertSleepSessions(sessions, deviceId: "my-whoop-noop") + for (i, start) in starts.enumerated() { + _ = try await store.persistSessionMotion(deviceId: "my-whoop-noop", sessionStart: start, + motionEpochs: [Double(i) + 0.5]) + } + let repo = Repository(deviceId: "my-whoop") + repo.setStoreForTesting(store) + + let motion = await repo.sessionMotions(sessions: sessions) + XCTAssertEqual(motion.count, starts.count, "a night dropped by the batch reads as having no motion") + for (i, start) in starts.enumerated() { + XCTAssertEqual(motion[start] ?? [], [Double(i) + 0.5], "night at \(start) got another night's series") + } + } + + /// Provenance and the probe must agree, or the fast path is a behaviour change wearing a perf label. + /// + /// The stored block carries the device it was read from and skips the ownership probe; the same block + /// with that field stripped falls back to the probe. Both must resolve to the same motion. They do + /// because the search normalises whichever id it finds to its `-noop` twin, and the computed ids are + /// exactly the raw ids under that suffix, so both namespaces land on one source. + @MainActor + func testProvenanceAndTheOwnerProbeResolveTheSameMotion() 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.3, 0.4]) + let repo = Repository(deviceId: "my-whoop") + repo.setStoreForTesting(store) + + // As the store hands it back: provenance present, no probe. + let stored = try await store.sleepSessions(deviceId: "my-whoop", from: 0, to: 10_000, limit: 8) + XCTAssertEqual(stored.first?.deviceId, "my-whoop") + let viaProvenance = await repo.sessionMotions(sessions: stored) + + // The same block with provenance stripped: the probe resolves it instead. + let viaProbe = await repo.sessionMotions(sessions: [session]) + + XCTAssertEqual(viaProvenance[1_000] ?? [], [0.3, 0.4]) + XCTAssertEqual(viaProbe[1_000] ?? [], viaProvenance[1_000] ?? [], + "the shortcut and the probe disagreed about the same night") + } + + @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.. RescoreBackgroundPolicy.Decision { + running: Bool = false, + attemptedSecondsAgo: Double? = 60) -> RescoreBackgroundPolicy.Decision { RescoreBackgroundPolicy.decide(isBackground: background, + isRealUpdate: realUpdate, rescoreAlreadyOwed: unfinished, - lastCompletedPassSeconds: lastSeconds, - budgetSeconds: budget) + passInProgress: running, + secondsSinceLastAttempt: attemptedSecondsAgo) } private func isDeferred(_ d: RescoreBackgroundPolicy.Decision) -> Bool { @@ -32,83 +35,93 @@ final class RescoreBackgroundPolicyTests: XCTestCase { func testAForegroundPassAlwaysRuns() { XCTAssertEqual(decide(background: false), .run) XCTAssertEqual(decide(background: false, unfinished: true), .run) - XCTAssertEqual(decide(background: false, lastSeconds: 9_999), .run) + XCTAssertEqual(decide(background: false, realUpdate: false), .run) + } + + // MARK: - A real update runs, paced + + /// An offload in the background runs now. It paces itself under the CPU limit and resumes across + /// wakes, so how long it takes is no longer a reason to hand it to a processing task that iOS may not + /// grant until the afternoon — which is when last night's scores used to appear. + func testABackgroundOffloadRuns() { + XCTAssertEqual(decide(), .run) } // MARK: - The livelock - /// The core fix. An earlier pass marked itself started and never finished, which survives process - /// death — so the phone has already proved once that it cannot complete this work in the background. - /// Attempting it again is what burned nearly eight minutes per offload in #1538 while producing - /// nothing. + /// An earlier pass marked itself started and never finished, and nothing is running now: that pass + /// was killed. Attempting it again on every offload is what burned the phone in #1538. func testAnInterruptedPriorAttemptDefersInsteadOfRetrying() { XCTAssertTrue(isDeferred(decide(unfinished: true))) } - /// ...and it defers even when the last COMPLETED pass looks fast, because "unfinished" is evidence - /// about this install right now, whereas the measurement may predate the history that made it slow. - func testAnInterruptedAttemptOutranksAFastMeasurement() { - XCTAssertTrue(isDeferred(decide(unfinished: true, lastSeconds: 2))) + /// ...but only for a while. A suspended app is routinely terminated for memory, so an unfinished pass + /// is ordinary; deferring on it forever left a night unscored for 19 hours on one phone. + func testAnInterruptedAttemptIsRetriedOnceTheCooldownHasPassed() { + let cooldown = RescoreBackgroundPolicy.interruptedRetryCooldownSeconds + XCTAssertTrue(isDeferred(decide(unfinished: true, attemptedSecondsAgo: cooldown - 1))) + XCTAssertEqual(decide(unfinished: true, attemptedSecondsAgo: cooldown), .run) + // No recorded attempt (an install from before the attempt time existed) is not a recent one. + XCTAssertEqual(decide(unfinished: true, attemptedSecondsAgo: nil), .run) } - // MARK: - The measurement - - /// A pass measured well inside the budget is exactly what SHOULD run in the background — that is the - /// case this whole mechanism must not break. - func testAPassThatFitsTheBudgetRuns() { - XCTAssertEqual(decide(lastSeconds: 5), .run) + /// A pass running in THIS process reads as owed through its own started-mark. That is not a killed + /// pass, and deferring on it recorded a newer debt the running pass could then never settle (#1681), + /// so every later offload deferred too. The engine re-arms a follow-up pass for a mid-run trigger. + func testARunningPassIsNotMistakenForAKilledOne() { + XCTAssertEqual(decide(unfinished: true, running: true), .run) } - /// The reporter's install: 474.778 s against a 20 s budget. - func testTheReportedPassDefers() { - XCTAssertTrue(isDeferred(decide(lastSeconds: 474.778))) - } + // MARK: - The backstop - /// The boundary is stated rather than inherited: equal to the budget still runs, over it defers. - func testTheBudgetBoundary() { - XCTAssertEqual(decide(lastSeconds: 20, budget: 20), .run) - XCTAssertTrue(isDeferred(decide(lastSeconds: 20.001, budget: 20))) + /// The steady-state tick cannot tell live HR from a real change, and a paced pass costs minutes, so a + /// backgrounded tick does not run. Real updates run their own. + func testABackgroundedBackstopDoesNotRun() { + guard case .deferToBackgroundTask(let reason) = decide(realUpdate: false) else { + return XCTFail("expected the backstop to be skipped") + } + XCTAssertTrue(reason.contains("backstop"), reason) } - /// The reason is carried into the strap log, so it has to name the numbers that drove the decision. - /// #1538 was three nights of chasing BLE because the log recorded that scoring had not happened - /// without ever recording why. - func testTheDeferralReasonNamesTheMeasurementAndTheBudget() { - guard case .deferToBackgroundTask(let reason) = decide(lastSeconds: 475, budget: 20) else { - return XCTFail("expected a deferral") - } - XCTAssertTrue(reason.contains("475"), reason) - XCTAssertTrue(reason.contains("20"), reason) + // MARK: - Pacing + + /// Resting as long as it worked holds a backgrounded pass near 50% CPU, under the 80% iOS kills at. + func testABackgroundedPassRestsAsLongAsItWorked() { + XCTAssertEqual(RescoreBackgroundPolicy.restSeconds(afterWorkSeconds: 12, isBackground: true), 12) } - // MARK: - Unknown is not "too slow" + /// Short units run back to back until a quantum of work has built up: every rest is a chance for iOS to + /// suspend the process until the next wake, so resting after each night advanced a pass one night a wake. + func testWorkUnderAQuantumDoesNotRest() { + let quantum = RescoreBackgroundPolicy.backgroundWorkQuantumSeconds + XCTAssertEqual(RescoreBackgroundPolicy.restSeconds(afterWorkSeconds: 0.05, isBackground: true), 0) + XCTAssertEqual(RescoreBackgroundPolicy.restSeconds(afterWorkSeconds: quantum - 0.01, isBackground: true), 0) + XCTAssertEqual(RescoreBackgroundPolicy.restSeconds(afterWorkSeconds: quantum, isBackground: true), quantum) + } - /// Nothing has ever completed on this install, so there is no measurement to defer on. Running is the - /// only way to acquire one, and a first attempt costs at most one pass. - func testAnInstallWithNoMeasurementRuns() { - XCTAssertEqual(decide(lastSeconds: nil), .run) + /// No CPU limit applies in the foreground, and the user is waiting on the result. + func testTheForegroundNeverRests() { + XCTAssertEqual(RescoreBackgroundPolicy.restSeconds(afterWorkSeconds: 6, isBackground: false), 0) } - /// A corrupted or absent default must never be read as "slow". Refusing to score on the strength of - /// a value that cannot be interpreted is a far worse failure than one wasted pass. - func testUnreadableMeasurementsRunRatherThanDefer() { - XCTAssertEqual(decide(lastSeconds: 0), .run) - XCTAssertEqual(decide(lastSeconds: -1), .run) - XCTAssertEqual(decide(lastSeconds: .nan), .run) - XCTAssertEqual(decide(lastSeconds: .infinity), .run) + /// Work measured on the uptime clock can include a suspension; resting for all of it would stall a + /// pass that has already been idle. + func testARestIsCapped() { + XCTAssertEqual(RescoreBackgroundPolicy.restSeconds(afterWorkSeconds: 3_600, isBackground: true), + RescoreBackgroundPolicy.maxBackgroundRestSeconds) } - /// A nonsensical budget disables the measurement rule rather than deferring everything — the same - /// principle, applied to the other input. - func testANonPositiveBudgetDoesNotDeferEverything() { - XCTAssertEqual(decide(lastSeconds: 9_999, budget: 0), .run) - XCTAssertEqual(decide(lastSeconds: 9_999, budget: -5), .run) + /// An unreadable measurement rests zero rather than stalling the pass on a value that means nothing. + func testAnUnreadableMeasurementDoesNotRest() { + XCTAssertEqual(RescoreBackgroundPolicy.restSeconds(afterWorkSeconds: 0, isBackground: true), 0) + XCTAssertEqual(RescoreBackgroundPolicy.restSeconds(afterWorkSeconds: -1, isBackground: true), 0) + XCTAssertEqual(RescoreBackgroundPolicy.restSeconds(afterWorkSeconds: .nan, isBackground: true), 0) + XCTAssertEqual(RescoreBackgroundPolicy.restSeconds(afterWorkSeconds: .infinity, isBackground: true), 0) } - /// The shipped budget is the one the app actually uses; pin it so a change is deliberate. - func testTheDefaultBudgetIsTheShippedOne() { - XCTAssertEqual(RescoreBackgroundPolicy.backgroundBudgetSeconds, 20) - XCTAssertTrue(isDeferred(RescoreBackgroundPolicy.decide( - isBackground: true, rescoreAlreadyOwed: false, lastCompletedPassSeconds: 21))) + /// The shipped constants are the ones the app uses; pin them so a change is deliberate. + func testTheShippedPacingConstants() { + XCTAssertEqual(RescoreBackgroundPolicy.backgroundRestPerWorkSecond, 1.0) + XCTAssertEqual(RescoreBackgroundPolicy.maxBackgroundRestSeconds, 30) } } diff --git a/StrandTests/RescoreBackgroundSchedulerTests.swift b/StrandTests/RescoreBackgroundSchedulerTests.swift index 6f830af4f9..65cfbf8286 100644 --- a/StrandTests/RescoreBackgroundSchedulerTests.swift +++ b/StrandTests/RescoreBackgroundSchedulerTests.swift @@ -13,6 +13,9 @@ final class RescoreBackgroundSchedulerTests: XCTestCase { private var savedOwed: Any? private var savedSeconds: Any? + private var savedToken: Any? + private var savedAfterCompleted: Any? + private var savedAttemptAt: Any? override func setUp() { super.setUp() @@ -20,13 +23,23 @@ final class RescoreBackgroundSchedulerTests: XCTestCase { // restore rather than assume this suite owns them. savedOwed = UserDefaults.standard.object(forKey: RescoreBackgroundScheduler.owedKey) savedSeconds = UserDefaults.standard.object(forKey: RescoreBackgroundScheduler.lastPassSecondsKey) + savedToken = UserDefaults.standard.object(forKey: RescoreBackgroundScheduler.owedTokenKey) + savedAfterCompleted = UserDefaults.standard.object( + forKey: RescoreBackgroundScheduler.owedAfterCompletedPassKey) + savedAttemptAt = UserDefaults.standard.object(forKey: RescoreBackgroundScheduler.lastAttemptStartedAtKey) + UserDefaults.standard.removeObject(forKey: RescoreBackgroundScheduler.lastAttemptStartedAtKey) UserDefaults.standard.removeObject(forKey: RescoreBackgroundScheduler.owedKey) UserDefaults.standard.removeObject(forKey: RescoreBackgroundScheduler.lastPassSecondsKey) + UserDefaults.standard.removeObject(forKey: RescoreBackgroundScheduler.owedTokenKey) + UserDefaults.standard.removeObject(forKey: RescoreBackgroundScheduler.owedAfterCompletedPassKey) } override func tearDown() { restore(savedOwed, RescoreBackgroundScheduler.owedKey) restore(savedSeconds, RescoreBackgroundScheduler.lastPassSecondsKey) + restore(savedToken, RescoreBackgroundScheduler.owedTokenKey) + restore(savedAfterCompleted, RescoreBackgroundScheduler.owedAfterCompletedPassKey) + restore(savedAttemptAt, RescoreBackgroundScheduler.lastAttemptStartedAtKey) super.tearDown() } @@ -41,9 +54,9 @@ final class RescoreBackgroundSchedulerTests: XCTestCase { /// discover that an earlier one was killed — the killed process never gets to report anything itself. func testAStartedPassOwesUntilItCompletes() { XCTAssertFalse(RescoreBackgroundScheduler.isRescoreOwed) - RescoreBackgroundScheduler.markRescoreOwed() + let token = RescoreBackgroundScheduler.markRescoreOwed() XCTAssertTrue(RescoreBackgroundScheduler.isRescoreOwed) - RescoreBackgroundScheduler.markRescoreCompleted(seconds: 12) + RescoreBackgroundScheduler.markRescoreCompleted(seconds: 12, owedToken: token) XCTAssertFalse(RescoreBackgroundScheduler.isRescoreOwed) } @@ -51,10 +64,10 @@ final class RescoreBackgroundSchedulerTests: XCTestCase { /// whether a background wake can finish the work, so a garbage value must read as "no measurement" /// rather than as a number. func testOnlyAUsableDurationIsBanked() { - RescoreBackgroundScheduler.markRescoreCompleted(seconds: 474.778) + RescoreBackgroundScheduler.markRescoreCompleted(seconds: 474.778, owedToken: nil) // fixture: bank a duration, settle nothing XCTAssertEqual(RescoreBackgroundScheduler.lastCompletedPassSeconds ?? 0, 474.778, accuracy: 0.001) - RescoreBackgroundScheduler.markRescoreCompleted(seconds: .nan) + RescoreBackgroundScheduler.markRescoreCompleted(seconds: .nan, owedToken: nil) // fixture: bank a duration, settle nothing XCTAssertEqual(RescoreBackgroundScheduler.lastCompletedPassSeconds ?? 0, 474.778, accuracy: 0.001, "a NaN must not overwrite a good measurement") @@ -67,9 +80,8 @@ final class RescoreBackgroundSchedulerTests: XCTestCase { /// The bug this file exists for: deferring has to record the debt, or the background task it defers /// to has nothing to find. func testDeferringMarksTheWorkOwedAndDoesNotRunIt() async { - // A measured pass far over the background budget, so the policy defers. - RescoreBackgroundScheduler.markRescoreCompleted(seconds: 474.778) - XCTAssertFalse(RescoreBackgroundScheduler.isRescoreOwed) + // An earlier pass was killed moments ago: its mark is set and nothing runs now, so the policy defers. + let killedToken = RescoreBackgroundScheduler.markRescoreOwed(passStarting: true) var ran = false var logged: [String] = [] @@ -80,15 +92,17 @@ final class RescoreBackgroundSchedulerTests: XCTestCase { XCTAssertFalse(ran, "the pass must not be started in a context that cannot finish it") XCTAssertTrue(RescoreBackgroundScheduler.isRescoreOwed, "the deferred work must be recorded, or the background task does nothing") + XCTAssertNotEqual(RescoreBackgroundScheduler.currentOwedToken, killedToken, + "the deferral records its own debt for the processing task to settle") XCTAssertEqual(logged.count, 1) XCTAssertTrue(logged[0].contains("deferred"), logged[0]) - XCTAssertTrue(logged[0].contains("475"), logged[0]) + XCTAssertTrue(logged[0].contains("outstanding"), logged[0]) } /// A foregrounded pass runs, whatever the measurement says. This is the case the mechanism must not /// break: there is no suspension deadline, so deferring would be a pure regression. func testAForegroundPassRunsEvenWhenSlow() async { - RescoreBackgroundScheduler.markRescoreCompleted(seconds: 474.778) + RescoreBackgroundScheduler.markRescoreCompleted(seconds: 474.778, owedToken: nil) // fixture: bank a duration, settle nothing var ran = false var logged: [String] = [] @@ -100,9 +114,9 @@ final class RescoreBackgroundSchedulerTests: XCTestCase { XCTAssertTrue(logged.isEmpty, "a pass that simply runs should not narrate itself") } - /// A background pass with no measurement yet is allowed to run — that is how the measurement is - /// acquired, and a first attempt costs at most one pass. + /// A background offload with nothing outstanding runs now, paced, however long the last pass took. func testAnUnmeasuredBackgroundPassRuns() async { + RescoreBackgroundScheduler.markRescoreCompleted(seconds: 474.778, owedToken: nil) // fixture: bank a duration, settle nothing var ran = false await RescoreBackgroundScheduler.run(isBackground: true, log: { _ in }) { ran = true } XCTAssertTrue(ran) @@ -112,20 +126,31 @@ final class RescoreBackgroundSchedulerTests: XCTestCase { /// is the livelock fix: #1538 paid for a full eight-minute pass on every offload because nothing /// remembered that the previous one had not finished. func testASecondBackgroundTriggerDoesNotStartADuplicatePass() async { - RescoreBackgroundScheduler.markRescoreOwed() + RescoreBackgroundScheduler.markRescoreOwed(passStarting: true) var ran = false await RescoreBackgroundScheduler.run(isBackground: true, log: { _ in }) { ran = true } XCTAssertFalse(ran) } + /// ...unless the owed mark is the running pass's own. The trigger then reaches the engine, which + /// re-arms one follow-up pass, and records no newer debt for the running pass to fail to settle. + func testATriggerDuringARunningPassReachesTheEngine() async { + let runningToken = RescoreBackgroundScheduler.markRescoreOwed() + + var ran = false + await RescoreBackgroundScheduler.run(isBackground: true, passInProgress: true, log: { _ in }) { ran = true } + XCTAssertTrue(ran) + XCTAssertEqual(RescoreBackgroundScheduler.currentOwedToken, runningToken) + } + // MARK: - The backstop tick owes nothing /// The steady-state tick is a backstop: every real update forces its own pass, so a tick that cannot /// run here is simply skipped. Recording a debt for it would send a processing task off to run a /// forced full pass when most likely nothing changed — the churn #1146 exists to avoid. func testASkippedBackstopDoesNotConjureADebt() async { - RescoreBackgroundScheduler.markRescoreCompleted(seconds: 474.778) + RescoreBackgroundScheduler.markRescoreCompleted(seconds: 474.778, owedToken: nil) // fixture: bank a duration, settle nothing var ran = false var logged: [String] = [] @@ -151,18 +176,176 @@ final class RescoreBackgroundSchedulerTests: XCTestCase { XCTAssertTrue(RescoreBackgroundScheduler.isRescoreOwed) } - /// A backstop still RUNS in the foreground, and in a background that can afford it — the flag changes - /// only what a deferral records, never whether the pass happens. - func testABackstopStillRunsWhenItCan() async { + /// A backstop still RUNS in the foreground. In the background it does not, however fast the last pass + /// was: a paced pass costs minutes, and every real update runs its own. + func testABackstopRunsOnlyInTheForeground() async { var foreground = false await RescoreBackgroundScheduler.run(isBackground: false, owesOnDefer: false, log: { _ in }) { foreground = true } XCTAssertTrue(foreground) - var affordable = false - RescoreBackgroundScheduler.markRescoreCompleted(seconds: 3) + var background = false + RescoreBackgroundScheduler.markRescoreCompleted(seconds: 3, owedToken: nil) // fixture: bank a duration, settle nothing await RescoreBackgroundScheduler.run(isBackground: true, owesOnDefer: false, - log: { _ in }) { affordable = true } - XCTAssertTrue(affordable) + log: { _ in }) { background = true } + XCTAssertFalse(background) + } + + // MARK: - #1681: whose debt is it? + + /// The reported bug. A pass records its debt, and while it is running a LATER trigger records another + /// — for data that arrived after this pass had already read its inputs. Completion used to clear one + /// global boolean unconditionally, erasing that newer debt before the correction it was recorded for + /// ever ran. The night stayed scored from a partial sync until something unrelated happened to + /// re-score it. + + // MARK: - #2238: a debt from a COMPLETED pass is not the same as one from an interrupted pass + + /// The distinction the Oura re-score storm turns on. A pass that finishes and is outvoted by a token + /// recorded mid-pass HAS advanced the watermark, so its resume may ask the fingerprint whether anything + /// changed. A pass that dies has not, so its resume must force. + func testACompletedButUnsettledPassMarksItsDebtAsProven() { + let mine = RescoreBackgroundScheduler.markRescoreOwed() + XCTAssertFalse(RescoreBackgroundScheduler.isOwedAfterCompletedPass, + "a debt is unproven until the pass owning it finishes") + + _ = RescoreBackgroundScheduler.markRescoreOwed() // a newer trigger lands mid-pass + let settled = RescoreBackgroundScheduler.markRescoreCompleted(seconds: 1, owedToken: mine) + + XCTAssertFalse(settled, "an outvoted pass must not settle the debt (#1681)") + XCTAssertTrue(RescoreBackgroundScheduler.isRescoreOwed) + XCTAssertTrue(RescoreBackgroundScheduler.isOwedAfterCompletedPass, + "#2238: this pass completed and advanced the watermark, so the resume may gate") + } + + /// A pass that never reaches completion leaves the flag where `markRescoreOwed` put it: false. The + /// resume then forces, which is the behaviour #1538 relies on. + func testAnInterruptedPassLeavesItsDebtUnproven() { + _ = RescoreBackgroundScheduler.markRescoreOwed() + // no markRescoreCompleted: the process died here + + XCTAssertTrue(RescoreBackgroundScheduler.isRescoreOwed) + XCTAssertFalse(RescoreBackgroundScheduler.isOwedAfterCompletedPass, + "#2238: an interrupted pass never advanced the watermark, so its resume must force") + } + + /// The flag describes the CURRENT debt. A resume that starts its own pass and is then killed must not + /// inherit the previous debt's proof, or the chain would gate on a watermark that was never advanced. + func testAFreshDebtClearsThePreviousProof() { + let first = RescoreBackgroundScheduler.markRescoreOwed() + _ = RescoreBackgroundScheduler.markRescoreOwed() + _ = RescoreBackgroundScheduler.markRescoreCompleted(seconds: 1, owedToken: first) + XCTAssertTrue(RescoreBackgroundScheduler.isOwedAfterCompletedPass) + + _ = RescoreBackgroundScheduler.markRescoreOwed() // the resume's own pass starts, then dies + + XCTAssertFalse(RescoreBackgroundScheduler.isOwedAfterCompletedPass, + "#2238: proof belongs to the debt that earned it, not to whatever is owed next") + } + + /// A settled pass clears the debt outright, so the flag is moot; pinned so a later change cannot leave + /// a stale proof behind a cleared debt. + func testASettledPassLeavesNoOutstandingProof() { + let mine = RescoreBackgroundScheduler.markRescoreOwed() + let settled = RescoreBackgroundScheduler.markRescoreCompleted(seconds: 1, owedToken: mine) + + XCTAssertTrue(settled) + XCTAssertFalse(RescoreBackgroundScheduler.isRescoreOwed) + XCTAssertFalse(RescoreBackgroundScheduler.isOwedAfterCompletedPass) + } + + func testADebtRecordedMidPassSurvivesThatPassCompleting() { + let mine = RescoreBackgroundScheduler.markRescoreOwed() + _ = RescoreBackgroundScheduler.markRescoreOwed() // a later trigger, while the pass is still running + + RescoreBackgroundScheduler.markRescoreCompleted(seconds: 9, owedToken: mine) + + XCTAssertTrue(RescoreBackgroundScheduler.isRescoreOwed, + "the newer debt must outlive the pass that did not pay it") + } + + /// …and the pass that DOES hold the current token still settles, or the flag would never clear and + /// every launch would re-score forever. + func testTheHolderOfTheCurrentTokenSettles() { + _ = RescoreBackgroundScheduler.markRescoreOwed() + let latest = RescoreBackgroundScheduler.markRescoreOwed() + RescoreBackgroundScheduler.markRescoreCompleted(seconds: 9, owedToken: latest) + XCTAssertFalse(RescoreBackgroundScheduler.isRescoreOwed) + } + + /// The duration is telemetry about THIS pass and is banked either way — it is true regardless of + /// whose debt is now outstanding, and the policy needs it to size a background wake. + func testAPassThatCannotSettleStillBanksItsDuration() { + let mine = RescoreBackgroundScheduler.markRescoreOwed() + _ = RescoreBackgroundScheduler.markRescoreOwed() + RescoreBackgroundScheduler.markRescoreCompleted(seconds: 33.5, owedToken: mine) + XCTAssertEqual(RescoreBackgroundScheduler.lastCompletedPassSeconds ?? 0, 33.5, accuracy: 0.001) + } + + // MARK: - the settlement rule itself + + /// A pass with NO token never settles. That costs one extra pass; the other direction costs a night's + /// scores until something unrelated re-scores it, which is the failure being fixed. + func testNoTokenNeverSettles() { + XCTAssertFalse(RescoreBackgroundScheduler.maySettleDebt(capturedToken: nil, currentToken: "a")) + XCTAssertFalse(RescoreBackgroundScheduler.maySettleDebt(capturedToken: "", currentToken: "")) + } + + func testOnlyTheCurrentTokenSettles() { + XCTAssertTrue(RescoreBackgroundScheduler.maySettleDebt(capturedToken: "a", currentToken: "a")) + XCTAssertFalse(RescoreBackgroundScheduler.maySettleDebt(capturedToken: "a", currentToken: "b")) + XCTAssertFalse(RescoreBackgroundScheduler.maySettleDebt(capturedToken: "a", currentToken: nil)) + } + + /// Each mark stamps a DISTINCT token. A counter would need read-modify-write, and two triggers marking + /// at the same moment could both read N and both write N+1 — losing exactly the debt this protects. + func testEveryMarkStampsADistinctToken() { + let seen = Set((0..<50).map { _ in RescoreBackgroundScheduler.markRescoreOwed() }) + XCTAssertEqual(seen.count, 50) + } + + // MARK: - the outcome has to be reportable + + /// Declining is the interesting outcome, and the caller can only SAY so if it is told. #1538 cost + /// three nights because the log recorded that scoring had not happened without recording why; a pass + /// that completes while leaving the mark set looks identical in a capture to one that cleared it. + func testCompletionReportsWhetherItSettled() { + let mine = RescoreBackgroundScheduler.markRescoreOwed() + _ = RescoreBackgroundScheduler.markRescoreOwed() + XCTAssertFalse(RescoreBackgroundScheduler.markRescoreCompleted(seconds: 1, owedToken: mine), + "a pass that could not settle must report that, or the log cannot explain itself") + + let latest = RescoreBackgroundScheduler.markRescoreOwed() + XCTAssertTrue(RescoreBackgroundScheduler.markRescoreCompleted(seconds: 1, owedToken: latest)) + } + + func testThePassCostLineSeparatesASuspendedPassFromABusyOne() { + // The field shape: 2 h 27 min of uptime for a pass that is ~2 min of CPU when run in the foreground. + XCTAssertEqual(RescoreBackgroundScheduler.passCostLogLine(cpuSeconds: 150, elapsedSeconds: 8_813.2, + assertionExpiries: 1, backgroundedAtEnd: true), + "re-score: cost cpu=150.0s elapsed=8813.2s cpuShare=2% assertionExpired=1 backgrounded=true") + XCTAssertEqual(RescoreBackgroundScheduler.passCostLogLine(cpuSeconds: nil, elapsedSeconds: 3, + assertionExpiries: 0, backgroundedAtEnd: false), + "re-score: cost cpu=n/a elapsed=3.0s cpuShare=n/a assertionExpired=0 backgrounded=false") + } + + func testProcessCPUTimeAdvances() { + let start = RescoreBackgroundScheduler.processCPUSeconds() ?? 0 + var x = 0.0 + for i in 0..<2_000_000 { x += sin(Double(i)) } + XCTAssertNotEqual(x, 0) + XCTAssertGreaterThan(RescoreBackgroundScheduler.processCPUSeconds() ?? 0, start) + } + + /// Repeated deferrals re-mark the debt but must not refresh the attempt time, or an old unfinished pass + /// would look recent forever and every offload would keep deferring. + func testADeferralDoesNotMakeAnOldAttemptLookRecent() async { + RescoreBackgroundScheduler.markRescoreOwed(passStarting: true) + let old = Date().timeIntervalSince1970 - RescoreBackgroundPolicy.interruptedRetryCooldownSeconds - 60 + UserDefaults.standard.set(old, forKey: RescoreBackgroundScheduler.lastAttemptStartedAtKey) + RescoreBackgroundScheduler.markRescoreOwed() + var ran = false + await RescoreBackgroundScheduler.run(isBackground: true, log: { _ in }) { ran = true } + XCTAssertTrue(ran, "an attempt older than the cooldown is retried in the background") } } diff --git a/StrandTests/Resources/raw_data_collector_parity.json b/StrandTests/Resources/raw_data_collector_parity.json new file mode 100644 index 0000000000..a843cb951b --- /dev/null +++ b/StrandTests/Resources/raw_data_collector_parity.json @@ -0,0 +1,69 @@ +{ + "schema_version": 1, + "capabilities": { + "start_stop": { + "swift": ["startGroundTruthRawCapture", "stopGroundTruthRawCapture"], + "kotlin": ["startGroundTruthImuCapture", "stopGroundTruthImuCapture"] + }, + "persisted_sessions": { + "swift": ["RawDataSessionStore", "persist(session)"], + "kotlin": ["GroundTruthCollector", "sessionStartKey"] + }, + "session_comments": { + "swift": ["setComment(", "Session comment"], + "kotlin": ["setComment(", "ground_truth_comment"] + }, + "session_markers": { + "swift": ["addMarker(sessionId:", "updateMarker(sessionId:", "deleteMarker(sessionId:", "Add marker"], + "kotlin": ["fun addMarker(", "fun updateMarker(", "fun deleteMarker(", "ground_truth_add_marker"] + }, + "from_to_time": { + "swift": ["func range(", "endedAtMs"], + "kotlin": ["sessionTimeRange(", "endedAtMs"] + }, + "session_export": { + "swift": ["exportEntries(for:", "Export session"], + "kotlin": ["suspend fun export(", "ground_truth_export"] + }, + "historical_time_windows": { + "swift": ["createHistorical(deviceId:", "Historical export window"], + "kotlin": ["createHistoricalSession(", "ground_truth_add_historical"] + }, + "editable_analysis_range": { + "swift": ["func setRange(sessionId:", "DatePicker(\"From\""], + "kotlin": ["fun setSessionRange(", "ground_truth_edit_start"] + }, + "file_only_imu_storage": { + "swift": ["v41-drop-raw-imu-sample"], + "kotlin": ["MIGRATION_34_35", "DROP TABLE IF EXISTS `rawImuSample`"] + }, + "session_bound_imu_file": { + "swift": ["ImuSessionFileStore", "segmentSeconds: Int64 = 30 * 60", "NOOPIMU2"], + "kotlin": ["ImuSessionFileStore", "SEGMENT_SECONDS = 30 * 60L", "NOOPIMU2"] + }, + "decoded_segment_export": { + "swift": ["exportSegments", "imu/\\(segment.name)"], + "kotlin": ["exportSegments", "imu/${segment.name}"] + }, + "explicit_imu_coverage": { + "swift": ["imu-coverage.json", "private static func covers"], + "kotlin": ["imu_100hz_complete", "private fun covers"] + }, + "delete_one_and_all_with_confirmation": { + "swift": ["Delete this session?", "Delete all sessions?", "removeMetadata(session.id)"], + "kotlin": ["ground_truth_delete_session_title", "ground_truth_delete_all_title", "deleteAllSessions"] + }, + "active_session_delete_guard": { + "swift": ["guard session.endedAtMs != nil else { return }", ".disabled(store.active != nil)"], + "kotlin": ["current.active && current.sessionId == sessionId", "sessions.none { it.active }"] + }, + "bluetooth_reconnect_rearm": { + "swift": ["onChangeCompat(of: live.bonded)", "rawCaptureInFlight = false"], + "kotlin": ["LaunchedEffect(state.active, state.sessionId, live.connected)", "startGroundTruthImuCapture"] + }, + "session_files_removed_with_session": { + "swift": ["deleteFiles(sessionId", "removeItem(metadata)"], + "kotlin": ["deleteFiles(sessionId)", "eventFile(sessionId).delete()"] + } + } +} diff --git a/StrandTests/Resources/whoop5_rr_paired_capture.json b/StrandTests/Resources/whoop5_rr_paired_capture.json new file mode 100644 index 0000000000..d1037a0030 --- /dev/null +++ b/StrandTests/Resources/whoop5_rr_paired_capture.json @@ -0,0 +1,31 @@ +{ + "schema_version": 1, + "device_family": "whoop5", + "firmware": "50.41.1.0", + "provenance": "Paired notifications from one observed WHOOP 5 link. Firmware came from that link metadata.", + "redaction": "Re-encoded frames: only RR count and RR word bytes retain captured values. Identity and original times are omitted; timestamps, HR and other payload fields are synthetic; checksums are recomputed. This is paired RR evidence, not an unmodified capture.", + "cases": [ + { + "name": "type40-paired-multiple-rr", + "kind": "type40", + "native_hex": "aa011800010022e1280000f1536500003c029a03070300000000000037a122dc", + "standard_hex": "103c9a030703", + "rr_count": 2, + "pairing": { + "same_physical_owner": true, + "standard_minus_native_arrival_ms": 0 + } + }, + { + "name": "v18-paired-multiple-rr", + "kind": "v18", + "native_hex": "aa01740001003fb12f12000000000000f153650000003c02b903b5030000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000686695b9", + "standard_hex": "103cb903b503", + "rr_count": 2, + "pairing": { + "same_physical_owner": true, + "standard_host_second_minus_native_second": 2 + } + } + ] +} diff --git a/StrandTests/RespRateLogLineTests.swift b/StrandTests/RespRateLogLineTests.swift new file mode 100644 index 0000000000..9610478cee --- /dev/null +++ b/StrandTests/RespRateLogLineTests.swift @@ -0,0 +1,76 @@ +import XCTest +@testable import Strand + +/// Pins the resp diagnostic's nil-reason (#1331). Kotlin twin: `RespRateLogLineTest`. +/// +/// The line exists because "rpm=nil" alone cost a cross-subsystem investigation to explain: on a WHOOP +/// 4.0 the RSA beat-accuracy gate empties the card on nearly every night, and nothing said so. +final class RespRateLogLineTests: XCTestCase { + + func testARealRateIsUnchangedAndCarriesNoReason() { + XCTAssertEqual( + IntelligenceEngine.respRateLogLine(day: "2026-08-11", respRateBpm: 16.0, + beatAccurate: 0.52, rrIntegrity: "crossSecondOverCount"), + "resp day=2026-08-11 rpm=16.0" + ) + } + + func testNilBelowTheGateNamesTheGateThatRefusedIt() { + XCTAssertEqual( + IntelligenceEngine.respRateLogLine(day: "2026-08-26", respRateBpm: nil, + beatAccurate: 0.45, rrIntegrity: "crossSecondOverCount"), + "resp day=2026-08-26 rpm=nil beatAccurate=0.45<0.50 rrIntegrity=crossSecondOverCount" + + " — RSA gate refused the R-R" + ) + } + + func testNilAboveTheGateSaysTheCauseIsElsewhere() { + // The estimator has four other NaN exits (beats, span, grid, window). Naming the gate here would + // be wrong, so the line says only what it knows. + XCTAssertEqual( + IntelligenceEngine.respRateLogLine(day: "2026-08-26", respRateBpm: nil, + beatAccurate: 0.83, rrIntegrity: "plausible"), + "resp day=2026-08-26 rpm=nil beatAccurate=0.83>=0.50 rrIntegrity=plausible" + + " — gate passed, cause is elsewhere" + ) + } + + func testANightWithNoHRVBlockReadsExactlyAsItAlwaysDid() { + XCTAssertEqual( + IntelligenceEngine.respRateLogLine(day: "2026-08-26", respRateBpm: nil), + "resp day=2026-08-26 rpm=nil" + ) + } + + func testTheBoundaryItselfPasses() { + // 0.50 is >= the gate, so it must NOT read as refused — an off-by-one here would blame the gate + // for a night it actually admitted. + XCTAssertEqual( + IntelligenceEngine.respRateLogLine(day: "2026-08-26", respRateBpm: nil, + beatAccurate: 0.50, rrIntegrity: "plausible"), + "resp day=2026-08-26 rpm=nil beatAccurate=0.50>=0.50 rrIntegrity=plausible" + + " — gate passed, cause is elsewhere" + ) + } + + func testAnUnknownIntegrityIsLabelledRatherThanBlank() { + XCTAssertEqual( + IntelligenceEngine.respRateLogLine(day: "2026-08-26", respRateBpm: nil, beatAccurate: 0.45), + "resp day=2026-08-26 rpm=nil beatAccurate=0.45<0.50 rrIntegrity=unknown" + + " — RSA gate refused the R-R" + ) + } + + func testANaNFractionReadsAsPassedMirroringTheGatesOwnNaNConvention() { + // beatValuesAreTrustworthy is written as !(f < MIN) precisely so NaN lands on TRUE — "not + // measured" must not be silently refused. This line uses the same `<` for the same reason. + // Rewriting it as `f >= MIN` would read identically for every real number and flip NaN to + // "refused", diverging from the gate it reports on. + XCTAssertEqual( + IntelligenceEngine.respRateLogLine(day: "2026-08-26", respRateBpm: nil, + beatAccurate: Double.nan, rrIntegrity: "plausible"), + "resp day=2026-08-26 rpm=nil beatAccurate=NaN>=0.50 rrIntegrity=plausible" + + " — gate passed, cause is elsewhere" + ) + } +} diff --git a/StrandTests/RestFreshnessTests.swift b/StrandTests/RestFreshnessTests.swift index 4b0e9f5dff..61d0beaedc 100644 --- a/StrandTests/RestFreshnessTests.swift +++ b/StrandTests/RestFreshnessTests.swift @@ -57,4 +57,46 @@ final class RestFreshnessTests: XCTestCase { TodayView.freshRestScore(todayValue: nil, lastDay: nil, lastValue: nil, isTodaySelected: true, todayKey: todayKey)) } + + // MARK: - #1164 Rest pending-sync (provisional before full offload) + + /// An active offload (`backfilling`) with today's Rest present → the score is captioned as pending. + /// #2012: "pending" marks the number, it does not withhold it. + func testPendingSync_backfillingWithRestScore_showsPending() { + XCTAssertTrue(TodayView.restPendingSync( + restScore: 72, backfilling: true, historyPendingSync: false, isTodaySelected: true)) + } + + /// The strap has banked records not yet ingested (`historyPendingSync`) even with no active offload → + /// pending. This is the right-after-connect window before the first offload starts. + func testPendingSync_historyPendingWithRestScore_showsPending() { + XCTAssertTrue(TodayView.restPendingSync( + restScore: 72, backfilling: false, historyPendingSync: true, isTodaySelected: true)) + } + + /// No pending signals and a Rest score → NOT pending. The normal finalized state: the score is real. + func testPendingSync_noSignalsWithRestScore_notPending() { + XCTAssertFalse(TodayView.restPendingSync( + restScore: 72, backfilling: false, historyPendingSync: false, isTodaySelected: true)) + } + + /// No Rest score → never pending (pending suppresses a provisional NUMBER; it does not fabricate one + /// when there is none — the calibrating/no-data states already cover that). + func testPendingSync_noRestScore_neverPending_evenWithSignals() { + XCTAssertFalse(TodayView.restPendingSync( + restScore: nil, backfilling: true, historyPendingSync: true, isTodaySelected: true)) + } + + /// A PAST day is never pending — its score is final, no more data is coming for it. Even with both + /// signals true, a navigated past day shows its number. + func testPendingSync_pastDayNeverPending_evenWithSignals() { + XCTAssertFalse(TodayView.restPendingSync( + restScore: 72, backfilling: true, historyPendingSync: true, isTodaySelected: false)) + } + + /// Both signals true → pending (either signal alone is enough; both is the strongest case). + func testPendingSync_bothSignals_showsPending() { + XCTAssertTrue(TodayView.restPendingSync( + restScore: 72, backfilling: true, historyPendingSync: true, isTodaySelected: true)) + } } diff --git a/StrandTests/RollingStepsAverageTests.swift b/StrandTests/RollingStepsAverageTests.swift new file mode 100644 index 0000000000..cb2cb39b38 --- /dev/null +++ b/StrandTests/RollingStepsAverageTests.swift @@ -0,0 +1,67 @@ +import XCTest +@testable import Strand + +final class RollingStepsAverageTests: XCTestCase { + func testWindowIncludesSelectedDayAnd29PriorCalendarDays() { + XCTAssertEqual(RollingStepsAverage.startDay(ending: "2026-03-01"), "2026-01-31") + XCTAssertEqual(RollingStepsAverage.startDay(ending: "2024-03-01"), "2024-02-01") + let result = RollingStepsAverage.calculate(readings: [ + ("2026-01-30", 99999), ("2026-01-31", 1000), + ("2026-03-01", 3000), ("2026-03-02", 99999) + ], ending: "2026-03-01") + XCTAssertEqual(result.mean, 2000) + XCTAssertEqual(result.observedDays, 2) + } + + func testMissingDaysAreExcludedButRecordedZeroCounts() { + let result = RollingStepsAverage.calculate(readings: [ + ("2026-09-01", 0), ("2026-09-14", 10000) + ], ending: "2026-09-14") + XCTAssertEqual(result.mean, 5000) + XCTAssertEqual(result.observedDays, 2) + XCTAssertNil(RollingStepsAverage.calculate(readings: [], ending: "2026-09-14").mean) + } + + func testInvalidReadingsDoNotInflateCoverage() { + let result = RollingStepsAverage.calculate(readings: [ + ("2026-09-01", -1), ("2026-09-02", .nan), ("2026-09-03", .infinity), + ("2026-09-04", 2000), ("2026-09-04", 3000) + ], ending: "2026-09-14") + XCTAssertEqual(result.mean, 3000) + XCTAssertEqual(result.observedDays, 1) + } + + func testAverageRequiresExplicitOptInAndUsesAndroidPreferenceKey() { + let initial = DashboardCardPrefs.decodeEnabled("") + XCTAssertFalse(initial.contains(.stepsAverage30)) + let draft = EditableLayoutDraft(visible: initial, allItems: DashboardCard.canonicalOrder) + XCTAssertTrue(draft.hidden.contains(.stepsAverage30)) + XCTAssertEqual(DashboardCardPrefs.decodeEnabled(DashboardCardPrefs.encode([.stepsAverage30])), [.stepsAverage30]) + XCTAssertNil(KeyMetric(rawValue: "stepsAverage30")) + } + + func testLegacyOptInMovesOnceAndDoesNotReturnAfterHiding() throws { + let suite = "RollingStepsAverageTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + DashboardCardPrefs.migrateLegacyStepsAverage(defaults: defaults) + XCTAssertNil(defaults.string(forKey: DashboardCardPrefs.selectionKey)) + defaults.set("hrv,stepsAverage30", forKey: "today.keyMetrics") + DashboardCardPrefs.migrateLegacyStepsAverage(defaults: defaults) + XCTAssertTrue(DashboardCardPrefs.decodeEnabled(defaults.string(forKey: DashboardCardPrefs.selectionKey) ?? "").contains(.stepsAverage30)) + XCTAssertEqual(defaults.string(forKey: "today.keyMetrics"), "hrv") + defaults.set(DashboardCardPrefs.encode([.hrv]), forKey: DashboardCardPrefs.selectionKey) + DashboardCardPrefs.migrateLegacyStepsAverage(defaults: defaults) + XCTAssertEqual(DashboardCardPrefs.decodeEnabled(defaults.string(forKey: DashboardCardPrefs.selectionKey) ?? ""), [.hrv]) + } + + func testCombinedDestinationDoesNotReplaceSourceSpecificCatalogEntries() throws { + let combined = try XCTUnwrap(MetricCatalog.metric(key: "steps", source: MetricCatalog.combinedStepsSource)) + XCTAssertEqual(combined.source, MetricCatalog.combinedStepsSource) + XCTAssertFalse(MetricCatalog.all.contains(combined)) + for source in ["my-whoop", "apple-health", "xiaomi-band"] { + XCTAssertEqual(MetricCatalog.metric(key: "steps", source: source)?.source, source) + } + XCTAssertEqual(MetricCatalog.metric(key: "steps_est", source: "my-whoop")?.key, "steps_est") + } +} diff --git a/StrandTests/ScreenIdleTests.swift b/StrandTests/ScreenIdleTests.swift new file mode 100644 index 0000000000..ff7258d2f4 --- /dev/null +++ b/StrandTests/ScreenIdleTests.swift @@ -0,0 +1,40 @@ +import XCTest +@testable import Strand + +/// `ScreenIdle` tracks independent keep-awake holders by reason, because the platform idle-timer flag is a +/// single process-wide boolean: a strap sync finishing must not release the hold a breathing session or a +/// workout still needs, and vice versa. The platform write is a no-op on macOS, so this pins the hold set. +@MainActor +final class ScreenIdleTests: XCTestCase { + private func releaseAll() { + ScreenIdle.hold(.session, false) + ScreenIdle.hold(.strapSync, false) + } + + func testSyncEndingKeepsASessionHold() { + releaseAll() + ScreenIdle.keepAwake(true) + ScreenIdle.hold(.strapSync, true) + ScreenIdle.hold(.strapSync, false) + XCTAssertTrue(ScreenIdle.isHeld) + ScreenIdle.keepAwake(false) + XCTAssertFalse(ScreenIdle.isHeld) + } + + func testSessionEndingKeepsASyncHold() { + releaseAll() + ScreenIdle.hold(.strapSync, true) + ScreenIdle.keepAwake(true) + ScreenIdle.keepAwake(false) + XCTAssertTrue(ScreenIdle.isHeld) + ScreenIdle.hold(.strapSync, false) + XCTAssertFalse(ScreenIdle.isHeld) + } + + func testRepeatedReleaseIsHarmless() { + releaseAll() + ScreenIdle.hold(.strapSync, false) + ScreenIdle.keepAwake(false) + XCTAssertFalse(ScreenIdle.isHeld) + } +} diff --git a/StrandTests/ShortcutHealthExportTests.swift b/StrandTests/ShortcutHealthExportTests.swift index c7b8e29194..a4e6a4400d 100644 --- a/StrandTests/ShortcutHealthExportTests.swift +++ b/StrandTests/ShortcutHealthExportTests.swift @@ -10,6 +10,16 @@ import WhoopProtocol /// moved past a failed write would silently drop that span from Apple Health forever. final class ShortcutHealthExportTests: XCTestCase { + /// A synthetic bucket whose extremes equal its mean. + /// + /// The export path reads only `bpm`, so a spread inside the bucket is not what these tests are + /// about. Written once here rather than repeated at every call site, and deliberately NOT a default + /// on `HRBucket` itself: production builds those from `MIN(bpm)`/`MAX(bpm)`, and a type-level default + /// would let a real one silently claim its mean as its range, which is the bug #2032 was. + private func flatBucket(ts: Int, bpm: Double) -> HRBucket { + HRBucket(ts: ts, bpm: bpm, minBpm: bpm, maxBpm: bpm) + } + private let utc = TimeZone(secondsFromGMT: 0)! private var defaults: UserDefaults! private var suiteName: String! @@ -100,14 +110,14 @@ final class ShortcutHealthExportTests: XCTestCase { func testAggregateBucketsHRIntoWindowsAscending() { // hrBuckets(900) keys are already the window starts; bpm means round half-up. - let hr = [HRBucket(ts: 900, bpm: 61.5), HRBucket(ts: 0, bpm: 61.4)] + let hr = [flatBucket(ts: 900, bpm: 61.5), flatBucket(ts: 0, bpm: 61.4)] let windows = ShortcutHealthExport.aggregate(hr: hr, rr: [], steps: [], end: 1800) XCTAssertEqual(windows, [Window(start: 0, hr: 61), Window(start: 900, hr: 62)]) } // Only windows holding ≥1 value are emitted — an empty middle window produces NO line. func testAggregateSkipsEmptyWindows() { - let hr = [HRBucket(ts: 0, bpm: 60), HRBucket(ts: 1800, bpm: 70)] + let hr = [flatBucket(ts: 0, bpm: 60), flatBucket(ts: 1800, bpm: 70)] let windows = ShortcutHealthExport.aggregate(hr: hr, rr: [], steps: [], end: 2700) XCTAssertEqual(windows.map(\.start), [0, 1800]) } @@ -115,7 +125,7 @@ final class ShortcutHealthExportTests: XCTestCase { // Data at/after `end` (the still-open window) is excluded — it would otherwise be frozen // partial and never revisited once the watermark advances. func testAggregateExcludesOpenWindow() { - let hr = [HRBucket(ts: 0, bpm: 60), HRBucket(ts: 900, bpm: 70)] + let hr = [flatBucket(ts: 0, bpm: 60), flatBucket(ts: 900, bpm: 70)] let windows = ShortcutHealthExport.aggregate(hr: hr, rr: [], steps: [], end: 900) XCTAssertEqual(windows, [Window(start: 0, hr: 60)]) } @@ -201,7 +211,7 @@ final class ShortcutHealthExportTests: XCTestCase { // MARK: - Export + watermark (advance only on success) func testExportWritesFileAndAdvancesWatermark() async throws { - let source = FakeReads(hr: [HRBucket(ts: 0, bpm: 62)]) + let source = FakeReads(hr: [flatBucket(ts: 0, bpm: 62)]) let outcome = await ShortcutHealthExport.export( source: source, deviceId: "dev", now: Date(timeIntervalSince1970: 10_000), defaults: defaults, directory: dir, timeZone: utc) @@ -223,7 +233,7 @@ final class ShortcutHealthExportTests: XCTestCase { func testExportWriteFailureLeavesWatermark() async { // Destination directory doesn't exist → the atomic write throws → watermark must not move. let missing = dir.appendingPathComponent("nope") - let source = FakeReads(hr: [HRBucket(ts: 0, bpm: 62)]) + let source = FakeReads(hr: [flatBucket(ts: 0, bpm: 62)]) let outcome = await ShortcutHealthExport.export( source: source, deviceId: "dev", now: Date(timeIntervalSince1970: 10_000), defaults: defaults, directory: missing, timeZone: utc) @@ -234,7 +244,7 @@ final class ShortcutHealthExportTests: XCTestCase { func testExportNothingNewWhenWatermarkCoversNow() async throws { defaults.set(9_900, forKey: ShortcutHealthExport.watermarkKey) let outcome = await ShortcutHealthExport.export( - source: FakeReads(hr: [HRBucket(ts: 0, bpm: 62)]), deviceId: "dev", + source: FakeReads(hr: [flatBucket(ts: 0, bpm: 62)]), deviceId: "dev", now: Date(timeIntervalSince1970: 10_000), defaults: defaults, directory: dir, timeZone: utc) XCTAssertEqual(outcome, .nothingNew) @@ -248,12 +258,12 @@ final class ShortcutHealthExportTests: XCTestCase { // the file must be EMPTY, or the Shortcut re-imports the previous rows on every automation run. func testNothingNewTruncatesStaleFileSoShortcutCannotReimport() async throws { _ = await ShortcutHealthExport.export( - source: FakeReads(hr: [HRBucket(ts: 0, bpm: 62)]), deviceId: "dev", + source: FakeReads(hr: [flatBucket(ts: 0, bpm: 62)]), deviceId: "dev", now: Date(timeIntervalSince1970: 10_000), defaults: defaults, directory: dir, timeZone: utc) XCTAssertEqual(try fileText(), "62,,,1970-01-01 00:00") let second = await ShortcutHealthExport.export( - source: FakeReads(hr: [HRBucket(ts: 0, bpm: 62)]), deviceId: "dev", + source: FakeReads(hr: [flatBucket(ts: 0, bpm: 62)]), deviceId: "dev", now: Date(timeIntervalSince1970: 10_060), // +60s — same 15-min window, nothing new defaults: defaults, directory: dir, timeZone: utc) XCTAssertEqual(second, .nothingNew) @@ -264,11 +274,11 @@ final class ShortcutHealthExportTests: XCTestCase { // (the Shortcut has no dedup; re-offered lines would be double-logged into Health). func testExportReplacesWholeFile() async throws { _ = await ShortcutHealthExport.export( - source: FakeReads(hr: [HRBucket(ts: 0, bpm: 62)]), deviceId: "dev", + source: FakeReads(hr: [flatBucket(ts: 0, bpm: 62)]), deviceId: "dev", now: Date(timeIntervalSince1970: 10_000), defaults: defaults, directory: dir, timeZone: utc) let outcome = await ShortcutHealthExport.export( - source: FakeReads(hr: [HRBucket(ts: 9_900, bpm: 70)]), deviceId: "dev", + source: FakeReads(hr: [flatBucket(ts: 9_900, bpm: 70)]), deviceId: "dev", now: Date(timeIntervalSince1970: 20_000), defaults: defaults, directory: dir, timeZone: utc) XCTAssertEqual(outcome, .written(lines: 1)) diff --git a/StrandTests/SkinTempAbsoluteDisplayTests.swift b/StrandTests/SkinTempAbsoluteDisplayTests.swift new file mode 100644 index 0000000000..bbdba9f399 --- /dev/null +++ b/StrandTests/SkinTempAbsoluteDisplayTests.swift @@ -0,0 +1,142 @@ +import XCTest +import SwiftUI +import StrandAnalytics +import WhoopStore +@testable import Strand + +/// #1636: the skin-temp tile leads with the night's ABSOLUTE, with the deviation in the caption beneath. +/// +/// A deviation with no anchor cannot be read — the reporter's flu night was "+0.94 Δ°F", which looks like +/// nothing, against 96.4 °F on a 94.4 °F mean, which reads as a fever. Both numbers are needed and +/// neither is sufficient. +/// +/// `BodyVitalReading.stateCaption` is pure, so the ordering is asserted directly. Twin of Kotlin +/// `SkinTempAbsoluteDisplayTest`, which asserts the same two properties through its own pure seams +/// (`latestSkinAbsoluteC` / `skinTempSecondaryNote`) because Android's builder resolves resources and +/// cannot run in a JVM test. +final class SkinTempAbsoluteDisplayTests: XCTestCase { + + private func reading(secondary: String?, caveat: String? = nil) -> 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/SleepNightSpanLabelTests.swift b/StrandTests/SleepNightSpanLabelTests.swift new file mode 100644 index 0000000000..c1572d8495 --- /dev/null +++ b/StrandTests/SleepNightSpanLabelTests.swift @@ -0,0 +1,119 @@ +import XCTest +import WhoopStore +@testable import Strand + +/// #2199: the night caption repeated a date. @bartmuskala reported it three times, on Android. +/// +/// The carousel is keyed by the night's WAKE day, so each row is one wake date and the rows are +/// unique by construction. A night that BEGINS after midnight has its onset on that same wake date, +/// so naming it by the onset made it lead with the date the row above already leads with: +/// +/// 1 night ago Sun 13 → Mon 14 Sep +/// 2 nights ago Sun 13 Sep <- leading with Sun 13 a second time +/// +/// Apple was never reported, because the cross-midnight branch renders a span, so the repeat is a +/// shared LEADING date rather than an identical string and reads as coincidence. It is the same +/// defect presented more quietly. These pin the anchor so both platforms name a night identically: +/// every row leads with its own wake day minus one. Android twin: SleepHeroLogicTest.kt +/// (`afterMidnightNightDoesNotRepeatTheDateAboveIt_issue2199`). +/// +/// The assertions derive their expected dates through a formatter carrying the SAME format and the +/// system locale, rather than hardcoding English. `Night.dateFmt` pins no locale, by design, so it +/// renders in the reader's; a test spelling out "Sat 12 Sep" would pass here and fail on a runner +/// configured for anywhere else. What is under test is the DATE, not its spelling. +/// +/// Pure: builds `Night` values directly, no store and no view mounting. +final class SleepNightSpanLabelTests: XCTestCase { + + private let calendar = Calendar.current + + private func at(_ day: String, _ hour: Int, _ minute: Int) -> Int { + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.dateFormat = "yyyy-MM-dd" + f.timeZone = .current + let midnight = f.date(from: day)! + return Int(midnight.timeIntervalSince1970) + hour * 3_600 + minute * 60 + } + + private func night(onset: Int, wake: Int) -> Night { + Night(session: CachedSleepSession(startTs: onset, endTs: wake, efficiency: nil, + restingHr: nil, avgHrv: nil, stagesJSON: nil, + userEdited: false, startTsAdjusted: nil), + stages: Stages(awake: 0, light: 0, deep: 0, rem: 0)) + } + + /// The date a row should lead with: its wake day minus one, rendered the way `Night.dateFmt` + /// renders it on whatever machine is running this. + private func expectedNightDate(wake: Int, format: String = "EEE d MMM") -> String { + let wakeDay = Date(timeIntervalSince1970: TimeInterval(wake)) + let nightDay = calendar.date(byAdding: .day, value: -1, to: wakeDay)! + let f = DateFormatter() + f.dateFormat = format + return f.string(from: nightDay) + } + + /// Whatever a row leads with: the onset side of a span, or the whole label when it is one date. + private func leadingDate(_ label: String) -> String { + label.components(separatedBy: " →").first ?? label + } + + /// The reported shape. A night beginning at 00:30 is named by the evening it belongs to, not by + /// the calendar date its sleep began, so it cannot repeat its neighbour's date. + func testNightBeginningAfterMidnightIsNamedByTheEveningBefore() { + let wake = at("2026-09-13", 7, 0) + let afterMidnight = night(onset: at("2026-09-13", 0, 30), wake: wake) + let crossing = night(onset: at("2026-09-13", 22, 50), wake: at("2026-09-14", 6, 48)) + + XCTAssertEqual(afterMidnight.spanLabel, expectedNightDate(wake: wake), + "#2199: an after-midnight night is named by the evening it belongs to") + XCTAssertNotEqual(leadingDate(afterMidnight.spanLabel), leadingDate(crossing.spanLabel), + "#2199: it must not lead with the date the row above leads with") + } + + /// The invariant rather than four strings: consecutive rows never share a leading date, whatever + /// mix of cross-midnight and after-midnight nights they are. This is the property the reporter + /// asked for, and it holds in any locale. + func testConsecutiveNightsNeverShareALeadingDate() { + let rows = [ + night(onset: at("2026-09-14", 22, 50), wake: at("2026-09-15", 6, 48)), + night(onset: at("2026-09-13", 22, 50), wake: at("2026-09-14", 6, 48)), + night(onset: at("2026-09-13", 0, 30), wake: at("2026-09-13", 7, 0)), + night(onset: at("2026-09-12", 0, 30), wake: at("2026-09-12", 7, 0)), + ] + var seen: [String: Int] = [:] + for (index, row) in rows.enumerated() { + let leading = leadingDate(row.spanLabel) + if let owner = seen[leading] { + XCTFail("rows \(owner) and \(index) both lead with \(leading): \(row.spanLabel)") + } + seen[leading] = index + } + } + + /// Every row leads with its own wake day minus one, which is the anchor Android prints. + func testEveryRowLeadsWithItsOwnWakeDayMinusOne() { + let wakes = [at("2026-09-15", 6, 48), at("2026-09-14", 6, 48), + at("2026-09-13", 7, 0), at("2026-09-12", 7, 0)] + let onsets = [at("2026-09-14", 22, 50), at("2026-09-13", 22, 50), + at("2026-09-13", 0, 30), at("2026-09-12", 0, 30)] + for (onset, wake) in zip(onsets, wakes) { + let label = night(onset: onset, wake: wake).spanLabel + // A span leads with "EEE d", dropping the month its wake side carries; a single date + // spells the month. Compare against whichever this row is, exactly: `hasPrefix` would + // let "Sun 1" pass against "Sun 13 Sep". + let expected = expectedNightDate(wake: wake, + format: label.contains("→") ? "EEE d" : "EEE d MMM") + XCTAssertEqual(leadingDate(label), expected, + "row leading \(leadingDate(label)) should name \(expected): \(label)") + } + } + + /// A cross-midnight night keeps its span: it states both ends, which is more than the anchor + /// needs, and changing it was never part of the report. + func testCrossMidnightNightKeepsItsSpan() { + let crossing = night(onset: at("2026-09-13", 22, 50), wake: at("2026-09-14", 6, 48)) + XCTAssertTrue(crossing.spanLabel.contains("→"), + "the cross-midnight span is unchanged, got \(crossing.spanLabel)") + } +} diff --git a/StrandTests/SleepPhantomNightFallbackTests.swift b/StrandTests/SleepPhantomNightFallbackTests.swift index 7d0388e1b3..163b03e631 100644 --- a/StrandTests/SleepPhantomNightFallbackTests.swift +++ b/StrandTests/SleepPhantomNightFallbackTests.swift @@ -77,4 +77,58 @@ 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. + /// #2108: a night already in hand silences `.calculating`. + /// + /// Reported as a banner that never clears, and the screenshot showed worse: "detecting and staging + /// the night now" printed directly above that same night, scored, timed and staged. The ladder + /// checked `calculating` before `hasCurrentNight`, so a finished night could not silence it however + /// complete it was. This is the combination none of the cases below covered, which is why it shipped. + /// Twin of the Kotlin `a night already in hand silences calculating`. + func testFreshnessNightInHandSilencesCalculating() { + XCTAssertNil(resolveSleepFreshness(hasCurrentNight: true, morningReady: true, syncing: false, + calculating: true, syncedSinceDayStart: true, syncFailed: false)) + } + + /// `.syncing` deliberately still outranks a present night: data landing now can change what is shown, + /// where recalculating an already scored night cannot make the banner true about what is on screen. + /// Pinned so the fix above is not later tidied into suppressing both. + func testFreshnessSyncingStillShowsWithNightInHand() { + XCTAssertEqual(resolveSleepFreshness(hasCurrentNight: true, morningReady: true, syncing: true, + calculating: true, syncedSinceDayStart: true, + syncFailed: false), .syncing) + } + + /// And the reorder did not silence the one state that genuinely reports work in progress: before + /// morning with no night detected, `.calculating` still wins over the quiet return. + func testFreshnessCalculatingStillShowsBeforeMorningWithNoNight() { + XCTAssertEqual(resolveSleepFreshness(hasCurrentNight: false, morningReady: false, syncing: false, + calculating: true, syncedSinceDayStart: false, + syncFailed: false), .calculating) + } + + 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/SleepSparseNoteGateTests.swift b/StrandTests/SleepSparseNoteGateTests.swift new file mode 100644 index 0000000000..04249286e4 --- /dev/null +++ b/StrandTests/SleepSparseNoteGateTests.swift @@ -0,0 +1,45 @@ +import XCTest +@testable import Strand + +/// The #345 "May be incomplete" gate: sparse motion is necessary, a short total is what makes it worth +/// saying. Twin of Kotlin `SleepSparseNoteGateTest`. +/// +/// The numbers here are the shape of the false positive this closes: nights of 10.5h, 12h and 12.75h that +/// carried the sparse flag and were captioned as possibly reading short. +final class SleepSparseNoteGateTests: XCTestCase { + + private let need = 8.0 + + func testALongNightNeverEarnsTheCaveatHoweverSparseItsMotion() { + for hours in [8.0, 10.5, 12.0, 12.75] { + XCTAssertFalse(SleepView.stageSparseNoteApplies(stagingSparse: true, + asleepMin: hours * 60, needHours: need), + "a \(hours)h night cannot honestly be captioned as possibly reading short") + } + } + + func testAGenuinelyShortSparseNightStillWarns() { + XCTAssertTrue(SleepView.stageSparseNoteApplies(stagingSparse: true, asleepMin: 60, needHours: need)) + XCTAssertTrue(SleepView.stageSparseNoteApplies(stagingSparse: true, asleepMin: 7.9 * 60, needHours: need)) + } + + func testANightThatStagedToNothingIsTheStrongestCaseNotAnExemption() { + XCTAssertTrue(SleepView.stageSparseNoteApplies(stagingSparse: true, asleepMin: 0, needHours: need)) + } + + func testSparseStaysNecessary() { + XCTAssertFalse(SleepView.stageSparseNoteApplies(stagingSparse: false, asleepMin: 60, needHours: need)) + XCTAssertFalse(SleepView.stageSparseNoteApplies(stagingSparse: false, asleepMin: 0, needHours: need)) + } + + func testTheBoundaryIsTheNeedItself() { + XCTAssertTrue(SleepView.stageSparseNoteApplies(stagingSparse: true, asleepMin: need * 60 - 1, needHours: need)) + XCTAssertFalse(SleepView.stageSparseNoteApplies(stagingSparse: true, asleepMin: need * 60, needHours: need)) + } + + func testTheDefaultNeedIsTheSharedEngineConstant() { + // 8h by default, so a 7h sparse night warns and a 9h one does not, with no needHours passed. + XCTAssertTrue(SleepView.stageSparseNoteApplies(stagingSparse: true, asleepMin: 7 * 60)) + XCTAssertFalse(SleepView.stageSparseNoteApplies(stagingSparse: true, asleepMin: 9 * 60)) + } +} 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..c9a434ca42 --- /dev/null +++ b/StrandTests/StandardHeartRateTests.swift @@ -0,0 +1,32 @@ +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?.rrRawTicks, [1024]) + XCTAssertEqual(parsed?.contact, .supportedDetected) + } +} diff --git a/StrandTests/StandardHrNotifyLineTests.swift b/StrandTests/StandardHrNotifyLineTests.swift new file mode 100644 index 0000000000..39f9d2d82d --- /dev/null +++ b/StrandTests/StandardHrNotifyLineTests.swift @@ -0,0 +1,40 @@ +import XCTest +@testable import Strand + +/// #2384: the `HR notify:` line, extracted so Android could be given a twin that cannot drift from it. +/// +/// A reporter's WHOOP 5/MG banked `live hr=0 rr=0` on ten consecutive links while the historical offload +/// ran perfectly, and their Android log could not distinguish the strap never notifying on 0x2A37 from it +/// notifying with a reading the 30...220 value gate drops in silence. Those call for opposite fixes. +/// +/// Twin of the Kotlin `StandardHrNotifyLineTest`. +final class StandardHrNotifyLineTests: XCTestCase { + + func testAUsableReadingSaysSoPlainly() { + XCTAssertEqual(BLEManager.standardHrNotifyLine(hr: 62, rrCount: 2), + "HR notify: 62 bpm, rr=2") + } + + func testAnUnusableReadingIsMarkedIgnored() { + XCTAssertEqual(BLEManager.standardHrNotifyLine(hr: 0, rrCount: 0), + "HR notify: 0 bpm ignored, rr=0") + } + + /// The marker must track the value gate exactly, or the line lies about what reached the store. + func testTheIgnoredMarkerUsesTheSameRangeAsTheValueGate() { + for hr in [30, 31, 219, 220] { + XCTAssertFalse(BLEManager.standardHrNotifyLine(hr: hr, rrCount: 0).contains("ignored"), + "hr=\(hr) is inside the gate") + } + for hr in [0, 29, 221, 300] { + XCTAssertTrue(BLEManager.standardHrNotifyLine(hr: hr, rrCount: 0).contains(" ignored"), + "hr=\(hr) is outside the gate") + } + } + + /// R-R rides the same line, because "HR arrived but carried no intervals" is its own diagnosis. + func testTheIntervalCountRidesTheSameLine() { + XCTAssertEqual(BLEManager.standardHrNotifyLine(hr: 58, rrCount: 0), + "HR notify: 58 bpm, rr=0") + } +} diff --git a/StrandTests/StepsDetailDensityIntegrationTests.swift b/StrandTests/StepsDetailDensityIntegrationTests.swift new file mode 100644 index 0000000000..16d3a9c0ba --- /dev/null +++ b/StrandTests/StepsDetailDensityIntegrationTests.swift @@ -0,0 +1,121 @@ +import XCTest +@testable import Strand + +final class StepsDetailDensityIntegrationTests: XCTestCase { + private let sparseHistory: [(day: String, value: Double)] = [ + ("2014-01-01", 900), + ("2026-01-05", 800), + ("2026-07-05", 700), + ("2026-10-05", 600), + ("2026-11-15", 500), + ("2026-12-05", 400), + ("2026-12-15", 300), + ("2026-12-21", 200), + ("2026-12-30", 100), + ("2026-12-31", 0), + ] + + func testEveryStepsDetailSourceForcesBarsDespiteLinePreference() throws { + let stepMetrics = [ + try XCTUnwrap(MetricCatalog.metric(key: "steps", source: "my-whoop")), + try XCTUnwrap(MetricCatalog.metric(key: "steps", source: "apple-health")), + try XCTUnwrap(MetricCatalog.metric(key: "steps", source: "xiaomi-band")), + try XCTUnwrap(MetricCatalog.metric(key: "steps_est", source: "my-whoop")), + ] + + for metric in stepMetrics { + XCTAssertTrue(MetricDetailSteps.showsBars(metricKey: metric.key, + preferredStyleRaw: TrendChartStyle.line.rawValue), + metric.id) + } + } + + func testOtherMetricsKeepTheGlobalChartPreference() { + XCTAssertFalse(MetricDetailSteps.showsBars(metricKey: "hrv", + preferredStyleRaw: TrendChartStyle.line.rawValue)) + XCTAssertTrue(MetricDetailSteps.showsBars(metricKey: "hrv", + preferredStyleRaw: TrendChartStyle.bar.rawValue)) + } + + func testOneReadingAndOneAggregatedBucketRemainBars() throws { + let oneReading = MetricDetailSteps.presentation( + readings: [("2026-03-08", 1_234)], range: .week) + XCTAssertEqual(oneReading.series.count, 1) + XCTAssertEqual(oneReading.series.first?.value, 1_234) + + let oneWeeklyBucket = MetricDetailSteps.presentation( + readings: [("2026-03-02", 1_000), ("2026-03-04", 2_000)], range: .quarter) + XCTAssertEqual(oneWeeklyBucket.series.count, 1) + XCTAssertEqual(oneWeeklyBucket.series.first?.day, "2026-03-02") + XCTAssertEqual(oneWeeklyBucket.series.first?.value, 1_500) + let bucketCount = 1 + let mean = 1_500 + let noun = String(localized: "bar") + let period = MetricDetailSteps.periodLabel(day: "2026-03-02", resolution: .weekly) + XCTAssertEqual( + oneWeeklyBucket.accessibilitySummary, + String(localized: "Steps chart, \(bucketCount) weekly \(noun), latest \(mean) average steps per observed day, \(period)")) + + // Grouping follows the selected app locale; the displayed value is no longer a raw integer. + let formatter = NumberFormatter() + formatter.locale = AppLanguage.activeLocale + formatter.numberStyle = .decimal + formatter.maximumFractionDigits = 0 + let formatted = try XCTUnwrap(formatter.string(from: NSNumber(value: mean))) + XCTAssertEqual( + MetricDetailSteps.valueLabel(1_500, resolution: .weekly), + String(localized: "\(formatted) average steps per observed day")) + } + + func testSparseHistoryUsesTheExpectedResolutionForEveryRange() { + let expected: [(ExploreRange, MetricDetailSteps.Resolution)] = [ + (.week, .daily), (.twoWeeks, .daily), (.threeWeeks, .daily), (.month, .daily), + (.quarter, .weekly), (.half, .monthly), (.year, .monthly), (.all, .monthly), + ] + + for (range, resolution) in expected { + let presentation = MetricDetailSteps.presentation(readings: sparseHistory, range: range) + XCTAssertEqual(presentation.resolution, resolution, range.label) + XCTAssertFalse(presentation.series.isEmpty, range.label) + XCTAssertEqual(presentation.series.last?.day, + resolution == .monthly ? "2026-12-01" : + (resolution == .weekly ? "2026-12-28" : "2026-12-31"), range.label) + } + + XCTAssertEqual(MetricDetailSteps.widening(from: .twoWeeks).first, .twoWeeks) + XCTAssertEqual(MetricDetailSteps.widening(from: .threeWeeks).first, .threeWeeks) + } + + func testPreviousWindowIsTheImmediatelyPrecedingEqualCalendarPeriod() { + let readings: [(day: String, value: Double)] = [ + ("2025-12-20", 99_999), + ("2026-01-01", 500), + ("2026-01-07", 1_500), + ("2026-01-08", 1_000), + ("2026-01-14", 3_000), + ] + + let previous = MetricDetailSteps.previousPresentation( + readings: readings, range: .week, currentAnchorDay: "2026-01-14") + + XCTAssertEqual(previous.series.map(\.day), ["2026-01-01", "2026-01-07"]) + XCTAssertEqual(previous.series.map(\.value), [500, 1_500]) + } + + func testAllStepsUsesFullHistoryAndRangeParticipatesInLoadIdentity() { + XCTAssertTrue(MetricDetailSteps.requiresFullHistory(metricKey: "steps", range: .all)) + XCTAssertTrue(MetricDetailSteps.requiresFullHistory(metricKey: "steps_est", range: .all)) + XCTAssertFalse(MetricDetailSteps.requiresFullHistory(metricKey: "steps", range: .year)) + XCTAssertFalse(MetricDetailSteps.requiresFullHistory(metricKey: "hrv", range: .all)) + + let month = MetricDetailSteps.loadIdentity(metricID: "apple-health:steps", refreshSequence: 4, + skinTemperatureStyle: "", range: .month) + let all = MetricDetailSteps.loadIdentity(metricID: "apple-health:steps", refreshSequence: 4, + skinTemperatureStyle: "", range: .all) + XCTAssertNotEqual(month, all) + + let old = MetricDetailSteps.presentation( + readings: [("2014-01-01", 900), ("2026-12-31", 100)], range: .all) + XCTAssertEqual(old.series.first?.day, "2014-01-01") + } +} 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/StrapLogArchiveTests.swift b/StrandTests/StrapLogArchiveTests.swift new file mode 100644 index 0000000000..a769b4cece --- /dev/null +++ b/StrandTests/StrapLogArchiveTests.swift @@ -0,0 +1,196 @@ +import XCTest +@testable import Strand + +/// The strap log on disk (`StrapLogArchive`). What it must never do again is what the UserDefaults ring did: +/// lose the lines logged just before a restart, a run's head beyond 1,000 lines, and every run before the +/// last three — which on 21 Sep 2026 cost half an hour of a gym session's log. +final class StrapLogArchiveTests: XCTestCase { + + private var directory: URL! + private let t0 = Date(timeIntervalSince1970: 1_790_000_000) + + override func setUp() { + super.setUp() + directory = FileManager.default.temporaryDirectory + .appendingPathComponent("StrapLogArchiveTests-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } + + override func tearDown() { + try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: directory.path) + try? FileManager.default.removeItem(at: directory) + super.tearDown() + } + + /// A process of NOOP, started `seconds` after t0, writing into the same folder. + private func process(at seconds: TimeInterval, budget: Int = StrapLogArchive.budgetBytes, + segment: Int = StrapLogArchive.segmentBytes) -> StrapLogArchive { + StrapLogArchive(directory: directory, budgetBytes: budget, segmentBytes: segment, + now: t0.addingTimeInterval(seconds)) + } + + private func iso(_ seconds: TimeInterval) -> String { + let f = ISO8601DateFormatter() + f.timeZone = TimeZone(identifier: "UTC") + return f.string(from: t0.addingTimeInterval(seconds)) + } + + private func logFiles() -> [URL] { + ((try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? []) + .filter { $0.pathExtension == "log" } + } + + /// THE ONE THAT MATTERS: every line reaches disk as it is logged, so a process killed without warning + /// (it is never closed here) leaves all of them for the next run's export — none held back for a batch. + func testEveryLineSurvivesARestartWithoutAClose() { + let killed = process(at: 0) + for line in ["20:34:01 double-tap", "20:34:02 buzz", "20:34:03 cpu"] { killed.append(line) } + + let next = process(at: 10) + XCTAssertEqual(next.exportText(), """ + ===== previous app session, 3 line(s), rolled at \(iso(10)) (this launch) ===== + 20:34:01 double-tap + 20:34:02 buzz + 20:34:03 cpu + ===== current app session ===== + + """) + next.append("20:34:12 restored") + XCTAssertTrue(next.exportText().hasSuffix("===== current app session =====\n20:34:12 restored")) + } + + /// Four restarts in half an hour keep every run, oldest first, each rolled at the start of the next — + /// the ring kept only the last three. + func testEveryRunIsKeptOldestFirstEachRolledAtTheNextStart() { + for (i, start) in [0.0, 100, 200, 300].enumerated() { + process(at: start).append("run \(i + 1)") + } + let text = process(at: 400).exportText() + XCTAssertEqual(text, """ + ===== previous app session, 1 line(s), rolled at \(iso(100)) (this launch) ===== + run 1 + ===== previous app session, 1 line(s), rolled at \(iso(200)) (this launch) ===== + run 2 + ===== previous app session, 1 line(s), rolled at \(iso(300)) (this launch) ===== + run 3 + ===== previous app session, 1 line(s), rolled at \(iso(400)) (this launch) ===== + run 4 + ===== current app session ===== + + """) + } + + /// A run longer than a segment is split across files and exported whole, in order — the ring kept a + /// run's last 1,000 lines, and the screen's buffer keeps 5,000. + func testALongRunIsSplitIntoSegmentsAndExportedWhole() { + let archive = process(at: 0, segment: 200) + let lines = (1...100).map { String(format: "line %03d", $0) } + for (i, line) in lines.enumerated() { + archive.append(line) + if i == 40 { _ = archive.exportText() } // an export mid-run must not freeze the rest + } + XCTAssertGreaterThan(logFiles().count, 1, "the run spans several segments") + XCTAssertEqual(archive.exportText(), lines.joined(separator: "\n")) + XCTAssertEqual(process(at: 10).exportText(), """ + ===== previous app session, 100 line(s), rolled at \(iso(10)) (this launch) ===== + \(lines.joined(separator: "\n")) + ===== current app session ===== + + """) + } + + /// Past the budget the oldest segments go first — never the newest lines — and a run that lost its head + /// says so, so the missing lines do not read as silence. + func testPastTheBudgetTheOldestGoFirstAndAClippedRunSaysSo() { + let old = process(at: 0, budget: 1_000, segment: 200) + for i in 1...60 { old.append(String(format: "old %02d", i)) } // 420 bytes, 3 segments + let recent = process(at: 100, budget: 1_000, segment: 200) + for i in 1...120 { recent.append(String(format: "new %03d", i)) } // 960 bytes, 5 segments + + let onDisk = logFiles().reduce(0) { + $0 + ((try? $1.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0) + } + XCTAssertLessThanOrEqual(onDisk, 1_000 + 200, "the budget, plus the segment being written") + let text = process(at: 200, budget: 1_000, segment: 200).exportText() + XCTAssertTrue(text.contains("new 120"), "the newest line is kept") + XCTAssertFalse(text.contains("old 01"), "the oldest line is the first to go") + XCTAssertTrue(text.contains("line(s), head clipped, rolled at"), text) + } + + /// #1263: Report tapped right after a restart, before the new process has logged a line, still carries + /// the run before it. + func testAnExportBeforeTheFirstLineCarriesTheRunBefore() { + process(at: 0).append("03:14 reconnect storm") + let text = process(at: 60).exportText() + XCTAssertTrue(text.contains("03:14 reconnect storm")) + XCTAssertTrue(text.hasSuffix("===== current app session =====\n")) + } + + /// Before the first unlock after a boot iOS refuses the files, and that is when a restart after a reboot is + /// logged. Those lines stay in memory past a segment boundary (#2386 review: they used to vanish there) and + /// reach disk the moment a file opens, so a later restart keeps them too. + func testLinesThatCannotBeWrittenWaitAndReachDiskOnceStorageOpens() throws { + try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: directory.path) + let locked = process(at: 0, segment: 200) + let early = (1...100).map { String(format: "locked %03d", $0) } // 1,100 bytes: five segments + for line in early { locked.append(line) } + XCTAssertTrue(logFiles().isEmpty, "nothing can be written yet") + XCTAssertEqual(locked.exportText(), early.joined(separator: "\n")) + + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: directory.path) + let later = (1...64).map { String(format: "unlocked %02d", $0) } // the next open attempt comes within 64 + for line in later { locked.append(line) } + XCTAssertEqual(locked.exportText(), (early + later).joined(separator: "\n")) + XCTAssertEqual(process(at: 10, segment: 200).exportText(), """ + ===== previous app session, 164 line(s), rolled at \(iso(10)) (this launch) ===== + \((early + later).joined(separator: "\n")) + ===== current app session ===== + + """) + } + + /// While nothing can be written, memory holds the newest lines within the budget; once they reach disk the run + /// says it lost its head. + func testAnUnwrittenBacklogStaysWithinTheBudgetAndSaysItsHeadWasClipped() throws { + try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: directory.path) + let locked = process(at: 0, budget: 1_000, segment: 200) + for i in 1...200 { locked.append(String(format: "locked %03d", i)) } // 2,200 bytes + let held = locked.exportText() + XCTAssertLessThanOrEqual(held.utf8.count, 1_000) + XCTAssertTrue(held.hasSuffix("locked 200"), "the newest line is kept") + XCTAssertFalse(held.contains("locked 001"), "the oldest line is the first to go") + + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: directory.path) + for i in 1...64 { locked.append(String(format: "open %02d", i)) } + XCTAssertTrue(process(at: 10, budget: 1_000, segment: 200).exportText().contains("head clipped")) + } + + func testNothingLoggedExportsNothing() { + XCTAssertEqual(process(at: 0).exportText(), "") + } + + /// The ring's lines are carried over once, ahead of every run, exactly as its export printed them. + func testTheRingIsCarriedOverOnceAheadOfEveryRun() { + let ring = StrapLogArchive.legacyRingLines( + generations: [["===== previous app session, 1 line(s), rolled at 2026-09-20T10:00:00Z (this launch) =====", + "old run"]], + tail: ["unrolled tail"], now: t0) + XCTAssertEqual(ring, [ + "===== previous app session, 1 line(s), rolled at 2026-09-20T10:00:00Z (this launch) =====", + "old run", + "===== previous app session, 1 line(s), rolled at \(iso(0)) (this launch) =====", + "unrolled tail", + ]) + let first = process(at: 0) + first.importLegacy(ring) + first.append("first line on disk") + let later = process(at: 50) + later.importLegacy(["must not be written twice"]) + XCTAssertEqual(later.exportText(), (ring + [ + "===== previous app session, 1 line(s), rolled at \(iso(50)) (this launch) =====", + "first line on disk", + "===== current app session =====", + "", + ]).joined(separator: "\n")) + } +} diff --git a/StrandTests/StrapProvidesLineTests.swift b/StrandTests/StrapProvidesLineTests.swift new file mode 100644 index 0000000000..ad8a5345c7 --- /dev/null +++ b/StrandTests/StrapProvidesLineTests.swift @@ -0,0 +1,55 @@ +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, + deviceId: "my-whoop"), + "Provides: HR yes · R-R yes · motion NO · steps NO (my-whoop, last 48h)" + ) + } + + func testAFullySyncedStrapProvidesAllFour() { + XCTAssertEqual( + DebugDataDiagnostics.strapProvidesLine(hr: true, rr: true, motion: true, steps: true, + deviceId: "my-whoop"), + "Provides: HR yes · R-R yes · motion yes · steps yes (my-whoop, 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, + deviceId: "my-whoop") + XCTAssertEqual(line.components(separatedBy: "NO").count - 1, 2, line) + XCTAssertEqual(line, "Provides: HR yes · R-R NO · motion NO · steps yes (my-whoop, last 48h)") + } + + /// #2012: the line asks ONE id, the active one, while every scorer reads the union of the active, + /// canonical and computed ids. On a re-added strap or an archived spine those disagree, and the line + /// then reads as "this install has no heart rate" when it means "the active strap id delivered none". + /// Naming the id is what stops a reader drawing the first conclusion, which cost real triage time. + func testTheLineNamesWhoseDataItIsDescribing() { + XCTAssertEqual( + "Provides: HR NO · R-R NO · motion NO · steps NO (whoop-5A0FAKE, last 48h)", + DebugDataDiagnostics.strapProvidesLine(hr: false, rr: false, motion: false, steps: false, + deviceId: "whoop-5A0FAKE")) + } + + /// The funnel's heading says "latest night"; when it falls back it has to say so. + func testTheFunnelNoteFiresOnlyWhenAnOlderNightWasAnalysed() { + XCTAssertEqual("", DebugDataDiagnostics.funnelFallbackNote(chosenDay: "2026-09-09", + newestDay: "2026-09-09")) + XCTAssertEqual( + " (NOT the latest night: 2026-09-09 carried no skin temperature)", + DebugDataDiagnostics.funnelFallbackNote(chosenDay: "2026-09-05", newestDay: "2026-09-09")) + } +} diff --git a/StrandTests/StressTraceTests.swift b/StrandTests/StressTraceTests.swift new file mode 100644 index 0000000000..c08db83072 --- /dev/null +++ b/StrandTests/StressTraceTests.swift @@ -0,0 +1,275 @@ +import XCTest +import StrandAnalytics + +/// The stress widget's pure half, and the parity that keeps it honest. +/// +/// Mirrors the Kotlin `StressTraceTest` case for case. The two that matter most pin where this departs +/// from `HrTrace`: the domain is fixed rather than normalised, and an unscored hour is a hole rather +/// than a point to draw through. +final class StressTraceTests: XCTestCase { + + private let hour: Int64 = 3_600 + private func at(_ h: Int64, _ level: Double?, moving: Bool = false) -> StressPoint { + StressPoint(ts: h * hour, level: level, moving: moving) + } + + // MARK: - the constant the extension cannot import + + func testHighBandFloorStillMatchesTheAnalyticsConstant() { + // `StressTrace` restates this because the widget extension links no packages, so it cannot see + // `DaytimeStress`. This test is the only place that can compare them, and it exists so the copy + // cannot drift silently the day the band moves. If this fails, change the copy, not this line. + XCTAssertEqual(StressTrace.highBandFloor, DaytimeStress.highBandFloor) + } + + func testDomainMaxMatchesTheScaleTheScoreIsOn() { + // The 0-3 proxy's top. A widget drawing against a different ceiling would misplace every hour. + XCTAssertEqual(StressTrace.domainMax, 3.0) + } + + // MARK: - formatting + + func testAStressLevelRoundsHalfAwayFromZeroLikeAndroid() { + XCTAssertEqual(StressTrace.formatLevel(0.25), "0.3") + XCTAssertEqual(StressTrace.formatLevel(1.25), "1.3") + XCTAssertEqual(StressTrace.formatLevel(2.25), "2.3") + } + + func testTheLfHfRatioSharesTheLevelArithmeticWithoutItsCeiling() { + // 9/4 is an exact binary 2.25: Java's %.1f said "2.3" while C's said "2.2" (#2167). + XCTAssertEqual(StressTrace.formatRatio(9.0 / 4.0), "2.3") + XCTAssertEqual(StressTrace.formatRatio(0.25), "0.3") + XCTAssertEqual(StressTrace.formatRatio(1.75), "1.8") + // No stress-domain ceiling: a sympathetic-leaning ratio prints as itself, not as 3.0. + XCTAssertEqual(StressTrace.formatRatio(4.25), "4.3") + XCTAssertEqual(StressTrace.formatRatio(-0.3), "0.0") + } + + func testAStressLevelRoundsToOneDecimal() { + XCTAssertEqual(StressTrace.formatLevel(1.85), "1.9") + XCTAssertEqual(StressTrace.formatLevel(1.96), "2.0") + XCTAssertEqual(StressTrace.formatLevel(0.04), "0.0") + } + + func testAStressLevelIsClampedToTheDomainAtBothEnds() { + XCTAssertEqual(StressTrace.formatLevel(StressTrace.domainMax + 0.4), "3.0") + XCTAssertEqual(StressTrace.formatLevel(-1.0), "0.0") + } + + // MARK: - the fixed domain + + func testACalmDayIsDrawnLowRatherThanStretchedAcrossTheBox() { + let calm = [at(0, 0.2), at(1, 0.3), at(2, 0.25)] + let pts = StressTrace.segments(calm, width: 100, height: 100)[0] + // Every point sits in the bottom tenth, because 0.3 of 3 IS low. Normalising to the day's own + // range would have spread these three across the full height and drawn a dramatic day. + XCTAssertTrue(pts.allSatisfy { $0.y >= 88 }, "calm day should hug the bottom, got \(pts)") + } + + func testTheSameShapeAtAHigherLevelDrawsHigher() { + let calm = StressTrace.segments([at(0, 0.2), at(1, 0.4)], width: 100, height: 100)[0] + let tense = StressTrace.segments([at(0, 2.2), at(1, 2.4)], width: 100, height: 100)[0] + // Identical spread, different absolute level: under a normalising domain these would be drawn + // identically, which is the lie the fixed domain exists to prevent. + XCTAssertLessThan(tense[0].y, calm[0].y) + } + + func testTheTopOfTheDomainIsTheTopOfTheBoxAndZeroIsTheBottom() { + let pts = StressTrace.segments([at(0, 3.0), at(1, 0.0)], width: 100, height: 100)[0] + XCTAssertEqual(pts[0].y, 0, accuracy: 0.001) + XCTAssertEqual(pts[1].y, 100, accuracy: 0.001) + } + + // MARK: - gaps + + func testAnUnscoredHourSplitsTheLineRatherThanBeingDrawnThrough() { + let day = [at(0, 1.0), at(1, 1.2), at(2, nil), at(3, 0.9), at(4, 1.1)] + let segs = StressTrace.segments(day, width: 100, height: 100) + XCTAssertEqual(segs.count, 2, "the hole must break the line in two") + XCTAssertEqual(segs[0].count, 2) + XCTAssertEqual(segs[1].count, 2) + } + + func testTheGapKeepsItsWidthSoTheAfternoonDoesNotSlideLeft() { + let segs = StressTrace.segments([at(0, 1.0), at(1, nil), at(2, 1.0)], width: 100, height: 100) + XCTAssertEqual(segs[0][0].x, 0, accuracy: 0.001) + XCTAssertEqual(segs[1][0].x, 100, accuracy: 0.001) + } + + func testADayWithNothingScoredDrawsNothing() { + let day = [at(0, nil), at(1, nil, moving: true)] + XCTAssertTrue(StressTrace.segments(day, width: 100, height: 100).isEmpty) + XCTAssertNil(StressTrace.stats(day)) + } + + // MARK: - movement + + func testHoursMaskedAsMovementAreMarkedAndDoNotJoinTheLine() { + let day = [at(0, 1.0), at(1, nil, moving: true), at(2, 1.0)] + XCTAssertEqual(StressTrace.segments(day, width: 100, height: 100).count, 2) + let span = StressTrace.movingSpans(day, width: 100)[0] + XCTAssertEqual(span.lowerBound, 25, accuracy: 0.001) + XCTAssertEqual(span.upperBound, 75, accuracy: 0.001) + } + + // #2106: contiguous masked stretches, so the marks read as regions rather than as axis ticks. + + /// Adjacent masked hours become ONE span: that is the whole point, a bar under the hole it explains. + func testAdjacentMovingHoursJoinIntoOneSpan() { + let day = [at(0, 1.0), at(1, nil, moving: true), at(2, nil, moving: true), at(3, 1.5)] + XCTAssertEqual(StressTrace.movingSpans(day, width: 100).count, 1) + } + + /// Separated runs stay separate, so two different stretches are not merged into one claim. + func testSeparatedMovingRunsStaySeparate() { + let day = [at(0, nil, moving: true), at(1, 1.0), at(2, nil, moving: true)] + XCTAssertEqual(StressTrace.movingSpans(day, width: 100).count, 2) + } + + /// A run ending at the LAST hour still closes, rather than being dropped for want of a terminator. + func testARunEndingAtTheLastPointIsStillEmitted() { + let day = [at(0, 1.0), at(1, nil, moving: true), at(2, nil, moving: true)] + let spans = StressTrace.movingSpans(day, width: 100) + XCTAssertEqual(spans.count, 1) + XCTAssertEqual(spans[0].upperBound, 100, accuracy: 0.001) + } + + /// No moving hours means no marks, so an ordinary day carries no band at all. + func testNoMovingHoursYieldsNoSpans() { + XCTAssertTrue(StressTrace.movingSpans([at(0, 1.0), at(1, 2.0)], width: 100).isEmpty) + } + + /// A LONE masked hour is the case the geometry exists for: centre to centre it has a width of zero, + /// and it is the hour with no neighbours to make it obvious, so it is also the one that most needs + /// to be legible. It covers its own hour, half a slot either side of its centre. + func testALoneMovingHourSpansItsOwnHourNotAnInstant() { + let day = [at(0, 1.0), at(1, 1.0), at(2, nil, moving: true), at(3, 1.0), at(4, 1.0)] + let spans = StressTrace.movingSpans(day, width: 100) + XCTAssertEqual(spans.count, 1) + XCTAssertEqual(spans[0].lowerBound, 37.5, accuracy: 0.001) + XCTAssertEqual(spans[0].upperBound, 62.5, accuracy: 0.001) + } + + /// A run covers its hours EDGE to edge, so the bar reaches past the outermost masked centres. + func testARunCoversItsHoursEdgeToEdge() { + let day = [at(0, 1.0), at(1, nil, moving: true), at(2, nil, moving: true), at(3, 1.0)] + let spans = StressTrace.movingSpans(day, width: 100) + XCTAssertEqual(spans.count, 1) + XCTAssertEqual(spans[0].lowerBound, 100.0 / 6.0, accuracy: 0.001) + XCTAssertEqual(spans[0].upperBound, 100.0 * 5.0 / 6.0, accuracy: 0.001) + } + + /// At the ends of the day the territory stops at the data: nothing is invented past what was sampled. + func testARunStartingAtTheFirstHourStartsAtTheEdgeOfTheBox() { + let day = [at(0, nil, moving: true), at(1, 1.0), at(2, 1.0)] + let spans = StressTrace.movingSpans(day, width: 100) + XCTAssertEqual(spans.count, 1) + XCTAssertEqual(spans[0].lowerBound, 0, accuracy: 0.001) + } + + // MARK: - the high band + + func testOnlyHoursAtOrAboveTheHighBandGetADot() { + let day = [at(0, 1.0), at(1, 1.9), at(2, 2.0), at(3, 2.8), at(4, nil)] + // 2.0 is the floor itself, so it counts; 1.9 does not. + XCTAssertEqual(StressTrace.highPoints(day, width: 100, height: 100).count, 2) + } + + func testADotLandsOnItsOwnVertex() { + let day = [at(0, 0.5), at(1, 2.5)] + let vertex = StressTrace.segments(day, width: 100, height: 100)[0][1] + let dot = StressTrace.highPoints(day, width: 100, height: 100)[0] + // Dot and line are placed by the same rule, so a nudge to one cannot leave the other behind. + // The view lifts the dot above the stroke; the geometry must agree exactly. + XCTAssertEqual(vertex.x, dot.x, accuracy: 0.001) + XCTAssertEqual(vertex.y, dot.y, accuracy: 0.001) + } + + func testACalmDayGetsNoDotsAtAll() { + XCTAssertTrue(StressTrace.highPoints([at(0, 0.4), at(1, 1.2)], width: 100, height: 100).isEmpty) + } + + // MARK: - stats + + func testMeanCoversScoredHoursOnlyAndPeakIsTheHighestOfThem() throws { + let day = [at(0, 1.0), at(1, nil), at(2, 2.0), at(3, nil, moving: true)] + let stats = try XCTUnwrap(StressTrace.stats(day)) + XCTAssertEqual(stats.mean, 1.5, accuracy: 0.0001) + XCTAssertEqual(stats.peak.level ?? 0, 2.0, accuracy: 0.0001) + XCTAssertEqual(stats.peak.ts, 2 * hour) + XCTAssertEqual(stats.scoredHours, 2) + XCTAssertEqual(stats.movingHours, 1) + } + + // MARK: - axes + + func testTheLevelScaleIsFixedSoTwoDaysCanBeCompared() { + XCTAssertEqual(StressTrace.levelTicks(), [3, 2, 1, 0]) + } + + func testTimeTicksLabelTheAxisWhichSpansTheWholeSeries() { + let day = [at(0, nil), at(8, 1.0), at(12, 1.5), at(16, 2.0), at(23, nil)] + // Every renderer spreads these three evenly across the chart, and the chart spans the SERIES. + // Anything narrower names the wrong instant at the edge it is drawn against. + XCTAssertEqual(StressTrace.timeTicks(day), [0, 11 * hour + 1_800, 23 * hour]) + } + + /// #2106: scored to 18:30, masked as movement until 22:00, and the axis said the day ended at 18:30. + /// The right-hand label is the end of the DAY, not the end of scoring, or a chart that is perfectly + /// current reads as one that stopped updating hours ago. + func testADayWhoseClosingHoursWereAllMaskedStillNamesItsTrueEnd() { + let day = [at(6, 1.0), at(18, 1.5), at(20, nil, moving: true), at(22, nil, moving: true)] + XCTAssertEqual(StressTrace.timeTicks(day).last, 22 * hour) + } + + func testOneInstantNamesOneInstant() { + // The renderers hide a lone label, so this is what keeps a one-point day from showing a stray. + XCTAssertEqual(StressTrace.timeTicks([at(9, 1.0)]), [9 * hour]) + } + + func testTwoInstantsNameBothEndsAndTheMidpointBetweenThem() { + XCTAssertEqual(StressTrace.timeTicks([at(9, 1.0), at(10, nil)]), + [9 * hour, 9 * hour + 1_800, 10 * hour]) + } + + // MARK: - the snapshot's day guard + + func testACurveScoredYesterdayIsNotDrawnToday() { + let now = Date() + var snap = WidgetSnapshot(recovery: nil, bpm: nil, batteryPct: nil, bonded: false, updated: now) + snap.stressSeries = [at(9, 1.5)] + snap.stressDay = WidgetSnapshot.localDayNumber(now) - 1 + // Nothing runs at midnight to tidy the App Group, so the staleness check has to happen where the + // value is read. Otherwise the card shows yesterday's afternoon under today's date until the + // first scorable hour of the new day arrives. + XCTAssertTrue(snap.stressCurve(now: now).isEmpty) + } + + func testTodaysCurveIsDrawn() { + let now = Date() + var snap = WidgetSnapshot(recovery: nil, bpm: nil, batteryPct: nil, bonded: false, updated: now) + snap.stressSeries = [at(9, 1.5)] + snap.stressDay = WidgetSnapshot.localDayNumber(now) + XCTAssertEqual(snap.stressCurve(now: now).count, 1) + } + + func testAnOlderSnapshotWithNoCurveDecodesAndDrawsNothing() { + // Both fields are optional so a snapshot written before this existed still decodes; it simply + // has no curve to draw. + let snap = WidgetSnapshot(recovery: 50, bpm: 60, batteryPct: 80, bonded: true, updated: Date()) + XCTAssertNil(snap.stressDay) + XCTAssertTrue(snap.stressCurve().isEmpty) + } + + func testPublishingAFreshlyScoredHourIsNotDedupedAway() { + let now = Date() + var a = WidgetSnapshot(recovery: 50, bpm: 60, batteryPct: 80, bonded: true, updated: now) + a.stressDay = WidgetSnapshot.localDayNumber(now) + a.stressSeries = [at(9, 1.5)] + var b = a + b.stressSeries = [at(9, 1.5), at(10, 2.1)] + // Without the curve in the comparison, a publish that scored a fresh hour and changed nothing + // else would be dropped, and the widget would sit an hour behind until an unrelated field moved. + XCTAssertTrue(WidgetSnapshot.renderedContentChanged(from: a, to: b)) + } +} diff --git a/StrandTests/SyncChipStateTests.swift b/StrandTests/SyncChipStateTests.swift index 9b35a2919f..39b88a935a 100644 --- a/StrandTests/SyncChipStateTests.swift +++ b/StrandTests/SyncChipStateTests.swift @@ -15,7 +15,7 @@ final class SyncChipStateTests: XCTestCase { let live = LiveState() live.backfilling = true live.syncChunksThisSession = 7 - XCTAssertEqual(SyncChipState.resolve(live: live), .syncing(chunks: 7)) + XCTAssertEqual(SyncChipState.resolve(live: live), .syncing(chunks: 7, pagesBehind: nil)) } func testLastSyncedAt_isSyncedWithAgeText() { @@ -50,7 +50,7 @@ final class SyncChipStateTests: XCTestCase { live.backfilling = true live.syncChunksThisSession = 2 live.lastSyncedAt = Date().timeIntervalSince1970 - 5 - XCTAssertEqual(SyncChipState.resolve(live: live), .syncing(chunks: 2)) + XCTAssertEqual(SyncChipState.resolve(live: live), .syncing(chunks: 2, pagesBehind: nil)) } func testLastSyncedAt_takesPriorityOverHistorySyncExperimental() { @@ -63,4 +63,46 @@ final class SyncChipStateTests: XCTestCase { XCTFail("A known last-sync should win over the experimental fallback") } } + + // #689/#815: the connect-time backlog sample. Twin of the Android cases in `SyncChipStateTest`. + + @MainActor + func testBackfillingWithBacklogCarriesThePagesFigure() { + let live = LiveState() + live.backfilling = true + live.syncChunksThisSession = 3 + live.pagesBehindAtConnect = 120 + XCTAssertEqual(SyncChipState.resolve(live: live), .syncing(chunks: 3, pagesBehind: 120)) + } + + /// A zero backlog is dropped rather than rendered: "0 pages behind at connect" beside a running + /// sync contradicts itself, and a zero sample gives a reader nothing to act on. The rule lives in + /// `resolve` on both platforms so neither view layer decides it alone. + @MainActor + func testBackfillingWithZeroBacklogDropsTheDetail() { + let live = LiveState() + live.backfilling = true + live.syncChunksThisSession = 3 + live.pagesBehindAtConnect = 0 + XCTAssertEqual(SyncChipState.resolve(live: live), .syncing(chunks: 3, pagesBehind: nil)) + } + + /// No reply yet this session, or a frame that did not decode. + @MainActor + func testBackfillingWithoutASampleIsUnchanged() { + let live = LiveState() + live.backfilling = true + live.syncChunksThisSession = 3 + XCTAssertEqual(SyncChipState.resolve(live: live), .syncing(chunks: 3, pagesBehind: nil)) + } + + /// The backlog only qualifies an in-progress sync; a stale figure must not survive into `.synced`. + @MainActor + func testNotBackfillingIgnoresTheBacklog() { + let live = LiveState() + live.backfilling = false + live.lastSyncedAt = Date().timeIntervalSince1970 - 65 + live.pagesBehindAtConnect = 120 + XCTAssertEqual(SyncChipState.resolve(live: live), .synced(agoText: "1m")) + } } diff --git a/StrandTests/SyncLiveActivityStateTests.swift b/StrandTests/SyncLiveActivityStateTests.swift new file mode 100644 index 0000000000..225bbb62ae --- /dev/null +++ b/StrandTests/SyncLiveActivityStateTests.swift @@ -0,0 +1,41 @@ +import XCTest +@testable import Strand + +/// The strap-sync Live Activity's words and phase (`SyncActivityCopy`). `exitBackfilling` raises +/// `lastSyncError` only when the idle watchdog ended the offload, so its presence is the one honest +/// "interrupted" signal; anything else completed. A zero backlog is dropped, as `SyncChipState` drops it. +final class SyncLiveActivityStateTests: XCTestCase { + + func testCompletedWithChunksReadsSyncedWithCount() { + let line = SyncActivityCopy.final(lastSyncError: nil, chunks: 12) + XCTAssertEqual(line.phase, .done) + XCTAssertEqual(line.chunks, 12) + XCTAssertTrue(line.status.contains("12")) + XCTAssertNil(line.detail) + } + + func testCompletedWithNothingPulledReadsSyncedAlone() { + let line = SyncActivityCopy.final(lastSyncError: nil, chunks: 0) + XCTAssertEqual(line.phase, .done) + XCTAssertFalse(line.status.contains("0")) + } + + func testWatchdogErrorReadsInterruptedAndKeepsTheCount() { + let line = SyncActivityCopy.final(lastSyncError: "Sync interrupted", chunks: 3) + XCTAssertEqual(line.phase, .interrupted) + XCTAssertEqual(line.chunks, 3) + XCTAssertNotNil(line.detail) + } + + func testSyncingWithNoChunksYetClaimsNoCount() { + let line = SyncActivityCopy.syncing(chunks: 0, pagesBehind: nil) + XCTAssertEqual(line.phase, .syncing) + XCTAssertFalse(line.status.contains("0")) + XCTAssertNil(line.detail) + } + + func testSyncingCarriesTheBacklogButDropsZero() { + XCTAssertNotNil(SyncActivityCopy.syncing(chunks: 3, pagesBehind: 120).detail) + XCTAssertNil(SyncActivityCopy.syncing(chunks: 3, pagesBehind: 0).detail) + } +} diff --git a/StrandTests/TestBundleAssemblerTests.swift b/StrandTests/TestBundleAssemblerTests.swift index 65f5375bfb..90c97cfe45 100644 --- a/StrandTests/TestBundleAssemblerTests.swift +++ b/StrandTests/TestBundleAssemblerTests.swift @@ -46,6 +46,36 @@ final class TestBundleAssemblerTests: XCTestCase { XCTAssertEqual(raw.data.last, Data(oversized.utf8).last) } + func testCapSnapsTrimmedTailToLineBoundary() { + // A raw byte-count tail can (and in production did) land mid-record: a real export shipped + // oura-cva-ppg.jsonl/oura-real-steps.jsonl/oura-motion.jsonl each with a corrupted first line. + // Every trimmable name is newline-delimited JSONL, so the trimmed tail must always start at a + // clean line boundary. + let small = FileExport.BundleEntry(name: "report.txt", data: Data("small".utf8)) + let lines = (1...2000).map { "{\"n\":\($0),\"pad\":\"\(String(repeating: "x", count: 50))\"}" } + let raw = FileExport.BundleEntry(name: "oura-raw.jsonl", data: Data(lines.joined(separator: "\n").utf8)) + // Force a mid-file trim at a cap unlikely to land exactly on a newline. + let cap = raw.data.count / 2 + let (capped, truncated) = TestBundleAssembler.capEntries([small, raw], capBytes: cap) + XCTAssertTrue(truncated) + let cappedRaw = capped.first { $0.name == "oura-raw.jsonl" }! + let text = String(data: cappedRaw.data, encoding: .utf8)! + XCTAssertFalse(text.isEmpty) + XCTAssertTrue(text.hasPrefix("{"), "trimmed tail must start at a clean line boundary, got: \(text.prefix(30))") + // Every kept line must still be valid JSON (no partial record survived). + for line in text.split(separator: "\n") { + XCTAssertNotNil(try? JSONSerialization.jsonObject(with: Data(line.utf8)), + "corrupted line survived trimming: \(line.prefix(30))") + } + } + + func testTrimToLineBoundaryHandlesNoNewline() { + // A single-line (or already-empty) entry has nothing to snap to - returned unchanged, never worse. + let noNewline = Data("no newline here".utf8) + XCTAssertEqual(TestBundleAssembler.trimToLineBoundary(noNewline), noNewline) + XCTAssertEqual(TestBundleAssembler.trimToLineBoundary(Data()), Data()) + } + func testCapLeavesUndersizedBundleUntouched() { let entries = [FileExport.BundleEntry(name: "report.txt", data: Data("tiny".utf8))] let (capped, truncated) = TestBundleAssembler.capEntries(entries, capBytes: 20 * 1024 * 1024) @@ -56,30 +86,61 @@ final class TestBundleAssemblerTests: XCTestCase { // MARK: - Oura diagnostics attachment (#Test-Centre Oura sidecars) func testNormalizedOuraEntryNameDropsRingId() { - // The three sidecars normalize to id-free names; the ring UUID is gone from the filename. + // All six sidecars normalize to id-free names; the ring UUID is gone from the filename. XCTAssertEqual(TestBundleAssembler.normalizedOuraEntryName( forFile: "oura-raw-5C4C0BF8-2DF6-1B3A-18D0-3DF0B3590148.jsonl"), "oura-raw.jsonl") XCTAssertEqual(TestBundleAssembler.normalizedOuraEntryName( forFile: "oura-ibihr-5C4C0BF8-2DF6-1B3A-18D0-3DF0B3590148.jsonl"), "oura-ibihr.jsonl") XCTAssertEqual(TestBundleAssembler.normalizedOuraEntryName( forFile: "oura-activity-oura-5C4C0BF8.jsonl"), "oura-activity.jsonl") + // #Test-Centre follow-up: cva-ppg/motion/real-steps shipped writers (Strand/BLE/Oura*Dump.swift) + // before the bundler knew their kind — regression coverage for that gap. + XCTAssertEqual(TestBundleAssembler.normalizedOuraEntryName( + forFile: "oura-cva-ppg-oura-2H3B2405003655.jsonl"), "oura-cva-ppg.jsonl") + XCTAssertEqual(TestBundleAssembler.normalizedOuraEntryName( + forFile: "oura-motion-oura-2H3B2405003655.jsonl"), "oura-motion.jsonl") + XCTAssertEqual(TestBundleAssembler.normalizedOuraEntryName( + forFile: "oura-real-steps-oura-2H3B2405003655.jsonl"), "oura-real-steps.jsonl") // Non-sidecar files are ignored. XCTAssertNil(TestBundleAssembler.normalizedOuraEntryName(forFile: "raw-capture.jsonl")) XCTAssertNil(TestBundleAssembler.normalizedOuraEntryName(forFile: "whoop.sqlite")) XCTAssertNil(TestBundleAssembler.normalizedOuraEntryName(forFile: "oura-raw.txt")) } + /// A ROLLED GENERATION must normalize to the same entry name as the live file, so + /// `ouraDiagnosticEntries` can MERGE the generations behind that one name (it used to choose the + /// largest between them, which is why a morning export kept shipping the wrong session). + /// Before this, `hasSuffix(".jsonl")` rejected every `.jsonl.` and a morning export could only ever + /// carry the live file — a 30-second session, while the night sat in a generation nothing looked at + /// (measured 2026-08-10). + func testRolledGenerationsNormalizeToTheSameEntryName() { + for suffix in [".jsonl.1", ".jsonl.2", ".jsonl.8", ".jsonl.12"] { + XCTAssertEqual(TestBundleAssembler.normalizedOuraEntryName( + forFile: "oura-raw-oura-2H3B2405003655" + suffix), "oura-raw.jsonl", suffix) + } + XCTAssertEqual(TestBundleAssembler.normalizedOuraEntryName( + forFile: "oura-cva-ppg-oura-2H3B2405003655.jsonl.3"), "oura-cva-ppg.jsonl") + // A non-numeric or empty tail is NOT a generation — those are someone else's files, not ours. + XCTAssertNil(TestBundleAssembler.normalizedOuraEntryName( + forFile: "oura-raw-oura-2H3B2405003655.jsonl.bak")) + XCTAssertNil(TestBundleAssembler.normalizedOuraEntryName( + forFile: "oura-raw-oura-2H3B2405003655.jsonl.")) + XCTAssertNil(TestBundleAssembler.normalizedOuraEntryName( + forFile: "oura-raw-oura-2H3B2405003655.jsonl.1.gz")) + } + func testNormalizedOuraNamesAreAllTrimmable() { // Every normalized sidecar name must be in the cap's trimmable set, else a big night's dump could // blow the 20 MB cap instead of being tail-trimmed. - for kind in ["raw", "ibihr", "activity"] { + for kind in TestBundleAssembler.ouraSidecarKinds { XCTAssertTrue(TestBundleAssembler.trimmableNames.contains("oura-\(kind).jsonl")) + XCTAssertTrue(TestBundleAssembler.ouraSidecarNames.contains("oura-\(kind).jsonl")) } } func testCapSharesBudgetAcrossOuraAndRawCaptureKeepingTails() { - // report.txt kept whole; raw-capture + oura-ibihr together blow the cap → BOTH trimmed to a - // proportional tail, bundle stays under cap, and each keeps its most-recent byte. + // report.txt kept whole; raw-capture + oura-ibihr together blow the cap → BOTH over their fair + // share, so both are trimmed, the bundle stays under cap, and each keeps its most-recent byte. let small = FileExport.BundleEntry(name: "report.txt", data: Data("small".utf8)) let raw = FileExport.BundleEntry(name: "raw-capture.jsonl", data: Data((String(repeating: "r", count: 30 * 1024 * 1024) + "R").utf8)) @@ -96,8 +157,57 @@ final class TestBundleAssemblerTests: XCTestCase { XCTAssertLessThan(cappedIbi.data.count, ibi.data.count) // trimmed XCTAssertEqual(cappedRaw.data.last, raw.data.last) // newest kept XCTAssertEqual(cappedIbi.data.last, ibi.data.last) - // Bigger stream gets the bigger share (raw was 3× ibi). - XCTAssertGreaterThan(cappedRaw.data.count, cappedIbi.data.count) + // Both were over the fair share, so they land on it — within a byte of each other, NOT split 3:1 + // by size the way the old proportional rule would have. + XCTAssertLessThanOrEqual(abs(cappedRaw.data.count - cappedIbi.data.count), 1) + } + + /// The bug this allocation exists to prevent (measured on `noop-master-iOS-v9.3.1-260809-0716.zip`): + /// proportional-to-size handed 53.9 % of the budget to a bulk sidecar dump and left the raw wire capture + /// — the only sidecar a protocol fact can be re-derived from — with 1.9 %, i.e. 8 min of an 8.4 h night. + /// Max-min fair keeps a modest stream WHOLE and takes the bytes off the stream that is merely bulky. + /// (The bulky neighbour is modelled here as the CVA-PPG dump — the measured case was a SpO2 research + /// sidecar that this branch does not carry; the allocation rule is kind-agnostic.) + func testSmallHighValueSidecarSurvivesABulkyNeighbour() { + let report = FileExport.BundleEntry(name: "report.txt", data: Data("small".utf8)) + // Mirrors the real shape: one 40 MB bulk dump beside a 2 MB wire capture, 20 MB of room. + let bulk = FileExport.BundleEntry(name: "oura-cva-ppg.jsonl", + data: Data(String(repeating: "s\n", count: 20 * 1024 * 1024).utf8)) + let raw = FileExport.BundleEntry(name: "oura-raw.jsonl", + data: Data(String(repeating: "r\n", count: 1024 * 1024).utf8)) + let cap = 20 * 1024 * 1024 + let (capped, truncated) = TestBundleAssembler.capEntries([report, bulk, raw], capBytes: cap) + XCTAssertTrue(truncated) + XCTAssertLessThanOrEqual(capped.reduce(0) { $0 + $1.data.count }, cap) + // The wire capture is under its ~10 MB fair share, so it ships WHOLE — not scaled down to ~9 % of + // the budget the way size-proportional splitting would have left it. + XCTAssertEqual(capped.first { $0.name == "oura-raw.jsonl" }?.data.count, raw.data.count) + // Its surplus rolls forward: the bulk dump gets everything the raw capture did not need. + let cappedBulk = capped.first { $0.name == "oura-cva-ppg.jsonl" }! + XCTAssertLessThan(cappedBulk.data.count, bulk.data.count) + XCTAssertGreaterThan(cappedBulk.data.count, cap - raw.data.count - 1024) + } + + func testFairAllowancesIsWaterFillingAndNeverBreachesBudget() { + // Classic water-filling: 1 fits whole, 10 takes its share of what is left, 30 takes the rest. + let a = TestBundleAssembler.fairAllowances( + sizes: [("big", 30), ("small", 1), ("mid", 10)], budget: 20) + XCTAssertEqual(a["small"], 1) // whole, under any share + XCTAssertEqual(a["mid"], 9) // (20-1)/2 + XCTAssertEqual(a["big"], 10) // the remainder + XCTAssertEqual(a.values.reduce(0, +), 20) // budget fully used + // Input order must not change the answer. + let b = TestBundleAssembler.fairAllowances( + sizes: [("small", 1), ("mid", 10), ("big", 30)], budget: 20) + XCTAssertEqual(a, b) + // Everything fits: nobody is trimmed. + XCTAssertEqual(TestBundleAssembler.fairAllowances(sizes: [("x", 3), ("y", 4)], budget: 100), + ["x": 3, "y": 4]) + // Degenerate budgets stay in range rather than going negative. + XCTAssertEqual(TestBundleAssembler.fairAllowances(sizes: [("x", 5)], budget: 0), ["x": 0]) + XCTAssertEqual(TestBundleAssembler.fairAllowances(sizes: [], budget: 10), [:]) + // A single trimmable stream still gets the WHOLE remainder — the original behaviour, unchanged. + XCTAssertEqual(TestBundleAssembler.fairAllowances(sizes: [("only", 999)], budget: 42), ["only": 42]) } /// The redaction contract for the Oura sidecars (PR review, ryanbr): the ONLY PII in a raw line is the @@ -124,6 +234,38 @@ final class TestBundleAssemblerTests: XCTestCase { XCTAssertTrue(out.contains("\"hex\":\"\(hex)\""), "the raw hex bytes must survive redaction intact") } + /// 2026-09-11: the test above only proves the raw `hex` field survives for a payload that happens to + /// avoid `redactHexDump`'s WHOOP-serial heuristic (its bytes never decode to a 9+ byte run of unbroken + /// alnum ASCII starting with a letter). A real capture's `0x80` live-HR/IBI and `0x43`/`0x61` debug-text + /// channels routinely produce exactly that shape by coincidence or by design (found 2026-09-11 in a + /// user's #2075 attachment: 38 of 3112 `oura-raw.jsonl` lines had bytes silently replaced with `•` — not + /// even valid hex — because they matched the WHOOP-serial shape this heuristic was built to catch on a + /// console hex dump, e.g. "WHOOP 4C1594026"). This pins that `oura-raw.jsonl` is now exempt from that + /// sweep while its `deviceId` is still masked. + func testRawSidecarSurvivesTheWhoopSerialHeuristicFalsePositive() { + let ringId = "5C4C0BF8-2DF6-1B3A-18D0-3DF0B3590148" + // 0x43 op, header-ish non-alnum bytes, then an UNBROKEN 9-byte letter-led alnum run ("SN1234567") - + // exactly what `redactHexDump` masks in a WHOOP console hex dump, coincidentally reachable by any + // Oura frame whose bytes happen to fall in that ASCII range. + let header = "e5a30000" + let payload = Array("SN1234567".utf8).map { String(format: "%02x", $0) }.joined() + let bodyLen = (header.count + payload.count) / 2 + let hex = "43" + String(format: "%02x", bodyLen) + header + payload + let line = "{\"schema\":1,\"deviceId\":\"\(ringId)\",\"utc\":1,\"iso\":\"2026-09-11T00:00:00Z\",\"hex\":\"\(hex)\"}" + let out = String(data: TestBundleAssembler.redactEntries( + [FileExport.BundleEntry(name: "oura-raw.jsonl", data: Data(line.utf8))]).first!.data, encoding: .utf8)! + XCTAssertTrue(out.contains("\"hex\":\"\(hex)\""), + "the debug-text frame must survive byte-for-byte, got: \(out)") + XCTAssertFalse(out.contains(ringId), "the ring UUID must still be scrubbed") + XCTAssertTrue(out.contains("\"deviceId\":\"\""), "the ring id must still be masked to ") + // A sibling non-raw sidecar (decoded fields, no hex blob) is unaffected by this exemption and still + // rides the general `redactPii` sweep — the exemption is scoped to `raw` alone. + let ibiLine = "{\"schema\":1,\"deviceId\":\"\(ringId)\",\"hr\":60}" + let ibiOut = String(data: TestBundleAssembler.redactEntries( + [FileExport.BundleEntry(name: "oura-ibihr.jsonl", data: Data(ibiLine.utf8))]).first!.data, encoding: .utf8)! + XCTAssertFalse(ibiOut.contains(ringId)) + } + /// #572 follow-up: the field-aware `deviceId` mask closes the gap the canonical-only contract left — a /// DASHLESS (or truncated) ring id, which the dash-anchored UUID rule can't match and would otherwise /// leak verbatim, is still masked to ``, and the raw `hex` capture is STILL untouched (the mask @@ -149,4 +291,82 @@ final class TestBundleAssemblerTests: XCTestCase { [FileExport.BundleEntry(name: "report.txt", data: Data(line.utf8))]).first!.data, encoding: .utf8)! XCTAssertTrue(out.contains("\"deviceId\":\"my-whoop\""), "a non-sidecar logical deviceId must be left readable") } + + // MARK: - generation merge (the capture-coverage defect) + + /// END-TO-END over real files on disk, reproducing the layout that broke the export on + /// 2026-09-05: the live file holds the morning drain (small), while a rolled generation holds the + /// previous day's post-reinstall backfill (large). Under the old largest-wins rule the bundle + /// shipped the BACKFILL and the night's own drain never left the device — the shipped + /// `oura-raw.jsonl` spanned `2026-09-04 10:58 → 12:03 UTC` against a night that ended that + /// morning. The merged entry must now contain BOTH, with the morning drain LAST so the cap's + /// keep-the-tail trim can never be the thing that drops it. + func testOuraDiagnosticEntriesMergesGenerationsNewestLast() throws { + let dir = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("oura-gen-merge-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let morning = "{\"utc\":1788627494,\"iso\":\"2026-09-05T07:08:14Z\",\"hex\":\"5c08\"}\n" + let backfill = String(repeating: "{\"utc\":1788519502,\"iso\":\"2026-09-04T10:58:22Z\"}\n", + count: 50) + try morning.write(to: dir.appendingPathComponent("oura-raw-RING1.jsonl"), + atomically: true, encoding: .utf8) + try backfill.write(to: dir.appendingPathComponent("oura-raw-RING1.jsonl.1"), + atomically: true, encoding: .utf8) + + let entries = TestBundleAssembler.ouraDiagnosticEntries(directory: dir) + XCTAssertEqual(entries.map(\.name), ["oura-raw.jsonl"], "one entry, ring id dropped") + let text = try XCTUnwrap(String(data: entries[0].data, encoding: .utf8)) + XCTAssertTrue(text.contains("2026-09-04T10:58:22Z"), "older generation must still be present") + XCTAssertTrue(text.hasSuffix(morning), "the newest capture must be LAST — the cap keeps the tail") + XCTAssertLessThan(try XCTUnwrap(text.range(of: "2026-09-04T10:58:22Z")).lowerBound, + try XCTUnwrap(text.range(of: "2026-09-05T07:08:14Z")).lowerBound, + "chronological order") + } + + /// A generation whose last write did not end in a newline must not splice its final record onto + /// the next generation's first one — that would produce a line parsing as neither. + func testOuraDiagnosticEntriesInsertsANewlineBetweenGenerations() throws { + let dir = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("oura-gen-nl-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + try "{\"a\":1}".write(to: dir.appendingPathComponent("oura-raw-R.jsonl.1"), // NO trailing newline + atomically: true, encoding: .utf8) + try "{\"b\":2}\n".write(to: dir.appendingPathComponent("oura-raw-R.jsonl"), + atomically: true, encoding: .utf8) + + let entries = TestBundleAssembler.ouraDiagnosticEntries(directory: dir) + let text = try XCTUnwrap(String(data: entries[0].data, encoding: .utf8)) + XCTAssertEqual(text, "{\"a\":1}\n{\"b\":2}\n") + XCTAssertEqual(text.split(separator: "\n").count, 2, "two whole lines, nothing spliced") + } + + /// Every merged line must survive the redact + cap pass the bundle actually applies, and the + /// newest record must be the one that survives a cap far smaller than the merged size. + func testMergedSidecarKeepsTheNewestRecordsUnderTheCap() throws { + let dir = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("oura-gen-cap-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + try String(repeating: "{\"old\":1}\n", count: 4000) + .write(to: dir.appendingPathComponent("oura-raw-R.jsonl.1"), atomically: true, encoding: .utf8) + try String(repeating: "{\"new\":1}\n", count: 100) + .write(to: dir.appendingPathComponent("oura-raw-R.jsonl"), atomically: true, encoding: .utf8) + + let merged = TestBundleAssembler.ouraDiagnosticEntries(directory: dir) + let (capped, truncated) = TestBundleAssembler.capEntries(merged, capBytes: 2_000) + XCTAssertTrue(truncated) + let text = try XCTUnwrap(String(data: capped[0].data, encoding: .utf8)) + XCTAssertTrue(text.contains("{\"new\":1}"), "the newest records must survive the cap") + // Every kept line must be whole — the front trim snaps to a line boundary, so the partial + // record the raw byte-count tail would otherwise start on is dropped. + for line in text.split(separator: "\n") { + XCTAssertTrue(line.hasPrefix("{") && line.hasSuffix("}"), "no partial line: \(line)") + } + } + } 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/TestReportFlowTests.swift b/StrandTests/TestReportFlowTests.swift index a004202c63..f8a3d5d544 100644 --- a/StrandTests/TestReportFlowTests.swift +++ b/StrandTests/TestReportFlowTests.swift @@ -18,9 +18,13 @@ final class TestReportFlowTests: XCTestCase { func testAttachToastNamesTheSavedFile() { let toast = TestReportFlow.Plan.attachToast(savedName: "noop-sleep-ios-v7.3.0-260626-0712.zip") XCTAssertTrue(toast.contains("noop-sleep-ios-v7.3.0-260626-0712.zip")) - // The instruction to tap the paperclip on the next screen (no em-dash anywhere). - XCTAssertTrue(toast.contains("tap")) + XCTAssertTrue(toast.contains("Attach")) XCTAssertFalse(toast.contains("\u{2014}")) // hard rule: no em-dash + // The flow no longer opens a GitHub issue composer, so the toast must not walk the user through + // one. It used to say "On the next screen tap the paperclip and pick it", describing a screen + // that will never appear now. + XCTAssertFalse(toast.contains("paperclip")) + XCTAssertFalse(toast.contains("next screen")) } func testCopyFallbackOfferedOnMobileOnly() { diff --git a/StrandTests/TestReportLinkTests.swift b/StrandTests/TestReportLinkTests.swift deleted file mode 100644 index fcb2ecd4f5..0000000000 --- a/StrandTests/TestReportLinkTests.swift +++ /dev/null @@ -1,113 +0,0 @@ -import XCTest -@testable import Strand -import StrandAnalytics - -/// Locks the prefilled new-issue URL (spec section 5.2): it must bind bug_report.yml's existing -/// id fields (version/platform/os_version/test_profile/title) and self-apply the "bug,test:" -/// labels, with every component percent-encoded. The repo is ryanbr/noop (bug_report.yml line 10). -final class TestReportLinkTests: XCTestCase { - - func testSleepProfileURLEncodesEveryFieldAndLabel() { - let url = TestReportLink.reportURL( - profile: .sleep, title: "no score last night", - version: "7.3.0", platform: "iOS", osVersion: "18.5") - XCTAssertNotNil(url) - let s = url!.absoluteString - XCTAssertTrue(s.hasPrefix("https://github.com/ryanbr/noop/issues/new?")) - XCTAssertTrue(s.contains("template=bug_report.yml")) - // Label component: "bug,test:sleep" with the comma percent-encoded. - XCTAssertTrue(s.contains("labels=bug%2Ctest:sleep")) - XCTAssertTrue(s.contains("version=7.3.0")) - XCTAssertTrue(s.contains("platform=iOS")) - XCTAssertTrue(s.contains("os_version=18.5")) - XCTAssertTrue(s.contains("test_profile=sleep")) - // Title is "[sleep] no score last night", brackets and spaces percent-encoded. - XCTAssertTrue(s.contains("title=%5Bsleep%5D%20no%20score%20last%20night")) - } - - func testDataImportProfileUsesImportWireIdNotRawValue() { - // The dataImport case maps to the wire id "import" (TestDomain contract); the URL must - // carry test_profile=import and labels=bug,test:import, never "dataImport". - let url = TestReportLink.reportURL( - profile: .dataImport, title: "x", version: "7.3.0", platform: "Android", osVersion: "15") - let s = url!.absoluteString - XCTAssertTrue(s.contains("test_profile=import")) - XCTAssertTrue(s.contains("labels=bug%2Ctest:import")) - } - - func testMasterProfileLabelIsTestAll() { - let url = TestReportLink.reportURL( - profile: .master, title: "x", version: "7.3.0", platform: "macOS", osVersion: "14.5") - XCTAssertTrue(url!.absoluteString.contains("labels=bug%2Ctest:all")) - } - - // MARK: - CAPTURE-A (#812): prefill log + what_happens so a no-attachment report isn't empty - - func testNoReportTextOmitsLogAndWhatHappensParams() { - // The legacy 5-field call (no reportText / seed) must compose the SAME URL as before , no log=, - // no what_happens= , so existing callers are unchanged. - let url = TestReportLink.reportURL( - profile: .sleep, title: "x", version: "7.3.0", platform: "iOS", osVersion: "18.5") - let s = url!.absoluteString - XCTAssertFalse(s.contains("&log=")) - XCTAssertFalse(s.contains("what_happens=")) - } - - func testReportTextPrefillsLogFieldWithDetailsTail() { - // A report.txt tail is wrapped in a
block and bound to the form's `log` id. - let report = (1...200).map { "line \($0)" }.joined(separator: "\n") - let url = TestReportLink.reportURL( - profile: .sleep, title: "no score", version: "7.3.0", platform: "iOS", osVersion: "18.5", - reportText: report, whatHappensSeed: "Saw no recovery score this morning") - let s = url!.absoluteString - // The field id is exactly `log` (bug_report.yml id: log) and `what_happens` (id: what_happens). - XCTAssertTrue(s.contains("&log=")) - XCTAssertTrue(s.contains("what_happens=")) - // The
wrapper and a recent line are present (percent-encoded). The tail is the LAST - // logTailLines, so the newest line is in and line 1 is out. - let decoded = url!.absoluteString.removingPercentEncoding ?? "" - XCTAssertTrue(decoded.contains("
")) - XCTAssertTrue(decoded.contains("line 200")) - XCTAssertFalse(decoded.contains("line 1\n")) // line 1 trimmed off the log tail - XCTAssertTrue(decoded.contains("Saw no recovery score this morning")) - // No em-dash leaks into the composed URL (hard rule). - XCTAssertFalse(decoded.contains("\u{2014}")) - } - - func testOversizedLogIsDroppedNotTruncated() { - // M2 (#812): a tail so long the URL would breach maxURLLength must DROP the log param entirely - // (never a truncated
), while keeping the short id fields + what_happens seed so the - // submitted body is still non-empty. The full trace travels in the attached .zip, not the URL. - let huge = (1...TestReportLink.logTailLines).map { _ in String(repeating: "x", count: 500) } - .joined(separator: "\n") - let url = TestReportLink.reportURL( - profile: .sleep, title: "x", version: "7.3.0", platform: "iOS", osVersion: "18.5", - reportText: huge, whatHappensSeed: "it broke") - let s = url!.absoluteString - XCTAssertFalse(s.contains("&log=")) // dropped, not truncated - XCTAssertTrue(s.contains("what_happens=")) // seed kept, body still non-empty - XCTAssertLessThanOrEqual(s.count, TestReportLink.maxURLLength) // under the GitHub prefill ceiling - } - - func testLogDetailsBlockEmptyTextYieldsNil() { - XCTAssertNil(TestReportLink.logDetailsBlock(reportText: "")) - XCTAssertNil(TestReportLink.logDetailsBlock(reportText: "\n\n \n")) - // A blank report contributes NO log param (the URL still forms from the required fields). - let url = TestReportLink.reportURL( - profile: .sleep, title: "x", version: "7.3.0", platform: "iOS", osVersion: "18.5", - reportText: " \n ") - XCTAssertFalse(url!.absoluteString.contains("&log=")) - } - - func testWhatHappensSeedJoinsAnsweredPromptsInOrder() { - let qs = [ - Question(id: "q1", prompt: "Which screen", kind: .text), - Question(id: "q2", prompt: "Did it recover", kind: .yesNo), - Question(id: "q3", prompt: "Unanswered", kind: .text), - ] - let seed = TestReportLink.whatHappensSeed( - questionnaire: qs, answers: ["q1": "Sleep tab", "q2": "No", "q3": " "]) - XCTAssertEqual(seed, "Which screen: Sleep tab\nDid it recover: No") // q3 blank → skipped, order kept - XCTAssertNil(TestReportLink.whatHappensSeed(questionnaire: qs, answers: [:])) - } -} diff --git a/StrandTests/TimeoutSyncErrorTests.swift b/StrandTests/TimeoutSyncErrorTests.swift index 78c417306a..f06490cf55 100644 --- a/StrandTests/TimeoutSyncErrorTests.swift +++ b/StrandTests/TimeoutSyncErrorTests.swift @@ -49,4 +49,41 @@ final class TimeoutSyncErrorTests: XCTestCase { XCTAssertEqual(BLEManager.timeoutSyncError(futureClockBanner: "clock is ahead", bankedThisOffload: true), "clock is ahead") } + + // MARK: - sessionEndedOutcome (#2387) + + /// The banner above is right and always was; the LOG line was the one that read as a failure either + /// way. A reporter read six productive timeouts as six interrupted syncs, and so did the maintainer + /// reviewing their log. The rows were on the next line the whole time. + func testATimeoutThatBankedRowsSaysItDrained() { + XCTAssertEqual(BLEManager.sessionEndedOutcome(reason: "timeout", bankedRows: true), + " outcome=drained") + } + + /// The stall case, the one the banner also warns about. + func testATimeoutThatBankedNothingSaysSo() { + XCTAssertEqual(BLEManager.sessionEndedOutcome(reason: "timeout", bankedRows: false), + " outcome=nothing-banked") + } + + /// Every other ending is byte-identical to before: HISTORY_COMPLETE already says it completed, and a + /// disconnect is not a claim about what landed. + func testOtherReasonsAreUnchanged() { + for reason in ["HISTORY_COMPLETE", "disconnected", "aborted by user"] { + XCTAssertEqual(BLEManager.sessionEndedOutcome(reason: reason, bankedRows: true), "") + XCTAssertEqual(BLEManager.sessionEndedOutcome(reason: reason, bankedRows: false), "") + } + } + + /// Deliberately ROWS, where the banner asks `offloadBankedAnything` (chunks OR rows OR deep packets). + /// A session that acked chunks but persisted nothing is the #77/#120/#214 empty-offload shape: the + /// banner stays silent because progress was made, while the log says nothing-banked because nothing + /// landed. Both are honest about their own question; pinned so the difference is not read as a bug. + func testOutcomeAsksAboutRowsNotProgress() { + XCTAssertEqual(BLEManager.sessionEndedOutcome(reason: "timeout", bankedRows: false), + " outcome=nothing-banked") + XCTAssertNil(BLEManager.timeoutSyncError( + futureClockBanner: nil, + bankedThisOffload: BLEManager.offloadBankedAnything(chunks: 3, rows: 0, deepPackets: 0))) + } } diff --git a/StrandTests/TodayDayNavClampTests.swift b/StrandTests/TodayDayNavClampTests.swift index 61270e8cc8..e6f3d80e48 100644 --- a/StrandTests/TodayDayNavClampTests.swift +++ b/StrandTests/TodayDayNavClampTests.swift @@ -93,4 +93,30 @@ final class TodayDayNavClampTests: XCTestCase { let picked = cal.date(from: DateComponents(year: 2026, month: 6, day: 24))! XCTAssertEqual(TodayView.pickedDayOffset(pickedDate: picked, anchorLogicalDay: logical), 4) } + + // MARK: - daySwipeDelta (#2378) + + /// A rightward drag reveals the past, so it steps OLDER (+1). Twin of the Kotlin + /// `DayNavTest.rightwardSwipeGoesOlder`, which pins the same direction for `dayNavSwipeTarget`. + func testRightwardSwipeStepsOlder() { + XCTAssertEqual(TodayView.daySwipeDelta(dx: 120), 1) + XCTAssertEqual(TodayView.daySwipeDelta(dx: 51), 1) + } + + /// A leftward drag brings the later day in from the right, so it steps NEWER (-1). Twin of the + /// Kotlin `DayNavTest.leftwardSwipeGoesNewerClampedAtToday` (the clamp itself is covered above). + func testLeftwardSwipeStepsNewer() { + XCTAssertEqual(TodayView.daySwipeDelta(dx: -120), -1) + XCTAssertEqual(TodayView.daySwipeDelta(dx: -51), -1) + } + + /// Composed with the clamp: from today a leftward swipe cannot reach a future day, and a rightward + /// one reaches yesterday — the two halves the gesture closures actually chain. + func testSwipeDeltaComposedWithClamp() { + let older = TodayView.daySwipeDelta(dx: 200) + XCTAssertEqual(TodayView.clampedDayOffset(current: 0, delta: older, maxOffset: 30), 1) + let newer = TodayView.daySwipeDelta(dx: -200) + XCTAssertEqual(TodayView.clampedDayOffset(current: 0, delta: newer, maxOffset: 30), 0) + XCTAssertEqual(TodayView.clampedDayOffset(current: 5, delta: newer, maxOffset: 30), 4) + } } 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/TrainingLoadCardModelKeyTests.swift b/StrandTests/TrainingLoadCardModelKeyTests.swift new file mode 100644 index 0000000000..5d7d2bff60 --- /dev/null +++ b/StrandTests/TrainingLoadCardModelKeyTests.swift @@ -0,0 +1,72 @@ +import XCTest +import StrandAnalytics +import WhoopStore +@testable import Strand + +/// PERF (chart-invalidation-and-tooltips): `TrainingLoadCard.result` used to re-run +/// `TrainingLoadEngine.evaluate` (a full-history EWMA scan) on every access, and TWICE per render. It's +/// now memoized behind `modelKey(for:)` + `@State` cache, mirroring `CompareView`'s `modelKey`/ +/// `currentModel` idiom. `days` is `Repository.days` — a live-updating array whose LAST entry keeps +/// accumulating strain through the day — so unlike `CompareView.modelKey`'s count+endpoints idiom, this +/// key must change when a value inside the trailing window changes, not just when the day range/count +/// does. These pin that contract directly, without rendering the chart. +final class TrainingLoadCardModelKeyTests: XCTestCase { + private func metric(day: String, strain: Double?) -> DailyMetric { + DailyMetric(day: day, totalSleepMin: nil, efficiency: nil, deepMin: nil, remMin: nil, + lightMin: nil, disturbances: nil, restingHr: nil, avgHrv: nil, recovery: nil, + strain: strain, exerciseCount: nil) + } + + private func days(_ n: Int, strain: (Int) -> Double?) -> [DailyMetric] { + (0.. TrendsReportPage { + TrendsReportPage( + report: RangeReport(start: "2026-08-01", end: "2026-08-25", totalDays: 25, + metrics: [], headlines: []), + range: .days30, series: [:], generatedOn: "Aug 26, 2026", units: units) + } + + func testSkinTempRowCarriesTheChosenUnit() { + // The reporter's Aug 14: 0.52 °C stored. The app showed Δ°F; the PDF showed °C. + XCTAssertEqual(page(celsius).valueText(0.52, .skinTempDev), "+0.5 Δ°C") + XCTAssertEqual(page(fahrenheit).valueText(0.52, .skinTempDev), "+0.9 Δ°F") + // Their Aug 19 minimum. + XCTAssertEqual(page(celsius).valueText(-0.5, .skinTempDev), "-0.5 Δ°C") + XCTAssertEqual(page(fahrenheit).valueText(-0.5, .skinTempDev), "-0.9 Δ°F") + } + + func testOnlyPositiveDeviationsGetAnExplicitSign() { + XCTAssertEqual(page(celsius).valueText(0.0, .skinTempDev), "0.0 Δ°C") + XCTAssertEqual(page(celsius).valueText(0.1, .skinTempDev), "+0.1 Δ°C") + XCTAssertEqual(page(celsius).valueText(-0.1, .skinTempDev), "-0.1 Δ°C") + } + + func testEffortRowFollowsTheChosenAxis() { + // Native 0–100: a whole number, no unit — unchanged from before the fix. + XCTAssertEqual(page(celsius).valueText(59.0, .strain), "59") + // WHOOP 0–21: rescaled, one decimal, and the denominator named. + XCTAssertEqual(page(whoopAxis).valueText(59.0, .strain), "12.4 / 21") + XCTAssertEqual(page(whoopAxis).valueText(100.0, .strain), "21.0 / 21") + } + + func testRowsWithNoDisplayPreferenceAreIdenticalUnderEverySetting() { + let cases: [(ReportMetric, String)] = [ + (.hrv, "58 ms"), (.restingHr, "58 bpm"), (.recovery, "58"), + (.respRate, "58.0 br/min"), (.sleepHours, "58.0 h"), + (.workouts, "58.0 /day"), (.stress, "58.0"), + ] + for (metric, expected) in cases { + for units in [celsius, fahrenheit, whoopAxis] { + XCTAssertEqual(page(units).valueText(58.0, metric), expected, + "\(metric) moved under a display toggle") + } + } + } + + /// Half away from zero, the rule the engine and the Android page share. Android rounded ties + /// toward +∞ until #1637; these are the values that disagreed. + func testNegativeTiesRoundAwayFromZero() { + let p = page(celsius) + XCTAssertEqual(p.round1Text(-0.25), "-0.3") + XCTAssertEqual(p.round1Text(-0.35), "-0.4") + XCTAssertEqual(p.round1Text(-0.45), "-0.5") + XCTAssertEqual(p.round1Text(-0.15), "-0.2") + XCTAssertEqual(p.round1Text(-0.05), "-0.1") + XCTAssertEqual(p.round1Text(-0.75), "-0.8") + } + + func testPositiveTiesAndOrdinaryValuesAreUnchanged() { + let p = page(celsius) + XCTAssertEqual(p.round1Text(0.25), "0.3") + XCTAssertEqual(p.round1Text(0.35), "0.4") + XCTAssertEqual(p.round1Text(0.75), "0.8") + XCTAssertEqual(p.round1Text(0.9359999999999999), "0.9") + XCTAssertEqual(p.round1Text(12.389999999999999), "12.4") + XCTAssertEqual(p.round1Text(0.0), "0.0") + } + + /// A negative tie must reach the SKIN TEMP row, not just the formatter. + func testANegativeTieRendersIdenticallyToKotlinInTheRowItself() { + XCTAssertEqual(page(celsius).valueText(-0.25, .skinTempDev), "-0.3 Δ°C") + } +} diff --git a/StrandTests/UnescalatedWorkTests.swift b/StrandTests/UnescalatedWorkTests.swift new file mode 100644 index 0000000000..040d4cfc4e --- /dev/null +++ b/StrandTests/UnescalatedWorkTests.swift @@ -0,0 +1,48 @@ +import XCTest +@testable import Strand + +/// The analysis pass and the unprompted stress rescore are meant to yield to the UI. They said +/// `.utility` and did not: `await Task.detached(priority: .utility) { … }.value` from a main-actor +/// caller records a dependency, and the runtime raises the awaited task to the waiter's priority so a +/// high-priority waiter cannot be starved by what it waits on. The hop off the main actor was real the +/// whole time, so nothing blocked; the work simply competed with the UI instead of giving way to it. +/// +/// These pin the difference rather than the fix's shape, so a future rewrite is free as long as the +/// unprompted work still yields. +final class UnescalatedWorkTests: XCTestCase { + + /// The property the callers actually need: the body runs at the priority it asked for, even though a + /// main-actor caller is waiting on it. + @MainActor + func testUnescalatedWorkKeepsItsDeclaredPriority() async { + let inside = await runUnescalated(priority: .utility) { Task.currentPriority } + XCTAssertEqual(inside, .utility, + "unprompted work must keep the priority it declared, not the waiter's") + } + + /// The control, and the reason the helper exists. If this ever stops escalating, the runtime changed + /// and the helper can go — so this failing is informative rather than a nuisance. + @MainActor + func testAwaitingADetachedTaskEscalatesItInstead() async { + let inside = await Task.detached(priority: .utility) { Task.currentPriority }.value + XCTAssertNotEqual(inside, .utility, + "a plain awaited detached task is expected to be escalated to the waiter's priority") + } + + /// A suspension point inside the body must not hand the priority back. + @MainActor + func testPriorityHoldsAcrossASuspensionInsideTheBody() async { + let inside = await runUnescalated(priority: .utility) { () async -> TaskPriority in + try? await Task.sleep(nanoseconds: 1_000_000) + return Task.currentPriority + } + XCTAssertEqual(inside, .utility) + } + + /// The value comes back intact; the helper is a priority fix, not a behaviour change. + @MainActor + func testTheResultIsReturnedUnchanged() async { + let sum = await runUnescalated { (1...10).reduce(0, +) } + XCTAssertEqual(sum, 55) + } +} 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.. [UInt8] { + let chars = Array(hex) + return stride(from: 0, to: chars.count, by: 2).map { UInt8(String(chars[$0...$0+1]), radix: 16)! } + } + for pair in fixture.cases { + let standardBytes = bytes(pair.standard_hex) + // This fixture fixes flags at 0x10 and an 8-bit HR; R-R words start at byte 2. + XCTAssertEqual(standardBytes[0], 0x10) + let rawTicks = stride(from: 2, to: standardBytes.count, by: 2).map { + Int(standardBytes[$0]) | (Int(standardBytes[$0 + 1]) << 8) + } + XCTAssertEqual(rawTicks.count, pair.rr_count, pair.name) + XCTAssertGreaterThan(pair.rr_count, 1, "compare complete multi-beat arrays") + let native = parseFrame(bytes(pair.native_hex), family: .whoop5) + XCTAssertTrue(native.ok, pair.name) + XCTAssertEqual(native.crcOK, true, pair.name) + XCTAssertEqual(native.parsed["rr_raw_ticks"]?.intArrayValue, rawTicks, pair.name) + // WHOOP 5 words are already milliseconds (identity); both channels carry the same raw words. + XCTAssertEqual(native.parsed["rr_intervals"]?.intArrayValue, rawTicks, pair.name) + // The standard BLE parser still applies 1/1024 conversion — wrong for this strap, but + // confirms transport parity (same raw words, different conversion gives different ms). + let standard = try XCTUnwrap(StandardHeartRate.parse(standardBytes)) + XCTAssertNotEqual(standard.rr, rawTicks, "standard parser converts; WHOOP 5 words are already ms") + XCTAssertEqual(standard.rrRawTicks, rawTicks, "raw ticks match the native channel's raw words") + let streams = pair.kind == "v18" + ? extractHistoricalStreams([native], deviceClockRef: 0, wallClockRef: 0) + : extractStreams([native], deviceClockRef: 0, wallClockRef: 0) + XCTAssertEqual(streams.rr.map(\.rrMs), rawTicks, pair.name) + } + } + +} diff --git a/StrandTests/WidgetTelemetryTests.swift b/StrandTests/WidgetTelemetryTests.swift new file mode 100644 index 0000000000..0b7eb0e1f2 --- /dev/null +++ b/StrandTests/WidgetTelemetryTests.swift @@ -0,0 +1,126 @@ +import XCTest + +/// Pins the widget cost counters. Twin of Android's `WidgetTelemetryTest`, minus the bitmap half, +/// which has no analogue here. +/// +/// The Android side has had three defects in these counters and none of them were arithmetic: each +/// time a counter's NAME claimed more than its wiring delivered. So the cases below are written +/// against the questions the numbers are supposed to answer rather than against the sums. +final class WidgetTelemetryTests: XCTestCase { + + private let t0 = Date(timeIntervalSince1970: 1_700_000_000) + + override func setUp() { + super.setUp() + WidgetTelemetry.resetForTest() + } + + /// The throttle is the whole reason a live-HR stream does not become a publish per tick, so the + /// offered count has to include what it dropped. Reporting only what got through would make a + /// broken throttle look like a quiet one. + func testOfferedIncludesWhatTheThrottleDropped() { + WidgetTelemetry.noteAdmitted(now: t0) + for i in 0..<59 { WidgetTelemetry.noteGated(now: t0.addingTimeInterval(Double(i))) } + let s = WidgetTelemetry.snapshot(now: t0.addingTimeInterval(60)) + XCTAssertEqual(s.admitted, 1) + XCTAssertEqual(s.gated, 59) + XCTAssertEqual(s.offered, 60) + XCTAssertTrue(s.render().contains("0 reloaded / 1 admitted / 60 offered"), s.render()) + } + + /// Reloads, not publishes. A publish that declined the reload cost nothing and spent none of + /// WidgetKit's budget, so counting it here would make the figure unable to fall when the dedup in + /// `saveAndReloadIfChanged` did its job — the one thing that dedup is for. + func testTheRateCountsReloadsNotPublishes() { + WidgetTelemetry.noteAdmitted(now: t0) + for m in 1...10 { + let at = t0.addingTimeInterval(Double(m) * 60) + WidgetTelemetry.noteAdmitted(now: at) + if m > 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/StrandTests/WorkoutCatalogTests.swift b/StrandTests/WorkoutCatalogTests.swift index daebc3655f..9db6cd57d9 100644 --- a/StrandTests/WorkoutCatalogTests.swift +++ b/StrandTests/WorkoutCatalogTests.swift @@ -8,6 +8,23 @@ import StrandDesign /// WorkoutSportTest intent for the same two sports. final class WorkoutCatalogTests: XCTestCase { + /// Asked for by a user. The Swift catalogue carries no Health Connect type, so what is pinned here + /// is the label: it is the stored, cross-platform value and must read identically on Android. + func testNordicWalkingIsOffered() { + XCTAssertTrue(WorkoutCatalog.all.contains { $0.name == "Nordic walking" }) + } + + /// The WHOOP-parity batch. Labels only on this side, but they are the stored cross-platform value, + /// so a rename here silently splits one sport into two across the two apps. + func testWhoopParitySportsAreOffered() { + let names = Set(WorkoutCatalog.all.map(\.name)) + for expected in ["Ballet", "Billiards", "Breakdancing", "Cheerleading", "Darts", "Disc golf", + "Hurling/Camogie", "Jiu jitsu", "Judo", "Kiteboarding", "Motocross", + "Muay Thai", "Paintball", "Parkour", "Polo", "Skydiving"] { + XCTAssertTrue(names.contains(expected), "missing \(expected)") + } + } + func testTreadmillWalkPresetExistsWithGpsOff() { let s = WorkoutCatalog.sport(named: "Treadmill walk") XCTAssertNotNil(s, "Treadmill walk must be in the suggestion catalogue (#714)") diff --git a/StrandTests/WorkoutNamespaceTests.swift b/StrandTests/WorkoutNamespaceTests.swift new file mode 100644 index 0000000000..1ae1a077fd --- /dev/null +++ b/StrandTests/WorkoutNamespaceTests.swift @@ -0,0 +1,76 @@ +import XCTest +@testable import Strand + +/// The Workouts list and the Workouts delete must agree about where a row lives (#2278). +/// +/// Why this exists: the list unioned many device namespaces while the delete touched exactly one, the +/// active strap. A row banked anywhere else was visible but undeletable, because the delete reported +/// nothing and the reload re-read the row from a namespace the delete never went near. Both sides now +/// derive from `workoutNamespaces`, and these tests pin what that list must contain. +final class WorkoutNamespaceTests: XCTestCase { + + func testEveryRawIdAndItsComputedSiblingAreIncluded() { + let ids = Repository.workoutNamespaces(rawIds: ["strap-a", "my-whoop"]) + XCTAssertTrue(ids.contains("strap-a")) + XCTAssertTrue(ids.contains("my-whoop")) + XCTAssertTrue(ids.contains("strap-a-noop"), "the computed sibling holds detected bouts") + XCTAssertTrue(ids.contains("my-whoop-noop")) + } + + func testAnIdThatIsAlreadyComputedIsNotDoubleSuffixed() { + let ids = Repository.workoutNamespaces(rawIds: ["my-whoop-noop"]) + XCTAssertTrue(ids.contains("my-whoop-noop")) + XCTAssertFalse(ids.contains("my-whoop-noop-noop"), "suffixing must be idempotent") + } + + func testTheReadIncludesImportNamespaces() { + // A workout imported from Apple Health, Hevy/Liftosaur or a FIT/GPX/TCX file is SHOWN by the list. + let ids = Repository.workoutNamespaces(rawIds: ["strap-a"]) + XCTAssertTrue(ids.contains("apple-health")) + XCTAssertTrue(ids.contains("lifting")) + XCTAssertTrue(ids.contains("activity-file")) + } + + func testDeleteNeverReachesImportNamespaces() { + // Imported history is read-only, enforced in the row menu (imported rows are offered only + // "Duplicate as manual…"), in bulkDeleteWorkouts and in mergeWorkouts ("never rewrite imported + // history"). A delete sweeping the import namespaces would reach underneath all three and destroy + // a row nothing in the UI ever offers to remove, so the deletable set must stay strap-only. + let ids = Repository.deletableWorkoutNamespaces(rawIds: ["strap-a"]) + XCTAssertFalse(ids.contains("apple-health"), "imported Apple Health history must survive a delete") + XCTAssertFalse(ids.contains("lifting"), "imported Hevy / Liftosaur history must survive a delete") + XCTAssertFalse(ids.contains("activity-file"), "imported FIT / GPX / TCX history must survive") + } + + func testDeleteStillCoversEveryStrapNamespace() { + // The actual bug: a manual row under a retained strap or a computed sibling was undeletable. + let ids = Repository.deletableWorkoutNamespaces(rawIds: ["active", "retained"]) + XCTAssertEqual(ids, ["active", "retained", "active-noop", "retained-noop"]) + } + + func testTheDeletableSetIsASubsetOfWhatTheListReads() { + // If a delete could target a namespace the list never reads, it would be deleting something the + // wearer cannot see. Pin the containment rather than the two lists separately. + let raw = ["active", "retained", "my-whoop-noop"] + let readable = Set(Repository.workoutNamespaces(rawIds: raw)) + for id in Repository.deletableWorkoutNamespaces(rawIds: raw) { + XCTAssertTrue(readable.contains(id), "\(id) is deletable but never read") + } + } + + func testNoDuplicatesAndReadOrderIsStable() { + // Duplicates would make the delete issue the same statement twice and the read return the same row + // twice, which the natural-key dedup would then have to clean up. + let ids = Repository.deletableWorkoutNamespaces(rawIds: ["a", "a", "b"]) + XCTAssertEqual(ids.count, Set(ids).count, "duplicates must collapse") + XCTAssertEqual(ids.firstIndex(of: "a"), 0, "the active id stays first, preserving read order") + } + + func testTheActiveStrapAloneIsNotEnough() { + // The regression in one line: the old delete used only the active id. If that were still the whole + // namespace set, every import and every retained strap would remain undeletable. + let ids = Repository.workoutNamespaces(rawIds: ["active"]) + XCTAssertGreaterThan(ids.count, 1, "delete must reach more than the active strap") + XCTAssertTrue(ids.contains("active")) + } +} diff --git a/StrandTests/WorkoutSampleOncePerSecondTests.swift b/StrandTests/WorkoutSampleOncePerSecondTests.swift new file mode 100644 index 0000000000..92be51c84e --- /dev/null +++ b/StrandTests/WorkoutSampleOncePerSecondTests.swift @@ -0,0 +1,55 @@ +import XCTest +import StrandAnalytics +import WhoopProtocol +@testable import Strand + +/// A manual workout records one heart-rate sample a second. The live heart rate reaches it once per R-R packet and +/// again whenever the rate moves, and Effort credits a repeated second as another second of effort. +final class WorkoutSampleOncePerSecondTests: XCTestCase { + + func testASecondThatAlreadyHasASampleTakesNoOther() { + var workout = AppModel.ActiveWorkout(start: Date(timeIntervalSince1970: 1_000)) + XCTAssertTrue(workout.recordSample(HRSample(ts: 1_000, bpm: 120))) + XCTAssertFalse(workout.recordSample(HRSample(ts: 1_000, bpm: 121))) + XCTAssertTrue(workout.recordSample(HRSample(ts: 1_001, bpm: 122))) + XCTAssertEqual(workout.samples.map(\.ts), [1_000, 1_001]) + XCTAssertEqual(workout.samples.map(\.bpm), [120, 122]) + } + + /// The rate moving is often why a second arrives twice, so the refused reading can be that second's high: it is + /// not recorded, but it is the workout's peak, live and saved. + func testARepeatedSecondStillReachesThePeak() { + var workout = AppModel.ActiveWorkout(start: Date(timeIntervalSince1970: 1_000)) + XCTAssertNil(workout.savedPeak) + _ = workout.recordSample(HRSample(ts: 1_000, bpm: 120)) + workout.peakHr = 120 // what captureWorkoutSample does after an accepted sample + XCTAssertFalse(workout.recordSample(HRSample(ts: 1_000, bpm: 131))) + XCTAssertEqual(workout.samples.map(\.bpm), [120]) + XCTAssertEqual(workout.peakHr, 131) + XCTAssertEqual(workout.savedPeak, 131) + XCTAssertFalse(workout.recordSample(HRSample(ts: 1_000, bpm: 125))) // a lower repeat moves nothing + XCTAssertEqual(workout.peakHr, 131) + } + + /// Twenty minutes climbing from 100 to 160 bpm, each second arriving twice (the R-R packet, then the rate + /// moving) as it does during exercise. Recorded through `recordSample`, the workout's Effort is the Effort of + /// the plain once-a-second stream; the repeats, kept, scored higher. + func testRepeatedSecondsNoLongerAddEffort() { + let seconds = 20 * 60 + let clean = (0.. Double { + StrainScorer.strain(samples, maxHR: 190, restingHR: 60) ?? 0 + } + XCTAssertEqual(workout.samples.map(\.ts), clean.map(\.ts)) + XCTAssertEqual(effort(workout.samples), effort(clean)) + XCTAssertGreaterThan(effort(everyArrival), effort(clean)) + } +} diff --git a/StrandTests/WorkoutScopeTests.swift b/StrandTests/WorkoutScopeTests.swift new file mode 100644 index 0000000000..16c7fbaa40 --- /dev/null +++ b/StrandTests/WorkoutScopeTests.swift @@ -0,0 +1,99 @@ +import XCTest +import WhoopStore +@testable import Strand + +/// Current / Archived is a VIEW split, and the sub-minute discard is a save-time gate. +/// +/// Why these are tested together: they are the two halves of one request, "keep the last 10 workouts then +/// auto delete". Auto-deleting real training history would be irreversible on a device with no server and +/// no cloud copy, so the split hides rather than removes, and the only thing actually discarded is a +/// session too short to contain training data. +final class WorkoutScopeTests: XCTestCase { + + private func row(_ startTs: Int, sport: String = "Running") -> WorkoutRow { + WorkoutRow(startTs: startTs, endTs: startTs + 600, sport: sport, source: "manual", + durationS: 600, energyKcal: nil, avgHr: nil, maxHr: nil, strain: nil, + distanceM: nil, zonesJSON: nil, notes: nil, steps: nil) + } + + // MARK: - The split + + func testFewerThanTheLimitPutsEverythingInCurrentAndNothingInArchived() { + let rows = (0..<4).map { row(1_700_000_000 + $0 * 3600) } + XCTAssertEqual(WorkoutsView.scopedRows(rows, scope: .current, currentCount: 10).count, 4) + XCTAssertTrue(WorkoutsView.scopedRows(rows, scope: .archived, currentCount: 10).isEmpty) + } + + func testCurrentHoldsTheMostRecentAndArchivedHoldsTheRest() { + let rows = (0..<25).map { row(1_700_000_000 + $0 * 3600) } // ascending + let current = WorkoutsView.scopedRows(rows, scope: .current, currentCount: 10) + let archived = WorkoutsView.scopedRows(rows, scope: .archived, currentCount: 10) + XCTAssertEqual(current.count, 10) + XCTAssertEqual(archived.count, 15) + let newestCurrent = current.map(\.startTs).min() ?? 0 + let newestArchived = archived.map(\.startTs).max() ?? 0 + XCTAssertGreaterThan(newestCurrent, newestArchived, "every Current row is newer than every Archived one") + } + + func testTheTwoScopesPartitionTheInputExactly() { + // Nothing may be lost or duplicated by the split: a row the wearer cannot find in either tab has + // effectively been deleted by the UI, which is the outcome this design exists to avoid. + let rows = (0..<23).map { row(1_700_000_000 + $0 * 3600) } + let combined = WorkoutsView.scopedRows(rows, scope: .current) + + WorkoutsView.scopedRows(rows, scope: .archived) + XCTAssertEqual(combined.count, rows.count) + XCTAssertEqual(Set(combined.map(\.startTs)), Set(rows.map(\.startTs))) + } + + func testTiedStartTimesCannotOverfillCurrent() { + // Ranking, not a cutoff timestamp: twelve sessions that all start in the same second must still + // yield exactly ten in Current. A threshold comparison would hand back all twelve. + let rows = (0..<12).map { row(1_700_000_000, sport: "Sport\($0)") } + XCTAssertEqual(WorkoutsView.scopedRows(rows, scope: .current, currentCount: 10).count, 10) + XCTAssertEqual(WorkoutsView.scopedRows(rows, scope: .archived, currentCount: 10).count, 2) + } + + func testOrderIsPreserved() { + // The caller's sort decides what the screen shows; the split only chooses membership. + let rows = (0..<25).map { row(1_700_000_000 + $0 * 3600) }.sorted { $0.startTs > $1.startTs } + let current = WorkoutsView.scopedRows(rows, scope: .current) + XCTAssertEqual(current.map(\.startTs), current.map(\.startTs).sorted(by: >)) + } + + // MARK: - The discard gate + + func testSessionsUnderAMinuteAreDiscarded() { + XCTAssertTrue(AppModel.isTooShortToSave(elapsedSeconds: 5)) + XCTAssertTrue(AppModel.isTooShortToSave(elapsedSeconds: 30)) + XCTAssertTrue(AppModel.isTooShortToSave(elapsedSeconds: 59.9)) + } + + func testManualEntryHonoursTheSameFloor() { + // The span-shaped builder is the one the Add/Edit sheet uses, and it had no floor: a start and end + // thirty seconds apart made a row the live path would have discarded. The duration-shaped builder + // enforced it only by accident, counting whole minutes. + let start = Date(timeIntervalSince1970: 1_700_000_000) + let now = start.addingTimeInterval(86_400) + func build(_ seconds: TimeInterval) -> WorkoutRow? { + WorkoutSource.buildManualRowFromSpan(start: start, end: start.addingTimeInterval(seconds), + sport: "Running", avgHr: nil, energyKcal: nil, now: now) + } + XCTAssertNil(build(30), "a 30-second manual entry is refused") + XCTAssertNil(build(59), "just under the floor is refused") + XCTAssertNotNil(build(60), "exactly a minute is kept, matching the live-session floor") + XCTAssertNotNil(build(3600), "an ordinary session is unaffected") + } + + func testTheTwoFloorsAgree() { + // One rule, whether a session was tracked or typed in. If these ever diverge, the same workout + // would be accepted by one door and refused by the other. + XCTAssertEqual(Double(WorkoutSource.minManualSpanSeconds), AppModel.minimumWorkoutSeconds) + } + + func testExactlyAMinuteIsKept() { + // A deliberate one-minute effort is training. The gate is for what falls SHORT of a minute. + XCTAssertFalse(AppModel.isTooShortToSave(elapsedSeconds: 60)) + XCTAssertFalse(AppModel.isTooShortToSave(elapsedSeconds: 61)) + XCTAssertFalse(AppModel.isTooShortToSave(elapsedSeconds: 3600)) + } +} diff --git a/StrandTests/WorkoutSourceTests.swift b/StrandTests/WorkoutSourceTests.swift index 5612ff0c25..fff0cbd05d 100644 --- a/StrandTests/WorkoutSourceTests.swift +++ b/StrandTests/WorkoutSourceTests.swift @@ -486,4 +486,106 @@ final class WorkoutSourceTests: XCTestCase { XCTAssertNil(m?.distanceM) XCTAssertNil(m?.avgHr) } + // MARK: - buildManualRowFromSpan (#2034) + + /// #2034: entering a manual workout by its start and END, not only by a duration. + /// + /// The row has always been stored as `startTs`/`endTs`, so the span is the shape the storage already + /// speaks and the duration was the lossy intermediate. Mirrors Android `ManualWorkoutSpanTest` except + /// for its overflow case, which has no safe twin here: Kotlin guards a Long addition, while the + /// equivalent Swift input has to be built as a Date, and `Int(Double)` TRAPS on a value that large + /// rather than returning nil. The whole-minute bounds either side of it are already pinned by + /// `testBuildManualRowRejectsBadInput`. + func testSpanBuilderKeepsTheExactEndItIsGiven() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let start = now.addingTimeInterval(-7_200) + // 45m17s. The duration path cannot express this: it would store 45m00s and silently shorten the + // session by 17 seconds. This is the whole point of the span form. + let end = start.addingTimeInterval(45 * 60 + 17) + let r = WorkoutSource.buildManualRowFromSpan(start: start, end: end, sport: "Run", + avgHr: nil, energyKcal: nil, now: now) + XCTAssertNotNil(r) + XCTAssertEqual(r?.startTs, Int(start.timeIntervalSince1970)) + XCTAssertEqual(r?.endTs, Int(end.timeIntervalSince1970)) + XCTAssertEqual(r?.durationS, Double(45 * 60 + 17)) + } + + func testDurationFrontDoorStillProducesTheIdenticalRow() { + // The delegation invariant. If these ever diverge, the auto-detected nudge and the sheet would + // write different rows for the same session. + let now = Date(timeIntervalSince1970: 1_700_000_000) + let start = now.addingTimeInterval(-7_200) + let viaDuration = WorkoutSource.buildManualRow(start: start, durationMin: 45, sport: "Run", + avgHr: 140, energyKcal: 400, distanceM: 8_000, + now: now) + let viaSpan = WorkoutSource.buildManualRowFromSpan(start: start, + end: start.addingTimeInterval(45 * 60), + sport: "Run", avgHr: 140, energyKcal: 400, + distanceM: 8_000, now: now) + XCTAssertNotNil(viaDuration) + XCTAssertEqual(viaDuration, viaSpan) + } + + func testSpanBuilderRejectsAnEndAtOrBeforeTheStart() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let start = now.addingTimeInterval(-7_200) + XCTAssertNil(WorkoutSource.buildManualRowFromSpan(start: start, end: start, sport: "Run", + avgHr: nil, energyKcal: nil, now: now)) + XCTAssertNil(WorkoutSource.buildManualRowFromSpan(start: start, + end: start.addingTimeInterval(-1), sport: "Run", + avgHr: nil, energyKcal: nil, now: now)) + } + + func testSpanBuilderRejectsASpanOverTwentyFourHours() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let cap = TimeInterval(WorkoutSource.maxManualSpanSeconds) + let start = now.addingTimeInterval(-cap - 60) + XCTAssertNotNil(WorkoutSource.buildManualRowFromSpan(start: start, + end: start.addingTimeInterval(cap), + sport: "Run", avgHr: nil, energyKcal: nil, + now: now)) + XCTAssertNil(WorkoutSource.buildManualRowFromSpan(start: start, + end: start.addingTimeInterval(cap + 1), + sport: "Run", avgHr: nil, energyKcal: nil, + now: now)) + } + + func testSpanBuilderAllowsAnEndExactlyAtNowButNotOneSecondPast() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let start = now.addingTimeInterval(-7_200) + XCTAssertNotNil(WorkoutSource.buildManualRowFromSpan(start: start, end: now, sport: "Run", + avgHr: nil, energyKcal: nil, now: now)) + XCTAssertNil(WorkoutSource.buildManualRowFromSpan(start: start, + end: now.addingTimeInterval(1), sport: "Run", + avgHr: nil, energyKcal: nil, now: now)) + } + + func testSpanDurationMinRoundsForDisplayOnly() { + let start = Date(timeIntervalSince1970: 1_700_000_000) + XCTAssertEqual(WorkoutSource.spanDurationMin(start: start, + end: start.addingTimeInterval(45 * 60)), 45) + // 45m17s reads as 45, 45m45s reads as 46. The stored end is untouched either way. + XCTAssertEqual(WorkoutSource.spanDurationMin(start: start, + end: start.addingTimeInterval(45 * 60 + 17)), 45) + XCTAssertEqual(WorkoutSource.spanDurationMin(start: start, + end: start.addingTimeInterval(45 * 60 + 45)), 46) + } + + func testEndForDurationIsTheInverseOfWholeMinutes() { + let start = Date(timeIntervalSince1970: 1_700_000_000) + let end = WorkoutSource.endForDuration(start: start, durationMin: 45) + XCTAssertEqual(end, start.addingTimeInterval(45 * 60)) + XCTAssertEqual(WorkoutSource.spanDurationMin(start: start, end: end), 45) + } + + func testMovingTheStartCarriesTheEndAndKeepsTheLength() { + let start = Date(timeIntervalSince1970: 1_700_000_000) + let end = start.addingTimeInterval(45 * 60 + 17) + let newStart = start.addingTimeInterval(-3_600) + let newEnd = WorkoutSource.endAfterStartMove(oldStart: start, oldEnd: end, newStart: newStart) + XCTAssertEqual(newEnd, newStart.addingTimeInterval(45 * 60 + 17)) + // The length is what survives, including the seconds a duration field cannot show. + XCTAssertEqual(newEnd.timeIntervalSince(newStart), end.timeIntervalSince(start)) + } + } diff --git a/StrandiOS/App/AppModel+iOS.swift b/StrandiOS/App/AppModel+iOS.swift index 5b706e3751..0b65f5fb75 100644 --- a/StrandiOS/App/AppModel+iOS.swift +++ b/StrandiOS/App/AppModel+iOS.swift @@ -1,10 +1,27 @@ #if os(iOS) import Foundation +import UIKit + +/// What the "Sync Strap" shortcut observed when it asked for a sync. Each case names only what was seen. +enum StrapSyncShortcutOutcome { + /// `syncNow` ran and a sync session is now in progress. + case started + /// A sync was already running when the shortcut arrived, so nothing new was requested. + case alreadyRunning + /// The link was not ready within the wait (NOOP was launched in the background by the shortcut and is + /// still connecting). The request is parked and the connect handshake runs it once the link can serve. + case willSyncWhenConnected + /// No app model at all, so there was nowhere to park the request. + case strapNotReady + /// The link was ready and `syncNow` ran, but no session started. The strap log says why. + case notStarted +} 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 +29,82 @@ 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) + } + } + } + } + } + + /// Background entry point for the "Sync Strap" shortcut. When iOS launches NOOP in the background to run + /// the intent, the strap link is still coming up: NOOP auto-connects to the remembered strap on launch, + /// but a connect + bond + handshake takes longer than an App Intent can wait, and `BLEManager.syncNow` + /// declines until it has run (`LiveState.historyReady`). So: wait briefly for a link that is already up + /// (the app-in-background case, where the reply can say the sync started), otherwise park the request + /// with `armPendingManualSync` so the connect handshake runs it the moment the link can serve. + static func startStrapSyncFromShortcut(waitSeconds: Int = 8) async -> StrapSyncShortcutOutcome { + // The Dynamic Island readout goes up first, from here, because only a LiveActivityIntent may start + // one from the background; the controller carries it through the sync from there. + var islandStarted = false + if let model = shared { + SyncLiveActivityController.shared.startFromShortcut(live: model.live) + islandStarted = true + } + for _ in 0.. 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..33f62f011a 100644 --- a/StrandiOS/App/RootTabView.swift +++ b/StrandiOS/App/RootTabView.swift @@ -6,6 +6,25 @@ 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. + /// The Coach master switch, under the same `noop.` key Android writes. Default ON, so every install + /// that shipped with the tab is unchanged. + /// + /// Not tab chrome: with this off the AI is off. The tab goes, the Today launcher card goes, and the + /// daily brief is cancelled, because the brief calls a provider from the BACKGROUND with no UI + /// attached and would otherwise keep posting AI notifications for a feature the wearer switched off. + @AppStorage("noop.coachEnabled") private var coachEnabled = true + @AppStorage("noop.bottomBarAutoHide") private var bottomBarAutoHide = false + + /// The live gym session, owned at the app root — see `LiftSessionController`. + @EnvironmentObject private var liftSession: LiftSessionController /// 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 +51,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 +114,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 +129,28 @@ 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. + // Conditional on the master switch. The tags stay LITERAL rather than being renumbered when + // Coach is absent: `tabPaths` and `scrollTop` are indexed by tag, and More stays tag 4 in both + // shapes, so a wearer's More tab keeps its identity, its navigation path and its scroll + // position across a flip instead of inheriting Coach's. + if coachEnabled { + tab(CoachView(), "Coach", "sparkles", path: $tabPaths[3], scrollSignal: scrollTop[3]).tag(3) + } + moreTab(path: $tabPaths[4], scrollSignal: scrollTop[4]).tag(4) } .tint(StrandPalette.accent) + // Switching Coach off while STANDING on it leaves `selectedTab` pointing at a tag no tab claims + // any more, which renders as an empty tab rather than as an error. Send that wearer to Today, and + // only in that case, so a flip made from anywhere else does not move them. + .onChangeCompat(of: coachEnabled) { enabled in + if !enabled && selectedTab == 3 { selectedTab = 0 } + } + // #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 +210,21 @@ 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. + // + // Guarded on the master switch, because this route is reachable with Coach OFF. A brief + // notification already sitting in Notification Centre still calls `openCoach()` when it is + // tapped (StrandApp wires `onCoachBriefTapped` to it), and with no tab claiming tag 3 the + // wearer would land on a BLANK tab. Dropping the request leaves them where they were, which + // is the honest answer for a feature that is switched off. + guard coachEnabled else { + router.requestedDestination = nil + break + } + 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 } @@ -214,6 +267,26 @@ struct RootTabView: View { .onChange(of: homeScreenQuickActionsEnabled) { _, _ in presentPendingHomeScreenQuickActionIfPossible() } + // The running gym session, reachable from ANY tab. It sits above the tab bar rather than + // inside the Lift Log screen, because a workout outlives whichever screen you wandered to — + // and because swiping the sheet away must minimise the session, not end it. + .safeAreaInset(edge: .bottom, spacing: 0) { + if liftSession.isActive { + LiftSessionBar() + .padding(.horizontal, 14) + // Clear the floating tab bar with the same constant every screen uses, or the + // session bar sits on top of the tab labels. + .padding(.bottom, NoopMetrics.tabBarClearance) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + .animation(.easeInOut(duration: 0.25), value: liftSession.isActive) + // A session left running by a previous launch is back before this view exists + // (`LiftSessionController.resumeSaved`, from `StrandiOSApp.init`), as the BAR — not as a sheet + // thrown in the user's face; they open it when they want it. + .sheet(isPresented: $liftSession.isPresented) { + LiftSessionView { } + } } /// Mandatory launch gates defer an external action. Once the shell is available, an explicit Home @@ -261,6 +334,11 @@ 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() + // .coach switches to the Coach tab (handled above — the morning-brief tap-through and the + // #1862 launcher both arrive that way, the launcher's question riding on + // `AICoachEngine.pendingPrompt`); this keeps the switch exhaustive and falls back to Coach if + // it ever reaches the host. + case .coach: CoachView() } } // The Trends/Today fallbacks above emit TabRoute value pushes (#198), which need a @@ -387,7 +465,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) @@ -395,6 +473,7 @@ struct RootTabView: View { moreSection("Body") { MoreRow("Live", "waveform.path.ecg", .live) MoreRow("Workouts", "figure.run", .workouts) + MoreRow("Lift Log", "dumbbell.fill", .liftLog) MoreRow("Health", "heart.text.square.fill", .health) MoreRow("Lab Book", "books.vertical.fill", .labBook) MoreRow("Stress", "bolt.heart.fill", .stress) @@ -518,7 +597,7 @@ struct RootTabView: View { /// registration in `moreTab`. private enum MoreDestination: Hashable { case insightsHub, intelligence, coach, insights, explore, compare - case live, workouts, health, labBook, stress, breathe, intervals, rhythm + case live, workouts, liftLog, health, labBook, stress, breathe, intervals, rhythm case fusedRecord, appleHealth, miBand, dataSources, backupSync, shortcutsExport, noopLimitations case alarms, automations, testCentre, siriShortcuts, powerSaving, settings @@ -532,6 +611,7 @@ private enum MoreDestination: Hashable { case .compare: CompareView() case .live: LiveView() case .workouts: WorkoutsView() + case .liftLog: LiftLogView() case .health: HealthView() case .labBook: LabBookView() case .stress: StressView() @@ -686,3 +766,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/SiriShortcutsSettingsView.swift b/StrandiOS/App/SiriShortcutsSettingsView.swift index c6b55c5dc3..d713e6172b 100644 --- a/StrandiOS/App/SiriShortcutsSettingsView.swift +++ b/StrandiOS/App/SiriShortcutsSettingsView.swift @@ -4,7 +4,7 @@ import AppIntents import StrandDesign /// Surfaces NOOP's already-registered App Intents (see StrandiOS/System/NOOPAppIntents.swift) in the -/// UI so users discover them. `NOOPShortcuts` auto-registers "Buzz Strap" and "Mark a Moment" with +/// UI so users discover them. `NOOPShortcuts` auto-registers "Sync Strap", "Buzz Strap" and "Mark a Moment" with /// Siri/Spotlight/Shortcuts, but nothing in-app advertised them — this is the iOS analogue of the /// Mac's strap-double-tap-runs-a-Shortcut feature. Apple's `SiriTipView`/`ShortcutsLink` (iOS 16+) /// do exactly that: tip the user on the spoken phrase and deep-link into the Shortcuts app, scoped to @@ -29,10 +29,12 @@ struct SiriShortcutsSettingsView: View { .font(StrandFont.headline) .foregroundStyle(StrandPalette.textPrimary) } - Text("Buzz your strap or mark a moment from Siri, Spotlight, the Shortcuts app, or a Back-Tap / automation. No setup needed.") + Text("Sync your strap, buzz it or mark a moment from Siri, Spotlight, the Shortcuts app, or a Back-Tap / automation. No setup needed.") .font(StrandFont.caption) .foregroundStyle(StrandPalette.textTertiary) .fixedSize(horizontal: false, vertical: true) + SiriTipView(intent: SyncStrapIntent(), isVisible: .constant(true)) + .siriTipViewStyle(.dark) SiriTipView(intent: BuzzStrapIntent(), isVisible: .constant(true)) .siriTipViewStyle(.dark) SiriTipView(intent: MarkMomentIntent(), isVisible: .constant(true)) diff --git a/StrandiOS/App/StrandiOSApp.swift b/StrandiOS/App/StrandiOSApp.swift index 366bc066e0..c4a7b12d0b 100644 --- a/StrandiOS/App/StrandiOSApp.swift +++ b/StrandiOS/App/StrandiOSApp.swift @@ -23,8 +23,19 @@ 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() - @State private var liveActivity = LiveActivityController() + @StateObject private var router: NavRouter + /// NOOP's live heart rate banner. Built in `init` and fed from there (`LiveActivityController.follow`), not from + /// a view: a process iOS starts in the background need not build one. + @State private var liveActivity: LiveActivityController + /// The Lift Log session's own Live Activity. Separate from the live-HR one above: while a gym + /// session is open this is the banner that matters (it carries the heart rate too), so the HR + /// activity is suppressed rather than stacked beside it. Built in `init`, where the strap log it + /// writes to exists. + @State private var liftActivity: LiftLiveActivityController + /// The live gym session. Owned HERE, at the app root, rather than by the screen that shows it: + /// swiping the workout sheet away must not stop the clock, silence the strap or drop the + /// double-tap handler. See `LiftSessionController`. + @StateObject private var liftSession: LiftSessionController @Environment(\.scenePhase) private var scenePhase /// Appearance preference (System/Light/Dark). Default follows the OS; the Settings picker writes it. @AppStorage(AppearanceMode.storageKey) private var appearanceRaw = AppearanceMode.system.rawValue @@ -36,6 +47,9 @@ struct StrandiOSApp: App { /// Effort's display scale is also embedded in the shared widget snapshot. Observe it here so a /// Settings change gets one accurate full rebuild instead of waiting for an unrelated repo refresh. @AppStorage(UnitPrefs.effortScaleKey) private var effortScaleRaw = EffortScale.hundred.rawValue + /// kg vs lb for the Lift Log Live Activity's "8 x 30 kg" line — the app formats it, because the + /// unit preference lives here and not in the widget extension. + @AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue init() { // #1008: pin the pre-change Overnight-only default for existing installs before @@ -66,8 +80,47 @@ 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) + // Settings → "Keep screen on while syncing". Wired once here, not as another modifier on `body`. + SyncKeepAwake.shared.attach(to: model.live) + // The strap-sync Live Activity (Lock Screen + Dynamic Island). Same placement, same reason — and + // it must also run in a process the Sync Strap shortcut launched with no scene. + SyncLiveActivityController.shared.attach(to: model.live) + // The buzz and the strap-gesture claim are injected, so the controller itself knows nothing + // about BLE and stays testable. + let liftSession = LiftSessionController( + buzz: { [weak model] loops in + model?.buzz(loops: loops, gate: HapticPrefs.liftRest) + }, + setStrapHandler: { [weak model] handler in + model?.strapDoubleTapOverride = handler + }, + log: { [weak model] line in + model?.live.append(log: AppModel.stamped(line)) + }) + _liftSession = StateObject(wrappedValue: liftSession) + let liftActivity = LiftLiveActivityController(log: { [weak model] line in + model?.live.append(log: AppModel.stamped(line)) + }) + _liftActivity = State(initialValue: liftActivity) + // The live heart rate banner makes room only for the Lift Log banner actually on screen, which carries the + // heart rate itself — not for a sync (`LiveHRBannerLifecycle`). + let liveActivity = LiveActivityController() + liveActivity.follow(model, standsAside: { [weak liftActivity] in liftActivity?.isShowing == true }) + _liveActivity = State(initialValue: liveActivity) + // A gym session keeps ONE banner on the Lock Screen, its own — as the live-HR banner already + // stands aside for it. A sync started in the foreground mid-session starts no sync banner. + SyncLiveActivityController.shared.holdsBackNewBanner = { [weak liftSession] in + liftSession?.isActive == true + } + // Before any view or publisher exists: the first push to the Lock Screen banner must find the + // session already running, or it ends the banner iOS kept alive across the restart. + liftSession.resumeSaved() // #1538: a strap offload completes while the app is BACKGROUNDED — it stays alive as a // bluetooth-central to receive it — and the re-score it triggers took nearly eight minutes on the // reporter's install, far longer than that wake survives. The pass is all-or-nothing, so being @@ -75,9 +128,11 @@ struct StrandiOSApp: App { // start the same doomed pass again. This processing task is where that work is escalated to; it is // the long, deferrable kind rather than the metered refresh kind the two schedulers above use. // Registered before launch finishes and permitted in project.yml, or iOS never delivers it. - RescoreBackgroundScheduler.register { [weak model] in + RescoreBackgroundScheduler.register(perform: { [weak model] in await model?.runDeferredRescoreIfOwed() - } + }, onExpire: { [weak model] in + model?.live.append(log: "re-score: background processing time expired before the pass finished (#1538)") + }) let bridge = HealthKitBridge( repo: model.repo, appleDeviceId: model.appleDeviceId, @@ -106,6 +161,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() @@ -120,6 +214,7 @@ struct StrandiOSApp: App { .environmentObject(health) .environmentObject(router) .environmentObject(UpdateStore.shared) + .environmentObject(liftSession) // v5 L3: the shared stress check-in nudge surface, so the Breathe screen's passive // card observes the SAME instance the central detector (AppModel.evaluateStress) posts to. .environment(\.stressNudgeCenter, model.stressNudgeCenter) @@ -133,34 +228,20 @@ struct StrandiOSApp: App { // fixed-geometry tiles/gauges stay legible at the largest accessibility sizes rather than // clipping; the common Larger-Text range still scales fully. .dynamicTypeSize(...DynamicTypeSize.accessibility1) - .onReceive(model.live.$heartRate) { _ in - // #911: anchor the Live Activity on the SAME shared `Repository.widgetAnchor` the - // Home/Lock widget and the watch snapshot use, so this fourth surface can't drift to a - // different day at the rollover (it previously read `days.last(where: recovery != nil)`, - // which kept pointing at yesterday's scored row after Today had moved on). - // Memoized: this closure fires on EVERY live-HR tick, so re-deriving the anchor here - // scanned the whole history + hit the DateFormatter lock ~1-3x/sec (#1051-shaped). - let day = model.repo.cachedWidgetAnchor() - liveActivity.update( - bpm: model.live.connected ? (model.bpm ?? model.live.heartRate) : nil, - recovery: day?.recovery.map { Int($0.rounded()) }, - connected: model.live.connected, - effort: day?.strain.map { Int($0.rounded()) } - ) - } - // End the Live Activity the moment the link drops, even if no further HR tick arrives. - .onReceive(model.live.$connected) { isConnected in - // #911: same shared anchor as the heartRate site above, so the Live Activity, the - // widget, the watch and Today never disagree about which day they describe. Memoized - // (shares the heartRate site's cache; recomputes only on a data refresh or day-roll). - let day = model.repo.cachedWidgetAnchor() - liveActivity.update( - bpm: isConnected ? (model.bpm ?? model.live.heartRate) : nil, - recovery: day?.recovery.map { Int($0.rounded()) }, - connected: isConnected, - effort: day?.strain.map { Int($0.rounded()) } - ) + // `hr` is the value being written: this runs in willSet, when `live.heartRate` still holds the old one. + .onReceive(model.live.$heartRate) { hr in + // The gym banner's own cheap path: no presentation is built here, and a heart rate moves + // the banner only when `LiftBannerPushPolicy` says it is worth a push. Everything else + // about the session pushes through `pushLiftActivity` below, carrying the current number. + liftActivity.updateHeartRate(model.live.connected ? (model.bpm ?? hr) : nil) } + // The gym session's own banner follows each change to the session once it has landed — + // a stage, typed numbers, a rest's end — and the heart rate above; the controller decides + // what is worth pushing, and the banner's clocks tick on their own. A strap step is pushed + // at once, with its light-up alert, below. + .onReceive(liftSession.changesSettled) { _ in pushLiftActivity() } + // A strap double-tap lights the Lock Screen on the step it took. + .onReceive(liftSession.strapStepTaken) { _ in pushLiftActivity(alert: true) } // #911/#759: republish the Home/Lock-Screen widget whenever the dashboard caches actually // change mid-session. The only other publish site is the scenePhase .active handler, so // during a long foreground session the widget froze at the last-foreground snapshot while @@ -226,20 +307,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 +331,14 @@ 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) + // iOS starts a Lift Log banner only for an app on screen, so a banner lost while NOOP was in + // the background comes back now, whether or not the strap is sending anything. + pushLiftActivity() + // Only the foreground may start the live heart rate banner: offer it now. + liveActivity.appBecameActive() + // End a "Connecting…" sync island whose sync never came, rather than leave it greyed. + SyncLiveActivityController.shared.reconcile(live: model.live) // 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() @@ -318,6 +396,35 @@ struct StrandiOSApp: App { } } } + + /// Map the running session onto the Lock Screen banner. + /// + /// The wording and the numbers come from `LiftSessionController.presentation`, the same + /// resolution the in-app minimised bar renders, so the two surfaces cannot disagree. The heart + /// rate is the app's smoothed value, and only while the strap is actually connected — a frozen + /// last-known bpm on a Lock Screen reads as live and is not. `alert` lights the Lock Screen for this + /// push — see `LiftLiveActivityController.update`. + @MainActor + private func pushLiftActivity(alert: Bool = false) { + let system = UnitSystem(rawValue: unitSystemRaw) ?? .metric + guard let p = liftSession.presentation(system: system) else { + liftActivity.update(state: nil) + return + } + let lightUp = liftActivity.update( + state: LiftActivityAttributes.ContentState( + isResting: p.isResting, + exercise: p.exercise, + status: p.status, + detail: p.detail, + bpm: model.live.connected ? (model.bpm ?? model.live.heartRate) : nil, + next: p.next, + stageStartedAt: p.stageStartedAt, + restEndsAt: p.restEndsAt), + alert: alert) + // One line per strap step into NOOP's strap log: whether the Lock Screen was asked to light. + if let lightUp { model.live.append(log: AppModel.stamped(lightUp.logLine)) } + } } /// iOS root — the `RootTabView` shell with the first-run onboarding/pairing wizard overlaid until @@ -400,6 +507,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() } } @@ -497,5 +614,6 @@ private struct OuraOnboardingDemoHost: View { var body: some View { AddDeviceWizard(live: live, onClose: {}, startAt: (.oura, .prep)) } + } #endif diff --git a/StrandiOS/Health/HealthKitBridge.swift b/StrandiOS/Health/HealthKitBridge.swift index de533e31e5..f55e1cd383 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 } @@ -273,9 +279,10 @@ final class HealthKitBridge: ObservableObject { // MARK: - Live delivery (continuous ingestion) - /// The scored read types we want a live observer + hourly background delivery on. This is the - /// subset of `quantityReadIds` (plus sleep) that actually feeds Charge/Rest/Effort/Fitness Age, so - /// a watch-only user's numbers refresh on their own rather than only when the app is foregrounded. + /// The QUANTITY types we want a live observer + hourly background delivery on. This is the subset of + /// `quantityReadIds` that actually feeds Charge/Rest/Effort/Fitness Age, so a watch-only user's numbers + /// refresh on their own rather than only when the app is foregrounded. `enableLiveDelivery` observes + /// these plus sleep and workouts; this list is not the whole observed set. /// We deliberately do NOT observe the body-composition reads (weight/BMI/etc.) — those don't move a /// score and a manual weigh-in shouldn't wake the app every hour. private static let liveQuantityIds: [HKQuantityTypeIdentifier] = [ @@ -299,6 +306,24 @@ final class HealthKitBridge: ObservableObject { if let t = HKObjectType.quantityType(forIdentifier: id) { types.append(t) } } if let sleep = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) { types.append(sleep) } + // Workouts, for the same reason the scored reads are here: without an observer a Watch workout + // reached NOOP only at the next foreground open, or whenever an unrelated type happened to wake + // the app, so up to an hour late and sometimes longer (#2265). + // + // The lag is not only cosmetic. A strap offload can score, detect a bout and write it to Apple + // Health entirely in the background while the Watch workout covering the same session is still + // unknown here, so the overlap check that would have suppressed that bout has nothing to compare + // against, and the wearer is asked to confirm a session the Watch already recorded. + // + // Nothing else needs widening: the loop below is already over `HKSampleType`, and + // `collectWorkouts` already filters on `notNoopAuthored`, so a workout THIS app wrote cannot wake + // it into re-importing its own row. + // + // This NARROWS the race rather than closing it. An observer wake is not instantaneous, so an + // offload that completes inside the wake latency still sees no Apple workout. Whether HealthKit + // honours background delivery on `workoutType` at the quantity-type cadence is also unverified, + // and unverifiable off-device: HealthKit has no simulator support. + types.append(HKObjectType.workoutType()) for type in types { let key = type.identifier @@ -363,7 +388,8 @@ final class HealthKitBridge: ObservableObject { let window = max(1, min(31, daysBack + 1)) // A sync completed moments ago and covered at least this wake's window, so a second full pass // would burn ~15 HealthKit aggregate queries and a write-back to re-derive rows that sync just - // wrote. Stand down. This is what collapses the hourly burst of six observers into one sync. + // wrote. Stand down. This is what collapses the hourly burst of observers into one sync (seven of + // them since workouts joined the set, #2265 — a count worth not restating as a literal again). // FOREGROUND catch-up deliberately does not consult this: an explicit resume should always read. // `age >= 0` is not defensive noise. This is WALL-CLOCK time, and it can move backwards — an NTP // correction, or a user changing the date. A negative age satisfies `< window`, so every wake would @@ -762,23 +788,112 @@ final class HealthKitBridge: ObservableObject { var sleepsByStart: [Int: CachedSleepSession] = [:] for s in computedSleeps { sleepsByStart[s.startTs] = s } for s in importedSleeps { sleepsByStart[s.startTs] = s } - let sessions = sleepsByStart.keys.sorted().map { sleepsByStart[$0]! } + // A night the strap may still be recording is held back, with its day's vitals, until it closes + // (`HealthWriteback.nightIsStillOpen`). + // The strap's own heart rate, the same stream the HR write reads, so an Apple Watch still recording + // cannot make a strap night that stopped at its sync frontier look finished. + let newestHeartRateTs = (try? await whoopStore.hrFingerprint(deviceId: noopDeviceId, from: nowTs - 2 * 86_400, + to: nowTs).maxTs) ?? 0 + let openStarts = Set(computedSleeps.filter { + HealthWriteback.nightIsStillOpen(endTs: $0.endTs, newestHeartRateTs: newestHeartRateTs, now: nowTs) + }.map(\.startTs)) + let sessions = sleepsByStart.keys.sorted().filter { !openStarts.contains($0) }.map { sleepsByStart[$0]! } + let openDays = Set(computedSleeps.filter { openStarts.contains($0.startTs) } + .map { HealthKitBridge.dayString(Date(timeIntervalSince1970: TimeInterval($0.endTs))) }) var firstError: Error? func attempt(_ op: () async throws -> Void) async { do { try await op() } catch { if firstError == nil { firstError = error } } } - await attempt { try await writeVitals(whoopStore: whoopStore, days: days, sessions: sessions) } + // #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, holdingDays: openDays) } await attempt { try await writeSleep(sessions: sessions) } await attempt { try await writeHeartRate(whoopStore: whoopStore, fromTs: fromTs, nowTs: nowTs) } await attempt { try await writeWorkouts(whoopStore: whoopStore, fromTs: fromTs, toTs: nowTs) } 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. - private func writeVitals(whoopStore: WhoopStore, days: Int, sessions: [CachedSleepSession]) async throws { + private func writeVitals(whoopStore: WhoopStore, days: Int, sessions: [CachedSleepSession], + holdingDays: Set = []) async throws { let cal = Calendar.current let to = HealthKitBridge.dayString(Date()) guard let fromDate = cal.date(byAdding: .day, value: -days, to: Date()) else { return } @@ -799,15 +914,24 @@ final class HealthKitBridge: ObservableObject { let imported = (try? await whoopStore.dailyMetrics(deviceId: noopDeviceId, from: from, to: to)) ?? [] var byDay: [String: DailyMetric] = [:] for r in computed { byDay[r.day] = r } // computed first - for r in imported { byDay[r.day] = r } // imported overrides - let rows = byDay.keys.sorted().map { byDay[$0]! } + // Imported overrides, EXCEPT for a field the importer cannot populate. See HealthExportMerge: a + // CSV carries no raw R-R, so an imported row never has `avgSdnn`, and replacing the row wholesale + // threw away a computed SDNN that was already correct. The export then fell back to `avgHrv` + // (RMSSD for a strap row) under the SDNN type, turning a right value into a wrong one on every day + // an import happened to cover (#2264). + for r in imported { byDay[r.day] = HealthExportMerge.merged(computed: byDay[r.day], imported: r) } + let rows = byDay.keys.sorted().filter { !holdingDays.contains($0) }.map { byDay[$0]! } struct Candidate { let type: HKQuantityType; let key: String; let sample: HKQuantitySample } var candidates: [Candidate] = [] 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), @@ -816,6 +940,14 @@ final class HealthKitBridge: ObservableObject { ) candidates.append(Candidate(type: type, key: key, sample: sample)) } + // Keys of OUR earlier samples to delete with no replacement: a value this build no longer writes + // must not survive from a build that did. + var retracted: [HKQuantityType: [String]] = [:] + func retract(_ id: HKQuantityTypeIdentifier, _ day: String) { + guard let type = HKQuantityType.quantityType(forIdentifier: id), + store.authorizationStatus(for: type) == .sharingAuthorized else { return } + retracted[type, default: []].append(HealthWriteback.appleHealthVitalKey(metricId: id.rawValue, day: day)) + } for row in rows { guard let date = HealthKitBridge.date(from: row.day) else { continue } @@ -824,16 +956,21 @@ final class HealthKitBridge: ObservableObject { if let rhr = row.restingHr { add(.restingHeartRate, HKUnit.count().unitDivided(by: .minute()), Double(rhr), row.day, at) } - // Export the GENUINE SDNN (v31) when present — the strap's `avgHrv` is RMSSD, which HealthKit - // has no field for, so writing it under `.heartRateVariabilitySDNN` mislabels it. `avgSdnn` is the - // 5-min SDNN index, deliberately window-matched to Apple's own short-window SDNN samples so the - // written values sit consistently in the user's Health SDNN history (a whole-night SD would land - // 2-3× high). WHOOP rows backfill `avgSdnn` from stored raw R-R on the next re-score; Apple rows' - // `avgHrv` already IS SDNN. The `avgHrv` fallback fires for those two before re-scoring, and - // permanently for summary-only sources (Oura) that give RMSSD with no raw R-R to derive SDNN from - // — HealthKit's single HRV type leaves no better label there. - if let sdnn = row.avgSdnn ?? row.avgHrv { + // Export only the GENUINE SDNN (v31). `avgSdnn` is the 5-min SDNN index, deliberately + // window-matched to Apple's own short-window SDNN samples so the written values sit consistently + // in the user's Health SDNN history (a whole-night SD would land 2-3× high). + // + // No `avgHrv` fallback (#2264). Every row here is a strap-computed or WHOOP-imported daily (rows + // imported FROM Apple Health live under `appleDeviceId` and are never read back out), so `avgHrv` is always + // RMSSD — a different magnitude that HealthKit has no type for. Writing it under + // `.heartRateVariabilitySDNN` put a step into the series that no physiology produced, and a + // Health reader that scores off SDNN took it at face value. A night without an SDNN (not yet + // re-scored from raw R-R, or a summary-only source such as Oura) exports no HRV instead, and any + // mislabelled sample an earlier build wrote for that day is retracted. + if let sdnn = row.avgSdnn { add(.heartRateVariabilitySDNN, .secondUnit(with: .milli), sdnn, row.day, at) + } else { + retract(.heartRateVariabilitySDNN, row.day) } if let spo2 = row.spo2Pct { add(.oxygenSaturation, .percent(), spo2 / 100, row.day, at) @@ -842,21 +979,23 @@ final class HealthKitBridge: ObservableObject { add(.respiratoryRate, HKUnit.count().unitDivided(by: .minute()), rr, row.day, at) } } - guard !candidates.isEmpty else { return } + guard !candidates.isEmpty || !retracted.isEmpty else { return } // Delete any of OUR prior samples that carry the same metadata keys, then write the fresh // batch. Scoped to HKSource.default() so we never touch a sample written by another app // that happens to use the same external UUID. Delete failures are non-fatal (e.g., nothing // to delete on first run) — only the save throws. let bySource = HKQuery.predicateForObjects(from: HKSource.default()) - let grouped = Dictionary(grouping: candidates, by: { $0.type }) - for (type, items) in grouped { - let keys = Array(Set(items.map { $0.key })) + var keysByType = Dictionary(grouping: candidates, by: { $0.type }).mapValues { $0.map { $0.key } } + for (type, keys) in retracted { keysByType[type, default: []] += keys } + for (type, allKeys) in keysByType { + let keys = Array(Set(allKeys)) let byKey = HKQuery.predicateForObjects(withMetadataKey: HKMetadataKeyExternalUUID, allowedValues: keys) let pred = NSCompoundPredicate(andPredicateWithSubpredicates: [bySource, byKey]) _ = try? await self.store.deleteObjects(of: type, predicate: pred) } + guard !candidates.isEmpty else { return } try await self.store.save(candidates.map { $0.sample }) } @@ -870,10 +1009,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 +1030,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,20 +1126,116 @@ 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`. + /// How many workout rows each write-back read asks the store for, per source. + /// + /// Named rather than inline because [deleteOrphanedWorkouts] has to compare against it: a read that + /// comes back holding exactly this many rows may have been truncated, and a truncated read cannot be + /// used to decide that a Health workout has no counterpart. (#2210) + private static let workoutReadLimit = 500 + + /// Delete the workouts we wrote into [fromTs, toTs] whose key is no longer among [keeping]. + /// + /// Reads the window back from Health and deletes only objects that are ours by source AND carry a + /// `noop:workout:` external UUID, so a workout written by another app, or by us under some future + /// scheme, is never touched. Everything removed was observed first; nothing is deleted by range. + /// + /// A workout of ours with no external UUID at all is LEFT ALONE. It cannot be matched against the + /// store, so deleting it would be guessing, and the #1503 sweep already exists for that class. + /// + /// ONE assumption this rests on, and it is newly load-bearing: that deleting an `HKWorkout` also + /// removes the energy and distance samples `writeWorkouts` attached through its `HKWorkoutBuilder`. + /// The key-based delete already assumed it, but harmlessly, because every delete there is followed + /// immediately by a rewrite of the same key, so a surviving child is replaced rather than stranded. + /// An orphan is deleted and NOT rewritten, so if the assumption is wrong its children are left in + /// Health attributed to us with no workout above them, and nothing here will ever collect them. + /// + /// There is no fallback handle. `builder.addMetadata` puts the external UUID on the WORKOUT; the + /// samples go through `builder.addSamples` carrying no metadata at all, so they cannot be found by + /// key. Finding them by type and range instead would sweep `activeEnergyBurned` written by our own + /// vitals path and by every other source, which is precisely the blind range delete this function + /// exists to avoid. Verifying the assumption needs a device (#2210). + /// + /// Note also that this reads. `authorizationStatus` reports SHARE permission only, and HealthKit + /// does not let an app ask whether it may read. With write granted and read withheld the query + /// returns nothing rather than failing, so reconciliation quietly does nothing and the duplicates + /// stay. That fails safe, but it fails silent. + private func deleteOrphanedWorkouts(fromTs: Int, toTs: Int, keeping: Set) async { + let pred = NSCompoundPredicate(andPredicateWithSubpredicates: [ + HKQuery.predicateForObjects(from: HKSource.default()), + // `.strictStartDate`, and it is load-bearing. A workout is an INTERVAL, and the default + // options match anything overlapping the window, while the store read this is compared + // against filters `startTs >= from AND startTs <= to`. Without strict matching, a workout + // that began before `fromTs` and was still running at it is matched here, is absent from the + // store read, and would therefore be deleted as an orphan. `fromTs` is a rolling 14-day + // boundary recomputed on every write-back, so it lands mid-workout sooner or later. + // + // The heart-rate writer this reconciliation is modelled on uses the default options, and is + // right to: its samples are instantaneous, so overlapping the window and starting inside it + // are the same question. For an interval type they are not. + HKQuery.predicateForSamples(withStart: Date(timeIntervalSince1970: TimeInterval(fromTs)), + end: Date(timeIntervalSince1970: TimeInterval(toTs)), + options: [.strictStartDate]), + ]) + let orphans: [HKWorkout] = await withCheckedContinuation { (cont: CheckedContinuation<[HKWorkout], Never>) in + let q = HKSampleQuery(sampleType: HKObjectType.workoutType(), predicate: pred, + limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, _ in + var out: [HKWorkout] = [] + for case let workout as HKWorkout in samples ?? [] { + guard let uuid = workout.metadata?[HKMetadataKeyExternalUUID] as? String, + uuid.hasPrefix(HealthWriteback.appleHealthWorkoutKeyPrefix) else { continue } + if !keeping.contains(uuid) { out.append(workout) } + } + cont.resume(returning: out) + } + store.execute(q) + } + guard !orphans.isEmpty else { return } + _ = try? await store.delete(orphans) + } + 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)) ?? [] - let computed = (try? await whoopStore.workouts(deviceId: computedDeviceId, from: fromTs, to: toTs, limit: 500)) ?? [] + // Read result kept OPTIONAL rather than collapsed with `?? []`, because the reconciliation below + // has to tell "the store holds no workouts here" apart from "the read failed". Collapsed, a + // transient read error would look like an empty window and delete every workout we had written + // into it. (#2210) + let mineRead = try? await whoopStore.workouts(deviceId: noopDeviceId, from: fromTs, to: toTs, + limit: Self.workoutReadLimit) + let computedRead = try? await whoopStore.workouts(deviceId: computedDeviceId, from: fromTs, to: toTs, + limit: Self.workoutReadLimit) + let mine = mineRead ?? [] + let computed = computedRead ?? [] var byKey: [String: WorkoutRow] = [:] for w in computed + mine where w.source != HealthKitBridge.appleWorkoutSource { byKey["\(w.startTs):\(w.sport)"] = w } 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) } + + // #2210: remove workouts we wrote that the store no longer holds. The delete below only ever + // names the keys it is about to rewrite, so a row that LEFT the store keeps its Health copy for + // good: a detected bout dropped for overlapping an Apple workout, a bout whose startTs drifted + // and was rewritten under a new key, or a workout deleted, dismissed or merged in the app. + // + // Deliberately NOT a range delete. This reads the window back, keeps only what carries our own + // key, and deletes the ones absent from `rows`, so nothing is removed that was not first + // observed and identified as ours. A blind range delete would also have to trust that the store + // read returned everything, and the two guards below are exactly the cases where it did not. + // + // Runs BEFORE the empty-rows return: a window whose last workout was deleted in the app is the + // case where every Health copy is an orphan, and returning early would leave all of them. + if mineRead != nil, computedRead != nil, + mine.count < Self.workoutReadLimit, computed.count < Self.workoutReadLimit { + await deleteOrphanedWorkouts(fromTs: fromTs, toTs: toTs, keeping: Set(rows.map(key))) + } + + guard !rows.isEmpty else { return } let pred = NSCompoundPredicate(andPredicateWithSubpredicates: [ HKQuery.predicateForObjects(from: HKSource.default()), HKQuery.predicateForObjects(withMetadataKey: HKMetadataKeyExternalUUID, @@ -1293,16 +1530,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 +1556,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/Resources/Info.plist b/StrandiOS/Resources/Info.plist index 81cef27e03..9fde8c3476 100644 --- a/StrandiOS/Resources/Info.plist +++ b/StrandiOS/Resources/Info.plist @@ -67,8 +67,12 @@ NOOP writes the metrics it computes from your strap back into Apple Health, on-device and only when you allow it. NSLocationWhenInUseUsageDescription NOOP records your route during an outdoor workout to show distance, pace, and a map, and keeps recording while the screen is off. The route stays on your device. Nothing leaves it. + NSMicrophoneUsageDescription + NOOP uses the microphone to let you ask the coach a question by voice. The audio is transcribed on-device and never leaves your iPhone. 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. + NSSpeechRecognitionUsageDescription + NOOP transcribes your question on-device so you can ask the coach out loud instead of typing. Audio never leaves your iPhone. NSSupportsLiveActivities OURA_CLIENT_ID diff --git a/StrandiOS/Resources/lift-step-silence.caf b/StrandiOS/Resources/lift-step-silence.caf new file mode 100644 index 0000000000..7312e5e431 Binary files /dev/null and b/StrandiOS/Resources/lift-step-silence.caf differ 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..41b23aecc8 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,9 +82,59 @@ struct BuzzStrapIntent: AppIntent { } } +/// Pull the strap's stored history now: the Shortcuts twin of the "Sync now" button, run WITHOUT opening NOOP. +/// iOS runs an in-app intent inside NOOP's own process (launching or resuming it in the background), where the +/// strap link lives under the bluetooth-central background mode, so the offload carries on after this returns. +/// The spoken/shown reply reports only what this path observed about the sync starting. +/// +/// `LiveActivityIntent`, not plain `AppIntent`: that is what lets it START the strap-sync Live Activity +/// (the Dynamic Island "Connecting… / Syncing… N chunks" readout) from the background. A plain intent +/// running in a background-launched app is refused by ActivityKit. +struct SyncStrapIntent: LiveActivityIntent { + static var title: LocalizedStringResource = "Sync Strap" + static var description = IntentDescription("Pull your WHOOP strap's stored history into NOOP now.") + static var openAppWhenRun = false + + @MainActor + func perform() async throws -> some IntentResult & ProvidesDialog { + switch await AppModel.startStrapSyncFromShortcut() { + case .started: return .result(dialog: "Syncing your strap.") + case .alreadyRunning: return .result(dialog: "Your strap is already syncing.") + case .willSyncWhenConnected: return .result(dialog: "NOOP is connecting to your strap and will sync as soon as it's ready.") + case .strapNotReady: return .result(dialog: "Your strap isn't connected to NOOP yet, so the sync didn't start.") + case .notStarted: return .result(dialog: "NOOP couldn't start the sync. Open NOOP to see the strap log.") + } + } +} + +/// 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] { + AppShortcut(intent: SyncStrapIntent(), + phrases: [ + "Sync my \(.applicationName) strap", + "Sync \(.applicationName)", + ], + shortTitle: "Sync Strap", + systemImageName: "arrow.triangle.2.circlepath") AppShortcut(intent: MarkMomentIntent(), phrases: ["Mark a moment in \(.applicationName)"], shortTitle: "Mark a Moment", @@ -73,6 +143,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/System/SyncKeepAwake.swift b/StrandiOS/System/SyncKeepAwake.swift new file mode 100644 index 0000000000..38d9f788bd --- /dev/null +++ b/StrandiOS/System/SyncKeepAwake.swift @@ -0,0 +1,36 @@ +#if os(iOS) +import Combine +import Foundation + +/// Holds the screen awake while a strap history sync runs, when Settings → "Keep screen on while syncing" +/// is on (default OFF). Follows `LiveState.backfilling` for the whole sync, whichever trigger started it +/// (Sync now, the Sync Strap shortcut, a foreground or periodic sync), and releases the hold the moment the +/// sync ends or the toggle is switched off. +/// +/// A Combine subscription attached once at launch, not a modifier on `StrandiOSApp.body`: that modifier +/// chain has already exceeded the type-checker's budget (#1767). +@MainActor +final class SyncKeepAwake { + static let shared = SyncKeepAwake() + + private var cancellable: AnyCancellable? + + private init() {} + + func attach(to live: LiveState) { + let key = ScreenIdle.strapSyncKeepAwakeKey + let enabled = NotificationCenter.default + .publisher(for: UserDefaults.didChangeNotification) + .map { _ in UserDefaults.standard.bool(forKey: key) } + .prepend(UserDefaults.standard.bool(forKey: key)) + cancellable = live.$backfilling + .combineLatest(enabled) + .map { backfilling, on in backfilling && on } + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { hold in + MainActor.assumeIsolated { ScreenIdle.hold(.strapSync, hold) } + } + } +} +#endif diff --git a/StrandiOS/Widgets/LiftLiveActivityController.swift b/StrandiOS/Widgets/LiftLiveActivityController.swift new file mode 100644 index 0000000000..4928f0add6 --- /dev/null +++ b/StrandiOS/Widgets/LiftLiveActivityController.swift @@ -0,0 +1,207 @@ +#if os(iOS) +import Foundation +import ActivityKit +import UIKit + +/// Starts, updates and ends the Lift Log session Live Activity. +/// +/// Modelled on `LiveActivityController` (live HR) and deliberately separate from it: the two answer +/// different questions and have different lifetimes. While a gym session is open this one is the +/// useful banner — it carries the heart rate too — so the app suppresses the HR activity rather than +/// stacking two on the Lock Screen. +/// +/// PUSHES ARE CONTENT-DRIVEN, NOT CLOCK-DRIVEN. The widget's timers tick on their own from dates in +/// the content state, so this only sends an update when something a person would notice changes: +/// the stage, the exercise, the set, the numbers, or the heart rate. Without that, an activity that +/// merely shows a running clock would push once a second for a whole workout and be throttled by +/// ActivityKit for it. +@MainActor +final class LiftLiveActivityController { + private var activity: Activity? + /// A Lift Log banner is on screen — what NOOP's live heart rate banner makes room for (`LiveHRBannerLifecycle`). + var isShowing: Bool { activity != nil } + /// Writes to NOOP's strap log — only when the banner's situation CHANGES (picked up after a restart, + /// or waiting for NOOP to be opened), never per push. + private let log: (String) -> Void + /// Set while a session runs with no banner that NOOP may start, so the reason is logged once. + private var waitingForForeground = false + private var lastPush: Date = .distantPast + private var lastSignature: String? + /// The state the banner is showing, so a heart-rate tick can push a copy of it without the app + /// building a whole presentation again (`updateHeartRate`). + private var lastState: LiftActivityAttributes.ContentState? + /// Cached for the controller's lifetime — the same reasoning as `LiveActivityController`: this is + /// consulted on every push and its value only changes via Settings. + private let authInfo = ActivityAuthorizationInfo() + /// Guards against two pushes both firing `Activity.request` before the first has returned. + private var isStarting = false + /// How long iOS may keep showing the activity as fresh without a push. Generous, because a long + /// rest legitimately produces no content change at all — the clock is ticking client-side. + private static let staleAfter: TimeInterval = 15 * 60 + + /// A bundled sound file of silence. ActivityKit offers an alert only the default sound or a named + /// file, and a chime from a phone on a bench every set is not what the lifter asked for; the strap + /// has already buzzed. + static let silentAlertSound = "lift-step-silence.caf" + + /// What the push for a strap step did about lighting the screen — one strap-log line per step, so a + /// step that did not light can be told apart: NOOP was on screen, no banner was running, or the alert + /// went to iOS and iOS chose (a face-down phone, a Focus, its own limits). + enum LightUp { + case askedIOS, appOnScreen, noBanner + + var logLine: String { + switch self { + case .askedIOS: return "Lift Log: strap step sent to the Lock Screen with a light-up alert" + case .appOnScreen: return "Lift Log: strap step not lighting the Lock Screen — NOOP is open on screen" + case .noBanner: return "Lift Log: strap step not lighting the Lock Screen — no Lift Log banner is running" + } + } + } + + init(log: @escaping (String) -> Void = { _ in }) { + self.log = log + } + + /// Drive the activity from the session's current state. `state` nil means no session is running, + /// which ends any activity that is showing. + /// + /// `alert` is set for the push a strap double-tap causes. It lights a dark Lock Screen on the new + /// step, so a lifter sees what they are on, and the screen goes dark again on the phone's own timer + /// (Utku, 16–17 Sep 2026: "just light up", nothing else). It is an ActivityKit alert, the only way iOS + /// lets an app light the screen, carried on the update the step needs anyway, and it is sent whenever + /// NOOP is not the app on screen. It used to wait for the phone to report itself LOCKED (protected + /// data unavailable), but iOS reports that only about 10 s after the screen goes dark, so a tap soon + /// after it dimmed — right after checking the rest timer, say — lit nothing (gym session, 17 Sep + /// 2026: "sometimes it lights up and sometimes not"). With another app open, iOS shows the step in the + /// Dynamic Island instead. ActivityKit offers no setting for vibration; the sound is silence. + /// Returns what happened about lighting when `alert` was asked for; nil otherwise. + @discardableResult + func update(state: LiftActivityAttributes.ContentState?, alert: Bool = false) -> LightUp? { + guard authInfo.areActivitiesEnabled else { return alert ? .noBanner : nil } + + // A banner the lifter swiped off the Lock Screen, or one iOS ended, takes no more updates: let it + // go, so the session is not left pushing to — and trying to light — a banner nobody can see. + if let current = activity, !Self.isShowing(current) { activity = nil } + // Re-adopt an activity that outlived a previous app session — ActivityKit keeps them alive + // across relaunches, and a fresh controller starts with `activity == nil`. Without this we + // could neither update nor END one already on the Lock Screen, and could spawn a duplicate. + let adopted = activity == nil + ? Activity.activities.first(where: Self.isShowing) : nil + if let adopted { activity = adopted } + + // Shares the existing Live Activity opt-out rather than adding a second switch: a user who + // turned Live Activities off meant all of them. + guard UnitPrefs.liveActivityEnabled(), let state else { + if activity != nil { Task { await end() } } + return alert ? .noBanner : nil + } + if adopted != nil { + log("Lift Log: Lock Screen banner picked up again after NOOP restarted") + waitingForForeground = false + } + + // Everything a person would notice, EXCLUDING the clocks (which tick client-side) and the + // heart rate (handled by its own interval below). + let signature = [ + state.isResting ? "rest" : "work", state.exercise, state.status, + state.detail ?? "", state.next, + "\(state.stageStartedAt.timeIntervalSince1970)", + "\(state.restEndsAt?.timeIntervalSince1970 ?? 0)", + ].joined(separator: "|") + + let contentChanged = signature != lastSignature + let heartRateDue = LiftBannerPushPolicy.heartRateDue( + shown: lastState?.bpm, latest: state.bpm, sinceLastPush: Date().timeIntervalSince(lastPush)) + let content = ActivityContent(state: state, + staleDate: Date().addingTimeInterval(Self.staleAfter)) + + if let activity { + let appOnScreen = UIApplication.shared.applicationState == .active + let lightsScreen = alert && !appOnScreen + guard contentChanged || heartRateDue || lightsScreen else { return alert ? .appOnScreen : nil } + lastSignature = signature + lastState = state + lastPush = Date() + if lightsScreen { + let stepAlert = AlertConfiguration( + title: LocalizedStringResource(stringLiteral: state.exercise), + body: LocalizedStringResource(stringLiteral: state.detail.map { "\(state.status) — \($0)" } + ?? state.status), + sound: .named(Self.silentAlertSound)) + Task { await activity.update(content, alertConfiguration: stepAlert) } + } else { + Task { await activity.update(content) } + } + return alert ? (lightsScreen ? .askedIOS : .appOnScreen) : nil + } else { + // iOS starts a Live Activity only for the app on screen; asked from the background it throws, + // and this runs several times a second. The banner comes back the next time NOOP is opened. + guard UIApplication.shared.applicationState == .active else { + if !waitingForForeground { + waitingForForeground = true + log("Lift Log: no Lock Screen banner — iOS starts one only while NOOP is open, so it " + + "comes back the next time NOOP is opened") + } + return alert ? .noBanner : nil + } + waitingForForeground = false + // Set synchronously before any await, so a second push arriving while `Activity.request` + // is still in flight bails here instead of creating a duplicate activity. + guard !isStarting else { return alert ? .noBanner : nil } + isStarting = true + do { + activity = try Activity.request( + attributes: LiftActivityAttributes(), + content: content, + pushType: nil) + lastSignature = signature + lastState = state + lastPush = Date() + } catch { + activity = nil + } + isStarting = false + // A banner requested just now carries no alert: there was nothing on the Lock Screen to light. + return alert ? .noBanner : nil + } + } + + /// A new live heart rate, straight from the HR stream: the cheap path, called once a second. + /// + /// It never builds a presentation and never starts a banner — only a banner already on the Lock Screen + /// takes a heart-rate push, and only when `LiftBannerPushPolicy` says the number is worth one. Everything + /// else the banner shows comes from `update(state:alert:)`. + func updateHeartRate(_ bpm: Int?) { + guard let activity, let state = lastState, state.bpm != bpm else { return } + guard LiftBannerPushPolicy.heartRateDue(shown: state.bpm, latest: bpm, + sinceLastPush: Date().timeIntervalSince(lastPush)) else { return } + var next = state + next.bpm = bpm + lastState = next + lastPush = Date() + let content = ActivityContent(state: next, staleDate: Date().addingTimeInterval(Self.staleAfter)) + Task { await activity.update(content) } + } + + func end() async { + // End every lift activity, not just the cached handle — covers a straggler from a previous + // app session that was never re-adopted, and any rare duplicate. + for act in Activity.activities { + await act.end(nil, dismissalPolicy: .immediate) + } + activity = nil + lastSignature = nil + lastState = nil + waitingForForeground = false + } + + /// Still on the Lock Screen and taking updates: not ended by the app or iOS, not swiped away. + private static func isShowing(_ activity: Activity) -> Bool { + switch activity.activityState { + case .ended, .dismissed: return false + default: return true + } + } +} +#endif diff --git a/StrandiOS/Widgets/LiveActivityController.swift b/StrandiOS/Widgets/LiveActivityController.swift index 90f33a4c50..f17d3944f8 100644 --- a/StrandiOS/Widgets/LiveActivityController.swift +++ b/StrandiOS/Widgets/LiveActivityController.swift @@ -1,13 +1,23 @@ #if os(iOS) import Foundation import ActivityKit +import Combine +import UIKit -/// Starts, updates, and ends the live-HR Live Activity. The activity appears on the Lock Screen and -/// in the Dynamic Island while the strap is bonded and streaming heart rate. +/// Starts, updates, and ends the live-HR Live Activity on the Lock Screen and in the Dynamic Island: the heart rate +/// while the strap measures it, the dash while it does not. It follows the strap from process start (`follow`). @MainActor final class LiveActivityController { private var activity: Activity? + /// What the banner reads — the live heart rate, the link, the day's recovery and effort — set once by `follow`. + private weak var model: AppModel? + /// Whether the Lift Log banner is on screen, which the heart rate banner makes room for. + private var standsAside: () -> Bool = { false } + private var cancellables: Set = [] private var lastPush: Date = .distantPast + /// What the banner was last pushed with, so an unchanged banner is not pushed again + /// (`LiveHRBannerPushPolicy`). Nil until this controller pushes, and again once it ends the activity. + private var shownState: NOOPActivityAttributes.ContentState? /// Cached `ActivityAuthorizationInfo` — `update` runs at ~1 Hz off the live HR stream, and /// instantiating this system bridge per tick is needless allocation. ActivityKit's auth status /// only changes via Settings, so caching for the controller's lifetime is safe. @@ -17,71 +27,210 @@ final class LiveActivityController { /// yet), so without this guard two close-together HR samples could both fire `Activity.request` /// and create duplicate Live Activities. private var isStarting = false - /// How long after the last push iOS may keep showing the activity as fresh. The activity is - /// refreshed every ~2 s while streaming, so this never bites a live session; it auto-greys a - /// frozen activity if the app is suspended/killed without an explicit end (a missed-tick safety net - /// on top of the connected-driven end below). - private static let staleAfter: TimeInterval = 120 + /// When the banner being fed was started, for iOS's eight-hour limit (`LiveHRBannerLifecycle.renewAfter`). Kept in + /// the defaults with the banner's id, because a banner outlives the run that started it. Nil when unknown. + private var startedAt: Date? + private static let startedKey = "liveActivity.hr.startedAt" + /// An end is in flight, so the ticks that arrive meanwhile neither end it again nor log it twice. + private var isEnding = false + /// iOS refused a start, and it was logged: once, not on every tick while NOOP is on screen. + private var refusalLogged = false + /// How long after the last push iOS treats the banner as fresh; after that the banner draws the dash + /// (`NOOPLiveActivity.shownBpm`). A WHOOP 5.0 taken off the wrist goes quiet, and with nothing arriving iOS + /// suspends NOOP, so no timer of NOOP's can clear the number: iOS's own stale date is what does it, in at most + /// this long (a tester's log, 23 Sep 2026). A steady number is re-pushed once half of this has passed + /// (`LiveHRBannerPushPolicy`), so a banner fed by a worn strap never goes stale. + static let staleAfter: TimeInterval = 30 - /// Drive the activity from the latest live values. Lazily starts when the strap is CONNECTED (the - /// live link, not the sticky "paired" flag) and a heart rate is present; ends the moment the link - /// drops. Throttled to ~once every 2 s so we stay well under the Live Activity update budget. - func update(bpm: Int?, recovery: Int?, connected: Bool, effort: Int? = nil) { + /// Follow the strap from process start, not from a screen. iOS starts NOOP in the background — the strap + /// reconnecting, a sync, the Sync Strap shortcut — and a process started that way need not build any screen (the + /// shortcut's never does), while a banner the previous run left on the Lock Screen is there to be picked up and + /// fed from the first reading. Called once, from the app's `init`, like the Lift Log's own resume. + func follow(_ model: AppModel, standsAside: @escaping () -> Bool) { + self.model = model + self.standsAside = standsAside + // A `@Published` sink runs in willSet: each hands on the value being written and reads the other from `live`. + // AppModel's own sinks, subscribed before these, have already folded the value into its median (`bpm`). + model.live.$heartRate + .sink { [weak self, weak model] hr in + guard let model else { return } + self?.refreshBanner(heartRate: hr, connected: model.live.connected) + } + .store(in: &cancellables) + model.live.$connected + .sink { [weak self, weak model] isConnected in + guard let model else { return } + self?.refreshBanner(heartRate: model.live.heartRate, connected: isConnected) + } + .store(in: &cancellables) + // The switch is the one way to be rid of the banner, so it acts at once — not at the next heart-rate tick, + // which a strap off the wrist may not send for hours. + NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification) + .receive(on: DispatchQueue.main) + .map { _ in UnitPrefs.liveActivityEnabled() } + .prepend(UnitPrefs.liveActivityEnabled()) + .removeDuplicates() + .dropFirst() + .sink { [weak self] _ in self?.refreshBanner() } + .store(in: &cancellables) + } + + /// NOOP came on screen, the only time iOS lets it start the banner: offered now rather than at the next heart-rate + /// change, which a strap off the wrist may not bring for a long while. Said by the caller, from the scene phase, + /// because `applicationState` can still read inactive while the scene turns active. + func appBecameActive() { + refreshBanner(appActive: true) + } + + private func refreshBanner(appActive: Bool? = nil) { + guard let model else { return } + refreshBanner(heartRate: model.live.heartRate, connected: model.live.connected, appActive: appActive) + } + + /// #911: recovery and effort come from the SAME shared `Repository.widgetAnchor` the widget and the watch use, so + /// the banner cannot name a different day at the rollover; memoized, because this runs on every heart-rate tick + /// (re-deriving it once scanned the whole history, #1051). + private func refreshBanner(heartRate: Int?, connected: Bool, appActive: Bool? = nil) { + guard let model else { return } + let day = model.repo.cachedWidgetAnchor() + update(bpm: connected ? (model.bpm ?? heartRate) : nil, recovery: day?.recovery.map { Int($0.rounded()) }, + connected: connected, standsAside: standsAside(), + appActive: appActive ?? (UIApplication.shared.applicationState == .active), + effort: day?.strain.map { Int($0.rounded()) }) + } + + /// Drive the activity from the latest live values (`LiveHRBannerLifecycle` decides start / push / end). Starts + /// only in the foreground (`appActive`), with the strap CONNECTED (the live link, not the sticky "paired" flag), + /// before a heart rate arrives if need be; a running banner shows the dash through a dropped link or a strap + /// that is not measuring, and ends only when its switch is off or the Lift Log banner takes the screen + /// (`standsAside`). Pushed when what it shows changes, and often enough to stay fresh (`LiveHRBannerPushPolicy`, + /// `staleAfter`). + private func update(bpm: Int?, recovery: Int?, connected: Bool, standsAside: Bool, appActive: Bool, + effort: Int?) { guard authInfo.areActivitiesEnabled else { return } + // A banner iOS ended (after about eight hours) or the user swiped away is gone: forget it, so the next time + // NOOP is on screen it starts one again rather than pushing to nothing. (One NOOP is ending is not gone yet.) + if !isEnding, let activity, !Self.isShowing(activity) { + self.activity = nil + shownState = nil + startedAt = nil + log("gone from the Lock Screen (ended by iOS or dismissed); started again when NOOP is next on screen") + } // Re-adopt an activity that outlived a previous app session. ActivityKit keeps Live Activities // alive across launches/relaunches, but a fresh controller starts with `activity == nil`, so // without recovering the handle here we can neither update nor END an already-showing activity // — which made the #336 opt-out a no-op (#341: toggle off, heart stays) and risked spawning a // duplicate on the start path below. Done on the HR tick rather than in `init` because // `Activity.activities` isn't reliably hydrated at the instant of process launch. - if activity == nil { activity = Activity.activities.first } - - // User opt-out (#336): if the in-app toggle is off, never start — and end any activity that's - // already showing (the user just turned it off; this fires on the next ~1 Hz HR tick). - guard UnitPrefs.liveActivityEnabled() else { - if activity != nil { Task { await end() } } - return + if activity == nil, let adopted = Activity.activities.first(where: Self.isShowing) { + activity = adopted + startedAt = (UserDefaults.standard.dictionary(forKey: Self.startedKey)?[adopted.id] as? Double) + .map(Date.init(timeIntervalSince1970:)) + log("picked up the one already on the Lock Screen") } - // End the moment the live link drops — `bonded` stays true across every disconnect (it means - // "this strap is paired"), so keying off it left a frozen, fabricated "live" HR on the Lock - // Screen / Dynamic Island indefinitely after the strap went out of range. - if !connected { + // The switch (#336) and the gym banner on screen end it; nothing that passes does (`LiveHRBannerLifecycle`). + let now = Date() + let switchOn = UnitPrefs.liveActivityEnabled() + let age = startedAt.map { now.timeIntervalSince($0) } + let step = LiveHRBannerLifecycle.step( + switchOn: switchOn, standsAside: standsAside, linkUp: connected, + showing: activity != nil, age: age, appActive: appActive) + switch step { + case .nothing: return + case .end: + guard !isEnding else { return } + isEnding = true + log(switchOn ? "ended: the Lift Log banner takes its place" : "ended: its switch is off") Task { await end() } return + case .start, .push, .renew: break } - guard bpm != nil else { return } - let state = NOOPActivityAttributes.ContentState(bpm: bpm, recovery: recovery, bonded: connected, - effort: effort) - let staleDate = Date().addingTimeInterval(Self.staleAfter) + // Link down: the dash, never the last number (`bonded` stays true across a disconnect, and keying off it once + // left a fabricated "live" HR standing). No timed end: a timer in a suspended app fires at its next wake, + // which is typically the strap coming back — exactly when the banner should stay. + let state = NOOPActivityAttributes.ContentState(bpm: connected ? bpm : nil, recovery: recovery, + bonded: connected, effort: effort) - if let activity { - guard Date().timeIntervalSince(lastPush) > 2 else { return } - lastPush = Date() + if step == .renew, let old = activity { + // The fresh banner first, then the old one goes, so the Lock Screen is never without one; if iOS refuses + // the fresh one, the old one stays. + if start(state, at: now) { + log("renewed after \(age.map { "\(Int($0 / 60)) min" } ?? "an unknown time"), " + + "so iOS's eight-hour limit starts again") + Task { await old.end(nil, dismissalPolicy: .immediate) } + } + } else if let activity { + // The number giving way to the dash (the strap off the wrist, the link dropping) is pushed at once: no + // tick follows it, so a push skipped for spacing would leave the last number standing. + guard LiveHRBannerPushPolicy.due(shown: shownState, next: state, reading: \.bpm, + sinceLastPush: now.timeIntervalSince(lastPush), + staleAfter: Self.staleAfter) else { return } + if let shown = shownState, (shown.bpm == nil) != (state.bpm == nil) { logReading(state) } + lastPush = now + shownState = state + let staleDate = now.addingTimeInterval(Self.staleAfter) Task { await activity.update(ActivityContent(state: state, staleDate: staleDate)) } - } else { - // Set the start gate SYNCHRONOUSLY before any await so a second `update` arriving on the - // main actor while `Activity.request` is still in flight bails here instead of issuing a - // second request. The 2-second throttle above only guards the update path. - guard !isStarting else { return } - isStarting = true - do { - activity = try Activity.request( - attributes: NOOPActivityAttributes(title: String(localized: "Live HR")), - content: ActivityContent(state: state, staleDate: staleDate), - pushType: nil - ) - lastPush = Date() - } catch { - activity = nil + } else if start(state, at: now) { + log(state.bpm == nil ? "started, showing – until a heart rate arrives" : "started") + } + } + + /// Ask iOS for a new banner, which it grants only while NOOP is on screen. Returns whether it did. + @discardableResult + private func start(_ state: NOOPActivityAttributes.ContentState, at now: Date) -> Bool { + // Set the start gate SYNCHRONOUSLY before any await so a second `update` arriving on the + // main actor while `Activity.request` is still in flight bails here instead of issuing a + // second request. The 2-second throttle above only guards the update path. + guard !isStarting else { return false } + isStarting = true + defer { isStarting = false } + do { + let started = try Activity.request( + attributes: NOOPActivityAttributes(title: String(localized: "Live HR")), + content: ActivityContent(state: state, staleDate: now.addingTimeInterval(Self.staleAfter)), + pushType: nil + ) + activity = started + startedAt = now + UserDefaults.standard.set([started.id: now.timeIntervalSince1970], forKey: Self.startedKey) + lastPush = now + shownState = state + refusalLogged = false + return true + } catch { + if !refusalLogged { + refusalLogged = true + log("iOS did not start it: \(error.localizedDescription)") } - isStarting = false + return false } } - func end() async { + /// The banner turning to the dash, or back to a number: pushed at once (`LiveHRBannerPushPolicy`), and logged. + private func logReading(_ state: NOOPActivityAttributes.ContentState) { + if state.bpm != nil { + log("heart rate again") + } else { + log(state.bonded ? "– (strap connected, no heart rate)" : "– (strap not connected)") + } + } + + /// One line in NOOP's strap log for each thing that happens to the banner: started, picked up, renewed, ended, + /// gone, and each turn to the dash and back. Rare, so always on. A tester's banner once showed the dash for a + /// strap he was wearing, and a log without a word about the banner could not say why (24 Sep 2026). + private func log(_ line: String) { + model?.live.append(log: AppModel.stamped("Live HR banner: " + line)) + } + + /// Still on the Lock Screen and able to take an update: not ended by iOS, the user or NOOP. + private static func isShowing(_ activity: Activity) -> Bool { + activity.activityState == .active || activity.activityState == .stale + } + + private func end() async { // End every NOOP Live Activity, not just our cached handle — covers a straggler from a prior // session we never re-adopted (#341) and any rare duplicate. Iterating the live list is the // only way to reach activities this controller instance never started. @@ -89,6 +238,9 @@ final class LiveActivityController { await act.end(nil, dismissalPolicy: .immediate) } self.activity = nil + shownState = nil + startedAt = nil + isEnding = false } } #endif diff --git a/StrandiOS/Widgets/SyncLiveActivityController.swift b/StrandiOS/Widgets/SyncLiveActivityController.swift new file mode 100644 index 0000000000..195dcb9b99 --- /dev/null +++ b/StrandiOS/Widgets/SyncLiveActivityController.swift @@ -0,0 +1,192 @@ +#if os(iOS) +import ActivityKit +import Combine +import Foundation +import UIKit + +/// Starts, updates and ends the strap-sync Live Activity. +/// +/// Modelled on `LiveActivityController` (live HR) and `LiftLiveActivityController`, and separate from +/// both for the same reason they are separate from each other. Attached once at launch to `LiveState`, +/// rather than as a modifier on `StrandiOSApp.body` (that chain is past the type-checker's budget, and +/// this must also run in a process the Sync Strap shortcut launched with no scene at all). +/// +/// WHO MAY START IT. iOS only lets an app start a Live Activity from the foreground, or from an App Intent +/// that adopts `LiveActivityIntent`. So a sync started in the foreground starts it here on the +/// `backfilling` edge; the Sync Strap shortcut starts it itself through `startFromShortcut`, while the +/// link is still coming up; and an automatic background sync (the 15-minute tick) can only update an +/// activity that already exists — it never starts one, and this never pretends otherwise. +/// +/// PUSHES ARE CONTENT-DRIVEN. The elapsed clock ticks client-side from `startedAt`; a push goes out only +/// when the chunk count or the phase changes, and chunk pushes no more than every 2 s. The words come +/// from `SyncActivityCopy`, which is where they are tested. +@MainActor +final class SyncLiveActivityController { + static let shared = SyncLiveActivityController() + + private var activity: Activity? + private var cancellables: Set = [] + /// For the strap log: a refused `Activity.request` must say so, or "no island" has no evidence. + private weak var live: LiveState? + private var startedAt = Date() + private var lastPush: Date = .distantPast + private var lastPushedChunks = -1 + private let authInfo = ActivityAuthorizationInfo() + private var isStarting = false + /// How long iOS may show the activity as fresh without a push. Generous: a long history recovery can + /// go minutes between acked chunks, and a shortcut-launched "connecting" run has no pushes at all + /// until the strap answers. Matches `BLEManager.pendingManualSyncTTL`, after which a parked request + /// no longer fires, so a connecting island that never syncs greys out at the same moment. + private static let staleAfter: TimeInterval = 600 + /// How long the final "Synced · N chunks" / "Sync interrupted" state stays before dismissing. + private static let finalShownFor: TimeInterval = 8 + private static let chunkMinInterval: TimeInterval = 2 + + /// Asked before a foreground sync STARTS a banner; true holds it back. The Lift Log sets it for the + /// length of a gym session, whose own banner is the one on the Lock Screen. A banner the Sync Strap + /// shortcut started still updates and ends as before. + var holdsBackNewBanner: () -> Bool = { false } + + private init() {} + + func attach(to live: LiveState) { + self.live = live + live.$backfilling + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self, weak live] on in + guard let self, let live else { return } + if on { + self.syncStarted(live) + } else { + // One hop later, deliberately: `exitBackfilling` clears `backfilling` FIRST and stamps + // `lastSyncedAt` / `lastSyncError` further down the same synchronous call, so reading + // the outcome on the edge itself would see the previous sync's values. + DispatchQueue.main.async { self.syncEnded(live) } + } + } + .store(in: &cancellables) + live.$syncChunksThisSession + .receive(on: DispatchQueue.main) + .sink { [weak self, weak live] chunks in + guard let self, let live, live.backfilling else { return } + self.pushProgress(live, chunks: chunks) + } + .store(in: &cancellables) + } + + /// The Sync Strap shortcut's entry: may start the activity from the background because the intent is + /// a `LiveActivityIntent`. Shows "Connecting…" until the link is up, or the running sync's state if + /// one is already going. + func startFromShortcut(live: LiveState) { + adoptExisting() + startedAt = Date() + request(state: live.backfilling ? state(syncing(live)) : state(SyncActivityCopy.connecting())) + } + + /// The shortcut found no model, or asked and nothing started: do not leave a "Connecting…" island up + /// for a sync that is not coming. + func shortcutDidNotStart() { + guard let activity else { return } + end(activity, with: state(SyncActivityCopy.notStarted())) + } + + /// Foreground housekeeping: a "connecting" island from a shortcut whose sync never came is ended here + /// rather than left greyed on the Lock Screen. Called on the `.active` scene phase. + func reconcile(live: LiveState) { + adoptExisting() + guard let activity, !live.backfilling, + activity.content.state.phase == .connecting, + Date().timeIntervalSince(activity.content.state.startedAt) > Self.staleAfter else { return } + shortcutDidNotStart() + } + + // MARK: - Edges + + private func syncStarted(_ live: LiveState) { + adoptExisting() + if let activity { + // Started by the shortcut while connecting: move it to syncing on the same clock. + if activity.content.state.phase == .connecting { startedAt = activity.content.state.startedAt } + push(activity, state(syncing(live))) + return + } + // Only the foreground may start one. A background automatic sync stays silent, honestly. + guard UIApplication.shared.applicationState == .active, !holdsBackNewBanner() else { return } + startedAt = Date() + request(state: state(syncing(live))) + } + + private func pushProgress(_ live: LiveState, chunks: Int) { + guard let activity, chunks != lastPushedChunks, + Date().timeIntervalSince(lastPush) >= Self.chunkMinInterval else { return } + push(activity, state(syncing(live))) + } + + private func syncEnded(_ live: LiveState) { + guard let activity else { return } + let chunks = max(lastPushedChunks, live.syncChunksThisSession, 0) + end(activity, with: state(SyncActivityCopy.final(lastSyncError: live.lastSyncError, chunks: chunks))) + } + + // MARK: - Helpers + + private func syncing(_ live: LiveState) -> SyncActivityCopy.Line { + SyncActivityCopy.syncing(chunks: live.syncChunksThisSession, pagesBehind: live.pagesBehindAtConnect) + } + + private func state(_ line: SyncActivityCopy.Line) -> SyncActivityAttributes.ContentState { + .init(phase: SyncActivityAttributes.Phase(rawValue: line.phase.rawValue) ?? .syncing, + chunks: line.chunks, startedAt: startedAt, status: line.status, detail: line.detail) + } + + /// Re-adopt an activity that outlived a previous process — the shortcut's "connecting" island can + /// easily be older than the process that finishes the sync. + private func adoptExisting() { + if activity == nil { activity = Activity.activities.first } + } + + private func request(state: SyncActivityAttributes.ContentState) { + // Each refusal names its gate. These are rare-event lines (one per attempted start), so they stay + // always-on rather than behind a Test Centre domain. + guard authInfo.areActivitiesEnabled else { + live?.append(log: "Sync activity: not started — Live Activities are off for NOOP in iOS Settings") + return + } + guard UnitPrefs.syncLiveActivityEnabled() else { + live?.append(log: "Sync activity: not started — \"Strap sync in Dynamic Island\" is off in NOOP Settings") + return + } + if let activity { push(activity, state); return } + guard !isStarting else { return } + isStarting = true + do { + activity = try Activity.request( + attributes: SyncActivityAttributes(title: String(localized: "Strap sync")), + content: ActivityContent(state: state, staleDate: Date().addingTimeInterval(Self.staleAfter)), + pushType: nil) + lastPush = Date() + lastPushedChunks = state.chunks + live?.append(log: "Sync activity: started (\(state.phase.rawValue), app state \(UIApplication.shared.applicationState.rawValue))") + } catch { + activity = nil + live?.append(log: "Sync activity: request refused — \(error) (app state \(UIApplication.shared.applicationState.rawValue))") + } + isStarting = false + } + + private func push(_ activity: Activity, _ state: SyncActivityAttributes.ContentState) { + lastPush = Date() + lastPushedChunks = state.chunks + let content = ActivityContent(state: state, staleDate: Date().addingTimeInterval(Self.staleAfter)) + Task { await activity.update(content) } + } + + private func end(_ activity: Activity, with state: SyncActivityAttributes.ContentState) { + Task { await activity.end(ActivityContent(state: state, staleDate: nil), + dismissalPolicy: .after(Date().addingTimeInterval(Self.finalShownFor))) } + self.activity = nil + lastPushedChunks = -1 + } +} +#endif diff --git a/StrandiOS/Widgets/WidgetPublish.swift b/StrandiOS/Widgets/WidgetPublish.swift index 2f75feb1fb..4950e57875 100644 --- a/StrandiOS/Widgets/WidgetPublish.swift +++ b/StrandiOS/Widgets/WidgetPublish.swift @@ -3,6 +3,26 @@ import Foundation import WidgetKit extension WidgetSnapshot { + /// The ACTIVE device's charge for the widget (#2075). + /// + /// `LiveState.batteryPct` is the WHOOP's, and `LiveState` is one object every live source writes + /// into, so publishing it unconditionally put the strap's charge on the widget while a ring was the + /// active device. Same rule as the Live Console, through the same seam. + /// + /// `@MainActor` like both publishers that call it: `AppModel.deviceRegistry` and `LiveState` are + /// main-actor isolated, so a nonisolated helper cannot read them. + @MainActor + static func activeBatteryPct(from model: AppModel) -> Int? { + LiveConsoleReadout.batteryPercent( + activeIsWhoop: LiveConsoleReadout.activeIsWhoop( + devices: model.deviceRegistry?.devices ?? [], + activeId: model.deviceRegistry?.activeDeviceId, + ), + whoopPct: model.live.batteryPct, + ringPct: model.live.ouraBatteryPct, + ) + } + /// Build a glance snapshot from the live app state and publish it to the shared App Group, then /// ask WidgetKit to refresh. Called when the app becomes active and after a Health sync. /// @@ -21,6 +41,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 @@ -59,10 +80,28 @@ extension WidgetSnapshot { } return "\(Int(stored.rounded()))" } + // #2040: today's stress curve. Self-gating on a cheap heart-rate fingerprint, so a publish that + // changed nothing costs one indexed COUNT and no rows. Only the FULL path scores it; the live + // fast path below reuses the previous snapshot and so carries the curve forward untouched. + let stress = await StressDayCurve.today(repo: model.repo) + // The widget's own point type is built HERE, at the one place that needs it: `StressPoint` + // lives in the iOS/widget shared sources, and the producer is now also read by the Today card, + // which is compiled for macOS too. + let stressPoints: [StressPoint]? = stress.map { scored in + scored.result.timeline.map { + // `startTs` is the wall-clock bucket start with the local shift already undone, so it + // is a true instant and formats correctly against the device's zone. + StressPoint(ts: Int64($0.startTs), level: $0.level, moving: $0.maskedForActivity) + } + } + // Loaded ONCE for the carry-forward below. Reaching for `load()` in each of the two arguments + // would decode the App Group blob twice on any publish that could not score, and this file + // already went to the trouble of removing one such decode from the live path. + let storedStress: WidgetSnapshot? = stress == nil ? load() : nil let snap = WidgetSnapshot( recovery: day?.recovery.map { Int($0.rounded()) }, bpm: model.bpm ?? model.live.heartRate, - batteryPct: model.live.batteryPct.map { Int($0.rounded()) }, + batteryPct: activeBatteryPct(from: model), bonded: model.live.bonded, updated: Date(), // Stored 0–100 axis for ring fill; display string carries the #313 scale. @@ -71,7 +110,11 @@ extension WidgetSnapshot { hrv: day?.avgHrv.map { Int($0.rounded()) }, restingHr: day?.restingHr, effortDisplay: effortDisplay, - effortWhoop: effortScale == .whoop + effortWhoop: effortScale == .whoop, + // nil when the curve could not be scored at all, which must not blank a widget that already + // has one: carry the stored values forward instead of publishing an absence. + stressSeries: stressPoints ?? storedStress?.stressSeries, + stressDay: stress?.day ?? storedStress?.stressDay ) saveAndReloadIfChanged(snap) } @@ -95,7 +138,7 @@ extension WidgetSnapshot { // ONCE per tick instead of loading it again inside saveAndReloadIfChanged. let previous = snap snap.bpm = model.bpm ?? model.live.heartRate - snap.batteryPct = model.live.batteryPct.map { Int($0.rounded()) } + snap.batteryPct = Self.activeBatteryPct(from: model) snap.bonded = model.live.bonded snap.updated = now saveAndReloadIfChanged(snap, previous: previous) @@ -110,15 +153,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 +220,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..901eff66c0 --- /dev/null +++ b/StrandiOSShared/HrTrace.swift @@ -0,0 +1,208 @@ +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 + + /// What counts as a GAP. Deliberately NOT the Today chart's "more than one bucket": that chart reads + /// a fixed DB grid, where a missing bucket really is missing data, while this series only gains a + /// point when the app PUBLISHES one, and background scheduling routinely skips a minute. At a + /// one-bucket threshold that ordinary jitter would shatter a healthy trace into dots, which is a + /// worse lie than the joined line being fixed. + /// + /// Set to `HrDisplay.staleCap` rather than to a number picked here, so the line breaks exactly where + /// the widget would already have dropped the headline reading for being too old to represent HR at + /// all. `testTheGapThresholdMatchesTheStaleCap` pins the two together. + public static let gapSec: Int64 = 15 * bucketSec + + /// 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. + /// + /// `startsRun` means LIFT THE PEN before this point: nothing was recorded for `gapSec` before it. + /// The trace joined every point unconditionally, and because x is mapped by TIME rather than by + /// index, a 90-minute disconnect inside the 3-hour window drew as one confident diagonal across + /// half the widget. The gap was already the right width; it was the line across it that was never + /// measured: the same defect #2082 describes on the Today sparkline, on a second surface. + public struct Pt: Equatable { + public let x: CGFloat + public let y: CGFloat + public let startsRun: Bool + + public init(x: CGFloat, y: CGFloat, startsRun: Bool = false) { + self.x = x + self.y = y + self.startsRun = startsRun + } + } + + /// The trace split into runs of consecutive readings: each range is a stretch the strap recorded + /// without a break, and the pen lifts between one run and the next. A series with no gaps is one + /// run, which is the common case and draws exactly as it always did. + public static func runs(_ points: [Pt]) -> [ClosedRange] { + guard !points.isEmpty else { return [] } + var out: [ClosedRange] = [] + var start = 0 + for i in 1.. [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.enumerated().map { i, 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, startsRun: i > 0 && p.ts - series[i - 1].ts > gapSec) + } + } + + /// 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/LiftActivityAttributes.swift b/StrandiOSShared/LiftActivityAttributes.swift new file mode 100644 index 0000000000..8a64a5d8b3 --- /dev/null +++ b/StrandiOSShared/LiftActivityAttributes.swift @@ -0,0 +1,57 @@ +#if os(iOS) +import Foundation +import ActivityKit + +/// Live Activity attributes for a running Lift Log session — the minimised session bar, on the Lock +/// Screen and in the Dynamic Island. +/// +/// Deliberately a SEPARATE activity type from `NOOPActivityAttributes` (live HR). They answer +/// different questions and have different lifetimes: the HR activity lives as long as the strap is +/// streaming, this one as long as a gym session is open. While a session is running this one is the +/// useful surface — it carries the heart rate too — so the app suppresses the HR activity rather +/// than stacking two banners on the Lock Screen. +/// +/// TIME IS CARRIED AS DATES, NOT AS A FORMATTED STRING. The widget renders them with +/// `Text(timerInterval:)`, which ticks on its own without the app pushing anything. Pushing a new +/// content state every second to animate a clock would burn the Live Activity update budget and +/// still look worse. +public struct LiftActivityAttributes: ActivityAttributes { + public struct ContentState: Codable, Hashable { + /// Amber rest vs green working — the same colour language as the sheet and the bar. + public var isResting: Bool + /// The exercise being worked, or rested from. + public var exercise: String + /// "Set 2" / "Resting after set 2" — resolved app-side so the wording matches the bar. + public var status: String + /// "8 x 30 kg", already unit-converted, because the unit preference lives in the app. + /// Nil when neither reps nor weight is known. + public var detail: String? + public var bpm: Int? + /// "Next: Set 3 · Bench press", localized APP-SIDE (`LiftSessionController.nextLine`). The widget + /// extension ships no string catalog, so every word it renders has to arrive already translated + /// — the same reason `status` and `detail` are strings rather than numbers. + public var next: String + /// When the current stage began — the widget counts UP from here while working. + public var stageStartedAt: Date + /// When the running rest is due to end; the widget counts DOWN to it. Nil while working. + public var restEndsAt: Date? + + public init(isResting: Bool, exercise: String, status: String, detail: String?, + bpm: Int?, next: String, + stageStartedAt: Date, restEndsAt: Date?) { + self.isResting = isResting + self.exercise = exercise + self.status = status + self.detail = detail + self.bpm = bpm + self.next = next + self.stageStartedAt = stageStartedAt + self.restEndsAt = restEndsAt + } + } + + /// Nothing is fixed for the life of a session: everything the banner shows can change mid-session and + /// travels in `ContentState`. + public init() {} +} +#endif diff --git a/StrandiOSShared/StressTrace.swift b/StrandiOSShared/StressTrace.swift new file mode 100644 index 0000000000..4f9245e481 --- /dev/null +++ b/StrandiOSShared/StressTrace.swift @@ -0,0 +1,252 @@ +import Foundation + +/// One hour on the widget's stress trace. +/// +/// `level` is the shared 0-3 proxy, nil when the hour was not scored. `moving` separates the two +/// reasons it can be nil: the motion gate masked an AMBULATORY hour (exertion raises heart rate on its +/// own, so it is deliberately not scored as stress), or there was not enough signal. Both break the +/// line; only the first earns a mark along the base. +/// +/// `Sendable` for the same reason `HrPoint` is: a SwiftUI `Shape` holding these is itself Sendable, and +/// carrying a non-Sendable stored property there is an error in a future language mode. Three immutable +/// value fields, so it is Sendable by inspection rather than by assertion. +public struct StressPoint: Codable, Equatable, Sendable { + public let ts: Int64 + public let level: Double? + public let moving: Bool + public init(ts: Int64, level: Double?, moving: Bool = false) { + self.ts = ts + self.level = level + self.moving = moving + } +} + +/// The pure half of the stress widget: what the curve CONTAINS and where its ink goes. +/// +/// Swift twin of the Kotlin `StressTrace`. The domain, the gap rule, the band threshold and the tick +/// choices are the same decisions on both platforms and must not be made twice: a widget that scaled +/// differently, or drew through a hole, would be a different reading of the same day. +/// +/// TWO THINGS DIFFER FROM `HrTrace`, and both are deliberate. +/// +/// The domain is FIXED at `domainMax`, not normalised to the day's own range. Heart rate has no +/// absolute meaning at widget size, so its trace spreads whatever range it has across the full box. A +/// stress score does have one: the bands are what the number means. Normalising here would redraw a +/// flat calm day as a dramatic one, which is precisely the lie the fixed domain prevents. +/// +/// And the series is REPLACED rather than appended to. The HR trace folds a live push into a rolling +/// window; today's stress is a whole scored day that arrives complete each time it is scored, so there +/// is no retention rule, no clock-jump guard and no cap. Yesterday is overwritten, not merged. +/// +/// What deliberately does NOT port is Android's `encode`/`decode`: it packs the series into a +/// SharedPreferences string because that is what it has, while the snapshot here is already `Codable` +/// and carries `[StressPoint]` directly. Nor does the bitmap sizing, for the reason `HrTrace` gives: +/// WidgetKit strokes a vector and has no payload budget to fit inside. +public enum StressTrace { + + /// Top of the scored range, the same 0-3 scale the gauge and the screen use. + public static let domainMax: Double = 3.0 + + /// Floor of the HIGH band, the level at which an hour earns a dot. + /// + /// This is `StrandAnalytics.DaytimeStress.highBandFloor`, RESTATED rather than referenced, because + /// the widget extension links no packages: everything it draws with has to live in shared sources + /// that import Foundation and nothing else. Android has no such wall and reads the analytics + /// constant directly, so this is the one place the two platforms express the same threshold + /// differently. `StressTraceTests` asserts the two are equal from the app target, which can see + /// both, so the copy cannot drift silently. + public static let highBandFloor: Double = 2.0 + + /// A point in the curve's box, origin top-left. + public struct Pt: Equatable, Sendable { + public let x: CGFloat + public let y: CGFloat + public init(x: CGFloat, y: CGFloat) { + self.x = x + self.y = y + } + } + + /// The numbers the header shows. Nil when no hour was scored. + public struct Stats: Equatable, Sendable { + /// Mean across SCORED hours, the figure the screen prints as the day's average. + public let mean: Double + /// Highest scored hour. + public let peak: StressPoint + /// How many hours carry a score. + public let scoredHours: Int + /// How many were masked as movement rather than scored. + public let movingHours: Int + } + + public static func stats(_ series: [StressPoint]) -> Stats? { + let scored = series.filter { $0.level != nil } + guard let first = scored.first else { return nil } + var peak = first + var sum = 0.0 + for p in scored { + sum += p.level ?? 0 + if (p.level ?? 0) > (peak.level ?? 0) { peak = p } + } + return Stats(mean: sum / Double(scored.count), + peak: peak, + scoredHours: scored.count, + movingHours: series.filter(\.moving).count) + } + + /// Prints a stress level to one decimal using the same arithmetic as Android. + /// + /// `String(format: "%.1f")` rounds exact halves to even, while Kotlin's `roundToInt` rounds + /// them away from zero. Building the tenths explicitly keeps values such as 0.25, 1.25 and 2.25 + /// identical on both platforms, and also keeps the decimal separator independent of locale. + public static func formatLevel(_ value: Double) -> String { + let domainTenths = Int((domainMax * 10).rounded(.toNearestOrAwayFromZero)) + let tenths = min(max(Int((value * 10).rounded(.toNearestOrAwayFromZero)), 0), domainTenths) + return "\(tenths / 10).\(tenths % 10)" + } + + /// Prints the LF/HF ratio to one decimal with the same tenths arithmetic as `formatLevel`. + /// + /// The two platforms disagreed through their printf-family formatters in OPPOSITE directions — + /// Java's `%.1f` rounds half UP where C's rounds half to EVEN — so an exact quotient such as + /// 9/4 spelled "2.3" on Android and "2.2" here. A ratio has no domain ceiling, so only the + /// floor is braced; band powers cannot go negative, but the brace keeps the two surfaces + /// identical rather than trusting that. + public static func formatRatio(_ value: Double) -> String { + let tenths = max(Int((value * 10).rounded(.toNearestOrAwayFromZero)), 0) + return "\(tenths / 10).\(tenths % 10)" + } + + /// The one placement rule, shared so a dot and its vertex cannot land apart. + private static func place(ts: Int64, level: Double, t0: Int64, span: CGFloat, + width: CGFloat, height: CGFloat) -> Pt { + let x = span <= 0 ? 0 : CGFloat(ts - t0) / span * width + let fraction = min(max(CGFloat(level / domainMax), 0), 1) + return Pt(x: x, y: height - fraction * height) + } + + /// The curve as CONTIGUOUS RUNS of scored hours, each already in the box's coordinates. + /// + /// Runs rather than one list because an unscored hour is a hole in the day, and a line drawn + /// straight across it would invent a reading for an hour that has none. The screen's own caption + /// promises bare gaps, so the widget owes the same. + /// + /// X is placed on the hour's position in the day's SPAN, so an afternoon hole leaves a hole of the + /// right width rather than closing up. A single scored hour, or a day whose hours share one + /// timestamp, has no span to spread across and sits at the left edge. + /// + /// Y comes off the FIXED domain, so a calm day draws along the bottom where it belongs, and is + /// flipped on the way out: stress rises upward, points rise downward. + public static func segments(_ series: [StressPoint], width: CGFloat, height: CGFloat) -> [[Pt]] { + guard let firstPoint = series.first, let lastPoint = series.last, + width > 0, height > 0 else { return [] } + let t0 = firstPoint.ts + let span = CGFloat(lastPoint.ts - t0) + var out: [[Pt]] = [] + var run: [Pt] = [] + for p in series { + guard let level = p.level else { + if !run.isEmpty { out.append(run); run = [] } + continue + } + run.append(place(ts: p.ts, level: level, t0: t0, span: span, width: width, height: height)) + } + if !run.isEmpty { out.append(run) } + return out + } + + /// The scored hours sitting in the HIGH band, for the dots the screen puts above the line. + /// + /// Mapped through the same placement as `segments`, so a dot lands exactly on its own vertex. + public static func highPoints(_ series: [StressPoint], width: CGFloat, height: CGFloat) -> [Pt] { + guard let firstPoint = series.first, let lastPoint = series.last, + width > 0, height > 0 else { return [] } + let t0 = firstPoint.ts + let span = CGFloat(lastPoint.ts - t0) + return series.compactMap { p in + guard let level = p.level, level >= highBandFloor else { return nil } + return place(ts: p.ts, level: level, t0: t0, span: span, width: width, height: height) + } + } + + /// The hours masked as movement, grouped into CONTIGUOUS x ranges, for the marks along the base. + /// + /// A run rather than a mark per hour, because a run says which STRETCH of the day was masked, and + /// that is what a reader needs when they are looking at a hole in the trace and trying to work out + /// what put it there. The reason it matters was reported rather than theorised: a wearer saw the + /// gaps, read the evenly spaced marks along the zero line as axis ticks, concluded the data itself + /// was missing, and asked whether continuous HRV tracking would fill them in. One bar under the + /// stretch it explains cannot be mistaken for a scale, because a scale does not start and stop with + /// the data. + /// + /// Adjacency is by POSITION in the series, not by timestamp arithmetic: the series is already the + /// hour grid the chart draws, so two neighbouring entries are two neighbouring hours by construction. + /// + /// A span runs edge to edge of the hours it covers, NOT centre to centre. An hour is a stretch of the + /// day, not an instant, and the difference is the whole case for the shape: centre to centre gives a + /// lone masked hour a width of zero, which is exactly the hour that most needs to be legible, since + /// there are no neighbours to make it obvious. Edges are the MIDPOINT to each neighbour rather than a + /// fixed slot, so an irregular series (a DST-long day, an hour missing from the list entirely) still + /// gets honest extents. At the ends of the series the territory stops at the point itself: the day's + /// extent is what was sampled, nothing is invented past it, and every span stays inside the box + /// without a clamp. + /// + /// The upper edge is floored to the lower one. On a sorted series it never binds, but the two + /// platforms disagree about what an inverted range means, Kotlin yielding an empty one where Swift + /// traps, and a twin that crashes on one side and shrugs on the other is not a twin. + public static func movingSpans(_ series: [StressPoint], width: CGFloat) -> [ClosedRange] { + guard let firstPoint = series.first, let lastPoint = series.last, width > 0 else { return [] } + let t0 = firstPoint.ts + let span = CGFloat(lastPoint.ts - t0) + let xs = series.map { span <= 0 ? CGFloat(0) : CGFloat($0.ts - t0) / span * width } + let last = series.count - 1 + func leftEdge(_ i: Int) -> CGFloat { i == 0 ? xs[0] : (xs[i - 1] + xs[i]) / 2 } + func rightEdge(_ i: Int) -> CGFloat { i == last ? xs[last] : (xs[i] + xs[i + 1]) / 2 } + var out: [ClosedRange] = [] + var runStart: Int? + for i in series.indices { + let moving = series[i].moving + if moving, runStart == nil { runStart = i } + // A run closes at the first hour that is NOT moving, and also at the end of the series, or + // a day whose last hours were all masked would be dropped for want of a terminator. + if let from = runStart, !moving || i == last { + let lo = leftEdge(from) + out.append(lo...max(rightEdge(moving ? i : i - 1), lo)) + runStart = nil + } + } + return out + } + + /// The labels up the left edge, top-down: the top of the domain down to zero. + /// + /// Fixed, not derived, for the same reason the domain is: these are the scale, and an axis that + /// moved with the day would make two days impossible to compare at a glance. + public static func levelTicks() -> [Int] { [3, 2, 1, 0] } + + /// The three timestamps along the bottom: first, middle and last of the SERIES. + /// + /// The series, not the scored hours, because these label the AXIS, and the axis IS the series: every + /// placement in this file maps x across `first.ts ... last.ts`, and every renderer spreads these + /// three labels evenly across that same width. Anchoring them to the scored subset instead put the + /// last SCORED instant at the right-hand edge, so a day whose closing hours were all masked as + /// movement announced that it ended when scoring stopped rather than when the day did. + /// + /// That is the second half of #2106, and it was read exactly as it was drawn: a chart running to + /// 22:00 labelled "18:30" at its right edge, reported as the app having stopped updating. The + /// trailing hours were there the whole time, masked as exertion. Only the axis disagreed. + /// + /// The rationale this replaces was that an evening-only wearer would otherwise be labelled with a + /// morning that was never sampled. The buckets are built FROM the samples, so the series carries no + /// unsampled hours to begin with, and the curve was already spread across the series either way. + /// The labels were the only part that ever disagreed with the geometry. + /// + /// Fewer than three distinct instants returns what there is, so the caller draws one label rather + /// than three copies of it. + public static func timeTicks(_ series: [StressPoint]) -> [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 + return (mid == first || mid == last) ? [first, last] : [first, mid, last] + } +} diff --git a/StrandiOSShared/SyncActivityAttributes.swift b/StrandiOSShared/SyncActivityAttributes.swift new file mode 100644 index 0000000000..08fa9b2a8f --- /dev/null +++ b/StrandiOSShared/SyncActivityAttributes.swift @@ -0,0 +1,55 @@ +#if os(iOS) +import Foundation +import ActivityKit + +/// Live Activity attributes for a strap history sync — the Lock Screen banner and the Dynamic Island +/// while NOOP pulls stored history off the strap. +/// +/// A SEPARATE activity type from `NOOPActivityAttributes` (live HR) and `LiftActivityAttributes`, for the +/// same reason those two are separate: a different question with a different lifetime. It lives from the +/// moment a sync is asked for until a few seconds after it ends. While it is showing, the app suppresses +/// the live-HR activity rather than stacking two banners. +/// +/// THERE IS NO PERCENT. The strap never says how much history is pending, so the app's own Today control +/// shows a chunk COUNT and, when the strap reported one at connect, a "pages behind" figure. This carries +/// the same and nothing more. Elapsed time is carried as a date and rendered with `Text(timerInterval:)`, +/// which ticks on its own; every word is localized APP-SIDE because the widget extension ships no catalog. +public struct SyncActivityAttributes: ActivityAttributes { + public enum Phase: String, Codable, Hashable { + /// Asked for by the Sync Strap shortcut while the strap link was still coming up. + case connecting + case syncing + /// The strap reported HISTORY_COMPLETE. + case done + /// The offload ended on the idle watchdog, or never started. + case interrupted + } + + public struct ContentState: Codable, Hashable { + public var phase: Phase + /// Chunks acked so far this session. A count, never a fraction. + public var chunks: Int + /// When the activity's current run began; the widget counts up from here while active. + public var startedAt: Date + /// "Syncing…" / "Synced · 12 chunks" / "Sync interrupted" — resolved and localized app-side. + public var status: String + /// "120 pages behind at connect", when known. Nil otherwise. + public var detail: String? + + public init(phase: Phase, chunks: Int, startedAt: Date, status: String, detail: String?) { + self.phase = phase + self.chunks = chunks + self.startedAt = startedAt + self.status = status + self.detail = detail + } + } + + /// "Strap sync", localized app-side. + public var title: String + + public init(title: String) { + self.title = title + } +} +#endif diff --git a/StrandiOSShared/WidgetSnapshot.swift b/StrandiOSShared/WidgetSnapshot.swift index fb7e5c6f5e..d7fab6051f 100644 --- a/StrandiOSShared/WidgetSnapshot.swift +++ b/StrandiOSShared/WidgetSnapshot.swift @@ -21,10 +21,31 @@ 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]? + /// Today's hourly stress curve for the stress widget (#2040), earliest to latest. + /// + /// Unlike `hrSeries` this is NOT folded by `save()`. It arrives complete from the publish that + /// scored the day, so a publish either carries a whole day or says nothing about stress at all, and + /// the live fast path simply carries the loaded value forward untouched. Optional so a snapshot + /// written by an older build still decodes. + public var stressSeries: [StressPoint]? + /// Local day number `stressSeries` was scored for, or nil when no curve has been published. + /// + /// Read back as the staleness check: a curve from any day but today is dropped rather than drawn, + /// so the widget cannot show yesterday's afternoon under today's date while waiting for the first + /// scorable hour after midnight. + public var stressDay: Int? 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, stressSeries: [StressPoint]? = nil, + stressDay: Int? = nil) { self.recovery = recovery self.bpm = bpm self.batteryPct = batteryPct @@ -36,6 +57,35 @@ public struct WidgetSnapshot: Codable, Equatable { self.restingHr = restingHr self.effortDisplay = effortDisplay self.effortWhoop = effortWhoop + self.hrSeries = hrSeries + self.stressSeries = stressSeries + self.stressDay = stressDay + } + + /// The curve to DRAW: what was published, unless it belongs to a day that is over. + /// + /// Resolved on read rather than cleared on write, the same discipline `HrTrace.prune` applies to + /// age: nothing runs at midnight to tidy the App Group, so the check has to happen where the value + /// is used. Calendar is injectable so a test can cross a rollover without waiting for one. + public func stressCurve(now: Date = Date(), calendar: Calendar = .current) -> [StressPoint] { + guard let stressDay, let stressSeries, + stressDay == WidgetSnapshot.localDayNumber(now, calendar: calendar) else { return [] } + return stressSeries + } + + /// Days since the epoch on the LOCAL calendar, corresponding to Kotlin's + /// `LocalDate.toEpochDay()` built-in. + /// + /// Counted by the calendar rather than by dividing the day's start by 86 400. That arithmetic is + /// wrong on a DST day and measurably so: walking a year of local noons, `Europe/London` produces + /// ONE day whose number equals the previous day's, because its winter offset is UTC and a + /// 23-hour day then lands inside the same 86 400-second bucket. On that day the widget would have + /// read yesterday's curve as today's and drawn it, which is the one thing this number exists to + /// prevent. The calendar knows how long each local day actually was. + public static func localDayNumber(_ date: Date, calendar: Calendar = .current) -> Int { + let epoch = calendar.startOfDay(for: Date(timeIntervalSince1970: 0)) + return calendar.dateComponents([.day], from: epoch, + to: calendar.startOfDay(for: date)).day ?? 0 } /// App Group suite the app and widget both use. Injected from the `APP_GROUP_ID` build setting @@ -113,13 +163,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. @@ -139,6 +223,12 @@ public struct WidgetSnapshot: Codable, Equatable { || previous.restingHr != next.restingHr || previous.effortDisplay != next.effortDisplay || previous.effortWhoop != next.effortWhoop + // The curve joins the comparison (#2040): a publish that scored a fresh hour and changed + // nothing else would otherwise be deduped away, and the widget would sit an hour behind + // until some unrelated field moved. The DAY joins it too, so the first publish after + // midnight still reaches WidgetKit even when the new day has no scored hour yet. + || previous.stressSeries != next.stressSeries + || previous.stressDay != next.stressDay } /// A live-only update may reuse score fields only within the same local calendar day. At rollover, 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 + } + // Draw a run at a time, lifting the pen across the gaps. x is mapped by TIME, so a gap already + // occupies its true width; it was only the line drawn across it that was never measured. + for run in HrTrace.runs(pts) { + let head = pts[run.lowerBound] + guard run.lowerBound != run.upperBound else { + // A lone reading between two gaps is not a line either, and gets the same dot the + // single-point series above does. + if !filled { + // Held a full dot inside the box: the likeliest lone run of all is the NEWEST + // reading after a long disconnect, which sits exactly on the right edge. + let dot: CGFloat = 2.5 + let cx = min(max(head.x, dot), max(rect.maxX - dot, dot)) + path.addEllipse(in: CGRect(x: cx - dot, y: head.y - dot, + width: dot * 2, height: dot * 2)) + } + continue + } + // Each run closes its own area, so the gradient stops at the gap along with the line. + if filled { + path.move(to: CGPoint(x: head.x, y: rect.maxY)) + path.addLine(to: CGPoint(x: head.x, y: head.y)) + } else { + path.move(to: CGPoint(x: head.x, y: head.y)) + } + for i in (run.lowerBound + 1)...run.upperBound { + path.addLine(to: CGPoint(x: pts[i].x, y: pts[i].y)) + } + if filled { + path.addLine(to: CGPoint(x: pts[run.upperBound].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/LiftLiveActivity.swift b/StrandiOSWidgets/LiftLiveActivity.swift new file mode 100644 index 0000000000..57dad8eb03 --- /dev/null +++ b/StrandiOSWidgets/LiftLiveActivity.swift @@ -0,0 +1,189 @@ +import WidgetKit +import SwiftUI +import ActivityKit +import StrandDesign + +/// Live Activity for a running Lift Log session — the minimised session bar, on the Lock Screen and +/// in the Dynamic Island. +/// +/// It carries the same four things the in-app bar does, in the same order, because it is answering +/// the same question from further away: what am I doing, on what, with what numbers, and how long. +/// The colour language matches too — green while a set is being worked, amber through the rest. +/// +/// THE CLOCK TICKS WITHOUT THE APP. Both timers are `Text(timerInterval:)`, driven by dates in the +/// content state, so the Lock Screen counts on its own between pushes. The app only sends a new +/// state when something actually changes (stage, set, heart rate), never once a second to animate a +/// number. +struct LiftLiveActivity: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: LiftActivityAttributes.self) { context in + lockScreen(context.state) + .activityBackgroundTint(StrandPalette.surfaceBase) + .activitySystemActionForegroundColor(StrandPalette.textPrimary) + } dynamicIsland: { context in + let tint = tint(context.state) + return DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + Label(context.state.exercise, systemImage: "dumbbell.fill") + .font(.caption).lineLimit(1) + .foregroundStyle(tint) + } + DynamicIslandExpandedRegion(.trailing) { + Label { + Text(context.state.bpm.map(String.init) ?? "—").monospacedDigit() + } icon: { + Image(systemName: "heart.fill") + } + .font(.caption) + .foregroundStyle(context.state.bpm == nil + ? StrandPalette.textTertiary + : StrandPalette.metricRose) + } + DynamicIslandExpandedRegion(.bottom) { + HStack { + Text(context.state.detail ?? context.state.status) + .font(.caption).lineLimit(1) + .foregroundStyle(StrandPalette.textSecondary) + Spacer(minLength: 8) + clock(context.state, tint: tint) + .font(.system(size: 15, weight: .semibold, design: .rounded)) + } + } + } compactLeading: { + // The heart rate, where the island has the room for it, with the dumbbell standing in until + // the strap reports one — so this side is never the blank it was (Utku, 22 Sep 2026). + Label { + Text(context.state.bpm.map(String.init) ?? "").monospacedDigit() + } icon: { + Image(systemName: context.state.bpm == nil ? "dumbbell.fill" : "heart.fill") + } + .font(Self.islandFont) + .foregroundStyle(context.state.bpm == nil ? tint : StrandPalette.metricRose) + } compactTrailing: { + // Sized like the Lock Screen's clock: a running `Text(timerInterval:)` takes every point it + // is offered, which stretched the island and left the digits adrift in its middle with blank + // to their right (Utku, 22 Sep 2026). A hidden "00:00" in the same font gives the region the + // width of the clock itself, and the live one is right-aligned over it. + Text(verbatim: "00:00") + .font(Self.islandFont) + .monospacedDigit() + .hidden() + .overlay(alignment: .trailing) { + clock(context.state, tint: tint) + .font(Self.islandFont) + .multilineTextAlignment(.trailing) + } + } minimal: { + Image(systemName: "dumbbell.fill").foregroundStyle(tint) + } + } + } + + /// The Lock Screen clock's face, shared by the clock and the hidden template that sizes it. + private static let clockFont = Font.system(size: 22, weight: .bold, design: .rounded) + /// The Dynamic Island's compact face, shared by its heart rate, its clock and that clock's template. + private static let islandFont = Font.system(size: 13, weight: .semibold, design: .rounded) + + /// Green while working, amber through the rest — the sheet's and the bar's colour language. + private func tint(_ state: LiftActivityAttributes.ContentState) -> Color { + state.isResting ? StrandPalette.metricAmber : StrandPalette.statusPositive + } + + private func lockScreen(_ state: LiftActivityAttributes.ContentState) -> some View { + // Width goes to the words. The icon and the numbers sit nearer the banner's edges, and the heart + // rate stacks over the clock instead of beside it, so the exercise and the next set lose less to + // truncation — at the same sizes (Utku, 21 Sep 2026: "the writings are usually cut too quick"). + HStack(spacing: 10) { + Image(systemName: "dumbbell.fill") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(tint(state)) + + VStack(alignment: .leading, spacing: 2) { + Text(state.exercise) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(StrandPalette.textPrimary) + .lineLimit(1) + Text(state.detail.map { "\(state.status) — \($0)" } ?? state.status) + .font(.caption) + .foregroundStyle(StrandPalette.textSecondary) + .lineLimit(1) + // The set coming up, alone on the line, arriving pre-localized from the app (the + // extension has no catalog). It puts the set number before the exercise, so the tail + // truncation a long name needs cuts the name and keeps the number. + Text(state.next) + .font(.caption2) + .foregroundStyle(StrandPalette.textTertiary) + .lineLimit(1) + .truncationMode(.tail) + } + + Spacer(minLength: 6) + + // Heart rate over the clock, both flush right. A running `Text(timerInterval:)` takes all the + // width it is offered, so the clock's width comes from a hidden "00:00" in the same font — + // the widest a set or a rest shows under an hour — and the live clock is right-aligned over + // it. Sized from the timer itself, a working set's count-up spread across the whole banner. + // + // The heart rate is ALWAYS present, dash and all. A readout that vanishes when the strap + // stops reading is indistinguishable from a missing feature — which is exactly how it + // was first reported. + VStack(alignment: .trailing, spacing: 2) { + Label { + Text(state.bpm.map(String.init) ?? "—").monospacedDigit() + } icon: { + Image(systemName: "heart.fill") + } + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(state.bpm == nil + ? StrandPalette.textTertiary + : StrandPalette.metricRose) + + Text(verbatim: "00:00") + .font(Self.clockFont) + .monospacedDigit() + .hidden() + .overlay(alignment: .trailing) { + clock(state, tint: tint(state)) + .font(Self.clockFont) + .multilineTextAlignment(.trailing) + } + } + } + .padding(.vertical, 14) + .padding(.leading, 10) + .padding(.trailing, 12) + } + + /// Counts DOWN through a rest (the number you act on) and UP through a set, both self-ticking. + /// + /// Both branches use `Text(timerInterval:)`, which is the API widgets are given for a clock that + /// advances without the app pushing. `Text(date, style: .timer)` looks equivalent and is not: on + /// the Lock Screen it rendered "25 minutes" — a rounded, prose duration — where a gym timer has + /// to read 25:02. Verified in the simulator, which is the only reason it was caught. + /// + /// A rest that is over reads 0:00 and stays there, as the in-app bar does + /// (`LiftSessionEngine.restRemaining` floors at zero). It used to count UP past the end, and a + /// clock climbing from zero on the Lock Screen read as a new timer rather than a finished rest + /// (gym session, 16 Sep 2026). The countdown's range therefore starts at the REST'S start, not at + /// `.now`: a widget re-rendered after the end — for a heart-rate push, say — still gets a range + /// that is entirely past, which `Text(timerInterval:)` shows as its end value instead of switching + /// to a count-up. A rest with no length (the sheet is complete) has no range to count and shows + /// the same 0:00. A working set counts up from its start; a zero-length range would render + /// nothing, so that end is pushed a day out — well beyond any session. + private func clock(_ state: LiftActivityAttributes.ContentState, tint: Color) -> some View { + Group { + if let ends = state.restEndsAt { + if ends > state.stageStartedAt { + Text(timerInterval: state.stageStartedAt...ends, countsDown: true) + } else { + Text(verbatim: "0:00") + } + } else { + Text(timerInterval: state.stageStartedAt...state.stageStartedAt.addingTimeInterval(86_400), + countsDown: false) + } + } + .monospacedDigit() + .foregroundStyle(tint) + } +} diff --git a/StrandiOSWidgets/NOOPLiveActivity.swift b/StrandiOSWidgets/NOOPLiveActivity.swift index a10e7d65f2..16aa2c528c 100644 --- a/StrandiOSWidgets/NOOPLiveActivity.swift +++ b/StrandiOSWidgets/NOOPLiveActivity.swift @@ -5,6 +5,14 @@ import StrandDesign /// Live Activity for an active live-HR session — shown on the Lock Screen and in the Dynamic Island. struct NOOPLiveActivity: Widget { + /// The heart rate to draw: none once iOS has marked the banner stale. Each push is fresh for 30 s + /// (`LiveActivityController.staleAfter`) and NOOP re-pushes a steady number well inside that, so a stale banner + /// means the readings stopped — the strap off the wrist, or out of reach — even while NOOP itself is asleep and + /// cannot say so: iOS redraws the banner at the stale date on its own. + static func shownBpm(_ context: ActivityViewContext) -> Int? { + context.isStale ? nil : context.state.bpm + } + var body: some WidgetConfiguration { ActivityConfiguration(for: NOOPActivityAttributes.self) { context in // Lock Screen / banner presentation. @@ -15,7 +23,7 @@ struct NOOPLiveActivity: Widget { VStack(alignment: .leading, spacing: 2) { Text(context.attributes.title) .font(.caption).foregroundStyle(StrandPalette.textSecondary) - Text("\(context.state.bpm.map(String.init) ?? "–") bpm") + Text("\(Self.shownBpm(context).map(String.init) ?? "–") bpm") .font(.system(size: 26, weight: .bold, design: .rounded)) .foregroundStyle(StrandPalette.textPrimary) } @@ -36,7 +44,7 @@ struct NOOPLiveActivity: Widget { } dynamicIsland: { context in DynamicIsland { DynamicIslandExpandedRegion(.leading) { - Label("\(context.state.bpm.map(String.init) ?? "–")", systemImage: "heart.fill") + Label("\(Self.shownBpm(context).map(String.init) ?? "–")", systemImage: "heart.fill") .foregroundStyle(StrandPalette.statusCritical) } DynamicIslandExpandedRegion(.trailing) { @@ -56,7 +64,7 @@ struct NOOPLiveActivity: Widget { } compactLeading: { Image(systemName: "heart.fill").foregroundStyle(StrandPalette.statusCritical) } compactTrailing: { - Text("\(context.state.bpm.map(String.init) ?? "–")") + Text("\(Self.shownBpm(context).map(String.init) ?? "–")") } minimal: { Image(systemName: "heart.fill").foregroundStyle(StrandPalette.statusCritical) } 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..b688369f1d 100644 --- a/StrandiOSWidgets/NOOPWidgetBundle.swift +++ b/StrandiOSWidgets/NOOPWidgetBundle.swift @@ -1,11 +1,19 @@ 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), the +/// heart-rate trace widget (#1957), the stress curve widget (#2040), the Lift Log session +/// Live Activity, and the strap-sync Live Activity. @main struct NOOPWidgetBundle: WidgetBundle { var body: some Widget { NOOPWidget() NOOPLiveActivity() + CoachBriefWidget() + HeartRateWidget() + StressWidget() + LiftLiveActivity() + SyncLiveActivity() } } diff --git a/StrandiOSWidgets/StressWidget.swift b/StrandiOSWidgets/StressWidget.swift new file mode 100644 index 0000000000..243a7fb831 --- /dev/null +++ b/StrandiOSWidgets/StressWidget.swift @@ -0,0 +1,323 @@ +import StrandDesign +import SwiftUI +import WidgetKit + +/// Home-screen widget: today's stress as the intraday curve the Stress screen draws (#2040). +/// +/// Swift twin of the Android `StressGlanceWidget`, but the drawing is NOT a port. Glance compiles to +/// RemoteViews and cannot draw, so Android renders the curve to a Bitmap under a payload budget and a +/// reduced pixel depth, and has to composite every translucent colour by hand because 565 carries no +/// alpha. WidgetKit is SwiftUI: stroked `Path`s, resolution-independent, with real opacity. What IS +/// shared is `StressTrace` — the fixed domain, the gap rule, the band threshold and the tick choices — +/// because those are the same reading of the same day. +/// +/// Honest-blank throughout, matching the twin: an unscored hour is a GAP rather than an interpolation, +/// an hour the motion gate masked gets a faint mark along the base instead of a score, and a day with +/// nothing scored shows no chart at all rather than a flat line at zero. +struct StressEntry: TimelineEntry { + let date: Date + let snap: WidgetSnapshot? +} + +struct StressProvider: TimelineProvider { + func placeholder(in context: Context) -> StressEntry { + StressEntry(date: Date(), snap: nil) + } + + func getSnapshot(in context: Context, completion: @escaping (StressEntry) -> Void) { + completion(StressEntry(date: Date(), snap: WidgetSnapshot.load())) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let entry = StressEntry(date: Date(), snap: WidgetSnapshot.load()) + // Half an hour, where the heart-rate widget takes fifteen minutes. This curve gains at most one + // point an hour, so a tighter net would spend budget on entries identical to the one before it. + // The app reloads timelines when it scores an hour, so this is only the safety net for when it + // is not running — and it also carries the card across midnight, when the day number changes and + // yesterday's curve stops being drawn. + let next = Calendar.current.date(byAdding: .minute, value: 30, to: Date()) + ?? Date().addingTimeInterval(1_800) + completion(Timeline(entries: [entry], policy: .after(next))) + } +} + +/// The curve: every contiguous run of scored hours as its own subpath. +/// +/// Runs rather than one path because an unscored hour is a hole, and a line drawn across it would +/// invent a reading. When `filled` each run is closed to the baseline SEPARATELY, so the area cannot +/// spread under hours that were never scored, which would undo the gap the broken line exists to draw. +private struct StressCurveShape: Shape { + let series: [StressPoint] + let filled: Bool + + func path(in rect: CGRect) -> Path { + var path = Path() + for run in StressTrace.segments(series, width: rect.width, height: rect.height) { + guard let first = run.first else { continue } + if run.count == 1 { + // A run of one hour is not a line. A dot says "one scored hour"; an empty box says + // "no data", and those are different things. + guard !filled else { continue } + 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)) + continue + } + path.move(to: CGPoint(x: first.x, y: first.y)) + for p in run.dropFirst() { path.addLine(to: CGPoint(x: p.x, y: p.y)) } + if filled { + path.addLine(to: CGPoint(x: run[run.count - 1].x, y: rect.maxY)) + path.addLine(to: CGPoint(x: first.x, y: rect.maxY)) + path.closeSubpath() + } + } + return path + } +} + +/// The hours sitting in the HIGH band, dotted above the line as the screen marks them. +private struct StressHighDotsShape: Shape { + let series: [StressPoint] + + func path(in rect: CGRect) -> Path { + var path = Path() + let r: CGFloat = 2 + for p in StressTrace.highPoints(series, width: rect.width, height: rect.height) { + // Lifted clear of the stroke so a dot is never half-hidden under the line it marks. + let y = max(p.y - 5, r) + path.addEllipse(in: CGRect(x: min(max(p.x, r), rect.maxX - r) - r, y: y - r, + width: r * 2, height: r * 2)) + } + return path + } +} + +/// The stretches the motion gate masked, marked along the base rather than scored. +private struct StressMovingMarksShape: Shape { + let series: [StressPoint] + + func path(in rect: CGRect) -> Path { + var path = Path() + // A span arrives covering its hours edge to edge, so the only width left to decide is the + // FLOOR, for a degenerate series with no width to spread across. It is the width the mark used + // to have unconditionally, kept so the smallest mark is no less visible than before. + let minWidth: CGFloat = 6 + let radius = rect.height / 2 + for span in StressTrace.movingSpans(series, width: rect.width) { + let lo = min(max(span.lowerBound, 0), rect.width) + let hi = min(max(span.upperBound, 0), rect.width) + // Floored by GROWING right, then left if that ran into the edge, so a mark at either end + // of the day keeps its width instead of being trimmed away by the box. + let x1 = max(hi, min(lo + minWidth, rect.width)) + let x0 = min(lo, max(x1 - minWidth, 0)) + path.addRoundedRect( + in: CGRect(x: x0, y: rect.minY, width: x1 - x0, height: rect.height), + cornerSize: CGSize(width: radius, height: radius), + ) + } + return path + } +} + +struct StressWidgetView: View { + let entry: StressEntry + + /// Resolved on read, so a curve scored for a day that is over is dropped rather than drawn. Measured + /// from `entry.date` rather than `Date()` because WidgetKit renders an entry at ITS date, which is + /// the same reasoning the heart-rate widget's prune follows. + private var series: [StressPoint] { + entry.snap?.stressCurve(now: entry.date) ?? [] + } + private var stats: StressTrace.Stats? { StressTrace.stats(series) } + private var latest: Double? { series.last(where: { $0.level != nil })?.level } + + // The ramp's band anchors, taken from the palette tokens the Stress screen's own ramp is built from, + // rather than the local hexes the Glance twin has to carry. Blue calm, green steady, amber tense. + private var calm: Color { StrandPalette.accent } + private var steady: Color { StrandPalette.statusPositive } + private var tense: Color { StrandPalette.statusWarning } + + /// Vertical, because `StressTrace.segments` maps the score onto Y off a FIXED domain: height already + /// encodes level, so one top-to-bottom gradient paints every run the colour its own score deserves. + /// The screen's ramp read upward. + private var rampGradient: LinearGradient { + LinearGradient(colors: [tense, steady, calm], startPoint: .top, endPoint: .bottom) + } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Stress") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(StrandPalette.textPrimary) + + HStack(alignment: .lastTextBaseline, spacing: 4) { + Text(latest.map { StressTrace.formatLevel($0) } ?? "—") + .font(.system(size: 30, weight: .bold, design: .rounded)) + .foregroundStyle(StrandPalette.textPrimary) + if latest != nil { + Text("of 3") + .font(.system(size: 12)) + .foregroundStyle(StrandPalette.textSecondary) + } + if let stats, let peak = stats.peak.level { + // "Peak" is a catalog key the app already carries in every locale; the value and the + // time are DATA, so they are formatted into a plain String and shown verbatim. That + // keeps a translator's job to the word that has one, and adds no catalog entry for a + // string that is otherwise punctuation. + let peakTime = Date(timeIntervalSince1970: TimeInterval(stats.peak.ts)) + .formatted(date: .omitted, time: .shortened) + HStack(spacing: 4) { + Text("Peak") + Text(verbatim: StressTrace.formatLevel(peak) + " · " + peakTime) + } + .font(.system(size: 11)) + .foregroundStyle(StrandPalette.textPrimary) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(tense.opacity(0.18), in: Capsule()) + .padding(.leading, 6) + } + } + + if stats != nil { + HStack(alignment: .top, spacing: 6) { + // The scale sits on the LEFT, where the Stress screen puts it. (The heart-rate + // widget puts its scale on the right, which is right for a trace whose numbers are + // read off the end.) Fixed rather than derived, because that is what the domain is: + // an axis that moved with the day would make two days impossible to compare. + let ticks = StressTrace.levelTicks() + 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) } + } + } + StressCurveChart(series: series, ramp: rampGradient, + fillTint: steady, dotTint: tense) + } + StressTimeAxis(series: series) + } + + Spacer(minLength: 0) + if let updated = entry.snap?.updated, updated != .distantPast { + HStack { + Spacer() + // Both halves are catalog keys the app already carries, `avg %@` and `Updated %@`, + // joined by a separator with nothing in it to translate. One composite key would have + // needed a new entry in ten locales to say what these two already say. + if let stats { + HStack(spacing: 0) { + Text("avg \(StressTrace.formatLevel(stats.mean))") + Text(verbatim: " · ") + Text("Updated \(updated, format: .dateTime.hour().minute())") + } + .font(.system(size: 10)) + .foregroundStyle(StrandPalette.textSecondary) + } else { + 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 same choice the heart-rate widget + /// makes. `String(localized:)` rather than bare literals: the audit matches a literal sitting + /// immediately after `.accessibilityLabel(`, and this is a computed property, so hardcoded English + /// here would ship to every locale with the gate green. + private var accessibilityText: String { + guard let latest else { return String(localized: "Stress, no reading today") } + let now = StressTrace.formatLevel(latest) + guard let stats, let peak = stats.peak.level else { + return String(localized: "Stress \(now) of 3") + } + let peakText = StressTrace.formatLevel(peak) + let meanText = StressTrace.formatLevel(stats.mean) + return String(localized: "Stress \(now) of 3, average \(meanText), peak \(peakText)") + } +} + +/// The chart: fill, stroke, high-band dots, and the movement strip beneath them. +/// +/// The marks get their OWN row rather than a reserved band inside the chart's coordinate space. The +/// Glance twin has to carve the band out of one bitmap and normalise around it, which is exactly where +/// that side went wrong once; here a `VStack` gives each part its own rect and the arithmetic +/// disappears. +private struct StressCurveChart: View { + let series: [StressPoint] + let ramp: LinearGradient + let fillTint: Color + let dotTint: Color + + private var hasMarks: Bool { series.contains(where: \.moving) } + + var body: some View { + VStack(spacing: 2) { + ZStack { + StressCurveShape(series: series, filled: true) + .fill(LinearGradient( + colors: [fillTint.opacity(0.3), fillTint.opacity(0)], + startPoint: .top, endPoint: .bottom, + )) + StressCurveShape(series: series, filled: false) + .stroke(ramp, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round)) + StressHighDotsShape(series: series) + .fill(dotTint) + } + if hasMarks { + StressMovingMarksShape(series: series) + .fill(StrandPalette.textSecondary.opacity(0.45)) + .frame(height: 3) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +private struct StressTimeAxis: View { + let series: [StressPoint] + + var body: some View { + let ticks = StressTrace.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 StressWidget: Widget { + static let kind = "StressWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: Self.kind, provider: StressProvider()) { entry in + if #available(iOS 17.0, *) { + StressWidgetView(entry: entry) + .containerBackground(StrandPalette.surfaceBase, for: .widget) + } else { + StressWidgetView(entry: entry) + .padding() + .background(StrandPalette.surfaceBase) + } + } + .configurationDisplayName("Stress") + .description("Today's stress as an hour-by-hour curve.") + .supportedFamilies([.systemMedium]) + } +} diff --git a/StrandiOSWidgets/SyncLiveActivity.swift b/StrandiOSWidgets/SyncLiveActivity.swift new file mode 100644 index 0000000000..d00713365d --- /dev/null +++ b/StrandiOSWidgets/SyncLiveActivity.swift @@ -0,0 +1,95 @@ +import WidgetKit +import SwiftUI +import ActivityKit +import StrandDesign + +/// Live Activity for a strap history sync — the Lock Screen banner and the Dynamic Island. +/// +/// Shows what the app's own Today sync control shows: that a sync is running, how many chunks it has +/// pulled, how long it has been going, and the strap's connect-time backlog when it reported one. No +/// progress bar, because there is no total to draw one against (see `SyncActivityAttributes`). +struct SyncLiveActivity: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: SyncActivityAttributes.self) { context in + // Lock Screen / banner presentation. + HStack(spacing: 14) { + syncGlyph(context.state.phase) + .font(.title2) + VStack(alignment: .leading, spacing: 2) { + Text(context.attributes.title) + .font(.caption).foregroundStyle(StrandPalette.textSecondary) + Text(context.state.status) + .font(.system(size: 20, weight: .bold, design: .rounded)) + .foregroundStyle(StrandPalette.textPrimary) + if let detail = context.state.detail { + Text(detail).font(.caption2).foregroundStyle(StrandPalette.textSecondary) + } + } + Spacer() + if isActive(context.state.phase) { + elapsed(since: context.state.startedAt) + .font(.system(.headline, design: .rounded).monospacedDigit()) + .foregroundStyle(StrandPalette.textPrimary) + } + } + .padding() + .activityBackgroundTint(StrandPalette.surfaceBase) + .activitySystemActionForegroundColor(StrandPalette.textPrimary) + } dynamicIsland: { context in + // ONE line, deliberately. iOS shows the expanded layout for a few seconds whenever an activity + // starts and offers no way to start compact, so the only lever on that flash is how tall the + // expanded layout is: no bottom or centre region, so it is a short pill rather than a card. + // The backlog detail and title live on the Lock Screen banner instead. + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + Label { Text(context.state.status) } icon: { syncGlyph(context.state.phase) } + .font(.subheadline) + .lineLimit(1) + } + DynamicIslandExpandedRegion(.trailing) { + if isActive(context.state.phase) { + elapsed(since: context.state.startedAt) + .font(.system(.subheadline, design: .rounded).monospacedDigit()) + } + } + } compactLeading: { + // Same footprint as the live-HR island's heart: one symbol, no label, so the compact pill + // stays as narrow as that one does. + syncGlyph(context.state.phase) + } compactTrailing: { + // "…" while connecting and until the first chunk lands; then the chunk count, the only + // live number a sync has. Never "0", so the island never claims progress the strap has + // not made. + Text(context.state.chunks > 0 ? "\(context.state.chunks)" : "…") + .monospacedDigit() + } minimal: { + syncGlyph(context.state.phase) + } + } + } +} + +private func isActive(_ phase: SyncActivityAttributes.Phase) -> Bool { + phase == .connecting || phase == .syncing +} + +/// Counts up on its own from the run's start; no pushes needed to keep it moving. +private func elapsed(since start: Date) -> some View { + Text(timerInterval: start...Date.distantFuture, countsDown: false) +} + +/// One glyph per phase. The sync arrows in the positive (green) colour for both active phases — the +/// connecting/syncing distinction is carried by the trailing "…" vs count, not by swapping symbols, which +/// kept the compact pill's width steady — then a tick once done, and the critical colour when the strap +/// went quiet. +@ViewBuilder +private func syncGlyph(_ phase: SyncActivityAttributes.Phase) -> some View { + switch phase { + case .connecting, .syncing: + Image(systemName: "arrow.triangle.2.circlepath").foregroundStyle(StrandPalette.statusPositive) + case .done: + Image(systemName: "checkmark.circle.fill").foregroundStyle(StrandPalette.statusPositive) + case .interrupted: + Image(systemName: "exclamationmark.circle.fill").foregroundStyle(StrandPalette.statusCritical) + } +} diff --git a/Tools/PARITY_GOVERNANCE.md b/Tools/PARITY_GOVERNANCE.md new file mode 100644 index 0000000000..9e7be1b425 --- /dev/null +++ b/Tools/PARITY_GOVERNANCE.md @@ -0,0 +1,177 @@ +# Parity governance + +This directory contains the product-free foundation for tracking Swift/Kotlin +parity. It inventories declarations and constants, records the current accepted +debt, and prevents regeneration from accepting new debt silently. + +## Local checks + +Run the complete Python tool suite, then the two repository gates: + +```sh +python3 -m unittest discover -s Tools -p 'test*.py' +python3 Tools/parity_ledger.py +python3 Tools/parity_ratchet.py --base origin/main --offline +``` + +The ledger must report no finding beyond the checked-in baseline. The ratchet +also rescans the working tree, so editing a baseline without matching current +sources fails closed. `--offline` skips only GitHub existence checks; it does +not relax syntax, schema, current-tree, or exact-base checks. + +Scan errors are reported before baseline evaluation because unresolved or +malformed source evidence makes the inventory untrustworthy. In that case the +ledger ends with `Baseline not evaluated: N scan errors.`; fix the listed scan +errors before interpreting or refreshing baseline state. + +For a reported debt decrease, the ledger resolves `--base` (default +`origin/main`) once to an immutable commit and proves the current identities are +a subset of that exact tree. A missing or shallow base fails closed. + +The v3 twin map contains explicit source roots plus a count and SHA-256 for each +canonical semantic set. The full file/function/property/constant inventory is +derived deterministically from source; prose evidence and name-only suggestions +are not authority. A normal scan expands it and deep-checks every fingerprint. +The ratchet separately materializes and scans the exact base and current trees, +so regenerating both JSON files cannot hide a newly unpaired declaration. The ledger is +fail-closed when invoked; repository CI enforcement is intentionally deferred to the final +stack PR, alongside native execution and path-filtered ledger/ratchet invocation. + +Function pairs exactly equal resolved attached source claims. File pairs derive +from those claims before constant resolution, so stale file metadata cannot +steer constant pairing. Swift selector labels disambiguate overloads. Stale, +overlapping, duplicate, or ambiguous attached-pair structure is a hard scan +error and cannot be accepted by the baseline. A claim originating in an +authority root may resolve to an exact declaration in the wider repository +reference scope; that external endpoint is hashed into the pair authority but +does not silently widen the unpaired-inventory roots. + +The compact baseline stores exact-identity-set hashes grouped by rule and narrow +source scope. Every group has a review reason and provenance; no wildcard or +umbrella issue matches findings. Governance is intentionally asymmetric: new +or changed debt blocks, while a proven decrease is green with a concise warning. +No JSON regeneration or disposition cleanup is required merely to land an +improvement. + +This PR is the governance foundation and executable demo. Its Python self-tests +run in a dedicated workflow when the governance implementation, authority, +tests, or workflow changes. Ordinary product-source and documentation changes +do not repeatedly self-test the scanner. The workflow does **not** invoke the +ledger or ratchet as a product-source gate yet. PR2 adds the portable case layer, +PR3 adds corpora and native runners, and PR4 wires product parity/differential +checks into required CI. +When PR4 wires this gate into CI, that invocation must omit +`--offline`. Every governed `issue` field must use the exact +`owner/repository#number` form. Online validation derives the API path from +that value and verifies both the repository and issue number; pull requests do +not satisfy an issue reference. A new experimental disposition additionally needs its +own fresh issue, a specific reason, and this marker in the issue body: + +```text +parity-governance-identity-sha256: +``` + +Issues cannot be reused across dispositions, and `bhelm/noop#17` is explicitly +forbidden as an umbrella. + +## Updating the inventory + +Initial creation is deliberately one-shot: + +```sh +python3 Tools/parity_ledger.py --bootstrap-map --write-baseline +``` + +The two bootstrap flags are inseparable. Both snapshots are computed and +validated before either is published; a scan or publication failure leaves +both prior files byte-for-byte unchanged (or both absent on first creation). + +Once authority exists, refresh derived snapshots only through the guarded flow: + +```sh +python3 Tools/parity_ledger.py --refresh-derived --base origin/main +python3 Tools/parity_ledger.py +python3 Tools/parity_ratchet.py --base origin/main --offline +``` + +Generation computes state; it never approves it. Refresh writes candidates, +runs the identity-set ratchet against the exact base, and restores both snapshots +on failure. There is no accept/force switch, and the generator never creates or +edits `parity_dispositions.json`. +Deleting a paired duplicate is fail-closed derived-snapshot drift that requires +this guarded refresh flow by design. + +If the scheduled `main` run finds that the exact base already contains stale +metadata, use the explicit repair variant: + +```sh +python3 Tools/parity_ledger.py --refresh-derived --repair-stale-base --base origin/main +``` + +This is not a force switch and does not create a disposition. The ratchet +independently scans the exact base and the current tree, then permits the repair +only when semantic authority, finding identities, counters, and typed +dispositions are unchanged between them and both regenerated snapshots exactly +match that shared state. Any source debt introduced on the repair branch still +fails closed, as does a hand-edited or partial snapshot. + +Repair therefore covers a base that is stale and OTHERWISE UNCHANGED. It cannot +cover a base whose stored authority no longer reproduces at all, which is what +merged product files leave behind: the governed state genuinely differs, so the +equality repair insists on can never hold. That case fails with + +``` +base authority cannot be reproduced with the current derivation; migration +required (see --migrate-authority) +``` + +and its remedy is the migration variant: + +```sh +python3 Tools/parity_ledger.py --refresh-derived --migrate-authority --base origin/main +``` + +Migration re-bases the comparison onto a freshly derived base authority. It +waives exactly one thing, whether the base's STORED manifest can be reproduced, +and it is not a force switch either. New one-sided declarations are still +computed from the freshly derived base and current sets, so anything this branch +adds still needs its own issue-bound disposition and an undeclared one still +fails closed. A hand-edited current authority is refused outright. + +Be clear-eyed about what a post-merge refresh does: it ADOPTS the one-sided +declarations already on `main` into the authority, because the ratchet governs +debt relative to the base and merged debt is the base. That is the intended +maintenance action, and it is also why the daily schedule matters. It finds the +drift within a day rather than letting it accumulate until a contributor +inherits it. + +For a new one-sided declaration, choose explicitly: + +The `add-unpaired-function` diagnostic defines "new" by absence of the +platform-qualified owner, name, and arity from the base declaration inventory, +not by a changed source line. + +1. Implement and test its Swift/Kotlin twin (required for shared bug fixes and + final shared features). +2. Manually add an `experimental` disposition with exact identity/platform, + reason, fresh issue, and expiry date. +3. Manually add a durable `platform_specific` disposition with exact + identity/platform and a concrete platform rationale. + +These are the only disposition types. They cannot waive removal/retargeting of +an existing twin, a shared bug fix, or a final shared feature. The central typed +registry is manually reviewed; regeneration cannot populate it. A disposition +whose debt disappears emits a warning and may be removed later, but it never +authorizes reintroduction against the current merge base. Debt reductions pass +with a warning even when totals fall while another new identity remains +blocking. +Do not preserve stale findings or freeze commit hashes in tests; repository +consistency is proved by independent rescanning and canonical set hashes. + +## Layer boundary + +This foundation has no module runner, case corpus, coverage report, Gradle, +SwiftPM, governance-gate workflow invocation, or native-test dependency. The +path-filtered parity workflow only discovers the Python self-tests and protects +their count. Later layers can consume the inventory without making this scanner +depend on their orchestration. diff --git a/Tools/PARITY_HARNESS.md b/Tools/PARITY_HARNESS.md new file mode 100644 index 0000000000..90c470789a --- /dev/null +++ b/Tools/PARITY_HARNESS.md @@ -0,0 +1,159 @@ +# Shared parity cases and independent validation + +PR2 provides the portable test contract for the parity guard series. A shared +case is written once with its inputs and literal expected result. Each platform +can eventually execute those same cases and validate its own output independently; +a later artifact-only comparison additionally checks platform agreement. + +## Relationship to PR1 and existing native tests + +PR1 inventories Swift/Kotlin counterparts and protects the static parity debt +baseline with ledger/ratchet checks. It does not execute both implementations. +PR2 adds case specifications, canonical transport, splitting/remerging, standalone +expected-result validation, provenance checks, and a differential comparator. +It leaves PR1's rules in place. + +The target is to share **platform-independent behavioral cases**, such as +algorithm inputs and expected scores. Native UI, permissions, Bluetooth-stack +and OS-integration tests remain platform-specific. Existing native tests stay +in place in PR2: no native test has been migrated or deleted. + +Both platforms agreeing is insufficient: they can share a bug. Every case in +this contract therefore requires a checked-in `expected` value. Validation +compares actual output against that literal oracle, never against a dynamically +generated reference answer. Positive comparison validates both sides against the +oracle as well as each other, so agreeing-but-wrong results fail. Oracle quality +still depends on correct domain expectations and suitable case coverage. + +| Layer | Scope | +| --- | --- | +| PR1, merged | Static inventory, debt governance and ledger/ratchet tools | +| PR2 | Shared cases/oracles, portable artifact contracts and tool self-tests | +| PR3 | Native Swift/Kotlin runners and the first migration of existing algorithm cases; broader test data | +| PR4 | Product-source CI enforcement | + +PR3 should start with a small real algorithm, collect relevant cases from both +native test suites, preserve their assertions as common expectations, and prove +that both adapters execute them. Remove redundant native cases only after their +previous checks are demonstrably covered. Continue migrating shared behavior +incrementally; platform-internal tests stay native. + +## Independent builds and later comparison + +An Android job needs only the shared cases and Android implementation. Its +standalone expected-result check needs only those cases and its own result file. +A Swift job can independently do the same on macOS. Neither needs the other +platform's build or artifact to pass its own behavioral tests. Once both artifacts +exist, a separate comparator can consume them without rebuilding either platform. + +PR2 includes only two Python implementations of a small clamp demonstration, +called `core` and `reference`. These exercise the contract and negative controls; +they do not execute Android or Swift product code or establish native parity. +The validator accepts an explicit side label (for example `android` or `swift`), +and the comparator accepts explicit labels for both artifacts. This prepares the +interface; it does not supply native runners or native CI orchestration. + +## Case and provenance contract + +- The specification requires integer `schemaVersion: 1` and a nonblank string + `suiteVersion`, identifying the suite revision. Update the suite version when + changing the shared cases or their intended behavior. This is separate from the + schema version and the product source revision. +- Each case requires `id`, `args` and literal `expected`. The registry in + `parity_shards.py` is the sole ordered shard/operation authority. +- Expanded input records contain `args`, `expected`, `function`, `id`, `shard`, + `suiteVersion` and `caseSha256`. SHA-256 covers the canonical record excluding + `caseSha256` itself, including its expected result and suite version. All rows + of one input artifact must have the same suite version. +- Output records contain `caseSha256`, `function`, `id`, `mutant`, `side`, + `sourceRevision`, `suiteVersion` and `value`. Every row must match the exact + input identity, function, digest and suite version. Missing, duplicated, + unexpected or reordered rows fail closed. +- Runners must supply `sourceRevision`. Every validation, comparison and mutant + verification requires an explicit `--expected-revision`: the intended full + lowercase Git object ID (40 hex digits for SHA-1 or 64 for SHA-256). Every output + row must carry that exact revision. Moving branch names, abbreviated revisions, + mixed revisions, and artifacts from a different revision are rejected. + +The expected revision must come from the caller's intended build/job context, +not be copied from an untrusted result just to make the check pass. The harness +checks metadata consistency; it does **not** attest which code actually ran, +verify a clean checkout, or authenticate artifact producers. Trusted build and +artifact provenance are responsibilities of the later native CI integration. +The source revision is not embedded in persistent cases: the same checked-in +behavioral cases can be reused across product-code changes. Case hashes detect +changed content even if someone forgets to bump the suite version. + +## Run the Python demonstration + +From the repository root, use the revision intended for this demonstration: + +```sh +parity_revision=$(git rev-parse HEAD) +python3 Tools/parity_harness.py expand \ + Tools/parity_case_specs/core_reference.json /tmp/parity-cases.jsonl +python3 Tools/parity_harness.py run core \ + /tmp/parity-cases.jsonl /tmp/parity-core.jsonl \ + --source-revision "$parity_revision" +python3 Tools/parity_harness.py validate core \ + /tmp/parity-cases.jsonl /tmp/parity-core.jsonl \ + --expected-revision "$parity_revision" +``` + +The standalone validation above is complete with no reference artifact. Later: + +```sh +python3 Tools/parity_harness.py run reference \ + /tmp/parity-cases.jsonl /tmp/parity-reference.jsonl \ + --source-revision "$parity_revision" +python3 Tools/parity_harness.py compare \ + /tmp/parity-cases.jsonl /tmp/parity-core.jsonl /tmp/parity-reference.jsonl \ + --expected-revision "$parity_revision" +``` + +For future native artifacts, the same interfaces are: + +```sh +python3 Tools/parity_harness.py validate android cases.jsonl android.jsonl \ + --expected-revision "$parity_revision" +python3 Tools/parity_harness.py validate swift cases.jsonl swift.jsonl \ + --expected-revision "$parity_revision" +python3 Tools/parity_harness.py compare cases.jsonl android.jsonl swift.jsonl \ + --core-side android --reference-side swift \ + --expected-revision "$parity_revision" +``` + +These commands validate already-produced artifacts; PR2 cannot yet produce those +native files. Both comparison sides must have distinct labels. Successful checks +return 0, value/oracle differences return 1, and malformed contracts or provenance +mismatches return 2. `EXPECTED` diagnostics identify oracle failures; `DIFF` +diagnostics identify platform differences. Agreement does not suppress oracle failures. + +## Transport and negative controls + +`split` writes one canonical file per registered shard; `merge` requires the +original expanded input and proves an exact remerge. Shard parts must appear once +each in registry order, with canonical row order. Empty, unknown, duplicate, +missing or changed cases are rejected. Splitting refuses unexpected JSONL files +in the destination before writing. + +Canonical JSON uses UTF-8 Unicode scalar values, sorted object keys, compact +separators, finite floats and signed 64-bit integers. JSONL uses LF only, exactly +one record per line and a final LF; CRLF and blank lines are rejected. Unicode +line/paragraph separators inside strings remain ordinary string content. +Comparison uses exact canonical JSON at every nesting level: booleans, integers +and floats remain distinct, and `-0.0` differs from `0.0`. Float spelling follows +Python's standard JSON encoder; future native adapters must match this contract. +The representative clamp retains the input value (including its number type and +signed zero) when it equals either bound. + +The demonstration `run --mutant` deliberately changes the returned value. +Positive validation/comparison rejects mutant metadata. `verify-mutant` requires +an explicit expected revision, mutant metadata on exactly the named side, a normal +side matching the literal oracle, and every supplied mutant case differing from +that normal result. Controls exist for both sides. + +The existing parity-governance CI runs these tool self-tests as an explicitly +listed module and triggers on case-spec changes. It adds no native build dependency or product-source parity +enforcement. Additional modules, native runners, bulk corpora and enforcement +remain the later series layers described above. diff --git a/Tools/appchangelog-gen.py b/Tools/appchangelog-gen.py index c6f8e5f4ae..8c6a6c890a 100644 --- a/Tools/appchangelog-gen.py +++ b/Tools/appchangelog-gen.py @@ -53,11 +53,13 @@ #: The locale resource dirs the i18n gate treats as the focus set. `values` is the English source. # Polish shipped in #1250 but was never added here, so a `title_locales.pl` entry was accepted and then -# silently dropped — v10.1.0 supplied one and Polish users still saw the English card. Keep this in step -# with the res/values-* directories that actually exist. +# silently dropped: v10.1.0 supplied one and Polish users still saw the English card. Russian repeated it +# in #1872, added as a locale in the same cycle this list was not. Keep this in step with the +# res/values-* directories that actually exist, which `test_locale_dirs_match_the_res_directories` now +# asserts rather than trusting anyone to remember. LOCALE_DIRS = {"en": "values", "de": "values-de", "es": "values-es", "fr": "values-fr", "pt-PT": "values-pt-rPT", "zh": "values-zh", - "pl": "values-pl"} + "pl": "values-pl", "ru": "values-ru"} def title_key(title: str) -> str: 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..5c2f06c947 100644 --- a/Tools/i18n_audit.py +++ b/Tools/i18n_audit.py @@ -26,9 +26,11 @@ import argparse import json import re +import subprocess import sys import xml.etree.ElementTree as ET from pathlib import Path +from typing import Callable ROOT = Path(__file__).resolve().parent.parent LANGS = ["de", "es", "fr", "pt-PT"] @@ -39,6 +41,12 @@ "pt-PT": "values-pt-rPT", } +# A file's text (or None if absent) at some point in time — either the +# working tree (`_disk_read`) or a git ref (`ref_reader`). Every scan_*/ +# *_gaps function accepts one so `ci_check` can compute the same violations +# at HEAD and at `base_ref` and diff the two. +Reader = Callable[[Path], str | None] + # Strings that are legitimately identical across all languages (symbols, # format-only placeholders, brand name, units) — mirrors the exclude # reasoning already established in Tools/translate-de.py. Extend as needed; @@ -419,13 +427,14 @@ def _visible_val_initializer( _PRECEDED_BY_ARG_BOUNDARY = re.compile(r"[(,]\s*\Z") -def scan_android() -> list[tuple[str, int, str]]: +def scan_android(read: Reader | None = None) -> list[tuple[str, int, str]]: + read = read or _disk_read findings = [] for base in ANDROID_DIRS: if not base.exists(): continue for path in sorted(base.rglob("*.kt")): - raw = path.read_text(encoding="utf-8", errors="replace") + raw = read(path) or "" text = _mask_comments(raw) seen: set[int] = set() @@ -435,7 +444,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 @@ -481,34 +490,63 @@ def record(span_start: int, span_end: int) -> None: ANDROID_EXEMPT_KEYS = {"app_name"} # brand name -def android_strings_xml_gaps() -> dict[str, set[str]]: +def android_strings_xml_gaps(read: Reader | None = None) -> dict[str, set[str]]: """Keys present in the base values/strings.xml but missing from an existing values-/strings.xml. (Doesn't invent missing locale dirs — see the audit summary for languages with NO directory at all.)""" + read = read or _disk_read base_path = ROOT / "android/app/src/main/res/values/strings.xml" # count too: converting a hand-rolled singular/plural PAIR into one would # otherwise DROP those keys out of this gate's view entirely, so a locale could silently lose them — # fixing the plural model must not open a coverage hole (see #540 for the same class of blind spot). - base_keys = set(re.findall(r'<(?:string|plurals) name="([^"]+)"', base_path.read_text(encoding="utf-8"))) + base_keys = set(re.findall(r'<(?:string|plurals) name="([^"]+)"', read(base_path) or "")) gaps: dict[str, set[str]] = {} for lang in LANGS: locale_dir = ANDROID_LOCALE_DIRS[lang] lang_path = ROOT / f"android/app/src/main/res/{locale_dir}/strings.xml" - if not lang_path.exists(): + lang_text = read(lang_path) + if lang_text is None: gaps[lang] = {"" % locale_dir} continue - lang_keys = set(re.findall(r'<(?:string|plurals) name="([^"]+)"', lang_path.read_text(encoding="utf-8"))) + lang_keys = set(re.findall(r'<(?:string|plurals) name="([^"]+)"', lang_text)) missing = (base_keys - ANDROID_EXEMPT_KEYS) - lang_keys if missing: gaps[lang] = missing return gaps +ANDROID_STRING_PATTERN = re.compile(r']*>(.*?)', re.S) + + +def android_edge_whitespace() -> dict[str, list[str]]: + """Resource keys whose value starts or ends in whitespace, per locale directory. + + AAPT2 trims leading and trailing whitespace from an unquoted string resource, so that + whitespace never reaches the device. Copy that leans on it renders two words run together + (the caption that read "scoredagainst your own calm hours today"). A resource that really + does need an edge space has to be wrapped in double quotes, which this check honours; the + reliable fix for a split sentence is to keep the joining space in the code instead. + """ + out: dict[str, list[str]] = {} + for path in sorted((ROOT / "android/app/src/main/res").glob("values*/strings.xml")): + offenders = [ + key + for key, value in ( + (m.group(1), m.group(2)) for m in ANDROID_STRING_PATTERN.finditer(path.read_text(encoding="utf-8")) + ) + if value != value.strip() and not value.strip().startswith('"') + ] + if offenders: + out[path.parent.name] = offenders + return out + + ANDROID_FORMAT_PATTERN = re.compile(r"%[1-9]\d*\$[-+0 #,(]*\d*(?:\.\d+)?([sdif])") -def android_format_gaps() -> dict[str, list[str]]: +def android_format_gaps(read: Reader | None = None) -> dict[str, list[str]]: """Resource keys whose translated Formatter arguments differ from English.""" + read = read or _disk_read paths = { "en": ROOT / "android/app/src/main/res/values/strings.xml", **{ @@ -522,9 +560,10 @@ def signature(value: str) -> list[str]: values: dict[str, dict[str, str]] = {} plural_items: dict[str, dict[str, list[str]]] = {} for lang, path in paths.items(): - if not path.exists(): + text = read(path) + if text is None: continue - root = ET.parse(path).getroot() + root = ET.fromstring(text) entries = {node.attrib["name"]: node.text or "" for node in root.findall("string")} items_by_key: dict[str, list[str]] = {} # carry their format args on the CHILDREN, so a plain findall("string") leaves @@ -593,6 +632,38 @@ def signature(value: str) -> list[str]: r"\b(?:Text|Button|Label|Toggle|Menu|Picker|ProgressView|SectionHeader)\s*\(" r"|" r"\.(?:navigationTitle|confirmationDialog|alert|accessibilityLabel|help)\s*\(" + r"|" + # `String(localized:)` is the sanctioned spelling for copy that has to be a `String`, and it + # still has to EXIST in the catalog to render in anything but English. Without this alternative + # the spelling was invisible here, so nothing checked its key: 242 of them resolve to no catalog + # entry and ship English in every locale, the app's legal terms among them. The lookahead keeps + # `String(format:)`/`String(describing:)` out, which are not copy. + r"\bString\s*\((?=\s*localized:)" +) + +# A computed property that RETURNS user-facing copy as a `String`, e.g. +# `var label: String { ... }` on a screen's scope/mode enum. +# +# These are invisible to SWIFT_CALL_START_PATTERN above, because the literal sits +# in a `return`, not inside a `Text(`/`Picker(` argument. That is not a harmless +# miss: a bare literal returned as a String reaches `Text` already resolved, so it +# renders in English on every device forever, and nothing flags it. It shipped +# exactly once that way (a Workouts "Current"/"Archived" tab pair) while the gate +# passed, having caught only the accessibility label beside it. +# +# The repository's own convention already avoids this, either `String(localized:)` +# for a value that must be a String, or `LocalizedStringKey` when the value only +# ever reaches `Text`. Both are recognised: the first because the literal sits in +# a `localized:` argument, the second because `LocalizedStringKey` resolves in the +# view environment. So this rule has no pre-existing findings to baseline; it +# exists to keep it that way. +# +# Deliberately narrow. It matches only property names that ARE copy (label, title, +# caption, subtitle) and only literals that are returned, so the many String +# helpers that build keys, symbol names, trace tokens and log lines stay out. +SWIFT_COPY_PROPERTY_PATTERN = re.compile( + r"\bvar\s+\w*(?:label|title|caption|subtitle)\w*\s*:\s*String\s*\{", + re.IGNORECASE, ) # A placeholder generated by Swift's LocalizedStringKey interpolation. The @@ -699,6 +770,61 @@ def swift_string_literals(text: str): i += 1 +def swift_returned_copy_literals(text: str): + """Yield (offset, literal) for copy RETURNED as a String from a `var label: String { ... }`-shaped + property, e.g. `case .current: return "Current"`. + + Separate from `swift_string_literals`, and applied to SCREEN files only, because the same shape means + something else elsewhere: `Commands.swift` names BLE opcodes through a `var label: String`, and those + are diagnostics rather than copy a wearer reads. That directory restriction is what keeps this rule at + zero pre-existing findings instead of 71. + + Only literals at the property's own brace level are yielded, so one nested inside a closure or a + helper call stays out and string building is not flagged. + """ + for match in SWIFT_COPY_PROPERTY_PATTERN.finditer(text): + body_start = match.end() - 1 + depth = 0 + i = body_start + while i < len(text): + ch = text[i] + if ch == '"': + literal_end = _skip_swift_string_literal(text, i) + prefix = text[max(body_start, i - 60):i] + line = prefix.rsplit("\n", 1)[-1] + # Returned copy, in the spellings a label property actually uses: a `switch` arm + # (`case .a: return "Alpha"`, or the implicit-return form), or a ternary. + # + # A bare "ends with a colon" test is NOT enough to spot a case arm: every argument label + # ends the same way, so `joined(separator: ", ")` looked like returned copy and the + # separator was reported as untranslated UI. + # + # Keyed on the RETURN, not on brace depth. Depth alone looked right and silently missed + # the commoner shape: a `switch` opens a second brace level, so every `case ... return` + # arm sat a level deeper than the ternary this rule was first written against, and the + # dominant form in this repository went unchecked. + stripped = line.lstrip() + returned = ( + "return" in line # `return "Alpha"` + or "?" in line # `cond ? "Alpha" : "Beta"` + or stripped.startswith(("case ", "default")) # `case .a: "Alpha"` (implicit return) + ) + # `String(localized: "...")` is the sanctioned spelling for a String-typed value, so this + # rule leaves it alone. SWIFT_CALL_START_PATTERN is what checks its catalog key, a + # delegation that was only asserted in this comment until it was made true. + if returned and "localized:" not in prefix: + yield i, text[i + 1:literal_end - 1] + i = literal_end + continue + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + break + i += 1 + + def swift_catalog_pattern(literal: str) -> re.Pattern[str] | None: """Turn a Swift source literal into a regex for its compiled catalog key.""" parts: list[str] = [] @@ -826,7 +952,14 @@ def signature(value: str) -> list[str]: # Compare EVERY form independently against the key, never a folded concatenation: folding would # make the signature depend on how many plural categories the language HAS (ru/pl carry four, # zh one), so a correct translation would read as a format mismatch purely for having more forms. - values = [u.get("value", "") for u in _string_units(entry, lang)] or [""] + # An ABSENT localization is a coverage gap, reported by the missing/allowance counters, and + # must not be read as a format mismatch. `or [""]` used to make one look like the other: the + # empty signature differs from any key carrying a specifier. That never showed for the focus + # languages, which are held at zero missing, and it turned every ratcheted gap in it/ru/pl + # into a false format failure the moment this check was widened past them. + values = [u.get("value", "") for u in _string_units(entry, lang)] + if not values: + continue if any(signature(key) != signature(v) for v in values): mismatched.append(key) return mismatched @@ -840,23 +973,30 @@ def catalog_lookup(cat: dict, key: str) -> dict | None: return cat.get("strings", {}).get(key) -def scan_ios() -> tuple[list[tuple[str, int, str]], dict[str, list[str]]]: +def scan_ios(read: Reader | None = None) -> tuple[list[tuple[str, int, str]], dict[str, list[str]]]: + read = read or _disk_read hardcoded: list[tuple[str, int, str]] = [] # not in any catalog at all lang_gaps: dict[str, list[str]] = {lang: [] for lang in LANGS} for dirs, catalog_path in CATALOGS: - cat = load_catalog(catalog_path) + cat_text = read(catalog_path) + cat = json.loads(cat_text) if cat_text else {"strings": {}} for base in dirs: if not base.exists(): continue for path in sorted(base.rglob("*.swift")): - text = path.read_text(encoding="utf-8", errors="replace") - for offset, literal in swift_string_literals(text): + text = read(path) or "" + literals = list(swift_string_literals(text)) + # Screen files only: see `swift_returned_copy_literals` for why the same shape + # elsewhere (BLE opcode names, design-system internals) is not copy. + if "/Screens/" in path.as_posix() or "/Liquid/" in path.as_posix(): + literals += list(swift_returned_copy_literals(text)) + for offset, literal in literals: if not is_probably_ui_text(literal): 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 +1004,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 +1066,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 +1108,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 +1144,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 @@ -1063,87 +1223,190 @@ def write_baseline() -> None: print(f"Wrote {len(android)} android + {len(ios)} ios entries to {BASELINE_PATH.relative_to(ROOT)}") +def _disk_read(path: Path) -> str | None: + if not path.exists(): + return None + return path.read_text(encoding="utf-8", errors="replace") + + +def git_show(ref: str, rel_path: str) -> str | None: + """File content at `ref`, or None if the path didn't exist there.""" + result = subprocess.run( + ["git", "show", f"{ref}:{rel_path}"], + cwd=ROOT, capture_output=True, text=True, + ) + return result.stdout if result.returncode == 0 else None + + +def ref_reader(ref: str) -> Reader: + """A `Reader` backed by `git show ref:` instead of disk, for diffing + the current tree against a base ref. Uses the CURRENT file list (a file + added by the PR simply reads as empty at the base ref, which correctly + counts its literals as new).""" + def read(path: Path) -> str | None: + return git_show(ref, str(path.relative_to(ROOT))) + return read + + +def apple_missing_and_format_gaps( + read: Reader | None = None, +) -> tuple[dict[tuple[str, str], set[str]], dict[tuple[str, str], set[str]]]: + """Per (catalog, lang): the set of catalog keys missing a translation, and + the set of catalog keys whose translated printf arguments don't match.""" + read = read or _disk_read + missing: dict[tuple[str, str], set[str]] = {} + formats: dict[tuple[str, str], set[str]] = {} + for _dirs, catalog_path in CATALOGS: + cat_text = read(catalog_path) + cat = json.loads(cat_text) if cat_text else {"strings": {}} + rel = str(catalog_path.relative_to(ROOT)) + for lang in LANGS: + keys_missing = { + key for key, entry in cat.get("strings", {}).items() + if entry.get("shouldTranslate") is not False and not _is_translated(entry, lang) + } + if keys_missing: + missing[(rel, lang)] = keys_missing + fmt_gaps = set(apple_format_gaps(cat, lang)) + if fmt_gaps: + formats[(rel, lang)] = fmt_gaps + return missing, formats + + def ci_check(base_ref: str) -> int: - """Strict CI gate: the #453 backlog is closed, so every focus language - translation must remain complete, and no NEW hardcoded literal may land - (pre-existing ones are tracked in the baseline — see load_baseline()). - ``base_ref`` remains in the CLI for workflow compatibility but coverage - is now a standing invariant, not a diff-scoped allowance. + """CI gate, exempting a violation on either of two independent grounds: + + 1. It's in the committed baseline (Tools/i18n_audit_baseline.json) — the + 248-entry backlog #540/#558's improved scanner surfaced, tracked so it + can be closed incrementally instead of blocking the scanner fix itself. + 2. It already exists at `base_ref` — so this PR didn't cause it. A prior + version of this gate audited the whole tree unconditionally, ignoring + base_ref entirely: a transient regression on `base_ref` itself (e.g. a + release generating a raw literal, #514) then red-flagged every open PR + whose diff never touched the offending file, and those PRs stayed red + until they got a fresh push, because a GitHub `pull_request` workflow + doesn't re-run just because the base branch changed. The baseline + alone doesn't cover this case — it's a fixed snapshot, so a *new* + regression on main after the snapshot was taken would still red-flag + every unrelated PR until someone updates the baseline. Diffing against + `base_ref` closes that gap: a violation already present there isn't + this PR's fault regardless of whether it made it into the baseline, + so a main-side regression is self-contained to whoever caused it + instead of spreading. + + Locale-gap and format-mismatch checks aren't in the baseline (it only + tracks hardcoded literals) — the base_ref diff is their only exemption, + which is sufficient since that backlog is fully closed today. """ + base_read = ref_reader(base_ref) failed = False baseline = load_baseline() - print("--- Android: no NEW hardcoded UI copy, and complete focus locales ---") - android_literals = scan_android() - android_found = {(p, lit) for p, _line, lit in android_literals} - android_new = [f for f in android_literals if (f[0], f[2]) not in baseline["android"]] - if android_new: + print(f"--- Android: no new hardcoded UI copy or focus-locale gaps vs {base_ref} ---") + cur_android = scan_android() + android_found = {(p, lit) for p, _line, lit in cur_android} + base_android_keys = {(path, literal) for path, _line, literal in scan_android(base_read)} + exempt_android = baseline["android"] | base_android_keys + new_android = [f for f in cur_android if (f[0], f[2]) not in exempt_android] + if new_android: failed = True - print(f"FAIL {len(android_new)} NEW hardcoded literal(s) (not in {BASELINE_PATH.relative_to(ROOT)}):") - for path, line, literal in android_new[:30]: + print(f"FAIL {len(new_android)} new hardcoded literal(s):") + for path, line, literal in new_android[:30]: print(f" {path}:{line}: {literal!r}") else: - print(f" OK no new hardcoded literals ({len(android_found)} pre-existing, tracked in the baseline)") + note = f" ({len(android_found)} pre-existing, tracked in the baseline or on {base_ref})" if android_found else "" + print(f" OK no new hardcoded literals{note}") android_fixed = baseline["android"] - android_found if android_fixed: print(f" {len(android_fixed)} baseline entr(y/ies) no longer found — run --update-baseline to shrink the backlog") - android_gaps = android_strings_xml_gaps() - android_formats = android_format_gaps() + + cur_gaps = android_strings_xml_gaps() + base_gaps = android_strings_xml_gaps(base_read) + cur_formats = android_format_gaps() + base_formats = android_format_gaps(base_read) for lang in LANGS: - gaps = android_gaps.get(lang) - if gaps: + new_gap = sorted(cur_gaps.get(lang, set()) - base_gaps.get(lang, set())) + if new_gap: failed = True locale_dir = ANDROID_LOCALE_DIRS[lang] - print(f"FAIL {locale_dir}/strings.xml missing {len(gaps)} key(s): {sorted(gaps)[:30]}") + print(f"FAIL {locale_dir}/strings.xml has {len(new_gap)} new missing key(s): {new_gap[:30]}") else: locale_dir = ANDROID_LOCALE_DIRS[lang] print(f" OK {locale_dir}/strings.xml") - format_gaps = android_formats.get(lang) - if format_gaps: + new_fmt = sorted(set(cur_formats.get(lang, [])) - set(base_formats.get(lang, []))) + if new_fmt: failed = True locale_dir = ANDROID_LOCALE_DIRS[lang] - print(f"FAIL {locale_dir}/strings.xml has {len(format_gaps)} format mismatch(es): {format_gaps[:30]}") + print(f"FAIL {locale_dir}/strings.xml has {len(new_fmt)} new format mismatch(es): {new_fmt[:30]}") - print("\n--- Apple: no NEW un-extracted UI copy, and complete focus locales ---") - ios_literals, _source_gaps = scan_ios() - ios_found = {(p, lit) for p, _line, lit in ios_literals} - ios_new = [f for f in ios_literals if (f[0], f[2]) not in baseline["ios"]] - if ios_new: + edge = android_edge_whitespace() + if edge: + failed = True + for locale_dir, keys in edge.items(): + print(f"FAIL {locale_dir}/strings.xml has {len(keys)} string(s) whose edge whitespace " + f"AAPT2 strips: {sorted(keys)[:30]}") + else: + print(" OK no string resource leans on edge whitespace") + + print(f"\n--- Apple: no new un-extracted UI copy or focus-locale gaps vs {base_ref} ---") + cur_ios, _cur_ios_lang_gaps = scan_ios() + ios_found = {(p, lit) for p, _line, lit in cur_ios} + base_ios_keys = {(path, literal) for path, _line, literal in scan_ios(base_read)[0]} + exempt_ios = baseline["ios"] | base_ios_keys + new_ios = [f for f in cur_ios if (f[0], f[2]) not in exempt_ios] + if new_ios: failed = True - print(f"FAIL {len(ios_new)} NEW literal(s) absent from their target catalog:") - for path, line, literal in ios_new[:30]: + print(f"FAIL {len(new_ios)} new literal(s) absent from their target catalog:") + for path, line, literal in new_ios[:30]: print(f" {path}:{line}: {literal!r}") else: - print(f" OK no new un-extracted literals ({len(ios_found)} pre-existing, tracked in the baseline)") + note = f" ({len(ios_found)} pre-existing, tracked in the baseline or on {base_ref})" if ios_found else "" + print(f" OK no new un-extracted literals{note}") ios_fixed = baseline["ios"] - ios_found if ios_fixed: print(f" {len(ios_fixed)} baseline entr(y/ies) no longer found — run --update-baseline to shrink the backlog") allowance = extra_locale_allowance() extra_apple_gaps: dict[str, int] = {} + cur_missing, cur_fmt = apple_missing_and_format_gaps() + base_missing, base_fmt = apple_missing_and_format_gaps(base_read) for _dirs, catalog_path in CATALOGS: - cat = load_catalog(catalog_path) + rel = str(catalog_path.relative_to(ROOT)) # #844: count the shipped locales OUTSIDE the focus set while the catalog is already parsed, # and gate them below. Reloading each catalog for a second pass wasted a full re-parse of a - # 3255-string file. + # 3255-string file. Deliberately disk-based like the rest of this ratchet (not base_ref-diffed + # like the LANGS check below): the allowance file is already the mechanism that keeps a main-side + # change here from spreading to unrelated PRs, by tracking a target count instead of demanding + # zero, so it doesn't need base_ref's protection on top. + cat = load_catalog(catalog_path) 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"{rel}:{extra}"] = sum( 1 for v in cat.get("strings", {}).values() if v.get("shouldTranslate") is not False and not _is_translated(v, extra) ) + # COVERAGE for these locales is ratcheted, because they carry inherited gaps that would + # red-check every open PR. FORMAT is not: a specifier the translation drops or invents is + # a runtime substitution bug, not a gap, and it is exactly as broken in Russian as in + # German. Checking it only for LANGS left zh, it, ru and pl free to ship a dropped `%@` + # through a green board, which is how `%lld app%@ on` and `%lld frame%@ captured this + # session.` kept a Russian mismatch each for as long as they existed. Zero tolerance + # here is affordable because the count across every catalogue and every locale is now 0. + extra_format_gaps = apple_format_gaps(cat, extra) + if extra_format_gaps: + failed = True + print(f"FAIL {catalog_path.relative_to(ROOT)} {extra}: " + f"{len(extra_format_gaps)} format mismatch(es): {extra_format_gaps[:10]}") for lang in LANGS: - missing = sum( - 1 for v in cat.get("strings", {}).values() - if v.get("shouldTranslate") is not False and not _is_translated(v, lang) - ) - if missing: + key = (rel, lang) + new_missing = sorted(cur_missing.get(key, set()) - base_missing.get(key, set())) + if new_missing: failed = True - print(f"FAIL {catalog_path.relative_to(ROOT)} {lang}: missing={missing}") + print(f"FAIL {rel} {lang}: {len(new_missing)} new missing translation(s): {new_missing[:30]}") else: - print(f" OK {catalog_path.relative_to(ROOT)} {lang}") - format_gaps = apple_format_gaps(cat, lang) - if format_gaps: + print(f" OK {rel} {lang}") + new_fmt_gap = sorted(cur_fmt.get(key, set()) - base_fmt.get(key, set())) + if new_fmt_gap: failed = True - print(f"FAIL {catalog_path.relative_to(ROOT)} {lang}: {len(format_gaps)} format mismatch(es): {format_gaps[:10]}") + print(f"FAIL {rel} {lang}: {len(new_fmt_gap)} new format mismatch(es): {new_fmt_gap[:10]}") # A key that EXISTS in a language still says nothing about whether it was TRANSLATED. This section is # the difference between "complete" and "translated": it counts localizations whose value is the @@ -1253,7 +1516,7 @@ def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--platform", choices=["ios", "android", "all"], default="all") ap.add_argument("--full", action="store_true", help="print every finding, not just counts") - ap.add_argument("--ci", metavar="BASE_REF", help="strict coverage gate (BASE_REF is retained for workflow compatibility); see ci_check() docstring") + ap.add_argument("--ci", metavar="BASE_REF", help="coverage gate: fail only on violations new vs BASE_REF or the baseline; see ci_check() docstring") ap.add_argument("--update-baseline", action="store_true", help="rewrite Tools/i18n_audit_baseline.json from the current hardcoded-literal scan (see load_baseline() docstring). Does NOT touch Tools/i18n_extra_locale_baseline.txt — that one is lowered by hand, so shrinking it stays a deliberate act") args = ap.parse_args() @@ -1277,6 +1540,16 @@ def main() -> int: if len(findings) > 25: print(f" ... and {len(findings) - 25} more (use --full)") + print("\n=== Android: string resources leaning on stripped edge whitespace ===") + edge = android_edge_whitespace() + if not edge: + print(" none") + for locale_dir, keys in edge.items(): + print(f" {locale_dir}: {len(keys)} string(s)") + if args.full: + for k in sorted(keys): + print(f" {k}") + print("\n=== Android: values-/strings.xml key gaps ===") gaps = android_strings_xml_gaps() if not gaps: diff --git a/Tools/i18n_audit_baseline.json b/Tools/i18n_audit_baseline.json index ab395e6c41..16e46f2962 100644 --- a/Tools/i18n_audit_baseline.json +++ b/Tools/i18n_audit_baseline.json @@ -100,34 +100,10 @@ "android/app/src/main/java/com/noop/ui/CoachScreen.kt", "Bring your own API key. It is stored encrypted on this device and only used to " ], - [ - "android/app/src/main/java/com/noop/ui/CoachScreen.kt", - "Collapse coach instructions" - ], - [ - "android/app/src/main/java/com/noop/ui/CoachScreen.kt", - "Customised. Your edited instructions frame every reply." - ], - [ - "android/app/src/main/java/com/noop/ui/CoachScreen.kt", - "Edit coach instructions" - ], - [ - "android/app/src/main/java/com/noop/ui/CoachScreen.kt", - "Edit how the coach thinks and talks. Takes effect on your next message." - ], [ "android/app/src/main/java/com/noop/ui/CoachScreen.kt", "Fetching…" ], - [ - "android/app/src/main/java/com/noop/ui/CoachScreen.kt", - "Off: the coach answers generally and sends none of your metrics." - ], - [ - "android/app/src/main/java/com/noop/ui/CoachScreen.kt", - "On: your recovery, sleep, HRV and workouts are shared with the provider for tailored coaching." - ], [ "android/app/src/main/java/com/noop/ui/CoachScreen.kt", "Only if your server requires one" @@ -448,10 +424,6 @@ "android/app/src/main/java/com/noop/ui/LiveSessionScreen.kt", "Signal lost — coaching paused." ], - [ - "android/app/src/main/java/com/noop/ui/LiveWorkoutScreen.kt", - "%d:%02d" - ], [ "android/app/src/main/java/com/noop/ui/LiveWorkoutScreen.kt", "Below Zone 1" @@ -724,10 +696,6 @@ "android/app/src/main/java/com/noop/ui/UpdatesInboxScreen.kt", "Unread. ${item.title}. ${item.message}" ], - [ - "android/app/src/main/java/com/noop/ui/WorkoutStart.kt", - "%d:%02d" - ], [ "android/app/src/main/java/com/noop/ui/WorkoutsScreen.kt", "${WorkoutEditing.displaySport(row.sport)}, ${dateLabel(row.startTs)}" @@ -898,6 +866,70 @@ ] ], "ios": [ + [ + "NOOPWatch/WatchBreatheView.swift", + "Follow the guided cue" + ], + [ + "NOOPWatch/WatchBreatheView.swift", + "Hold for \\(secs) seconds" + ], + [ + "NOOPWatch/WatchBreatheView.swift", + "Push" + ], + [ + "NOOPWatch/WatchBreatheView.swift", + "Regular" + ], + [ + "NOOPWatch/WatchBreatheView.swift", + "\\(breathCount) breaths · \\(timeString(sessionSeconds))/\\(timeString(target))" + ], + [ + "NOOPWatch/WatchBreatheView.swift", + "\\(title) · \\(sessionLength.label)" + ], + [ + "NOOPWatch/WatchBreatheView.swift", + "\\(title) · guided" + ], + [ + "Packages/StrandDesign/Sources/StrandDesign/Appearance.swift", + "Custom" + ], + [ + "Packages/StrandDesign/Sources/StrandDesign/Appearance.swift", + "Fit" + ], + [ + "Packages/StrandDesign/Sources/StrandDesign/Appearance.swift", + "Frosted" + ], + [ + "Packages/StrandDesign/Sources/StrandDesign/Appearance.swift", + "Midnight" + ], + [ + "Packages/StrandDesign/Sources/StrandDesign/Appearance.swift", + "Mint" + ], + [ + "Packages/StrandDesign/Sources/StrandDesign/Appearance.swift", + "Ocean" + ], + [ + "Packages/StrandDesign/Sources/StrandDesign/Appearance.swift", + "Stretch" + ], + [ + "Packages/StrandDesign/Sources/StrandDesign/Appearance.swift", + "Tile" + ], + [ + "Packages/StrandDesign/Sources/StrandDesign/Appearance.swift", + "WHOOP Blue" + ], [ "Packages/StrandDesign/Sources/StrandDesign/ChartHover.swift", "\\(value), \\(label!)" @@ -906,34 +938,322 @@ "Packages/StrandDesign/Sources/StrandDesign/YearHeatStrip.swift", "\\(DateFormatterCache.day.string(from: day.date)) · recovery \\(Int(score.rounded()))" ], + [ + "Strand/BLE/PuffinDeepBufferLog.swift", + "End experiment" + ], + [ + "Strand/BLE/PuffinDeepBufferLog.swift", + "Off wrist · room light" + ], + [ + "Strand/BLE/PuffinDeepBufferLog.swift", + "Off wrist · sensor covered" + ], + [ + "Strand/BLE/PuffinDeepBufferLog.swift", + "On wrist · again" + ], + [ + "Strand/BLE/PuffinDeepBufferLog.swift", + "On wrist · gentle pressure" + ], + [ + "Strand/BLE/PuffinDeepBufferLog.swift", + "On wrist · normal breathing" + ], + [ + "Strand/BLE/PuffinDeepBufferLog.swift", + "On wrist · slow paced breathing" + ], + [ + "Strand/BLE/PuffinDeepBufferLog.swift", + "On wrist · still" + ], + [ + "Strand/Data/DataBackup.swift", + "Import failed its post-restore integrity check (SQLite reports: \\(DatabaseIntegrity.readableComplaint(complaint))). The damaged file was removed; there was no previous data to roll back." + ], + [ + "Strand/Data/DataBackup.swift", + "Import failed its post-restore integrity check (SQLite reports: \\(DatabaseIntegrity.readableComplaint(complaint))). Your previous data was rolled back automatically and is unchanged." + ], + [ + "Strand/Data/DataBackup.swift", + "This backup file is damaged and can't be restored (SQLite reports: \\(DatabaseIntegrity.readableComplaint(complaint))). Your current data was left untouched. Try an earlier backup file." + ], + [ + "Strand/Data/DataBackup.swift", + "the NOOP database failed its integrity check (SQLite reports: \\(DatabaseIntegrity.readableComplaint(complaint))). A backup of it would not restore. Export the WHOOP-format CSV (Settings → Export data) to save what's still readable." + ], + [ + "Strand/Data/MetricCatalog.swift", + "Cardiovascular load for the day, on a 0-100 scale (was 0-21)." + ], + [ + "Strand/Data/MetricCatalog.swift", + "HR Zones 1-3" + ], + [ + "Strand/Data/MetricCatalog.swift", + "HR Zones 4-5" + ], + [ + "Strand/Data/MetricCatalog.swift", + "Heart" + ], + [ + "Strand/Data/Repository.swift", + "Band Sleep State" + ], + [ + "Strand/Data/TodayLayoutPrefs.swift", + "Charge / Effort / Rest" + ], + [ + "Strand/Data/TodayLayoutPrefs.swift", + "Recovery Vitals" + ], + [ + "Strand/Data/TodayLayoutPrefs.swift", + "Your Cards" + ], [ "Strand/Liquid/LiquidPrimitives.swift", "%.\\(decimals)f" ], + [ + "Strand/Onboarding/OnboardingWizard.swift", + "Date of birth" + ], + [ + "Strand/Screens/AddDeviceWizard.swift", + "Newer WHOOP band with live data and history sync" + ], [ "Strand/Screens/AddDeviceWizard.swift", "The most common cause is the ring was not fully reset in the Oura app, or the Oura app is still running. Reset the ring again, force-quit Oura, then try once more. If it keeps failing, your ring may be a generation NOOP cannot adopt yet. The ring is not bricked: re-pair it in the Oura app to recover it. You can still use file import." ], + [ + "Strand/Screens/AutomationsView.swift", + "A buzz confirms a workout starting and saving." + ], + [ + "Strand/Screens/AutomationsView.swift", + "An early \\\"recharge tonight\\\" heads-up when the strap has about a day of estimated runtime left, at most once per discharge cycle. Turn off to keep only the 15% warning." + ], + [ + "Strand/Screens/AutomationsView.swift", + "Breathing pacer" + ], + [ + "Strand/Screens/AutomationsView.swift", + "Buzz each inhale and exhale during a breathing or resonance session." + ], + [ + "Strand/Screens/AutomationsView.swift", + "Buzz on each interval change." + ], + [ + "Strand/Screens/AutomationsView.swift", + "Choose which in-session cues buzz your wrist during a breathing session, timer, or workout." + ], + [ + "Strand/Screens/AutomationsView.swift", + "Coaching buzzes during a live workout session." + ], + [ + "Strand/Screens/AutomationsView.swift", + "Haptics" + ], + [ + "Strand/Screens/AutomationsView.swift", + "Interval timer" + ], + [ + "Strand/Screens/AutomationsView.swift", + "Live Session cues" + ], + [ + "Strand/Screens/AutomationsView.swift", + "Predictive runtime warning" + ], + [ + "Strand/Screens/AutomationsView.swift", + "Suppress auto-nudges overnight (10pm-7am)." + ], + [ + "Strand/Screens/AutomationsView.swift", + "Workout start & end" + ], + [ + "Strand/Screens/BackupSyncView.swift", + "Backups will be saved inside NOOP. Open the Files app → On My iPhone → NOOP → Backups to see them, or drag that folder into iCloud Drive to read it on your Mac. To use a different folder later, tap Change folder." + ], + [ + "Strand/Screens/BackupSyncView.swift", + "NOOP didn't get a folder back from the picker. If the Open button won't do anything, tap \\\"Use NOOP's own folder\\\" below to back up inside NOOP instead — you can read those backups from the Files app." + ], + [ + "Strand/Screens/BackupSyncView.swift", + "No folder selected" + ], + [ + "Strand/Screens/BackupSyncView.swift", + "Using NOOP's folder" + ], + [ + "Strand/Screens/BackupSyncView.swift", + "\\(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." + ], + [ + "Strand/Screens/BreathingView.swift", + "%.0f / %.0fs" + ], [ "Strand/Screens/BreathingView.swift", "%.1f br/min" ], + [ + "Strand/Screens/ChargeBreakdownFormat.swift", + "No deep sleep detected" + ], + [ + "Strand/Screens/ChargeBreakdownFormat.swift", + "The Deep sleep HRV window needs a night with deep-stage sleep to score Charge. Switch to Whole night in Settings, or wait for a night with more deep sleep." + ], + [ + "Strand/Screens/CompareView.swift", + "Each line min-max normalized within \\(range.phrase) · \\(inspectHint)" + ], + [ + "Strand/Screens/CompareView.swift", + "Min-max normalized · sparse series widened past \\(range.phrase) · \\(inspectHint)" + ], + [ + "Strand/Screens/ConsistencyCard.swift", + "vs typical - " + ], + [ + "Strand/Screens/CoupledView.swift", + "Recovery \\(Int(r.rounded())) percent" + ], + [ + "Strand/Screens/CoupledView.swift", + "Recovery calibrating, \\(banked) of \\(Baselines.minNightsSeed) nights" + ], + [ + "Strand/Screens/CoupledView.swift", + "Recovery, no data yet" + ], + [ + "Strand/Screens/CoupledView.swift", + "Sleep performance \\(Int(p.rounded())) percent" + ], + [ + "Strand/Screens/CoupledView.swift", + "Sleep performance \\(Int(p.rounded())) percent. \\(Self.hoursMinutes(asleep)) slept, \\(Self.hoursMinutes(sleepNeedForDay)) needed" + ], + [ + "Strand/Screens/CoupledView.swift", + "Sleep performance not available" + ], + [ + "Strand/Screens/CoupledView.swift", + "Strenuous" + ], + [ + "Strand/Screens/CoupledView.swift", + "\\(band.lowerBound) to \\(band.upperBound)" + ], + [ + "Strand/Screens/DashboardCards.swift", + "Estimated, updated weekly" + ], + [ + "Strand/Screens/DataSourcesView.swift", + "A one-time import of your own Oura history over the Oura API. Runs only when you tap it." + ], [ "Strand/Screens/DataSourcesView.swift", "Import your Oura history" ], + [ + "Strand/Screens/DataSourcesView.swift", + "Oura history import" + ], + [ + "Strand/Screens/DevicesView.swift", + "1 paired" + ], + [ + "Strand/Screens/DevicesView.swift", + "Clock latched: \\(latched) · last frame \\(frame)" + ], + [ + "Strand/Screens/DevicesView.swift", + "Connected, but not paired" + ], + [ + "Strand/Screens/DevicesView.swift", + "Data kept" + ], [ "Strand/Screens/DevicesView.swift", "Historical record layout v\\(layout)" ], + [ + "Strand/Screens/DevicesView.swift", + "\\(activeDevices.count) paired" + ], [ "Strand/Screens/FullDayChartView.swift", "Pinch to zoom · drag to pan · hold to read" ], + [ + "Strand/Screens/FullDayChartView.swift", + "This strap doesn’t send SpO₂ over Bluetooth. Import a WHOOP export or Health Connect to see it." + ], + [ + "Strand/Screens/FullDayChartView.swift", + "This strap sends no raw respiration stream. Your estimated respiratory rate appears on the Health screen." + ], [ "Strand/Screens/FullDayChartView.swift", "Zoomed in. Drag to pan · hold to read" ], + [ + "Strand/Screens/HealthView.swift", + "1 more night of wear and we can show your Fitness Age." + ], + [ + "Strand/Screens/HealthView.swift", + "1 more night of wear, plus your age and sex below, and we can show your Fitness Age." + ], + [ + "Strand/Screens/HealthView.swift", + "Add your age and sex below and we can show your Fitness Age." + ], + [ + "Strand/Screens/HealthView.swift", + "At least 1 year older than your age" + ], + [ + "Strand/Screens/HealthView.swift", + "At least 1 year younger than your age" + ], + [ + "Strand/Screens/HealthView.swift", + "At least \\(years) years older than your age" + ], + [ + "Strand/Screens/HealthView.swift", + "At least \\(years) years younger than your age" + ], + [ + "Strand/Screens/HealthView.swift", + "Fitness Age \\(bound)\\(shown), \\(ageDeltaLine(years: years, younger: younger, bound: bound)). Tap to see the trend." + ], [ "Strand/Screens/HealthView.swift", "Refresh Fitness Age now" @@ -942,6 +1262,26 @@ "Strand/Screens/HealthView.swift", "Vitality \\(Int(v.rounded())) out of 100" ], + [ + "Strand/Screens/HealthView.swift", + "\\(Int(FitnessAgeEngine.maxAge)) or older" + ], + [ + "Strand/Screens/HealthView.swift", + "\\(Int(FitnessAgeEngine.minAge)) or younger" + ], + [ + "Strand/Screens/HealthView.swift", + "\\(n) more nights of wear and we can show your Fitness Age." + ], + [ + "Strand/Screens/HealthView.swift", + "\\(n) more nights of wear, plus your age and sex below, and we can show your Fitness Age." + ], + [ + "Strand/Screens/HoursVsNeededCard.swift", + "vs typical - " + ], [ "Strand/Screens/HydrationView.swift", "Progress toward today's goal" @@ -1002,14 +1342,74 @@ "Strand/Screens/LabBookView.swift", "Choose a signal" ], + [ + "Strand/Screens/LabBookView.swift", + "Imported \\(result.importedReadings) readings (\\(result.distinctMarkers) markers)" + ], + [ + "Strand/Screens/LabBookView.swift", + "No usable rows found. Check the file has date, marker and value columns." + ], + [ + "Strand/Screens/LabBookView.swift", + "That file is too large for a markers CSV import." + ], [ "Strand/Screens/LabBookView.swift", "the signal" ], + [ + "Strand/Screens/LiveView.swift", + "Authenticated ring stream" + ], + [ + "Strand/Screens/LiveView.swift", + "Streaming" + ], + [ + "Strand/Screens/ManualWorkoutSheet.swift", + "Average HR must be 25-250 bpm." + ], + [ + "Strand/Screens/ManualWorkoutSheet.swift", + "Calories must be 0-20,000." + ], + [ + "Strand/Screens/MetricExplorerView.swift", + "No measured temperature for these nights — showing the difference from your baseline instead. A re-score refills temperatures for nights that have one." + ], + [ + "Strand/Screens/MetricExplorerView.swift", + "Only nights with a measured temperature are shown — the others only have a baseline difference." + ], [ "Strand/Screens/MetricExplorerView.swift", "\\(row.time), \\(row.value), \\(row.source)" ], + [ + "Strand/Screens/NightDetailCard.swift", + "vs typical - " + ], + [ + "Strand/Screens/RhythmView.swift", + "More varied" + ], + [ + "Strand/Screens/RhythmView.swift", + "No clear reading" + ], + [ + "Strand/Screens/RhythmView.swift", + "Some variation" + ], + [ + "Strand/Screens/ScoringGuideView.swift", + "Same cardiovascular-load idea as WHOOP's Day Strain (0-21). We rescaled the top of the ladder from 21 to 100 so all three scores share one scale. The rungs didn't move, so a 100 is as rare as a 21.0 was." + ], + [ + "Strand/Screens/ScoringGuideView.swift", + "Your cardiovascular load. NOOP turns every second of heart rate into a training-impulse using heart-rate-reserve zones (Karvonen), weights time in harder zones more heavily (Edwards / Banister), and places it on a logarithmic 0-100 scale, so easy days sit low and an all-out day approaches 100, which stays genuinely rare. A long walk with little cardio still counts, through a steps / active-energy floor." + ], [ "Strand/Screens/SettingsView.swift", "%+.0f%%" @@ -1024,7 +1424,35 @@ ], [ "Strand/Screens/SettingsView.swift", - "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." + "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." + ], + [ + "Strand/Screens/SettingsView.swift", + "Marked: \\(phase.displayName)" + ], + [ + "Strand/Screens/SettingsView.swift", + "Marker wasn't saved. Keep frame recording on and try again." + ], + [ + "Strand/Screens/SettingsView.swift", + "Needs the full encrypted bond: close the official WHOOP app and pair the strap to NOOP first (a live-HR-only link can't carry a config write)." + ], + [ + "Strand/Screens/SettingsView.swift", + "No WHOOP 5/MG deep buffers or phase markers have been recorded yet." + ], + [ + "Strand/Screens/SettingsView.swift", + "One tap writes the key; NOOP then reads it back off the strap and reports the value it actually stores — the write's own \\\"success\\\" is not treated as proof." + ], + [ + "Strand/Screens/SettingsView.swift", + "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." + ], + [ + "Strand/Screens/SettingsView.swift", + "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." ], [ "Strand/Screens/SettingsView.swift", @@ -1034,6 +1462,10 @@ "Strand/Screens/SettingsView.swift", "Stress: personal daytime baseline" ], + [ + "Strand/Screens/SettingsView.swift", + "The ECG gate needs an iPhone or Android. A Mac can't form the encrypted bond a 5/MG requires." + ], [ "Strand/Screens/SettingsView.swift", "Trend chart style" @@ -1042,10 +1474,38 @@ "Strand/Screens/SettingsView.swift", "WHOOP 5.0 / MG · motion → steps" ], + [ + "Strand/Screens/SettingsView.swift", + "Waiting for your strap to identify itself as an MG. Only a WHOOP MG has ECG electrodes, so NOOP won't write this key to anything else." + ], + [ + "Strand/Screens/SettingsView.swift", + "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." + ], [ "Strand/Screens/SettingsView.swift", "Your WHOOP 5.0/MG sends a strap-computed SpO₂ percentage (the @82 candidate byte) every second — an 8-night independent validation tracked it at corr +0.99 against the WHOOP app, but two nights on the original test device moved the OPPOSITE direction, so device/firmware variance is unresolved. An Oura ring's own SpO₂ reading runs high on the wire (over 100% on a fifth to a half of samples on a clean night); this instead surfaces the ring's mean with each sample capped at 100% first, which has matched the Oura app's own displayed value on every full night checked against it so far, though only a few nights. Turning this on surfaces whichever applies to your device as \\\"strap estimate (unverified)\\\" in the Blood Oxygen tile when no calibrated import exists. It never feeds recovery or illness scoring. WHOOP 4.0 has no @82 stream, so this does nothing there." ], + [ + "Strand/Screens/SettingsView.swift", + "\\(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." + ], + [ + "Strand/Screens/SleepDebtLedgerCard.swift", + "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." + ], + [ + "Strand/Screens/SleepDebtLedgerCard.swift", + "You've effectively met your current sleep need across \\(span)." + ], + [ + "Strand/Screens/SleepModel.swift", + "\\(Night.spanFmt.string(from: onsetDay)) → \\(Night.dateFmt.string(from: wakeDay))" + ], + [ + "Strand/Screens/SleepView.swift", + "Oura" + ], [ "Strand/Screens/SleepView.swift", "Removes this recorded sleep and recomputes the day without it. NOOP won't re-detect sleep in this window. You can undo for a few seconds after." @@ -1054,10 +1514,42 @@ "Strand/Screens/SleepView.swift", "Removes this sleep and recomputes the day without it. You can undo for a few seconds after." ], + [ + "Strand/Screens/SleepView.swift", + "Sleep deleted." + ], + [ + "Strand/Screens/SleepView.swift", + "Sleep deleted. NOOP won't detect sleep between \\(clockTime(banner.displayStart)) and \\(clockTime(banner.windowEnd)) again." + ], [ "Strand/Screens/SleepView.swift", "Undo sleep deletion" ], + [ + "Strand/Screens/SleepView.swift", + "\\(durationText(night.timeInBed)) in bed · \\(efficiencyText(night)) efficiency · \\(stageCaption)" + ], + [ + "Strand/Screens/SleepView.swift", + "about your usual" + ], + [ + "Strand/Screens/SleepView.swift", + "above your usual" + ], + [ + "Strand/Screens/SleepView.swift", + "below your usual" + ], + [ + "Strand/Screens/SleepView.swift", + "raw on-device stages" + ], + [ + "Strand/Screens/SleepView.swift", + "stages approximate (on-device)" + ], [ "Strand/Screens/SmartAlarmView.swift", "Notifications are off" @@ -1066,18 +1558,58 @@ "Strand/Screens/SmartAlarmView.swift", "Open Settings" ], + [ + "Strand/Screens/StagesCard.swift", + "\\(durationText(night.timeInBed)) in bed · \\(efficiencyText(night)) efficiency · \\(stageCaption)" + ], + [ + "Strand/Screens/StagesCard.swift", + "about your usual" + ], + [ + "Strand/Screens/StagesCard.swift", + "above your usual" + ], + [ + "Strand/Screens/StagesCard.swift", + "below your usual" + ], + [ + "Strand/Screens/StagesCard.swift", + "raw on-device stages" + ], + [ + "Strand/Screens/StagesCard.swift", + "stages approximate (on-device)" + ], [ "Strand/Screens/StressView.swift", - "Stress \\(String(format: \"%.1f\", score)) of 3" + "Daily 0-3 proxy" ], [ "Strand/Screens/StressView.swift", "Today's value is your recorded daily stress score (0-3)." ], + [ + "Strand/Screens/TestCentreView.swift", + "Backfilling…" + ], + [ + "Strand/Screens/TestCentreView.swift", + "Clock latched" + ], + [ + "Strand/Screens/TestCentreView.swift", + "Connected live" + ], [ "Strand/Screens/TestCentreView.swift", "Keys each Oura sleep night on its stable 0x49 onset and suppresses duplicate re-serves at the source, instead of the shipped end-anchored persist (#1284). Off by default — a hardware-validation toggle. Watch the strap log for \\u{201C}onset-key(#1284)\\u{201D} lines." ], + [ + "Strand/Screens/TestCentreView.swift", + "Last frame" + ], [ "Strand/Screens/TestCentreView.swift", "Oura onset keying (experimental)" @@ -1086,10 +1618,38 @@ "Strand/Screens/TestCentreView.swift", "Polar debug logging" ], + [ + "Strand/Screens/TestCentreView.swift", + "Requested" + ], + [ + "Strand/Screens/TestCentreView.swift", + "Rows drained (all time)" + ], + [ + "Strand/Screens/TestCentreView.swift", + "Rows drained (session)" + ], [ "Strand/Screens/TestCentreView.swift", "\\(identity). Logs this to the strap log on each connect, so a Polar bug report shows the model NOOP resolved your strap to." ], + [ + "Strand/Screens/TestCentreView.swift", + "no PPG" + ], + [ + "Strand/Screens/TrainingLoadChart.swift", + "CTL \\(fmt(p.ctl)) · ATL \\(fmt(p.atl))" + ], + [ + "Strand/Screens/TrendsReportView.swift", + "\\(prettyDate(report.start))-\\(prettyDate(report.end)) · 1 day" + ], + [ + "Strand/Screens/TrendsReportView.swift", + "\\(prettyDate(report.start))-\\(prettyDate(report.end)) · \\(span) days" + ], [ "Strand/Screens/TrendsView.swift", "Opens the full Charge metric." @@ -1098,6 +1658,22 @@ "Strand/Screens/TrendsView.swift", "Opens the full \\(accessibilityTitle) metric." ], + [ + "Strand/Screens/VitalSignsSummary.swift", + "No raw SpO₂ decode for the night" + ], + [ + "Strand/Screens/VitalSignsSummary.swift", + "Raw SpO₂" + ], + [ + "Strand/Screens/VitalSignsSummary.swift", + "Uncalibrated" + ], + [ + "Strand/Screens/VitalSignsSummary.swift", + "unverified · over-reports R-R" + ], [ "Strand/Screens/WorkoutDetailView.swift", "Effort \\(UnitFormatter.effortDisplay(strain, scale: effortScale)) \\(effortScale == .whoop ? \"of 21\" : \"of 100\")" @@ -1106,6 +1682,26 @@ "Strand/Screens/WorkoutDetailView.swift", "of 21" ], + [ + "Strand/Screens/WorkoutSelectionScreen.swift", + "Choose a workout" + ], + [ + "Strand/Screens/WorkoutSelectionScreen.swift", + "Pick an activity to begin recording heart rate, effort, peak, and average." + ], + [ + "Strand/Screens/WorkoutSelectionScreen.swift", + "Search workouts" + ], + [ + "Strand/Screens/WorkoutSelectionScreen.swift", + "\\(sport.name) workout, \\(labels.joined(separator: \", \"))" + ], + [ + "Strand/Screens/WorkoutsView.swift", + " · filtered" + ], [ "Strand/Screens/WorkoutsView.swift", "All sources" @@ -1122,14 +1718,42 @@ "Strand/Screens/WorkoutsView.swift", "Delete (\\(chosen.count))" ], + [ + "Strand/Screens/WorkoutsView.swift", + "Double-tap to select" + ], + [ + "Strand/Screens/WorkoutsView.swift", + "Effort \\(Self.effortCellLabel(strain: row.strain, scale: effortScale))" + ], + [ + "Strand/Screens/WorkoutsView.swift", + "Filter by source" + ], + [ + "Strand/Screens/WorkoutsView.swift", + "Filter by sport" + ], [ "Strand/Screens/WorkoutsView.swift", "Finish selecting" ], + [ + "Strand/Screens/WorkoutsView.swift", + "Imported history can't be merged" + ], + [ + "Strand/Screens/WorkoutsView.swift", + "Merge" + ], [ "Strand/Screens/WorkoutsView.swift", "Merge (\\(chosen.count))" ], + [ + "Strand/Screens/WorkoutsView.swift", + "Name the merged session" + ], [ "Strand/Screens/WorkoutsView.swift", "Select" @@ -1138,6 +1762,10 @@ "Strand/Screens/WorkoutsView.swift", "Select sessions to merge or delete" ], + [ + "Strand/Screens/WorkoutsView.swift", + "These sessions have no sport label yet. Pick one for the merged session." + ], [ "Strand/Screens/WorkoutsView.swift", "Typical effort \\(UnitFormatter.effortDisplay(avgStrain, scale: effortScale))" @@ -1146,9 +1774,53 @@ "Strand/Screens/WorkoutsView.swift", "Typical effort, no data" ], + [ + "Strand/Screens/WorkoutsView.swift", + "\\(WorkoutSource.displaySport(row.sport)), \\(compactRowSubtitle(row)), \\(effort)" + ], + [ + "Strand/Screens/WorkoutsView.swift", + "\\(base). Imported, can't be merged." + ], + [ + "Strand/Screens/WorkoutsView.swift", + "\\(base). Not selected." + ], + [ + "Strand/Screens/WorkoutsView.swift", + "\\(base). Selected." + ], + [ + "Strand/Screens/WorkoutsView.swift", + "\\(grouped(k)) kcal" + ], + [ + "Strand/Screens/WorkoutsView.swift", + "no Effort recorded" + ], [ "Strand/Screens/WorkoutsView.swift", "of 21" + ], + [ + "Strand/System/CoachBriefScheduler.swift", + "Coach brief unavailable" + ], + [ + "Strand/System/CoachBriefScheduler.swift", + "Couldn't generate today's brief. Tap to try again in Coach." + ], + [ + "Strand/System/CoachBriefScheduler.swift", + "Today's coaching brief" + ], + [ + "StrandiOSWidgets/StressWidget.swift", + "Stress \\(now) of 3, average \\(meanText), peak \\(peakText)" + ], + [ + "StrandiOSWidgets/StressWidget.swift", + "Stress, no reading today" ] ] } diff --git a/Tools/i18n_echo_baseline.txt b/Tools/i18n_echo_baseline.txt index 95318ab446..336087215b 100644 --- a/Tools/i18n_echo_baseline.txt +++ b/Tools/i18n_echo_baseline.txt @@ -17,9 +17,9 @@ Strand/Resources/Localizable.xcstrings pt-PT 11 Strand/Resources/Localizable.xcstrings ru 7 Strand/Resources/Localizable.xcstrings zh-Hans 8 Strand/Resources/Localizable.xcstrings zh-Hant 7 -android/app/src/main/res/values-de/strings.xml de 16 +android/app/src/main/res/values-de/strings.xml de 17 android/app/src/main/res/values-es/strings.xml es 42 -android/app/src/main/res/values-fr/strings.xml fr 13 +android/app/src/main/res/values-fr/strings.xml fr 14 android/app/src/main/res/values-pl/strings.xml pl 11 -android/app/src/main/res/values-pt-rPT/strings.xml pt-rPT 8 +android/app/src/main/res/values-pt-rPT/strings.xml pt-rPT 9 android/app/src/main/res/values-zh/strings.xml zh 15 diff --git a/Tools/i18n_extra_locale_baseline.txt b/Tools/i18n_extra_locale_baseline.txt index d854b49885..7779c7b1f1 100644 --- a/Tools/i18n_extra_locale_baseline.txt +++ b/Tools/i18n_extra_locale_baseline.txt @@ -3,19 +3,20 @@ # Ratchet DOWN as translations land — never up. de/es/fr/pt-PT are NOT here: they stay at zero # 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:zh-Hans 75 -Packages/StrandDesign/Sources/StrandDesign/Resources/Localizable.xcstrings:zh-Hant 81 +Packages/StrandDesign/Sources/StrandDesign/Resources/Localizable.xcstrings:it 32 +Packages/StrandDesign/Sources/StrandDesign/Resources/Localizable.xcstrings:ru 1 +Packages/StrandDesign/Sources/StrandDesign/Resources/Localizable.xcstrings:zh-Hans 0 +Packages/StrandDesign/Sources/StrandDesign/Resources/Localizable.xcstrings:zh-Hant 0 NOOPWatch/Localizable.xcstrings:it 0 NOOPWatch/Localizable.xcstrings:ru 0 NOOPWatch/Localizable.xcstrings:zh-Hans 0 NOOPWatch/Localizable.xcstrings:zh-Hant 0 NOOPWatchComplications/Localizable.xcstrings:it 25 -NOOPWatchComplications/Localizable.xcstrings:zh-Hans 25 -NOOPWatchComplications/Localizable.xcstrings:zh-Hant 25 +NOOPWatchComplications/Localizable.xcstrings:zh-Hans 0 +NOOPWatchComplications/Localizable.xcstrings:zh-Hant 0 Strand/Resources/Localizable.xcstrings:it 179 -Strand/Resources/Localizable.xcstrings:ru 156 -Strand/Resources/Localizable.xcstrings:zh-Hans 129 -Strand/Resources/Localizable.xcstrings:zh-Hant 179 -values-zh/strings.xml 49 +Strand/Resources/Localizable.xcstrings:ru 1 +Strand/Resources/Localizable.xcstrings:zh-Hans 0 +Strand/Resources/Localizable.xcstrings:zh-Hant 79 +values-ru/strings.xml 121 +values-zh/strings.xml 0 diff --git a/Tools/issue_ref.py b/Tools/issue_ref.py new file mode 100644 index 0000000000..9af8b717a6 --- /dev/null +++ b/Tools/issue_ref.py @@ -0,0 +1,55 @@ +"""Strict, repository-qualified references for governed GitHub issues.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + + +_CURRENT = re.compile( + r"(?P[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?)/" + r"(?P[A-Za-z0-9][A-Za-z0-9_.-]*)#(?P[1-9][0-9]*)" +) +class IssueRefError(ValueError): + """Raised when governed issue metadata is not canonical.""" + + +@dataclass(frozen=True, order=True) +class IssueRef: + repo: str + number: int + + def __str__(self) -> str: + return f"{self.repo}#{self.number}" + + +def parse_current(value: object) -> IssueRef: + """Parse only the canonical current ``owner/repo#N`` representation.""" + if not isinstance(value, str): + raise IssueRefError("issue must be a canonical owner/repo#N string") + match = _CURRENT.fullmatch(value) + if match is None: + raise IssueRefError(f"invalid issue reference {value!r}; expected owner/repo#N") + return IssueRef( + f"{match.group('owner')}/{match.group('name')}", + int(match.group("number")), + ) + + +def validate_current_issue_fields(value: object, location: str = "JSON") -> None: + """Validate every governed field literally named ``issue`` in current JSON.""" + def walk(node: object, pointer: str) -> None: + if isinstance(node, dict): + for key, child in node.items(): + child_pointer = f"{pointer}/{key}" + if key == "issue": + try: + parse_current(child) + except IssueRefError as exc: + raise IssueRefError(f"{location}{child_pointer}: {exc}") from exc + walk(child, child_pointer) + elif isinstance(node, list): + for index, child in enumerate(node): + walk(child, f"{pointer}/{index}") + + walk(value, "") 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..71d5a7ddea 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). + + The 4.0 value preserves its real type/seq/cmd minimum. The 5.0 value is an empirical NOOP policy: + a 12-byte empty-payload envelope is internally consistent and Goose accepts it, but no such frame + has been observed from hardware. It is rejected here so this tool and the protocol package answer + the same question; trim-ack callers already demand more bytes than either floor. + """ + + 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..bae2bf78b5 100644 --- a/Tools/linux-capture/whoop_frame.py +++ b/Tools/linux-capture/whoop_frame.py @@ -263,14 +263,28 @@ def command_response_cmd(frame: bytes, family: str): WHOOP5_META_TRIM_OFF = 21 WHOOP5_END_DATA_LEN = 8 +# Accepted frame floor per family, mirroring `FrameLimits` in the Swift WhoopProtocol package +# (whoop4MinimumFrameBytes = 11, whoop5MinimumFrameBytes = 13). The 4.0 value is structural for its +# type/seq/cmd record. The 5.0 value is empirical: a 12-byte empty-payload envelope is internally +# consistent (and accepted by Goose's v5Payload), but NOOP requires at least the inner type byte. +# Captured fixtures exist at 20, 24 and 32 bytes, not at the 12-byte boundary, so they do not prove +# the configured floor. +# +# 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 +341,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/make_lift_program_template.py b/Tools/make_lift_program_template.py new file mode 100644 index 0000000000..c14edcbc8f --- /dev/null +++ b/Tools/make_lift_program_template.py @@ -0,0 +1,255 @@ +#!/usr/bin/env python3 +"""Generate the Lift Log program template (.xlsx). + +The template is the thing a user actually touches: they fill it in on a computer, where typing a +dozen exercises with sets, reps, weights, max RPEs, rests and technique notes is a two-minute job instead of +the most tedious screen in the app. + +It is written by hand rather than with a library so the repository needs no Python dependency to +rebuild it, and so the exact bytes are reproducible. An .xlsx is a ZIP of XML; only the parts that +matter here are emitted. + +WHAT MAKES IT "FILLABLE, NOT EDITABLE": + * the sheet is protected, and only the data cells are unlocked — headers cannot be renamed, + reordered or deleted, which is what keeps the importer's column mapping true; + * the two muscle columns are dropdowns over the app's closed 20-token vocabulary, so a muscle + cannot be misspelled into something the importer has to reject; + * the max RPE column only accepts a number from 1 to 10; + * a second sheet carries the instructions and a worked example, and is NOT read by the importer, + which always reads the first sheet. + +Run: python3 Tools/make_lift_program_template.py +Out: docs/lift-log-program-template.xlsx +""" +import os +import zipfile + +MUSCLES = [ + "Chest", "Front delts", "Side delts", "Rear delts", "Triceps", + "Lats", "Upper back", "Traps", "Biceps", "Forearms", + "Quads", "Hamstrings", "Glutes", "Adductors", "Abductors", "Calves", + "Abs", "Obliques", "Lower back", "Neck", +] + +HEADERS = [ + "Program", "Program note", "Exercise", "Primary muscle", "Secondary muscles", + "Sets", "Reps", "Weight kg", "Target max RPE", "Rest sec", "Note", +] + +# Generous, so a user can paste a long routine in without running out of validated rows. +DATA_ROWS = 200 + +HELP = [ + ("How to use this template", True), + ("", False), + ("1. Fill in one row per exercise, in the order you want to do them.", False), + ("2. Put the same name in 'Program' for every exercise that belongs to the same session.", False), + (" You can keep several programs in one file - Upper A, Lower A - just change the name.", False), + ("3. Save the file, put it on your phone, and open NOOP > Lift Log > Import a program.", False), + ("", False), + ("Only 'Exercise' is required. Every other column can be left blank and filled in later", False), + ("in the app, or during the session itself.", False), + ("", False), + ("Primary muscle / Secondary muscles use the dropdowns. Secondary accepts several,", False), + ("separated by commas: Front delts, Triceps", False), + ("", False), + ("Weight is in KILOGRAMS. The app shows it in your chosen unit; it is stored in kg.", False), + ("Target max RPE is a CEILING from 1 to 10: the hardest a set should feel, 10 meaning nothing", False), + ("left. The app shows it grey during the session, and a set you leave unrated saves it as its rating.", False), + ("Rest is in SECONDS. 120 means two minutes.", False), + ("", False), + ("Do not rename, reorder or delete the header row - the import matches on those names.", False), + ("", False), + ("Worked example", True), + ("Program Exercise Primary Secondary Sets Reps Weight MaxRPE Rest", False), + ("Lower A Leg Press midfoot Quads Glutes 3 10 50 8 90", False), + ("Lower A Lying Leg Curl Hamstrings Calves 3 8 30 8 90", False), + ("Lower A Leg Extension Quads 3 12 40 60", False), + ("", False), + ("Muscle groups you can choose from", True), +] + [(m, False) for m in MUSCLES] + + +def esc(s): + return (s.replace("&", "&").replace("<", "<").replace(">", ">") + .replace('"', """)) + + +def col_letter(i): + s = "" + i += 1 + while i: + i, r = divmod(i - 1, 26) + s = chr(65 + r) + s + return s + + +def cell(ref, text, style): + """An inline-string cell. Inline rather than shared strings keeps this generator single-pass.""" + if text == "": + return f'' + return (f'' + f'{esc(text)}') + + +def program_sheet(): + rows = [] + # Header row: style 1 (bold + locked). + cells = "".join(cell(f"{col_letter(i)}1", h, 1) for i, h in enumerate(HEADERS)) + rows.append(f'{cells}') + # Data rows: style 2 (unlocked), empty and ready to type into. + for r in range(2, DATA_ROWS + 2): + cells = "".join(f'' for i in range(len(HEADERS))) + rows.append(f'{cells}') + + widths = [16, 26, 30, 16, 26, 7, 7, 11, 15, 10, 34] + cols = "".join( + f'' + for i, w in enumerate(widths)) + + listing = ",".join(MUSCLES) + rpe_col = col_letter(HEADERS.index("Target max RPE")) + validations = ( + f'' + f'"{esc(listing)}"' + f'"{esc(listing)}"' + f'110' + f'' + f'') + + # Sheet protection, and the attribute semantics are the opposite of what they look like. + # + # In OOXML each of these flags answers "is this operation PREVENTED", and they default to TRUE + # once `sheet="1"`. So an attribute set to "0" ALLOWS the thing it names. An earlier version of + # this file carried `insertColumns="0" deleteColumns="0"`, which permitted exactly the one edit + # that breaks the importer: the column mapping is by header name and position, so a column + # inserted in the middle silently moves reps into the weight field. + # + # Left at their default (prevented): inserting and deleting COLUMNS. + # Explicitly allowed: selecting cells (or the sheet is unreadable), typing into the unlocked data + # cells, cosmetic formatting, sorting and filtering, and inserting or deleting ROWS — a user with + # a long routine needs more rows, and row count is nothing the importer depends on. + protection = ('') + + return ( + '' + '' + '' + '' + '' + f'{cols}' + f'{"".join(rows)}' + f'{protection}{validations}' + '') + + +def help_sheet(): + rows = [] + for i, (text, bold) in enumerate(HELP, start=1): + rows.append(f'{cell(f"A{i}", text, 1 if bold else 0)}') + return ( + '' + '' + '' + f'{"".join(rows)}' + '' + '') + + +STYLES = ( + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + # 0: plain, locked + '' + # 1: header - bold, tinted, LOCKED so the structure cannot be edited + '' + # 2: data - UNLOCKED, the only cells a user can type into + '' + '' + '' + '' + '') + +CONTENT_TYPES = ( + '' + '' + '' + '' + '' + '' + '' + '' + '') + +ROOT_RELS = ( + '' + '' + '' + '') + +WORKBOOK = ( + '' + '' + '' + '' + '' + '') + +WORKBOOK_RELS = ( + '' + '' + '' + '' + '' + '') + + +def main(): + here = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + out = os.path.join(here, "docs", "lift-log-program-template.xlsx") + os.makedirs(os.path.dirname(out), exist_ok=True) + parts = { + "[Content_Types].xml": CONTENT_TYPES, + "_rels/.rels": ROOT_RELS, + "xl/workbook.xml": WORKBOOK, + "xl/_rels/workbook.xml.rels": WORKBOOK_RELS, + "xl/styles.xml": STYLES, + "xl/worksheets/sheet1.xml": program_sheet(), + "xl/worksheets/sheet2.xml": help_sheet(), + } + # Fixed timestamps: the file is a build artifact committed to the repo, and it should only change + # when its CONTENT does, not every time it is regenerated. + with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z: + for name, body in parts.items(): + info = zipfile.ZipInfo(name, date_time=(2026, 1, 1, 0, 0, 0)) + info.compress_type = zipfile.ZIP_DEFLATED + info.external_attr = 0o644 << 16 + z.writestr(info, body) + print(f"wrote {os.path.relpath(out, here)} ({os.path.getsize(out)} bytes)") + + +if __name__ == "__main__": + main() diff --git a/Tools/parity_case_specs/core_reference.json b/Tools/parity_case_specs/core_reference.json new file mode 100644 index 0000000000..5495c9ee70 --- /dev/null +++ b/Tools/parity_case_specs/core_reference.json @@ -0,0 +1,19 @@ +{ + "schemaVersion": 1, + "suiteVersion": "core-reference-v1", + "shards": [ + { + "name": "core-reference", + "operations": [ + { + "key": "Portable.clamp/3", + "cases": [ + {"id": "above", "expected": 10, "args": {"lower": 0, "upper": 10, "value": 14}}, + {"id": "inside", "expected": 4, "args": {"lower": 0, "upper": 10, "value": 4}}, + {"id": "lower", "expected": 0, "args": {"lower": 0, "upper": 10, "value": -2}} + ] + } + ] + } + ] +} 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/parity_dispositions.json b/Tools/parity_dispositions.json new file mode 100644 index 0000000000..b1efd58add --- /dev/null +++ b/Tools/parity_dispositions.json @@ -0,0 +1,85 @@ +{ + "schema_version": 1, + "dispositions": [ + { + "type": "platform_specific", + "kind": "add-unpaired-constant", + "identity": "swift\u0000Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift::openNightMarginSeconds", + "identity_sha256": "7f2231de6355adc6b5897cb6f0addc8b708bf08dd0277f465dcb794f4fd29b2a", + "platform": "swift", + "rationale": "Used only by the iOS HealthKitBridge write-back to hold a still-open night out of Apple Health; the Android Health Connect exporter does not call it (this PR is Swift-only)." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-constant", + "identity": "swift\u0000Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift::openNightMaxHoldSeconds", + "identity_sha256": "a497335d1554bb0604aa79b1ae05fedaca8d0a812141c038e7e5151d2a8348e5", + "platform": "swift", + "rationale": "Used only by the iOS HealthKitBridge write-back to hold a still-open night out of Apple Health; the Android Health Connect exporter does not call it (this PR is Swift-only)." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "swift\u0000Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift::nightIsStillOpen/3#1", + "identity_sha256": "88a1bdfb13c227664f15d962fb6d41a5733fb4c2956e445e42c5e4d495bde0cb", + "platform": "swift", + "rationale": "Used only by the iOS HealthKitBridge write-back to hold a still-open night out of Apple Health; the Android Health Connect exporter does not call it (this PR is Swift-only)." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt::computeHabitualSleep/7#1", + "identity_sha256": "0f4c9098de75c40d0ed87c6ad6111d259d664e632fe4b34ea7c77fef57fe8484", + "platform": "kotlin", + "rationale": "Twin of the Swift IntelligenceEngine.computeHabitualSleep(finishedBefore:) in the app layer (Strand/Data/IntelligenceEngine.swift), outside the governed Swift roots." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt::finishedSessions/2#1", + "identity_sha256": "8f5d3811a0bc62b7a45c7faadd3a96ba6cd93609fdd023d540b41d19fff6a5d7", + "platform": "kotlin", + "rationale": "Kotlin-side test seam for the finished-nights filter the Swift twin applies inline in Strand/Data/IntelligenceEngine.swift (app layer, outside the governed roots)." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/PhysiologicalStepCycleEngine.kt::loadCacheKey/7#1", + "identity_sha256": "d975e64811f395077839f35c6797284bac55e6ad3e3b5e049bf826d69d5a2131", + "platform": "kotlin", + "rationale": "Twin of the Swift cycle load cache key built inline in Strand/Data/DayCycleIntelligenceIntegration.swift (app layer, outside the governed roots)." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/data/WhoopRepository.kt::hrUnionFingerprint/3#1", + "identity_sha256": "c6879bd0c72d81c059254b85d6826258e1cf3cd97bf81e26bf013559678f415b", + "platform": "kotlin", + "rationale": "Repository witness for the cycle load cache; the Swift side calls WhoopStore.hrFingerprint(deviceId:from:to:) per owner inline in Strand/Data/DayCycleIntelligenceIntegration.swift." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-property", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/AnalyticsModels.kt::cacheKey@property#1", + "identity_sha256": "44346ba83a628dc53e4f7eb6757d3ece1d98c77c612653df659182d5aceeecf0", + "platform": "kotlin", + "rationale": "Twin of the Swift UserProfile.cacheKey, which lives with the Swift UserProfile in Packages/StrandAnalytics/Sources/StrandAnalytics/WorkoutDetector.swift; the two types sit in differently named files, so the ledger cannot pair them." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-property", + "identity": "swift\u0000Packages/StrandAnalytics/Sources/StrandAnalytics/WorkoutDetector.swift::cacheKey@property#1", + "identity_sha256": "5c7b107d76b7c42ea4a0deb3bbd2bd62dc3d5f267b276af2f521502add2c73a2", + "platform": "swift", + "rationale": "Twin of the Kotlin UserProfile.cacheKey in android/app/src/main/java/com/noop/analytics/AnalyticsModels.kt; the two types sit in differently named files, so the ledger cannot pair them." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-property", + "identity": "swift\u0000Packages/OuraProtocol/Sources/OuraProtocol/OuraEvents.swift::channel@property#1", + "identity_sha256": "ecbc737217fc7e501b46226d4560ed9f23d2bd636faeef2f5e59e0a7274821e1", + "platform": "swift", + "rationale": "Twin of the Kotlin `val OuraSpO2.channel` in android/app/src/main/java/com/noop/oura/OuraEvents.kt. Both are one-line extension properties over OuraSpO2 returning OuraSpO2Channel, added together in the same change. Kotlin declares its copy as a TOP-LEVEL extension property with a dotted receiver, which the ledger's Kotlin property scan does not index, so the Swift side reads as one-sided. The pair is real; only one half is visible to the inventory." + } + ] +} diff --git a/Tools/parity_harness.py b/Tools/parity_harness.py new file mode 100644 index 0000000000..dc4db050c3 --- /dev/null +++ b/Tools/parity_harness.py @@ -0,0 +1,562 @@ +#!/usr/bin/env python3 +"""Compact, deterministic, standard-library-only differential harness core.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import math +import re +import sys +from pathlib import Path +from typing import Iterable + +import parity_shards + + +class HarnessError(ValueError): + """Raised when a portable harness contract fails closed.""" + + +_CASE_ID = re.compile(r"[a-z][a-z0-9-]*") +_INPUT_FIELDS = {"args", "caseSha256", "expected", "function", "id", "shard", "suiteVersion"} +_OUTPUT_FIELDS = {"caseSha256", "function", "id", "mutant", "side", "sourceRevision", "suiteVersion", "value"} + + +def _nonempty_string(value: object, label: str) -> None: + if not isinstance(value, str) or not value.strip(): + raise HarnessError(f"{label} must be a non-empty string") + + +def _revision(value: object) -> None: + if not isinstance(value, str) or re.fullmatch(r"(?:[0-9a-f]{40}|[0-9a-f]{64})", value) is None: + raise HarnessError("sourceRevision must be a full lowercase Git revision (40 or 64 hex digits)") + + +def _reject_constant(token: str) -> None: + raise HarnessError(f"numbers must be finite, got {token}") + + +def _object_without_duplicate_keys(pairs: list[tuple[str, object]]) -> dict: + value: dict = {} + for key, item in pairs: + if key in value: + raise HarnessError(f"duplicate JSON key {key!r}") + value[key] = item + return value + + +def _validate_portable_json(value: object, path: str = "value") -> None: + if isinstance(value, str): + if any(0xD800 <= ord(char) <= 0xDFFF for char in value): + raise HarnessError(f"{path} strings must contain Unicode scalar values") + return + if value is None or isinstance(value, bool): + return + if isinstance(value, int): + if not -(1 << 63) <= value <= (1 << 63) - 1: + raise HarnessError(f"{path} integer must fit signed 64-bit") + return + if isinstance(value, float): + if not math.isfinite(value): + raise HarnessError(f"{path} number must be finite") + return + if isinstance(value, list): + for number, item in enumerate(value): + _validate_portable_json(item, f"{path}[{number}]") + return + if isinstance(value, dict): + for key, item in value.items(): + if not isinstance(key, str): + raise HarnessError(f"{path} object keys must be strings") + _validate_portable_json(key, f"{path} key") + _validate_portable_json(item, f"{path}.{key}") + return + raise HarnessError(f"{path} has non-JSON type {type(value).__name__}") + + +def parse_json(text: str) -> object: + try: + value = json.loads( + text, + parse_constant=_reject_constant, + object_pairs_hook=_object_without_duplicate_keys, + ) + except json.JSONDecodeError as exc: + raise HarnessError(f"invalid JSON: {exc}") from exc + _validate_portable_json(value) + return value + + +def canonical_json(value: object) -> str: + _validate_portable_json(value) + try: + return json.dumps( + value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False + ) + except (TypeError, ValueError) as exc: + raise HarnessError(f"value is not finite canonical JSON: {exc}") from exc + + +def _exact_fields(value: object, expected: set[str], path: str) -> dict: + if not isinstance(value, dict) or set(value) != expected: + actual = sorted(value) if isinstance(value, dict) else type(value).__name__ + raise HarnessError(f"{path} fields must be exactly {sorted(expected)}, got {actual}") + return value + + +def _operation_index() -> dict[str, tuple[parity_shards.ShardSpec, parity_shards.OperationSpec]]: + parity_shards.validate_registry(parity_shards.REGISTRY) + return parity_shards.operation_index() + + +def validate_spec(value: object) -> dict: + _validate_portable_json(value, "spec") + spec = _exact_fields(value, {"schemaVersion", "shards", "suiteVersion"}, "spec") + _nonempty_string(spec["suiteVersion"], "suiteVersion") + if type(spec["schemaVersion"]) is not int or spec["schemaVersion"] != 1: + raise HarnessError("spec schemaVersion must be 1") + if not isinstance(spec["shards"], list): + raise HarnessError("spec shards must be an array") + expected_shards = [shard.name for shard in parity_shards.REGISTRY] + actual_shards: list[str] = [] + operation_index = _operation_index() + for shard_number, raw_shard in enumerate(spec["shards"]): + path = f"spec.shards[{shard_number}]" + shard = _exact_fields(raw_shard, {"name", "operations"}, path) + if not isinstance(shard["name"], str) or not isinstance(shard["operations"], list): + raise HarnessError(f"{path} requires string name and operations array") + actual_shards.append(shard["name"]) + registered = next( + (item for item in parity_shards.REGISTRY if item.name == shard["name"]), None + ) + if registered is None: + raise HarnessError(f"{path} names unknown shard {shard['name']!r}") + actual_operations: list[str] = [] + for operation_number, raw_operation in enumerate(shard["operations"]): + op_path = f"{path}.operations[{operation_number}]" + operation = _exact_fields(raw_operation, {"key", "cases"}, op_path) + if not isinstance(operation["key"], str) or not isinstance(operation["cases"], list): + raise HarnessError(f"{op_path} requires string key and cases array") + actual_operations.append(operation["key"]) + registered_pair = operation_index.get(operation["key"]) + if registered_pair is None or registered_pair[0].name != shard["name"]: + raise HarnessError(f"{op_path} operation is not registered in shard {shard['name']}") + if not operation["cases"]: + raise HarnessError(f"{op_path} must contain at least one case") + case_ids: list[str] = [] + for case_number, raw_case in enumerate(operation["cases"]): + case_path = f"{op_path}.cases[{case_number}]" + case = _exact_fields(raw_case, {"id", "args", "expected"}, case_path) + if not isinstance(case["id"], str) or _CASE_ID.fullmatch(case["id"]) is None: + raise HarnessError(f"{case_path} id must match {_CASE_ID.pattern}") + if not isinstance(case["args"], dict): + raise HarnessError(f"{case_path} args must be an object") + if tuple(sorted(case["args"])) != registered_pair[1].argument_keys: + raise HarnessError( + f"{case_path} args must contain exactly {list(registered_pair[1].argument_keys)}" + ) + case_ids.append(case["id"]) + try: + registered_pair[1].reference(case["args"]) + except parity_shards.RegistryError as exc: + raise HarnessError(f"{case_path}: {exc}") from exc + if len(case_ids) != len(set(case_ids)): + raise HarnessError(f"{op_path} case ids must be unique") + if case_ids != sorted(case_ids): + raise HarnessError(f"{op_path} cases must be in canonical id order") + expected_operations = [item.key for item in registered.operations] + if actual_operations != expected_operations: + raise HarnessError( + f"{path} operations must exactly match registry order {expected_operations}" + ) + if actual_shards != expected_shards: + raise HarnessError(f"spec shards must exactly match registry order {expected_shards}") + return spec + + +def load_spec(path: Path) -> dict: + try: + raw = path.read_text(encoding="utf-8") + except (OSError, UnicodeError) as exc: + raise HarnessError(f"cannot read case spec {path}: {exc}") from exc + value = parse_json(raw) + return validate_spec(value) + + +def case_digest(row: dict) -> str: + payload = dict(row) + payload.pop("caseSha256", None) + return hashlib.sha256(canonical_json(payload).encode("utf-8")).hexdigest() + + +def expand_spec(value: object) -> list[dict]: + spec = validate_spec(value) + rows: list[dict] = [] + for shard in spec["shards"]: + for operation in shard["operations"]: + for case in operation["cases"]: + row = { + "args": case["args"], + "expected": case["expected"], + "suiteVersion": spec["suiteVersion"], + "function": operation["key"], + "id": f"{shard['name']}/{operation['key']}/{case['id']}", + "shard": shard["name"], + } + row["caseSha256"] = case_digest(row) + rows.append(row) + return sorted(rows, key=lambda item: item["id"]) + + +def canonical_jsonl(records: Iterable[dict]) -> str: + rows = list(records) + if not rows: + raise HarnessError("JSONL must contain at least one record") + return "".join(f"{canonical_json(row)}\n" for row in rows) + + +def parse_jsonl(text: str, label: str) -> list[dict]: + if not text.endswith("\n"): + raise HarnessError(f"{label} JSONL requires a final newline") + lines = text[:-1].split("\n") + if not lines or any(not line for line in lines): + raise HarnessError(f"{label} JSONL must be non-empty without blank lines") + rows: list[dict] = [] + for number, line in enumerate(lines, 1): + row = parse_json(line) + if not isinstance(row, dict) or line != canonical_json(row): + raise HarnessError(f"{label} line {number} is not canonical JSON") + rows.append(row) + return rows + + +def read_jsonl(path: Path, label: str) -> list[dict]: + try: + with path.open(encoding="utf-8", newline="") as stream: + return parse_jsonl(stream.read(), label) + except (OSError, UnicodeError) as exc: + raise HarnessError(f"cannot read {label} {path}: {exc}") from exc + + +def write_jsonl(path: Path, rows: Iterable[dict]) -> None: + text = canonical_jsonl(rows) + try: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8", newline="\n") as stream: + stream.write(text) + except (OSError, UnicodeError) as exc: + raise HarnessError(f"cannot write JSONL {path}: {exc}") from exc + + +def _index(records: Iterable[dict], label: str, fields: set[str]) -> dict[str, dict]: + indexed: dict[str, dict] = {} + for number, raw in enumerate(records, 1): + row = _exact_fields(raw, fields, f"{label}[{number}]") + case_id = row.get("id") + if not isinstance(case_id, str) or not case_id: + raise HarnessError(f"{label}[{number}] requires a non-empty id") + if case_id in indexed: + raise HarnessError(f"duplicate {label} id {case_id}") + indexed[case_id] = row + if not indexed: + raise HarnessError(f"{label} must be non-empty") + return indexed + + +def _input_index(records: Iterable[dict]) -> dict[str, dict]: + indexed = _index(records, "input", _INPUT_FIELDS) + operation_index = _operation_index() + versions = set() + for case_id, row in indexed.items(): + _nonempty_string(row["suiteVersion"], "suiteVersion") + versions.add(row["suiteVersion"]) + if not isinstance(row["function"], str) or not isinstance(row["shard"], str): + raise HarnessError(f"input id={case_id} function and shard must be strings") + if not isinstance(row["caseSha256"], str) or not isinstance(row["args"], dict): + raise HarnessError(f"input id={case_id} requires string caseSha256 and object args") + prefix = f"{row['shard']}/{row['function']}/" + if not case_id.startswith(prefix) or _CASE_ID.fullmatch(case_id[len(prefix):]) is None: + raise HarnessError(f"input id={case_id} is not structurally bound to shard/function") + pair = operation_index.get(row["function"]) + if pair is None or pair[0].name != row["shard"]: + raise HarnessError(f"input id={case_id} targets an unknown operation/shard") + if row["caseSha256"] != case_digest(row): + raise HarnessError(f"input id={case_id} caseSha256 mismatch") + if len(versions) != 1: + raise HarnessError("input suiteVersion must be identical across cases") + if list(indexed) != sorted(indexed): + raise HarnessError("input rows must be in canonical id order") + return indexed + + +def split_records(records: Iterable[dict]) -> dict[str, list[dict]]: + indexed = _input_index(records) + shards = {shard.name: [] for shard in parity_shards.REGISTRY} + for row in indexed.values(): + shards[row["shard"]].append(row) + empty = [name for name, rows in shards.items() if not rows] + if empty: + raise HarnessError(f"every registered shard must be non-empty: {empty}") + return shards + + +def merge_records(parts: Iterable[Iterable[dict]], expected: Iterable[dict]) -> list[dict]: + expected_index = _input_index(expected) + merged: list[dict] = [] + seen: set[str] = set() + part_shards: list[set[str]] = [] + for part in parts: + part = list(part) + part_shards.append({row.get("shard") for row in part if isinstance(row, dict) and isinstance(row.get("shard"), str)}) + for row in part: + case_id = row.get("id") if isinstance(row, dict) else None + if case_id in seen: + raise HarnessError(f"duplicate shard id {case_id}") + if isinstance(case_id, str): + seen.add(case_id) + merged.append(row) + actual_index = _input_index(merged) + missing = sorted(set(expected_index) - set(actual_index)) + unexpected = sorted(set(actual_index) - set(expected_index)) + if missing or unexpected: + raise HarnessError(f"shard IDs mismatch: missing={missing} unexpected={unexpected}") + registered_shards = [{shard.name} for shard in parity_shards.REGISTRY] + if part_shards != registered_shards: + raise HarnessError("merge requires one part per registered shard in registry order") + for case_id in expected_index: + if canonical_json(actual_index[case_id]) != canonical_json(expected_index[case_id]): + raise HarnessError(f"shard id={case_id} content mismatch") + return [actual_index[case_id] for case_id in sorted(actual_index)] + + +def run_records(records: Iterable[dict], side: str, *, source_revision: str, mutant: bool = False) -> list[dict]: + _revision(source_revision) + if side not in {"core", "reference"}: + raise HarnessError("side must be core or reference") + indexed = _input_index(records) + operations = _operation_index() + output: list[dict] = [] + for case_id, row in indexed.items(): + operation = operations[row["function"]][1] + try: + value = (operation.core if side == "core" else operation.reference)(row["args"]) + if mutant: + value = operation.mutant(value) + except parity_shards.RegistryError as exc: + raise HarnessError(f"{side} id={case_id}: {exc}") from exc + canonical_json(value) + output.append( + { + "caseSha256": row["caseSha256"], + "function": row["function"], + "id": case_id, + "mutant": mutant, + "side": side, + "sourceRevision": source_revision, + "suiteVersion": row["suiteVersion"], + "value": value, + } + ) + return output + + +def _output_index(records: Iterable[dict], label: str) -> dict[str, dict]: + indexed = _index(records, label, _OUTPUT_FIELDS) + for case_id, row in indexed.items(): + if not isinstance(row["caseSha256"], str) or not isinstance(row["function"], str): + raise HarnessError(f"{label} id={case_id} function and caseSha256 must be strings") + if not isinstance(row["side"], str) or type(row["mutant"]) is not bool: + raise HarnessError(f"{label} id={case_id} requires string side and boolean mutant") + canonical_json(row["value"]) + if list(indexed) != sorted(indexed): + raise HarnessError(f"{label} rows must be in canonical id order") + return indexed + + +def _bound_output(inputs: dict[str, dict], records: Iterable[dict], side: str, + expected_revision: str, *, allow_mutants: bool = False) -> dict[str, dict]: + _revision(expected_revision) + _nonempty_string(side, "side") + actual = _output_index(records, f"{side} output") + missing = sorted(set(inputs) - set(actual)) + unexpected = sorted(set(actual) - set(inputs)) + if missing or unexpected: + raise HarnessError(f"{side} IDs mismatch: missing={missing} unexpected={unexpected}") + for case_id, row in actual.items(): + if row["side"] != side: + raise HarnessError(f"{side} id={case_id} side mismatch") + if row["sourceRevision"] != expected_revision: + raise HarnessError(f"{side} id={case_id} sourceRevision mismatch") + if row["mutant"] and not allow_mutants: + raise HarnessError(f"{side} id={case_id} mutant output is forbidden in positive compare/validate") + for field in ("caseSha256", "function", "suiteVersion"): + if row[field] != inputs[case_id][field]: + raise HarnessError(f"{side} id={case_id} {field} mismatch") + return actual + + +def _oracle_diffs(inputs: dict[str, dict], output: dict[str, dict], side: str) -> list[str]: + return [ + f"EXPECTED id={case_id} side={side} expected={canonical_json(source['expected'])} " + f"actual={canonical_json(output[case_id]['value'])}" + for case_id, source in inputs.items() + if canonical_json(source["expected"]) != canonical_json(output[case_id]["value"]) + ] + + +def validate_records(inputs: Iterable[dict], output: Iterable[dict], side: str, + *, expected_revision: str) -> list[str]: + """Check one artifact against literal oracles, without a peer build or output.""" + expected = _input_index(inputs) + actual = _bound_output(expected, output, side, expected_revision) + return _oracle_diffs(expected, actual, side) + + +def compare_records(inputs: Iterable[dict], core: Iterable[dict], reference: Iterable[dict], + *, expected_revision: str, core_side: str = "core", + reference_side: str = "reference") -> list[str]: + """Check both oracles and parity against the caller's intended immutable revision.""" + if core_side == reference_side: + raise HarnessError("comparison requires distinct sides") + expected = _input_index(inputs) + left = _bound_output(expected, core, core_side, expected_revision) + right = _bound_output(expected, reference, reference_side, expected_revision) + diffs = _oracle_diffs(expected, left, core_side) + _oracle_diffs(expected, right, reference_side) + for case_id in expected: + if canonical_json(left[case_id]["value"]) != canonical_json(right[case_id]["value"]): + diffs.append(f"DIFF id={case_id} function={expected[case_id]['function']} " + f"{core_side}={canonical_json(left[case_id]['value'])} " + f"{reference_side}={canonical_json(right[case_id]['value'])}") + return diffs + + +def verify_mutant( + inputs: Iterable[dict], core: Iterable[dict], reference: Iterable[dict], expected_side: str, + *, expected_revision: str +) -> int: + if expected_side not in {"core", "reference"}: + raise HarnessError("expected mutant side must be core or reference") + input_rows = list(inputs) + core_rows = list(core) + reference_rows = list(reference) + side_rows = {"core": core_rows, "reference": reference_rows} + other_side = "reference" if expected_side == "core" else "core" + expected_inputs = _input_index(input_rows) + for side, rows in side_rows.items(): + _bound_output(expected_inputs, rows, side, expected_revision, allow_mutants=True) + if ( + not side_rows[expected_side] + or not all(row["mutant"] is True for row in side_rows[expected_side]) + or any(row["mutant"] is not False for row in side_rows[other_side]) + ): + raise HarnessError(f"expected {expected_side} mutant metadata on exactly one side") + normal = _output_index(side_rows[other_side], "normal output") + if _oracle_diffs(expected_inputs, normal, other_side): + raise HarnessError("non-mutant side must match expected results") + mutated = _output_index(side_rows[expected_side], "mutant output") + killed = {case_id for case_id in expected_inputs + if canonical_json(mutated[case_id]["value"]) != canonical_json(normal[case_id]["value"])} + expected = {row["id"] for row in input_rows} + if killed != expected: + raise HarnessError( + f"{expected_side} mutant was not killed exactly: missing={sorted(expected-killed)} " + f"unexpected={sorted(killed-expected)}" + ) + return len(killed) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + expand = commands.add_parser("expand") + expand.add_argument("spec", type=Path) + expand.add_argument("output", type=Path) + split = commands.add_parser("split") + split.add_argument("input", type=Path) + split.add_argument("output_dir", type=Path) + run = commands.add_parser("run") + run.add_argument("side", choices=("core", "reference")) + run.add_argument("input", type=Path) + run.add_argument("output", type=Path) + run.add_argument("--source-revision", required=True) + run.add_argument("--mutant", action="store_true") + merge = commands.add_parser("merge") + merge.add_argument("expected", type=Path) + merge.add_argument("output", type=Path) + merge.add_argument("parts", type=Path, nargs="+") + compare = commands.add_parser("compare") + compare.add_argument("input", type=Path) + compare.add_argument("core", type=Path) + compare.add_argument("reference", type=Path) + compare.add_argument("--core-side", default="core") + compare.add_argument("--reference-side", default="reference") + validate = commands.add_parser("validate") + validate.add_argument("side") + validate.add_argument("input", type=Path) + validate.add_argument("output", type=Path) + verify = commands.add_parser("verify-mutant") + verify.add_argument("side", choices=("core", "reference")) + verify.add_argument("input", type=Path) + verify.add_argument("core", type=Path) + verify.add_argument("reference", type=Path) + for command in (compare, validate, verify): + command.add_argument("--expected-revision", required=True) + args = parser.parse_args(argv) + + if args.command == "expand": + write_jsonl(args.output, expand_spec(load_spec(args.spec))) + elif args.command == "split": + shards = split_records(read_jsonl(args.input, "input")) + unexpected = sorted(path.name for path in args.output_dir.glob("*.jsonl") + if path.name not in {f"{name}.jsonl" for name in shards}) + if unexpected: + raise HarnessError(f"split output directory contains unexpected JSONL: {unexpected}") + for name, rows in shards.items(): + write_jsonl(args.output_dir / f"{name}.jsonl", rows) + elif args.command == "run": + write_jsonl( + args.output, + run_records(read_jsonl(args.input, "input"), args.side, source_revision=args.source_revision, mutant=args.mutant), + ) + elif args.command == "merge": + expected = read_jsonl(args.expected, "expected input") + parts = [read_jsonl(path, f"shard {path}") for path in args.parts] + write_jsonl(args.output, merge_records(parts, expected)) + elif args.command == "validate": + diffs = validate_records(read_jsonl(args.input, "input"), + read_jsonl(args.output, "output"), args.side, + expected_revision=args.expected_revision) + for line in diffs: + print(line) + return 1 if diffs else 0 + elif args.command == "compare": + diffs = compare_records( + read_jsonl(args.input, "input"), + read_jsonl(args.core, "core output"), + read_jsonl(args.reference, "reference output"), + expected_revision=args.expected_revision, + core_side=args.core_side, reference_side=args.reference_side, + ) + for line in diffs: + print(line) + return 1 if diffs else 0 + else: + killed = verify_mutant( + read_jsonl(args.input, "input"), + read_jsonl(args.core, "core output"), + read_jsonl(args.reference, "reference output"), + args.side, + expected_revision=args.expected_revision, + ) + print(f"OK killed {killed} {args.side} mutant case(s)") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except HarnessError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + raise SystemExit(2) diff --git a/Tools/parity_ledger.py b/Tools/parity_ledger.py new file mode 100644 index 0000000000..e297408693 --- /dev/null +++ b/Tools/parity_ledger.py @@ -0,0 +1,2851 @@ +#!/usr/bin/env python3 +"""Inventory and gate declared Swift/Kotlin parity contracts. + +The ledger is deliberately lexical. It does not compile either language and uses only +the Python standard library. Compact checked metadata hashes exact semantic sets and +accepted finding identities; normal runs independently rederive both from source. + +Usage: + python3 Tools/parity_ledger.py + python3 Tools/parity_ledger.py --no-baseline + python3 Tools/parity_ledger.py --bootstrap-map --write-baseline + +New semantic debt is governed by exact, manually reviewed typed dispositions. +""" + +from __future__ import annotations + +import argparse +import hashlib +import io +import json +import os +import re +import subprocess +import sys +import tarfile +import tempfile +from collections import Counter, defaultdict +from dataclasses import dataclass +from decimal import Decimal, InvalidOperation +from pathlib import Path +from typing import Iterable + +import issue_ref + + +ROOT = Path(__file__).resolve().parent.parent +DEFAULT_MAP = ROOT / "Tools/parity_twin_map.json" +DEFAULT_BASELINE = ROOT / "Tools/parity_ledger_baseline.json" + +SWIFT_GLOBS = ( + "Packages/StrandAnalytics/Sources/**/*.swift", + "Packages/StrandImport/Sources/**/*.swift", + "Packages/WhoopStore/Sources/**/*.swift", + "Packages/WhoopProtocol/Sources/**/*.swift", + "Packages/OuraProtocol/Sources/**/*.swift", +) +KOTLIN_GLOBS = ( + "android/app/src/main/java/com/noop/analytics/**/*.kt", + "android/app/src/main/java/com/noop/ingest/**/*.kt", + "android/app/src/main/java/com/noop/data/**/*.kt", + "android/app/src/main/java/com/noop/protocol/**/*.kt", + "android/app/src/main/java/com/noop/oura/**/*.kt", +) +SWIFT_EXCLUDED_GLOBS = ( + "Packages/NoopLocalAccess/Sources/**/*.swift", + "Packages/PolarProtocol/Sources/**/*.swift", + "Packages/StrandDesign/Sources/**/*.swift", + "Strand/**/*.swift", + "StrandiOS*/**/*.swift", + "NOOPWatch*/**/*.swift", +) +KOTLIN_EXCLUDED_GLOBS = ( + "android/app/src/main/java/com/noop/*.kt", + "android/app/src/main/java/com/noop/ai/**/*.kt", + "android/app/src/main/java/com/noop/alarm/**/*.kt", + "android/app/src/main/java/com/noop/ble/**/*.kt", + "android/app/src/main/java/com/noop/location/**/*.kt", + "android/app/src/main/java/com/noop/notif/**/*.kt", + "android/app/src/main/java/com/noop/polar/**/*.kt", + "android/app/src/main/java/com/noop/testcentre/**/*.kt", + "android/app/src/main/java/com/noop/ui/**/*.kt", + "android/app/src/main/java/com/noop/update/**/*.kt", + "android/app/src/main/java/com/noop/widget/**/*.kt", +) +PRODUCTION_GLOBS = ( + "Packages/**/Sources/**/*.swift", + "Strand/**/*.swift", + "StrandiOS*/**/*.swift", + "NOOPWatch*/**/*.swift", + "android/app/src/main/java/**/*.kt", +) +TEST_GLOBS = ( + "Packages/**/Tests/**/*.swift", + "StrandTests/**/*.swift", + "android/app/src/test/**/*.kt", + "android/app/src/androidTest/**/*.kt", +) +REFERENCE_GLOBS = ( + "Packages/**/*.swift", + "Strand/**/*.swift", + "StrandTests/**/*.swift", + "StrandiOS*/**/*.swift", + "NOOPWatch*/**/*.swift", + "android/**/*.kt", +) + +# These constants intentionally describe different platform-local persistence schemas. Their normalized +# names happen to match, but their migration generations do not and must not be compared as parity twins. +# Keep the exclusion exact so unrelated schema constants still go through the normal pairing audit. +CONSTANT_NON_TWIN_PAIRS = frozenset({ + ( + "Packages/WhoopStore/Sources/WhoopStore/WhoopStore.swift::schemaVersion", + "android/app/src/main/java/com/noop/data/WhoopDatabase.kt::SCHEMA_VERSION", + ), +}) + +HARD_FINDING_RULES = frozenset({ + "malformed-twin-map", "duplicate-twin-target", "twin-map-overlap", + "stale-twin-file", "stale-twin-function", "stale-twin-property", + "unmapped-declared-function-pair", "stale-declared-function-pair", + "unmapped-declared-file-pair", "stale-declared-file-pair", + "unmapped-constant-pair", "stale-constant-pair", "dead-twin-reference", + "unresolved-attached-function-claim", "ambiguous-attached-function-claim", + "stale-bootstrap-exemption", +}) + + +@dataclass(frozen=True) +class Declaration: + language: str + path: str + name: str + arity: int + line: int + ordinal: int = 1 + kind: str = "function" + owner_name: str | None = None + offset: int = -1 + opening: int = -1 + parameter_labels: tuple[str, ...] = () + required_arity: int = 0 + + @property + def key(self) -> str: + if self.kind == "property": + return f"{self.path}::{self.name}@property#{self.ordinal}" + return f"{self.path}::{self.name}/{self.arity}#{self.ordinal}" + + @property + def owner(self) -> str: + if self.owner_name: + return self.owner_name + stem = Path(self.path).stem + return stem.replace("+Trace", "Trace") + + +@dataclass(frozen=True) +class Constant: + language: str + path: str + name: str + value: str | None + display_value: str + line: int + owner_name: str | None = None + + @property + def key(self) -> str: + return f"{self.path}::{self.name}" + + @property + def owner(self) -> str: + return self.owner_name or Path(self.path).stem + + +@dataclass(frozen=True) +class Finding: + rule: str + path: str + line: int + text: str + identity: str + + def output(self) -> str: + return f"{self.path}:{self.line}: {self.rule}: {self.text}" + + +@dataclass(frozen=True) +class ScanError: + rule: str + path: str + line: int + text: str + + def output(self) -> str: + return f"{self.path}:{self.line}: {self.rule}: {self.text}" + + +@dataclass +class ScanResult: + findings: list[Finding] + counters: dict[str, int] + stats: dict[str, int] + errors: list[ScanError] + missing_attached_claimants: set[str] + bootstrap_unpaired_debts: set[str] + + +class _InvalidSourceEncoding(ValueError): + def __init__(self, path: Path, detail: str): + super().__init__(detail) + self.path = path + + +@dataclass(frozen=True) +class TwinReference: + language: str + path: str + line: int + raw_target: str + target_name: str + target_owner: str | None + attached_function: str | None + claim_ordinal: int + + +@dataclass(frozen=True) +class CallSite: + name: str + arity: int + owner: str | None + path: str + lexical_owner: str | None = None + + +# Directories that hold BUILD OUTPUT rather than source. Every glob here ends in `**`, and `**` +# descends into these exactly as happily as into a source tree: a dependency checked out under +# `Packages/StrandAnalytics/.build/checkouts/...` matches `Packages/**/Sources/**/*.swift` whenever the +# dependency happens to lay itself out with a `Sources` directory, which SwiftPM packages do by +# convention. +# +# Left unfiltered this is worse than noise, because the pollution is SUBTRACTIVE. It does not add +# findings a reader would question; it REMOVES them, by handing a declaration a callsite that only +# exists in a vendored copy of somebody else's library. That is how a scan on a working tree came back +# one `test-only-callsite` short for Packages/StrandAnalytics, against a baseline derived on a clean +# checkout, and the local acceptance test passed anyway because both sides of its comparison came from +# the same polluted tree. CI checks out clean, so it never saw any of it and could not warn. +# +# Filtering here rather than in each glob because `_paths` is the ONLY place this module globs: the +# declaration scan, the reference scan and both callsite corpora all come through it. +# +# The invariant that makes this safe is checked by a test rather than asserted here: everything this +# drops is untracked by git, so no file the repository actually contains can be hidden by it. +_ARTEFACT_DIRS = frozenset({ + ".build", # SwiftPM: checkouts, index-build, the lot + ".swiftpm", + "DerivedData", # Xcode + "build", # Gradle output, incl. anything KSP or the AGP generates + ".gradle", + "node_modules", + "Pods", # CocoaPods, if it ever appears +}) + + +def _is_build_artefact(root: Path, path: Path) -> bool: + """True when `path` sits inside a build-output directory rather than the source tree.""" + try: + parts = path.relative_to(root).parts + except ValueError: + parts = path.parts + return any(part in _ARTEFACT_DIRS for part in parts) + + +def _paths(root: Path, globs: Iterable[str]) -> list[Path]: + found: set[Path] = set() + for pattern in globs: + found.update(path for path in root.glob(pattern) + if path.is_file() and not _is_build_artefact(root, path)) + return sorted(found) + + +def _relative(root: Path, path: Path) -> str: + return path.relative_to(root).as_posix() + + +def _read(path: Path) -> str: + try: + return path.read_text(encoding="utf-8", errors="strict") + except UnicodeDecodeError as exc: + raise _InvalidSourceEncoding(path, str(exc)) from exc + + +class _SourceSnapshot: + """Operation-local immutable source view; discarded after one top-level scan.""" + + def __init__(self) -> None: + self._text: dict[Path, str] = {} + self._masked: dict[tuple[Path, bool], str] = {} + self._functions: dict[tuple[Path, str], tuple[Declaration, ...]] = {} + self._properties: dict[tuple[Path, str], tuple[Declaration, ...]] = {} + self._constants: dict[tuple[Path, str], tuple[Constant, ...]] = {} + + def text(self, path: Path) -> str: + resolved = path.resolve() + if resolved not in self._text: + self._text[resolved] = _read(resolved) + return self._text[resolved] + + def masked(self, path: Path, *, kotlin_templates: bool = False) -> str: + resolved = path.resolve() + key = (resolved, kotlin_templates) + if key not in self._masked: + self._masked[key] = _mask_non_code( + self.text(resolved), kotlin_templates=kotlin_templates + ) + return self._masked[key] + + def functions(self, root: Path, path: Path, language: str) -> tuple[Declaration, ...]: + key = (path.resolve(), language) + if key not in self._functions: + self._functions[key] = _parse_functions_content( + str(root.resolve()), str(key[0]), language, self.text(key[0]), self.masked(key[0]) + ) + return self._functions[key] + + def properties(self, root: Path, path: Path, language: str) -> tuple[Declaration, ...]: + key = (path.resolve(), language) + if key not in self._properties: + self._properties[key] = _parse_properties_content( + str(root.resolve()), str(key[0]), language, self.text(key[0]), self.masked(key[0]) + ) + return self._properties[key] + + def constants(self, root: Path, path: Path, language: str) -> tuple[Constant, ...]: + key = (path.resolve(), language) + if key not in self._constants: + self._constants[key] = _parse_constants_content( + str(root.resolve()), str(key[0]), language, self.text(key[0]), self.masked(key[0]) + ) + return self._constants[key] + + +def _mask_non_code(text: str, kotlin_templates: bool = False) -> str: + """Replace comments and string contents with spaces, preserving newlines.""" + if kotlin_templates: + return _mask_kotlin_template_code(text) + out = list(text) + i = 0 + state = "code" + block_depth = 0 + quote = "" + while i < len(text): + if state == "code": + if text.startswith("//", i): + out[i] = out[i + 1] = " " + i += 2 + state = "line" + elif text.startswith("/*", i): + out[i] = out[i + 1] = " " + i += 2 + block_depth = 1 + state = "block" + elif text.startswith('"""', i): + out[i : i + 3] = " " + i += 3 + quote = '"""' + state = "string" + elif text[i] in "\"'": + quote = text[i] + out[i] = " " + i += 1 + state = "string" + else: + i += 1 + elif state == "line": + if text[i] == "\n": + state = "code" + else: + out[i] = " " + i += 1 + elif state == "block": + if text.startswith("/*", i): + out[i] = out[i + 1] = " " + block_depth += 1 + i += 2 + elif text.startswith("*/", i): + out[i] = out[i + 1] = " " + block_depth -= 1 + i += 2 + if block_depth == 0: + state = "code" + else: + if text[i] != "\n": + out[i] = " " + i += 1 + else: + if quote == '"""' and text.startswith(quote, i): + out[i : i + 3] = " " + i += 3 + state = "code" + elif quote != '"""' and text[i] == "\\" and i + 1 < len(text): + if text[i] != "\n": + out[i] = " " + if text[i + 1] != "\n": + out[i + 1] = " " + i += 2 + elif quote != '"""' and text[i] == quote: + out[i] = " " + i += 1 + state = "code" + else: + if text[i] != "\n": + out[i] = " " + i += 1 + return "".join(out) + + +class _MalformedKotlinTemplate(ValueError): + def __init__(self, offset: int): + super().__init__("unterminated Kotlin string template") + self.offset = offset + + +def _mask_kotlin_template_code(text: str) -> str: + """Mask Kotlin non-code while retaining balanced ``${...}`` expressions. + + Kotlin string templates contain executable callsites, but their surrounding text—and strings or + comments nested inside an expression—must remain invisible to the lexical call scanner. The + context stack keeps braces balanced and supports nested strings/templates. A plain unterminated + literal is masked through EOF; an unterminated template raises an explicit scan error because + silently discarding its executable expression could hide a production callsite. + """ + + out = list(text) + stack: list[dict[str, object]] = [{"kind": "code"}] + i = 0 + + def blank(start: int, end: int) -> None: + for index in range(start, min(end, len(out))): + if text[index] != "\n": + out[index] = " " + + def fail_closed() -> str: + string_starts = [ + int(context["start"]) + for context in stack + if context["kind"] == "string" + ] + if string_starts: + blank(min(string_starts), len(text)) + template_starts = [ + int(context["template_start"]) + for context in stack + if context["kind"] == "string" and "template_start" in context + ] + [ + int(context["start"]) + for context in stack + if context["kind"] == "template" + ] + if template_starts: + raise _MalformedKotlinTemplate(min(template_starts)) + return "".join(out) + + while i < len(text): + context = stack[-1] + kind = context["kind"] + if kind in {"code", "template"}: + if text.startswith("//", i): + blank(i, i + 2) + stack.append({"kind": "line"}) + i += 2 + elif text.startswith("/*", i): + blank(i, i + 2) + stack.append({"kind": "block", "depth": 1}) + i += 2 + elif text.startswith('"""', i): + blank(i, i + 3) + out[i] = "0" # Occupy a containing call argument without exposing literal text. + stack.append({"kind": "string", "quote": '"""', "start": i}) + i += 3 + elif text[i] in "\"'": + blank(i, i + 1) + out[i] = "0" # String/character literals are one lexical argument token. + stack.append({"kind": "string", "quote": text[i], "start": i}) + i += 1 + elif kind == "template" and text[i] == "{": + context["depth"] = int(context["depth"]) + 1 + i += 1 + elif kind == "template" and text[i] == "}": + context["depth"] = int(context["depth"]) - 1 + i += 1 + if context["depth"] == 0: + stack.pop() + else: + i += 1 + elif kind == "line": + if text[i] == "\n": + stack.pop() + else: + blank(i, i + 1) + i += 1 + elif kind == "block": + if text.startswith("/*", i): + blank(i, i + 2) + context["depth"] = int(context["depth"]) + 1 + i += 2 + elif text.startswith("*/", i): + blank(i, i + 2) + context["depth"] = int(context["depth"]) - 1 + i += 2 + if context["depth"] == 0: + stack.pop() + else: + blank(i, i + 1) + i += 1 + else: + quote = str(context["quote"]) + if quote == '"""' and text.startswith(quote, i): + blank(i, i + 3) + stack.pop() + i += 3 + elif quote != '"""' and text[i] == "\n": + return fail_closed() + elif quote != '"""' and text[i] == "\\" and i + 1 < len(text): + blank(i, i + 2) + i += 2 + elif quote != '"""' and text[i] == quote: + blank(i, i + 1) + stack.pop() + i += 1 + elif quote != "'" and text.startswith("${", i): + blank(i, i + 1) + context.setdefault("template_start", i) + stack.append({"kind": "template", "depth": 1, "start": i}) + i += 2 + else: + blank(i, i + 1) + i += 1 + + if any(context["kind"] in {"string", "template"} for context in stack): + return fail_closed() + return "".join(out) + + +def _arity(masked: str, opening: int, *, angles_are_brackets: bool = True) -> int | None: + stack: list[str] = [] + pairs = {")": "(", "]": "[", "}": "{", ">": "<"} + segments = 0 + segment_has_token = False + i = opening + 1 + while i < len(masked): + char = masked[i] + if char == "(" or char == "[" or char == "{": + stack.append(char) + elif char == "<": + # Parameter lists use angle brackets for types. Do not treat Kotlin/Swift arrows as generics. + # + # Nor Swift's half-open range operator. `a[x..= len(masked) or masked[i + 1] not in "= "): + stack.append(char) + elif char in pairs: + if char == ")" and not stack: + return segments + (1 if segment_has_token else 0) + if stack and stack[-1] == pairs[char]: + stack.pop() + elif char == "," and not stack: + if segment_has_token: + segments += 1 + segment_has_token = False + elif not char.isspace() and not stack: + segment_has_token = True + i += 1 + + # The walk never balanced, which means some `<` was pushed that nothing closed. Falling out of + # here returns None, and every caller answers None with `continue` -- so an argument this walk + # cannot parse does not merely lose its arity, it ERASES THE ENTIRE CALLSITE and the scan reports + # a declaration nobody calls. + # + # `<` is the only genuinely ambiguous character: a generic argument list needs it treated as a + # bracket, while `a << b`, `a tuple[str, ...]: + labels: list[str] = [] + stack: list[str] = [] + start = opening + 1 + pairs = {")": "(", "]": "[", "}": "{"} + i = start + while i < len(masked): + char = masked[i] + if char in "([{": + stack.append(char) + elif char == ")" and not stack: + segment = masked[start:i].strip() + if segment: + match = re.match(r"(_|[A-Za-z_][A-Za-z0-9_]*)\b", segment) + labels.append(match.group(1) if match else "") + return tuple(labels) + elif char in pairs and stack and stack[-1] == pairs[char]: + stack.pop() + elif char == "," and not stack: + segment = masked[start:i].strip() + match = re.match(r"(_|[A-Za-z_][A-Za-z0-9_]*)\b", segment) + labels.append(match.group(1) if match else "") + start = i + 1 + i += 1 + return () + + +def _parameter_segments(masked: str, opening: int) -> tuple[str, ...]: + segments: list[str] = [] + stack: list[str] = [] + start = opening + 1 + pairs = {")": "(", "]": "[", "}": "{"} + i = start + while i < len(masked): + char = masked[i] + if char in "([{": + stack.append(char) + elif char == ")" and not stack: + segment = masked[start:i].strip() + if segment: + segments.append(segment) + return tuple(segments) + elif char in pairs and stack and stack[-1] == pairs[char]: + stack.pop() + elif char == "," and not stack: + segment = masked[start:i].strip() + if segment: + segments.append(segment) + start = i + 1 + i += 1 + return () + + +def _required_arity(masked: str, opening: int) -> int: + required = 0 + for segment in _parameter_segments(masked, opening): + stack: list[str] = [] + has_default = False + pairs = {")": "(", "]": "[", "}": "{"} + for char in segment: + if char in "([{": + stack.append(char) + elif char in pairs and stack and stack[-1] == pairs[char]: + stack.pop() + elif char == "=" and not stack: + has_default = True + break + if not has_default: + required += 1 + return required + + +SWIFT_FUNC = re.compile( + r"\bfunc\s+(`?[A-Za-z_][A-Za-z0-9_]*`?|[=!<>+\-*/%&|^~?.]+)\s*(?:<[^\n{}()]*>\s*)?\(" +) +KOTLIN_FUNC = re.compile( + r"\bfun\s+(?:<[^\n{}()]*>\s*)?([^\n{}()=]+?)\s*\(" +) + +TYPE_DECLARATION = { + "swift": re.compile(r"\b(?:struct|class|enum|actor|protocol|extension)\s+([A-Za-z_][A-Za-z0-9_]*)"), + "kotlin": re.compile( + r"\b(?:(?:data|sealed|enum|annotation|value)\s+)?(?:class|object|interface)\s+([A-Za-z_][A-Za-z0-9_]*)" + ), +} + + +def _matching_brace(masked: str, opening: int) -> int: + depth = 0 + for index in range(opening, len(masked)): + if masked[index] == "{": + depth += 1 + elif masked[index] == "}": + depth -= 1 + if depth == 0: + return index + return len(masked) + + +def _type_spans(masked: str, language: str) -> list[tuple[int, int, str]]: + spans: list[tuple[int, int, str]] = [] + for match in TYPE_DECLARATION[language].finditer(masked): + opening = masked.find("{", match.end()) + if opening < 0: + continue + # Do not attach a type to a later, unrelated declaration when its body is absent. + next_decl = TYPE_DECLARATION[language].search(masked, match.end()) + if next_decl and next_decl.start() < opening: + continue + spans.append((opening, _matching_brace(masked, opening), match.group(1))) + return spans + + +def _owner_at(spans: list[tuple[int, int, str]], offset: int, fallback: str) -> str: + containing = [item for item in spans if item[0] < offset < item[1]] + return max(containing, key=lambda item: item[0])[2] if containing else fallback + + +def _swift_module_owner(path: str) -> str | None: + parts = Path(path).parts + if len(parts) >= 4 and parts[0] == "Packages" and parts[2] == "Sources": + return parts[1] + return None + + +def _receiver_owner(header: str) -> str | None: + name_match = re.search(r"(`?[A-Za-z_][A-Za-z0-9_]*`?)\s*$", header) + if not name_match: + return None + prefix = header[: name_match.start()].rstrip() + if not prefix.endswith("."): + return None + receiver = prefix[:-1].strip().rstrip("?") + # The receiver can contain nested generics; the leading nominal type is the useful owner. + names = re.findall(r"[A-Za-z_][A-Za-z0-9_]*", receiver) + return names[0] if names else None + + +def _parse_functions_content( + root_string: str, path_string: str, language: str, text: str, masked: str | None = None +) -> tuple[Declaration, ...]: + root = Path(root_string) + path = Path(path_string) + masked = masked if masked is not None else _mask_non_code(text) + pattern = SWIFT_FUNC if language == "swift" else KOTLIN_FUNC + rel = _relative(root, path) + fallback_owner = path.stem.replace("+Trace", "Trace") + spans = _type_spans(masked, language) + out: list[Declaration] = [] + ordinals: Counter[tuple[str, int]] = Counter() + for match in pattern.finditer(masked): + arity = _arity(masked, match.end() - 1) + if arity is None: + continue + header = match.group(1) + if language == "kotlin": + name_match = re.search(r"(`?[A-Za-z_][A-Za-z0-9_]*`?)\s*$", header) + if not name_match: + continue + name = name_match.group(1).strip("`") + name_offset = match.start(1) + name_match.start(1) + receiver = _receiver_owner(header) + else: + name = header.strip("`") + name_offset = match.start(1) + receiver = None + ordinals[(name, arity)] += 1 + out.append( + Declaration( + language=language, + path=rel, + name=name, + arity=arity, + line=text.count("\n", 0, match.start()) + 1, + ordinal=ordinals[(name, arity)], + owner_name=receiver or _owner_at(spans, match.start(), fallback_owner), + offset=name_offset, + opening=match.end() - 1, + parameter_labels=( + _swift_parameter_labels(masked, match.end() - 1) + if language == "swift" else () + ), + required_arity=_required_arity(masked, match.end() - 1), + ) + ) + return tuple(out) + + +def parse_functions(root: Path, path: Path, language: str) -> list[Declaration]: + return list( + _parse_functions_content( + str(root.resolve()), str(path.resolve()), language, _read(path) + ) + ) + + +def _parse_properties_content( + root_string: str, path_string: str, language: str, text: str, masked: str | None = None +) -> tuple[Declaration, ...]: + """Inventory computed properties/getters, excluding stored fields.""" + root = Path(root_string) + path = Path(path_string) + masked = masked if masked is not None else _mask_non_code(text) + rel = _relative(root, path) + fallback_owner = path.stem.replace("+Trace", "Trace") + spans = _type_spans(masked, language) + if language == "swift": + pattern = re.compile( + r"\bvar\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*[^=\n{]+\{" + ) + else: + pattern = re.compile( + r"\b(?:val|var)\s+([A-Za-z_][A-Za-z0-9_]*)\b" + r"(?:\s*:\s*[^=\n{]+)?\s*(?:\n[ \t]*)?get\s*\(\s*\)" + ) + out: list[Declaration] = [] + ordinals: Counter[str] = Counter() + for match in pattern.finditer(masked): + name = match.group(1) + ordinals[name] += 1 + out.append( + Declaration( + language=language, + path=rel, + name=name, + arity=0, + line=text.count("\n", 0, match.start()) + 1, + ordinal=ordinals[name], + kind="property", + owner_name=_owner_at(spans, match.start(), fallback_owner), + offset=match.start(1), + ) + ) + return tuple(out) + + +def parse_properties(root: Path, path: Path, language: str) -> list[Declaration]: + return list( + _parse_properties_content( + str(root.resolve()), str(path.resolve()), language, _read(path) + ) + ) + + +NUMBER_PATTERN = ( + r"(?:0[xX][0-9A-Fa-f_]+[lL]?|0[bB][01_]+[lL]?|0[oO][0-7_]+[lL]?|" + r"(?:\d[\d_]*(?:\.[\d_]*)?|\.[\d_]+)(?:[eE][-+]?\d[\d_]*)?[fFdDlL]?)" +) +NUMBER_TOKEN = re.compile(NUMBER_PATTERN) + + +class _NumberExpression: + def __init__(self, raw: str): + self.raw = raw + self.tokens = re.findall( + NUMBER_PATTERN + r"|[()+\-*/]", + raw, + ) + self.index = 0 + + def parse(self) -> Decimal: + compact = re.sub(r"\s+", "", self.raw) + if "".join(self.tokens) != compact or not self.tokens: + raise InvalidOperation + value = self._sum() + if self.index != len(self.tokens): + raise InvalidOperation + return value + + def _sum(self) -> Decimal: + value = self._product() + while self._peek() in {"+", "-"}: + operator = self._take() + right = self._product() + value = value + right if operator == "+" else value - right + return value + + def _product(self) -> Decimal: + value = self._unary() + while self._peek() in {"*", "/"}: + operator = self._take() + right = self._unary() + if operator == "*": + value *= right + else: + if right == 0: + raise InvalidOperation + value /= right + return value + + def _unary(self) -> Decimal: + if self._peek() in {"+", "-"}: + operator = self._take() + value = self._unary() + return value if operator == "+" else -value + if self._peek() == "(": + self._take() + value = self._sum() + if self._take() != ")": + raise InvalidOperation + return value + token = self._take() + if not NUMBER_TOKEN.fullmatch(token): + raise InvalidOperation + number = token.replace("_", "") + if number.lower().startswith(("0x", "0b", "0o")): + # f/F/d/D are hex digits here; radix literals only take the l/L suffix. + return Decimal(int(number.rstrip("lL"), 0)) + return Decimal(number.rstrip("fFdDlL")) + + def _peek(self) -> str | None: + return self.tokens[self.index] if self.index < len(self.tokens) else None + + def _take(self) -> str: + if self.index >= len(self.tokens): + raise InvalidOperation + token = self.tokens[self.index] + self.index += 1 + return token + + +def _initializer(text: str, start: int) -> str: + """Return exactly one single-line constant initializer, without its terminator/comment.""" + out: list[str] = [] + quote: str | None = None + escaped = False + depth = 0 + index = start + while index < len(text): + char = text[index] + if quote: + out.append(char) + if escaped: + escaped = False + elif char == "\\": + escaped = True + elif char == quote: + quote = None + index += 1 + continue + if text.startswith("//", index): + break + if char in {'"', "'"}: + quote = char + out.append(char) + elif char == "(": + depth += 1 + out.append(char) + elif char == ")": + depth = max(0, depth - 1) + out.append(char) + elif char in "\n;," and depth == 0: + break + elif char == "}" and depth == 0: + break + else: + out.append(char) + index += 1 + return "".join(out).strip().rstrip(",").strip() + + +def _literal(raw: str) -> tuple[str, str] | None: + value = raw.strip() + if re.fullmatch(r'"(?:\\.|[^"\\])*"', value): + token = value + token_for_json = re.sub( + r"\\u\{([0-9A-Fa-f]{1,8})\}", + lambda item: "\\u" + item.group(1).zfill(4), + token, + ) + try: + decoded = json.loads(token_for_json) + except json.JSONDecodeError: + decoded = token[1:-1] + return f"string:{decoded}", token + if value in {"true", "false"}: + return f"bool:{value}", value + if value in {"nil", "null"}: + return "null", value + try: + number = _NumberExpression(value).parse() + return f"number:{number.normalize()}", value + except (InvalidOperation, ZeroDivisionError): + return None + + +def _parse_constants_content( + root_string: str, path_string: str, language: str, text: str, masked: str | None = None +) -> tuple[Constant, ...]: + root = Path(root_string) + path = Path(path_string) + masked = masked if masked is not None else _mask_non_code(text) + if language == "swift": + pattern = re.compile(r"\blet\s+([A-Za-z_][A-Za-z0-9_]*)\s*(?::[^=\n]+)?=") + else: + pattern = re.compile(r"\bconst\s+val\s+([A-Za-z_][A-Za-z0-9_]*)\s*(?::[^=\n]+)?=") + rel = _relative(root, path) + fallback_owner = path.stem.replace("+Trace", "Trace") + spans = _type_spans(masked, language) + out: list[Constant] = [] + for match in pattern.finditer(masked): + if language == "swift": + line_start = text.rfind("\n", 0, match.start()) + 1 + prefix = text[line_start : match.start()] + # Kotlin's `const val` has static storage. Pair it only with a Swift `static + # let` or a file-scope declaration, never with a local/instance `let`. + if "static" not in prefix.split() and len(prefix) - len(prefix.lstrip()) > 4: + continue + raw = _initializer(text, match.end()) + parsed = _literal(raw) + canonical, display = parsed if parsed is not None else (None, raw or "") + out.append( + Constant( + language, + rel, + match.group(1), + canonical, + display, + text.count("\n", 0, match.start()) + 1, + _owner_at(spans, match.start(), fallback_owner), + ) + ) + return tuple(out) + + +def parse_constants(root: Path, path: Path, language: str) -> list[Constant]: + return list( + _parse_constants_content( + str(root.resolve()), str(path.resolve()), language, _read(path) + ) + ) + + +def _inventory( + root: Path, + snapshot: _SourceSnapshot | None = None, +) -> tuple[ + list[Path], + list[Path], + list[Declaration], + list[Declaration], + list[Declaration], + list[Declaration], + list[Constant], + list[Constant], +]: + snapshot = snapshot or _SourceSnapshot() + swift_files = _paths(root, SWIFT_GLOBS) + kotlin_files = _paths(root, KOTLIN_GLOBS) + swift_functions: list[Declaration] = [] + kotlin_functions: list[Declaration] = [] + swift_properties: list[Declaration] = [] + kotlin_properties: list[Declaration] = [] + swift_constants: list[Constant] = [] + kotlin_constants: list[Constant] = [] + for paths, language, functions, properties, constants in ( + (swift_files, "swift", swift_functions, swift_properties, swift_constants), + (kotlin_files, "kotlin", kotlin_functions, kotlin_properties, kotlin_constants), + ): + for path in paths: + functions.extend(snapshot.functions(root, path, language)) + properties.extend(snapshot.properties(root, path, language)) + constants.extend(snapshot.constants(root, path, language)) + return ( + swift_files, + kotlin_files, + swift_functions, + kotlin_functions, + swift_properties, + kotlin_properties, + swift_constants, + kotlin_constants, + ) + + +def _annotation_count(files: list[Path], snapshot: _SourceSnapshot | None = None) -> int: + snapshot = snapshot or _SourceSnapshot() + pattern = re.compile(r"\b(?:twin|parity)\b", re.I) + return sum(len(pattern.findall(snapshot.text(path))) for path in files) + + +def _normal_name(name: str) -> str: + return re.sub(r"[^a-z0-9]", "", name.lower()) + + +def _comment_blocks(text: str, language: str) -> list[tuple[int, int, str]]: + out: list[tuple[int, int, str]] = [] + for match in re.finditer(r"/\*[\s\S]*?\*/", text): + start = text.count("\n", 0, match.start()) + 1 + end = text.count("\n", 0, match.end()) + 1 + out.append((start, end, match.group(0))) + for match in re.finditer(r"(?m)(?:^[ \t]*//[^\n]*(?:\n|$))+", text): + start = text.count("\n", 0, match.start()) + 1 + end = text.count("\n", 0, match.end()) + 1 + out.append((start, end, match.group(0))) + return sorted(out) + + +REFERENCE_PATTERNS = ( + re.compile(r"\b(?:Kotlin|Swift)(?:'s)?\s+twin\s*(?:is\s*|of\s*|:\s*)?(?:the\s+)?`([^`]+)`", re.I), + re.compile(r"\btwin\s+of\s+(?:the\s+)?(?:Kotlin|Swift)(?:'s)?\s+`([^`]+)`", re.I), + re.compile(r"\bmirrors\s+(?:Kotlin|Swift)(?:'s)?\s+`([^`]+)`", re.I), + re.compile( + r"\b(?:Kotlin|Swift)(?:'s)?\s+twin\s*(?:is\s*|:\s*)?" + r"([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)+)", + re.I, + ), +) + + +def _target(raw: str) -> tuple[str, str | None] | None: + value = raw.strip() + if "/" in value and value.lower().endswith((".swift", ".kt")): + return Path(value).stem, None + if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*(?:\([^)]*\))?)*", value): + return None + pieces = value.split(".") + if pieces[-1].lower() in {"swift", "kt"} and len(pieces) >= 2: + return pieces[-2], None + name = pieces[-1].split("(", 1)[0] + owner = pieces[-2] if len(pieces) > 1 else None + if len(pieces) > 2 and all(piece[:1].islower() for piece in pieces[:-1]): + owner = None # package-qualified type, e.g. com.noop.protocol.DeviceConfigWriteGate + elif name[:1].isupper(): + owner = None # module/type-qualified type, not an Owner.member reference + return name, owner + + +def parse_twin_references( + root: Path, + files: list[Path], + language: str, + functions: list[Declaration], + snapshot: _SourceSnapshot | None = None, +) -> list[TwinReference]: + snapshot = snapshot or _SourceSnapshot() + by_path: dict[str, list[Declaration]] = defaultdict(list) + for declaration in functions: + by_path[declaration.path].append(declaration) + out: list[TwinReference] = [] + expected = "kotlin" if language == "swift" else "swift" + for path in files: + rel = _relative(root, path) + text = snapshot.text(path) + claim_ordinals: Counter[tuple[str, str | None]] = Counter() + for start, end, comment in _comment_blocks(text, language): + if "twin" not in comment.lower() or expected not in comment.lower(): + continue + # Remove line-doc decoration without changing offsets, so wrapped unquoted + # references remain machine-readable and line reporting stays exact. + searchable = re.sub( + r"(?m)^(\s*)(?:///|//|/\*\*?|\*) ?", + lambda match: " " * len(match.group(0)), + comment, + ) + raw_targets: list[tuple[str, int]] = [] + for pattern in REFERENCE_PATTERNS: + raw_targets.extend((match.group(1), match.start(1)) for match in pattern.finditer(searchable)) + raw_targets.sort(key=lambda item: item[1]) + + parsed_targets: list[tuple[str, int, str, str | None]] = [] + seen: set[str] = set() + for raw, offset in raw_targets: + if raw in seen: + continue + seen.add(raw) + parsed = _target(raw) + if parsed is None: + continue + name, owner = parsed + parsed_targets.append((raw, offset, name, owner)) + + nearby_declaration = next( + (decl.key for decl in by_path[rel] if end <= decl.line <= end + 4), + None, + ) + # A prose block can mention older alternatives before stating the + # authoritative twin. Only its nearest function-shaped claim is + # attached to the following declaration. File/type references + # remain repository-wide references and never claim a function. + attachable = [ + item for item in parsed_targets + if item[3] is not None or item[2][:1].islower() + ] + attached_target = attachable[-1] if nearby_declaration and attachable else None + + for raw, offset, name, owner in parsed_targets: + attached = ( + nearby_declaration + if attached_target is not None and (raw, offset, name, owner) == attached_target + else None + ) + claim_key = (raw, attached) + claim_ordinals[claim_key] += 1 + line = start + searchable.count("\n", 0, offset) + out.append( + TwinReference( + language, + rel, + line, + raw, + name, + owner, + attached, + claim_ordinals[claim_key], + ) + ) + return out + + +def _resolve(reference: TwinReference, targets: list[Declaration]) -> list[Declaration]: + matches = [decl for decl in targets if _normal_name(decl.name) == _normal_name(reference.target_name)] + if reference.target_owner: + wanted = _normal_name(reference.target_owner) + matches = [ + decl for decl in matches + if wanted in { + _normal_name(decl.owner), + _normal_name(_swift_module_owner(decl.path) or ""), + } + ] + selector = re.search(r"\(([^)]*)\)\s*$", reference.raw_target) + if selector: + labels = tuple( + part.strip().rstrip(":") + for part in selector.group(1).split(":") + if part.strip() + ) + if labels: + labelled = [decl for decl in matches if decl.parameter_labels[: len(labels)] == labels] + matches = labelled + return matches + + +def attached_function_resolutions( + references: Iterable[TwinReference], + swift_functions: list[Declaration], + kotlin_functions: list[Declaration], +) -> dict[TwinReference, tuple[Declaration, ...]]: + """Resolve every attached claim, including missing and ambiguous claims.""" + result: dict[TwinReference, tuple[Declaration, ...]] = {} + for reference in references: + if reference.attached_function is None: + continue + targets = kotlin_functions if reference.language == "swift" else swift_functions + candidates = _resolve(reference, targets) + result[reference] = tuple(candidates) + return result + + +def resolved_attached_function_pairs( + references: Iterable[TwinReference], + swift_functions: list[Declaration], + kotlin_functions: list[Declaration], + *, + resolutions: dict[TwinReference, tuple[Declaration, ...]] | None = None, +) -> dict[tuple[str, str], list[TwinReference]]: + """Resolve attached source claims into the exact pairs they declare. + + Both map bootstrap and normal scans use this function so a source comment + cannot be retargeted, added, or removed independently of the checked map. + Ambiguous and unresolved claims are deliberately omitted here; the normal + dead-reference audit reports those claims separately. + """ + pairs: dict[tuple[str, str], list[TwinReference]] = defaultdict(list) + if resolutions is None: + resolutions = attached_function_resolutions( + references, swift_functions, kotlin_functions + ) + for reference, resolved in resolutions.items(): + if len(resolved) != 1: + continue + pair = ( + (reference.attached_function, resolved[0].key) + if reference.language == "swift" + else (resolved[0].key, reference.attached_function) + ) + pairs[pair].append(reference) + return pairs + + +def resolved_file_pairs( + function_pairs: Iterable[tuple[str, str]], + swift_functions: list[Declaration], + kotlin_functions: list[Declaration], +) -> set[tuple[str, str]]: + """Derive file authority from the same resolved function pairs everywhere.""" + swift_by_key = {item.key: item for item in swift_functions} + kotlin_by_key = {item.key: item for item in kotlin_functions} + return { + (swift_by_key[swift_key].path, kotlin_by_key[kotlin_key].path) + for swift_key, kotlin_key in function_pairs + if swift_key in swift_by_key and kotlin_key in kotlin_by_key + } + + +def _symbol_owners( + root: Path, + files: list[Path], + declarations: list[Declaration], + snapshot: _SourceSnapshot | None = None, +) -> dict[str, set[str]]: + snapshot = snapshot or _SourceSnapshot() + symbols: dict[str, set[str]] = defaultdict(set) + for item in declarations: + owners = {_normal_name(item.owner), _normal_name(Path(item.path).stem)} + module = _swift_module_owner(item.path) + if module: + owners.add(_normal_name(module)) + symbols[_normal_name(item.name)].update(owners) + for path in files: + file_owner = _normal_name(path.stem) + symbols[file_owner].add(file_owner) + language = "swift" if path.suffix == ".swift" else "kotlin" + text = snapshot.text(path) + masked = snapshot.masked(path) + spans = _type_spans(masked, language) + for _, _, name in spans: + symbols[_normal_name(name)].add(file_owner) + declaration = re.compile( + r"\b(?:typealias|let|var|val)\s+([A-Za-z_][A-Za-z0-9_]*)\b" + ) + for match in declaration.finditer(masked): + owner = _owner_at(spans, match.start(), path.stem) + symbols[_normal_name(match.group(1))].update((_normal_name(owner), file_owner)) + if language == "kotlin": + for type_match in TYPE_DECLARATION[language].finditer(masked): + opening = masked.find("{", type_match.end()) + header_end = opening if opening >= 0 else masked.find("\n", type_match.end()) + if header_end < 0: + header_end = len(masked) + header = masked[type_match.end():header_end] + for property_match in re.finditer(r"\b(?:val|var)\s+([A-Za-z_][A-Za-z0-9_]*)\b", header): + symbols[_normal_name(property_match.group(1))].update( + (_normal_name(type_match.group(1)), file_owner) + ) + return symbols + + +def _reference_resolves(reference: TwinReference, symbols: dict[str, set[str]]) -> bool: + owners = symbols.get(_normal_name(reference.target_name), set()) + if not owners: + return False + return reference.target_owner is None or _normal_name(reference.target_owner) in owners + + +def _symbol_names(files: list[Path], functions: list[Declaration]) -> set[str]: + """Compatibility helper retained for callers outside this module.""" + names = {_normal_name(item.name) for item in functions} + names.update(_normal_name(path.stem) for path in files) + declaration = re.compile( + r"\b(?:struct|class|enum|actor|protocol|object|interface|typealias|let|var|val)\s+([A-Za-z_][A-Za-z0-9_]*)\b" + ) + for path in files: + masked = _mask_non_code(_read(path)) + names.update(_normal_name(match.group(1)) for match in declaration.finditer(masked)) + return names + + +def _constant_pairing( + swift: list[Constant], kotlin: list[Constant], file_pairs: set[tuple[str, str]] | None = None +) -> tuple[list[tuple[Constant, Constant]], list[tuple[str, list[Constant], list[Constant]]]]: + """Pair normalized constant names, preferring type owner and then mapped/same-stem files.""" + file_pairs = file_pairs or set() + sw_by_name: dict[str, list[Constant]] = defaultdict(list) + kt_by_name: dict[str, list[Constant]] = defaultdict(list) + for item in swift: + sw_by_name[_normal_name(item.name)].append(item) + for item in kotlin: + kt_by_name[_normal_name(item.name)].append(item) + pairs: list[tuple[Constant, Constant]] = [] + ambiguous: list[tuple[str, list[Constant], list[Constant]]] = [] + for name in sorted(sw_by_name.keys() & kt_by_name.keys()): + left = list(sw_by_name[name]) + right = list(kt_by_name[name]) + + def consume(predicate) -> None: + nonlocal left, right + edges = [ + (sw, kt) + for sw in left + for kt in right + if predicate(sw, kt) and (sw.key, kt.key) not in CONSTANT_NON_TWIN_PAIRS + ] + left_degree = Counter(id(sw) for sw, _ in edges) + right_degree = Counter(id(kt) for _, kt in edges) + chosen = [(sw, kt) for sw, kt in edges if left_degree[id(sw)] == 1 and right_degree[id(kt)] == 1] + pairs.extend(chosen) + chosen_left = {id(sw) for sw, _ in chosen} + chosen_right = {id(kt) for _, kt in chosen} + left = [item for item in left if id(item) not in chosen_left] + right = [item for item in right if id(item) not in chosen_right] + + consume(lambda sw, kt: _normal_name(sw.owner) == _normal_name(kt.owner)) + consume(lambda sw, kt: (sw.path, kt.path) in file_pairs) + if (len(left) == 1 and len(right) == 1 + and (left[0].key, right[0].key) not in CONSTANT_NON_TWIN_PAIRS): + pairs.append((left.pop(), right.pop())) + allowed_edges = [ + (sw, kt) + for sw in left + for kt in right + if (sw.key, kt.key) not in CONSTANT_NON_TWIN_PAIRS + ] + if allowed_edges: + allowed_left = {id(sw) for sw, _ in allowed_edges} + allowed_right = {id(kt) for _, kt in allowed_edges} + ambiguous.append(( + name, + sorted((item for item in left if id(item) in allowed_left), key=lambda item: item.key), + sorted((item for item in right if id(item) in allowed_right), key=lambda item: item.key), + )) + return sorted(pairs, key=lambda pair: (pair[0].key, pair[1].key)), ambiguous + + +def _property_candidates( + swift: list[Declaration], kotlin: list[Declaration] +) -> list[tuple[Declaration, Declaration]]: + sw_by_identity: dict[tuple[str, str, str], list[Declaration]] = defaultdict(list) + kt_by_identity: dict[tuple[str, str, str], list[Declaration]] = defaultdict(list) + for item in swift: + sw_by_identity[(_normal_name(item.name), _normal_name(item.owner), _normal_name(Path(item.path).stem))].append(item) + for item in kotlin: + kt_by_identity[(_normal_name(item.name), _normal_name(item.owner), _normal_name(Path(item.path).stem))].append(item) + return [ + (sw_by_identity[key][0], kt_by_identity[key][0]) + for key in sorted(sw_by_identity.keys() & kt_by_identity.keys()) + if len(sw_by_identity[key]) == 1 and len(kt_by_identity[key]) == 1 + ] + + +def _reference_declarations( + root: Path, + inventory_declarations: tuple[ + list[Declaration], list[Declaration], list[Declaration], list[Declaration] + ] | None = None, + snapshot: _SourceSnapshot | None = None, +) -> tuple[list[Path], list[Declaration]]: + snapshot = snapshot or _SourceSnapshot() + if inventory_declarations is None: + inventory = _inventory(root, snapshot) + inventory_declarations = (inventory[2], inventory[3], inventory[4], inventory[5]) + files = _paths(root, REFERENCE_GLOBS) + governed: dict[str, list[Declaration]] = defaultdict(list) + for source_declarations in inventory_declarations: + for item in source_declarations: + governed[item.path].append(item) + declarations: list[Declaration] = [] + for path in files: + relative = _relative(root, path) + existing = governed.get(relative, []) + if existing: + declarations.extend(existing) + continue + language = "swift" if path.suffix == ".swift" else "kotlin" + declarations.extend(snapshot.functions(root, path, language)) + declarations.extend(snapshot.properties(root, path, language)) + return files, declarations + + +def build_twin_map( + root: Path, + inventory: tuple | None = None, + snapshot: _SourceSnapshot | None = None, +) -> dict: + """Derive the full internal semantic inventory from source.""" + root = root.resolve() + snapshot = snapshot or _SourceSnapshot() + inventory = inventory or _inventory(root, snapshot) + ( + sw_files, + kt_files, + sw_funcs, + kt_funcs, + sw_properties, + kt_properties, + sw_consts, + kt_consts, + ) = inventory + reference_files, reference_declarations = _reference_declarations( + root, (sw_funcs, kt_funcs, sw_properties, kt_properties), snapshot + ) + reference_functions = [item for item in reference_declarations if item.kind == "function"] + repo_sw_funcs = [item for item in reference_functions if item.language == "swift"] + repo_kt_funcs = [item for item in reference_functions if item.language == "kotlin"] + refs = parse_twin_references(root, sw_files, "swift", sw_funcs, snapshot) + parse_twin_references(root, kt_files, "kotlin", kt_funcs, snapshot) + pairs = set(resolved_attached_function_pairs(refs, repo_sw_funcs, repo_kt_funcs)) + + paired_sw = {left for left, _ in pairs} + paired_kt = {right for _, right in pairs} + file_pairs = sorted(resolved_file_pairs(pairs, repo_sw_funcs, repo_kt_funcs)) + property_pairs = _property_candidates(sw_properties, kt_properties) + constant_pairs, _ = _constant_pairing(sw_consts, kt_consts, set(file_pairs)) + paired_sw_files = {left for left, _ in file_pairs} + paired_kt_files = {right for _, right in file_pairs} + + sw_unpaired = [item for item in sw_funcs if item.key not in paired_sw] + kt_unpaired = [item for item in kt_funcs if item.key not in paired_kt] + return { + "file_pairs": [ + {"swift": left, "kotlin": right} + for left, right in file_pairs + ], + "function_pairs": [ + {"swift": left, "kotlin": right} + for left, right in sorted(pairs) + ], + "property_pairs": [ + {"swift": left.key, "kotlin": right.key} + for left, right in property_pairs + ], + "constant_pairs": [ + {"swift": left.key, "kotlin": right.key} + for left, right in constant_pairs + ], + "unpaired_files": { + "swift": [_relative(root, path) for path in sw_files if _relative(root, path) not in paired_sw_files], + "kotlin": [_relative(root, path) for path in kt_files if _relative(root, path) not in paired_kt_files], + }, + "unpaired_functions": { + "swift": [item.key for item in sw_unpaired], + "kotlin": [item.key for item in kt_unpaired], + }, + "unpaired_properties": { + "swift": [item.key for item in sw_properties if item.key not in {left.key for left, _ in property_pairs}], + "kotlin": [item.key for item in kt_properties if item.key not in {right.key for _, right in property_pairs}], + }, + } + + +SEMANTIC_AUTHORITY_SETS = ( + "files", + "functions", + "properties", + "constants", + "file_pairs", + "function_pairs", + "property_pairs", + "constant_pairs", + "unpaired_files", + "unpaired_functions", + "unpaired_properties", + "unpaired_constants", +) + + +def _canonical_sha256(value: object) -> str: + encoded = json.dumps( + value, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def semantic_authority( + root: Path, + *, + expanded: dict | None = None, + inventory: tuple | None = None, + snapshot: _SourceSnapshot | None = None, +) -> dict[str, list[str]]: + """Return exact canonical semantic sets, excluding descriptions and suggestions.""" + root = root.resolve() + snapshot = snapshot or _SourceSnapshot() + inventory = inventory or _inventory(root, snapshot) + expanded = expanded or build_twin_map(root, inventory, snapshot) + ( + sw_files, + kt_files, + sw_funcs, + kt_funcs, + sw_properties, + kt_properties, + sw_constants, + kt_constants, + ) = inventory + + def pairs(name: str) -> list[str]: + return sorted( + f"{item['swift']}\u0000{item['kotlin']}" + for item in expanded[name] + ) + + def both(name: str) -> list[str]: + group = expanded[name] + return sorted([f"swift\u0000{item}" for item in group["swift"]] + + [f"kotlin\u0000{item}" for item in group["kotlin"]]) + + paired_constant_keys = { + item[side] + for item in expanded["constant_pairs"] + for side in ("swift", "kotlin") + } + + return { + "files": sorted( + [f"swift\u0000{_relative(root, path)}" for path in sw_files] + + [f"kotlin\u0000{_relative(root, path)}" for path in kt_files] + ), + "functions": sorted( + [f"swift\u0000{item.key}" for item in sw_funcs] + + [f"kotlin\u0000{item.key}" for item in kt_funcs] + ), + "properties": sorted( + [f"swift\u0000{item.key}" for item in sw_properties] + + [f"kotlin\u0000{item.key}" for item in kt_properties] + ), + "constants": sorted( + [f"swift\u0000{item.key}" for item in sw_constants] + + [f"kotlin\u0000{item.key}" for item in kt_constants] + ), + "file_pairs": pairs("file_pairs"), + "function_pairs": pairs("function_pairs"), + "property_pairs": pairs("property_pairs"), + "constant_pairs": pairs("constant_pairs"), + "unpaired_files": both("unpaired_files"), + "unpaired_functions": both("unpaired_functions"), + "unpaired_properties": both("unpaired_properties"), + "unpaired_constants": sorted( + [f"swift\u0000{item.key}" for item in sw_constants if item.key not in paired_constant_keys] + + [f"kotlin\u0000{item.key}" for item in kt_constants if item.key not in paired_constant_keys] + ), + } + + +def authority_manifest(sets: dict[str, list[str]]) -> dict[str, dict[str, object]]: + return { + name: {"count": len(sets[name]), "sha256": _canonical_sha256(sets[name])} + for name in SEMANTIC_AUTHORITY_SETS + } + + +def _base_semantic_state( + root: Path, +) -> tuple[dict[str, list[str]], set[tuple[str, str, str, int]]] | None: + """Return semantic sets and function identities from the comparison base.""" + try: + try: + base = subprocess.check_output( + ["git", "merge-base", "HEAD", "origin/main"], + cwd=root, + text=True, + stderr=subprocess.DEVNULL, + ).strip() + except subprocess.CalledProcessError: + base = "HEAD" + archive = subprocess.check_output( + ["git", "archive", "--format=tar", base], + cwd=root, + stderr=subprocess.DEVNULL, + ) + with tempfile.TemporaryDirectory() as directory: + # Resolve before anything is derived from it: build_twin_map resolves its root, and an + # inventory taken from the unresolved spelling then fails relative_to (#2143, macOS). + base_root = Path(directory).resolve() + with tarfile.open(fileobj=io.BytesIO(archive), mode="r:") as bundle: + bundle.extractall(base_root, filter="data") + inventory = _inventory(base_root) + expanded = build_twin_map(base_root, inventory) + semantic_sets = semantic_authority( + base_root, expanded=expanded, inventory=inventory + ) + except (OSError, subprocess.CalledProcessError, tarfile.TarError, TypeError, + _InvalidSourceEncoding): + return None + function_identities = { + (declaration.language, declaration.owner, declaration.name, declaration.arity) + for declaration in [*inventory[2], *inventory[3]] + } + return semantic_sets, function_identities + + +def _authority_change_requires_refresh( + section: str, + base_sets: dict[str, list[str]], + current_sets: dict[str, list[str]], +) -> bool: + before = set(base_sets[section]) + after = set(current_sets[section]) + if section.startswith("unpaired_"): + return not after.issubset(before) + return not before.issubset(after) + + +def _new_unpaired_diagnostics( + semantic_sets: dict[str, list[str]], + declarations: Iterable[Declaration], + base_functions: set[tuple[str, str, str, int]], +) -> list[Finding]: + """Name locally added one-sided declarations while compact authority is stale.""" + unpaired = set(semantic_sets["unpaired_functions"]) + return [ + _finding( + "add-unpaired-function", + declaration.path, + declaration.line, + f"new one-sided {declaration.language} function {declaration.key}", + f"add-unpaired-function|{declaration.language}|{declaration.key}", + ) + for declaration in declarations + if f"{declaration.language}\0{declaration.key}" in unpaired + and ( + declaration.language, + declaration.owner, + declaration.name, + declaration.arity, + ) not in base_functions + ] + + +def build_compact_twin_map(root: Path) -> dict: + """Freeze derived semantic sets without checking in repeated inventory rows.""" + manifest = authority_manifest(semantic_authority(root)) + return { + "schema_version": 3, + "derivation": "parity_ledger.build_twin_map/v3", + "scope": { + "swift_roots": [glob.split("/**", 1)[0] for glob in SWIFT_GLOBS], + "kotlin_roots": [glob.split("/**", 1)[0] for glob in KOTLIN_GLOBS], + }, + "authority": manifest, + } + + +def expand_twin_map(root: Path, twin_map: dict) -> tuple[dict, list[str]]: + """Expand v3 from source and report every frozen-authority mismatch.""" + # Resolve before taking the inventory, as build_twin_map and semantic_authority do: both + # receive this inventory and resolve their own root, so the spellings must already agree. + root = root.resolve() + if twin_map.get("schema_version") != 3: + return twin_map, [] + inventory = _inventory(root) + expanded = build_twin_map(root, inventory) + checked = twin_map.get("authority", {}) + current = authority_manifest( + semantic_authority(root, expanded=expanded, inventory=inventory) + ) + drift = [ + key for key in SEMANTIC_AUTHORITY_SETS + if not isinstance(checked, dict) or checked.get(key) != current[key] + ] + return expanded, drift + + +def _finding(rule: str, path: str, line: int, text: str, identity: str) -> Finding: + return Finding(rule, path, line, text, f"{rule}|{identity}") + + +def _twin_map_consistency_findings( + twin_map: dict, + *, + files: set[str], + functions: set[str], + properties: set[str], +) -> list[Finding]: + """Validate the curated map itself instead of trusting it as inventory truth. + + Map regeneration is intentionally not used here: hand-curated evidence and + cross-name pairs are authoritative, but every asserted or explicitly unpaired + key must still resolve exactly and occur in only one state. + """ + + findings: list[Finding] = [] + categories = ( + ("file_pairs", "unpaired_files", files, "file"), + ("function_pairs", "unpaired_functions", functions, "function"), + ("property_pairs", "unpaired_properties", properties, "property"), + ) + for pair_name, unpaired_name, inventory, kind in categories: + pairs = twin_map.get(pair_name, []) + unpaired = twin_map.get(unpaired_name, {}) + if not isinstance(pairs, list) or not isinstance(unpaired, dict): + findings.append( + _finding( + "malformed-twin-map", DEFAULT_MAP.relative_to(ROOT).as_posix(), 1, + f"{pair_name}/{unpaired_name} must be an array/object", + f"{pair_name}|{unpaired_name}", + ) + ) + continue + for side in ("swift", "kotlin"): + paired = [entry.get(side) for entry in pairs if isinstance(entry, dict)] + paired_keys = [key for key in paired if isinstance(key, str)] + unpaired_keys = unpaired.get(side, []) + if not isinstance(unpaired_keys, list): + findings.append( + _finding( + "malformed-twin-map", DEFAULT_MAP.relative_to(ROOT).as_posix(), 1, + f"{unpaired_name}.{side} must be an array", + f"{unpaired_name}|{side}", + ) + ) + continue + for key, count in sorted(Counter(paired_keys).items()): + # A source file may intentionally contain declarations paired + # with more than one counterpart file. Declaration targets, + # however, are one-to-one and may never be reused. + if kind != "file" and count > 1: + path = key.split("::", 1)[0] + findings.append( + _finding( + "duplicate-twin-target", path, 1, + f"{kind} target {key} appears in {count} {pair_name} entries", + f"{pair_name}|{side}|{key}", + ) + ) + for key in sorted(set(paired_keys) & set(unpaired_keys)): + path = key.split("::", 1)[0] + findings.append( + _finding( + "twin-map-overlap", path, 1, + f"{kind} {key} is both paired and explicitly unpaired", + f"{kind}|{side}|{key}", + ) + ) + for key in sorted(set(paired_keys) | set(unpaired_keys)): + if key not in inventory: + path = key.split("::", 1)[0] + findings.append( + _finding( + f"stale-twin-{kind}", path, 1, + f"{kind} key {key} does not resolve exactly in the current inventory", + f"{side}|{key}", + ) + ) + return findings + + +def _mapped_sets(twin_map: dict) -> tuple[set[str], set[str], set[str]]: + files: set[str] = set() + functions: set[str] = set() + properties: set[str] = set() + for entry in twin_map.get("file_pairs", []): + files.update((entry["swift"], entry["kotlin"])) + unpaired_files = twin_map.get("unpaired_files", {}) + if isinstance(unpaired_files, dict): + files.update(unpaired_files.get("swift", [])) + files.update(unpaired_files.get("kotlin", [])) + for entry in twin_map.get("function_pairs", []): + functions.update((entry["swift"], entry["kotlin"])) + unpaired_functions = twin_map.get("unpaired_functions", {}) + if isinstance(unpaired_functions, dict): + functions.update(unpaired_functions.get("swift", [])) + functions.update(unpaired_functions.get("kotlin", [])) + for entry in twin_map.get("property_pairs", []): + properties.update((entry["swift"], entry["kotlin"])) + unpaired_properties = twin_map.get("unpaired_properties", {}) + if isinstance(unpaired_properties, dict): + properties.update(unpaired_properties.get("swift", [])) + properties.update(unpaired_properties.get("kotlin", [])) + return files, functions, properties + + +def _bootstrap_unpaired_debts( + identities: set[str], + twin_map: dict, + swift_functions: list[Declaration], + kotlin_functions: list[Declaration], +) -> set[str]: + declarations = { + "swift": {item.key: item for item in swift_functions}, + "kotlin": {item.key: item for item in kotlin_functions}, + } + unpaired = { + language: set(twin_map.get("unpaired_functions", {}).get(language, [])) + for language in ("swift", "kotlin") + } + debts: set[str] = set() + for identity in identities: + if not isinstance(identity, str) or "\0" not in identity: + continue + language, key = identity.split("\0", 1) + opposite = "kotlin" if language == "swift" else "swift" + declaration = declarations.get(language, {}).get(key) + if declaration is None or key not in unpaired.get(language, set()): + continue + counterparts = [ + item for item in declarations[opposite].values() + if _normal_name(item.name) == _normal_name(declaration.name) + and _normal_name(item.owner) == _normal_name(declaration.owner) + ] + if not counterparts: + debts.add(identity) + return debts + + +def bootstrap_unpaired_debts(root: Path, identities: set[str]) -> set[str]: + """Independently derive reviewed existing declaration debt without comment reliance.""" + inventory = _inventory(root.resolve()) + return _bootstrap_unpaired_debts( + identities, + build_twin_map(root, inventory), + inventory[2], + inventory[3], + ) + + +def _call_sites( + root: Path, + globs: tuple[str, ...], + errors: list[ScanError], + snapshot: _SourceSnapshot | None = None, +) -> dict[str, list[CallSite]]: + snapshot = snapshot or _SourceSnapshot() + calls: dict[str, list[CallSite]] = {"swift": [], "kotlin": []} + for path in _paths(root, globs): + language = "swift" if path.suffix == ".swift" else "kotlin" + text = snapshot.text(path) + try: + masked = snapshot.masked(path, kotlin_templates=language == "kotlin") + except _MalformedKotlinTemplate as error: + errors.append( + ScanError( + "malformed-kotlin-template", + _relative(root, path), + text.count("\n", 0, error.offset) + 1, + str(error), + ) + ) + continue + declarations = snapshot.functions(root, path, language) + declaration_openings = {item.opening for item in declarations} + spans = _type_spans(masked, language) + fallback_owner = path.stem.replace("+Trace", "Trace") + for match in re.finditer(r"\b(`?[A-Za-z_][A-Za-z0-9_]*`?)\s*\(", masked): + opening = match.end() - 1 + if opening in declaration_openings: + continue + arity = _arity(masked, opening) + if arity is None: + continue + prefix = masked[max(0, match.start() - 100) : match.start()] + receiver_match = re.search(r"(`?[A-Za-z_][A-Za-z0-9_]*`?)\s*\.\s*$", prefix) + lexical_owner = _owner_at(spans, match.start(), fallback_owner) + owner = receiver_match.group(1).strip("`") if receiver_match else None + if owner in {"self", "this", "Self"}: + owner = lexical_owner + elif owner is not None and owner[:1].islower(): + # `burst.codesWithTimes(...)`: the receiver is an instance variable; its + # type is not recoverable lexically, so resolve like an unqualified call. + owner = None + calls[language].append( + CallSite(match.group(1).strip("`"), arity, owner, _relative(root, path), lexical_owner) + ) + return calls + + +def _declaration_call_counts( + declarations: list[Declaration], sites: dict[str, list[CallSite]] +) -> dict[str, int]: + by_name: dict[tuple[str, str], list[Declaration]] = defaultdict(list) + for declaration in declarations: + by_name[(declaration.language, _normal_name(declaration.name))].append(declaration) + counts: Counter[str] = Counter() + for language, language_sites in sites.items(): + for site in language_sites: + # A call may omit defaulted parameters, so besides exact-arity declarations it + # can target any same-name declaration with MORE parameters (false test-only + # finding for HrvAnalyzer.rollingRmssd/4: its only production call leaves + # minBeatsPerWindow defaulted). Exact-arity candidates win outright so that the + # relaxed pool cannot introduce owner ambiguity where none existed before. + named = by_name.get((language, _normal_name(site.name)), []) + exact = [item for item in named if item.arity == site.arity] + relaxed = [ + item for item in named + if item.required_arity <= site.arity < item.arity + ] + + def _owner_filtered(pool: list[Declaration]) -> list[Declaration]: + if site.owner: + owner = _normal_name(site.owner) + return [ + item + for item in pool + if owner in {_normal_name(item.owner), _normal_name(Path(item.path).stem)} + ] + # Unqualified call: same-file declarations first (self-scope calls) — + # within the file, prefer the type whose span the call sits in, so three + # same-named members of sibling types don't all take credit. Then the + # global pool, only when its owner is unambiguous. + local = [item for item in pool if item.path == site.path] + if local: + if site.lexical_owner: + lexical = _normal_name(site.lexical_owner) + scoped = [item for item in local if _normal_name(item.owner) == lexical] + if scoped: + return scoped + return local + owners = {_normal_name(item.owner) for item in pool} + return pool if len(owners) == 1 else [] + + candidates = _owner_filtered(exact) or _owner_filtered(relaxed) + for candidate in candidates: + counts[candidate.key] += 1 + return dict(counts) + + +def scan(root: Path, twin_map: dict) -> ScanResult: + root = root.resolve() + snapshot = _SourceSnapshot() + compact_exemptions = twin_map.get("exemptions", []) + accepted_missing_claimants = { + item.get("identity") + for item in compact_exemptions + if isinstance(item, dict) and item.get("kind") == "bootstrap-unpaired-function" + } + try: + inventory = _inventory(root, snapshot) + if twin_map.get("schema_version") == 3: + expanded = build_twin_map(root, inventory, snapshot) + semantic_sets = semantic_authority( + root, expanded=expanded, inventory=inventory, snapshot=snapshot + ) + current = authority_manifest(semantic_sets) + checked = twin_map.get("authority", {}) + authority_drift = [ + key for key in SEMANTIC_AUTHORITY_SETS + if not isinstance(checked, dict) or checked.get(key) != current[key] + ] + base_state = _base_semantic_state(root) if authority_drift else None + if base_state is not None: + base_sets, base_functions = base_state + authority_drift = [ + section for section in authority_drift + if _authority_change_requires_refresh( + section, base_sets, semantic_sets + ) + ] + twin_map = expanded + else: + authority_drift = [] + base_state = None + base_functions = set() + semantic_sets = {} + ( + sw_files, + kt_files, + sw_funcs, + kt_funcs, + sw_properties, + kt_properties, + sw_consts, + kt_consts, + ) = inventory + reference_files, all_reference_declarations = _reference_declarations( + root, (sw_funcs, kt_funcs, sw_properties, kt_properties), snapshot + ) + repo_sw_funcs = [item for item in all_reference_declarations if item.language == "swift" and item.kind == "function"] + repo_kt_funcs = [item for item in all_reference_declarations if item.language == "kotlin" and item.kind == "function"] + except _InvalidSourceEncoding as error: + return ScanResult( + [], {}, {}, + [ScanError("invalid-utf8", _relative(root, error.path), 1, str(error))], + set(), set(), + ) + findings: list[Finding] = [ + _finding( + "twin-map-authority-drift", + DEFAULT_MAP.relative_to(ROOT).as_posix(), + 1, + f"derived twin-map authority changed in {section}", + section, + ) + for section in authority_drift + ] + if "unpaired_functions" in authority_drift and base_state is not None: + findings.extend( + _new_unpaired_diagnostics( + semantic_sets, [*sw_funcs, *kt_funcs], base_functions + ) + ) + errors: list[ScanError] = [] + findings.extend( + _twin_map_consistency_findings( + twin_map, + files={_relative(root, path) for path in reference_files}, + functions={item.key for item in all_reference_declarations if item.kind == "function"}, + properties={item.key for item in all_reference_declarations if item.kind == "property"}, + ) + ) + mapped_files, mapped_functions, mapped_properties = _mapped_sets(twin_map) + bootstrap_unpaired_debts = _bootstrap_unpaired_debts( + accepted_missing_claimants, twin_map, sw_funcs, kt_funcs + ) + + source_refs = ( + parse_twin_references(root, sw_files, "swift", sw_funcs, snapshot) + + parse_twin_references(root, kt_files, "kotlin", kt_funcs, snapshot) + ) + source_resolutions = attached_function_resolutions( + source_refs, repo_sw_funcs, repo_kt_funcs + ) + missing_attached_claimants = { + f"{reference.language}\0{reference.attached_function}" + for reference, candidates in source_resolutions.items() + if not candidates and reference.attached_function is not None + } + declared_pair_claims = resolved_attached_function_pairs( + source_refs, + repo_sw_funcs, + repo_kt_funcs, + resolutions=source_resolutions, + ) + declared_pairs = set(declared_pair_claims) + mapped_function_pairs = { + (entry["swift"], entry["kotlin"]) + for entry in twin_map.get("function_pairs", []) + if isinstance(entry, dict) + and isinstance(entry.get("swift"), str) + and isinstance(entry.get("kotlin"), str) + } + for reference, candidates in source_resolutions.items(): + if len(candidates) == 1: + continue + claimant = f"{reference.language}\0{reference.attached_function}" + if not candidates and claimant in accepted_missing_claimants: + continue + candidate_keys = tuple(item.key for item in candidates) + targets = repo_kt_funcs if reference.language == "swift" else repo_sw_funcs + if not candidates: + rule = "unresolved-attached-function-claim" + detail = "does not resolve to an inventory function" + else: + rule = "ambiguous-attached-function-claim" + detail = f"resolves to {len(candidates)} inventory functions: {', '.join(candidate_keys)}" + findings.append( + _finding( + rule, + reference.path, + reference.line, + f"attached twin claim {reference.raw_target} {detail}", + ( + f"{reference.path}|{reference.raw_target}|" + f"{reference.attached_function}|{reference.claim_ordinal}|" + f"{'|'.join(candidate_keys)}" + ), + ) + ) + for identity in sorted(accepted_missing_claimants - bootstrap_unpaired_debts): + findings.append( + _finding( + "stale-bootstrap-exemption", + identity.split("\0", 1)[-1].split("::", 1)[0], + 1, + f"bootstrap unpaired-function exemption no longer matches a declaration without a counterpart: {identity}", + identity, + ) + ) + for swift_key, kotlin_key in sorted(declared_pairs - mapped_function_pairs): + claim = declared_pair_claims[(swift_key, kotlin_key)][0] + findings.append( + _finding( + "unmapped-declared-function-pair", + claim.path, + claim.line, + f"attached twin claim {swift_key} -> {kotlin_key} is absent from the twin map", + f"{swift_key}|{kotlin_key}", + ) + ) + for swift_key, kotlin_key in sorted(mapped_function_pairs - declared_pairs): + findings.append( + _finding( + "stale-declared-function-pair", + swift_key.split("::", 1)[0], + 1, + f"mapped function pair {swift_key} -> {kotlin_key} has no resolved attached source claim", + f"{swift_key}|{kotlin_key}", + ) + ) + + declared_file_pairs = resolved_file_pairs( + declared_pairs, repo_sw_funcs, repo_kt_funcs + ) + checked_file_pairs = { + (entry["swift"], entry["kotlin"]) + for entry in twin_map.get("file_pairs", []) + if isinstance(entry, dict) + and isinstance(entry.get("swift"), str) + and isinstance(entry.get("kotlin"), str) + } + for swift_path, kotlin_path in sorted(declared_file_pairs - checked_file_pairs): + findings.append( + _finding( + "unmapped-declared-file-pair", + swift_path, + 1, + f"source-declared file pair {swift_path} -> {kotlin_path} is absent from the twin map", + f"{swift_path}|{kotlin_path}", + ) + ) + for swift_path, kotlin_path in sorted(checked_file_pairs - declared_file_pairs): + findings.append( + _finding( + "stale-declared-file-pair", + swift_path, + 1, + f"mapped file pair {swift_path} -> {kotlin_path} has no resolved source function claim", + f"{swift_path}|{kotlin_path}", + ) + ) + + for language, files in (("Swift", sw_files), ("Kotlin", kt_files)): + for path in files: + rel = _relative(root, path) + if rel not in mapped_files: + findings.append(_finding("unmapped-file", rel, 1, f"new {language} file has no twin-map entry", rel)) + for declaration in sw_funcs + kt_funcs: + if declaration.key not in mapped_functions: + findings.append( + _finding( + "unmapped-function", + declaration.path, + declaration.line, + f"{declaration.name}/{declaration.arity} has no twin-map entry", + declaration.key, + ) + ) + for declaration in sw_properties + kt_properties: + if declaration.key not in mapped_properties: + findings.append( + _finding( + "unmapped-property", + declaration.path, + declaration.line, + f"{declaration.owner}.{declaration.name} has no twin-map entry", + declaration.key, + ) + ) + + all_production_files = _paths(root, PRODUCTION_GLOBS) + all_functions: list[Declaration] = [] + all_properties: list[Declaration] = [] + for path in all_production_files: + language = "swift" if path.suffix == ".swift" else "kotlin" + all_functions.extend(snapshot.functions(root, path, language)) + all_properties.extend(snapshot.properties(root, path, language)) + all_swift_files = [path for path in reference_files if path.suffix == ".swift"] + all_kotlin_files = [path for path in reference_files if path.suffix == ".kt"] + all_swift_functions = [item for item in all_functions if item.language == "swift"] + all_kotlin_functions = [item for item in all_functions if item.language == "kotlin"] + + reference_swift_declarations = [item for item in all_reference_declarations if item.language == "swift"] + reference_kotlin_declarations = [item for item in all_reference_declarations if item.language == "kotlin"] + refs = parse_twin_references(root, all_swift_files, "swift", reference_swift_declarations, snapshot) + parse_twin_references( + root, + all_kotlin_files, + "kotlin", + reference_kotlin_declarations, + snapshot, + ) + swift_symbols = _symbol_owners( + root, + all_swift_files, + reference_swift_declarations, + snapshot, + ) + kotlin_symbols = _symbol_owners( + root, + all_kotlin_files, + reference_kotlin_declarations, + snapshot, + ) + resolved_refs = 0 + for reference in refs: + target_symbols = kotlin_symbols if reference.language == "swift" else swift_symbols + if _reference_resolves(reference, target_symbols): + resolved_refs += 1 + continue + claimant = ( + f"{reference.language}\0{reference.attached_function}" + if reference.attached_function is not None else None + ) + if claimant in accepted_missing_claimants: + continue + target_language = "Kotlin" if reference.language == "swift" else "Swift" + findings.append( + _finding( + "dead-twin-reference", + reference.path, + reference.line, + f"{target_language} target {reference.raw_target} does not resolve", + ( + f"{reference.path}|{reference.raw_target}|" + f"{reference.attached_function or ''}|{reference.claim_ordinal}" + ), + ) + ) + + constants_by_key = {item.key: item for item in sw_consts + kt_consts} + paired_constants, ambiguous_constants = _constant_pairing( + sw_consts, kt_consts, declared_file_pairs + ) + dynamic_pairs = {(left.key, right.key) for left, right in paired_constants} + mapped_pairs = {(entry["swift"], entry["kotlin"]) for entry in twin_map.get("constant_pairs", [])} + for left_key, right_key in sorted(dynamic_pairs - mapped_pairs): + left = constants_by_key[left_key] + right = constants_by_key[right_key] + findings.append( + _finding( + "unmapped-constant-pair", + left.path, + left.line, + f"mirrored constant pair {left_key} -> {right_key} is absent from the twin map", + f"{left_key}|{right_key}", + ) + ) + for left_key, right_key in sorted(dynamic_pairs | mapped_pairs): + left = constants_by_key.get(left_key) + right = constants_by_key.get(right_key) + if left is None or right is None: + if (left_key, right_key) in mapped_pairs: + present = left or right + missing = left_key if left is None else right_key + findings.append( + _finding( + "stale-constant-pair", + present.path if present is not None else left_key.split("::", 1)[0], + present.line if present is not None else 1, + f"mapped constant {missing} does not resolve", + f"{left_key}|{right_key}", + ) + ) + continue + if left.value is None or right.value is None: + findings.append( + _finding( + "constant-unverifiable", + left.path, + left.line, + f"cannot fully evaluate {left.name}={left.display_value} against Kotlin {right.name}={right.display_value} ({right.path}:{right.line})", + f"{left_key}|{left.display_value}|{right_key}|{right.display_value}", + ) + ) + continue + if left.value == right.value: + continue + findings.append( + _finding( + "constant-value-mismatch", + left.path, + left.line, + f"{left.name}={left.display_value} differs from Kotlin {right.name}={right.display_value} ({right.path}:{right.line})", + f"{left_key}|{left.value}|{right_key}|{right.value}", + ) + ) + + for normal_name, left, right in ambiguous_constants: + left_keys = ", ".join(item.key for item in left) + right_keys = ", ".join(item.key for item in right) + first = left[0] + findings.append( + _finding( + "constant-ambiguous", + first.path, + first.line, + f"{normal_name} cannot be paired uniquely: Swift [{left_keys}] vs Kotlin [{right_keys}]", + f"{normal_name}|{'|'.join(item.key for item in left)}|{'|'.join(item.key for item in right)}", + ) + ) + + inventory_functions = sw_funcs + kt_funcs + prod_calls = _declaration_call_counts( + inventory_functions, _call_sites(root, PRODUCTION_GLOBS, errors, snapshot) + ) + test_calls = _declaration_call_counts( + inventory_functions, _call_sites(root, TEST_GLOBS, errors, snapshot) + ) + for declaration in sw_funcs + kt_funcs: + if test_calls.get(declaration.key, 0) > 0 and prod_calls.get(declaration.key, 0) == 0: + findings.append( + _finding( + "test-only-callsite", + declaration.path, + declaration.line, + f"{declaration.owner}.{declaration.name}/{declaration.arity} has {test_calls[declaration.key]} test callsite(s) and no production callsite", + declaration.key, + ) + ) + + day_string = [item for item in all_functions if item.name == "dayString"] + ui_pearson = [ + item for item in all_functions + if item.language == "kotlin" and item.name == "pearson" and item.path.startswith("android/app/src/main/java/com/noop/ui/") + ] + resting = [item for item in sw_funcs + kt_funcs if item.name in {"restingHR", "sessionRestingHR"}] + for declaration in day_string: + findings.append( + _finding( + "duplicate-implementation", + declaration.path, + declaration.line, + f"dayString implementation {declaration.owner}.{declaration.name}/{declaration.arity}", + f"dayString|{declaration.key}", + ) + ) + for declaration in ui_pearson: + findings.append( + _finding( + "duplicate-implementation", + declaration.path, + declaration.line, + f"independent Android UI Pearson implementation {declaration.owner}.pearson/{declaration.arity}", + f"android-ui-pearson|{declaration.key}", + ) + ) + for declaration in resting: + findings.append( + _finding( + "duplicate-implementation", + declaration.path, + declaration.line, + f"resting-HR path {declaration.owner}.{declaration.name}/{declaration.arity}", + f"resting-hr|{declaration.key}", + ) + ) + + findings.sort(key=lambda item: (item.path, item.line, item.rule, item.identity)) + counters = { + "day_string_implementations": len(day_string), + "resting_hr_paths": len({item.name for item in resting}), + "android_ui_pearson_implementations": len(ui_pearson), + } + stats = { + "swift_files": len(sw_files), + "kotlin_files": len(kt_files), + "swift_functions": len(sw_funcs), + "kotlin_functions": len(kt_funcs), + "swift_properties": len(sw_properties), + "kotlin_properties": len(kt_properties), + "swift_constants": len(sw_consts), + "kotlin_constants": len(kt_consts), + "swift_parity_annotations": _annotation_count(sw_files, snapshot), + "kotlin_parity_annotations": _annotation_count(kt_files, snapshot), + "declared_twin_references": len(refs), + "resolved_twin_references": resolved_refs, + "constant_pairs": len(dynamic_pairs | mapped_pairs), + } + errors.extend( + ScanError(item.rule, item.path, item.line, item.text) + for item in findings + if item.rule in HARD_FINDING_RULES + ) + errors = sorted( + set(errors), + key=lambda item: (item.path, item.line, item.rule, item.text), + ) + return ScanResult( + findings, counters, stats, errors, missing_attached_claimants, + bootstrap_unpaired_debts, + ) + + +def _load_json(path: Path, default: dict) -> dict: + if not path.exists(): + return default + value = json.loads(path.read_text()) + issue_ref.validate_current_issue_fields(value, str(path)) + return value + + +def _write_json(path: Path, value: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + if value.get("schema_version") == 3: + def render(node: object, level: int = 0) -> list[str]: + pad = " " * level + if isinstance(node, dict): + if node and all(not isinstance(child, (dict, list)) for child in node.values()): + return [pad + json.dumps(node, ensure_ascii=False, separators=(", ", ": "))] + lines = [pad + "{"] + items = list(node.items()) + for index, (key, child) in enumerate(items): + rendered = render(child, level + 1) + prefix = " " * (level + 1) + json.dumps(key) + ": " + rendered[0] = prefix + rendered[0].lstrip() + if index + 1 < len(items): + rendered[-1] += "," + lines.extend(rendered) + lines.append(pad + "}") + return lines + if isinstance(node, list): + if not node: + return [pad + "[]"] + lines = [pad + "["] + for index, child in enumerate(node): + rendered = render(child, level + 1) + if index + 1 < len(node): + rendered[-1] += "," + lines.extend(rendered) + lines.append(pad + "]") + return lines + return [pad + json.dumps(node, ensure_ascii=False)] + + path.write_text("\n".join(render(value)) + "\n", encoding="utf-8") + else: + path.write_text(json.dumps(value, indent=2, sort_keys=False) + "\n", encoding="utf-8") + + +def _publish_json_pair(first_path: Path, first: dict, second_path: Path, second: dict) -> None: + """Publish two JSON snapshots as one rollback-safe operation.""" + paths = (first_path, second_path) + values = (first, second) + old = [(path.exists(), path.read_bytes() if path.exists() else b"") for path in paths] + temporary: list[Path] = [] + try: + for path, value in zip(paths, values): + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + os.close(descriptor) + candidate = Path(name) + temporary.append(candidate) + _write_json(candidate, value) + os.replace(temporary[0], first_path) + temporary.pop(0) + os.replace(temporary[0], second_path) + temporary.pop(0) + except BaseException: + for path, (existed, content) in zip(paths, old): + if existed: + descriptor, name = tempfile.mkstemp(prefix=f".{path.name}.rollback.", dir=path.parent) + try: + with os.fdopen(descriptor, "wb") as handle: + handle.write(content) + os.replace(name, path) + finally: + Path(name).unlink(missing_ok=True) + else: + path.unlink(missing_ok=True) + raise + finally: + for path in temporary: + path.unlink(missing_ok=True) + + +BASELINE_REASONS = { + "test-only-callsite": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", + "duplicate-implementation": "Exact platform-local implementation is retained as visible parity debt pending a source-level consolidation decision.", + "constant-unverifiable": "Exact mirrored constant uses a lexical expression that the standard-library scanner cannot evaluate safely.", + "constant-value-mismatch": "Exact mirrored constant values differ on current upstream and remain visible as parity debt.", + "constant-ambiguous": "Exact normalized constant name has multiple viable cross-language matches and remains visible until explicitly disambiguated.", +} + + +def _finding_domain(path: str) -> str: + pieces = path.split("/") + if path.startswith("android/app/src/main/java/com/noop/") and len(pieces) > 7: + return f"android/{pieces[7]}" + if path.startswith("Packages/") and len(pieces) > 1: + return f"Packages/{pieces[1]}" + return pieces[0] + + +def baseline_group_provenance(rule: str, scope: str) -> str: + return ( + f"Exact identities emitted by parity_ledger rule {rule} " + f"for current upstream scope {scope}; no wildcard matching." + ) + + +def build_compact_baseline(result: ScanResult) -> dict: + """Group exact accepted identities while retaining narrow review provenance.""" + grouped: dict[tuple[str, str], list[str]] = defaultdict(list) + for finding in result.findings: + if finding.rule in HARD_FINDING_RULES: + continue + if finding.rule not in BASELINE_REASONS: + raise ValueError( + f"finding {finding.identity} has no reviewed compact-baseline disposition" + ) + grouped[(finding.rule, _finding_domain(finding.path))].append(finding.identity) + return { + "schema_version": 3, + "accepted_findings": [ + { + "rule": rule, + "scope": domain, + "reason": BASELINE_REASONS[rule], + "provenance": baseline_group_provenance(rule, domain), + "count": len(identities), + "identities_sha256": _canonical_sha256(sorted(identities)), + } + for (rule, domain), identities in sorted(grouped.items()) + ], + "counters": result.counters, + } + + +def compact_baseline_drift(result: ScanResult, baseline: dict) -> list[str]: + try: + current = build_compact_baseline(result) + except ValueError as exc: + return [str(exc)] + checked_groups = { + (item.get("rule"), item.get("scope")): item + for item in baseline.get("accepted_findings", []) + if isinstance(item, dict) + } + current_groups = { + (item["rule"], item["scope"]): item + for item in current["accepted_findings"] + } + return [ + f"{rule}|{scope}" + for rule, scope in sorted(set(checked_groups) | set(current_groups)) + if checked_groups.get((rule, scope), {}).get("count") + != current_groups.get((rule, scope), {}).get("count") + or checked_groups.get((rule, scope), {}).get("identities_sha256") + != current_groups.get((rule, scope), {}).get("identities_sha256") + ] + + +def compact_baseline_changes( + result: ScanResult, baseline: dict +) -> tuple[list[str], list[str]]: + """Return (regressions, improvements) for compact finding groups.""" + try: + current = build_compact_baseline(result) + except ValueError as exc: + return [str(exc)], [] + checked_groups = { + (item.get("rule"), item.get("scope")): item + for item in baseline.get("accepted_findings", []) + if isinstance(item, dict) + } + current_groups = { + (item["rule"], item["scope"]): item + for item in current["accepted_findings"] + } + regressions: list[str] = [] + improvements: list[str] = [] + for key in sorted(set(checked_groups) | set(current_groups)): + old = checked_groups.get(key) + new = current_groups.get(key) + label = f"{key[0]}|{key[1]}" + if old is None: + regressions.append(label) + elif new is None or new["count"] < old.get("count", 0): + improvements.append(label) + elif (new["count"] > old.get("count", 0) + or new["identities_sha256"] != old.get("identities_sha256")): + regressions.append(label) + return regressions, improvements + + +def finding_identities_at_git_ref(root: Path, ref: str) -> set[str]: + """Independently scan an exact git tree for monotonic baseline proof.""" + try: + resolved = subprocess.check_output( + ["git", "rev-parse", "--verify", f"{ref}^{{commit}}"], + cwd=root, + text=True, + stderr=subprocess.DEVNULL, + ).strip() + if re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", resolved) is None: + raise ValueError(f"git returned an invalid commit for base {ref!r}") + archive = subprocess.check_output( + ["git", "archive", "--format=tar", resolved], cwd=root + ) + except (FileNotFoundError, subprocess.CalledProcessError) as exc: + raise ValueError(f"cannot scan exact base {ref!r}") from exc + with tempfile.TemporaryDirectory() as directory: + # Same resolution as _base_semantic_state, so both temp checkouts spell their root one way (#2143). + base_root = Path(directory).resolve() + try: + with tarfile.open(fileobj=io.BytesIO(archive), mode="r:") as bundle: + bundle.extractall(base_root, filter="data") + except (tarfile.TarError, TypeError) as exc: + raise ValueError(f"cannot materialize exact base {ref!r}") from exc + base_map = build_compact_twin_map(base_root) + base_result = scan(base_root, base_map) + if base_result.errors: + raise ValueError(f"cannot prove improvement against invalid base {ref!r}") + return {finding.identity for finding in base_result.findings} + + +def _summary(result: ScanResult) -> str: + stats = result.stats + return ( + f"{stats['swift_files']} Swift files, {stats['kotlin_files']} Kotlin files; " + f"{stats['swift_functions']} Swift functions, {stats['kotlin_functions']} Kotlin functions; " + f"{stats['swift_properties']} Swift properties, {stats['kotlin_properties']} Kotlin properties; " + f"{stats['swift_constants']} Swift constants, {stats['kotlin_constants']} Kotlin constants; " + f"annotations Swift={stats['swift_parity_annotations']}, Kotlin={stats['kotlin_parity_annotations']}; " + f"{stats['declared_twin_references']} declared twin references " + f"({stats['resolved_twin_references']} resolved); {stats['constant_pairs']} constant pairs; " + f"counters dayString={result.counters['day_string_implementations']}, " + f"resting-HR={result.counters['resting_hr_paths']}, " + f"Android-UI-Pearson={result.counters['android_ui_pearson_implementations']}" + ) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=ROOT, help="repository root") + parser.add_argument("--map", dest="map_path", type=Path, help="twin-map JSON path") + parser.add_argument("--baseline", dest="baseline_path", type=Path, help="baseline JSON path") + parser.add_argument("--no-baseline", action="store_true", help="show every current finding") + parser.add_argument("--bootstrap-map", action="store_true", help="write a fresh inventory map before scanning") + parser.add_argument("--write-baseline", action="store_true", help="rewrite the baseline with current findings") + parser.add_argument("--refresh-derived", action="store_true", help="refresh existing derived snapshots only if the ratchet accepts the result") + parser.add_argument( + "--repair-stale-base", + action="store_true", + help="with --refresh-derived, repair metadata drift already present in the exact base", + ) + parser.add_argument("--base", default="origin/main", help="exact git ref used to prove debt reductions") + parser.add_argument( + "--migrate-authority", + action="store_true", + help="re-base onto a freshly derived base authority when the base's stored one cannot be " + "reproduced; new debt still requires issue-bound dispositions", + ) + args = parser.parse_args(argv) + + root = args.root.resolve() + map_path = args.map_path or root / "Tools/parity_twin_map.json" + baseline_path = args.baseline_path or root / "Tools/parity_ledger_baseline.json" + if args.bootstrap_map != args.write_baseline: + print("FAIL --bootstrap-map and --write-baseline must be used together") + return 2 + if args.repair_stale_base and not args.refresh_derived: + print("FAIL --repair-stale-base requires --refresh-derived") + return 2 + if args.migrate_authority and not args.refresh_derived: + print("FAIL --migrate-authority requires --refresh-derived") + return 2 + if args.migrate_authority and args.repair_stale_base: + print("FAIL --repair-stale-base and --migrate-authority are different remedies; use one") + return 2 + if args.refresh_derived: + if args.bootstrap_map or args.write_baseline or args.no_baseline: + print("FAIL --refresh-derived cannot be combined with bootstrap, baseline, or display modes") + return 2 + if not map_path.exists() or not baseline_path.exists(): + print("FAIL --refresh-derived requires existing authority; use --bootstrap-map --write-baseline once") + return 2 + if args.map_path is not None or args.baseline_path is not None: + print("FAIL --refresh-derived only supports the canonical checked-in snapshot paths") + return 2 + old_map = map_path.read_bytes() + old_baseline = baseline_path.read_bytes() + candidate_map = build_compact_twin_map(root) + candidate_result = scan(root, candidate_map) + if candidate_result.errors: + print(f"FAIL {len(candidate_result.errors)} parity ledger scan error(s); snapshots unchanged") + return 1 + accepted = False + try: + _write_json(map_path, candidate_map) + _write_json(baseline_path, build_compact_baseline(candidate_result)) + command = [ + sys.executable, str(Path(__file__).with_name("parity_ratchet.py")), + "--root", str(root), "--base", args.base, "--offline", + ] + if args.repair_stale_base: + command.append("--repair-stale-base") + if args.migrate_authority: + command.append("--migrate-authority") + completed = subprocess.run(command, cwd=root, text=True, capture_output=True) + if completed.returncode: + print("FAIL derived refresh rejected; snapshots restored") + print((completed.stderr or completed.stdout).strip()) + return 1 + accepted = True + finally: + if not accepted: + map_path.write_bytes(old_map) + baseline_path.write_bytes(old_baseline) + print(f"WROTE reviewed derived snapshots ({len(candidate_result.findings)} known findings)") + return 0 + if args.bootstrap_map: + if map_path.exists() or baseline_path.exists(): + print("FAIL --bootstrap-map is for initial authority creation only; use the reviewed refresh workflow for existing authority") + return 2 + twin_map = build_compact_twin_map(root) + result = scan(root, twin_map) + if result.errors: + print(f"FAIL {len(result.errors)} parity ledger scan error(s); snapshots unchanged") + for error in result.errors: + print(f" {error.output()}") + return 1 + baseline = build_compact_baseline(result) + _publish_json_pair(map_path, twin_map, baseline_path, baseline) + expanded, drift = expand_twin_map(root, twin_map) + assert not drift + print(f"WROTE {map_path} and {baseline_path} ({len(expanded['function_pairs'])} derived function pairs; {len(result.findings)} known findings)") + return 0 + else: + twin_map = _load_json(map_path, {}) + + result = scan(root, twin_map) + if result.errors: + print(f"FAIL {len(result.errors)} parity ledger scan error(s):\n") + for error in result.errors: + print(f" {error.output()}") + noun = "error" if len(result.errors) == 1 else "errors" + print(f"\nBaseline not evaluated: {len(result.errors)} scan {noun}.") + return 1 + if args.no_baseline: + if result.findings: + print(f"FAIL {len(result.findings)} current parity ledger finding(s):\n") + for item in result.findings: + print(f" {item.output()}") + print(f"\nScanned {_summary(result)}") + return 1 + print(f"OK no parity ledger findings ({_summary(result)})") + return 0 + + baseline = _load_json(baseline_path, {}) + compact_drift, improvements = compact_baseline_changes(result, baseline) + baseline_counters = baseline.get("counters", {}) + counter_regressions = [ + (name, baseline_counters[name], count) + for name, count in result.counters.items() + if name in baseline_counters and count > baseline_counters[name] + ] + if compact_drift: + print(f"FAIL compact baseline drift in {', '.join(compact_drift)}") + actionable = [item for item in result.findings if item.rule.startswith("add-unpaired-")] + if actionable: + print("\nActionable source drift:") + for item in actionable: + print(f" {item.output()}") + print(f"\nScanned {_summary(result)}") + return 1 + + if improvements: + try: + base_identities = finding_identities_at_git_ref(root, args.base) + except ValueError as exc: + print(f"FAIL {exc}; cannot prove that compact baseline drift is only a decrease") + return 1 + new_identities = { + item.identity for item in result.findings + } - base_identities + if new_identities: + print("FAIL debt count decreased but replacement findings are new against the exact base:") + for identity in sorted(new_identities): + print(f" {identity}") + return 1 + for improvement in improvements: + print(f"WARNING debt decreased in {improvement}; baseline cleanup is optional") + + if counter_regressions: + total = len(counter_regressions) + print(f"FAIL {total} parity ledger finding(s) beyond the baseline:\n") + for name, was, now in counter_regressions: + print(f" {baseline_path.relative_to(root)}:1: duplicate-counter: {name} increased from {was} to {now}") + print(f"\nScanned {_summary(result)}") + return 1 + + print(f"OK no NEW parity ledger findings ({len(result.findings)} baselined; {_summary(result)})") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Tools/parity_ledger_baseline.json b/Tools/parity_ledger_baseline.json new file mode 100644 index 0000000000..6f0350d2b0 --- /dev/null +++ b/Tools/parity_ledger_baseline.json @@ -0,0 +1,29 @@ +{ + "schema_version": 3, + "accepted_findings": [ + {"rule": "constant-ambiguous", "scope": "Packages/StrandImport", "reason": "Exact normalized constant name has multiple viable cross-language matches and remains visible until explicitly disambiguated.", "provenance": "Exact identities emitted by parity_ledger rule constant-ambiguous for current upstream scope Packages/StrandImport; no wildcard matching.", "count": 2, "identities_sha256": "a09cc40da0a1374a5b83a5575bbf5c37e8a452da6b8a2230ec20a024e05620a2"}, + {"rule": "constant-unverifiable", "scope": "Packages/StrandAnalytics", "reason": "Exact mirrored constant uses a lexical expression that the standard-library scanner cannot evaluate safely.", "provenance": "Exact identities emitted by parity_ledger rule constant-unverifiable for current upstream scope Packages/StrandAnalytics; no wildcard matching.", "count": 9, "identities_sha256": "adb72d0c29519b98895644b5be8359eade019012349d43159e83490597db5080"}, + {"rule": "constant-unverifiable", "scope": "Packages/StrandImport", "reason": "Exact mirrored constant uses a lexical expression that the standard-library scanner cannot evaluate safely.", "provenance": "Exact identities emitted by parity_ledger rule constant-unverifiable for current upstream scope Packages/StrandImport; no wildcard matching.", "count": 3, "identities_sha256": "337509f798a8f5d4b5175ca24b3a6bcf8c905079fed63dd0f3f799f9b3af4c95"}, + {"rule": "constant-unverifiable", "scope": "Packages/WhoopProtocol", "reason": "Exact mirrored constant uses a lexical expression that the standard-library scanner cannot evaluate safely.", "provenance": "Exact identities emitted by parity_ledger rule constant-unverifiable for current upstream scope Packages/WhoopProtocol; no wildcard matching.", "count": 1, "identities_sha256": "ec2d804fac6265dd622a47119b60aad9125b8120acad624401852e05f922601a"}, + {"rule": "constant-value-mismatch", "scope": "Packages/StrandAnalytics", "reason": "Exact mirrored constant values differ on current upstream and remain visible as parity debt.", "provenance": "Exact identities emitted by parity_ledger rule constant-value-mismatch for current upstream scope Packages/StrandAnalytics; no wildcard matching.", "count": 2, "identities_sha256": "bd128022f805b1054a2d27ef711b56b766b0026b2e0a0996f59a24e7ba1051a3"}, + {"rule": "duplicate-implementation", "scope": "Packages/NoopLocalAccess", "reason": "Exact platform-local implementation is retained as visible parity debt pending a source-level consolidation decision.", "provenance": "Exact identities emitted by parity_ledger rule duplicate-implementation for current upstream scope Packages/NoopLocalAccess; no wildcard matching.", "count": 1, "identities_sha256": "93ac143d045e2adecdb4def57935476d9103e26336627f39c98bbf02f75f9154"}, + {"rule": "duplicate-implementation", "scope": "Packages/StrandAnalytics", "reason": "Exact platform-local implementation is retained as visible parity debt pending a source-level consolidation decision.", "provenance": "Exact identities emitted by parity_ledger rule duplicate-implementation for current upstream scope Packages/StrandAnalytics; no wildcard matching.", "count": 3, "identities_sha256": "bf4e358f9ff146278c782011d341a661bdbfd6ccd72784d4605d001382b5bbe1"}, + {"rule": "duplicate-implementation", "scope": "Packages/StrandImport", "reason": "Exact platform-local implementation is retained as visible parity debt pending a source-level consolidation decision.", "provenance": "Exact identities emitted by parity_ledger rule duplicate-implementation for current upstream scope Packages/StrandImport; no wildcard matching.", "count": 1, "identities_sha256": "f4174a7246cb82ac8a9e363fb0706d6c46cc118f64ae8842790d7fc2fb29a156"}, + {"rule": "duplicate-implementation", "scope": "Strand", "reason": "Exact platform-local implementation is retained as visible parity debt pending a source-level consolidation decision.", "provenance": "Exact identities emitted by parity_ledger rule duplicate-implementation for current upstream scope Strand; no wildcard matching.", "count": 3, "identities_sha256": "b4cdd17c98994f4fe2dda3a1a33e26df1f89981803fba600a42310c683a984b0"}, + {"rule": "duplicate-implementation", "scope": "StrandiOS", "reason": "Exact platform-local implementation is retained as visible parity debt pending a source-level consolidation decision.", "provenance": "Exact identities emitted by parity_ledger rule duplicate-implementation for current upstream scope StrandiOS; no wildcard matching.", "count": 1, "identities_sha256": "d92bf876aaac5bf751ce872c8aaabf4df2d4b6a2e1940f5e2e20804ad34869fa"}, + {"rule": "duplicate-implementation", "scope": "android/analytics", "reason": "Exact platform-local implementation is retained as visible parity debt pending a source-level consolidation decision.", "provenance": "Exact identities emitted by parity_ledger rule duplicate-implementation for current upstream scope android/analytics; no wildcard matching.", "count": 3, "identities_sha256": "524488fb20b5edfabb6a34acfb3b98844baf2b8bf158aac15cbc42436cd69be8"}, + {"rule": "duplicate-implementation", "scope": "android/ingest", "reason": "Exact platform-local implementation is retained as visible parity debt pending a source-level consolidation decision.", "provenance": "Exact identities emitted by parity_ledger rule duplicate-implementation for current upstream scope android/ingest; no wildcard matching.", "count": 2, "identities_sha256": "8b3126b470191bb155e6d7d3caf111423e5738edab66643558805020a0b1123d"}, + {"rule": "duplicate-implementation", "scope": "android/ui", "reason": "Exact platform-local implementation is retained as visible parity debt pending a source-level consolidation decision.", "provenance": "Exact identities emitted by parity_ledger rule duplicate-implementation for current upstream scope android/ui; no wildcard matching.", "count": 4, "identities_sha256": "b9f579c3d30221ff72e40282c29baab20d57d95acf044e0f71d2338e37c52b1b"}, + {"rule": "test-only-callsite", "scope": "Packages/OuraProtocol", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope Packages/OuraProtocol; no wildcard matching.", "count": 5, "identities_sha256": "ff6404cd4d27782334dd844223be3ec86bb3260bef7f7e29c658fb6ad6e7938e"}, + {"rule": "test-only-callsite", "scope": "Packages/StrandAnalytics", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope Packages/StrandAnalytics; no wildcard matching.", "count": 43, "identities_sha256": "6a22f1d814cffe36ecc9a7d0b0548f99cd910551ffc3da210e836397dcab35ed"}, + {"rule": "test-only-callsite", "scope": "Packages/StrandImport", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope Packages/StrandImport; no wildcard matching.", "count": 14, "identities_sha256": "1acdc71178e0a555f000c42980345e8c38495f9e3806a866f31b260865d3ac98"}, + {"rule": "test-only-callsite", "scope": "Packages/WhoopProtocol", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope Packages/WhoopProtocol; no wildcard matching.", "count": 30, "identities_sha256": "a9dc115534e8710217db9cd39bcceaa1419741737bfd3b733007dc0215cd5af2"}, + {"rule": "test-only-callsite", "scope": "Packages/WhoopStore", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope Packages/WhoopStore; no wildcard matching.", "count": 37, "identities_sha256": "2a62c1ece1c541b662dd407f0668d9a02782bb4d21f9bb181518272ff12f0e40"}, + {"rule": "test-only-callsite", "scope": "android/analytics", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope android/analytics; no wildcard matching.", "count": 64, "identities_sha256": "7002d52966c8fb33168de4117e5a6f1c00a4fbee5e7940483b1b43bf84bfc70d"}, + {"rule": "test-only-callsite", "scope": "android/data", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope android/data; no wildcard matching.", "count": 13, "identities_sha256": "6abaae19303cadccbe259d7db320850e269433472b28c84a1bfce7d32d54ce5f"}, + {"rule": "test-only-callsite", "scope": "android/ingest", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope android/ingest; no wildcard matching.", "count": 12, "identities_sha256": "d4f45d66087768eab133b918af6658b06ec1d91305e38fb045f6ae1629c94678"}, + {"rule": "test-only-callsite", "scope": "android/oura", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope android/oura; no wildcard matching.", "count": 4, "identities_sha256": "9beaa9034af0a1021b9a9f43739971934f260147e0a9a5c462468548a7fd577d"}, + {"rule": "test-only-callsite", "scope": "android/protocol", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope android/protocol; no wildcard matching.", "count": 35, "identities_sha256": "22cba7a970620ff06a32a2f79a506c9b5bbd547e3a49d7bce475038e44fb740d"} + ], + "counters": {"day_string_implementations": 12, "resting_hr_paths": 1, "android_ui_pearson_implementations": 4} +} diff --git a/Tools/parity_ratchet.py b/Tools/parity_ratchet.py new file mode 100644 index 0000000000..c3bbc9416b --- /dev/null +++ b/Tools/parity_ratchet.py @@ -0,0 +1,711 @@ +#!/usr/bin/env python3 +"""Fail-closed governance ratchet for the checked-in parity inventory. + +This first layer deliberately governs metadata only. It prevents the lexical +inventory or its accepted debt from being weakened in the same change that +updates the baseline. Differential runners, corpora, execution coverage and +native orchestration are separate layers and are not dependencies of this +tool. +""" + +from __future__ import annotations + +import argparse +import io +import json +import re +import subprocess +import sys +import tarfile +import tempfile +from contextlib import contextmanager +from datetime import date, datetime +from pathlib import Path +from typing import Iterable + +import issue_ref +import parity_ledger + + +ROOT = Path(__file__).resolve().parent.parent +TWIN_MAP_PATH = "Tools/parity_twin_map.json" +LEDGER_BASELINE_PATH = "Tools/parity_ledger_baseline.json" +DISPOSITIONS_PATH = "Tools/parity_dispositions.json" +class RatchetError(ValueError): + """Raised when governance inputs cannot be interpreted safely.""" + + +def _git(root: Path, arguments: list[str]) -> str: + try: + return subprocess.check_output( + ["git", *arguments], cwd=root, text=True, stderr=subprocess.STDOUT + ).strip() + except (OSError, subprocess.CalledProcessError) as exc: + detail = exc.output.strip() if isinstance(exc, subprocess.CalledProcessError) and exc.output else str(exc) + raise RatchetError(f"git {' '.join(arguments)} failed: {detail}") from exc + + +def resolve_base(root: Path, base: str | None) -> str: + """Resolve the exact requested base, defaulting to current ``origin/main``.""" + return _git(root, ["rev-parse", "--verify", base or "origin/main"]) + + +def _read_current(root: Path, relative: str) -> dict: + path = root / relative + try: + value = json.loads(path.read_text(encoding="utf-8")) + issue_ref.validate_current_issue_fields(value, relative) + except (OSError, json.JSONDecodeError, issue_ref.IssueRefError) as exc: + raise RatchetError(f"cannot read current {relative}: {exc}") from exc + if not isinstance(value, dict): + raise RatchetError(f"{relative}: JSON root must be an object") + return value + + +def _read_current_dispositions(root: Path) -> dict: + path = root / DISPOSITIONS_PATH + if not path.exists(): + return {"schema_version": 1, "dispositions": []} + return _read_current(root, DISPOSITIONS_PATH) + + +def _read_base(root: Path, base: str, relative: str) -> dict | None: + try: + raw = subprocess.check_output( + ["git", "show", f"{base}:{relative}"], + cwd=root, + text=True, + stderr=subprocess.DEVNULL, + ) + except (OSError, subprocess.CalledProcessError) as exc: + try: + listed = subprocess.check_output( + ["git", "ls-tree", "--name-only", base, "--", relative], + cwd=root, + text=True, + stderr=subprocess.DEVNULL, + ).strip() + except (OSError, subprocess.CalledProcessError) as tree_exc: + raise RatchetError( + f"cannot inspect base {base}:{relative}: {tree_exc}" + ) from tree_exc + if not listed: + return None + detail = exc.output.strip() if isinstance(exc, subprocess.CalledProcessError) and exc.output else str(exc) + raise RatchetError(f"cannot read base {base}:{relative}: {detail}") from exc + try: + value = json.loads(raw) + except json.JSONDecodeError as exc: + raise RatchetError(f"{base}:{relative}: invalid JSON: {exc}") from exc + if not isinstance(value, dict): + raise RatchetError(f"{base}:{relative}: JSON root must be an object") + issue_ref.validate_current_issue_fields(value, f"{base}:{relative}") + return value + + +def _array(value: dict, key: str, location: str) -> list: + result = value.get(key) + if not isinstance(result, list): + raise RatchetError(f"{location}: {key} must be an array") + return result + + +def _validate_twin_map(value: dict, location: str) -> None: + if value.get("schema_version") != 3: + raise RatchetError(f"{location}: schema_version must be 3") + expected_keys = {"schema_version", "derivation", "scope", "authority"} + if set(value) != expected_keys: + raise RatchetError(f"{location}: v3 top-level keys must be exact") + if value.get("derivation") != "parity_ledger.build_twin_map/v3": + raise RatchetError(f"{location}: unsupported v3 derivation") + expected_scope = { + "swift_roots": [glob.split("/**", 1)[0] for glob in parity_ledger.SWIFT_GLOBS], + "kotlin_roots": [glob.split("/**", 1)[0] for glob in parity_ledger.KOTLIN_GLOBS], + } + if value.get("scope") != expected_scope: + raise RatchetError(f"{location}: scope must equal the exact derivation roots") + authority = value.get("authority") + required = set(parity_ledger.SEMANTIC_AUTHORITY_SETS) + if not isinstance(authority, dict) or set(authority) != required: + raise RatchetError(f"{location}: authority must contain every semantic set exactly") + for name, item in authority.items(): + if (not isinstance(item, dict) or set(item) != {"count", "sha256"} + or type(item.get("count")) is not int or item["count"] < 0 + or not isinstance(item.get("sha256"), str) + or re.fullmatch(r"[0-9a-f]{64}", item["sha256"]) is None): + raise RatchetError(f"{location}: authority.{name} needs count and lowercase SHA-256") + + +def _validate_dispositions(value: dict, location: str) -> None: + if value.get("schema_version") != 1 or set(value) != {"schema_version", "dispositions"}: + raise RatchetError(f"{location}: typed disposition registry keys must be exact") + dispositions = value.get("dispositions") + if not isinstance(dispositions, list): + raise RatchetError(f"{location}: dispositions must be an array") + seen_identities: set[str] = set() + seen_issues: set[issue_ref.IssueRef] = set() + forbidden = issue_ref.parse_current("bhelm/noop#17") + allowed_kinds = { + "add-unpaired-file", "add-unpaired-function", "add-unpaired-property", + "add-unpaired-constant", + } + for index, item in enumerate(dispositions): + prefix = f"{location}: dispositions[{index}]" + if not isinstance(item, dict): + raise RatchetError(f"{prefix} must be an object") + disposition_type = item.get("type") + common = {"type", "kind", "identity", "identity_sha256", "platform"} + expected = { + "experimental": common | {"issue", "reason", "expires_on"}, + "platform_specific": common | {"rationale"}, + }.get(disposition_type) + if expected is None or set(item) != expected: + missing = "expires_on" if disposition_type == "experimental" and "expires_on" not in item else "keys" + raise RatchetError(f"{prefix} {missing} must be exact for type {disposition_type!r}") + kind, identity = item["kind"], item["identity"] + reason = item["reason"] if disposition_type == "experimental" else item["rationale"] + if (not isinstance(kind, str) or not kind or not isinstance(identity, str) + or not identity or "*" in identity): + raise RatchetError(f"{prefix} needs exact kind and identity without globs") + if kind not in allowed_kinds: + raise RatchetError(f"{prefix} cannot waive shared parity or pair removal: {kind!r}") + platform = item.get("platform") + if platform not in {"swift", "kotlin"} or not identity.startswith(platform + "\0"): + raise RatchetError(f"{prefix} platform must match the exact identity") + if item["identity_sha256"] != parity_ledger._canonical_sha256(identity): + raise RatchetError(f"{prefix} identity hash mismatch") + if not isinstance(reason, str) or len(reason.strip()) < 20: + raise RatchetError(f"{prefix} needs a specific non-generic rationale") + issue = issue_ref.parse_current(item["issue"]) if disposition_type == "experimental" else None + if issue == forbidden: + raise RatchetError(f"{prefix}: umbrella issue bhelm/noop#17 is forbidden") + if disposition_type == "experimental": + try: + datetime.strptime(item["expires_on"], "%Y-%m-%d") + except (TypeError, ValueError) as exc: + raise RatchetError(f"{prefix} expires_on must be YYYY-MM-DD") from exc + if identity in seen_identities or (issue is not None and issue in seen_issues): + raise RatchetError(f"{location}: dispositions require unique exact identities and issues") + seen_identities.add(identity) + if issue is not None: + seen_issues.add(issue) + + +def _validate_baseline(value: dict, location: str) -> None: + if value.get("schema_version") != 3 or set(value) != {"schema_version", "accepted_findings", "counters"}: + raise RatchetError(f"{location}: compact baseline v3 keys must be exact") + groups = _array(value, "accepted_findings", location) + identities: set[tuple[str, str]] = set() + group_keys = {"rule", "scope", "reason", "provenance", "count", "identities_sha256"} + for index, group in enumerate(groups): + if not isinstance(group, dict) or set(group) != group_keys: + raise RatchetError(f"{location}: accepted_findings[{index}] keys must be exact") + for key in ("rule", "scope", "reason", "provenance"): + if not isinstance(group[key], str) or not group[key].strip(): + raise RatchetError(f"{location}: accepted_findings[{index}] needs non-empty {key}") + expected_reason = parity_ledger.BASELINE_REASONS.get(group["rule"]) + expected_provenance = parity_ledger.baseline_group_provenance( + group["rule"], group["scope"] + ) + if group["reason"] != expected_reason or group["provenance"] != expected_provenance: + raise RatchetError( + f"{location}: accepted_findings[{index}] must retain its canonical reviewed reason and provenance" + ) + identity = (group["rule"], group["scope"]) + if identity in identities: + raise RatchetError(f"{location}: duplicate accepted finding group {identity}") + identities.add(identity) + if (type(group["count"]) is not int or group["count"] < 1 + or not isinstance(group["identities_sha256"], str) + or re.fullmatch(r"[0-9a-f]{64}", group["identities_sha256"]) is None): + raise RatchetError(f"{location}: accepted_findings[{index}] needs count and identity SHA-256") + counters = value.get("counters") + if not isinstance(counters, dict): + raise RatchetError(f"{location}: counters must be an object") + for name, count in counters.items(): + if not isinstance(name, str) or type(count) is not int or count < 0: + raise RatchetError(f"{location}: counter {name!r} must be a non-negative integer") + + +def _issues(value: object) -> set[issue_ref.IssueRef]: + found: set[issue_ref.IssueRef] = set() + + def walk(node: object) -> None: + if isinstance(node, dict): + for key, child in node.items(): + if key == "issue": + found.add(issue_ref.parse_current(child)) + walk(child) + elif isinstance(node, list): + for child in node: + walk(child) + + walk(value) + return found + + +def _fetch_issue(issue: issue_ref.IssueRef) -> dict | None: + try: + response = subprocess.run( + ["gh", "api", f"repos/{issue.repo}/issues/{issue.number}"], + check=True, + capture_output=True, + text=True, + ) + except FileNotFoundError as exc: + raise RatchetError("gh is required for online issue validation") from exc + except (OSError, subprocess.CalledProcessError): + return None + try: + payload = json.loads(response.stdout) + except (TypeError, json.JSONDecodeError): + return None + return payload if isinstance(payload, dict) else None + + +def _issue_payload_matches(issue: issue_ref.IssueRef, payload: dict | None) -> bool: + """Validate both issue number and repository; pull requests do not qualify.""" + if payload is None or "pull_request" in payload: + return False + if type(payload.get("number")) is not int or payload["number"] != issue.number: + return False + proofs: list[bool] = [] + if "repository_url" in payload: + proofs.append(payload["repository_url"] == f"https://api.github.com/repos/{issue.repo}") + if "html_url" in payload: + proofs.append(payload["html_url"] == f"https://github.com/{issue.repo}/issues/{issue.number}") + return bool(proofs) and all(proofs) + + +def issue_exists(issue: issue_ref.IssueRef) -> bool: + return _issue_payload_matches(issue, _fetch_issue(issue)) + + +def _exemption_payload_is_bound( + issue: issue_ref.IssueRef, + payload: dict | None, + identity_sha256: str, + base_created_at: str, +) -> bool: + try: + created = datetime.fromisoformat(str(payload["created_at"]).replace("Z", "+00:00")) + base_created = datetime.fromisoformat(base_created_at.replace("Z", "+00:00")) + except (TypeError, KeyError, ValueError): + return False + return ( + _exemption_payload_has_identity(issue, payload, identity_sha256) + and payload.get("state") == "open" + and created > base_created + ) + + +def _exemption_payload_has_identity( + issue: issue_ref.IssueRef, payload: dict | None, identity_sha256: str +) -> bool: + marker = f"parity-governance-identity-sha256: {identity_sha256}" + return ( + _issue_payload_matches(issue, payload) + and isinstance(payload.get("body"), str) + and marker in payload["body"] + ) + + +def exemption_issue_is_bound( + issue: issue_ref.IssueRef, identity_sha256: str, base_created_at: str +) -> bool: + """Require a post-base issue whose body names the exact governed identity hash.""" + return _exemption_payload_is_bound( + issue, _fetch_issue(issue), identity_sha256, base_created_at + ) + + +@contextmanager +def _base_tree(root: Path, base: str): + """Materialize the exact base tree; unavailable/shallow bases fail closed.""" + try: + archive = subprocess.check_output( + ["git", "archive", "--format=tar", base], cwd=root, stderr=subprocess.STDOUT + ) + except (OSError, subprocess.CalledProcessError) as exc: + detail = exc.output.decode(errors="replace").strip() if isinstance(exc, subprocess.CalledProcessError) else str(exc) + raise RatchetError(f"cannot independently scan base {base}: {detail}") from exc + with tempfile.TemporaryDirectory() as directory: + target = Path(directory) + try: + with tarfile.open(fileobj=io.BytesIO(archive), mode="r:") as bundle: + bundle.extractall(target, filter="data") + except (tarfile.TarError, TypeError) as exc: + raise RatchetError(f"cannot materialize base {base}: {exc}") from exc + yield target + + +def _required_v3_exemptions( + base_sets: dict[str, list[str]], + current_sets: dict[str, list[str]], + base_findings: set[str], + current_findings: set[str], +) -> set[tuple[str, str]]: + required: set[tuple[str, str]] = set() + # Explicit singulars rather than name[:-1]: "unpaired_properties" stems to + # "unpaired-propertie", which _validate_dispositions does not accept, so a one-sided + # property could be REQUIRED to carry a disposition that could never be written. Every + # other set survives the naive strip, which is why this went unnoticed: no test had a + # one-sided property until Lift Log added two. + for name, singular in ( + ("unpaired_files", "unpaired-file"), + ("unpaired_functions", "unpaired-function"), + ("unpaired_properties", "unpaired-property"), + ("unpaired_constants", "unpaired-constant"), + ): + for identity in set(current_sets[name]) - set(base_sets[name]): + required.add((f"add-{singular}", identity)) + for name in ("function_pairs", "property_pairs", "constant_pairs"): + for identity in set(base_sets[name]) - set(current_sets[name]): + required.add((f"remove-{name[:-1].replace('_', '-')}", identity)) + for identity in current_findings - base_findings: + required.add(("add-finding", identity)) + return required + + +def _exemption_applies( + key: tuple[str, str], + current_sets: dict[str, list[str]], + current_findings: set[str], + current_counters: dict[str, int], + bootstrap_unpaired_debts: set[str], +) -> bool: + kind, identity = key + add_sets = { + "add-unpaired-file": "unpaired_files", + "add-unpaired-function": "unpaired_functions", + "add-unpaired-property": "unpaired_properties", + "add-unpaired-constant": "unpaired_constants", + } + remove_sets = { + "remove-function-pair": "function_pairs", + "remove-property-pair": "property_pairs", + "remove-constant-pair": "constant_pairs", + } + if kind in add_sets: + return identity in current_sets[add_sets[kind]] + if kind in remove_sets: + return identity not in current_sets[remove_sets[kind]] + if kind == "add-finding": + return identity in current_findings + if kind == "bootstrap-unpaired-function": + return identity in current_sets["unpaired_functions"] and identity in bootstrap_unpaired_debts + if kind == "add-counter": + parts = identity.split("\u0000") + try: + return len(parts) == 3 and current_counters.get(parts[0]) == int(parts[2]) + except ValueError: + return False + return False + + +def compare_metadata( + root: Path, + base: str, + *, + offline: bool, + repair_stale_base: bool = False, + migrate_authority: bool = False, + warnings: list[str] | None = None, +) -> list[str]: + """Compare current governance metadata with the exact requested base.""" + root = root.resolve() + errors: list[str] = [] + warnings = warnings if warnings is not None else [] + current_map = _read_current(root, TWIN_MAP_PATH) + current_baseline = _read_current(root, LEDGER_BASELINE_PATH) + current_registry = _read_current_dispositions(root) + _validate_twin_map(current_map, TWIN_MAP_PATH) + _validate_baseline(current_baseline, LEDGER_BASELINE_PATH) + _validate_dispositions(current_registry, DISPOSITIONS_PATH) + for disposition in current_registry["dispositions"]: + if (disposition["type"] == "experimental" + and date.fromisoformat(disposition["expires_on"]) < date.today()): + errors.append( + f"{DISPOSITIONS_PATH}: experimental disposition expired on {disposition['expires_on']}: {disposition['identity']}" + ) + current_sets = parity_ledger.semantic_authority(root) + current_manifest = parity_ledger.authority_manifest(current_sets) + current_scan_map = parity_ledger.build_compact_twin_map(root) + current_scan_map["exemptions"] = current_registry["dispositions"] + current_scan = parity_ledger.scan(root, current_scan_map) + + old_map = _read_base(root, base, TWIN_MAP_PATH) + old_baseline = _read_base(root, base, LEDGER_BASELINE_PATH) + old_registry = _read_base(root, base, DISPOSITIONS_PATH) + if old_registry is None: + old_registry = {"schema_version": 1, "dispositions": []} + _validate_dispositions(old_registry, f"{base}:{DISPOSITIONS_PATH}") + new_exemptions: list[dict] = [] + active_exemptions: list[dict] = [] + if old_map is not None: + _validate_twin_map(old_map, f"{base}:{TWIN_MAP_PATH}") + with _base_tree(root, base) as base_root: + base_sets = parity_ledger.semantic_authority(base_root) + base_manifest = parity_ledger.authority_manifest(base_sets) + base_scan_map = parity_ledger.build_compact_twin_map(base_root) + base_scan_map["exemptions"] = old_registry["dispositions"] + base_scan = parity_ledger.scan(base_root, base_scan_map) + base_authority_is_stale = old_map["authority"] != base_manifest + if base_authority_is_stale: + repair_mismatches: list[str] = [] + if current_sets != base_sets: + repair_mismatches.append("semantic authority differs from the exact base") + if ({item.identity for item in current_scan.findings} + != {item.identity for item in base_scan.findings}): + repair_mismatches.append("finding identities differ from the exact base") + if current_scan.counters != base_scan.counters: + repair_mismatches.append("counters differ from the exact base") + if current_registry != old_registry: + repair_mismatches.append("typed dispositions differ from the exact base") + if current_map["authority"] != current_manifest: + repair_mismatches.append("current authority is not exactly derived") + if current_baseline != parity_ledger.build_compact_baseline(current_scan): + repair_mismatches.append("current baseline is not exactly derived") + if migrate_authority: + # The base's checked-in authority cannot be reproduced by the current derivation, + # so there is no exact basis to compare against and `--repair-stale-base` cannot + # help: repair exists for a base whose GOVERNED STATE matches, and here it does not. + # + # Migration re-bases the comparison onto a freshly derived base authority. It + # deliberately waives only the reproducibility of the base's stored manifest. It + # waives NO semantic debt: `required` below is computed from the freshly derived + # base and current sets, so every new one-sided declaration still needs its own + # issue-bound disposition, and an undeclared one still fails. + if current_map["authority"] != current_manifest: + errors.append( + f"{TWIN_MAP_PATH}: authority migration requires an exactly derived current " + "authority; refresh the snapshots rather than hand-editing them" + ) + else: + warnings.append( + f"{TWIN_MAP_PATH}: base authority at {base} is not reproducible with the " + "current derivation; migrated onto a freshly derived base. New debt still " + "requires issue-bound dispositions." + ) + elif not repair_stale_base: + errors.append( + f"{TWIN_MAP_PATH}: base authority cannot be reproduced with the current derivation; " + "migration required (see --migrate-authority)" + ) + elif repair_mismatches: + errors.append( + f"{TWIN_MAP_PATH}: stale-base repair rejected because " + + "; ".join(repair_mismatches) + ) + else: + warnings.append( + f"{TWIN_MAP_PATH}: repaired stale metadata already present in the exact base; " + "no current-tree governance delta accepted" + ) + base_findings = {item.identity for item in base_scan.findings} + current_findings = {item.identity for item in current_scan.findings} + required = _required_v3_exemptions( + base_sets, + current_sets, + base_findings, + current_findings, + ) + if current_map["authority"] != current_manifest: + if current_map["authority"] == old_map["authority"]: + warnings.append( + f"{TWIN_MAP_PATH}: debt decreased; checked authority may be cleaned up later" + ) + else: + errors.append( + f"{TWIN_MAP_PATH}: authority matches neither the current tree nor the exact base" + ) + for name, value in current_scan.counters.items(): + old_value = base_scan.counters.get(name, 0) + if value > old_value: + required.add(("add-counter", f"{name}\u0000{old_value}\u0000{value}")) + current_by_key = { + (item["kind"], item["identity"]): item + for item in current_registry["dispositions"] + } + old_by_key = { + (item["kind"], item["identity"]): item + for item in old_registry["dispositions"] + } + inherited: set[tuple[str, str]] = set() + for key, old_item in old_by_key.items(): + applied_at_base = _exemption_applies( + key, base_sets, base_findings, base_scan.counters, + base_scan.bootstrap_unpaired_debts, + ) + applies_now = _exemption_applies( + key, current_sets, current_findings, current_scan.counters, + current_scan.bootstrap_unpaired_debts, + ) + current_item = current_by_key.get(key) + if applied_at_base and applies_now and current_item is None: + errors.append(f"{TWIN_MAP_PATH}: inherited exemption was removed {key[0]} {key[1]}") + elif applied_at_base and applies_now and current_item != old_item: + errors.append(f"{TWIN_MAP_PATH}: inherited exemption changed {key[0]} {key[1]}") + elif applied_at_base and applies_now: + inherited.add(key) + elif current_item is not None and not applies_now: + warnings.append( + f"{DISPOSITIONS_PATH}: obsolete disposition {key[0]} {key[1]}; cleanup is optional" + ) + elif current_item is not None and applies_now: + warnings.append( + f"{DISPOSITIONS_PATH}: obsolete disposition cannot authorize reintroduced debt {key[0]} {key[1]}" + ) + checked = { + key for key in current_by_key + if key not in old_by_key or key in inherited + } + for kind, identity in sorted(required - checked): + errors.append( + f"{TWIN_MAP_PATH}: derived inventory changed without an exact issue-bound authority change: {kind} {identity}" + ) + for kind, identity in sorted(checked - required - inherited - set(old_by_key)): + warnings.append( + f"{DISPOSITIONS_PATH}: obsolete disposition {kind} {identity}; cleanup is optional" + ) + new_exemptions = [ + item for key, item in current_by_key.items() + if key not in old_by_key and key in required + ] + active_exemptions = [ + item for key, item in current_by_key.items() + if key in inherited or key in required + ] + elif old_map is None: + if current_map["authority"] != current_manifest: + errors.append(f"{TWIN_MAP_PATH}: authority does not match independently derived current semantic sets") + dispositions = current_registry["dispositions"] + if dispositions: + errors.append( + f"{DISPOSITIONS_PATH}: bootstrap cannot introduce dispositions; " + "add issue-bound authority in a later reviewed change" + ) + + if old_baseline is not None: + _validate_baseline(old_baseline, f"{base}:{LEDGER_BASELINE_PATH}") + + if not offline: + issues = _issues(current_baseline) | _issues(active_exemptions) + payloads = {issue: _fetch_issue(issue) for issue in sorted(issues)} + for issue, payload in payloads.items(): + if not _issue_payload_matches(issue, payload): + errors.append(f"issue {issue} does not exist or is not accessible") + for disposition in active_exemptions: + if disposition["type"] != "experimental": + continue + issue = issue_ref.parse_current(disposition["issue"]) + if (payloads.get(issue) or {}).get("state") != "open": + errors.append(f"experimental disposition issue {issue} must remain open") + base_created_at = _git(root, ["show", "-s", "--format=%cI", base]) + for exemption in new_exemptions: + if exemption["type"] == "platform_specific": + continue + issue = issue_ref.parse_current(exemption["issue"]) + if not _exemption_payload_is_bound( + issue, payloads.get(issue), exemption["identity_sha256"], base_created_at + ): + errors.append( + f"issue {issue} is not fresh after the base or lacks the exact identity-hash marker" + ) + return errors + + +def repository_consistency_errors( + root: Path, *, warnings: list[str] | None = None +) -> list[str]: + """Reject current-tree regressions while allowing proven debt reductions.""" + root = root.resolve() + warnings = warnings if warnings is not None else [] + twin_map = _read_current(root, TWIN_MAP_PATH) + baseline = _read_current(root, LEDGER_BASELINE_PATH) + registry = _read_current_dispositions(root) + _validate_twin_map(twin_map, TWIN_MAP_PATH) + _validate_baseline(baseline, LEDGER_BASELINE_PATH) + _validate_dispositions(registry, DISPOSITIONS_PATH) + scan_map = parity_ledger.build_compact_twin_map(root) + scan_map["exemptions"] = registry["dispositions"] + result = parity_ledger.scan(root, scan_map) + errors = [finding.output() for finding in result.errors] + current = parity_ledger.build_compact_baseline(result) + checked_groups = { + (item["rule"], item["scope"]): item + for item in baseline["accepted_findings"] + } + current_groups = { + (item["rule"], item["scope"]): item + for item in current["accepted_findings"] + } + for key in sorted(set(checked_groups) | set(current_groups)): + old = checked_groups.get(key) + new = current_groups.get(key) + label = "|".join(key) + if old is None: + errors.append(f"{LEDGER_BASELINE_PATH}: new accepted finding group {label}") + elif new is None or new["count"] < old["count"]: + warnings.append(f"{LEDGER_BASELINE_PATH}: debt decreased in {label}; cleanup is optional") + elif new["count"] > old["count"] or new["identities_sha256"] != old["identities_sha256"]: + errors.append(f"{LEDGER_BASELINE_PATH}: accepted finding group regressed {label}") + for name, value in result.counters.items(): + old_value = baseline["counters"].get(name, 0) + if value > old_value: + errors.append(f"{LEDGER_BASELINE_PATH}: counter {name} increased from {old_value} to {value}") + elif value < old_value: + warnings.append(f"{LEDGER_BASELINE_PATH}: counter {name} decreased from {old_value} to {value}; cleanup is optional") + return errors + + +def _print_errors(errors: Iterable[str]) -> int: + errors = list(errors) + for error in errors: + print(f"ERROR: {error}", file=sys.stderr) + print(f"parity governance ratchet: errors={len(errors)}") + return 1 if errors else 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=ROOT) + parser.add_argument("--base", help="base ref; defaults durably to origin/main") + parser.add_argument("--offline", action="store_true", help="skip GitHub issue existence checks") + parser.add_argument( + "--repair-stale-base", + action="store_true", + help="adopt exactly derived metadata only when governed state is unchanged from an already-stale base", + ) + parser.add_argument( + "--migrate-authority", + action="store_true", + help="re-base onto a freshly derived base authority when the base's stored one cannot be " + "reproduced; new debt still requires issue-bound dispositions", + ) + args = parser.parse_args(argv) + if args.repair_stale_base and args.migrate_authority: + parser.error("--repair-stale-base and --migrate-authority are different remedies; use one") + root = args.root.resolve() + try: + base = resolve_base(root, args.base) + warnings: list[str] = [] + errors = repository_consistency_errors(root, warnings=warnings) + errors.extend(compare_metadata( + root, + base, + offline=args.offline, + repair_stale_base=args.repair_stale_base, + migrate_authority=args.migrate_authority, + warnings=warnings, + )) + for warning in warnings: + print(f"WARNING: {warning}", file=sys.stderr) + return _print_errors(errors) + except (RatchetError, issue_ref.IssueRefError) as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Tools/parity_shards.py b/Tools/parity_shards.py new file mode 100644 index 0000000000..202f6b3e22 --- /dev/null +++ b/Tools/parity_shards.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Ordered operation and shard authority for the portable parity harness.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import Callable + + +JsonObject = dict[str, object] +Evaluator = Callable[[JsonObject], object] + + +class RegistryError(ValueError): + """Raised when the registry or an operation contract is invalid.""" + + +@dataclass(frozen=True) +class OperationSpec: + key: str + argument_keys: tuple[str, ...] + core: Evaluator + reference: Evaluator + mutant: Callable[[object], object] + + +@dataclass(frozen=True) +class ShardSpec: + name: str + operations: tuple[OperationSpec, ...] + + +def _clamp_arguments(args: JsonObject) -> tuple[int | float, int | float, int | float]: + if set(args) != {"lower", "upper", "value"}: + raise RegistryError("Portable.clamp/3 args must contain exactly lower, upper, value") + values = tuple(args[key] for key in ("value", "lower", "upper")) + if any(isinstance(value, bool) or not isinstance(value, (int, float)) for value in values): + raise RegistryError("Portable.clamp/3 args must be finite numbers") + if any(not math.isfinite(value) for value in values): + raise RegistryError("Portable.clamp/3 args must be finite numbers") + value, lower, upper = values + if lower > upper: + raise RegistryError("Portable.clamp/3 lower must not exceed upper") + return value, lower, upper + + +def _core_clamp(args: JsonObject) -> object: + value, lower, upper = _clamp_arguments(args) + return value if lower <= value <= upper else max(lower, min(upper, value)) + + +def _reference_clamp(args: JsonObject) -> object: + value, lower, upper = _clamp_arguments(args) + if value < lower: + return lower + if value > upper: + return upper + return value + + +def _off_by_one(value: object) -> object: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise RegistryError("representative mutant requires a numeric result") + return value + 1 + + +REGISTRY = ( + ShardSpec( + "core-reference", + ( + OperationSpec( + "Portable.clamp/3", + ("lower", "upper", "value"), + _core_clamp, + _reference_clamp, + _off_by_one, + ), + ), + ), +) + + +def validate_registry(registry: tuple[ShardSpec, ...]) -> None: + if not registry: + raise RegistryError("registry must contain at least one shard") + shard_names = [shard.name for shard in registry] + if shard_names != sorted(shard_names) or len(shard_names) != len(set(shard_names)): + raise RegistryError("shards must have unique names in canonical order") + seen: set[str] = set() + for shard in registry: + if not shard.name or not shard.operations: + raise RegistryError("every shard must have a name and at least one operation") + keys = [operation.key for operation in shard.operations] + if keys != sorted(keys): + raise RegistryError(f"shard {shard.name} operations must be in canonical order") + for operation in shard.operations: + if not operation.key or operation.key in seen: + raise RegistryError(f"duplicate operation {operation.key!r}") + if operation.argument_keys != tuple(sorted(operation.argument_keys)): + raise RegistryError(f"operation {operation.key} argument keys must be canonical") + seen.add(operation.key) + + +validate_registry(REGISTRY) + + +def operation_index() -> dict[str, tuple[ShardSpec, OperationSpec]]: + return { + operation.key: (shard, operation) + for shard in REGISTRY + for operation in shard.operations + } diff --git a/Tools/parity_twin_map.json b/Tools/parity_twin_map.json new file mode 100644 index 0000000000..e63b734522 --- /dev/null +++ b/Tools/parity_twin_map.json @@ -0,0 +1,34 @@ +{ + "schema_version": 3, + "derivation": "parity_ledger.build_twin_map/v3", + "scope": { + "swift_roots": [ + "Packages/StrandAnalytics/Sources", + "Packages/StrandImport/Sources", + "Packages/WhoopStore/Sources", + "Packages/WhoopProtocol/Sources", + "Packages/OuraProtocol/Sources" + ], + "kotlin_roots": [ + "android/app/src/main/java/com/noop/analytics", + "android/app/src/main/java/com/noop/ingest", + "android/app/src/main/java/com/noop/data", + "android/app/src/main/java/com/noop/protocol", + "android/app/src/main/java/com/noop/oura" + ] + }, + "authority": { + "files": {"count": 502, "sha256": "322bb433fab56fef5ec926d7847faef78e4c64ea409d161184baaf483c8eec95"}, + "functions": {"count": 4479, "sha256": "bdb69394501d448ac150e19fa571eb18999e54ee7312d4c5517367d35926a419"}, + "properties": {"count": 463, "sha256": "7d80508acfd6418a9f1dc099d65a6890e9b663fb9b056ac2e939b0e0e42d488e"}, + "constants": {"count": 1957, "sha256": "22820a60a99ee0bf0e9dbaf4f56e0416a445c552d8e05479c8cb09b47cf397a4"}, + "file_pairs": {"count": 74, "sha256": "1023813dc72dbe4122281d5bae27ac2b7a657dae5904cb5fd603032928199f94"}, + "function_pairs": {"count": 189, "sha256": "11df8bf87b16b535aeddd0c00471f87431236fd0b059484c07f6d5939ace9732"}, + "property_pairs": {"count": 149, "sha256": "8a62616eca6270e3d8899c47cfe8ae93691f16846ed1959e19a762827b6ec185"}, + "constant_pairs": {"count": 681, "sha256": "3484c8096ac8d2832e75f18900c052a19bfb7d92cbc2fa7fdb6137d8fa46ebf6"}, + "unpaired_files": {"count": 376, "sha256": "654d951b8137dbb2266b83734fa1e3f1cb1648f15c1aab3d015cdef7e79deb75"}, + "unpaired_functions": {"count": 4113, "sha256": "9455f235143adef089d343d45a1b43e10445953b866afb351dfa1ab9b27b4d5b"}, + "unpaired_properties": {"count": 165, "sha256": "fe609dd25384cda6de1616d36753fed68431b1a1f9a73b6fd664c7ff35089955"}, + "unpaired_constants": {"count": 595, "sha256": "f065b8e34db78569a84449444539956cb48df105c9b66274398422cf8c520bc7"} + } +} diff --git a/Tools/test_appchangelog_gen.py b/Tools/test_appchangelog_gen.py index 48fc409b6d..25880c8575 100644 --- a/Tools/test_appchangelog_gen.py +++ b/Tools/test_appchangelog_gen.py @@ -164,6 +164,32 @@ def test_focus_locales_match_the_resource_dirs_on_disk(self): self.assertTrue((ROOT / "android/app/src/main/res" / d / "strings.xml").is_file(), f"{loc} -> {d}/strings.xml is missing") + def test_locale_dirs_match_the_res_directories(self): + """And the OTHER direction, which is the one that has actually gone wrong twice. + + The test above only asserts that every entry points at a real directory, so ADDING a locale + breaks nothing: `title_locales` for it is accepted, silently dropped, and those readers get the + English card. Polish shipped in #1250 and was noticed at v10.1.0; Russian shipped in #1872 and + would have repeated it in the release that introduced Russian. + + Only language directories count. Android config qualifiers (`values-night`, `values-w600dp`) + live beside them and are not locales, so the match is on a language tag shape rather than on + "anything after the dash". + """ + res = ROOT / "android/app/src/main/res" + on_disk = { + d.name for d in res.iterdir() + if d.is_dir() and re.fullmatch(r"values-[a-z]{2}(-r[A-Z]{2})?", d.name) + and (d / "strings.xml").is_file() + } + declared = {d for loc, d in acg.LOCALE_DIRS.items() if loc != "en"} + missing = sorted(on_disk - declared) + self.assertEqual( + [], missing, + f"locale dir(s) with no LOCALE_DIRS entry: {missing}. A `title_locales` entry for them " + f"would be accepted and dropped, and those readers would see the English card.", + ) + def test_english_source_is_the_values_dir(self): self.assertEqual("values", acg.LOCALE_DIRS["en"]) diff --git a/Tools/test_check_source_references.py b/Tools/test_check_source_references.py new file mode 100644 index 0000000000..43e59e7314 --- /dev/null +++ b/Tools/test_check_source_references.py @@ -0,0 +1,143 @@ +"""Tests for protocol-document source reference validation.""" + +from __future__ import annotations + +import contextlib +import importlib.util +import io +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +CHECKER_PATH = REPO_ROOT / "docs/protocol-examples/check_source_references.py" +SPEC = importlib.util.spec_from_file_location("check_source_references", CHECKER_PATH) +if SPEC is None or SPEC.loader is None: + raise RuntimeError(f"cannot load source-reference checker from {CHECKER_PATH}") +check_source_references = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(check_source_references) + + +class SourceReferenceTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory() + self.root = Path(self.temp.name) + self.docs = self.root / "docs" + self.sources = self.root / "Sources" + self.docs.mkdir() + self.sources.mkdir() + + def tearDown(self) -> None: + self.temp.cleanup() + + def write(self, relative: str, text: str) -> Path: + path = self.root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + def run_doc(self, doc: Path) -> tuple[int, list[str]]: + output = io.StringIO() + with contextlib.redirect_stdout(output): + code = check_source_references.main([str(doc)]) + return code, output.getvalue().splitlines() + + def test_valid_symbol_description_and_backtick_references(self) -> None: + self.write("Sources/Client.swift", "func decode(value: Int) {}\n") + self.write("Sources/support.py", "HELP_TEXT = 'protocol support'\n") + self.write("Sources/config.json", "{}\n") + doc = self.write( + "docs/PROTOCOL.md", + """[Client.decode(value:)](../Sources/Client.swift) +[implementation source](../Sources/support.py) +`../Sources/config.json` +""", + ) + + code, lines = self.run_doc(doc) + + self.assertEqual(0, code) + self.assertEqual(["checked 3 references, 3 files, 0 failures"], lines) + + def test_missing_symbol_reports_line_and_failure(self) -> None: + self.write("Sources/Client.swift", "func available() {}\n") + doc = self.write( + "docs/PROTOCOL.md", + "Intro\n[Client.missing()](../Sources/Client.swift)\n", + ) + + code, lines = self.run_doc(doc) + + self.assertEqual(1, code) + self.assertEqual(2, len(lines)) + self.assertEqual( + f"FAIL {doc}:2 ../Sources/Client.swift [missing] symbol not found", + lines[0], + ) + self.assertEqual("checked 1 references, 1 files, 1 failures", lines[1]) + + def test_missing_link_target_reports_file_failure(self) -> None: + doc = self.write( + "docs/PROTOCOL.md", + "[missing implementation](../Sources/Missing.swift)\n", + ) + + code, lines = self.run_doc(doc) + + self.assertEqual(1, code) + self.assertEqual( + f"FAIL {doc}:1 ../Sources/Missing.swift file does not exist", + lines[0], + ) + self.assertEqual("checked 1 references, 1 files, 1 failures", lines[1]) + + def test_missing_backtick_target_reports_file_failure(self) -> None: + doc = self.write("docs/PROTOCOL.md", "Use `../Sources/missing.py` for details.\n") + + code, lines = self.run_doc(doc) + + self.assertEqual(1, code) + self.assertEqual( + f"FAIL {doc}:1 ../Sources/missing.py file does not exist", + lines[0], + ) + self.assertEqual("checked 1 references, 1 files, 1 failures", lines[1]) + + def test_qualified_signature_extracts_final_symbol_and_matches_whole_words(self) -> None: + self.assertEqual( + "method", + check_source_references.symbol_from_text("A.B.method(x:y:)"), + ) + self.write("Sources/Client.swift", "func decodeAll() {}\n") + doc = self.write( + "docs/PROTOCOL.md", + "[A.B.decode(x:y:)](../Sources/Client.swift)\n", + ) + + code, lines = self.run_doc(doc) + + self.assertEqual(1, code) + self.assertEqual( + f"FAIL {doc}:1 ../Sources/Client.swift [decode] symbol not found", + lines[0], + ) + self.assertEqual("checked 1 references, 1 files, 1 failures", lines[1]) + + def test_repository_protocol_document_passes_smoke_check(self) -> None: + result = subprocess.run( + [sys.executable, str(CHECKER_PATH), str(REPO_ROOT / "docs/PROTOCOL_IMPLEMENTATION.md")], + cwd=REPO_ROOT, + check=False, + capture_output=True, + text=True, + ) + + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + self.assertIn("0 failures", result.stdout.splitlines()[-1]) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/test_home_i18n.py b/Tools/test_home_i18n.py index ce2da56f11..c9570d4e87 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", "stepsAverage30", "today.dashboardCards", "yrs", "kcal", "", } @@ -252,7 +253,7 @@ def test_android_home_score_labels_and_explain_copy_are_resource_backed(self) -> def test_android_home_score_labels_and_empty_states_fit_localized_copy(self) -> None: source = (ROOT / "android/app/src/main/java/com/noop/ui/TodayScreen.kt").read_text(encoding="utf-8") self.assertIn("text = domainLabel.uppercase()", source) - self.assertIn(".padding(start = Metrics.space2, end = Metrics.space18)", source) + self.assertIn(".padding(horizontal = Metrics.space16)", source) self.assertIn("minScale = 0.7f", source) self.assertIn("private fun RingNoData(diameter: Dp)", source) self.assertIn("maxLines = 2", source) @@ -361,14 +362,21 @@ def test_android_home_resources_cover_focus_locales(self) -> None: for relative in ANDROID_HOME_FILES: used.update(_android_resource_names(ROOT / relative)) - paths = {"en": ROOT / "android/app/src/main/res/values/strings.xml"} + paths = {"en": ROOT / "android/app/src/main/res/values"} paths.update({ - lang: ROOT / f"android/app/src/main/res/{directory}/strings.xml" + lang: ROOT / f"android/app/src/main/res/{directory}" for lang, directory in audit.ANDROID_LOCALE_DIRS.items() }) missing: list[str] = [] - for lang, path in paths.items(): - names = {node.attrib["name"] for node in ET.parse(path).getroot() if node.tag in {"string", "plurals"}} + for lang, directory in paths.items(): + # Android merges every values XML file, including feature-specific resources. + # Inspect each locale independently so English fallback cannot hide missing copy. + names = { + node.attrib["name"] + for path in sorted(directory.glob("*.xml")) + for node in ET.parse(path).getroot() + if node.tag in {"string", "plurals"} + } missing.extend(f"{lang}: {name}" for name in sorted(used - names)) self.assertEqual([], missing, "Missing Android Home resources:\n" + "\n".join(missing)) @@ -505,7 +513,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 +531,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/Tools/test_i18n_audit.py b/Tools/test_i18n_audit.py index 2cbf8ef24a..5faeb39166 100644 --- a/Tools/test_i18n_audit.py +++ b/Tools/test_i18n_audit.py @@ -500,3 +500,105 @@ def test_flat_string_unit_still_counted(self): def test_should_translate_false_is_skipped(self): cat = {"strings": {"NOOP": {"shouldTranslate": False, "localizations": {}}}} self.assertIn("de missing=0", self._summary(cat)) + + +class SwiftReturnedCopyTests(unittest.TestCase): + """Copy a screen RETURNS as a String, not copy sitting inside a `Text(...)` argument. + + The scanner used to look only inside localized SwiftUI calls, so a literal returned from a + `var label: String { ... }` was invisible. That is not a harmless miss: a bare literal returned that + way reaches `Text` already resolved and renders in English on every device forever. It shipped once + that way, a Workouts Current/Archived tab pair, while the gate passed, having flagged only the + accessibility key beside it. + """ + + def found(self, text: str) -> list[str]: + return [lit for _, lit in ia.swift_returned_copy_literals(text)] + + def test_ternary_form_is_seen(self): + src = 'var label: String { self == .a ? "Alpha" : "Beta" }' + self.assertEqual(self.found(src), ["Alpha", "Beta"]) + + def test_switch_arm_is_seen(self): + # The shape this rule MUST cover, and the one its first draft missed: a `switch` opens a second + # brace level, so keying on brace depth silently skipped every case arm while the ternary above + # still passed. Switch is the commoner spelling in this repository. + src = ( + 'var label: String {\n' + ' switch self {\n' + ' case .a: return "Alpha"\n' + ' case .b: return "Beta"\n' + ' }\n' + '}' + ) + self.assertEqual(self.found(src), ["Alpha", "Beta"]) + + def test_implicit_return_switch_arm_is_seen(self): + src = 'var title: String {\n switch self {\n case .a: "Alpha"\n }\n}' + self.assertEqual(self.found(src), ["Alpha"]) + + def test_string_localized_is_left_to_the_normal_scan(self): + # The sanctioned spelling for a value that has to be a String. Flagging it would punish the + # convention this rule exists to protect. + src = 'var label: String {\n switch self {\n case .a: return String(localized: "Alpha")\n }\n}' + self.assertEqual(self.found(src), []) + + def test_argument_labels_are_not_mistaken_for_case_arms(self): + # `joined(separator: ", ")` ends in a colon exactly like `case .a:`, so a bare "ends with a + # colon" test reported the separator as untranslated UI. + src = 'var label: String {\n let p = names.joined(separator: ", ")\n return p\n}' + self.assertEqual(self.found(src), []) + + def test_non_copy_property_names_are_ignored(self): + # Only names that ARE copy. A `var id: String` or a `var sportKey: String` returns an + # identifier, and sweeping those is what produced 71 findings in the first draft. + for src in ( + 'var id: String { "raw-token" }', + 'var sportKey: String { return "running" }', + ): + self.assertEqual(self.found(src), [], src) + + def test_nested_closure_literal_is_not_returned_copy(self): + src = ( + 'var label: String {\n' + ' let joined = items.map { $0.replacingOccurrences(of: "x", with: "y") }\n' + ' return joined.first ?? ""\n' + '}' + ) + self.assertNotIn("x", self.found(src)) + + +class SwiftLocalizedStringScanning(unittest.TestCase): + """`String(localized:)` copy must reach the catalog-membership check. + + The scanner keys off SWIFT_CALL_START_PATTERN, which listed the SwiftUI views and modifiers that + localize but not `String(localized:)`. So the sanctioned spelling for copy that has to be a `String` + was the one spelling nothing checked a key for. `swift_returned_copy_literals` deliberately skips it + and said so in a comment, delegating to "the normal scan" that in fact never looked, which is how the + hole stayed invisible: 242 such strings resolve to no catalog entry and render English in every + locale, the app's legal terms in `Strand/App/Terms.swift` among them. + """ + + def found(self, text: str) -> list[str]: + return [lit for _, lit in ia.swift_string_literals(text)] + + def test_localized_string_is_seen(self): + self.assertEqual(self.found('let x = String(localized: "Alpha")'), ["Alpha"]) + + def test_interpolated_localized_string_is_seen(self): + # The shape that shipped unlocalized: a tooltip built as `String(localized: "1m \(v)")`. + self.assertEqual(self.found('let x = String(localized: "1m \\(v)")'), ["1m \\(v)"]) + + def test_string_format_is_not_copy(self): + # `String(format:)` carries a format spec, not user-facing copy. + self.assertEqual(self.found('let x = String(format: "%.1f", v)'), []) + + def test_string_describing_is_not_copy(self): + self.assertEqual(self.found('let x = String(describing: "raw")'), []) + + def test_spacing_variants_are_seen(self): + self.assertEqual(self.found('let x = String( localized: "Alpha")'), ["Alpha"]) + + def test_localized_alongside_a_view_literal(self): + src = 'Text("Shown")\nlet x = String(localized: "Also shown")' + self.assertEqual(self.found(src), ["Shown", "Also shown"]) diff --git a/Tools/test_steps_i18n.py b/Tools/test_steps_i18n.py new file mode 100644 index 0000000000..aede3f0fec --- /dev/null +++ b/Tools/test_steps_i18n.py @@ -0,0 +1,107 @@ +"""Regression coverage for every shipped locale of the steps feature.""" +import json +import re +import unittest +import xml.etree.ElementTree as ET +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +RES = ROOT / "android/app/src/main/res" +IOS_KEYS = [ + "3 weeks", + "sparse, widened to %@", + "30-day step average", + "Rolling average over the last 30 days", + "%lld of 30 days", + "Historical trend", + "bar", + "bars", + "week of %@", + "2W", + "3W", + "%lld daily %@ · %@", + "%lld weekly %@ · average per observed day · %@", + "%lld monthly %@ · average per observed day · %@", + "%@ steps", + "%@ average steps per observed day", + "Steps chart, no data", + "Steps chart, %lld daily %@, latest %lld steps, %@", + "Steps chart, %lld weekly %@, latest %lld average steps per observed day, %@", + "Steps chart, %lld monthly %@, latest %lld average steps per observed day, %@", + "1 observed day", + "%lld observed days", + "1 week · averages per observed day", + "%lld weeks · averages per observed day", + "1 month · averages per observed day", + "%lld months · averages per observed day" +] + +def unique_object(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError(f"Duplicate JSON key: {key}") + result[key] = value + return result + +def signature(value): + result = {} + next_index = 1 + for match in re.finditer(r"%(?:(\d+)\$)?(lld|[sd@])", value): + index, kind = match.groups() + index = int(index) if index else next_index + next_index += 1 + if index in result and result[index] != kind: + raise ValueError(f"Inconsistent placeholder {index}") + result[index] = kind + return result + +class StepsTranslationsTests(unittest.TestCase): + def test_android_all_shipped_locales(self): + source = ET.parse(RES / "values/steps_view.xml").getroot() + keys = {entry.attrib["name"]: entry for entry in source} + directories = [RES / "values", *sorted(RES.glob("values-*"))] + for directory in directories: + if directory != RES / "values" and not (directory / "strings.xml").exists(): + continue + with self.subTest(locale=directory.name): + entries = {} + for path in directory.glob("*.xml"): + for entry in ET.parse(path).getroot(): + name = entry.get("name") + if name not in keys: + continue + self.assertNotIn(name, entries, f"Duplicate {name} in {directory}") + entries[name] = entry + self.assertEqual(set(entries), set(keys)) + for name, original in keys.items(): + translated = entries[name] + self.assertEqual(original.tag, translated.tag) + if original.tag == "string-array": + self.assertEqual(len(original), len(translated)) + self.assertTrue(all(item.text for item in translated)) + else: + self.assertTrue(translated.text) + self.assertEqual(signature(original.text), signature(translated.text), name) + + def test_ios_all_shipped_locales(self): + catalog = json.loads( + (ROOT / "Strand/Resources/Localizable.xcstrings").read_text(), + object_pairs_hook=unique_object, + ) + locales = set().union(*(set(entry.get("localizations", {})) + for entry in catalog["strings"].values())) + locales.discard(catalog["sourceLanguage"]) + for key in IOS_KEYS: + entry = catalog["strings"][key] + self.assertTrue(locales <= set(entry["localizations"]), key) + for locale in locales: + with self.subTest(key=key, locale=locale): + unit = entry["localizations"][locale]["stringUnit"] + self.assertEqual(unit["state"], "translated") + self.assertTrue(unit["value"]) + self.assertEqual(signature(key), signature(unit["value"])) + +if __name__ == "__main__": + unittest.main() + diff --git a/Tools/tests/__init__.py b/Tools/tests/__init__.py new file mode 100644 index 0000000000..6089dd1376 --- /dev/null +++ b/Tools/tests/__init__.py @@ -0,0 +1 @@ +"""Python tests for repository tooling.""" diff --git a/Tools/tests/test_parity_arity.py b/Tools/tests/test_parity_arity.py new file mode 100644 index 0000000000..c1605987b1 --- /dev/null +++ b/Tools/tests/test_parity_arity.py @@ -0,0 +1,137 @@ +"""An argument the arity walk cannot parse must not make the whole call disappear.""" + +from __future__ import annotations + +import re +import sys +import unittest +from pathlib import Path + + +TOOLS = Path(__file__).resolve().parents[1] +REPOSITORY = TOOLS.parent +sys.path.insert(0, str(TOOLS)) + +import parity_ledger # noqa: E402 + + +def arity_of(source: str) -> int | None: + """Arity of the first call in `source`, as `_call_sites` would compute it.""" + return parity_ledger._arity(source, source.index("(")) + + +class HalfOpenRangeTests(unittest.TestCase): + """Why this exists: `_arity` returns None when it cannot balance the brackets, and every caller + responds with `if arity is None: continue`. An unparseable argument therefore does not degrade + the callsite, it ERASES it -- the scan reports a declaration nobody calls. + + Swift's half-open range operator ends in a `<` whose next character starts the upper bound, so it + presents exactly like `Array`. The walk pushed a bracket nothing ever closed, ran to the end + of the file and returned None. `Interpreter.hexString/1` is called once, on the line after its own + declaration, passing `frame[max(0, off)..(), key)"), 2) + + def test_nested_generic_argument_still_balances(self): + self.assertEqual(arity_of("store(Dictionary>(), key)"), 2) + + def test_less_than_comparison_is_not_an_opening_bracket(self): + self.assertEqual(arity_of("assert(a < b)"), 1) + + def test_less_than_or_equal_comparison_is_not_an_opening_bracket(self): + self.assertEqual(arity_of("assert(a <= b, message)"), 2) + + def test_empty_argument_list(self): + self.assertEqual(arity_of("reset()"), 0) + + +class OperatorAngleBracketTests(unittest.TestCase): + """Why this exists: `<` is the only genuinely ambiguous character in the walk. A generic argument + list needs it treated as a bracket; `a << b` and `a> 8)"), 1) + + def test_comparison_without_surrounding_space(self): + self.assertEqual(arity_of("assert(i(), flags << 2)"), 2) + + def test_retry_only_runs_when_the_strict_walk_fails(self): + """The retry is lossy for a generic carrying a comma, because demoting the angle brackets + exposes that comma as a separator. This is tolerable ONLY because a call that parses + strictly never reaches the retry. Pin that ordering, since losing it would silently + re-arity every generic call in the repository.""" + source = "store(Dictionary(), key)" + self.assertEqual(arity_of(source), 2) + self.assertEqual( + parity_ledger._arity(source, source.index("("), angles_are_brackets=False), + 3, + "retry is expected to over-count here; the strict walk must therefore win", + ) + + def test_half_open_range_is_kept_off_the_lossy_retry(self): + """`..<` is exempted in the strict walk rather than left to the retry, so a call that pairs + it with a comma-carrying generic still parses accurately.""" + self.assertEqual(arity_of("f(Dictionary(), x.. set[str]: + current = dict(self.EMPTY) + identity = "swift" + chr(0) + "X.swift::a/1#1" + for name in ("unpaired_files", "unpaired_functions", + "unpaired_properties", "unpaired_constants"): + current[name] = [identity] + required = parity_ratchet._required_v3_exemptions( + self.EMPTY, current, set(), set() + ) + return {kind for kind, _ in required} + + def test_every_required_add_kind_is_declarable(self) -> None: + identity = "swift" + chr(0) + "X.swift::a/1#1" + for kind in sorted(self._required_kinds()): + doc = { + "schema_version": 1, + "dispositions": [{ + "type": "platform_specific", + "kind": kind, + "identity": identity, + "identity_sha256": parity_ledger._canonical_sha256(identity), + "platform": "swift", + "rationale": "Declared one-sided on purpose for this vocabulary test.", + }], + } + with self.subTest(kind=kind): + parity_ratchet._validate_dispositions(doc, "test") + + def test_property_kind_is_singular(self) -> None: + self.assertIn("add-unpaired-property", self._required_kinds()) + self.assertNotIn("add-unpaired-propertie", self._required_kinds()) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/tests/test_parity_governance_acceptance.py b/Tools/tests/test_parity_governance_acceptance.py new file mode 100644 index 0000000000..b449d45793 --- /dev/null +++ b/Tools/tests/test_parity_governance_acceptance.py @@ -0,0 +1,1135 @@ +"""Acceptance tests for the product-free parity governance foundation.""" + +from __future__ import annotations + +import io +import json +import subprocess +import sys +import tempfile +import unittest +from fnmatch import fnmatchcase +from pathlib import Path +from unittest import mock + + +TOOLS = Path(__file__).resolve().parents[1] +REPOSITORY = TOOLS.parent +sys.path.insert(0, str(TOOLS)) + +import issue_ref # noqa: E402 +import parity_ledger # noqa: E402 +import parity_ratchet # noqa: E402 + + +class IssueReferenceTests(unittest.TestCase): + def test_current_metadata_requires_repository_qualified_references(self) -> None: + reference = issue_ref.parse_current("bhelm/noop#17") + self.assertEqual(("bhelm/noop", 17), (reference.repo, reference.number)) + for invalid in (17, True, "#17", " bhelm/noop#17", "bhelm/noop#0"): + with self.subTest(invalid=invalid), self.assertRaises(issue_ref.IssueRefError): + issue_ref.parse_current(invalid) + + def test_repository_is_part_of_issue_identity(self) -> None: + self.assertNotEqual( + issue_ref.parse_current("bhelm/noop#17"), + issue_ref.parse_current("other/noop#17"), + ) + + +class RepositoryBaselineTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + # These acceptance tests all inspect one immutable checkout. Build the + # repository evidence once for the class instead of paying for the same + # full-tree parse in every assertion. This is test-local state, not a + # persistent production cache; normal scanner calls still read fresh. + cls.compact_map = parity_ledger._load_json(TOOLS / "parity_twin_map.json", {}) + cls.baseline = parity_ledger._load_json(TOOLS / "parity_ledger_baseline.json", {}) + cls.snapshot = parity_ledger._SourceSnapshot() + cls.inventory = parity_ledger._inventory(REPOSITORY, cls.snapshot) + ( + cls.swift_files, + cls.kotlin_files, + cls.swift_functions, + cls.kotlin_functions, + _swift_properties, + _kotlin_properties, + _swift_constants, + _kotlin_constants, + ) = cls.inventory + _reference_files, declarations = parity_ledger._reference_declarations( + REPOSITORY, + cls.inventory[2:6], + cls.snapshot, + ) + cls.repo_swift_functions = [ + item for item in declarations + if item.language == "swift" and item.kind == "function" + ] + cls.repo_kotlin_functions = [ + item for item in declarations + if item.language == "kotlin" and item.kind == "function" + ] + cls.references = ( + parity_ledger.parse_twin_references( + REPOSITORY, cls.swift_files, "swift", cls.swift_functions, cls.snapshot + ) + + parity_ledger.parse_twin_references( + REPOSITORY, cls.kotlin_files, "kotlin", cls.kotlin_functions, cls.snapshot + ) + ) + cls.resolutions = parity_ledger.attached_function_resolutions( + cls.references, cls.repo_swift_functions, cls.repo_kotlin_functions + ) + cls.expanded_map = parity_ledger.build_twin_map( + REPOSITORY, cls.inventory, cls.snapshot + ) + cls.authority = parity_ledger.authority_manifest( + parity_ledger.semantic_authority( + REPOSITORY, + expanded=cls.expanded_map, + inventory=cls.inventory, + snapshot=cls.snapshot, + ) + ) + cls.result = parity_ledger.scan(REPOSITORY, cls.compact_map) + + def test_repository_has_one_typed_manual_disposition_registry(self) -> None: + registry = parity_ledger._load_json(TOOLS / "parity_dispositions.json", {}) + parity_ratchet._validate_dispositions(registry, "fixture") + self.assertEqual(1, registry["schema_version"]) + self.assertNotIn("exemptions", parity_ledger._load_json(TOOLS / "parity_twin_map.json", {})) + + def test_core_tools_filter_covers_every_governance_tool_path(self) -> None: + core = (REPOSITORY / ".github/workflows/tools-python.yml").read_text( + encoding="utf-8" + ) + governance = (REPOSITORY / ".github/workflows/parity-governance.yml").read_text( + encoding="utf-8" + ) + self.assertIn("pull_request:\n branches: [main]\n paths:", core) + core_paths = [ + line.strip()[3:-1] + for line in core.splitlines() + if line.startswith(" - '") + ] + self.assertEqual( + ["Tools/**", ".github/workflows/tools-python.yml"] * 2, + core_paths, + ) + self.assertNotIn("unittest discover -s tests", core) + self.assertIn("pull_request:\n branches: [main]\n paths:", governance) + governance_paths = [ + line.strip()[3:-1] + for line in governance.splitlines() + if line.startswith(" - '") + ] + self.assertEqual( + [ + "Tools/issue_ref.py", + "Tools/parity_*.py", + "Tools/parity_*.json", + "Tools/parity_case_specs/**", + "Tools/tests/test_parity_*.py", + "Tools/tests/test_rr_legacy_preservation_contract.py", + ".github/workflows/parity-governance.yml", + ] * 2, + governance_paths, + ) + self.assertIn("Tools/**", core_paths) + self.assertTrue( + all( + path.startswith("Tools/") + for path in governance_paths + if not path.startswith(".github/") + ) + ) + self.assertNotIn("'Packages/**/*.swift'", governance) + self.assertNotIn("'android/**/*.kt'", governance) + self.assertNotIn("Tools/tests/**", governance) + self.assertNotIn("test_german_today_localization", governance) + self.assertIn("tests.test_parity_ledger", governance) + self.assertIn("tests.test_parity_governance_acceptance", governance) + self.assertIn("tests.test_rr_legacy_preservation_contract", governance) + pull_request_paths = governance_paths[:7] + self.assertFalse(any( + fnmatchcase("Tools/tests/test_german_today_localization.py", pattern) + for pattern in pull_request_paths + )) + self.assertTrue(any( + fnmatchcase("Tools/tests/test_parity_ledger.py", pattern) + for pattern in pull_request_paths + )) + self.assertTrue(any( + fnmatchcase("Tools/tests/test_rr_legacy_preservation_contract.py", pattern) + for pattern in pull_request_paths + )) + + def test_checked_in_inventory_and_baseline_match_current_sources(self) -> None: + self.assertEqual([], self.result.errors) + self.assertEqual([], parity_ledger.compact_baseline_drift(self.result, self.baseline)) + self.assertEqual(self.result.counters, self.baseline["counters"]) + + def test_checked_metadata_is_compact_v3_and_expands_losslessly(self) -> None: + self.assertEqual(3, self.compact_map["schema_version"]) + self.assertEqual(self.compact_map["authority"], self.authority) + self.assertNotIn("unpaired_functions", self.compact_map) + self.assertNotIn("constant_pairs", self.compact_map) + + self.assertEqual(3, self.baseline["schema_version"]) + self.assertNotIn("findings", self.baseline) + self.assertTrue(self.baseline["accepted_findings"]) + for group in self.baseline["accepted_findings"]: + self.assertTrue(group["reason"].strip()) + self.assertTrue(group["provenance"].strip()) + self.assertGreater(group["count"], 0) + self.assertRegex(group["identities_sha256"], r"^[0-9a-f]{64}$") + + def test_v3_authority_schema_is_exact_and_canonically_ordered(self) -> None: + compact = self.compact_map + self.assertEqual( + list(parity_ledger.SEMANTIC_AUTHORITY_SETS), list(compact["authority"]) + ) + parity_ratchet._validate_twin_map(compact, "fixture") + + malformed = json.loads(json.dumps(compact)) + malformed["authority"]["unknown"] = {"count": 0, "sha256": "0" * 64} + with self.assertRaisesRegex(parity_ratchet.RatchetError, "every semantic set exactly"): + parity_ratchet._validate_twin_map(malformed, "fixture") + + unknown = json.loads(json.dumps(compact)) + unknown["ignored_override"] = True + with self.assertRaisesRegex(parity_ratchet.RatchetError, "top-level keys"): + parity_ratchet._validate_twin_map(unknown, "fixture") + + misleading = json.loads(json.dumps(compact)) + misleading["scope"]["swift_roots"] = ["Elsewhere"] + with self.assertRaisesRegex(parity_ratchet.RatchetError, "exact derivation roots"): + parity_ratchet._validate_twin_map(misleading, "fixture") + + tampered = json.loads(json.dumps(self.baseline)) + tampered["accepted_findings"][0]["reason"] = "arbitrary non-empty replacement" + with self.assertRaisesRegex(parity_ratchet.RatchetError, "canonical reviewed reason"): + parity_ratchet._validate_baseline(tampered, "fixture") + + def test_repository_metadata_uses_invariants_not_frozen_counts_or_commits(self) -> None: + for relative in ("parity_twin_map.json", "parity_ledger_baseline.json"): + value = json.loads((TOOLS / relative).read_text(encoding="utf-8")) + issue_ref.validate_current_issue_fields(value, relative) + self.assertNotIn("expected_count", json.dumps(value)) + self.assertNotIn("source_commit", json.dumps(value)) + + def test_checked_function_pairs_equal_current_attached_source_claims(self) -> None: + declared = set( + parity_ledger.resolved_attached_function_pairs( + self.references, self.repo_swift_functions, self.repo_kotlin_functions + ) + ) + checked = { + (item["swift"], item["kotlin"]) + for item in self.expanded_map["function_pairs"] + } + self.assertEqual(declared, checked) + declared_files = parity_ledger.resolved_file_pairs( + declared, self.repo_swift_functions, self.repo_kotlin_functions + ) + checked_files = { + (item["swift"], item["kotlin"]) + for item in self.expanded_map["file_pairs"] + } + self.assertEqual(declared_files, checked_files) + + def test_every_nonunique_attached_claim_has_one_explicit_finding(self) -> None: + unresolved_sites = { + (reference.path, reference.line) + for reference, candidates in self.resolutions.items() + if len(candidates) != 1 + } + finding_sites = { + (finding.path, finding.line) + for finding in self.result.findings + if finding.rule in { + "unresolved-attached-function-claim", + "ambiguous-attached-function-claim", + } + } + self.assertEqual(unresolved_sites, finding_sites) + + def test_repository_has_only_correct_collapse_pair_and_no_rank_waiver(self) -> None: + pairs = { + (item["swift"], item["kotlin"]) + for item in self.expanded_map["function_pairs"] + } + wrong = ( + "Packages/StrandAnalytics/Sources/StrandAnalytics/HRVAnalyzer.swift::collapseOverCount/4#1", + "android/app/src/main/java/com/noop/analytics/HrvAnalyzer.kt::collapsedCoverage/3#1", + ) + correct = ( + "Packages/StrandAnalytics/Sources/StrandAnalytics/HRVAnalyzer.swift::collapseOverCount/4#1", + "android/app/src/main/java/com/noop/analytics/HrvAnalyzer.kt::collapseOverCount/4#1", + ) + self.assertNotIn(wrong, pairs) + self.assertIn(correct, pairs) + kotlin_targets = [kotlin for _swift, kotlin in pairs] + self.assertEqual(len(kotlin_targets), len(set(kotlin_targets))) + + self.assertFalse(any( + "TestCentreLayout.swift::rank/1#1" in item.identity + for item in self.result.findings + )) + + def test_checked_constant_pairs_equal_current_dynamic_pairs(self) -> None: + swift_constants, kotlin_constants = self.inventory[6:8] + file_pairs = { + (item["swift"], item["kotlin"]) + for item in self.expanded_map["file_pairs"] + } + dynamic, _ambiguous = parity_ledger._constant_pairing( + swift_constants, kotlin_constants, file_pairs + ) + resolved = {(swift.key, kotlin.key) for swift, kotlin in dynamic} + checked = { + (item["swift"], item["kotlin"]) + for item in self.expanded_map["constant_pairs"] + } + self.assertEqual(resolved, checked) + + +class GovernanceRatchetTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory() + self.root = Path(self.temp.name) + subprocess.run(["git", "init", "-q"], cwd=self.root, check=True) + subprocess.run(["git", "config", "user.email", "tests@example.invalid"], cwd=self.root, check=True) + subprocess.run(["git", "config", "user.name", "Tests"], cwd=self.root, check=True) + + def tearDown(self) -> None: + self.temp.cleanup() + + def write(self, relative: str, value: object) -> None: + path = self.root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value), encoding="utf-8") + + def commit(self) -> str: + subprocess.run(["git", "add", "."], cwd=self.root, check=True) + subprocess.run(["git", "commit", "-qm", "base"], cwd=self.root, check=True) + return subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=self.root, text=True).strip() + + def experimental_registry(self, identity: str, issue: str = "bhelm/noop#78") -> dict: + return {"schema_version": 1, "dispositions": [{ + "type": "experimental", "kind": "add-unpaired-function", + "identity": identity, "platform": identity.split("\0", 1)[0], + "identity_sha256": parity_ledger._canonical_sha256(identity), + "issue": issue, "reason": "Time-boxed synthetic experiment awaiting its parity implementation.", + "expires_on": "2026-12-31", + }]} + + def stale_base_repair_fixture(self) -> tuple[Path, str]: + swift = self.root / "Packages/StrandAnalytics/Sources/StrandAnalytics/Engine.swift" + kotlin = self.root / "android/app/src/main/java/com/noop/analytics/Engine.kt" + test = self.root / "Packages/StrandAnalytics/Tests/StrandAnalyticsTests/EngineTests.swift" + swift.parent.mkdir(parents=True, exist_ok=True) + kotlin.parent.mkdir(parents=True, exist_ok=True) + test.parent.mkdir(parents=True, exist_ok=True) + swift.write_text("enum Engine {}\n", encoding="utf-8") + kotlin.write_text("object Engine {}\n", encoding="utf-8") + compact = parity_ledger.build_compact_twin_map(self.root) + self.write("Tools/parity_twin_map.json", compact) + self.write( + "Tools/parity_ledger_baseline.json", + parity_ledger.build_compact_baseline(parity_ledger.scan(self.root, compact)), + ) + self.write("Tools/parity_dispositions.json", {"schema_version": 1, "dispositions": []}) + self.commit() + + swift.write_text("enum Engine { static func alreadyOnMain() {} }\n", encoding="utf-8") + test.write_text("func testOnly() { Engine.alreadyOnMain() }\n", encoding="utf-8") + base = self.commit() + refreshed = parity_ledger.build_compact_twin_map(self.root) + self.write("Tools/parity_twin_map.json", refreshed) + self.write( + "Tools/parity_ledger_baseline.json", + parity_ledger.build_compact_baseline(parity_ledger.scan(self.root, refreshed)), + ) + return swift, base + + def test_typed_dispositions_require_explicit_platform_and_lifecycle_fields(self) -> None: + experimental = { + "schema_version": 1, + "dispositions": [{ + "type": "experimental", "kind": "add-unpaired-function", + "identity": "swift\0Engine.swift::trial/0#1", "platform": "swift", + "identity_sha256": parity_ledger._canonical_sha256("swift\0Engine.swift::trial/0#1"), + "issue": "bhelm/noop#78", "reason": "Time-boxed experiment awaiting parity decision.", + "expires_on": "2026-12-31", + }], + } + parity_ratchet._validate_dispositions(experimental, "fixture") + malformed = json.loads(json.dumps(experimental)) + del malformed["dispositions"][0]["expires_on"] + with self.assertRaisesRegex(parity_ratchet.RatchetError, "expires_on"): + parity_ratchet._validate_dispositions(malformed, "fixture") + + platform_specific = { + "schema_version": 1, + "dispositions": [{ + "type": "platform_specific", "kind": "add-unpaired-function", + "identity": "kotlin\0Engine.kt::androidOnly/0#1", "platform": "kotlin", + "identity_sha256": parity_ledger._canonical_sha256("kotlin\0Engine.kt::androidOnly/0#1"), + "rationale": "Uses an Android-only operating-system capability with no iOS equivalent.", + }], + } + parity_ratchet._validate_dispositions(platform_specific, "fixture") + + def test_bootstrap_refuses_to_overwrite_existing_authority(self) -> None: + tools = self.root / "Tools" + tools.mkdir() + map_path = tools / "parity_twin_map.json" + baseline_path = tools / "parity_ledger_baseline.json" + map_path.write_bytes(b"map-old-bytes\n") + baseline_path.write_bytes(b"baseline-old-bytes\n") + output = io.StringIO() + with mock.patch("sys.stdout", output): + code = parity_ledger.main([ + "--root", str(self.root), "--bootstrap-map", "--write-baseline", + ]) + self.assertEqual(2, code) + self.assertIn("initial authority creation only", output.getvalue()) + self.assertEqual(b"map-old-bytes\n", map_path.read_bytes()) + self.assertEqual(b"baseline-old-bytes\n", baseline_path.read_bytes()) + + def test_bootstrap_and_baseline_flags_are_inseparable(self) -> None: + for flag in ("--bootstrap-map", "--write-baseline"): + with self.subTest(flag=flag): + output = io.StringIO() + with mock.patch("sys.stdout", output): + code = parity_ledger.main(["--root", str(self.root), flag]) + self.assertEqual(2, code) + self.assertIn("must be used together", output.getvalue()) + self.assertFalse((self.root / "Tools/parity_twin_map.json").exists()) + self.assertFalse((self.root / "Tools/parity_ledger_baseline.json").exists()) + + def test_bootstrap_scan_exception_preserves_prior_absence(self) -> None: + with mock.patch.object(parity_ledger, "scan", side_effect=RuntimeError("synthetic scan failure")): + with self.assertRaisesRegex(RuntimeError, "synthetic scan failure"): + parity_ledger.main([ + "--root", str(self.root), "--bootstrap-map", "--write-baseline", + ]) + self.assertFalse((self.root / "Tools/parity_twin_map.json").exists()) + self.assertFalse((self.root / "Tools/parity_ledger_baseline.json").exists()) + + def test_bootstrap_scan_error_preserves_prior_absence(self) -> None: + finding = mock.Mock() + finding.output.return_value = "synthetic scan error" + result = mock.Mock(errors=[finding]) + output = io.StringIO() + with mock.patch.object(parity_ledger, "scan", return_value=result), mock.patch("sys.stdout", output): + code = parity_ledger.main([ + "--root", str(self.root), "--bootstrap-map", "--write-baseline", + ]) + self.assertEqual(1, code) + self.assertIn("snapshots unchanged", output.getvalue()) + self.assertFalse((self.root / "Tools/parity_twin_map.json").exists()) + self.assertFalse((self.root / "Tools/parity_ledger_baseline.json").exists()) + + def test_bootstrap_publication_failure_rolls_back_both_files(self) -> None: + real_replace = parity_ledger.os.replace + calls = 0 + + def fail_second(source, destination): + nonlocal calls + calls += 1 + if calls == 2: + raise OSError("synthetic second replace failure") + return real_replace(source, destination) + + with mock.patch.object(parity_ledger.os, "replace", side_effect=fail_second): + with self.assertRaisesRegex(OSError, "synthetic second replace failure"): + parity_ledger.main([ + "--root", str(self.root), "--bootstrap-map", "--write-baseline", + ]) + self.assertFalse((self.root / "Tools/parity_twin_map.json").exists()) + self.assertFalse((self.root / "Tools/parity_ledger_baseline.json").exists()) + + def test_refresh_restores_snapshots_when_new_debt_is_undisposed(self) -> None: + swift = self.root / "Packages/StrandAnalytics/Sources/StrandAnalytics/Engine.swift" + kotlin = self.root / "android/app/src/main/java/com/noop/analytics/Engine.kt" + swift.parent.mkdir(parents=True, exist_ok=True) + kotlin.parent.mkdir(parents=True, exist_ok=True) + swift.write_text("enum Engine {}\n", encoding="utf-8") + kotlin.write_text("object Engine {}\n", encoding="utf-8") + compact = parity_ledger.build_compact_twin_map(self.root) + baseline = parity_ledger.build_compact_baseline(parity_ledger.scan(self.root, compact)) + self.write("Tools/parity_twin_map.json", compact) + self.write("Tools/parity_ledger_baseline.json", baseline) + self.write("Tools/parity_dispositions.json", {"schema_version": 1, "dispositions": []}) + base = self.commit() + before_map = (self.root / "Tools/parity_twin_map.json").read_bytes() + before_baseline = (self.root / "Tools/parity_ledger_baseline.json").read_bytes() + before_dispositions = (self.root / "Tools/parity_dispositions.json").read_bytes() + + swift.write_text("enum Engine { static func accidentalDrift() {} }\n", encoding="utf-8") + output = io.StringIO() + with mock.patch("sys.stdout", output): + code = parity_ledger.main([ + "--root", str(self.root), "--refresh-derived", "--base", base, + ]) + self.assertEqual(1, code) + self.assertIn("snapshots restored", output.getvalue()) + self.assertEqual(before_map, (self.root / "Tools/parity_twin_map.json").read_bytes()) + self.assertEqual(before_baseline, (self.root / "Tools/parity_ledger_baseline.json").read_bytes()) + self.assertEqual(before_dispositions, (self.root / "Tools/parity_dispositions.json").read_bytes()) + + def test_stale_base_repair_accepts_only_governance_state_already_on_base(self) -> None: + _swift, base = self.stale_base_repair_fixture() + + ordinary = parity_ratchet.compare_metadata(self.root, base, offline=True) + repaired = parity_ratchet.compare_metadata( + self.root, base, offline=True, repair_stale_base=True + ) + + self.assertTrue(any("migration required" in error for error in ordinary), ordinary) + self.assertEqual([], repaired) + self.assertEqual( + {"schema_version": 1, "dispositions": []}, + parity_ledger._load_json(self.root / "Tools/parity_dispositions.json", {}), + ) + + def test_stale_base_repair_rejects_branch_added_governance_debt(self) -> None: + swift, base = self.stale_base_repair_fixture() + swift.write_text( + "enum Engine { static func alreadyOnMain() {}; static func addedOnBranch() {} }\n", + encoding="utf-8", + ) + refreshed = parity_ledger.build_compact_twin_map(self.root) + self.write("Tools/parity_twin_map.json", refreshed) + self.write( + "Tools/parity_ledger_baseline.json", + parity_ledger.build_compact_baseline(parity_ledger.scan(self.root, refreshed)), + ) + + errors = parity_ratchet.compare_metadata( + self.root, base, offline=True, repair_stale_base=True + ) + + self.assertTrue(any("stale-base repair rejected" in error for error in errors), errors) + self.assertTrue(any("addedOnBranch" in error for error in errors), errors) + + def test_stale_base_repair_rejects_disposition_changes(self) -> None: + _swift, base = self.stale_base_repair_fixture() + identity = next( + item for item in parity_ledger.semantic_authority(self.root)["unpaired_functions"] + if "alreadyOnMain" in item + ) + self.write("Tools/parity_dispositions.json", { + "schema_version": 1, + "dispositions": [{ + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": identity, + "platform": "swift", + "identity_sha256": parity_ledger._canonical_sha256(identity), + "rationale": "Synthetic platform-only decision must not ride a stale-base repair.", + }], + }) + + errors = parity_ratchet.compare_metadata( + self.root, base, offline=True, repair_stale_base=True + ) + + self.assertTrue(any( + "stale-base repair rejected" in error + and "typed dispositions differ from the exact base" in error + for error in errors + ), errors) + + def test_stale_base_repair_rejects_nonexact_map(self) -> None: + _swift, base = self.stale_base_repair_fixture() + compact = parity_ledger._load_json(self.root / "Tools/parity_twin_map.json", {}) + compact["authority"]["functions"]["count"] += 1 + self.write("Tools/parity_twin_map.json", compact) + + errors = parity_ratchet.compare_metadata( + self.root, base, offline=True, repair_stale_base=True + ) + + self.assertTrue(any( + "stale-base repair rejected" in error + and "current authority is not exactly derived" in error + for error in errors + ), errors) + + def test_stale_base_repair_rejects_nonexact_baseline(self) -> None: + _swift, base = self.stale_base_repair_fixture() + baseline = parity_ledger._load_json( + self.root / "Tools/parity_ledger_baseline.json", {} + ) + baseline["accepted_findings"][0]["count"] += 1 + self.write("Tools/parity_ledger_baseline.json", baseline) + + errors = parity_ratchet.compare_metadata( + self.root, base, offline=True, repair_stale_base=True + ) + + self.assertTrue(any( + "stale-base repair rejected" in error + and "current baseline is not exactly derived" in error + for error in errors + ), errors) + + def test_authority_migration_accepts_a_stale_base_when_new_debt_is_disposed(self) -> None: + """The remedy #2229 asked for: a base whose stored authority cannot be reproduced. + + `--repair-stale-base` cannot help here, because repair is for a base whose GOVERNED STATE + matches and this one carries genuinely new drift. Migration re-bases onto a freshly derived + base authority; the new declaration still has to be declared to pass. + """ + swift, base = self.stale_base_repair_fixture() + swift.write_text( + "enum Engine { static func alreadyOnMain() {}; static func addedOnBranch() {} }\n", + encoding="utf-8", + ) + refreshed = parity_ledger.build_compact_twin_map(self.root) + self.write("Tools/parity_twin_map.json", refreshed) + self.write( + "Tools/parity_ledger_baseline.json", + parity_ledger.build_compact_baseline(parity_ledger.scan(self.root, refreshed)), + ) + identity = next( + item for item in parity_ledger.semantic_authority(self.root)["unpaired_functions"] + if "addedOnBranch" in item + ) + self.write("Tools/parity_dispositions.json", { + "schema_version": 1, + "dispositions": [{ + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": identity, + "identity_sha256": parity_ledger._canonical_sha256(identity), + "platform": "swift", + "rationale": "Swift-only by design for this fixture.", + }], + }) + + warnings: list[str] = [] + errors = parity_ratchet.compare_metadata( + self.root, base, offline=True, migrate_authority=True, warnings=warnings, + ) + + self.assertEqual([], errors) + self.assertTrue(any("migrated onto a freshly derived base" in w for w in warnings), warnings) + + def test_authority_migration_still_rejects_undeclared_new_debt(self) -> None: + """Migration waives the base manifest's reproducibility and nothing else. + + This is the guard worth pinning: if migration ever started waiving semantic debt too, the + flag would become a way to launder undeclared one-sided declarations onto main. + """ + swift, base = self.stale_base_repair_fixture() + swift.write_text( + "enum Engine { static func alreadyOnMain() {}; static func addedOnBranch() {} }\n", + encoding="utf-8", + ) + refreshed = parity_ledger.build_compact_twin_map(self.root) + self.write("Tools/parity_twin_map.json", refreshed) + self.write( + "Tools/parity_ledger_baseline.json", + parity_ledger.build_compact_baseline(parity_ledger.scan(self.root, refreshed)), + ) + + errors = parity_ratchet.compare_metadata( + self.root, base, offline=True, migrate_authority=True, + ) + + self.assertTrue(any("addedOnBranch" in error for error in errors), errors) + self.assertTrue( + any("issue-bound authority change" in error for error in errors), errors + ) + + def test_authority_migration_rejects_a_hand_edited_current_authority(self) -> None: + _swift, base = self.stale_base_repair_fixture() + tampered = parity_ledger._load_json(self.root / "Tools/parity_twin_map.json", {}) + tampered["authority"]["unpaired_functions"]["count"] += 1 + self.write("Tools/parity_twin_map.json", tampered) + + errors = parity_ratchet.compare_metadata( + self.root, base, offline=True, migrate_authority=True, + ) + + self.assertTrue( + any("requires an exactly derived current authority" in error for error in errors), + errors, + ) + + def test_authority_migration_rejects_a_current_authority_equal_to_the_stale_base(self) -> None: + """The sharp case, found by mutating the guard rather than by reading it. + + The general protection against a non-exact current authority only ERRORS when it matches + neither the tree nor the base; when it matches the stale base exactly it merely warns + "debt decreased". Under migration that shape would otherwise sail through and adopt an + unrefreshed authority, which is the one thing migration must not do. + """ + _swift, base = self.stale_base_repair_fixture() + stale = parity_ratchet._read_base(self.root, base, "Tools/parity_twin_map.json") + self.write("Tools/parity_twin_map.json", stale) + + warnings: list[str] = [] + errors = parity_ratchet.compare_metadata( + self.root, base, offline=True, migrate_authority=True, warnings=warnings, + ) + + self.assertTrue( + any("requires an exactly derived current authority" in error for error in errors), + errors, + ) + self.assertFalse( + any("migrated onto a freshly derived base" in w for w in warnings), + "a stale current authority must not be reported as a completed migration", + ) + + def test_migrate_authority_flag_requires_guarded_refresh(self) -> None: + output = io.StringIO() + with mock.patch("sys.stdout", output): + code = parity_ledger.main([ + "--root", str(self.root), "--migrate-authority", + ]) + self.assertEqual(2, code) + self.assertIn("requires --refresh-derived", output.getvalue()) + + def test_migrate_authority_and_repair_are_mutually_exclusive(self) -> None: + output = io.StringIO() + with mock.patch("sys.stdout", output): + code = parity_ledger.main([ + "--root", str(self.root), "--refresh-derived", + "--repair-stale-base", "--migrate-authority", + ]) + self.assertEqual(2, code) + self.assertIn("different remedies", output.getvalue()) + + def test_ordinary_refusal_names_the_migration_flag(self) -> None: + """A dead end that does not name its exit is what made #2229 take a day.""" + _swift, base = self.stale_base_repair_fixture() + + errors = parity_ratchet.compare_metadata(self.root, base, offline=True) + + self.assertTrue(any("migration required" in error for error in errors), errors) + self.assertTrue(any("--migrate-authority" in error for error in errors), errors) + + def test_repair_stale_base_flag_requires_guarded_refresh(self) -> None: + output = io.StringIO() + with mock.patch("sys.stdout", output): + code = parity_ledger.main([ + "--root", str(self.root), "--repair-stale-base", + ]) + self.assertEqual(2, code) + self.assertIn("requires --refresh-derived", output.getvalue()) + + def test_expired_experimental_disposition_blocks(self) -> None: + marker = self.root / "README" + marker.write_text("base\n", encoding="utf-8") + base = self.commit() + compact = parity_ledger.build_compact_twin_map(self.root) + self.write("Tools/parity_twin_map.json", compact) + self.write("Tools/parity_ledger_baseline.json", parity_ledger.build_compact_baseline(parity_ledger.scan(self.root, compact))) + identity = "swift\0Example.swift::trial/0#1" + registry = self.experimental_registry(identity) + registry["dispositions"][0]["expires_on"] = "2020-01-01" + self.write("Tools/parity_dispositions.json", registry) + errors = parity_ratchet.compare_metadata(self.root, base, offline=True) + self.assertTrue(any("experimental disposition expired" in error for error in errors), errors) + + def test_unreadable_base_blob_is_not_treated_as_absent_bootstrap_metadata(self) -> None: + self.write("Tools/parity_twin_map.json", {"schema_version": 3}) + base = self.commit() + real_check_output = parity_ratchet.subprocess.check_output + + def fail_only_show(arguments, **kwargs): + if arguments[:2] == ["git", "show"]: + raise subprocess.CalledProcessError(128, arguments, output="missing blob") + return real_check_output(arguments, **kwargs) + + with mock.patch.object( + parity_ratchet.subprocess, "check_output", side_effect=fail_only_show + ): + with self.assertRaisesRegex(parity_ratchet.RatchetError, "cannot read base"): + parity_ratchet._read_base( + self.root, base, "Tools/parity_twin_map.json" + ) + + def test_regenerating_compact_map_and_baseline_cannot_accept_new_unpaired_source(self) -> None: + swift = self.root / "Packages/StrandAnalytics/Sources/StrandAnalytics/Engine.swift" + kotlin = self.root / "android/app/src/main/java/com/noop/analytics/Engine.kt" + swift.parent.mkdir(parents=True, exist_ok=True) + kotlin.parent.mkdir(parents=True, exist_ok=True) + swift.write_text("enum Engine {}\n", encoding="utf-8") + kotlin.write_text("object Engine {}\n", encoding="utf-8") + compact = parity_ledger.build_compact_twin_map(self.root) + baseline = parity_ledger.build_compact_baseline(parity_ledger.scan(self.root, compact)) + self.write("Tools/parity_twin_map.json", compact) + self.write("Tools/parity_ledger_baseline.json", baseline) + base = self.commit() + + swift.write_text("enum Engine { static func newlyUnpaired() {} }\n", encoding="utf-8") + compact = parity_ledger.build_compact_twin_map(self.root) + baseline = parity_ledger.build_compact_baseline(parity_ledger.scan(self.root, compact)) + self.write("Tools/parity_twin_map.json", compact) + self.write("Tools/parity_ledger_baseline.json", baseline) + + errors = parity_ratchet.compare_metadata(self.root, base, offline=True) + + self.assertTrue( + any("derived inventory changed without an exact issue-bound authority change" in error for error in errors), + errors, + ) + + def test_regenerating_compact_metadata_cannot_accept_one_sided_constant(self) -> None: + swift = self.root / "Packages/StrandAnalytics/Sources/StrandAnalytics/Engine.swift" + kotlin = self.root / "android/app/src/main/java/com/noop/analytics/Engine.kt" + swift.parent.mkdir(parents=True, exist_ok=True) + kotlin.parent.mkdir(parents=True, exist_ok=True) + swift.write_text("enum Engine {}\n", encoding="utf-8") + kotlin.write_text("object Engine {}\n", encoding="utf-8") + compact = parity_ledger.build_compact_twin_map(self.root) + baseline = parity_ledger.build_compact_baseline(parity_ledger.scan(self.root, compact)) + self.write("Tools/parity_twin_map.json", compact) + self.write("Tools/parity_ledger_baseline.json", baseline) + base = self.commit() + + swift.write_text("enum Engine { static let swiftOnlyLimit = 7 }\n", encoding="utf-8") + compact = parity_ledger.build_compact_twin_map(self.root) + baseline = parity_ledger.build_compact_baseline(parity_ledger.scan(self.root, compact)) + self.write("Tools/parity_twin_map.json", compact) + self.write("Tools/parity_ledger_baseline.json", baseline) + + errors = parity_ratchet.compare_metadata(self.root, base, offline=True) + self.assertTrue( + any("add-unpaired-constant" in error and "swiftOnlyLimit" in error for error in errors), + errors, + ) + + def test_exact_compact_exemption_allows_only_its_current_delta(self) -> None: + swift = self.root / "Packages/StrandAnalytics/Sources/StrandAnalytics/Engine.swift" + kotlin = self.root / "android/app/src/main/java/com/noop/analytics/Engine.kt" + swift.parent.mkdir(parents=True, exist_ok=True) + kotlin.parent.mkdir(parents=True, exist_ok=True) + swift.write_text("enum Engine {}\n", encoding="utf-8") + kotlin.write_text("object Engine {}\n", encoding="utf-8") + compact = parity_ledger.build_compact_twin_map(self.root) + baseline = parity_ledger.build_compact_baseline(parity_ledger.scan(self.root, compact)) + self.write("Tools/parity_twin_map.json", compact) + self.write("Tools/parity_ledger_baseline.json", baseline) + base = self.commit() + + swift.write_text("enum Engine { static func newlyUnpaired() {} }\n", encoding="utf-8") + compact = parity_ledger.build_compact_twin_map(self.root) + identity = next( + item for item in parity_ledger.semantic_authority(self.root)["unpaired_functions"] + if "newlyUnpaired" in item + ) + registry = self.experimental_registry(identity) + self.write("Tools/parity_dispositions.json", registry) + baseline = parity_ledger.build_compact_baseline(parity_ledger.scan(self.root, compact)) + self.write("Tools/parity_twin_map.json", compact) + self.write("Tools/parity_ledger_baseline.json", baseline) + self.assertEqual([], parity_ratchet.compare_metadata(self.root, base, offline=True)) + + swift.write_text("enum Engine {}\n", encoding="utf-8") + refreshed = parity_ledger.build_compact_twin_map(self.root) + self.write("Tools/parity_twin_map.json", refreshed) + self.write( + "Tools/parity_ledger_baseline.json", + parity_ledger.build_compact_baseline(parity_ledger.scan(self.root, refreshed)), + ) + warnings: list[str] = [] + errors = parity_ratchet.compare_metadata( + self.root, base, offline=True, warnings=warnings + ) + self.assertEqual([], errors) + self.assertTrue(any("obsolete disposition" in warning for warning in warnings), warnings) + with mock.patch.object(parity_ratchet, "_fetch_issue") as fetched: + online_warnings: list[str] = [] + self.assertEqual( + [], + parity_ratchet.compare_metadata( + self.root, base, offline=False, warnings=online_warnings + ), + ) + fetched.assert_not_called() + + def test_platform_specific_disposition_allows_only_exact_one_sided_identity(self) -> None: + swift = self.root / "Packages/StrandAnalytics/Sources/StrandAnalytics/Engine.swift" + kotlin = self.root / "android/app/src/main/java/com/noop/analytics/Engine.kt" + swift.parent.mkdir(parents=True, exist_ok=True) + kotlin.parent.mkdir(parents=True, exist_ok=True) + swift.write_text("enum Engine {}\n", encoding="utf-8") + kotlin.write_text("object Engine {}\n", encoding="utf-8") + compact = parity_ledger.build_compact_twin_map(self.root) + self.write("Tools/parity_twin_map.json", compact) + self.write("Tools/parity_ledger_baseline.json", parity_ledger.build_compact_baseline(parity_ledger.scan(self.root, compact))) + base = self.commit() + + kotlin.write_text("object Engine { fun androidOnly() = Unit }\n", encoding="utf-8") + compact = parity_ledger.build_compact_twin_map(self.root) + identity = next(item for item in parity_ledger.semantic_authority(self.root)["unpaired_functions"] if "androidOnly" in item) + self.write("Tools/parity_twin_map.json", compact) + self.write("Tools/parity_ledger_baseline.json", parity_ledger.build_compact_baseline(parity_ledger.scan(self.root, compact))) + self.write("Tools/parity_dispositions.json", {"schema_version": 1, "dispositions": [{ + "type": "platform_specific", "kind": "add-unpaired-function", + "identity": identity, "platform": "kotlin", + "identity_sha256": parity_ledger._canonical_sha256(identity), + "rationale": "Uses an Android-only operating-system capability with no iOS equivalent.", + }]}) + self.assertEqual([], parity_ratchet.compare_metadata(self.root, base, offline=True)) + + def test_debt_decrease_needs_no_metadata_rewrite_and_warns(self) -> None: + swift = self.root / "Packages/StrandAnalytics/Sources/StrandAnalytics/Engine.swift" + kotlin = self.root / "android/app/src/main/java/com/noop/analytics/Engine.kt" + swift.parent.mkdir(parents=True, exist_ok=True) + kotlin.parent.mkdir(parents=True, exist_ok=True) + swift.write_text("enum Engine { static func oldDebt() {} }\n", encoding="utf-8") + kotlin.write_text("object Engine {}\n", encoding="utf-8") + compact = parity_ledger.build_compact_twin_map(self.root) + baseline = parity_ledger.build_compact_baseline(parity_ledger.scan(self.root, compact)) + self.write("Tools/parity_twin_map.json", compact) + self.write("Tools/parity_ledger_baseline.json", baseline) + base = self.commit() + + swift.write_text("enum Engine {}\n", encoding="utf-8") + warnings: list[str] = [] + self.assertEqual( + [], + parity_ratchet.compare_metadata( + self.root, base, offline=True, warnings=warnings + ), + ) + self.assertTrue(any("debt decreased" in warning for warning in warnings), warnings) + + def test_obsolete_inherited_exemption_cannot_authorize_reintroduction(self) -> None: + swift = self.root / "Packages/StrandAnalytics/Sources/StrandAnalytics/Engine.swift" + kotlin = self.root / "android/app/src/main/java/com/noop/analytics/Engine.kt" + swift.parent.mkdir(parents=True, exist_ok=True) + kotlin.parent.mkdir(parents=True, exist_ok=True) + swift.write_text("enum Engine {}\n", encoding="utf-8") + kotlin.write_text("object Engine {}\n", encoding="utf-8") + compact = parity_ledger.build_compact_twin_map(self.root) + identity = "swift\0Packages/StrandAnalytics/Sources/StrandAnalytics/Engine.swift::oldDebt/0#1" + self.write("Tools/parity_dispositions.json", self.experimental_registry(identity)) + baseline = parity_ledger.build_compact_baseline(parity_ledger.scan(self.root, compact)) + self.write("Tools/parity_twin_map.json", compact) + self.write("Tools/parity_ledger_baseline.json", baseline) + base = self.commit() + + swift.write_text("enum Engine { static func oldDebt() {} }\n", encoding="utf-8") + refreshed = parity_ledger.build_compact_twin_map(self.root) + self.write("Tools/parity_twin_map.json", refreshed) + self.write( + "Tools/parity_ledger_baseline.json", + parity_ledger.build_compact_baseline(parity_ledger.scan(self.root, refreshed)), + ) + errors = parity_ratchet.compare_metadata(self.root, base, offline=True) + self.assertTrue(any("add-unpaired-function" in error and "oldDebt" in error for error in errors), errors) + + def test_removing_a_real_twin_claim_is_not_treated_as_debt_reduction(self) -> None: + swift = self.root / "Packages/StrandAnalytics/Sources/StrandAnalytics/Engine.swift" + kotlin = self.root / "android/app/src/main/java/com/noop/analytics/Engine.kt" + swift.parent.mkdir(parents=True, exist_ok=True) + kotlin.parent.mkdir(parents=True, exist_ok=True) + swift.write_text( + "enum Engine {\n /// Kotlin twin: `Engine.score`.\n static func score(_ value: Int) -> Int { value }\n}\n", + encoding="utf-8", + ) + kotlin.write_text( + "object Engine { fun score(value: Int): Int = value }\n", encoding="utf-8" + ) + compact = parity_ledger.build_compact_twin_map(self.root) + self.write("Tools/parity_twin_map.json", compact) + self.write( + "Tools/parity_ledger_baseline.json", + parity_ledger.build_compact_baseline(parity_ledger.scan(self.root, compact)), + ) + base = self.commit() + + swift.write_text( + "enum Engine { static func score(_ value: Int) -> Int { value } }\n", + encoding="utf-8", + ) + refreshed = parity_ledger.build_compact_twin_map(self.root) + self.write("Tools/parity_twin_map.json", refreshed) + self.write( + "Tools/parity_ledger_baseline.json", + parity_ledger.build_compact_baseline(parity_ledger.scan(self.root, refreshed)), + ) + errors = parity_ratchet.compare_metadata(self.root, base, offline=True) + self.assertTrue(any("remove-function-pair" in error for error in errors), errors) + + def test_lower_debt_count_cannot_mask_replacement_identity_in_ratchet(self) -> None: + swift = self.root / "Packages/StrandAnalytics/Sources/StrandAnalytics/Engine.swift" + kotlin = self.root / "android/app/src/main/java/com/noop/analytics/Engine.kt" + swift.parent.mkdir(parents=True, exist_ok=True) + kotlin.parent.mkdir(parents=True, exist_ok=True) + swift.write_text( + "enum Engine { static func oldOne() {}\n static func oldTwo() {} }\n", + encoding="utf-8", + ) + kotlin.write_text("object Engine {}\n", encoding="utf-8") + compact = parity_ledger.build_compact_twin_map(self.root) + self.write("Tools/parity_twin_map.json", compact) + self.write( + "Tools/parity_ledger_baseline.json", + parity_ledger.build_compact_baseline(parity_ledger.scan(self.root, compact)), + ) + base = self.commit() + + swift.write_text("enum Engine { static func replacement() {} }\n", encoding="utf-8") + refreshed = parity_ledger.build_compact_twin_map(self.root) + self.write("Tools/parity_twin_map.json", refreshed) + self.write( + "Tools/parity_ledger_baseline.json", + parity_ledger.build_compact_baseline(parity_ledger.scan(self.root, refreshed)), + ) + errors = parity_ratchet.compare_metadata(self.root, base, offline=True) + self.assertTrue( + any("add-unpaired-function" in error and "replacement" in error for error in errors), + errors, + ) + + def test_unchanged_inherited_exemption_stays_valid_without_refresh(self) -> None: + swift = self.root / "Packages/StrandAnalytics/Sources/StrandAnalytics/Engine.swift" + kotlin = self.root / "android/app/src/main/java/com/noop/analytics/Engine.kt" + swift.parent.mkdir(parents=True, exist_ok=True) + kotlin.parent.mkdir(parents=True, exist_ok=True) + swift.write_text("enum Engine { static func inheritedDebt() {} }\n", encoding="utf-8") + kotlin.write_text("object Engine {}\n", encoding="utf-8") + compact = parity_ledger.build_compact_twin_map(self.root) + identity = next( + item for item in parity_ledger.semantic_authority(self.root)["unpaired_functions"] + if "inheritedDebt" in item + ) + self.write("Tools/parity_dispositions.json", self.experimental_registry(identity)) + baseline = parity_ledger.build_compact_baseline(parity_ledger.scan(self.root, compact)) + self.write("Tools/parity_twin_map.json", compact) + self.write("Tools/parity_ledger_baseline.json", baseline) + base = self.commit() + + self.assertEqual([], parity_ratchet.compare_metadata(self.root, base, offline=True)) + payload = { + "number": 78, + "repository_url": "https://api.github.com/repos/bhelm/noop", + "html_url": "https://github.com/bhelm/noop/issues/78", + "state": "open", + } + with mock.patch.object(parity_ratchet, "_fetch_issue", return_value=payload) as fetched: + self.assertEqual([], parity_ratchet.compare_metadata(self.root, base, offline=False)) + fetched.assert_called_once() + + def test_bootstrap_on_base_without_governance_files_is_allowed(self) -> None: + marker = self.root / "README" + marker.write_text("base\n", encoding="utf-8") + base = self.commit() + compact = parity_ledger.build_compact_twin_map(self.root) + self.write("Tools/parity_twin_map.json", compact) + self.write("Tools/parity_ledger_baseline.json", parity_ledger.build_compact_baseline(parity_ledger.scan(self.root, compact))) + self.assertEqual([], parity_ratchet.compare_metadata(self.root, base, offline=True)) + + def test_bootstrap_cannot_introduce_dispositions(self) -> None: + marker = self.root / "README" + marker.write_text("base\n", encoding="utf-8") + base = self.commit() + compact = parity_ledger.build_compact_twin_map(self.root) + identity = "swift\0Example.swift::invented/0#1" + self.write("Tools/parity_dispositions.json", self.experimental_registry(identity, "bhelm/noop#18")) + self.write("Tools/parity_twin_map.json", compact) + self.write( + "Tools/parity_ledger_baseline.json", + parity_ledger.build_compact_baseline(parity_ledger.scan(self.root, compact)), + ) + + errors = parity_ratchet.compare_metadata(self.root, base, offline=True) + + self.assertTrue(any("bootstrap cannot introduce dispositions" in error for error in errors), errors) + + def test_same_issue_number_in_wrong_repository_fails_closed(self) -> None: + ref = issue_ref.parse_current("bhelm/noop#77") + response = subprocess.CompletedProcess( + [], 0, + json.dumps({ + "number": 77, + "repository_url": "https://api.github.com/repos/other/noop", + "html_url": "https://github.com/other/noop/issues/77", + }), + "", + ) + with mock.patch.object(parity_ratchet.subprocess, "run", return_value=response): + self.assertFalse(parity_ratchet.issue_exists(ref)) + + def test_pull_request_and_ambiguous_response_fail_closed(self) -> None: + ref = issue_ref.parse_current("bhelm/noop#77") + for payload in ( + {"number": 77, "pull_request": {}, "html_url": "https://github.com/bhelm/noop/issues/77"}, + {"number": 77}, + {"number": True, "repository_url": "https://api.github.com/repos/bhelm/noop"}, + ): + with self.subTest(payload=payload), mock.patch.object( + parity_ratchet.subprocess, + "run", + return_value=subprocess.CompletedProcess([], 0, json.dumps(payload), ""), + ): + self.assertFalse(parity_ratchet.issue_exists(ref)) + + def test_exemption_issue_must_be_fresh_and_bind_exact_identity_hash(self) -> None: + ref = issue_ref.parse_current("bhelm/noop#78") + payload = { + "number": 78, + "repository_url": "https://api.github.com/repos/bhelm/noop", + "html_url": "https://github.com/bhelm/noop/issues/78", + "created_at": "2026-08-21T12:00:00Z", + "state": "open", + "body": "parity-governance-identity-sha256: " + "a" * 64, + } + response = subprocess.CompletedProcess([], 0, json.dumps(payload), "") + with mock.patch.object(parity_ratchet.subprocess, "run", return_value=response): + self.assertTrue( + parity_ratchet.exemption_issue_is_bound( + ref, "a" * 64, "2026-08-21T11:00:00+00:00" + ) + ) + self.assertFalse( + parity_ratchet.exemption_issue_is_bound( + ref, "b" * 64, "2026-08-21T11:00:00+00:00" + ) + ) + self.assertFalse( + parity_ratchet.exemption_issue_is_bound( + ref, "a" * 64, "2026-08-21T13:00:00+00:00" + ) + ) + payload["state"] = "closed" + response = subprocess.CompletedProcess([], 0, json.dumps(payload), "") + with mock.patch.object(parity_ratchet.subprocess, "run", return_value=response): + self.assertFalse( + parity_ratchet.exemption_issue_is_bound( + ref, "a" * 64, "2026-08-21T11:00:00+00:00" + ) + ) + + def test_default_base_is_exact_current_origin_main_not_an_old_merge_base(self) -> None: + marker = self.root / "marker" + marker.write_text("common\n", encoding="utf-8") + common = self.commit() + subprocess.run(["git", "checkout", "-qb", "candidate"], cwd=self.root, check=True) + marker.write_text("candidate\n", encoding="utf-8") + subprocess.run(["git", "commit", "-qam", "candidate"], cwd=self.root, check=True) + subprocess.run(["git", "checkout", "-qb", "upstream", common], cwd=self.root, check=True) + marker.write_text("upstream\n", encoding="utf-8") + subprocess.run(["git", "commit", "-qam", "upstream"], cwd=self.root, check=True) + upstream = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=self.root, text=True).strip() + subprocess.run(["git", "update-ref", "refs/remotes/origin/main", upstream], cwd=self.root, check=True) + subprocess.run(["git", "checkout", "-q", "candidate"], cwd=self.root, check=True) + + self.assertEqual(upstream, parity_ratchet.resolve_base(self.root, None)) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/tests/test_parity_harness.py b/Tools/tests/test_parity_harness.py new file mode 100644 index 0000000000..57f4e4f719 --- /dev/null +++ b/Tools/tests/test_parity_harness.py @@ -0,0 +1,482 @@ +from __future__ import annotations + +import io +import json +import sys +import subprocess +import tempfile +import unittest +from contextlib import redirect_stdout +from pathlib import Path + + +TOOLS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(TOOLS)) + +import parity_harness +import parity_shards + + +FIXTURE = TOOLS / "parity_case_specs/core_reference.json" + + +def invoke_cli(args): + if args[0] == "run": + args += ["--source-revision", "a" * 40] + elif args[0] in ("compare", "verify-mutant", "validate"): + args += ["--expected-revision", "a" * 40] + return parity_harness.main(args) + + +def valid_spec() -> dict: + return json.loads(FIXTURE.read_text(encoding="utf-8")) + + +class SharedOracleTests(unittest.TestCase): + REVISION = "a" * 40 + + def setUp(self): + self.rows = parity_harness.expand_spec(valid_spec()) + + def run_side(self, side="core"): + return parity_harness.run_records(self.rows, side, source_revision=self.REVISION) + + def test_one_platform_validates_without_peer(self): + output = self.run_side() + for row in output: + row["side"] = "android" + self.assertEqual([], parity_harness.validate_records( + self.rows, output, "android", expected_revision=self.REVISION)) + + def test_agreeing_but_wrong_outputs_fail_oracle(self): + core, reference = self.run_side(), self.run_side("reference") + core[0]["value"] = reference[0]["value"] = 999 + errors = parity_harness.compare_records( + self.rows, core, reference, expected_revision=self.REVISION) + self.assertEqual(2, len(errors)) + self.assertTrue(all("EXPECTED" in error for error in errors)) + + def test_revision_rejects_stale_and_missing_contract(self): + output = self.run_side() + for revision in ("", "main", "a" * 7, "g" * 40, "b" * 40): + with self.subTest(revision=revision), self.assertRaises(parity_harness.HarnessError): + parity_harness.validate_records( + self.rows, output, "core", expected_revision=revision) + output[0]["sourceRevision"] = "b" * 40 + with self.assertRaisesRegex(parity_harness.HarnessError, "sourceRevision"): + parity_harness.validate_records( + self.rows, output, "core", expected_revision=self.REVISION) + + def test_expected_and_suite_version_are_digest_bound(self): + for field, value in (("expected", 999), ("suiteVersion", "changed")): + rows = [dict(row) for row in self.rows] + rows[0][field] = value + with self.subTest(field=field), self.assertRaises(parity_harness.HarnessError): + parity_harness.validate_records( + rows, self.run_side(), "core", expected_revision=self.REVISION) + + def test_suite_version_required_and_nonempty(self): + for value in (None, "", " ", 1): + spec = valid_spec() + spec["suiteVersion"] = value + with self.subTest(value=value), self.assertRaises(parity_harness.HarnessError): + parity_harness.expand_spec(spec) + + def test_missing_expected_is_rejected(self): + spec = valid_spec() + del spec["shards"][0]["operations"][0]["cases"][0]["expected"] + with self.assertRaisesRegex(parity_harness.HarnessError, "fields must be exactly"): + parity_harness.expand_spec(spec) + + def test_stale_or_mixed_provenance_is_rejected_even_when_values_agree(self): + for side in ("core", "reference", "both"): + core, reference = self.run_side(), self.run_side("reference") + for row in (core if side == "core" else reference if side == "reference" else core + reference): + row["sourceRevision"] = "b" * 40 + with self.subTest(side=side), self.assertRaisesRegex(parity_harness.HarnessError, "sourceRevision"): + parity_harness.compare_records(self.rows, core, reference, expected_revision=self.REVISION) + output = self.run_side() + output[0]["suiteVersion"] = "stale" + with self.assertRaisesRegex(parity_harness.HarnessError, "suiteVersion"): + parity_harness.validate_records(self.rows, output, "core", expected_revision=self.REVISION) + + def test_rehashed_case_changes_still_reject_previous_output(self): + output = self.run_side() + for field, value in (("expected", 999), ("suiteVersion", "new-version")): + rows = [{**row, field: value} for row in self.rows] + for row in rows: + row["caseSha256"] = parity_harness.case_digest(row) + with self.subTest(field=field), self.assertRaisesRegex(parity_harness.HarnessError, "caseSha256"): + parity_harness.validate_records(rows, output, "core", expected_revision=self.REVISION) + + def test_validation_rejects_invalid_single_artifact_and_preserves_exact_oracle_types(self): + output = self.run_side() + for candidate in (output[:-1], [*output, output[0]], + [{**output[0], "side": "swift"}, *output[1:]], + [{**output[0], "mutant": True}, *output[1:]]): + with self.subTest(candidate=candidate), self.assertRaises(parity_harness.HarnessError): + parity_harness.validate_records(self.rows, candidate, "core", expected_revision=self.REVISION) + for expected, actual in ((True, 1), ([1], [1.0]), ({"x": -0.0}, {"x": 0.0})): + rows = [dict(row) for row in self.rows] + rows[0]["expected"] = expected + rows[0]["caseSha256"] = parity_harness.case_digest(rows[0]) + candidate = [dict(row) for row in output] + candidate[0].update(caseSha256=rows[0]["caseSha256"], value=actual) + with self.subTest(expected=expected): + self.assertEqual(1, len(parity_harness.validate_records( + rows, candidate, "core", expected_revision=self.REVISION))) + + def test_mutant_control_rejects_wrong_normal_side(self): + core = parity_harness.run_records(self.rows, "core", source_revision=self.REVISION, mutant=True) + reference = self.run_side("reference") + reference[0]["value"] = 999 + with self.assertRaisesRegex(parity_harness.HarnessError, "non-mutant side"): + parity_harness.verify_mutant(self.rows, core, reference, "core", expected_revision=self.REVISION) + + def test_single_side_cli_exit_codes_and_later_artifact_comparison(self): + with tempfile.TemporaryDirectory() as directory: + target = Path(directory) + inputs, android, swift = (target / name for name in ("cases.jsonl", "android.jsonl", "swift.jsonl")) + parity_harness.write_jsonl(inputs, self.rows) + output = self.run_side() + for row in output: + row["side"] = "android" + parity_harness.write_jsonl(android, output) + command = [sys.executable, str(TOOLS / "parity_harness.py"), "validate", "android", str(inputs), str(android)] + def check(args, code, message=""): + result = subprocess.run(args, capture_output=True, text=True) + self.assertEqual(code, result.returncode, result.stdout + result.stderr) + self.assertIn(message, result.stdout + result.stderr) + check(command, 2, "--expected-revision") + check(command + ["--expected-revision", "main"], 2, "full lowercase Git revision") + command += ["--expected-revision", self.REVISION] + check(command, 0) + self.assertFalse(swift.exists()) # No peer artifact is needed for standalone success. + output[0]["value"] = 999 + parity_harness.write_jsonl(android, output) + check(command, 1, "EXPECTED") + for row in output: + row["side"] = "swift" + parity_harness.write_jsonl(swift, output) + check([sys.executable, str(TOOLS / "parity_harness.py"), "compare", str(inputs), str(android), str(swift), + "--core-side", "android", "--reference-side", "swift", "--expected-revision", self.REVISION], 1, "EXPECTED") + output[0]["sourceRevision"] = "b" * 40 + parity_harness.write_jsonl(swift, output) + check([sys.executable, str(TOOLS / "parity_harness.py"), "validate", "swift", str(inputs), str(swift), + "--expected-revision", self.REVISION], 2, "sourceRevision") + + def test_oracle_is_literal_and_not_reference_generated(self): + spec = valid_spec() + spec["shards"][0]["operations"][0]["cases"][0]["expected"] = 999 + rows = parity_harness.expand_spec(spec) + output = parity_harness.run_records(rows, "reference", source_revision=self.REVISION) + self.assertEqual(1, len(parity_harness.validate_records( + rows, output, "reference", expected_revision=self.REVISION))) + + +class CaseSchemaTests(unittest.TestCase): + def test_checked_fixture_expands_deterministically_and_canonically(self) -> None: + spec = parity_harness.load_spec(FIXTURE) + first = parity_harness.expand_spec(spec) + second = parity_harness.expand_spec(parity_harness.load_spec(FIXTURE)) + self.assertEqual(first, second) + self.assertEqual(parity_harness.canonical_jsonl(first), parity_harness.canonical_jsonl(second)) + self.assertEqual(sorted(row["id"] for row in first), [row["id"] for row in first]) + for row in first: + self.assertEqual( + {"args", "caseSha256", "expected", "function", "id", "shard", "suiteVersion"}, set(row) + ) + self.assertEqual(row["caseSha256"], parity_harness.case_digest(row)) + + def test_schema_rejects_unknown_fields_at_every_level(self) -> None: + paths = [ + (), + ("shards", 0), + ("shards", 0, "operations", 0), + ("shards", 0, "operations", 0, "cases", 0), + ] + for path in paths: + candidate = valid_spec() + node = candidate + for part in path: + node = node[part] + node["unknown"] = True + with self.subTest(path=path), self.assertRaisesRegex( + parity_harness.HarnessError, "fields must be exactly" + ): + parity_harness.validate_spec(candidate) + + def test_schema_rejects_version_order_duplicates_and_nonfinite_values(self) -> None: + candidate = valid_spec() + candidate["schemaVersion"] = 2 + with self.assertRaisesRegex(parity_harness.HarnessError, "schemaVersion"): + parity_harness.validate_spec(candidate) + + candidate = valid_spec() + candidate["shards"][0]["operations"][0]["cases"].reverse() + with self.assertRaisesRegex(parity_harness.HarnessError, "canonical id order"): + parity_harness.validate_spec(candidate) + + candidate = valid_spec() + candidate["shards"][0]["operations"][0]["cases"][1]["id"] = "above" + with self.assertRaisesRegex(parity_harness.HarnessError, "unique"): + parity_harness.validate_spec(candidate) + + with self.assertRaisesRegex(parity_harness.HarnessError, "finite"): + parity_harness.parse_json('{"schemaVersion":NaN,"shards":[]}') + + with self.assertRaisesRegex(parity_harness.HarnessError, "duplicate JSON key"): + parity_harness.parse_json('{"schemaVersion":1,"schemaVersion":1,"shards":[]}') + + candidate = valid_spec() + candidate["shards"][0]["operations"][0]["cases"][0]["args"]["value"] = 1 << 80 + with self.assertRaisesRegex(parity_harness.HarnessError, "signed 64-bit"): + parity_harness.validate_spec(candidate) + + def test_jsonl_requires_exact_canonical_encoding(self) -> None: + rows = parity_harness.expand_spec(valid_spec()) + encoded = parity_harness.canonical_jsonl(rows) + self.assertEqual(rows, parity_harness.parse_jsonl(encoded, "fixture")) + with self.assertRaisesRegex(parity_harness.HarnessError, "canonical"): + parity_harness.parse_jsonl(encoded.replace(":", ": ", 1), "fixture") + with self.assertRaisesRegex(parity_harness.HarnessError, "final newline"): + parity_harness.parse_jsonl(encoded.rstrip("\n"), "fixture") + + +class RegistryAndShardTests(unittest.TestCase): + def test_registry_is_ordered_unique_and_is_the_operation_authority(self) -> None: + parity_shards.validate_registry(parity_shards.REGISTRY) + operation_keys = [ + operation.key for shard in parity_shards.REGISTRY for operation in shard.operations + ] + self.assertEqual(len(operation_keys), len(set(operation_keys))) + self.assertEqual(operation_keys, sorted(operation_keys)) + + duplicate = ( + parity_shards.ShardSpec( + "a", (parity_shards.REGISTRY[0].operations[0],) + ), + parity_shards.ShardSpec( + "b", (parity_shards.REGISTRY[0].operations[0],) + ), + ) + with self.assertRaisesRegex(parity_shards.RegistryError, "duplicate operation"): + parity_shards.validate_registry(duplicate) + + def test_split_is_disjoint_complete_nonempty_canonical_and_exact_remerge(self) -> None: + rows = parity_harness.expand_spec(valid_spec()) + shards = parity_harness.split_records(rows) + self.assertEqual([shard.name for shard in parity_shards.REGISTRY], list(shards)) + id_sets = [{row["id"] for row in part} for part in shards.values()] + self.assertTrue(all(id_set for id_set in id_sets)) + self.assertFalse(any(left & right for i, left in enumerate(id_sets) for right in id_sets[i + 1 :])) + self.assertEqual({row["id"] for row in rows}, set().union(*id_sets)) + self.assertEqual(rows, parity_harness.merge_records(list(shards.values()), rows)) + self.assertEqual( + parity_harness.canonical_jsonl(rows), + parity_harness.canonical_jsonl(parity_harness.merge_records(list(shards.values()), rows)), + ) + + def test_merge_rejects_missing_duplicate_and_unexpected_ids(self) -> None: + rows = parity_harness.expand_spec(valid_spec()) + for parts, message in ( + ([[*rows, rows[0]]], "duplicate"), + ([rows[:-1]], "missing"), + ([[*rows, {**rows[-1], "id": "unexpected"}]], "unexpected"), + ): + with self.subTest(message=message), self.assertRaisesRegex( + parity_harness.HarnessError, message + ): + parity_harness.merge_records(parts, rows) + + def test_merge_rejects_reordered_shard_rows(self) -> None: + rows = parity_harness.expand_spec(valid_spec()) + with self.assertRaisesRegex(parity_harness.HarnessError, "canonical id order"): + parity_harness.merge_records([list(reversed(rows))], rows) + + def test_merge_binds_exactly_one_part_to_each_registered_shard(self) -> None: + rows = parity_harness.expand_spec(valid_spec()) + with self.assertRaisesRegex(parity_harness.HarnessError, "one part per registered shard"): + parity_harness.merge_records([[rows[0]], rows[1:]], rows) + + def test_split_rejects_stale_jsonl_in_output_directory(self) -> None: + rows = parity_harness.expand_spec(valid_spec()) + with tempfile.TemporaryDirectory() as directory: + target = Path(directory) + source = target / "cases.jsonl" + output = target / "shards" + parity_harness.write_jsonl(source, rows) + output.mkdir() + (output / "stale.jsonl").write_text("{}\n", encoding="utf-8") + with self.assertRaisesRegex(parity_harness.HarnessError, "unexpected JSONL"): + invoke_cli(["split", str(source), str(output)]) + + +class ComparatorTests(unittest.TestCase): + def setUp(self) -> None: + self.rows = parity_harness.expand_spec(valid_spec()) + + def test_positive_core_reference_compare(self) -> None: + core = parity_harness.run_records(self.rows, "core", source_revision="a" * 40) + reference = parity_harness.run_records(self.rows, "reference", source_revision="a" * 40) + self.assertEqual([], parity_harness.compare_records(self.rows, core, reference, expected_revision="a" * 40)) + + def test_both_mutant_sides_are_killed(self) -> None: + normal_core = parity_harness.run_records(self.rows, "core", source_revision="a" * 40) + normal_reference = parity_harness.run_records(self.rows, "reference", source_revision="a" * 40) + core_mutant = parity_harness.run_records(self.rows, "core", mutant=True, source_revision="a" * 40) + reference_mutant = parity_harness.run_records(self.rows, "reference", mutant=True, source_revision="a" * 40) + self.assertEqual( + len(self.rows), + parity_harness.verify_mutant(self.rows, core_mutant, normal_reference, "core", expected_revision="a" * 40), + ) + self.assertEqual( + len(self.rows), + parity_harness.verify_mutant(self.rows, normal_core, reference_mutant, "reference", expected_revision="a" * 40), + ) + with self.assertRaisesRegex(parity_harness.HarnessError, "expected core mutant"): + parity_harness.verify_mutant( + self.rows, normal_core, reference_mutant, "core", + expected_revision="a" * 40, + ) + + def test_output_contract_rejects_missing_duplicate_unexpected_and_retargeted_rows(self) -> None: + core = parity_harness.run_records(self.rows, "core", source_revision="a" * 40) + reference = parity_harness.run_records(self.rows, "reference", source_revision="a" * 40) + mutations = [ + (core[:-1], "missing"), + ([*core, core[0]], "duplicate"), + ([*core, {**core[-1], "id": "unexpected"}], "unexpected"), + ([{**core[0], "function": "Other.operation/0"}, *core[1:]], "function mismatch"), + ([{**core[0], "extra": True}, *core[1:]], "fields must be exactly"), + ] + for mutated, message in mutations: + with self.subTest(message=message), self.assertRaisesRegex( + parity_harness.HarnessError, message + ): + parity_harness.compare_records(self.rows, mutated, reference, expected_revision="a" * 40) + + def test_malformed_metadata_always_raises_stable_harness_error(self) -> None: + core = parity_harness.run_records(self.rows, "core", source_revision="a" * 40) + reference = parity_harness.run_records(self.rows, "reference", source_revision="a" * 40) + malformed_input = [{**self.rows[0], "function": []}, *self.rows[1:]] + with self.assertRaises(parity_harness.HarnessError): + parity_harness.compare_records(malformed_input, core, reference, expected_revision="a" * 40) + with self.assertRaises(parity_harness.HarnessError): + parity_harness.verify_mutant(self.rows, [42], reference, "core", expected_revision="a" * 40) + + def test_output_order_and_full_case_identity_are_canonical_and_bound(self) -> None: + core = parity_harness.run_records(self.rows, "core", source_revision="a" * 40) + reference = parity_harness.run_records(self.rows, "reference", source_revision="a" * 40) + with self.assertRaisesRegex(parity_harness.HarnessError, "canonical id order"): + parity_harness.compare_records(self.rows, list(reversed(core)), reference, expected_revision="a" * 40) + + rebound = [dict(row) for row in self.rows] + rebound[0]["id"] = "other/Portable.clamp/3/above" + rebound[0]["caseSha256"] = parity_harness.case_digest(rebound[0]) + rebound.sort(key=lambda row: row["id"]) + with self.assertRaisesRegex(parity_harness.HarnessError, "structurally bound"): + parity_harness.run_records(rebound, "core", source_revision="a" * 40) + + def test_one_reference_shard_end_to_end_and_cli(self) -> None: + with tempfile.TemporaryDirectory() as directory: + target = Path(directory) + expanded = target / "cases.jsonl" + core = target / "core.jsonl" + reference = target / "reference.jsonl" + core_mutant = target / "core-mutant.jsonl" + reference_mutant = target / "reference-mutant.jsonl" + with redirect_stdout(io.StringIO()): + self.assertEqual(0, invoke_cli(["expand", str(FIXTURE), str(expanded)])) + self.assertEqual(0, invoke_cli(["run", "core", str(expanded), str(core)])) + self.assertEqual(0, invoke_cli(["run", "reference", str(expanded), str(reference)])) + self.assertEqual( + 0, + invoke_cli( + ["compare", str(expanded), str(core), str(reference)] + ), + ) + self.assertEqual( + 0, + invoke_cli( + ["run", "core", str(expanded), str(core_mutant), "--mutant"] + ), + ) + self.assertEqual( + 0, + invoke_cli( + ["run", "reference", str(expanded), str(reference_mutant), "--mutant"] + ), + ) + self.assertEqual( + 0, + invoke_cli( + [ + "verify-mutant", "core", str(expanded), + str(core_mutant), str(reference), + ] + ), + ) + self.assertEqual( + 0, + invoke_cli( + [ + "verify-mutant", "reference", str(expanded), + str(core), str(reference_mutant), + ] + ), + ) + self.assertEqual(self.rows, parity_harness.read_jsonl(expanded, "expanded")) + + +class PortableContractRegressionTests(unittest.TestCase): + def test_schema_version_requires_integer_one(self): + for version in (True, 1.0): + spec = valid_spec() + spec["schemaVersion"] = version + with self.subTest(version=version), self.assertRaises(parity_harness.HarnessError): + parity_harness.validate_spec(spec) + + def test_comparison_preserves_nested_types_and_signed_zero(self): + rows = parity_harness.expand_spec(valid_spec()) + for left, right in (({"x": True}, {"x": 1}), ([1], [1.0]), (-0.0, 0.0)): + core = parity_harness.run_records(rows, "core", source_revision="a" * 40) + reference = parity_harness.run_records(rows, "reference", source_revision="a" * 40) + core[0]["value"], reference[0]["value"] = left, right + with self.subTest(left=left, right=right): + self.assertTrue(any(line.startswith("DIFF") for line in parity_harness.compare_records(rows, core, reference, expected_revision="a" * 40))) + + def test_unicode_separators_round_trip(self): + rows = [{"value": "a\u0085b\u2028c\u2029d"}] + self.assertEqual(rows, parity_harness.parse_jsonl(parity_harness.canonical_jsonl(rows), "unicode")) + + def test_crlf_is_rejected_in_memory_and_on_disk(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "input.jsonl" + path.write_bytes(b'{"value":1}\r\n') + with self.assertRaises(parity_harness.HarnessError): + parity_harness.parse_jsonl(path.read_bytes().decode(), "input") + with self.assertRaises(parity_harness.HarnessError): + parity_harness.read_jsonl(path, "input") + + def test_unpaired_surrogates_are_rejected(self): + for value in ("\ud800", {"\udfff": 1}): + with self.subTest(value=repr(value)), self.assertRaises(parity_harness.HarnessError): + parity_harness.canonical_json(value) + + def test_clamp_preserves_value_at_mixed_numeric_boundaries(self): + operation = parity_shards.REGISTRY[0].operations[0] + for value in (0.0, -0.0, 10.0): + args = {"lower": 0, "upper": 10, "value": value} + with self.subTest(value=value): + self.assertEqual(parity_harness.canonical_json(value), parity_harness.canonical_json(operation.core(args))) + self.assertEqual(parity_harness.canonical_json(value), parity_harness.canonical_json(operation.reference(args))) + + def test_write_failure_reports_harness_error(self): + with tempfile.TemporaryDirectory() as directory: + with self.assertRaises(parity_harness.HarnessError): + parity_harness.write_jsonl(Path(directory), [{"value": 1}]) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/tests/test_parity_ledger.py b/Tools/tests/test_parity_ledger.py new file mode 100644 index 0000000000..00b609719a --- /dev/null +++ b/Tools/tests/test_parity_ledger.py @@ -0,0 +1,1279 @@ +"""Acceptance tests for the cross-language parity ledger.""" + +from __future__ import annotations + +import contextlib +import io +import json +import os +import subprocess +import sys +import tempfile +import unittest +from decimal import Decimal +from pathlib import Path +from unittest import mock + +TOOLS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(TOOLS)) + +import parity_ledger # noqa: E402 + + +class ParityLedgerTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory() + self.root = Path(self.temp.name) + self.swift = self.root / "Packages/StrandAnalytics/Sources/StrandAnalytics/Engine.swift" + self.kotlin = self.root / "android/app/src/main/java/com/noop/analytics/Engine.kt" + self.swift.parent.mkdir(parents=True) + self.kotlin.parent.mkdir(parents=True) + + def tearDown(self) -> None: + self.temp.cleanup() + + def write_clean_tree(self) -> None: + self.swift.write_text( + """public enum Engine { + /// Kotlin twin: `Engine.score`. + public static func score(_ value: Int) -> Int { value } + public static let sampleLimit = 3 +} +""" + ) + self.kotlin.write_text( + """object Engine { + /** Swift twin: `Engine.score`. */ + fun score(value: Int): Int = value + const val SAMPLE_LIMIT = 3 +} +""" + ) + + def findings(self, twin_map: dict | None = None) -> list[parity_ledger.Finding]: + if twin_map is None: + twin_map = parity_ledger.build_twin_map(self.root) + return parity_ledger.scan(self.root, twin_map).findings + + def run_cli( + self, + twin_map: dict, + baseline: dict | None = None, + *, + no_baseline: bool = False, + base: str | None = None, + ) -> tuple[int, str]: + map_path = self.root / "map.json" + baseline_path = self.root / "baseline.json" + map_path.write_text(json.dumps(twin_map)) + args = ["--root", str(self.root), "--map", str(map_path), "--baseline", str(baseline_path)] + if baseline is not None: + baseline_path.write_text(json.dumps(baseline)) + if no_baseline: + args.append("--no-baseline") + if base is not None: + args.extend(["--base", base]) + output = io.StringIO() + with contextlib.redirect_stdout(output): + code = parity_ledger.main(args) + return code, output.getvalue() + + def exit_code(self, twin_map: dict) -> int: + return self.run_cli(twin_map, no_baseline=True)[0] + + def baseline_for(self, twin_map: dict) -> dict: + return parity_ledger.build_compact_baseline(parity_ledger.scan(self.root, twin_map)) + + def mark_current_tree_as_origin_main(self) -> None: + subprocess.run(["git", "init", "-q"], cwd=self.root, check=True) + subprocess.run(["git", "add", "."], cwd=self.root, check=True) + subprocess.run( + [ + "git", "-c", "user.name=Parity Test", "-c", + "user.email=parity@example.invalid", "commit", "-qm", "base", + ], + cwd=self.root, + check=True, + ) + subprocess.run(["git", "branch", "origin/main", "HEAD"], cwd=self.root, check=True) + + def test_base_semantic_state_survives_a_symlinked_temp_root(self) -> None: + # #2143: on macOS the temp dir is /var/folders/..., a symlink to /private/var/.... The base + # checkout built its inventory from the unresolved root, then build_twin_map resolved the + # root, so relative_to saw two spellings of one directory and raised. Pointing tempfile at a + # symlink reproduces that on any OS, including the Linux runner. + self.write_clean_tree() + self.mark_current_tree_as_origin_main() + with tempfile.TemporaryDirectory() as holder: + real = Path(holder) / "real" + real.mkdir() + link = Path(holder) / "link" + link.symlink_to(real, target_is_directory=True) + with mock.patch.object(tempfile, "tempdir", str(link)): + state = parity_ledger._base_semantic_state(self.root) + self.assertIsNotNone(state) + + def test_clean_synthetic_tree_has_no_findings(self) -> None: + self.write_clean_tree() + twin_map = parity_ledger.build_twin_map(self.root) + self.assertEqual([], self.findings(twin_map)) + self.assertEqual(0, self.exit_code(twin_map)) + + def test_compact_map_expands_losslessly_and_detects_source_drift(self) -> None: + self.write_clean_tree() + compact = parity_ledger.build_compact_twin_map(self.root) + expanded, drift = parity_ledger.expand_twin_map(self.root, compact) + self.assertEqual(3, compact["schema_version"]) + self.assertEqual([], drift) + self.assertEqual(parity_ledger.build_twin_map(self.root), expanded) + + self.swift.write_text(self.swift.read_text() + "\npublic func addedAfterFreeze() {}\n") + result = parity_ledger.scan(self.root, compact) + self.assertIn("twin-map-authority-drift", {item.rule for item in result.findings}) + + def test_expand_twin_map_accepts_a_symlinked_root(self) -> None: + # expand_twin_map built its inventory from the root as given while build_twin_map resolved it, + # so a root reached through a symlink (macOS /var -> /private/var) raised in relative_to. A + # symlinked root reproduces that on any OS, including the Linux runner. + self.write_clean_tree() + compact = parity_ledger.build_compact_twin_map(self.root) + with tempfile.TemporaryDirectory() as holder: + link = Path(holder) / "link" + link.symlink_to(self.root, target_is_directory=True) + expanded, drift = parity_ledger.expand_twin_map(link, compact) + self.assertEqual([], drift) + self.assertEqual(parity_ledger.build_twin_map(self.root), expanded) + + def test_compact_authority_drift_names_new_one_sided_declaration(self) -> None: + self.write_clean_tree() + subprocess.run(["git", "init", "-q"], cwd=self.root, check=True) + subprocess.run(["git", "add", "."], cwd=self.root, check=True) + subprocess.run( + [ + "git", "-c", "user.name=Parity Test", "-c", "user.email=parity@example.invalid", + "commit", "-qm", "fixture", + ], + cwd=self.root, + check=True, + ) + subprocess.run(["git", "branch", "origin/main", "HEAD"], cwd=self.root, check=True) + compact = parity_ledger.build_compact_twin_map(self.root) + baseline = self.baseline_for(compact) + self.kotlin.write_text( + self.kotlin.read_text() + + "\nfun aFreshlyInventedOneSidedHelper(value: Int): Int = value\n" + ) + subprocess.run(["git", "add", "."], cwd=self.root, check=True) + subprocess.run( + [ + "git", "-c", "user.name=Parity Test", "-c", "user.email=parity@example.invalid", + "commit", "-qm", "add one-sided helper", + ], + cwd=self.root, + check=True, + ) + + code, output = self.run_cli(compact, baseline) + + self.assertEqual(1, code) + self.assertIn("add-unpaired-function", output) + self.assertIn("kotlin", output) + self.assertIn("android/app/src/main/java/com/noop/analytics/Engine.kt", output) + self.assertIn("aFreshlyInventedOneSidedHelper/1#1", output) + + def test_compact_authority_drift_uses_declaration_inventory_for_new_functions(self) -> None: + self.write_clean_tree() + self.swift.write_text( + self.swift.read_text().replace( + " public static let sampleLimit = 3\n", + """ public static func existingOneSided(_ value: Int) -> Int { + value + } + public static let sampleLimit = 3 +""", + ) + ) + self.mark_current_tree_as_origin_main() + compact = parity_ledger.build_compact_twin_map(self.root) + baseline = self.baseline_for(compact) + + self.swift.write_text( + self.swift.read_text().replace( + """ public static func existingOneSided(_ value: Int) -> Int { + value + } +""", + " public static func existingOneSided(_ value: Int) -> Int { value }\n", + ).replace( + " public static let sampleLimit = 3\n", + """ /// Kotlin twin: `Engine.claimedHelper`. + public static func claimedHelper(_ value: Int) -> Int { value } + public static let sampleLimit = 3 +""", + ) + ) + self.kotlin.write_text( + self.kotlin.read_text().replace( + " const val SAMPLE_LIMIT = 3\n", + """ fun claimedHelper(value: Int): Int = value + const val SAMPLE_LIMIT = 3 +""", + ) + + "\nfun genuinelyNewOneSided(value: Int): Int = value\n" + ) + + code, output = self.run_cli(compact, baseline) + + self.assertEqual(1, code) + self.assertIn("add-unpaired-function", output) + self.assertIn("genuinelyNewOneSided/1#1", output) + self.assertNotIn("add-unpaired-function: new one-sided swift function", output) + self.assertNotIn("existingOneSided/1#1", output) + + def test_protocol_and_oura_source_pairs_are_in_inventory_scope(self) -> None: + self.assertIn("Packages/WhoopProtocol/Sources/**/*.swift", parity_ledger.SWIFT_GLOBS) + self.assertIn("Packages/OuraProtocol/Sources/**/*.swift", parity_ledger.SWIFT_GLOBS) + self.assertIn("android/app/src/main/java/com/noop/protocol/**/*.kt", parity_ledger.KOTLIN_GLOBS) + self.assertIn("android/app/src/main/java/com/noop/oura/**/*.kt", parity_ledger.KOTLIN_GLOBS) + self.write_clean_tree() + scope = parity_ledger.build_compact_twin_map(self.root)["scope"] + self.assertIn("Packages/StrandAnalytics/Sources", scope["swift_roots"]) + self.assertIn("android/app/src/main/java/com/noop/analytics", scope["kotlin_roots"]) + + def test_repo_wide_line_comment_reference_is_checked_outside_inventory(self) -> None: + self.write_clean_tree() + strand = self.root / "Strand/Outside.swift" + strand.parent.mkdir(parents=True) + strand.write_text("// Kotlin twin: MissingOwner.missing\nfunc outside() {}\n") + rules = {item.rule for item in self.findings() if item.path == "Strand/Outside.swift"} + self.assertIn("dead-twin-reference", rules) + + def test_attached_claim_resolves_exact_counterpart_outside_authority_roots(self) -> None: + self.swift.write_text("enum Engine {}\n") + self.kotlin.write_text( + """object Engine { + /** Swift twin: `ExternalEngine.compute`. */ + fun compute(value: Int): Int = value +} +""" + ) + strand = self.root / "Strand/ExternalEngine.swift" + strand.parent.mkdir(parents=True, exist_ok=True) + strand.write_text( + "enum ExternalEngine { static func compute(_ value: Int) -> Int { value } }\n" + ) + twin_map = parity_ledger.build_twin_map(self.root) + result = parity_ledger.scan(self.root, twin_map) + self.assertEqual([], result.errors) + self.assertTrue( + any("Strand/ExternalEngine.swift::compute/1#1" == item["swift"] for item in twin_map["function_pairs"]) + ) + + def test_swift_package_module_qualifies_top_level_twin(self) -> None: + swift = self.root / "Packages/StrandAnalytics/Sources/StrandAnalytics/Summary.swift" + swift.parent.mkdir(parents=True, exist_ok=True) + swift.write_text("public func summary(_ value: Int) -> Int { value }\n") + self.kotlin.write_text( + "/** Swift twin: `StrandAnalytics.summary`. */\n" + "fun summary(value: Int): Int = value\n" + ) + + result = parity_ledger.scan(self.root, parity_ledger.build_twin_map(self.root)) + self.assertFalse(any(item.rule == "dead-twin-reference" for item in result.findings)) + self.assertEqual([], result.errors) + + def test_kotlin_constructor_property_resolves_owned_twin_reference(self) -> None: + self.kotlin.write_text("data class LiveState(val historyReady: Boolean)\n") + self.swift.write_text( + "final class LiveState {\n" + " /// Kotlin twin: `LiveState.historyReady`.\n" + " var historyReady = false\n" + "}\n" + ) + + result = parity_ledger.scan(self.root, parity_ledger.build_twin_map(self.root)) + self.assertFalse(any(item.rule == "dead-twin-reference" for item in result.findings)) + + def test_android_test_reference_resolves_swift_strand_test_symbol(self) -> None: + self.write_clean_tree() + swift_test = self.root / "StrandTests/WorkoutSourceTests.swift" + swift_test.parent.mkdir(parents=True) + swift_test.write_text( + "final class WorkoutSourceTests { func testPreservingCapturedCarriesStepsFromOld() {} }\n" + ) + kotlin_test = self.root / "android/app/src/test/java/com/noop/ui/WorkoutEditingTest.kt" + kotlin_test.parent.mkdir(parents=True) + kotlin_test.write_text( + "/** Twin of Swift `testPreservingCapturedCarriesStepsFromOld`. */\n" + "fun preservingCapturedCarriesStepsFromOld() {}\n" + ) + + findings = [ + item for item in self.findings() + if item.rule == "dead-twin-reference" and item.path.endswith("WorkoutEditingTest.kt") + ] + self.assertEqual([], findings) + + def test_new_one_sided_function_is_rejected(self) -> None: + self.write_clean_tree() + twin_map = parity_ledger.build_twin_map(self.root) + self.swift.write_text(self.swift.read_text() + "\npublic func newlyAddedOnlyOnSwift(_ value: Int) -> Int { value }\n") + rules = {finding.rule for finding in self.findings(twin_map)} + self.assertIn("unmapped-function", rules) + self.assertEqual(1, self.exit_code(twin_map)) + + def test_trailing_comma_does_not_add_a_parameter(self) -> None: + self.kotlin.write_text("fun score(first: Int, second: Int,) = first + second\n") + declarations = parity_ledger.parse_functions(self.root, self.kotlin, "kotlin") + self.assertEqual([("score", 2)], [(item.name, item.arity) for item in declarations]) + + def test_generic_kotlin_extension_receivers_are_inventoried(self) -> None: + self.kotlin.write_text( + """fun Map.cell(vararg keys: String) = "" +fun Map.double(vararg keys: String) = 0.0 +fun Map.bool(vararg keys: String) = false +""" + ) + declarations = parity_ledger.parse_functions(self.root, self.kotlin, "kotlin") + self.assertEqual(["cell", "double", "bool"], [item.name for item in declarations]) + + def test_computed_swift_and_kotlin_properties_are_paired(self) -> None: + self.swift.write_text( + """enum SleepStageTotals { struct Minutes { + var asleep: Double { 1 } + var inBed: Double { asleep + 1 } +} } +""" + ) + self.kotlin.write_text( + """object SleepStageTotals { data class Minutes(val awake: Double) { + val asleep: Double get() = 1.0 + val inBed: Double + get() { return asleep + 1.0 } +} } +""" + ) + twin_map = parity_ledger.build_twin_map(self.root) + self.assertEqual(2, len(twin_map["property_pairs"])) + self.assertFalse(any(item.rule == "unmapped-property" for item in self.findings(twin_map))) + + def test_dead_twin_reference_is_rejected(self) -> None: + self.write_clean_tree() + self.swift.write_text(self.swift.read_text() + "\n/// Kotlin twin: `Engine.missingTarget`.\npublic func claimsMissingTwin() {}\n") + twin_map = parity_ledger.build_twin_map(self.root) + rules = {finding.rule for finding in self.findings(twin_map)} + self.assertIn("dead-twin-reference", rules) + self.assertEqual(1, self.exit_code(twin_map)) + + def test_dead_and_unresolved_claims_cannot_be_generated_into_a_green_baseline(self) -> None: + self.swift.write_text( + """enum Engine { + /// Kotlin twin: `Engine.missingTarget`. + static func unresolvedClaim() {} +} +""" + ) + self.kotlin.write_text("object Engine {}\n") + compact = parity_ledger.build_compact_twin_map(self.root) + result = parity_ledger.scan(self.root, compact) + baseline = parity_ledger.build_compact_baseline(result) + self.assertIn("dead-twin-reference", {item.rule for item in result.errors}) + self.assertIn("unresolved-attached-function-claim", {item.rule for item in result.errors}) + self.assertEqual(1, self.run_cli(compact, baseline)[0]) + + def test_repeated_dead_reference_has_one_identity_per_claim_site(self) -> None: + self.swift.write_text( + """/// Kotlin twin: `MissingOwner.missing`. +public enum Engine { + /// Kotlin twin: `MissingOwner.missing`. + public static func score(_ value: Int) -> Int { value } +} +""" + ) + self.kotlin.write_text("object Engine {}\n") + + # A baseline identity represents one claim site, not merely one target + # string. Otherwise removing either claim is invisible to the ratchet. + findings = [item for item in self.findings() if item.rule == "dead-twin-reference"] + self.assertEqual(2, len(findings)) + self.assertEqual(2, len({item.identity for item in findings})) + + def test_only_nearest_function_twin_reference_attaches_to_declaration(self) -> None: + self.swift.write_text( + """enum Engine { + /// Kotlin twin is `Engine.wrong`. + /// Mirrors Kotlin `Engine.right`. + static func claim(_ value: Int) -> Int { value } +} +""" + ) + self.kotlin.write_text( + """object Engine { + fun wrong(value: Int): Int = value + fun right(value: Int): Int = value +} +""" + ) + + twin_map = parity_ledger.build_twin_map(self.root) + pairs = { + (item["swift"], item["kotlin"]) + for item in twin_map["function_pairs"] + } + + self.assertIn( + ( + "Packages/StrandAnalytics/Sources/StrandAnalytics/Engine.swift::claim/1#1", + "android/app/src/main/java/com/noop/analytics/Engine.kt::right/1#1", + ), + pairs, + ) + self.assertFalse(any("::wrong/1#" in kotlin for _swift, kotlin in pairs)) + + def test_file_twin_reference_is_not_attached_to_nearby_function(self) -> None: + self.swift.write_text( + """/// The Kotlin twin is Engine.kt and is covered by parity tests. +enum Engine { + static func rank(_ value: Int) -> Int { value } +} +""" + ) + self.kotlin.write_text("object Engine { fun rank(value: Int): Int = value }\n") + + twin_map = parity_ledger.build_twin_map(self.root) + rules = {item.rule for item in self.findings(twin_map)} + + self.assertEqual([], twin_map["function_pairs"]) + self.assertNotIn("unresolved-attached-function-claim", rules) + + def test_swift_selector_label_disambiguates_same_arity_overload(self) -> None: + self.swift.write_text( + """enum Engine { + static func from(hardwareId: String) -> Int { 1 } + static func from(model: String) -> Int { 2 } +} +""" + ) + self.kotlin.write_text( + """object Engine { + /** Twin of Swift `Engine.from(hardwareId:)`. */ + fun fromHardwareId(value: String): Int = 1 +} +""" + ) + twin_map = parity_ledger.build_twin_map(self.root) + result = parity_ledger.scan(self.root, twin_map) + self.assertNotIn( + "ambiguous-attached-function-claim", {item.rule for item in result.findings} + ) + self.assertTrue(any("::from/1#1" in item["swift"] for item in twin_map["function_pairs"])) + + def test_attached_claim_allows_exact_endpoints_with_different_arities(self) -> None: + self.swift.write_text( + """enum Engine { + /// Kotlin twin: `Engine.score`. + static func score(a: Int, b: Int, c: Int, d: Int) -> Int { a + b + c + d } +} +""" + ) + self.kotlin.write_text( + "object Engine { fun score(a: Int, b: Int, c: Int): Int = a + b + c }\n" + ) + twin_map = parity_ledger.build_twin_map(self.root) + result = parity_ledger.scan(self.root, twin_map) + self.assertNotIn("unresolved-attached-function-claim", {item.rule for item in result.errors}) + self.assertEqual(1, len(twin_map["function_pairs"])) + self.assertIn("::score/4#1", twin_map["function_pairs"][0]["swift"]) + self.assertIn("::score/3#1", twin_map["function_pairs"][0]["kotlin"]) + + def test_attached_claim_rejects_stale_explicit_selector(self) -> None: + self.swift.write_text( + "enum Engine { static func score(actual: Int) -> Int { actual } }\n" + ) + self.kotlin.write_text( + """object Engine { + /** Swift twin: `Engine.score(stale:)`. */ + fun score(value: Int): Int = value +} +""" + ) + twin_map = parity_ledger.build_twin_map(self.root) + result = parity_ledger.scan(self.root, twin_map) + self.assertIn("unresolved-attached-function-claim", {item.rule for item in result.errors}) + self.assertEqual([], twin_map["function_pairs"]) + + def test_ambiguous_attached_claim_is_an_unbaselinable_scan_error(self) -> None: + self.swift.write_text( + """enum Engine { + static func score(first: Int) -> Int { first } + static func score(second: Int) -> Int { second } +} +""" + ) + self.kotlin.write_text( + """object Engine { + /** Swift twin: `Engine.score`. */ + fun score(value: Int): Int = value +} +""" + ) + twin_map = parity_ledger.build_twin_map(self.root) + result = parity_ledger.scan(self.root, twin_map) + self.assertIn("ambiguous-attached-function-claim", {item.rule for item in result.errors}) + + def test_normal_block_comment_reference_is_checked(self) -> None: + self.write_clean_tree() + self.kotlin.write_text(self.kotlin.read_text() + "\n/* Swift twin: MissingOwner.nope */\nfun claimant() = 1\n") + self.assertTrue(any(item.rule == "dead-twin-reference" for item in self.findings())) + + def test_attached_reference_retarget_to_existing_function_invalidates_frozen_map(self) -> None: + self.swift.write_text( + """public enum Engine { + /// Kotlin twin: `Engine.score`. + public static func score(_ value: Int) -> Int { value } +} +""" + ) + self.kotlin.write_text( + """object Engine { + fun score(value: Int): Int = value + fun other(value: Int): Int = value +} +""" + ) + frozen_map = parity_ledger.build_twin_map(self.root) + self.swift.write_text(self.swift.read_text().replace("Engine.score", "Engine.other")) + + rules = {item.rule for item in self.findings(frozen_map)} + + self.assertIn("unmapped-declared-function-pair", rules) + self.assertIn("stale-declared-function-pair", rules) + + def test_same_metadata_retarget_is_rescanned_from_current_content(self) -> None: + self.swift.write_text( + """public enum Engine { + /// Kotlin twin: `Engine.score`. + public static func score(_ value: Int) -> Int { value } +} +""" + ) + self.kotlin.write_text( + """object Engine { + fun score(value: Int): Int = value + fun other(value: Int): Int = value +} +""" + ) + frozen_map = parity_ledger.build_twin_map(self.root) + original_stat = self.swift.stat() + original_size = original_stat.st_size + + self.swift.write_text(self.swift.read_text().replace("Engine.score", "Engine.other")) + self.assertEqual(original_size, self.swift.stat().st_size) + os.utime(self.swift, ns=(original_stat.st_atime_ns, original_stat.st_mtime_ns)) + + rules = {item.rule for item in self.findings(frozen_map)} + self.assertIn("unmapped-declared-function-pair", rules) + self.assertIn("stale-declared-function-pair", rules) + + def test_scan_uses_one_immutable_source_snapshot_per_file(self) -> None: + self.write_clean_tree() + twin_map = parity_ledger.build_twin_map(self.root) + original_read = parity_ledger._read + reads: list[Path] = [] + + def recording_read(path: Path) -> str: + reads.append(path) + return original_read(path) + + with mock.patch.object(parity_ledger, "_read", side_effect=recording_read): + parity_ledger.scan(self.root, twin_map) + + # The ledger reads through resolved paths; compare resolved so a symlinked temp dir (macOS + # /var -> /private/var) does not fail a test about read counts. + self.assertEqual(sorted([self.swift.resolve(), self.kotlin.resolve()]), sorted(reads)) + + def test_retargeted_claims_cannot_hide_behind_stale_file_and_constant_pairs(self) -> None: + swift_one = self.swift.with_name("One.swift") + swift_two = self.swift.with_name("Two.swift") + kotlin_one = self.kotlin.with_name("One.kt") + kotlin_two = self.kotlin.with_name("Two.kt") + swift_one.write_text( + """enum Engine { + static let limit = 1 +} +enum SwiftOne { + /// Kotlin twin: `KotlinOne.score`. + static func score(_ value: Int) -> Int { value } +} +""" + ) + swift_two.write_text( + """enum Engine { + static let limit = 2 +} +enum SwiftTwo { + /// Kotlin twin: `KotlinTwo.score`. + static func score(_ value: Int) -> Int { value } +} +""" + ) + kotlin_one.write_text( + "object Engine { const val LIMIT = 1 }; object KotlinOne { fun score(value: Int): Int = value }\n" + ) + kotlin_two.write_text( + "object Engine { const val LIMIT = 2 }; object KotlinTwo { fun score(value: Int): Int = value }\n" + ) + frozen_map = parity_ledger.build_twin_map(self.root) + + swift_one.write_text(swift_one.read_text().replace("KotlinOne.score", "KotlinTwo.score")) + swift_two.write_text(swift_two.read_text().replace("KotlinTwo.score", "KotlinOne.score")) + refreshed_map = parity_ledger.build_twin_map(self.root) + stale_authority = json.loads(json.dumps(frozen_map)) + stale_authority["function_pairs"] = refreshed_map["function_pairs"] + stale_authority["unpaired_functions"] = refreshed_map["unpaired_functions"] + + rules = {item.rule for item in self.findings(stale_authority)} + + self.assertIn("unmapped-declared-file-pair", rules) + self.assertIn("stale-declared-file-pair", rules) + self.assertTrue( + {"unmapped-constant-pair", "stale-constant-pair", "constant-value-mismatch"} + & rules, + rules, + ) + + def test_attached_reference_removal_invalidates_frozen_map(self) -> None: + self.swift.write_text( + """public enum Engine { + /// Kotlin twin: `Engine.score`. + public static func score(_ value: Int) -> Int { value } +} +""" + ) + self.kotlin.write_text("object Engine { fun score(value: Int): Int = value }\n") + frozen_map = parity_ledger.build_twin_map(self.root) + self.swift.write_text(self.swift.read_text().replace(" /// Kotlin twin: `Engine.score`.\n", "")) + + self.assertTrue( + any(item.rule == "stale-declared-function-pair" for item in self.findings(frozen_map)) + ) + + def test_new_attached_reference_requires_corresponding_map_pair(self) -> None: + self.swift.write_text("public enum Engine { public static func score(_ value: Int) -> Int { value } }\n") + self.kotlin.write_text("object Engine { fun score(value: Int): Int = value }\n") + frozen_map = parity_ledger.build_twin_map(self.root) + self.swift.write_text( + """public enum Engine { + /// Kotlin twin: `Engine.score`. + public static func score(_ value: Int) -> Int { value } +} +""" + ) + + self.assertTrue( + any(item.rule == "unmapped-declared-function-pair" for item in self.findings(frozen_map)) + ) + + def test_stale_attached_reference_to_missing_target_remains_red(self) -> None: + self.swift.write_text( + """public enum Engine { + /// Kotlin twin: `Engine.missing`. + public static func score(_ value: Int) -> Int { value } +} +""" + ) + self.kotlin.write_text("object Engine { fun score(value: Int): Int = value }\n") + + rules = {item.rule for item in self.findings()} + self.assertIn("dead-twin-reference", rules) + self.assertIn("unresolved-attached-function-claim", rules) + + def test_ambiguous_attached_reference_is_an_explicit_finding(self) -> None: + self.swift.write_text( + """public enum Engine { + /// Kotlin twin: `Engine.score`. + public static func claim(_ value: Int) -> Int { value } +} +""" + ) + self.kotlin.write_text( + """object Engine { + fun score(value: Int): Int = value + fun score(value: Int, extra: Int): Int = value + extra +} +""" + ) + twin_map = parity_ledger.build_twin_map(self.root) + + self.assertTrue( + any( + item.rule == "ambiguous-attached-function-claim" + for item in self.findings(twin_map) + ) + ) + + def test_qualified_reference_requires_the_actual_owner(self) -> None: + self.swift.write_text("enum Bar { static func existingName() {} }\n") + self.kotlin.write_text("// Swift twin: Foo.existingName\nfun claim() = 1\n") + findings = self.findings() + self.assertTrue(any(item.rule == "dead-twin-reference" for item in findings)) + + def test_constant_expression_is_fully_evaluated(self) -> None: + self.swift.write_text("enum Engine { static let hours = 48 * 3_600 }\n") + self.kotlin.write_text("object Engine { const val HOURS = 48L * 3_600L }\n") + twin_map = parity_ledger.build_twin_map(self.root) + self.assertFalse(any(item.rule.startswith("constant-") for item in self.findings(twin_map))) + self.kotlin.write_text("object Engine { const val HOURS = 48L * 3_601L }\n") + self.assertTrue(any(item.rule == "constant-value-mismatch" for item in self.findings(twin_map))) + + def test_new_equal_mirrored_constant_pair_must_be_added_to_map(self) -> None: + self.write_clean_tree() + frozen_map = parity_ledger.build_twin_map(self.root) + self.swift.write_text( + self.swift.read_text().replace( + "sampleLimit = 3", "sampleLimit = 3\n public static let freshLimit = 7" + ) + ) + self.kotlin.write_text( + self.kotlin.read_text().replace( + "SAMPLE_LIMIT = 3", "SAMPLE_LIMIT = 3\n const val FRESH_LIMIT = 7" + ) + ) + + findings = self.findings(frozen_map) + + self.assertTrue(any(item.rule == "unmapped-constant-pair" for item in findings), findings) + refreshed_map = parity_ledger.build_twin_map(self.root) + self.assertFalse( + any(item.rule == "unmapped-constant-pair" for item in self.findings(refreshed_map)) + ) + + def test_new_one_sided_constant_is_not_assumed_to_be_a_twin(self) -> None: + self.write_clean_tree() + frozen_map = parity_ledger.build_twin_map(self.root) + self.swift.write_text( + self.swift.read_text().replace( + "sampleLimit = 3", "sampleLimit = 3\n public static let swiftOnlyLimit = 7" + ) + ) + + self.assertFalse( + any(item.rule == "unmapped-constant-pair" for item in self.findings(frozen_map)) + ) + + def test_numeric_expression_parser_consumes_every_supported_operator(self) -> None: + parsed = parity_ledger._literal("-(2 - 50) * (7_200 / 2) + 0x10 - 0b1") + self.assertEqual("number:172815", parsed[0]) + self.assertIsNone(parity_ledger._literal("48 * 3_600 trailing")) + + def test_hex_digits_f_and_d_are_not_stripped_as_suffixes(self) -> None: + cases = { + "0xffff_ffff": "4294967295", + "0xffff_ffffL": "4294967295", + "0xFFFFFFFF": "4294967295", + "0xABCD": "43981", + "0x2F": "47", + "0b1L": "1", + "1.5f": "1.5", + "2.0d": "2", + "10L": "10", + } + for raw, expected in cases.items(): + with self.subTest(raw=raw): + canonical = parity_ledger._literal(raw)[0] + self.assertEqual(f"number:{Decimal(expected).normalize()}", canonical) + + def test_hex_constant_ending_in_f_is_paired_by_its_full_value(self) -> None: + self.swift.write_text("enum Engine { static let mask: UInt32 = 0xffff_ffff }\n") + self.kotlin.write_text("object Engine { const val MASK = 0xffff_ffffL }\n") + twin_map = parity_ledger.build_twin_map(self.root) + self.assertEqual(1, len(twin_map["constant_pairs"])) + self.assertFalse(any(item.rule.startswith("constant-") for item in self.findings(twin_map))) + self.kotlin.write_text("object Engine { const val MASK = 0xffff_fffdL }\n") + self.assertTrue(any(item.rule == "constant-value-mismatch" for item in self.findings(twin_map))) + + def test_unparseable_mapped_constant_is_reported(self) -> None: + self.swift.write_text("enum Engine { static let limit = makeLimit() }\n") + self.kotlin.write_text("object Engine { const val LIMIT = 3 }\n") + twin_map = parity_ledger.build_twin_map(self.root) + self.assertTrue(any(item.rule == "constant-unverifiable" for item in self.findings(twin_map))) + + def test_stale_constant_pair_is_reported_after_one_sided_rename(self) -> None: + self.write_clean_tree() + twin_map = parity_ledger.build_twin_map(self.root) + self.kotlin.write_text(self.kotlin.read_text().replace("SAMPLE_LIMIT", "RENAMED_LIMIT")) + + stale = [item for item in self.findings(twin_map) if item.rule == "stale-constant-pair"] + + self.assertEqual(1, len(stale)) + self.assertIn("SAMPLE_LIMIT", stale[0].text) + + def test_resolvable_constant_pair_has_no_stale_finding(self) -> None: + self.write_clean_tree() + twin_map = parity_ledger.build_twin_map(self.root) + + self.assertFalse(any(item.rule == "stale-constant-pair" for item in self.findings(twin_map))) + + def test_constant_owner_disambiguates_same_normalized_name(self) -> None: + self.swift.write_text("enum SedentaryDetector { static let defaultSmoothWindowS = 240.0 }\n") + self.kotlin = self.kotlin.with_name("SedentaryDetector.kt") + self.kotlin.write_text("object SedentaryDetector { const val DEFAULT_SMOOTH_WINDOW_S = 240.0 }\n") + self.kotlin.with_name("NapDetector.kt").write_text( + "object NapDetector { const val DEFAULT_SMOOTH_WINDOW_S = 120.0 }\n" + ) + twin_map = parity_ledger.build_twin_map(self.root) + pairs = twin_map["constant_pairs"] + self.assertEqual(1, len(pairs)) + self.assertIn("SedentaryDetector", pairs[0]["kotlin"]) + self.assertFalse(any(item.rule == "constant-ambiguous" for item in self.findings(twin_map))) + + def test_remaining_constant_ambiguity_is_reported(self) -> None: + self.swift.write_text("enum Engine { static let limit = 3 }\n") + self.kotlin.write_text("object First { const val LIMIT = 3 }\n") + self.kotlin.with_name("Second.kt").write_text("object Second { const val LIMIT = 3 }\n") + twin_map = parity_ledger.build_twin_map(self.root) + self.assertTrue(any(item.rule == "constant-ambiguous" for item in self.findings(twin_map))) + + def test_constant_value_mismatch_is_rejected(self) -> None: + self.write_clean_tree() + self.kotlin.write_text(self.kotlin.read_text().replace("SAMPLE_LIMIT = 3", "SAMPLE_LIMIT = 4")) + twin_map = parity_ledger.build_twin_map(self.root) + rules = {finding.rule for finding in self.findings(twin_map)} + self.assertIn("constant-value-mismatch", rules) + self.assertEqual(1, self.exit_code(twin_map)) + + def test_same_metadata_constant_change_is_rescanned_from_current_content(self) -> None: + self.write_clean_tree() + twin_map = parity_ledger.build_twin_map(self.root) + original_stat = self.kotlin.stat() + original_size = original_stat.st_size + + self.kotlin.write_text(self.kotlin.read_text().replace("SAMPLE_LIMIT = 3", "SAMPLE_LIMIT = 4")) + self.assertEqual(original_size, self.kotlin.stat().st_size) + os.utime(self.kotlin, ns=(original_stat.st_atime_ns, original_stat.st_mtime_ns)) + + rules = {finding.rule for finding in self.findings(twin_map)} + self.assertIn("constant-value-mismatch", rules) + + def test_platform_database_schema_versions_are_not_parity_twins(self) -> None: + swift = self.root / "Packages/WhoopStore/Sources/WhoopStore/WhoopStore.swift" + kotlin = self.root / "android/app/src/main/java/com/noop/data/WhoopDatabase.kt" + swift.parent.mkdir(parents=True) + kotlin.parent.mkdir(parents=True) + swift.write_text("public enum WhoopStore { public static let schemaVersion = 18 }\n") + kotlin.write_text("object WhoopDatabase { const val SCHEMA_VERSION = 31 }\n") + + twin_map = parity_ledger.build_twin_map(self.root) + pairs = {(item["swift"], item["kotlin"]) for item in twin_map["constant_pairs"]} + self.assertNotIn( + ( + "Packages/WhoopStore/Sources/WhoopStore/WhoopStore.swift::schemaVersion", + "android/app/src/main/java/com/noop/data/WhoopDatabase.kt::SCHEMA_VERSION", + ), + pairs, + ) + constant_findings = [item for item in self.findings(twin_map) if item.rule.startswith("constant-")] + self.assertEqual([], constant_findings) + + def test_test_only_wiring_is_rejected(self) -> None: + self.write_clean_tree() + self.swift.write_text(self.swift.read_text() + "\npublic func testOnlyHelper(_ value: Int) -> Int { value }\n") + test_path = self.root / "Packages/StrandAnalytics/Tests/StrandAnalyticsTests/EngineTests.swift" + test_path.parent.mkdir(parents=True) + test_path.write_text("func testHelper() { _ = testOnlyHelper(1) }\n") + twin_map = parity_ledger.build_twin_map(self.root) + rules = {finding.rule for finding in self.findings(twin_map)} + self.assertIn("test-only-callsite", rules) + self.assertEqual(1, self.exit_code(twin_map)) + + def test_test_only_calls_use_owner_and_arity_and_ignore_extension_declaration(self) -> None: + self.kotlin.write_text( + """object Alpha { fun collide(first: Int, second: Int) = first + second } +object Beta { fun collide(value: Int) = value } +fun Map.extensionOnly(value: Int) = value +fun production() = Alpha.collide(1, 2) +""" + ) + test_path = self.root / "android/app/src/test/java/com/noop/analytics/EngineTest.kt" + test_path.parent.mkdir(parents=True) + test_path.write_text("fun testIt() { Beta.collide(1); Map.extensionOnly(1) }\n") + findings = [item for item in self.findings() if item.rule == "test-only-callsite"] + texts = "\n".join(item.text for item in findings) + self.assertIn("Beta.collide/1", texts) + self.assertIn("Map.extensionOnly/1", texts) + self.assertNotIn("Alpha.collide/1", texts) + + def test_call_omitting_defaults_counts_as_production_callsite(self) -> None: + self.kotlin.write_text( + """object Roller { fun roll(rr: Int, windowSec: Int = 90, stepSec: Int = 0) = rr + windowSec + stepSec } +fun production() = Roller.roll(1) +""" + ) + test_path = self.root / "android/app/src/test/java/com/noop/analytics/RollTest.kt" + test_path.parent.mkdir(parents=True) + test_path.write_text("fun testIt() { Roller.roll(1, 2, 3) }\n") + texts = "\n".join( + item.text for item in self.findings() if item.rule == "test-only-callsite" + ) + self.assertNotIn("Roller.roll/3", texts) + + def test_call_cannot_omit_required_parameters(self) -> None: + self.kotlin.write_text( + """object Roller { fun roll(rr: Int, windowSec: Int, stepSec: Int) = rr + windowSec + stepSec } +fun unrelated() = Roller.roll(1) +""" + ) + test_path = self.root / "android/app/src/test/java/com/noop/analytics/RollTest.kt" + test_path.parent.mkdir(parents=True) + test_path.write_text("fun testIt() { Roller.roll(1, 2, 3) }\n") + texts = "\n".join( + item.text for item in self.findings() if item.rule == "test-only-callsite" + ) + self.assertIn("Roller.roll/3", texts) + + def test_unqualified_same_file_call_counts_despite_sibling_owner(self) -> None: + self.kotlin.write_text( + """object Verdict { fun verdict(value: Int) = value + fun production() = verdict(1) } +""" + ) + sibling = self.kotlin.parent / "Sibling.kt" + sibling.write_text("object Sibling { fun verdict(first: Int, second: Int) = first + second }\n") + test_path = self.root / "android/app/src/test/java/com/noop/analytics/VerdictTest.kt" + test_path.parent.mkdir(parents=True) + test_path.write_text("fun testIt() { Verdict.verdict(1); Sibling.verdict(1, 2) }\n") + texts = "\n".join( + item.text for item in self.findings() if item.rule == "test-only-callsite" + ) + self.assertNotIn("Verdict.verdict/1", texts) + self.assertIn("Sibling.verdict/2", texts) + + def test_exact_arity_match_wins_over_relaxed_overload(self) -> None: + self.kotlin.write_text( + """object Over { fun pick(value: Int) = value + fun pick(value: Int, extra: Int = 0) = value + extra } +fun production() = Over.pick(1) +""" + ) + test_path = self.root / "android/app/src/test/java/com/noop/analytics/OverTest.kt" + test_path.parent.mkdir(parents=True) + test_path.write_text("fun testIt() { Over.pick(1, 2) }\n") + texts = "\n".join( + item.text for item in self.findings() if item.rule == "test-only-callsite" + ) + self.assertIn("Over.pick/2", texts) + + def test_lowercase_instance_receiver_resolves_like_unqualified(self) -> None: + self.kotlin.write_text( + """object Burst { fun codesWithTimes(first: Int, second: Int, extra: Int = 0) = first + second + extra } +object Assembler { val burst = Burst + fun production(): Int { return burst.codesWithTimes(1, 2) } } +""" + ) + test_path = self.root / "android/app/src/test/java/com/noop/analytics/BurstTest.kt" + test_path.parent.mkdir(parents=True) + test_path.write_text("fun testIt() { Burst.codesWithTimes(1, 2, 3) }\n") + texts = "\n".join( + item.text for item in self.findings() if item.rule == "test-only-callsite" + ) + self.assertNotIn("Burst.codesWithTimes/3", texts) + + def test_same_file_resolution_prefers_the_lexical_owner(self) -> None: + self.kotlin.write_text( + """object First { fun add(value: Int) = value + fun production() = add(1) } +object Second { fun add(value: Int) = value } +""" + ) + test_path = self.root / "android/app/src/test/java/com/noop/analytics/AddTest.kt" + test_path.parent.mkdir(parents=True) + test_path.write_text("fun testIt() { Second.add(1) }\n") + texts = "\n".join( + item.text for item in self.findings() if item.rule == "test-only-callsite" + ) + self.assertNotIn("First.add/1", texts) + self.assertIn("Second.add/1", texts) + + def test_kotlin_call_inside_string_template_counts_as_production(self) -> None: + self.kotlin.write_text( + '''object Trace { fun suffix(value: Int) = value } +fun production(value: Int) = "trace=${Trace.suffix(value)}" +''' + ) + test_path = self.root / "android/app/src/test/java/com/noop/analytics/TraceTest.kt" + test_path.parent.mkdir(parents=True) + test_path.write_text("fun testIt() { Trace.suffix(1) }\n") + + texts = "\n".join( + item.text for item in self.findings() if item.rule == "test-only-callsite" + ) + + self.assertNotIn("Trace.suffix/1", texts) + + def test_kotlin_nested_templates_preserve_code_and_mask_nested_string_text(self) -> None: + self.kotlin.write_text( + r'''object Trace { + fun suffix(value: Int) = value + fun literalOnly(value: Int) = value +} +fun production(value: Int) = + "outer ${run { if (value > 0) { "inner ${Trace.suffix(value)} noise=Trace.literalOnly(value) tab=\t dollar=\$" } else "none" }}" +''' + ) + test_path = self.root / "android/app/src/test/java/com/noop/analytics/TraceTest.kt" + test_path.parent.mkdir(parents=True) + test_path.write_text( + "fun testIt() { Trace.suffix(1); Trace.literalOnly(1) }\n" + ) + + texts = "\n".join( + item.text for item in self.findings() if item.rule == "test-only-callsite" + ) + + self.assertNotIn("Trace.suffix/1", texts) + self.assertIn("Trace.literalOnly/1", texts) + + def test_kotlin_string_arguments_keep_overload_arity_and_argument_positions(self) -> None: + self.kotlin.write_text( + r'''object Target { + fun emit(value: Int) = value + fun emit(value: Int, text: String) = text + fun first(text: String, middle: Int, last: Int) = text + fun middle(first: Int, text: String, last: Int) = text + fun last(first: Int, middle: Int, text: String) = text + fun nested(value: Int) = value + fun literalOnly(value: Int) = value +} +fun production(value: String) { + Target.emit(1, "outer ${value.ifEmpty { "fallback" }}") + Target.first("plain literal", 2, 3) + Target.middle(1, "plain literal", 3) + Target.last(1, 2, "outer ${run { "inner ${Target.nested(4)}" }}") + val noise = "Target.literalOnly(5)" +} +''' + ) + test_path = self.root / "android/app/src/test/java/com/noop/analytics/TargetTest.kt" + test_path.parent.mkdir(parents=True) + test_path.write_text( + """fun testIt(text: String) { + Target.emit(1, text) + Target.first(text, 2, 3) + Target.middle(1, text, 3) + Target.last(1, 2, text) + Target.nested(4) + Target.literalOnly(5) +} +""" + ) + + texts = "\n".join( + item.text for item in self.findings() if item.rule == "test-only-callsite" + ) + + for signature in ("Target.emit/2", "Target.first/3", "Target.middle/3", "Target.last/3", + "Target.nested/1"): + self.assertNotIn(signature, texts) + self.assertIn("Target.literalOnly/1", texts) + + def test_kotlin_literal_comment_and_plain_malformed_strings_stay_masked(self) -> None: + self.kotlin.write_text( + r'''object Trace { + fun literalOnly(value: Int) = value + fun malformed(value: Int) = value +} +fun literal(value: Int): String { + val plain = "Trace.literalOnly(value)" + val escaped = "\${Trace.literalOnly(value)}" + // ${Trace.literalOnly(value) + /* nested /* ${Trace.literalOnly(value) */ comment */ + return plain + escaped +} +fun broken(value: Int) = "broken Trace.malformed(value) +''' + ) + test_path = self.root / "android/app/src/test/java/com/noop/analytics/TraceTest.kt" + test_path.parent.mkdir(parents=True) + test_path.write_text( + "fun testIt() { Trace.literalOnly(1); Trace.malformed(1) }\n" + ) + + result = parity_ledger.scan(self.root, parity_ledger.build_twin_map(self.root)) + texts = "\n".join( + item.text for item in result.findings if item.rule == "test-only-callsite" + ) + + self.assertEqual([], result.errors) + self.assertIn("Trace.literalOnly/1", texts) + self.assertIn("Trace.malformed/1", texts) + + def test_unterminated_kotlin_template_is_a_stable_scan_error_and_cli_failure(self) -> None: + self.kotlin.write_text( + '''object Trace { fun suffix(value: Int) = value } +fun broken(value: Int) = "broken ${run { Trace.suffix(value) } +''' + ) + twin_map = parity_ledger.build_twin_map(self.root) + + result = parity_ledger.scan(self.root, twin_map) + errors = [error.output() for error in result.errors] + + self.assertEqual( + [ + "android/app/src/main/java/com/noop/analytics/Engine.kt:2: " + "malformed-kotlin-template: unterminated Kotlin string template" + ], + errors, + ) + code, output = self.run_cli(twin_map, parity_ledger.build_compact_baseline(result)) + self.assertEqual(1, code) + self.assertIn("FAIL 1 parity ledger scan error(s):", output) + self.assertIn(errors[0], output) + self.assertTrue( + output.rstrip().endswith("Baseline not evaluated: 1 scan error."), + output, + ) + + def test_invalid_utf8_is_a_stable_hard_error(self) -> None: + self.write_clean_tree() + twin_map = parity_ledger.build_twin_map(self.root) + self.swift.write_bytes(b"enum Engine {\n\xff\n}\n") + result = parity_ledger.scan(self.root, twin_map) + self.assertEqual(["invalid-utf8"], [item.rule for item in result.errors]) + code, output = self.run_cli(twin_map, no_baseline=True) + self.assertEqual(1, code) + self.assertIn("invalid-utf8", output) + + def test_artificial_duplicate_is_rejected(self) -> None: + self.write_clean_tree() + extra = self.swift.with_name("Other.swift") + extra.write_text("public func dayString(_ value: Int) -> String { \"x\" }\n") + self.swift.write_text(self.swift.read_text() + "\npublic func dayString(_ value: Int, offset: Int) -> String { \"x\" }\n") + twin_map = parity_ledger.build_twin_map(self.root) + rules = {finding.rule for finding in self.findings(twin_map)} + self.assertIn("duplicate-implementation", rules) + self.assertEqual(1, self.exit_code(twin_map)) + + def test_baseline_suppresses_known_finding_but_rejects_new_finding(self) -> None: + self.write_clean_tree() + self.kotlin.write_text(self.kotlin.read_text().replace("SAMPLE_LIMIT = 3", "SAMPLE_LIMIT = 4")) + twin_map = parity_ledger.build_twin_map(self.root) + baseline = self.baseline_for(twin_map) + self.assertEqual(0, self.run_cli(twin_map, baseline)[0]) + self.swift.write_text(self.swift.read_text() + "\nfunc newRegression() {}\n") + self.assertEqual(1, self.run_cli(twin_map, baseline)[0]) + + def test_disappeared_baseline_finding_is_warning_only(self) -> None: + self.write_clean_tree() + self.kotlin.write_text(self.kotlin.read_text().replace("SAMPLE_LIMIT = 3", "SAMPLE_LIMIT = 4")) + twin_map = parity_ledger.build_twin_map(self.root) + baseline = self.baseline_for(twin_map) + self.mark_current_tree_as_origin_main() + self.kotlin.write_text(self.kotlin.read_text().replace("SAMPLE_LIMIT = 4", "SAMPLE_LIMIT = 3")) + code, output = self.run_cli(twin_map, baseline) + self.assertEqual(0, code) + self.assertIn("WARNING debt decreased", output) + + def test_lower_count_with_replacement_finding_still_blocks(self) -> None: + self.write_clean_tree() + self.swift.write_text( + self.swift.read_text().replace( + "public static let sampleLimit = 3", + "public static let sampleLimit = 3\n public static let windowLimit = 5", + ) + ) + self.kotlin.write_text( + self.kotlin.read_text().replace( + "const val SAMPLE_LIMIT = 3", + "const val SAMPLE_LIMIT = 4\n const val WINDOW_LIMIT = 6", + ) + ) + twin_map = parity_ledger.build_twin_map(self.root) + baseline = self.baseline_for(twin_map) + self.mark_current_tree_as_origin_main() + + self.swift.write_text( + self.swift.read_text().replace( + "public static let windowLimit = 5", + "public static let windowLimit = 5\n public static let newLimit = 8", + ) + ) + self.kotlin.write_text( + self.kotlin.read_text() + .replace("const val SAMPLE_LIMIT = 4", "const val SAMPLE_LIMIT = 3") + .replace("const val WINDOW_LIMIT = 6", "const val WINDOW_LIMIT = 5\n const val NEW_LIMIT = 9") + ) + current_map = parity_ledger.build_twin_map(self.root) + code, output = self.run_cli(current_map, baseline) + self.assertEqual(1, code) + self.assertIn("replacement findings are new", output) + self.assertIn("newLimit", output) + + def test_improvement_base_selection_and_missing_ref_are_fail_closed(self) -> None: + self.write_clean_tree() + self.kotlin.write_text(self.kotlin.read_text().replace("SAMPLE_LIMIT = 3", "SAMPLE_LIMIT = 4")) + twin_map = parity_ledger.build_twin_map(self.root) + baseline = self.baseline_for(twin_map) + self.mark_current_tree_as_origin_main() + base_sha = subprocess.check_output( + ["git", "rev-parse", "origin/main"], cwd=self.root, text=True + ).strip() + self.kotlin.write_text(self.kotlin.read_text().replace("SAMPLE_LIMIT = 4", "SAMPLE_LIMIT = 3")) + + real = parity_ledger.finding_identities_at_git_ref + with mock.patch.object(parity_ledger, "finding_identities_at_git_ref", wraps=real) as scanned: + self.assertEqual(0, self.run_cli(twin_map, baseline)[0]) + scanned.assert_called_once_with(self.root.resolve(), "origin/main") + with mock.patch.object(parity_ledger, "finding_identities_at_git_ref", wraps=real) as scanned: + self.assertEqual(0, self.run_cli(twin_map, baseline, base=base_sha)[0]) + scanned.assert_called_once_with(self.root.resolve(), base_sha) + code, output = self.run_cli(twin_map, baseline, base="missing/shallow-base") + self.assertEqual(1, code) + self.assertIn("cannot scan exact base", output) + + def test_base_ref_is_resolved_once_before_archive(self) -> None: + self.write_clean_tree() + self.mark_current_tree_as_origin_main() + expected = subprocess.check_output( + ["git", "rev-parse", "origin/main"], cwd=self.root, text=True + ).strip() + real = subprocess.check_output + calls: list[list[str]] = [] + + def recording(arguments, **kwargs): + calls.append(arguments) + return real(arguments, **kwargs) + + with mock.patch.object(parity_ledger.subprocess, "check_output", side_effect=recording): + parity_ledger.finding_identities_at_git_ref(self.root, "origin/main") + + self.assertEqual( + [["git", "rev-parse", "--verify", "origin/main^{commit}"], + ["git", "archive", "--format=tar", expected]], + calls, + ) + + def test_counter_increase_beyond_baseline_is_rejected(self) -> None: + self.write_clean_tree() + old_map = parity_ledger.build_twin_map(self.root) + baseline = self.baseline_for(old_map) + self.swift.write_text(self.swift.read_text() + "\nfunc dayString(_ value: Int) -> String { \"x\" }\n") + new_map = parity_ledger.build_twin_map(self.root) + code, output = self.run_cli(new_map, baseline) + self.assertEqual(1, code) + self.assertIn("compact baseline drift", output) + + def test_changed_value_of_baselined_mismatch_is_a_new_finding(self) -> None: + self.write_clean_tree() + self.kotlin.write_text(self.kotlin.read_text().replace("SAMPLE_LIMIT = 3", "SAMPLE_LIMIT = 4")) + twin_map = parity_ledger.build_twin_map(self.root) + baseline = self.baseline_for(twin_map) + self.kotlin.write_text(self.kotlin.read_text().replace("SAMPLE_LIMIT = 4", "SAMPLE_LIMIT = 5")) + code, output = self.run_cli(twin_map, baseline) + self.assertEqual(1, code) + self.assertIn("constant-value-mismatch", output) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/tests/test_rr_legacy_preservation_contract.py b/Tools/tests/test_rr_legacy_preservation_contract.py new file mode 100644 index 0000000000..8e8be1d6a4 --- /dev/null +++ b/Tools/tests/test_rr_legacy_preservation_contract.py @@ -0,0 +1,58 @@ +"""Source contracts for cross-platform legacy WHOOP 5 score preservation.""" + +from __future__ import annotations + +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +SWIFT_ENGINE = ROOT / "Strand/Data/IntelligenceEngine.swift" +ANDROID_PERSISTENCE = ( + ROOT + / "android/app/src/main/java/com/noop/analytics/IntelligencePersistence.kt" +) + + +class LegacyScorePreservationContractTests(unittest.TestCase): + def test_swift_protects_only_the_persisted_and_displayed_copy(self) -> None: + source = SWIFT_ENGINE.read_text() + protection_start = source.index("// Apply the exact snapshot only after") + persistence_end = source.index("markPostLoopPhase(\"persist\")", protection_start) + persistence = source[protection_start:persistence_end] + + self.assertIn("var persistedDailies = dailies", persistence) + self.assertIn("for index in persistedDailies.indices", persistence) + self.assertIn("let fresh = persistedDailies[index]", persistence) + self.assertIn("persistedDailies[index] = fresh.with(avgHrv: snapshot.avgHrv", persistence) + self.assertIn("recovery: snapshot.recovery", persistence) + self.assertIn("respRateBpm: snapshot.respRateBpm", persistence) + self.assertIn("avgSdnn: snapshot.avgSdnn", persistence) + self.assertIn("for daily in persistedDailies", persistence) + self.assertIn("dailyMetrics: persistedDailies", persistence) + self.assertNotIn("dailies[index] = fresh.with(avgHrv:", persistence) + + derivations = source[persistence_end:] + self.assertIn("let fa7 = dailies.sorted", derivations) + self.assertIn("for d in dailies { faGateByDay[d.day] = d }", derivations) + self.assertIn("let vHRVs = fa7.compactMap { $0.avgHrv }", derivations) + self.assertNotIn("persistedDailies", derivations) + + def test_android_also_protects_a_separate_persistence_copy(self) -> None: + source = ANDROID_PERSISTENCE.read_text() + preparation_start = source.index("suspend fun prepareComputedWindow") + preparation_end = source.index("fun scoreProvenance", preparation_start) + preparation = source[preparation_start:preparation_end] + + self.assertIn("val mutableDailies = dailies.toMutableList()", preparation) + self.assertIn( + "repo, computedId, from, to, mutableDailies, ownerByDay,", + preparation, + ) + self.assertIn("dailies = mutableDailies", preparation) + self.assertIn("respRateBpm = existing.respRateBpm", source) + self.assertIn("avgSdnn = existing.avgSdnn", source) + + +if __name__ == "__main__": + unittest.main() diff --git a/altstore-source.json b/altstore-source.json index b581bd3a28..a429eb1914 100644 --- a/altstore-source.json +++ b/altstore-source.json @@ -19,6 +19,60 @@ "category": "healthcare-fitness", "screenshotURLs": [], "versions": [ + { + "version": "11.8.0", + "buildVersion": "400", + "date": "2026-09-17", + "localizedDescription": "NOOP 11.8.0. See the release notes for what changed.", + "downloadURL": "https://github.com/ryanbr/noop/releases/download/v11.8.0/NOOP-ios-unsigned-v11.8.0.ipa", + "size": 21442326, + "minOSVersion": "17.0" + }, + { + "version": "11.7.0", + "buildVersion": "387", + "date": "2026-09-14", + "localizedDescription": "NOOP 11.7.0. See the release notes for what changed.", + "downloadURL": "https://github.com/ryanbr/noop/releases/download/v11.7.0/NOOP-ios-unsigned-v11.7.0.ipa", + "size": 20280838, + "minOSVersion": "17.0" + }, + { + "version": "11.6.0", + "buildVersion": "371", + "date": "2026-09-11", + "localizedDescription": "NOOP 11.6.0. See the release notes for what changed.", + "downloadURL": "https://github.com/ryanbr/noop/releases/download/v11.6.0/NOOP-ios-unsigned-v11.6.0.ipa", + "size": 20179424, + "minOSVersion": "17.0" + }, + { + "version": "11.5.0", + "buildVersion": "356", + "date": "2026-09-09", + "localizedDescription": "NOOP 11.5.0. See the release notes for what changed.", + "downloadURL": "https://github.com/ryanbr/noop/releases/download/v11.5.0/NOOP-ios-unsigned-v11.5.0.ipa", + "size": 19998667, + "minOSVersion": "17.0" + }, + { + "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 +1447,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.8.0", + "buildVersion": "400", + "versionDate": "2026-09-17", + "versionDescription": "NOOP 11.8.0. See the release notes for what changed.", + "downloadURL": "https://github.com/ryanbr/noop/releases/download/v11.8.0/NOOP-ios-unsigned-v11.8.0.ipa", + "size": 21442326, "minOSVersion": "17.0" } ], diff --git a/android/README.md b/android/README.md index baee66a92b..95ff960841 100644 --- a/android/README.md +++ b/android/README.md @@ -3,12 +3,14 @@ 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 translated from that hardware-verified implementation, not invented here, and kept in -byte-for-byte parity with it (see the repo root [`CLAUDE.md`](../CLAUDE.md)). +byte-for-byte parity with it (see the repo root [`AGENTS.md`](../AGENTS.md)). --- @@ -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 916d35d74a..cdbc18d17e 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 = 362 - versionName = "10.6.1" + versionCode = 534 + versionName = "11.8.0" 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,9 @@ 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 + testImplementation("androidx.room:room-testing:$roomVersion") // create a prior DB from exported schemas + testImplementation("org.robolectric:robolectric:4.13") // run the real Room open/migration in Linux CI (#1803) 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..b546e7b717 100644 --- a/android/app/gradle.lockfile +++ b/android/app/gradle.lockfile @@ -7,7 +7,7 @@ androidx.activity:activity:1.9.0=demoDebugAndroidTestCompileClasspath,demoDebugA androidx.annotation:annotation-experimental:1.1.0=androidTestImplementationDependenciesMetadata androidx.annotation:annotation-experimental:1.4.0=demoDebugAndroidTestCompileClasspath,demoDebugAndroidTestRuntimeClasspath,demoDebugCompileClasspath,demoDebugRuntimeClasspath,demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseCompileClasspath,demoReleaseRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugAndroidTestCompileClasspath,fullDebugAndroidTestRuntimeClasspath,fullDebugCompileClasspath,fullDebugRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseCompileClasspath,fullReleaseRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,implementationDependenciesMetadata androidx.annotation:annotation-jvm:1.8.0=demoDebugAndroidTestCompileClasspath,demoDebugAndroidTestRuntimeClasspath,demoDebugCompileClasspath,demoDebugRuntimeClasspath,demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseCompileClasspath,demoReleaseRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugAndroidTestCompileClasspath,fullDebugAndroidTestRuntimeClasspath,fullDebugCompileClasspath,fullDebugRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseCompileClasspath,fullReleaseRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath -androidx.annotation:annotation:1.3.0=_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 +androidx.annotation:annotation:1.3.0=_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,testImplementationDependenciesMetadata androidx.annotation:annotation:1.7.0=androidTestImplementationDependenciesMetadata androidx.annotation:annotation:1.8.0=demoDebugAndroidTestCompileClasspath,demoDebugAndroidTestRuntimeClasspath,demoDebugCompileClasspath,demoDebugRuntimeClasspath,demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseCompileClasspath,demoReleaseRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugAndroidTestCompileClasspath,fullDebugAndroidTestRuntimeClasspath,fullDebugCompileClasspath,fullDebugRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseCompileClasspath,fullReleaseRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,implementationDependenciesMetadata androidx.arch.core:core-common:2.2.0=demoDebugAndroidTestCompileClasspath,demoDebugAndroidTestRuntimeClasspath,demoDebugCompileClasspath,demoDebugRuntimeClasspath,demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseCompileClasspath,demoReleaseRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugAndroidTestCompileClasspath,fullDebugAndroidTestRuntimeClasspath,fullDebugCompileClasspath,fullDebugRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseCompileClasspath,fullReleaseRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,implementationDependenciesMetadata @@ -107,30 +107,34 @@ androidx.navigation:navigation-compose:2.7.7=demoDebugAndroidTestCompileClasspat androidx.navigation:navigation-runtime-ktx:2.7.7=demoDebugAndroidTestCompileClasspath,demoDebugCompileClasspath,demoDebugRuntimeClasspath,demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseCompileClasspath,demoReleaseRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugAndroidTestCompileClasspath,fullDebugCompileClasspath,fullDebugRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseCompileClasspath,fullReleaseRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,implementationDependenciesMetadata androidx.navigation:navigation-runtime:2.7.7=demoDebugAndroidTestCompileClasspath,demoDebugCompileClasspath,demoDebugRuntimeClasspath,demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseCompileClasspath,demoReleaseRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugAndroidTestCompileClasspath,fullDebugCompileClasspath,fullDebugRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseCompileClasspath,fullReleaseRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,implementationDependenciesMetadata androidx.profileinstaller:profileinstaller:1.3.1=demoDebugAndroidTestRuntimeClasspath,demoDebugRuntimeClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseRuntimeClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugAndroidTestRuntimeClasspath,fullDebugRuntimeClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseRuntimeClasspath,fullReleaseUnitTestRuntimeClasspath -androidx.room:room-common:2.6.1=_agp_internal_javaPreCompileDemoDebug_kspClasspath,_agp_internal_javaPreCompileDemoRelease_kspClasspath,_agp_internal_javaPreCompileFullDebug_kspClasspath,_agp_internal_javaPreCompileFullRelease_kspClasspath,demoDebugAndroidTestCompileClasspath,demoDebugCompileClasspath,demoDebugRuntimeClasspath,demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseCompileClasspath,demoReleaseRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugAndroidTestCompileClasspath,fullDebugCompileClasspath,fullDebugRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseCompileClasspath,fullReleaseRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,implementationDependenciesMetadata,kspDemoDebugAndroidTestKotlinProcessorClasspath,kspDemoDebugKotlinProcessorClasspath,kspDemoDebugUnitTestKotlinProcessorClasspath,kspDemoReleaseKotlinProcessorClasspath,kspDemoReleaseUnitTestKotlinProcessorClasspath,kspFullDebugAndroidTestKotlinProcessorClasspath,kspFullDebugKotlinProcessorClasspath,kspFullDebugUnitTestKotlinProcessorClasspath,kspFullReleaseKotlinProcessorClasspath,kspFullReleaseUnitTestKotlinProcessorClasspath +androidx.room:room-common:2.6.1=_agp_internal_javaPreCompileDemoDebug_kspClasspath,_agp_internal_javaPreCompileDemoRelease_kspClasspath,_agp_internal_javaPreCompileFullDebug_kspClasspath,_agp_internal_javaPreCompileFullRelease_kspClasspath,demoDebugAndroidTestCompileClasspath,demoDebugCompileClasspath,demoDebugRuntimeClasspath,demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseCompileClasspath,demoReleaseRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugAndroidTestCompileClasspath,fullDebugCompileClasspath,fullDebugRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseCompileClasspath,fullReleaseRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,implementationDependenciesMetadata,kspDemoDebugAndroidTestKotlinProcessorClasspath,kspDemoDebugKotlinProcessorClasspath,kspDemoDebugUnitTestKotlinProcessorClasspath,kspDemoReleaseKotlinProcessorClasspath,kspDemoReleaseUnitTestKotlinProcessorClasspath,kspFullDebugAndroidTestKotlinProcessorClasspath,kspFullDebugKotlinProcessorClasspath,kspFullDebugUnitTestKotlinProcessorClasspath,kspFullReleaseKotlinProcessorClasspath,kspFullReleaseUnitTestKotlinProcessorClasspath,testImplementationDependenciesMetadata androidx.room:room-compiler-processing:2.6.1=_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 androidx.room:room-compiler:2.6.1=_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 androidx.room:room-ktx:2.6.1=demoDebugAndroidTestCompileClasspath,demoDebugCompileClasspath,demoDebugRuntimeClasspath,demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseCompileClasspath,demoReleaseRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugAndroidTestCompileClasspath,fullDebugCompileClasspath,fullDebugRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseCompileClasspath,fullReleaseRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,implementationDependenciesMetadata -androidx.room:room-migration:2.6.1=_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 -androidx.room:room-runtime:2.6.1=demoDebugAndroidTestCompileClasspath,demoDebugCompileClasspath,demoDebugRuntimeClasspath,demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseCompileClasspath,demoReleaseRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugAndroidTestCompileClasspath,fullDebugCompileClasspath,fullDebugRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseCompileClasspath,fullReleaseRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,implementationDependenciesMetadata +androidx.room:room-migration:2.6.1=_agp_internal_javaPreCompileDemoDebug_kspClasspath,_agp_internal_javaPreCompileDemoRelease_kspClasspath,_agp_internal_javaPreCompileFullDebug_kspClasspath,_agp_internal_javaPreCompileFullRelease_kspClasspath,demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,kspDemoDebugAndroidTestKotlinProcessorClasspath,kspDemoDebugKotlinProcessorClasspath,kspDemoDebugUnitTestKotlinProcessorClasspath,kspDemoReleaseKotlinProcessorClasspath,kspDemoReleaseUnitTestKotlinProcessorClasspath,kspFullDebugAndroidTestKotlinProcessorClasspath,kspFullDebugKotlinProcessorClasspath,kspFullDebugUnitTestKotlinProcessorClasspath,kspFullReleaseKotlinProcessorClasspath,kspFullReleaseUnitTestKotlinProcessorClasspath,testImplementationDependenciesMetadata +androidx.room:room-runtime:2.6.1=demoDebugAndroidTestCompileClasspath,demoDebugCompileClasspath,demoDebugRuntimeClasspath,demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseCompileClasspath,demoReleaseRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugAndroidTestCompileClasspath,fullDebugCompileClasspath,fullDebugRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseCompileClasspath,fullReleaseRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,implementationDependenciesMetadata,testImplementationDependenciesMetadata +androidx.room:room-testing:2.6.1=demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,testImplementationDependenciesMetadata androidx.savedstate:savedstate-ktx:1.2.1=demoDebugAndroidTestCompileClasspath,demoDebugAndroidTestRuntimeClasspath,demoDebugCompileClasspath,demoDebugRuntimeClasspath,demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseCompileClasspath,demoReleaseRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugAndroidTestCompileClasspath,fullDebugAndroidTestRuntimeClasspath,fullDebugCompileClasspath,fullDebugRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseCompileClasspath,fullReleaseRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,implementationDependenciesMetadata androidx.savedstate:savedstate:1.2.1=demoDebugAndroidTestCompileClasspath,demoDebugAndroidTestRuntimeClasspath,demoDebugCompileClasspath,demoDebugRuntimeClasspath,demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseCompileClasspath,demoReleaseRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugAndroidTestCompileClasspath,fullDebugAndroidTestRuntimeClasspath,fullDebugCompileClasspath,fullDebugRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseCompileClasspath,fullReleaseRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,implementationDependenciesMetadata androidx.security:security-crypto:1.1.0-alpha06=demoDebugAndroidTestCompileClasspath,demoDebugCompileClasspath,demoDebugRuntimeClasspath,demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseCompileClasspath,demoReleaseRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugAndroidTestCompileClasspath,fullDebugCompileClasspath,fullDebugRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseCompileClasspath,fullReleaseRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,implementationDependenciesMetadata -androidx.sqlite:sqlite-framework:2.4.0=demoDebugAndroidTestCompileClasspath,demoDebugCompileClasspath,demoDebugRuntimeClasspath,demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseCompileClasspath,demoReleaseRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugAndroidTestCompileClasspath,fullDebugCompileClasspath,fullDebugRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseCompileClasspath,fullReleaseRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,implementationDependenciesMetadata -androidx.sqlite:sqlite:2.4.0=demoDebugAndroidTestCompileClasspath,demoDebugCompileClasspath,demoDebugRuntimeClasspath,demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseCompileClasspath,demoReleaseRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugAndroidTestCompileClasspath,fullDebugCompileClasspath,fullDebugRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseCompileClasspath,fullReleaseRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,implementationDependenciesMetadata +androidx.sqlite:sqlite-framework:2.4.0=demoDebugAndroidTestCompileClasspath,demoDebugCompileClasspath,demoDebugRuntimeClasspath,demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseCompileClasspath,demoReleaseRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugAndroidTestCompileClasspath,fullDebugCompileClasspath,fullDebugRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseCompileClasspath,fullReleaseRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,implementationDependenciesMetadata,testImplementationDependenciesMetadata +androidx.sqlite:sqlite:2.4.0=demoDebugAndroidTestCompileClasspath,demoDebugCompileClasspath,demoDebugRuntimeClasspath,demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseCompileClasspath,demoReleaseRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugAndroidTestCompileClasspath,fullDebugCompileClasspath,fullDebugRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseCompileClasspath,fullReleaseRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,implementationDependenciesMetadata,testImplementationDependenciesMetadata androidx.startup:startup-runtime:1.1.1=demoDebugAndroidTestCompileClasspath,demoDebugAndroidTestRuntimeClasspath,demoDebugCompileClasspath,demoDebugRuntimeClasspath,demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseCompileClasspath,demoReleaseRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugAndroidTestCompileClasspath,fullDebugAndroidTestRuntimeClasspath,fullDebugCompileClasspath,fullDebugRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseCompileClasspath,fullReleaseRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,implementationDependenciesMetadata androidx.test.espresso:espresso-core:3.5.1=androidTestImplementationDependenciesMetadata,demoDebugAndroidTestCompileClasspath,demoDebugAndroidTestRuntimeClasspath,fullDebugAndroidTestCompileClasspath,fullDebugAndroidTestRuntimeClasspath androidx.test.espresso:espresso-idling-resource:3.5.1=androidTestImplementationDependenciesMetadata,demoDebugAndroidTestCompileClasspath,demoDebugAndroidTestRuntimeClasspath,fullDebugAndroidTestCompileClasspath,fullDebugAndroidTestRuntimeClasspath +androidx.test.espresso:espresso-idling-resource:3.6.1=demoDebugUnitTestRuntimeClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseUnitTestRuntimeClasspath androidx.test.ext:junit:1.1.5=androidTestImplementationDependenciesMetadata,demoDebugAndroidTestCompileClasspath,demoDebugAndroidTestRuntimeClasspath,fullDebugAndroidTestCompileClasspath,fullDebugAndroidTestRuntimeClasspath androidx.test.services:storage:1.4.2=androidTestImplementationDependenciesMetadata,demoDebugAndroidTestCompileClasspath,demoDebugAndroidTestRuntimeClasspath,fullDebugAndroidTestCompileClasspath,fullDebugAndroidTestRuntimeClasspath androidx.test:annotation:1.0.1=androidTestImplementationDependenciesMetadata,demoDebugAndroidTestCompileClasspath,demoDebugAndroidTestRuntimeClasspath,fullDebugAndroidTestCompileClasspath,fullDebugAndroidTestRuntimeClasspath androidx.test:core:1.5.0=androidTestImplementationDependenciesMetadata,demoDebugAndroidTestCompileClasspath,demoDebugAndroidTestRuntimeClasspath,fullDebugAndroidTestCompileClasspath,fullDebugAndroidTestRuntimeClasspath androidx.test:monitor:1.6.1=androidTestImplementationDependenciesMetadata,demoDebugAndroidTestCompileClasspath,demoDebugAndroidTestRuntimeClasspath,fullDebugAndroidTestCompileClasspath,fullDebugAndroidTestRuntimeClasspath +androidx.test:monitor:1.7.1=demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,testImplementationDependenciesMetadata androidx.test:runner:1.5.2=androidTestImplementationDependenciesMetadata,demoDebugAndroidTestCompileClasspath,demoDebugAndroidTestRuntimeClasspath,fullDebugAndroidTestCompileClasspath,fullDebugAndroidTestRuntimeClasspath androidx.tracing:tracing:1.0.0=androidTestImplementationDependenciesMetadata,demoDebugAndroidTestCompileClasspath,demoDebugAndroidTestRuntimeClasspath,demoDebugRuntimeClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseRuntimeClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugAndroidTestCompileClasspath,fullDebugAndroidTestRuntimeClasspath,fullDebugRuntimeClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseRuntimeClasspath,fullReleaseUnitTestRuntimeClasspath androidx.versionedparcelable:versionedparcelable:1.1.1=demoDebugAndroidTestCompileClasspath,demoDebugAndroidTestRuntimeClasspath,demoDebugCompileClasspath,demoDebugRuntimeClasspath,demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseCompileClasspath,demoReleaseRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugAndroidTestCompileClasspath,fullDebugAndroidTestRuntimeClasspath,fullDebugCompileClasspath,fullDebugRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseCompileClasspath,fullReleaseRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,implementationDependenciesMetadata androidx.work:work-runtime-ktx:2.9.0=demoDebugAndroidTestCompileClasspath,demoDebugCompileClasspath,demoDebugRuntimeClasspath,demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseCompileClasspath,demoReleaseRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugAndroidTestCompileClasspath,fullDebugCompileClasspath,fullDebugRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseCompileClasspath,fullReleaseRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,implementationDependenciesMetadata androidx.work:work-runtime:2.9.0=demoDebugAndroidTestCompileClasspath,demoDebugCompileClasspath,demoDebugRuntimeClasspath,demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseCompileClasspath,demoReleaseRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugAndroidTestCompileClasspath,fullDebugCompileClasspath,fullDebugRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseCompileClasspath,fullReleaseRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,implementationDependenciesMetadata +com.almworks.sqlite4java:sqlite4java:1.0.392=demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,testImplementationDependenciesMetadata com.android.tools.ddms:ddmlib:31.5.2=_internal-unified-test-platform-android-device-provider-ddmlib com.android.tools.emulator:proto:31.5.2=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-retention com.android.tools.utp:android-device-provider-ddmlib-proto:31.5.2=_internal-unified-test-platform-android-device-provider-ddmlib @@ -158,28 +162,30 @@ com.android.tools:annotations:31.5.2=_internal-unified-test-platform-android-dev com.android.tools:common:31.5.2=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle com.google.android:annotations:4.1.1.4=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle com.google.api.grpc:proto-google-common-protos:2.17.0=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle +com.google.auto.value:auto-value-annotations:1.11.0=demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,testImplementationDependenciesMetadata com.google.auto.value:auto-value-annotations:1.6.3=_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 com.google.auto:auto-common:0.11=_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 com.google.code.findbugs:jsr305:2.0.2=androidTestImplementationDependenciesMetadata -com.google.code.findbugs:jsr305:3.0.2=_agp_internal_javaPreCompileDemoDebug_kspClasspath,_agp_internal_javaPreCompileDemoRelease_kspClasspath,_agp_internal_javaPreCompileFullDebug_kspClasspath,_agp_internal_javaPreCompileFullRelease_kspClasspath,_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,demoDebugAndroidTestCompileClasspath,demoDebugAndroidTestRuntimeClasspath,demoDebugRuntimeClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseRuntimeClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugAndroidTestCompileClasspath,fullDebugAndroidTestRuntimeClasspath,fullDebugRuntimeClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseRuntimeClasspath,fullReleaseUnitTestRuntimeClasspath,kspDemoDebugAndroidTestKotlinProcessorClasspath,kspDemoDebugKotlinProcessorClasspath,kspDemoDebugUnitTestKotlinProcessorClasspath,kspDemoReleaseKotlinProcessorClasspath,kspDemoReleaseUnitTestKotlinProcessorClasspath,kspFullDebugAndroidTestKotlinProcessorClasspath,kspFullDebugKotlinProcessorClasspath,kspFullDebugUnitTestKotlinProcessorClasspath,kspFullReleaseKotlinProcessorClasspath,kspFullReleaseUnitTestKotlinProcessorClasspath +com.google.code.findbugs:jsr305:3.0.2=_agp_internal_javaPreCompileDemoDebug_kspClasspath,_agp_internal_javaPreCompileDemoRelease_kspClasspath,_agp_internal_javaPreCompileFullDebug_kspClasspath,_agp_internal_javaPreCompileFullRelease_kspClasspath,_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,demoDebugAndroidTestCompileClasspath,demoDebugAndroidTestRuntimeClasspath,demoDebugRuntimeClasspath,demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugAndroidTestCompileClasspath,fullDebugAndroidTestRuntimeClasspath,fullDebugRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,kspDemoDebugAndroidTestKotlinProcessorClasspath,kspDemoDebugKotlinProcessorClasspath,kspDemoDebugUnitTestKotlinProcessorClasspath,kspDemoReleaseKotlinProcessorClasspath,kspDemoReleaseUnitTestKotlinProcessorClasspath,kspFullDebugAndroidTestKotlinProcessorClasspath,kspFullDebugKotlinProcessorClasspath,kspFullDebugUnitTestKotlinProcessorClasspath,kspFullReleaseKotlinProcessorClasspath,kspFullReleaseUnitTestKotlinProcessorClasspath,testImplementationDependenciesMetadata com.google.code.gson:gson:2.10.1=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle -com.google.code.gson:gson:2.8.9=demoDebugRuntimeClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseRuntimeClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugRuntimeClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseRuntimeClasspath,fullReleaseUnitTestRuntimeClasspath -com.google.code.gson:gson:2.9.0=_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 +com.google.code.gson:gson:2.8.9=demoDebugRuntimeClasspath,demoReleaseRuntimeClasspath,fullDebugRuntimeClasspath,fullReleaseRuntimeClasspath +com.google.code.gson:gson:2.9.0=_agp_internal_javaPreCompileDemoDebug_kspClasspath,_agp_internal_javaPreCompileDemoRelease_kspClasspath,_agp_internal_javaPreCompileFullDebug_kspClasspath,_agp_internal_javaPreCompileFullRelease_kspClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseUnitTestRuntimeClasspath,kspDemoDebugAndroidTestKotlinProcessorClasspath,kspDemoDebugKotlinProcessorClasspath,kspDemoDebugUnitTestKotlinProcessorClasspath,kspDemoReleaseKotlinProcessorClasspath,kspDemoReleaseUnitTestKotlinProcessorClasspath,kspFullDebugAndroidTestKotlinProcessorClasspath,kspFullDebugKotlinProcessorClasspath,kspFullDebugUnitTestKotlinProcessorClasspath,kspFullReleaseKotlinProcessorClasspath,kspFullReleaseUnitTestKotlinProcessorClasspath com.google.crypto.tink:tink-android:1.8.0=demoDebugRuntimeClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseRuntimeClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugRuntimeClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseRuntimeClasspath,fullReleaseUnitTestRuntimeClasspath com.google.crypto.tink:tink:1.7.0=_internal-unified-test-platform-android-test-plugin-host-emulator-control com.google.devtools.ksp:symbol-processing-api:1.9.0-1.0.13=_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 com.google.devtools.ksp:symbol-processing-api:1.9.24-1.0.20=kotlinCompilerPluginClasspathDemoDebug,kotlinCompilerPluginClasspathDemoDebugAndroidTest,kotlinCompilerPluginClasspathDemoDebugUnitTest,kotlinCompilerPluginClasspathDemoRelease,kotlinCompilerPluginClasspathDemoReleaseUnitTest,kotlinCompilerPluginClasspathFullDebug,kotlinCompilerPluginClasspathFullDebugAndroidTest,kotlinCompilerPluginClasspathFullDebugUnitTest,kotlinCompilerPluginClasspathFullRelease,kotlinCompilerPluginClasspathFullReleaseUnitTest,kspPluginClasspath,kspPluginClasspathNonEmbeddable com.google.devtools.ksp:symbol-processing-cmdline:1.9.24-1.0.20=kspPluginClasspathNonEmbeddable com.google.devtools.ksp:symbol-processing:1.9.24-1.0.20=kotlinCompilerPluginClasspathDemoDebug,kotlinCompilerPluginClasspathDemoDebugAndroidTest,kotlinCompilerPluginClasspathDemoDebugUnitTest,kotlinCompilerPluginClasspathDemoRelease,kotlinCompilerPluginClasspathDemoReleaseUnitTest,kotlinCompilerPluginClasspathFullDebug,kotlinCompilerPluginClasspathFullDebugAndroidTest,kotlinCompilerPluginClasspathFullDebugUnitTest,kotlinCompilerPluginClasspathFullRelease,kotlinCompilerPluginClasspathFullReleaseUnitTest,kspPluginClasspath -com.google.errorprone:error_prone_annotations:2.11.0=_agp_internal_javaPreCompileDemoDebug_kspClasspath,_agp_internal_javaPreCompileDemoRelease_kspClasspath,_agp_internal_javaPreCompileFullDebug_kspClasspath,_agp_internal_javaPreCompileFullRelease_kspClasspath,demoDebugRuntimeClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseRuntimeClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugRuntimeClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseRuntimeClasspath,fullReleaseUnitTestRuntimeClasspath,kspDemoDebugAndroidTestKotlinProcessorClasspath,kspDemoDebugKotlinProcessorClasspath,kspDemoDebugUnitTestKotlinProcessorClasspath,kspDemoReleaseKotlinProcessorClasspath,kspDemoReleaseUnitTestKotlinProcessorClasspath,kspFullDebugAndroidTestKotlinProcessorClasspath,kspFullDebugKotlinProcessorClasspath,kspFullDebugUnitTestKotlinProcessorClasspath,kspFullReleaseKotlinProcessorClasspath,kspFullReleaseUnitTestKotlinProcessorClasspath +com.google.errorprone:error_prone_annotation:2.19.1=demoDebugUnitTestRuntimeClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseUnitTestRuntimeClasspath +com.google.errorprone:error_prone_annotations:2.11.0=_agp_internal_javaPreCompileDemoDebug_kspClasspath,_agp_internal_javaPreCompileDemoRelease_kspClasspath,_agp_internal_javaPreCompileFullDebug_kspClasspath,_agp_internal_javaPreCompileFullRelease_kspClasspath,demoDebugRuntimeClasspath,demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,kspDemoDebugAndroidTestKotlinProcessorClasspath,kspDemoDebugKotlinProcessorClasspath,kspDemoDebugUnitTestKotlinProcessorClasspath,kspDemoReleaseKotlinProcessorClasspath,kspDemoReleaseUnitTestKotlinProcessorClasspath,kspFullDebugAndroidTestKotlinProcessorClasspath,kspFullDebugKotlinProcessorClasspath,kspFullDebugUnitTestKotlinProcessorClasspath,kspFullReleaseKotlinProcessorClasspath,kspFullReleaseUnitTestKotlinProcessorClasspath,testImplementationDependenciesMetadata com.google.errorprone:error_prone_annotations:2.18.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle -com.google.guava:failureaccess:1.0.1=_agp_internal_javaPreCompileDemoDebug_kspClasspath,_agp_internal_javaPreCompileDemoRelease_kspClasspath,_agp_internal_javaPreCompileFullDebug_kspClasspath,_agp_internal_javaPreCompileFullRelease_kspClasspath,_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,demoDebugRuntimeClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseRuntimeClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugRuntimeClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseRuntimeClasspath,fullReleaseUnitTestRuntimeClasspath,kspDemoDebugAndroidTestKotlinProcessorClasspath,kspDemoDebugKotlinProcessorClasspath,kspDemoDebugUnitTestKotlinProcessorClasspath,kspDemoReleaseKotlinProcessorClasspath,kspDemoReleaseUnitTestKotlinProcessorClasspath,kspFullDebugAndroidTestKotlinProcessorClasspath,kspFullDebugKotlinProcessorClasspath,kspFullDebugUnitTestKotlinProcessorClasspath,kspFullReleaseKotlinProcessorClasspath,kspFullReleaseUnitTestKotlinProcessorClasspath -com.google.guava:guava:31.1-android=demoDebugRuntimeClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseRuntimeClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugRuntimeClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseRuntimeClasspath,fullReleaseUnitTestRuntimeClasspath -com.google.guava:guava:31.1-jre=_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 +com.google.guava:failureaccess:1.0.1=_agp_internal_javaPreCompileDemoDebug_kspClasspath,_agp_internal_javaPreCompileDemoRelease_kspClasspath,_agp_internal_javaPreCompileFullDebug_kspClasspath,_agp_internal_javaPreCompileFullRelease_kspClasspath,_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,demoDebugRuntimeClasspath,demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,kspDemoDebugAndroidTestKotlinProcessorClasspath,kspDemoDebugKotlinProcessorClasspath,kspDemoDebugUnitTestKotlinProcessorClasspath,kspDemoReleaseKotlinProcessorClasspath,kspDemoReleaseUnitTestKotlinProcessorClasspath,kspFullDebugAndroidTestKotlinProcessorClasspath,kspFullDebugKotlinProcessorClasspath,kspFullDebugUnitTestKotlinProcessorClasspath,kspFullReleaseKotlinProcessorClasspath,kspFullReleaseUnitTestKotlinProcessorClasspath,testImplementationDependenciesMetadata +com.google.guava:guava:31.1-android=demoDebugRuntimeClasspath,demoReleaseRuntimeClasspath,fullDebugRuntimeClasspath,fullReleaseRuntimeClasspath +com.google.guava:guava:31.1-jre=_agp_internal_javaPreCompileDemoDebug_kspClasspath,_agp_internal_javaPreCompileDemoRelease_kspClasspath,_agp_internal_javaPreCompileFullDebug_kspClasspath,_agp_internal_javaPreCompileFullRelease_kspClasspath,demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,kspDemoDebugAndroidTestKotlinProcessorClasspath,kspDemoDebugKotlinProcessorClasspath,kspDemoDebugUnitTestKotlinProcessorClasspath,kspDemoReleaseKotlinProcessorClasspath,kspDemoReleaseUnitTestKotlinProcessorClasspath,kspFullDebugAndroidTestKotlinProcessorClasspath,kspFullDebugKotlinProcessorClasspath,kspFullDebugUnitTestKotlinProcessorClasspath,kspFullReleaseKotlinProcessorClasspath,kspFullReleaseUnitTestKotlinProcessorClasspath,testImplementationDependenciesMetadata com.google.guava:guava:32.0.1-jre=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle com.google.guava:listenablefuture:1.0=androidTestImplementationDependenciesMetadata,implementationDependenciesMetadata -com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=_agp_internal_javaPreCompileDemoDebug_kspClasspath,_agp_internal_javaPreCompileDemoRelease_kspClasspath,_agp_internal_javaPreCompileFullDebug_kspClasspath,_agp_internal_javaPreCompileFullRelease_kspClasspath,_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,demoDebugAndroidTestCompileClasspath,demoDebugAndroidTestRuntimeClasspath,demoDebugCompileClasspath,demoDebugRuntimeClasspath,demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseCompileClasspath,demoReleaseRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugAndroidTestCompileClasspath,fullDebugAndroidTestRuntimeClasspath,fullDebugCompileClasspath,fullDebugRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseCompileClasspath,fullReleaseRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,kspDemoDebugAndroidTestKotlinProcessorClasspath,kspDemoDebugKotlinProcessorClasspath,kspDemoDebugUnitTestKotlinProcessorClasspath,kspDemoReleaseKotlinProcessorClasspath,kspDemoReleaseUnitTestKotlinProcessorClasspath,kspFullDebugAndroidTestKotlinProcessorClasspath,kspFullDebugKotlinProcessorClasspath,kspFullDebugUnitTestKotlinProcessorClasspath,kspFullReleaseKotlinProcessorClasspath,kspFullReleaseUnitTestKotlinProcessorClasspath -com.google.j2objc:j2objc-annotations:1.3=_agp_internal_javaPreCompileDemoDebug_kspClasspath,_agp_internal_javaPreCompileDemoRelease_kspClasspath,_agp_internal_javaPreCompileFullDebug_kspClasspath,_agp_internal_javaPreCompileFullRelease_kspClasspath,demoDebugRuntimeClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseRuntimeClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugRuntimeClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseRuntimeClasspath,fullReleaseUnitTestRuntimeClasspath,kspDemoDebugAndroidTestKotlinProcessorClasspath,kspDemoDebugKotlinProcessorClasspath,kspDemoDebugUnitTestKotlinProcessorClasspath,kspDemoReleaseKotlinProcessorClasspath,kspDemoReleaseUnitTestKotlinProcessorClasspath,kspFullDebugAndroidTestKotlinProcessorClasspath,kspFullDebugKotlinProcessorClasspath,kspFullDebugUnitTestKotlinProcessorClasspath,kspFullReleaseKotlinProcessorClasspath,kspFullReleaseUnitTestKotlinProcessorClasspath +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=_agp_internal_javaPreCompileDemoDebug_kspClasspath,_agp_internal_javaPreCompileDemoRelease_kspClasspath,_agp_internal_javaPreCompileFullDebug_kspClasspath,_agp_internal_javaPreCompileFullRelease_kspClasspath,_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,demoDebugAndroidTestCompileClasspath,demoDebugAndroidTestRuntimeClasspath,demoDebugCompileClasspath,demoDebugRuntimeClasspath,demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseCompileClasspath,demoReleaseRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugAndroidTestCompileClasspath,fullDebugAndroidTestRuntimeClasspath,fullDebugCompileClasspath,fullDebugRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseCompileClasspath,fullReleaseRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,kspDemoDebugAndroidTestKotlinProcessorClasspath,kspDemoDebugKotlinProcessorClasspath,kspDemoDebugUnitTestKotlinProcessorClasspath,kspDemoReleaseKotlinProcessorClasspath,kspDemoReleaseUnitTestKotlinProcessorClasspath,kspFullDebugAndroidTestKotlinProcessorClasspath,kspFullDebugKotlinProcessorClasspath,kspFullDebugUnitTestKotlinProcessorClasspath,kspFullReleaseKotlinProcessorClasspath,kspFullReleaseUnitTestKotlinProcessorClasspath,testImplementationDependenciesMetadata +com.google.j2objc:j2objc-annotations:1.3=_agp_internal_javaPreCompileDemoDebug_kspClasspath,_agp_internal_javaPreCompileDemoRelease_kspClasspath,_agp_internal_javaPreCompileFullDebug_kspClasspath,_agp_internal_javaPreCompileFullRelease_kspClasspath,demoDebugRuntimeClasspath,demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,kspDemoDebugAndroidTestKotlinProcessorClasspath,kspDemoDebugKotlinProcessorClasspath,kspDemoDebugUnitTestKotlinProcessorClasspath,kspDemoReleaseKotlinProcessorClasspath,kspDemoReleaseUnitTestKotlinProcessorClasspath,kspFullDebugAndroidTestKotlinProcessorClasspath,kspFullDebugKotlinProcessorClasspath,kspFullDebugUnitTestKotlinProcessorClasspath,kspFullReleaseKotlinProcessorClasspath,kspFullReleaseUnitTestKotlinProcessorClasspath,testImplementationDependenciesMetadata com.google.j2objc:j2objc-annotations:2.8=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle com.google.protobuf:protobuf-java:3.22.3=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle com.google.testing.platform:android-device-provider-local:0.0.9-alpha02=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle @@ -188,6 +194,7 @@ com.google.testing.platform:android-test-plugin:0.0.9-alpha02=_internal-unified- com.google.testing.platform:core-proto:0.0.9-alpha02=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-result-listener-gradle com.google.testing.platform:core:0.0.9-alpha02=_internal-unified-test-platform-core com.google.testing.platform:launcher:0.0.9-alpha02=_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-launcher +com.ibm.icu:icu4j:75.1=demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,testImplementationDependenciesMetadata com.intellij:annotations:12.0=_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 com.squareup.okhttp3:okhttp:4.12.0=demoDebugAndroidTestCompileClasspath,demoDebugCompileClasspath,demoDebugRuntimeClasspath,demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseCompileClasspath,demoReleaseRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugAndroidTestCompileClasspath,fullDebugCompileClasspath,fullDebugRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseCompileClasspath,fullReleaseRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,implementationDependenciesMetadata com.squareup.okio:okio-jvm:3.6.0=demoDebugAndroidTestCompileClasspath,demoDebugCompileClasspath,demoDebugRuntimeClasspath,demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseCompileClasspath,demoReleaseRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugAndroidTestCompileClasspath,fullDebugCompileClasspath,fullDebugRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseCompileClasspath,fullReleaseRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath @@ -217,15 +224,17 @@ io.netty:netty-resolver:4.1.93.Final=_internal-unified-test-platform-android-tes io.netty:netty-transport-native-unix-common:4.1.93.Final=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle io.netty:netty-transport:4.1.93.Final=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle io.perfmark:perfmark-api:0.26.0=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle -javax.annotation:javax.annotation-api:1.3.2=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle -javax.inject:javax.inject:1=androidTestImplementationDependenciesMetadata,demoDebugAndroidTestCompileClasspath,demoDebugAndroidTestRuntimeClasspath,fullDebugAndroidTestCompileClasspath,fullDebugAndroidTestRuntimeClasspath +javax.annotation:javax.annotation-api:1.3.2=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,testImplementationDependenciesMetadata +javax.inject:javax.inject:1=androidTestImplementationDependenciesMetadata,demoDebugAndroidTestCompileClasspath,demoDebugAndroidTestRuntimeClasspath,demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugAndroidTestCompileClasspath,fullDebugAndroidTestRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,testImplementationDependenciesMetadata junit:junit:4.13.2=androidTestImplementationDependenciesMetadata,demoDebugAndroidTestCompileClasspath,demoDebugAndroidTestRuntimeClasspath,demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugAndroidTestCompileClasspath,fullDebugAndroidTestRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,testImplementationDependenciesMetadata net.java.dev.jna:jna-platform:5.6.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle net.java.dev.jna:jna:5.6.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle net.sf.kxml:kxml2:2.3.0=_internal-unified-test-platform-android-device-provider-ddmlib,demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,testImplementationDependenciesMetadata -org.checkerframework:checker-qual:3.12.0=_agp_internal_javaPreCompileDemoDebug_kspClasspath,_agp_internal_javaPreCompileDemoRelease_kspClasspath,_agp_internal_javaPreCompileFullDebug_kspClasspath,_agp_internal_javaPreCompileFullRelease_kspClasspath,demoDebugRuntimeClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseRuntimeClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugRuntimeClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseRuntimeClasspath,fullReleaseUnitTestRuntimeClasspath,kspDemoDebugAndroidTestKotlinProcessorClasspath,kspDemoDebugKotlinProcessorClasspath,kspDemoDebugUnitTestKotlinProcessorClasspath,kspDemoReleaseKotlinProcessorClasspath,kspDemoReleaseUnitTestKotlinProcessorClasspath,kspFullDebugAndroidTestKotlinProcessorClasspath,kspFullDebugKotlinProcessorClasspath,kspFullDebugUnitTestKotlinProcessorClasspath,kspFullReleaseKotlinProcessorClasspath,kspFullReleaseUnitTestKotlinProcessorClasspath +org.bouncycastle:bcprov-jdk18on:1.78.1=demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,testImplementationDependenciesMetadata +org.checkerframework:checker-qual:3.12.0=_agp_internal_javaPreCompileDemoDebug_kspClasspath,_agp_internal_javaPreCompileDemoRelease_kspClasspath,_agp_internal_javaPreCompileFullDebug_kspClasspath,_agp_internal_javaPreCompileFullRelease_kspClasspath,demoDebugRuntimeClasspath,demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,kspDemoDebugAndroidTestKotlinProcessorClasspath,kspDemoDebugKotlinProcessorClasspath,kspDemoDebugUnitTestKotlinProcessorClasspath,kspDemoReleaseKotlinProcessorClasspath,kspDemoReleaseUnitTestKotlinProcessorClasspath,kspFullDebugAndroidTestKotlinProcessorClasspath,kspFullDebugKotlinProcessorClasspath,kspFullDebugUnitTestKotlinProcessorClasspath,kspFullReleaseKotlinProcessorClasspath,kspFullReleaseUnitTestKotlinProcessorClasspath,testImplementationDependenciesMetadata org.checkerframework:checker-qual:3.33.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle org.codehaus.mojo:animal-sniffer-annotations:1.23=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle +org.conscrypt:conscrypt-openjdk-uber:2.5.2=demoDebugUnitTestRuntimeClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseUnitTestRuntimeClasspath org.hamcrest:hamcrest-core:1.3=androidTestImplementationDependenciesMetadata,demoDebugAndroidTestCompileClasspath,demoDebugAndroidTestRuntimeClasspath,demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugAndroidTestCompileClasspath,fullDebugAndroidTestRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,testImplementationDependenciesMetadata org.hamcrest:hamcrest-integration:1.3=androidTestImplementationDependenciesMetadata,demoDebugAndroidTestCompileClasspath,demoDebugAndroidTestRuntimeClasspath,fullDebugAndroidTestCompileClasspath,fullDebugAndroidTestRuntimeClasspath org.hamcrest:hamcrest-library:1.3=androidTestImplementationDependenciesMetadata,demoDebugAndroidTestCompileClasspath,demoDebugAndroidTestRuntimeClasspath,fullDebugAndroidTestCompileClasspath,fullDebugAndroidTestRuntimeClasspath @@ -248,12 +257,12 @@ org.jetbrains.kotlin:kotlin-stdlib-common:1.9.20=_internal-unified-test-platform org.jetbrains.kotlin:kotlin-stdlib-common:1.9.21=testImplementationDependenciesMetadata org.jetbrains.kotlin:kotlin-stdlib-common:1.9.24=demoDebugAndroidTestCompileClasspath,demoDebugCompileClasspath,demoDebugRuntimeClasspath,demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseCompileClasspath,demoReleaseRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugAndroidTestCompileClasspath,fullDebugCompileClasspath,fullDebugRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseCompileClasspath,fullReleaseRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,implementationDependenciesMetadata org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.20=androidTestImplementationDependenciesMetadata -org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.22=_agp_internal_javaPreCompileDemoDebug_kspClasspath,_agp_internal_javaPreCompileDemoRelease_kspClasspath,_agp_internal_javaPreCompileFullDebug_kspClasspath,_agp_internal_javaPreCompileFullRelease_kspClasspath,implementationDependenciesMetadata,kspDemoDebugAndroidTestKotlinProcessorClasspath,kspDemoDebugKotlinProcessorClasspath,kspDemoDebugUnitTestKotlinProcessorClasspath,kspDemoReleaseKotlinProcessorClasspath,kspDemoReleaseUnitTestKotlinProcessorClasspath,kspFullDebugAndroidTestKotlinProcessorClasspath,kspFullDebugKotlinProcessorClasspath,kspFullDebugUnitTestKotlinProcessorClasspath,kspFullReleaseKotlinProcessorClasspath,kspFullReleaseUnitTestKotlinProcessorClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.22=_agp_internal_javaPreCompileDemoDebug_kspClasspath,_agp_internal_javaPreCompileDemoRelease_kspClasspath,_agp_internal_javaPreCompileFullDebug_kspClasspath,_agp_internal_javaPreCompileFullRelease_kspClasspath,implementationDependenciesMetadata,kspDemoDebugAndroidTestKotlinProcessorClasspath,kspDemoDebugKotlinProcessorClasspath,kspDemoDebugUnitTestKotlinProcessorClasspath,kspDemoReleaseKotlinProcessorClasspath,kspDemoReleaseUnitTestKotlinProcessorClasspath,kspFullDebugAndroidTestKotlinProcessorClasspath,kspFullDebugKotlinProcessorClasspath,kspFullDebugUnitTestKotlinProcessorClasspath,kspFullReleaseKotlinProcessorClasspath,kspFullReleaseUnitTestKotlinProcessorClasspath,testImplementationDependenciesMetadata org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.0=kotlinCompilerPluginClasspathDemoDebug,kotlinCompilerPluginClasspathDemoDebugAndroidTest,kotlinCompilerPluginClasspathDemoDebugUnitTest,kotlinCompilerPluginClasspathDemoRelease,kotlinCompilerPluginClasspathDemoReleaseUnitTest,kotlinCompilerPluginClasspathFullDebug,kotlinCompilerPluginClasspathFullDebugAndroidTest,kotlinCompilerPluginClasspathFullDebugUnitTest,kotlinCompilerPluginClasspathFullRelease,kotlinCompilerPluginClasspathFullReleaseUnitTest,kspPluginClasspath,kspPluginClasspathNonEmbeddable org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.10=demoDebugAndroidTestCompileClasspath,demoDebugCompileClasspath,demoDebugRuntimeClasspath,demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseCompileClasspath,demoReleaseRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugAndroidTestCompileClasspath,fullDebugCompileClasspath,fullDebugRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseCompileClasspath,fullReleaseRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.20=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.20=androidTestImplementationDependenciesMetadata -org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.22=_agp_internal_javaPreCompileDemoDebug_kspClasspath,_agp_internal_javaPreCompileDemoRelease_kspClasspath,_agp_internal_javaPreCompileFullDebug_kspClasspath,_agp_internal_javaPreCompileFullRelease_kspClasspath,implementationDependenciesMetadata,kspDemoDebugAndroidTestKotlinProcessorClasspath,kspDemoDebugKotlinProcessorClasspath,kspDemoDebugUnitTestKotlinProcessorClasspath,kspDemoReleaseKotlinProcessorClasspath,kspDemoReleaseUnitTestKotlinProcessorClasspath,kspFullDebugAndroidTestKotlinProcessorClasspath,kspFullDebugKotlinProcessorClasspath,kspFullDebugUnitTestKotlinProcessorClasspath,kspFullReleaseKotlinProcessorClasspath,kspFullReleaseUnitTestKotlinProcessorClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.22=_agp_internal_javaPreCompileDemoDebug_kspClasspath,_agp_internal_javaPreCompileDemoRelease_kspClasspath,_agp_internal_javaPreCompileFullDebug_kspClasspath,_agp_internal_javaPreCompileFullRelease_kspClasspath,implementationDependenciesMetadata,kspDemoDebugAndroidTestKotlinProcessorClasspath,kspDemoDebugKotlinProcessorClasspath,kspDemoDebugUnitTestKotlinProcessorClasspath,kspDemoReleaseKotlinProcessorClasspath,kspDemoReleaseUnitTestKotlinProcessorClasspath,kspFullDebugAndroidTestKotlinProcessorClasspath,kspFullDebugKotlinProcessorClasspath,kspFullDebugUnitTestKotlinProcessorClasspath,kspFullReleaseKotlinProcessorClasspath,kspFullReleaseUnitTestKotlinProcessorClasspath,testImplementationDependenciesMetadata org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.0=kotlinCompilerPluginClasspathDemoDebug,kotlinCompilerPluginClasspathDemoDebugAndroidTest,kotlinCompilerPluginClasspathDemoDebugUnitTest,kotlinCompilerPluginClasspathDemoRelease,kotlinCompilerPluginClasspathDemoReleaseUnitTest,kotlinCompilerPluginClasspathFullDebug,kotlinCompilerPluginClasspathFullDebugAndroidTest,kotlinCompilerPluginClasspathFullDebugUnitTest,kotlinCompilerPluginClasspathFullRelease,kotlinCompilerPluginClasspathFullReleaseUnitTest,kspPluginClasspath,kspPluginClasspathNonEmbeddable org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.10=demoDebugAndroidTestCompileClasspath,demoDebugCompileClasspath,demoDebugRuntimeClasspath,demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseCompileClasspath,demoReleaseRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugAndroidTestCompileClasspath,fullDebugCompileClasspath,fullDebugRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseCompileClasspath,fullReleaseRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.20=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle @@ -280,8 +289,23 @@ org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1=demoDebugAndroidTestCompileC org.jetbrains:annotations:13.0=_agp_internal_javaPreCompileDemoDebug_kspClasspath,_agp_internal_javaPreCompileDemoRelease_kspClasspath,_agp_internal_javaPreCompileFullDebug_kspClasspath,_agp_internal_javaPreCompileFullRelease_kspClasspath,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,androidTestImplementationDependenciesMetadata,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDemoDebug,kotlinCompilerPluginClasspathDemoDebugAndroidTest,kotlinCompilerPluginClasspathDemoDebugUnitTest,kotlinCompilerPluginClasspathDemoRelease,kotlinCompilerPluginClasspathDemoReleaseUnitTest,kotlinCompilerPluginClasspathFullDebug,kotlinCompilerPluginClasspathFullDebugAndroidTest,kotlinCompilerPluginClasspathFullDebugUnitTest,kotlinCompilerPluginClasspathFullRelease,kotlinCompilerPluginClasspathFullReleaseUnitTest,kotlinKlibCommonizerClasspath,kspDemoDebugAndroidTestKotlinProcessorClasspath,kspDemoDebugKotlinProcessorClasspath,kspDemoDebugUnitTestKotlinProcessorClasspath,kspDemoReleaseKotlinProcessorClasspath,kspDemoReleaseUnitTestKotlinProcessorClasspath,kspFullDebugAndroidTestKotlinProcessorClasspath,kspFullDebugKotlinProcessorClasspath,kspFullDebugUnitTestKotlinProcessorClasspath,kspFullReleaseKotlinProcessorClasspath,kspFullReleaseUnitTestKotlinProcessorClasspath,kspPluginClasspath,kspPluginClasspathNonEmbeddable org.jetbrains:annotations:23.0.0=_internal-unified-test-platform-android-device-provider-ddmlib,demoDebugAndroidTestCompileClasspath,demoDebugAndroidTestRuntimeClasspath,demoDebugCompileClasspath,demoDebugRuntimeClasspath,demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseCompileClasspath,demoReleaseRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugAndroidTestCompileClasspath,fullDebugAndroidTestRuntimeClasspath,fullDebugCompileClasspath,fullDebugRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseCompileClasspath,fullReleaseRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath org.json:json:20240303=demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,testImplementationDependenciesMetadata +org.ow2.asm:asm-analysis:9.7=demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,testImplementationDependenciesMetadata 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-util: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.robolectric:annotations:4.13=demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,testImplementationDependenciesMetadata +org.robolectric:junit:4.13=demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,testImplementationDependenciesMetadata +org.robolectric:nativeruntime-dist-compat:1.0.12=demoDebugUnitTestRuntimeClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseUnitTestRuntimeClasspath +org.robolectric:nativeruntime:4.13=demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,testImplementationDependenciesMetadata +org.robolectric:pluginapi:4.13=demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,testImplementationDependenciesMetadata +org.robolectric:plugins-maven-dependency-resolver:4.13=demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,testImplementationDependenciesMetadata +org.robolectric:resources:4.13=demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,testImplementationDependenciesMetadata +org.robolectric:robolectric:4.13=demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,testImplementationDependenciesMetadata +org.robolectric:sandbox:4.13=demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,testImplementationDependenciesMetadata +org.robolectric:shadowapi:4.13=demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,testImplementationDependenciesMetadata +org.robolectric:shadows-framework:4.13=demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,testImplementationDependenciesMetadata +org.robolectric:utils-reflector:4.13=demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,testImplementationDependenciesMetadata +org.robolectric:utils:4.13=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,demoDebugUnitTestCompileClasspath,demoDebugUnitTestRuntimeClasspath,demoReleaseUnitTestCompileClasspath,demoReleaseUnitTestRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseUnitTestCompileClasspath,fullReleaseUnitTestRuntimeClasspath,kspDemoDebugAndroidTestKotlinProcessorClasspath,kspDemoDebugKotlinProcessorClasspath,kspDemoDebugUnitTestKotlinProcessorClasspath,kspDemoReleaseKotlinProcessorClasspath,kspDemoReleaseUnitTestKotlinProcessorClasspath,kspFullDebugAndroidTestKotlinProcessorClasspath,kspFullDebugKotlinProcessorClasspath,kspFullDebugUnitTestKotlinProcessorClasspath,kspFullReleaseKotlinProcessorClasspath,kspFullReleaseUnitTestKotlinProcessorClasspath,testImplementationDependenciesMetadata 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..c3f1222aa4 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,48 @@ 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..2b7561eb48 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,17 @@ 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) + // #2185: a stress widget placed by an older version fired its `onEnabled` long before the + // scheduler existed, so the receiver hook alone would never reach it. Enqueued with KEEP, so + // this is a no-op once a schedule exists, and the worker retires itself when no widget is + // placed — which is what stops this costing anything for an install that has never had one. + com.noop.widget.StressWidgetRefresh.ensureScheduled(this) } /** Process-wide Room-backed store. One instance shared by the UI and the background service. */ @@ -58,20 +66,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 +168,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. @@ -126,10 +192,20 @@ class NoopApplication : Application() { * "noop.selectedWhoopModel" in the shared noop_prefs store. Defaults to [WhoopModel.WHOOP4] when * unset or unparseable (the historical connect() default), so a fresh install is unchanged. Used to * reconnect on the right service after a WHOOP->WHOOP switch (#74). */ - private fun persistedWhoopModel(): WhoopModel = + private fun persistedWhoopModel(): WhoopModel = persistedWhoopModelOrNull() ?: WhoopModel.WHOOP4 + + /** + * The persisted family, or null when nothing has been recorded yet. + * + * Split from [persistedWhoopModel] because the default it applies, WHOOP4, is indistinguishable + * from a genuine recorded WHOOP4, and a caller choosing between this and some other source needs to + * know which it got. `AppViewModel` needs exactly that: a recorded family should beat the remembered + * pair, while an install that predates this pref must keep falling back to it rather than being + * silently reset to WHOOP4. + */ + internal fun persistedWhoopModelOrNull(): WhoopModel? = NoopPrefs.of(this).getString("noop.selectedWhoopModel", null) ?.let { runCatching { WhoopModel.valueOf(it) }.getOrNull() } - ?: WhoopModel.WHOOP4 companion object { @Volatile private var instance: NoopApplication? = null 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..8a4667f1b7 100644 --- a/android/app/src/main/java/com/noop/ai/AiCoach.kt +++ b/android/app/src/main/java/com/noop/ai/AiCoach.kt @@ -1,15 +1,25 @@ 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 import com.noop.data.LabMarkerRow import com.noop.data.WhoopRepository +import com.noop.ingest.ActivityFileImporter +import com.noop.ingest.LiftingImporter import com.noop.ui.NoopPrefs +import com.noop.data.WorkoutRow +import com.noop.ui.UnitFormatter +import com.noop.ui.UnitSystem +import com.noop.ui.UnitPrefs +import com.noop.ui.WorkoutEditing import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType @@ -24,10 +34,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 +60,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" @@ -110,8 +124,13 @@ class AiCoach( // strongest on-device patterns + Lab Book markers. Summary text only, no raw rows, no // per-day series, so the anonymity / no-raw-egress posture holds. Best-effort; never blocks. val signals = if (includeSignals) runCatching { buildSignalsContext() }.getOrNull() else null + // #2033: per-session workout detail, so the coach can tell a run from a lift. Best-effort + // like the blocks around it; a read that throws leaves the day table standing rather than + // failing the send. Matches where Swift appends it in `buildFullContext`. + val workouts = runCatching { recentWorkoutsBlock(ctx) }.getOrNull() val full = buildString { append(buildContext(days)) + if (!workouts.isNullOrBlank()) append("\n\n").append(workouts) if (!stress.isNullOrBlank()) append("\n\n").append(stress) if (!signals.isNullOrBlank()) append("\n\n").append(signals) } @@ -127,39 +146,111 @@ 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 + // #2033: per-session workout detail, so the coach can tell a run from a lift. Best-effort + // like the blocks around it; a read that throws leaves the day table standing rather than + // failing the send. Matches where Swift appends it in `buildFullContext`. + val workouts = runCatching { recentWorkoutsBlock(ctx) }.getOrNull() + val full = buildString { + append(buildContext(days)) + if (!workouts.isNullOrBlank()) append("\n\n").append(workouts) + 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 +311,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 +424,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" ) } @@ -297,6 +478,105 @@ class AiCoach( return sb.toString().trim() } + /** + * Recent workouts, one line PER SESSION rather than a per-day count. + * + * The day table above says a wearer did two workouts on a day and what the day's effort was. It + * cannot say what they did, for how long, how far, or how hard their heart worked, so the coach + * could not tell a run from a lift and could not help plan training around either (#2033). This is + * the Kotlin twin of Swift `AICoachEngine.recentWorkoutsBlock`, which has emitted exactly these + * fields in exactly this order since it was written; Android simply never had it. + * + * SCOPE, deliberately the wearer's own words on the issue, "all information of your workouts, that + * are visible to yourself". So this mirrors what the Workouts screen actually lists, which + * `AppViewModel` assembles: the strap union, Apple Health, Health Connect, auto-detected sessions, + * imported activity files and imported lifting, with dismissed sessions filtered OUT and the rest + * deduped cross-source so a live recording and its thin import collapse to the richer row. + * + * The dismissal filter is the part that matters and the part a first pass here got wrong. Detected + * sessions were dropped wholesale on the reasoning that a wearer can dismiss them, which confused + * the category with the act: the screen shows detected sessions and hides DISMISSED ones, of any + * source. Dropping the category hid a strap-only wearer's auto-detected training from the coach + * entirely, while still sending a dismissed import. Both halves are now the screen's behaviour. + * + * One thing the screen does that this does not: `fillWorkoutHrFromStrap`, which borrows the strap's + * samples to fill an imported session's missing average HR. Skipped deliberately. It needs the + * profile and the effort method, and its absence costs a field that is simply omitted rather than a + * field that is wrong, which is the rule this block already follows everywhere else. + * + * Six sessions, thirty days. This rides inside a prompt payload, so it is a summary and not an + * export; the day table above still carries the fourteen-day shape. + * + * PRIVACY: only reached under the same `consent` gate as every other figure here, and it widens + * what that consent covers. Aggregate counts become where and how someone exercises. That is the + * disclosure the Apple build has always made, and the consent copy should say so plainly. + */ + internal suspend fun recentWorkoutsBlock(ctx: Context, limit: Int = 6): String { + val now = System.currentTimeMillis() / 1000L + val from = now - 30L * 86_400L + val rows = runCatching { visibleWorkoutRows(from, now) }.getOrDefault(emptyList()) + return formatWorkoutsBlock(rows, UnitPrefs.distanceSystem(ctx), limit) + } + + /** + * The sessions a wearer can see, newest first. Split out from the formatter and from the unit + * lookup so it has a test: the only other way in reads SharedPreferences, and this half is where the + * decisions live. It is also where the bug was, twice over, which is the argument for the seam. + * + * Mirrors the assembly `AppViewModel` runs for the Workouts screen. If that list gains a source, + * this one has to as well, or the coach quietly reasons about less than the wearer is looking at. + */ + internal suspend fun visibleWorkoutRows(from: Long, to: Long): List { + val id = activeStrapId() + val all = repo.workoutsUnion(id, from, to) + + repo.workouts("apple-health", from, to) + + repo.workouts("health-connect", from, to) + + repo.detectedWorkoutsUnion(id, from, to) + + repo.workouts(ActivityFileImporter.SOURCE_ID, from, to) + + repo.workouts(LiftingImporter.SOURCE_ID, from, to) + // Dismissed first, then dedup: the same order the screen uses, so a dismissed row cannot be the + // one a cross-source collapse decides to keep. + return WorkoutEditing.dedupCrossSource( + WorkoutEditing.filterDismissed(all, repo.dismissedDetected(id)), + ).sortedByDescending { it.startTs } + } + + /** + * The emitted text, given rows and a resolved unit system. Pure, and `internal` for the same reason + * Swift's `dayLine` is: without a seam the formatter has no test, because the only way in reads + * SharedPreferences and these run on the JVM with no Context. The reading half above is a union and + * a sort; every decision a reviewer would want pinned is in here. + * + * Field order and separators mirror Swift's `recentWorkoutsBlock` exactly, since both feed the same + * model and a wearer comparing platforms would otherwise get differently-shaped advice from + * identical data. A field the row does not carry is OMITTED rather than emitted as a dash: this is + * a prompt, and a dash invites the model to reason about a gap that is only a missing sensor. + */ + internal fun formatWorkoutsBlock( + rows: List, + distanceSystem: UnitSystem, + limit: Int = 6, + ): String { + if (rows.isEmpty()) return "Recent workouts: none recorded in the last 30 days." + val sb = StringBuilder("Recent workouts (newest first):") + for (w in rows.take(limit)) { + val parts = mutableListOf(" ${workoutDay(w.startTs)} ${w.sport}") + w.durationS?.let { parts.add("${(it / 60.0).roundToInt()} min") } + w.strain?.let { parts.add("effort ${fmt1(it)}") } + w.avgHr?.let { parts.add("avg HR $it") } + w.energyKcal?.let { parts.add("${it.roundToInt()} kcal") } + w.distanceM?.let { parts.add(UnitFormatter.distanceFromMeters(it, distanceSystem)) } + sb.append("\n").append(parts.joinToString(", ")) + } + return sb.toString() + } + + /** `yyyy-MM-dd` in the wearer's own zone, matching Swift's `dateString` for the same line. */ + private fun workoutDay(startTs: Long): String = + java.time.LocalDate.ofInstant( + java.time.Instant.ofEpochSecond(startTs), java.time.ZoneId.systemDefault(), + ).toString() + /** * SUMMARY-ONLY on-device signals context (v5): the user's strongest associations (from the same * [EffectRanker] the Insights hub surfaces) and a one-line-per-marker Lab Book snapshot. Sent only @@ -307,11 +587,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 +621,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 +933,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 // --------------------------------------------------------------------------------------- @@ -678,7 +1110,12 @@ class AiCoach( } } - /** Map a non-2xx response to a clear, user-facing message (key, rate-limit, server). */ + /** Map a non-2xx response to a clear, user-facing message (key, rate-limit, server). + * + * A key rejection returns [AiKeyRejectedException] rather than a bare one, so the UI can offer the + * wearer the field the message tells them to check. The message alone cannot carry that: matching + * on its text would break the moment the copy is localized, which is every locale but English. + * The status-to-type decision itself lives in [isKeyRejection], where a test can pin it. */ private fun httpError(provider: AiProvider, code: Int, body: String): Exception { val detail = extractApiErrorMessage(body) val base = when (code) { @@ -688,7 +1125,8 @@ class AiCoach( 400 -> "The request was rejected by ${provider.displayName} (HTTP 400)." else -> "${provider.displayName} returned an error (HTTP $code)." } - return Exception(if (detail != null) "$base ($detail)" else base) + val message = if (detail != null) "$base ($detail)" else base + return if (isKeyRejection(code)) AiKeyRejectedException(message) else Exception(message) } /** Pull the provider's error message out of an error JSON body, if present. */ @@ -712,9 +1150,35 @@ 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()}%" + } + + /** `Locale.US` is load-bearing, not tidiness. This text is a PROMPT, read by a model, not a label + * read by a person: on a German or French device the default locale emits `12,4`, which Swift never + * does, so the two platforms would hand the same effort figure to the same model in two notations + * and one of them invites parsing as two numbers. `oneDecimal` beside it in `Units` already pins + * the locale for exactly this reason. */ private fun fmt1(v: Double): String = if (v == v.roundToInt().toDouble()) v.roundToInt().toString() - else String.format("%.1f", v) + else String.format(java.util.Locale.US, "%.1f", v) private inline fun avgInt(days: List, sel: (DailyMetric) -> Double?): String { val vals = days.mapNotNull(sel) @@ -729,6 +1193,18 @@ class AiCoach( companion object { private val JSON = "application/json; charset=utf-8".toMediaType() + /** + * Whether an HTTP status means the stored key itself was turned away, as opposed to the + * provider being busy, broken, or asked for something it does not have. + * + * Named rather than left as two literals because it is the hinge the key-repair affordance + * hangs on, and it decides what the wearer is told to go and do. Widen it and a rate limit + * starts demanding a new key; narrow it and the trap this exists to remove comes straight + * back. Byte-identical twin of Swift `AICoachError.isKeyRejection`, which the two response + * switches in `AIProvider.swift` read. + */ + internal fun isKeyRejection(code: Int): Boolean = code == 401 || code == 403 + /** * Normalise a user-entered Custom base URL: trim, drop a trailing slash, and tolerate a pasted * FULL chat URL by stripping a trailing OpenAI-style chat path. So the derived `/chat/completions` @@ -897,7 +1373,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 +1394,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/AiKeyStore.kt b/android/app/src/main/java/com/noop/ai/AiKeyStore.kt index d173544852..9a956193d9 100644 --- a/android/app/src/main/java/com/noop/ai/AiKeyStore.kt +++ b/android/app/src/main/java/com/noop/ai/AiKeyStore.kt @@ -4,6 +4,17 @@ import android.content.Context import android.content.SharedPreferences import com.noop.data.SecurePrefs +/** + * The provider turned the stored key away (HTTP 401 or 403). + * + * Typed rather than left as a bare exception because the message it carries tells the wearer to check + * the key, and until the UI can recognise this case it has nowhere to send them: the coach shows the + * chat once ANY key is stored, and a wrong key is still a stored key. The alternative, matching on the + * message text, stops working in every locale but English. This corresponds to the Swift + * `AICoachError.badKey` enum case. + */ +class AiKeyRejectedException(message: String) : Exception(message) + /** * Secure, at-rest-encrypted storage for the user's AI Coach API key. * diff --git a/android/app/src/main/java/com/noop/ai/AiProvider.kt b/android/app/src/main/java/com/noop/ai/AiProvider.kt index 1616062a6c..9158a97df9 100644 --- a/android/app/src/main/java/com/noop/ai/AiProvider.kt +++ b/android/app/src/main/java/com/noop/ai/AiProvider.kt @@ -24,13 +24,27 @@ enum class AiProvider( ) { OPENAI( displayName = "OpenAI", - defaultModel = "gpt-4o-mini", + defaultModel = "gpt-5-mini", + // Pinned ids, not aliases: OpenAI has no stable per-tier "-latest" alias the way Gemini does + // (#400), so this list is bumped by hand. The live /models refresh stays the authority for + // anything released after this. + // + // The reasoning tiers (o3, o4-mini) and the GPT-5 family reject `temperature` and + // `max_tokens`. Nothing special is needed for them here: [AiCoach] sends the classic + // parameters and, on a 400 naming one of them, retries with `max_completion_tokens` and no + // temperature. One extra round trip on the first message, not a per-model table to maintain. + // Twin of the Swift `AIProvider.modelOptions`. models = listOf( - "gpt-4o", - "gpt-4o-mini", + "gpt-5", + "gpt-5-mini", + "gpt-5-nano", "gpt-4.1", "gpt-4.1-mini", "gpt-4.1-nano", + "gpt-4o", + "gpt-4o-mini", + "o3", + "o4-mini", ), endpoint = "https://api.openai.com/v1/chat/completions", modelsEndpoint = "https://api.openai.com/v1/models", 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..4dc8030657 --- /dev/null +++ b/android/app/src/main/java/com/noop/analytics/AlarmReadback.kt @@ -0,0 +1,96 @@ +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. + * + * #2322 extends that from the same strap to the same ARM. `alarm.lastReportedEpoch` is written ONLY when + * the readback frame decodes; the decode-failure branch logs raw hex and leaves the old value standing. + * So an arm whose readback never decoded leaves a FRESH sent epoch beside a STALE reported one, and the + * export then prints a confident "strap didn't accept the time" assembled from two different arms. + * Decode failures are not hypothetical: the strap log attached to issue #2302 carries one on a + * neighbouring frame, which is where this was noticed. That issue is about HRV, not alarms. + * + * The arrival times were already persisted for both halves (`alarm.lastArmAt`, `alarm.lastReportedAt`) and + * simply were not consulted; comparing them is the whole guard. + * + * 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, + /** The readback on hand is OLDER than the arm it is being compared against, so it answered an + * EARLIER arm. Same strap, but not the same question, and nothing can be concluded. */ + STALE_READBACK, + } + + /** + * [sentAt] and [reportedAt] are WALL-CLOCK arrival stamps for the two halves, in whatever unit this + * platform's store already holds (Android: `System.currentTimeMillis()`). Only their ORDER is read, + * never their difference, so the unit does not have to match the Swift twin's; both sides compare two + * stamps taken from their OWN clock. 0 on either means "not recorded" (an install that predates the + * keys), and staleness is then not judged rather than guessed. + */ + fun verdict( + sentEpoch: Long, + reportedEpoch: Long, + sentDeviceId: String?, + reportedDeviceId: String?, + // toleranceS stays in the position it has always held. Kotlin allows positional calls, so moving + // it would silently rebind a `verdict(a, b, c, d, 300)` written against the old signature to + // sentAt, turning a tuning value into a timestamp with no compiler complaint. The new parameters + // are appended instead, which is the only order that cannot break a caller written before them. + toleranceS: Long = TOLERANCE_S, + sentAt: Long = 0L, + reportedAt: Long = 0L, + ): Verdict { + if (sentDeviceId.isNullOrBlank() || reportedDeviceId.isNullOrBlank()) return Verdict.UNATTRIBUTED + if (sentDeviceId != reportedDeviceId) return Verdict.DIFFERENT_STRAP + // Ordered AFTER attribution on purpose: a cross-strap pair is unusable whether or not it is also + // stale, and naming the strap problem first sends the reader to the real one. + if (sentAt > 0L && reportedAt > 0L && reportedAt < sentAt) return Verdict.STALE_READBACK + 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)" + Verdict.STALE_READBACK -> " (readback predates this arm — not comparable)" + } + + /** + * Whether this verdict may advance the consecutive-rejection streak. Only a proven same-strap, + * same-arm disagreement counts: an unattributed, cross-strap or stale reading must leave the streak + * untouched rather than reset it, since none of the three 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/AnalysisPhaseTally.kt b/android/app/src/main/java/com/noop/analytics/AnalysisPhaseTally.kt new file mode 100644 index 0000000000..739fdfa369 --- /dev/null +++ b/android/app/src/main/java/com/noop/analytics/AnalysisPhaseTally.kt @@ -0,0 +1,78 @@ +package com.noop.analytics + +import kotlin.math.roundToLong + +/** + * Where an `analyzeRecent` pass spends the time AFTER its per-day loop. Kotlin twin of the Swift + * `AnalysisPhaseTally`. + * + * #1538 added a cost line, but it brackets the day loop and is emitted the moment that loop returns. Every + * phase after it — the baseline folds, pass 2, the persist, the weekly metrics, the steps calibration, the + * sleep writes and heals, the workout rescore — was outside the tally, so a pass whose time went somewhere + * after the loop reported a small `prep`/`score` and no explanation for the rest. The steps calibration + * alone re-folded sixty days of gravity per pass under that blind spot. + * + * This is the same idea at the other end of the pass: stamp each phase as it completes and print them in + * order, so the dominant phase names itself instead of being inferred from what the day-loop line does not + * account for. + */ +object AnalysisPhaseTally { + /** + * Render ordered `(name, seconds)` phases as one line, each in milliseconds. + * + * Phases print in the order given, which is execution order — reading the line top to bottom is reading + * the pass front to back. The total is included because the phases deliberately do NOT have to sum to + * the pass: anything not bracketed shows up as the difference, which is itself the signal that a phase + * is still unmeasured. + * + * [scope] names WHAT was bracketed, and it is a parameter rather than a constant because the two + * platforms can afford to measure different amounts — see the class note. A line that said `postLoop` + * while timing only part of the post-loop would misreport its own total, which is the one thing a cost + * line must not do. Byte-identical to the Swift `AnalysisPhaseTally.logLine`. + */ + fun logLine(scope: String, phases: List>): String { + val parts = phases.map { "${it.first}=${millis(it.second)}ms" } + val total = phases.sumOf { it.second } + return "analyzeRecent $scope total=${millis(total)}ms " + + if (parts.isEmpty()) "(no phases)" else parts.joinToString(" ") + } + + /** + * Seconds to whole milliseconds, floored at zero. + * + * The clamp is the parity contract, not defensive habit. These are wall-clock differences, and a clock + * that steps backwards mid-pass (an NTP correction is enough) yields a negative interval — the one + * input where Swift's round-half-away-from-zero and Kotlin's round-half-up disagree, at exactly + * -0.5 ms. Clamping first makes both sides provably identical instead of identical only while the + * clock behaves. A phase cannot take less than no time, so nothing true is lost. + */ + private fun millis(seconds: Double): Long = + if (!seconds.isFinite() || seconds <= 0.0) 0L else (seconds * 1000).roundToLong() +} + +/** + * Ordered phase stopwatch for one `analyzeRecent` pass, rendered by [AnalysisPhaseTally.logLine]. + * + * A collector rather than the local closure the Swift side uses, and — unlike Swift — it brackets only + * `persistFitnessVitalityAndSteps` rather than the whole post-loop. That is a JVM constraint, not a choice: + * `analyzeRecentOnCpu` sits 210 bytes under the JaCoCo method-size ratchet that `IntelligenceEngineJacocoBudgetTest` + * holds, and marking its phases costs about 467. The ratchet's own note says to extract rather than raise it, + * and an extraction is a larger change than this one should carry, so Android measures the phase the cost was + * actually found in and names its scope honestly. Extending it to the rest of the post-loop needs that + * extraction first. + * + * Monotonic (`System.nanoTime`), matching the clock the day-loop tally beside it already uses. + */ +class AnalysisPhaseMarks { + private val collected = ArrayList>() + private var last = System.nanoTime() + + /** Close the phase that ends here and name it. */ + fun mark(name: String) { + val now = System.nanoTime() + collected.add(name to (now - last) / 1_000_000_000.0) + last = now + } + + val phases: List> get() = collected +} diff --git a/android/app/src/main/java/com/noop/analytics/Analytics.kt b/android/app/src/main/java/com/noop/analytics/Analytics.kt index e857edfe32..634154812b 100644 --- a/android/app/src/main/java/com/noop/analytics/Analytics.kt +++ b/android/app/src/main/java/com/noop/analytics/Analytics.kt @@ -86,6 +86,21 @@ object IllnessWatch { val recent = days.takeLast(2) // ~28 days ending 3 days ago: take the last 31, drop the most recent 3. val base = days.takeLast(31).dropLast(3) + // Whether a signal's window is fit to accuse the recent one (#2130). The Swift twin folds this + // SAME window through `Baselines.foldHistory` and refuses the signal unless the state is usable; + // here it was a plain mean, which is happy with ONE value and gated nothing. + // + // Folding rather than counting is the point: it is the statistic Charge is scored against, so + // the banner and the score can no longer hold two different baselines for one metric. + // + // Values rather than a helper because a named local function is a declaration the parity ledger + // counts, and this file is inside its scan. + val rhrBaseUsable = Baselines.metricCfg["resting_hr"]?.let { cfg -> + Baselines.foldHistory(base.map { it.restingHr?.toDouble() }, cfg).usable + } == true + val hrvBaseUsable = Baselines.metricCfg["hrv"]?.let { cfg -> + Baselines.foldHistory(base.map { it.avgHrv }, cfg).usable + } == true fun mean(vals: List): Double? = if (vals.isEmpty()) null else vals.sum() / vals.size.toDouble() @@ -101,15 +116,17 @@ object IllnessWatch { run { val r = rm { it.restingHr?.toDouble() } val b = bm { it.restingHr?.toDouble() } - if (r != null && b != null && r >= b + 5) { + if (r != null && b != null && rhrBaseUsable && r >= b + 5) { flags.add("resting HR +${(r - b).roundToInt()} bpm") } } run { + // The sparsest of the four: the over-count gate withholds whole nights (#1118), so HRV is + // the likeliest to have been resting on a cold-start baseline. val r = rm { it.avgHrv } val b = bm { it.avgHrv } - if (r != null && b != null && b > 0 && r <= b * 0.80) { + if (r != null && b != null && b > 0 && hrvBaseUsable && r <= b * 0.80) { flags.add("HRV −${((1 - r / b) * 100).roundToInt()}%") } } 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 32d854a2d1..9f44a6b6e9 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,32 @@ 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 } + // Resting Heart Rate: Use PrimarySessionRestingHR (arithmetic sample mean of the longest/primary + // sleep session, #1169), eliminating daytime nap floor distortion. + // #804: Preserve ring/device-provided resting HR when present in `providedSleep`. + // Cleanly falls back to physiologySessions.mapNotNull { it.restingHR }.minOrNull() when coverage is sparse. + val providedPrimaryRHR = physiologySessions.maxByOrNull { it.end - it.start } + ?.let { p -> providedSleep.firstOrNull { it.start == p.start && it.end == p.end }?.restingHR } + val restingHRDaily: Int? = providedPrimaryRHR + ?: primarySessionRestingHR(physiologySessions, hr)?.roundToInt() + ?: 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 +593,29 @@ 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 -> + // A main night REFUSED by the #1118 over-count gate is not replaced by the day's naps. The + // day-wide pool below rests on "the main overnight dominates" (see physiologySessions), which + // holds only while that night has a value: once the gate makes it null it carries zero weight, + // and a 26-minute nap became 100 % of the day's HRV and was folded into the baseline as a + // night. So when a main-group session's HRV is null BECAUSE the gate refused it, only the main + // group is pooled, and the day holds (null) unless another fragment of that night measured + // cleanly. A main night that simply banked no R-R is not refused, so #1884's fill-in from the + // day's other sessions is unchanged. Byte-parity twin of Swift `avgHRVDaily`. + val mainNightRefused = mainGroup.any { s -> + s.avgHRV == null && SleepStager.sessionHrvOverCounted(s.start, s.end, rr) + } + val hrvPool = if (mainNightRefused) { + physiologySessions.filter { p -> mainGroup.any { it.start == p.start && it.end == p.end } } + } else { + physiologySessions + } + val pairs = hrvPool.mapNotNull { s -> s.avgHRV?.let { it to (s.end - s.start).toDouble() } } if (pairs.isEmpty()) { @@ -560,7 +631,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, ) @@ -597,8 +668,35 @@ object AnalyticsEngine { // `reported` is the value NOOP actually displays (duration-weighted session-mean-of-means); // `wholeNight` is the pooled-window mean it equals on single-session nights and the apples-to- // apples baseline for the deepOnly/lastSWS comparison (all three are pooled window means). + // #2128: say WHY `reported` is nil, but ONLY when the night printed real window means beside + // it. That is the confusing case: a nil next to `wholeNight=31.55ms` reads as a value that + // went missing, when the usual cause is the #1118 gate refusing an over-counted night on + // purpose. A night with no windows at all explains itself and pays nothing here. + // + // The `hrv diag` row above carries `rrIntegrity`, but that verdict is scored over the whole + // DAY while the gate runs per SESSION, so the two disagree exactly when it matters: a day + // reading `underCovered` can still hold a session the gate refused. Reading the adjacent line + // is what led #2128 to be filed against intended behaviour. + // + // Derived from the two existing calls rather than by re-classifying the beats. Inside + // [SleepStager.sessionAvgHRV] the ONLY paths to null are "no window yielded an RMSSD" and the + // gate, so windows-with-RMSSD plus a null value IS the gate, with nothing restated that could + // later disagree with it. It says `overCount` rather than naming a verdict because that + // function pins every over-count to CROSS_SECOND internally to avoid a sort, so the specific + // label would be a distinction it does not actually make. + // + // NOT in deep-window mode. That branch re-derives from `sessionHrvWindows` and never reads + // `s.avgHRV`, so the gate plays no part in its nil and naming it would be a diagnostic + // asserting a cause it did not verify. `nDeep` on this same line already explains that case. + val refused = !deepHrvWindow && avgHRVDaily == null && withR.isNotEmpty() && + physiologySessions.any { s -> + SleepStager.sessionHrvWindows(s.start, s.end, rrSorted, emptyList()) + .any { it.rmssd != null } && + SleepStager.sessionAvgHRV(s.start, s.end, rrSorted) == null + } hrvTraceSink( "hrv nightSummary reported=${avgHRVDaily?.let { "${round2(it)}ms" } ?: "nil"} " + + (if (refused) "refused=overCount " else "") + "wholeNight=${meanMs(withR)} deepOnly=${meanMs(deepW)} " + "lastSWS=${meanMs(lastSws)} nWin=${withR.size} nDeep=${deepW.size}", ) @@ -732,6 +830,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 +936,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, @@ -844,6 +956,10 @@ object AnalyticsEngine { spo2Red = nightlySpo2Raw?.first, spo2Ir = nightlySpo2Raw?.second, avgSdnn = avgSDNNDaily, + // 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 + // row directly keeps both thermal values, not just the one. + skinTempC = nightlySkinTempC, ) // ── Per-score confidence tiers (mirror Swift ScoreConfidence.derive decisions) ── @@ -852,8 +968,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, @@ -861,6 +980,7 @@ object AnalyticsEngine { restorativeSeconds = deepS + remS, efficiency = efficiency, gravitySparse = gravitySparse, + stageCoverage = HypnogramCoverage.fraction(coveredS, spanS), ) // ── Per-session per-epoch motion (H8) ───────────────────────────────── @@ -903,9 +1023,35 @@ object AnalyticsEngine { sessionSleepStateByStart = sessionSleepStateByStart, gravitySparse = gravitySparse, detectionFunnel = detectionFunnel, + mainNightBlocks = mainGroup.map { SleepStageTotals.NightBlock(it.start, it.end) }, ) } + /** + * The R-R rows the always-on `hrv diag` line describes (#2425). Pure. Byte-parity twin of Swift + * `AnalyticsEngine.hrvDiagnosticRows`. + * + * The line used to pool every sleep session of the day and divide their beat-time by first-to-last beat, + * so the hours BETWEEN a night and a nap entered the denominator. On a real day whose main night the + * #1118 gate refused at 1.31 coverage, the pooled line printed `coverage=0.48 rrIntegrity=underCovered`: + * the log said "not an over-count" about a night refused for one, and the HRV card's over-count flag, + * read from the same verdict, agreed with the log rather than the gate. + * + * So the line now reads the MAIN-night group ([DayResult.mainNightBlocks], the same group scoring uses) + * over the gate's own inclusive `[start, end]` window. On a one-block night that is exactly the set of + * beats [SleepStager.sessionHrvOverCounted] judged, so the over-count half of the verdict cannot disagree + * with the gate. A bridged multi-fragment night still spans its short bridge gap, which can only pull + * coverage DOWN. A day with no main night keeps the old pooled set ([fallback]), since there is no night + * to describe. + */ + fun hrvDiagnosticRows( + rr: List, + mainNight: List, + fallback: List, + ): List = + if (mainNight.isEmpty()) rr.filter { r -> fallback.any { r.ts >= it.start && r.ts < it.end } } + else rr.filter { r -> mainNight.any { r.ts >= it.start && r.ts <= it.end } } + /** Round to 2 decimal places (matches the imported/demo skin-temp deviation precision). (PR #85) */ private fun round2(v: Double): Double = kotlin.math.round(v * 100.0) / 100.0 @@ -951,6 +1097,14 @@ object AnalyticsEngine { * wear gate (unlike skin temp): the strap only streams SpO2 on-wrist, so there is no off-charger * drift to exclude, and the value is surfaced honestly as raw ADC — never scored — so there is * nothing to poison into a fake %. Pure + deterministic; twin of the Swift `nightlySpo2RawMeans`. (#93) + * + * TWO-CHANNEL ROWS ONLY (`ir > 0`). The same table holds the Oura ring's single-channel rows, which + * OuraStreamMapping writes as `red` = the ring's reading and `ir = 0` (an unread channel). Averaging + * those in produced an `ir` mean of 0 beside a `red` mean of ~97, so the Health "Raw SpO₂" tile — the + * `(red + ir) / 2` ADC mean — read a ring night's ~97 % as "~49 ADC"; a pre-#2152 `dc_raw` row (up to + * ~11.7M) inflated it further. A ring has no red/IR ADC pair, so its night yields null here and the + * tile stays empty; its SpO2 lives on the [nightlySpo2CeilingMean] path. A WHOOP 4.0 sample with a + * zero IR reading was never a usable ADC pair either. */ internal fun nightlySpo2RawMeans( sessions: List, @@ -961,6 +1115,7 @@ object AnalyticsEngine { var irSum = 0L var kept = 0 for (s in spo2) { + if (s.ir <= 0) continue if (sessions.none { s.ts in it.start..it.end }) continue redSum += s.red irSum += s.ir @@ -1455,13 +1610,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 @@ -1506,6 +1654,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..6480c88a17 100644 --- a/android/app/src/main/java/com/noop/analytics/AnalyticsModels.kt +++ b/android/app/src/main/java/com/noop/analytics/AnalyticsModels.kt @@ -52,7 +52,17 @@ data class UserProfile( * (the body term cancels out of the age formula). Default param so existing call-sites compile. */ val waistCm: Double = 0.0, -) +) { + /** + * Every stored field, for a cache key that must change when the profile does (the per-cycle load cache, + * `IntelligenceEngine.loadCacheKey`). Named explicitly rather than read from the generated `toString`, + * which is not a contract. A new field belongs here too. Doubles by bit pattern, so the key is exact. + * Twin of Swift `UserProfile.cacheKey`. + */ + val cacheKey: String + get() = "w=${weightKg.toRawBits()},h=${heightCm.toRawBits()},a=${age.toRawBits()},s=$sex," + + "t=${stepTicksPerStep.toRawBits()},waist=${waistCm.toRawBits()}" +} // ───────────────────────────────────────────────────────────────────────────── // Sleep staging output shapes (SleepStager.swift) @@ -87,6 +97,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, ) /** @@ -308,4 +328,11 @@ data class DayResult( * the counts exist to explain. Trailing + defaulted so every existing construction site is unchanged. */ val detectionFunnel: WorkoutDetector.DetectionFunnel? = null, + /** + * The bounds of the day's MAIN-night group, the SAME `mainGroup` the sleep aggregates and the + * refused-main-night HRV rule use; empty when the day has no main night. Exposed so the `hrv diag` line + * can describe the night the #1118 gate actually judged instead of re-deriving it (#2425). Trailing + + * defaulted so every existing construction site is unchanged. Mirrors Swift `DayResult.mainNightBlocks`. + */ + val mainNightBlocks: List = emptyList(), ) 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..812d6e9ae8 100644 --- a/android/app/src/main/java/com/noop/analytics/AnalyzeRecentDayCache.kt +++ b/android/app/src/main/java/com/noop/analytics/AnalyzeRecentDayCache.kt @@ -20,6 +20,31 @@ package com.noop.analytics * contract (see the tests, kept in lockstep with the Swift oracle). */ object AnalyzeRecentDayCache { + /** + * WHICH part of a day's cache key moved, for the miss reason on the reuse line (#2073). + * + * The line has only ever reported how many nights were reused. A healthy pass reuses all but today, + * whose heart rate is still growing; a pass that reuses NOTHING has had something shared by every + * day's key change, and the two cases need different fixes. A field log showed 0 of 21 on 13 passes + * out of 17, each costing ~50s of prep and 1.75M row reads, and the reuse count alone could not say + * why. `rrAlias5` is called out separately because it is the one input computed ONCE per pass and + * folded into all 21 keys, so it alone can turn a single flip into a total miss. + * + * Keys are `owner|hrCount:hrMaxTs:anchor:detail|streams`, so the segment that differs names the + * cause. Pure, so it is unit-tested directly. + */ + internal fun missReason(cachedKey: String, freshKey: String): String { + if (cachedKey == freshKey) return "none" + val a = cachedKey.split("|", limit = 3) + val b = freshKey.split("|", limit = 3) + if (a.size < 3 || b.size < 3) return "shape" + if (a[0] != b[0]) return "owner" + if (a[1] != b[1]) return "hr" + val ra = a[2].substringAfter("rrAlias5=", "") + val rb = b[2].substringAfter("rrAlias5=", "") + return if (ra != rb) "rrAlias5" else "streams" + } + /** * The per-day reuse key. Reuse a cached night iff this string is unchanged since the scan was cached. * @@ -35,6 +60,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 +76,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 +87,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/AutoWorkoutDetector.kt b/android/app/src/main/java/com/noop/analytics/AutoWorkoutDetector.kt index 452c35d2f0..66ed4e9a98 100644 --- a/android/app/src/main/java/com/noop/analytics/AutoWorkoutDetector.kt +++ b/android/app/src/main/java/com/noop/analytics/AutoWorkoutDetector.kt @@ -11,10 +11,10 @@ import kotlin.math.sqrt * BYTE-PARITY on the detection logic (same thresholds, same span/merge/overlap rules, * same outputs), verified by the mirrored unit tests on each platform. * - * This is DELIBERATELY SEPARATE from [WorkoutDetector] (the exercise.py port that computes - * calories / zones / strain and writes the durable "detected" rows the IntelligenceEngine - * churns). This one is the lightweight, OPT-IN, NON-DESTRUCTIVE MVP that only ever SUGGESTS - * a workout via a dismissible Today card — it never writes a row on its own. The user taps + * This remains the published, validation-frozen suggestion policy during #2187's shadow phase. + * [WorkoutDetector] separately computes daily analytics and missing-field enrichment, but no + * longer publishes generic workout rows. This lightweight, OPT-IN, NON-DESTRUCTIVE path only ever + * SUGGESTS a workout via a dismissible Today card — it never writes a row on its own. The user taps * "Save" to turn a suggestion into a manual workout, or X to dismiss it forever. * * The thresholds here are intentionally CONSERVATIVE (low sensitivity): a sustained ≥12-min @@ -38,6 +38,13 @@ object AutoWorkoutDetector { /** A candidate must hold the elevated gate for a contiguous span of at least this long. */ const val minSustainedMin: Double = 12.0 + /** + * Candidate duration policies evaluated only by the local Workouts Test Centre shadow pass. + * Neither value changes the published 12-minute suggestion policy until labelled real-world + * validation supports a rollout decision. + */ + val shadowSustainedMinutes: List = listOf(10.0, 15.0) + /** A dip below the gate no longer than this does NOT break the span (a red light, a sip of water). */ const val maxDipS: Long = 90L @@ -105,7 +112,7 @@ object AutoWorkoutDetector { * 2. Grow a contiguous span across elevated samples. A run of NON-elevated samples is tolerated * (does not end the span) ONLY while the dip's wall-clock duration stays <= [maxDipS]; a longer * dip closes the span. The span's [start, end] are the first/last ELEVATED sample timestamps. - * 3. Keep a span only when it lasts >= [minSustainedMin]. + * 3. Keep a span only when it lasts >= [minimumSustainedMinutes]. * 4. Merge two kept spans when the gap between them is strictly < [mergeGapS]. * 5. If a motion series is supplied, drop a window unless its mean motion intensity over the window * is >= [motionConfirmMean] (confirmation). With no motion series, HR-only — keep it. @@ -116,12 +123,15 @@ object AutoWorkoutDetector { * @param restingHR the nightly resting HR for the day; null → [defaultRestingHR] (60). * @param gravity OPTIONAL continuous motion series for confirmation; empty/omitted → HR-only. * @param savedWorkouts already-saved workout windows as (startSec, endSec) pairs to exclude by overlap. + * @param minimumSustainedMinutes qualification duration. The default preserves the published + * 12-minute suggestion behavior; other values are for explicit shadow evaluation only. */ fun detect( hr: List, restingHR: Int? = null, gravity: List = emptyList(), savedWorkouts: List> = emptyList(), + minimumSustainedMinutes: Double = minSustainedMin, ): List { val seg = cleanHR(hr) if (seg.isEmpty()) return emptyList() @@ -138,7 +148,7 @@ object AutoWorkoutDetector { fun closeSpan() { val s = spanStart - if (s != null && (spanEnd - s) >= minSustainedMin * 60.0) { + if (s != null && (spanEnd - s) >= minimumSustainedMinutes * 60.0) { spans.add(s to spanEnd) } spanStart = null diff --git a/android/app/src/main/java/com/noop/analytics/AutoWorkoutDetectorTrace.kt b/android/app/src/main/java/com/noop/analytics/AutoWorkoutDetectorTrace.kt index c70c76797d..7c8c4f4493 100644 --- a/android/app/src/main/java/com/noop/analytics/AutoWorkoutDetectorTrace.kt +++ b/android/app/src/main/java/com/noop/analytics/AutoWorkoutDetectorTrace.kt @@ -2,6 +2,7 @@ package com.noop.analytics import com.noop.data.GravitySample import com.noop.data.HrSample +import kotlin.math.abs // AutoWorkoutDetectorTrace.kt - Kotlin twin of AutoWorkoutDetector+Trace.swift. The Workouts & GPS // test-mode auto-detect trace + line formatters. @@ -19,6 +20,12 @@ import com.noop.data.HrSample object AutoWorkoutDetectorTrace { + private data class ShadowOption( + val candidate: Int, + val saved: Int, + val overlapS: Long, + ) + /** * Side-effect-free diagnostic twin of [AutoWorkoutDetector.detect]: returns the SAME * List detect would (it reuses detect verbatim), plus the trace. The trace logs the @@ -32,9 +39,12 @@ object AutoWorkoutDetectorTrace { gravity: List = emptyList(), savedWorkouts: List> = emptyList(), path: String = "autoDetect", + minimumSustainedMinutes: Double = AutoWorkoutDetector.minSustainedMin, ): Pair, List> { // The result the Today card reads, verbatim, so the trace cannot diverge from it. - val results = AutoWorkoutDetector.detect(hr, restingHR, gravity, savedWorkouts) + val results = AutoWorkoutDetector.detect( + hr, restingHR, gravity, savedWorkouts, minimumSustainedMinutes, + ) val lines = ArrayList() val floor = (restingHR ?: AutoWorkoutDetector.defaultRestingHR) + AutoWorkoutDetector.elevatedMarginBPM @@ -48,7 +58,7 @@ object AutoWorkoutDetectorTrace { ) lines.add( "autoDetect thresholds elevatedMargin=${AutoWorkoutDetector.elevatedMarginBPM}bpm " + - "minSustainedMin=${AutoWorkoutDetector.minSustainedMin} maxDipS=${AutoWorkoutDetector.maxDipS} " + + "minSustainedMin=$minimumSustainedMinutes maxDipS=${AutoWorkoutDetector.maxDipS} " + "mergeGapS=${AutoWorkoutDetector.mergeGapS} motionConfirmMean=${AutoWorkoutDetector.motionConfirmMean}", ) @@ -65,7 +75,7 @@ object AutoWorkoutDetectorTrace { var dipStart: Long? = null fun closeSpan() { val s = spanStart - if (s != null && (spanEnd - s) >= AutoWorkoutDetector.minSustainedMin * 60.0) spans.add(s to spanEnd) + if (s != null && (spanEnd - s) >= minimumSustainedMinutes * 60.0) spans.add(s to spanEnd) spanStart = null dipStart = null } @@ -84,7 +94,7 @@ object AutoWorkoutDetectorTrace { if (spans.isEmpty()) { lines.add( "autoDetect why=noSustainedSpan " + - "(no contiguous run held >=${AutoWorkoutDetector.minSustainedMin}min above ${floor}bpm)", + "(no contiguous run held >=${minimumSustainedMinutes}min above ${floor}bpm)", ) lines.add("autoDetect result windows=0") return results to lines @@ -133,6 +143,202 @@ object AutoWorkoutDetectorTrace { ) return results to lines } + + /** + * Compare one shadow duration policy with real, already-saved workout spans. This formats only + * aggregate counts: it performs no I/O and cannot publish a candidate or mutate workout history. + * Matching is deterministic and one-to-one: inputs are first put in canonical order, then a weighted + * assignment maximises cardinality first and total overlap second. A match requires strictly positive + * overlap; endpoint-only contact is not evidence that the detector found the labelled workout. + * + * When [hrForObservability] is supplied, a label is observable only when its longest recorded HR run + * (no sample gap above the detector's 90-second dip tolerance) spans the shorter of the label duration + * and this policy's qualification duration. This retains genuinely short labelled workouts as evidence, + * but two isolated points cannot manufacture a policy miss. Unobservable labels are reported separately + * and never counted as misses. + */ + fun shadowComparisonLine( + policyMinutes: Double, + candidates: List, + savedSpans: List>, + hrForObservability: List? = null, + ): String { + val orderedCandidates = candidates.sortedWith( + compareBy { it.startSec } + .thenBy { it.endSec } + .thenBy { it.avgBpm } + .thenBy { it.peakBpm } + .thenBy { it.durationMin }, + ) + val (observableSaved, unobservableCount) = shadowLabelPartition( + savedSpans, + hrForObservability, + policyMinutes, + ) + val orderedSaved = observableSaved.sortedWith( + compareBy> { it.first }.thenBy { it.second }, + ) + val optionsByCandidate = List(orderedCandidates.size) { ArrayList() } + for ((candidateIndex, candidate) in orderedCandidates.withIndex()) { + for ((savedIndex, saved) in orderedSaved.withIndex()) { + val overlapS = minOf(candidate.endSec, saved.second) - + maxOf(candidate.startSec, saved.first) + if (overlapS <= 0L) continue + optionsByCandidate[candidateIndex] += ShadowOption( + candidateIndex, + savedIndex, + overlapS, + ) + } + } + val matchedPairs = maximumCardinalityOverlapPairs(optionsByCandidate, orderedSaved.size) + val onsetErrors = ArrayList() + val endErrors = ArrayList() + for (option in matchedPairs) { + val candidate = orderedCandidates[option.candidate] + val saved = orderedSaved[option.saved] + onsetErrors += abs(candidate.startSec - saved.first) + endErrors += abs(candidate.endSec - saved.second) + } + val matched = matchedPairs.size + val policy = if (policyMinutes % 1.0 == 0.0) { + policyMinutes.toInt().toString() + } else { + policyMinutes.toString() + } + return "workout shadow policy=${policy}min candidates=${candidates.size} " + + "matched=$matched missed=${orderedSaved.size - matched} unobservable=$unobservableCount " + + "unmatched=${candidates.size - matched} " + + "medianOnsetErrorS=${medianError(onsetErrors)} medianEndErrorS=${medianError(endErrors)}" + } + + /** Split labels by whether the detector input has enough contiguous-in-time HR to evaluate this policy. */ + private fun shadowLabelPartition( + savedSpans: List>, + hr: List?, + policyMinutes: Double, + ): Pair>, Int> { + if (hr == null) return savedSpans to 0 + val timestamps = hr.map { it.ts }.distinct().sorted() + val observable = savedSpans.filter { span -> + val labelDuration = maxOf(0L, span.second - span.first) + val requiredCoverage = minOf(labelDuration.toDouble(), maxOf(0.0, policyMinutes * 60.0)) + var runStart: Long? = null + var previousTimestamp: Long? = null + var longestRun = 0L + for (timestamp in timestamps) { + if (timestamp < span.first) continue + if (timestamp > span.second) break + val previous = previousTimestamp + val currentRunStart = if ( + previous != null && timestamp - previous > AutoWorkoutDetector.maxDipS + ) { + timestamp + } else { + runStart ?: timestamp + } + runStart = currentRunStart + longestRun = maxOf(longestRun, timestamp - currentRunStart) + previousTimestamp = timestamp + } + longestRun > 0L && longestRun.toDouble() >= requiredCoverage + } + return observable to (savedSpans.size - observable.size) + } + + /** + * Hungarian assignment over candidate rows and saved-label + dummy columns. Every positive-overlap + * edge receives a cardinality bonus larger than the maximum possible total overlap, making the scalar + * objective exactly lexicographic: match count first, summed overlap second. Canonical input order and + * lowest-column tie breaks keep equal optima deterministic across Kotlin and Swift. + */ + private fun maximumCardinalityOverlapPairs( + optionsByCandidate: List>, + savedCount: Int, + ): List { + val candidateCount = optionsByCandidate.size + val maxOverlap = optionsByCandidate.flatten().maxOfOrNull { it.overlapS } + if (candidateCount == 0 || savedCount == 0 || maxOverlap == null) return emptyList() + + val maximumMatches = minOf(candidateCount, savedCount) + val cardinalityBonus = maxOverlap * maximumMatches.toLong() + 1L + val columnCount = savedCount + candidateCount + val weights = Array(candidateCount) { LongArray(columnCount) } + for (options in optionsByCandidate) { + for (option in options) { + weights[option.candidate][option.saved] = cardinalityBonus + option.overlapS + } + } + + // Minimum-cost Hungarian algorithm over negated weights. assignedRow[column] is 1-based. + val rowPotential = LongArray(candidateCount + 1) + val columnPotential = LongArray(columnCount + 1) + val assignedRow = IntArray(columnCount + 1) + val previousColumn = IntArray(columnCount + 1) + val infinity = Long.MAX_VALUE / 4L + for (row in 1..candidateCount) { + assignedRow[0] = row + var currentColumn = 0 + val minimumReducedCost = LongArray(columnCount + 1) { infinity } + val usedColumn = BooleanArray(columnCount + 1) + do { + usedColumn[currentColumn] = true + val currentRow = assignedRow[currentColumn] + var delta = infinity + var nextColumn = 0 + for (column in 1..columnCount) { + if (usedColumn[column]) continue + val cost = -weights[currentRow - 1][column - 1] + val reducedCost = cost - rowPotential[currentRow] - columnPotential[column] + if (reducedCost < minimumReducedCost[column]) { + minimumReducedCost[column] = reducedCost + previousColumn[column] = currentColumn + } + if (minimumReducedCost[column] < delta) { + delta = minimumReducedCost[column] + nextColumn = column + } + } + for (column in 0..columnCount) { + if (usedColumn[column]) { + rowPotential[assignedRow[column]] += delta + columnPotential[column] -= delta + } else { + minimumReducedCost[column] -= delta + } + } + currentColumn = nextColumn + } while (assignedRow[currentColumn] != 0) + + do { + val prior = previousColumn[currentColumn] + assignedRow[currentColumn] = assignedRow[prior] + currentColumn = prior + } while (currentColumn != 0) + } + + return (1..savedCount).mapNotNull { savedColumn -> + val candidateRow = assignedRow[savedColumn] + if (candidateRow == 0) null else optionsByCandidate[candidateRow - 1] + .firstOrNull { it.saved == savedColumn - 1 } + }.sortedWith(compareBy { it.candidate }.thenBy { it.saved }) + } + + /** Median of non-negative whole-second errors; even samples can produce a `.5` value. */ + private fun medianError(values: List): String { + if (values.isEmpty()) return "n/a" + val sorted = values.sorted() + val middle = sorted.size / 2 + if (sorted.size % 2 == 1) return sorted[middle].toString() + val lower = sorted[middle - 1] + val upper = sorted[middle] + val whole = lower / 2 + upper / 2 + return if (lower % 2L == upper % 2L) { + (whole + lower % 2L).toString() + } else { + "$whole.5" + } + } } /** @@ -181,12 +387,11 @@ object WorkoutsTrace { "dropped=$droppedSource(richness=$droppedRichness) (same activity, richer kept)" /** - * An engine detected-bout decision line (#975): the IntelligenceEngine derives a bout from raw HR then - * either PERSISTS it (source "-noop", sport "detected") or DROPS it because it overlaps a real logged - * session (manual / imported), so the same bout is never counted twice. `verdict` is "persisted" / - * "droppedOverlap" / "droppedShadow"; `durMin` is the whole-minute bout length; on a drop, `overlapSource` - * names the real row it collided with. No PII (a source label + minutes + bpm only). Swift twin - * WorkoutsTrace.detectedBoutLine. + * An analytics detected-bout decision line (#975/#2187): the IntelligenceEngine derives a bout from raw + * HR for metrics only. A non-overlap is `analyticsOnly`; an overlap with a real manual/imported session + * is `droppedOverlap` or `droppedOverlapBackfilled` when missing fields were enriched. `durMin` is the + * whole-minute bout length; on an overlap, `overlapSource` names the real row it collided with. No PII + * (a source label + minutes + bpm only). Swift twin WorkoutsTrace.detectedBoutLine. */ fun detectedBoutLine( verdict: String, 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..0ec98c9d72 100644 --- a/android/app/src/main/java/com/noop/analytics/ConnectionReadout.kt +++ b/android/app/src/main/java/com/noop/analytics/ConnectionReadout.kt @@ -33,6 +33,25 @@ object ConnectionTrace { } + /** + * The R-R transport line: what a device has banked versus what its unit policy can actually score. + * + * #2117: a WHOOP 5 window is pinned to one transport, and a window holding no beat on a scorable + * channel reads back EMPTY rather than falling back. Everything derived from beats then goes blank + * (HRV, respiratory rate) while heart-rate-derived values carry on, which is what a wearer reports as + * "HRV stopped working". The analyzer cannot explain it: handed nothing, it honestly reports nInput=0 + * and has no way to know whether the strap banked nothing or banked beats the policy refused. + * + * These two facts separate those cases, and the store already computes both. Rides every export for + * the same reason the clock-drift line does: the wearer who needs it is the one who did not know to + * turn a mode on. Returns null for a device the policy does not apply to, so a WHOOP 4 export is + * unchanged. + * + * Twin of Swift `UniversalTrace.rrTransportLine`. + */ + fun rrTransportLine( + strictWhoop5: Boolean, + /** * The CLOCK-DRIFT summary line (#767 / #754 cluster): the strap-reported banked-record window * [oldest, newest] against the wall clock, ending in the shared clock VERDICT ([clockVerdict]): @@ -42,6 +61,23 @@ object ConnectionTrace { * .connection line. All timestamps are unix seconds in the same wall domain. [oldestUnix] is optional * (a half/short range reply gives only the upper bound). Mirrors the Swift formatter exactly. */ + firstRecordedUnix: Long?, + firstScorableUnix: Long?, + ): String? { + if (!strictWhoop5) return null + if (firstRecordedUnix == null) return "rrTransport recorded=none scorable=none" + val recorded = "recorded=" + isoDate(firstRecordedUnix) + // Beats on disk, none the policy will score: the whole history predates transport labelling or + // sits on an excluded channel. This is the state that blanks every night at once. + if (firstScorableUnix == null) return "rrTransport $recorded scorable=none unscorableHistory=yes" + val gapDays = maxOf( + 0L, + Math.round((firstScorableUnix - firstRecordedUnix).toDouble() / 86_400.0), + ) + return "rrTransport $recorded scorable=" + isoDate(firstScorableUnix) + + " unscorableHistory=" + (if (gapDays > 0) "yes" else "no") + " gapDays=" + gapDays + } + fun clockDriftLine( oldestUnix: Long?, newestUnix: Long, @@ -211,20 +247,181 @@ 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. + * + * [rssiDbm] answers the question the END STATUS raises and could not previously settle. The dominant + * disconnect in a field log is the supervision timeout, which NOOP itself renders as "the strap went + * out of range or stopped responding" - so the log names range as the leading suspect and then records + * nothing about range. Signal was read once per link, seconds after connect, and never again: a 27 + * minute link carried a single reading from its third second, and ten of twenty-one links in the + * report that prompted this (#2332) died without any reading at all. + * + * Both halves are nullable, and the pair is printed with its AGE, because a reading is only evidence + * about the drop if it was taken near the drop. A value with no age, or the previous link's value + * carried into this one, is the hazard this whole line exists to avoid: the caller MUST clear its + * stash on teardown, exactly as it clears the hold time, or the epitaph invents the evidence it was + * built to find. `never read on this link` is the honest answer and is printed as one. + * + * #2397: [rssiReads], [rssiWorstDbm] and [rssiSumDbm] describe every reading the link took, not + * just the last one. The periodic read landed in #2332 and its value was then discarded on each + * new reading, so a link that took two hundred readings reported one: a field log carried 467 reads + * across three links and two epitaphs naming two numbers. A last value alone cannot separate a link + * that was marginal throughout from one that walked out of range, which is precisely what a + * supervision timeout leaves a reader asking. The mean is computed HERE, from a sum and a count, + * rather than passed in, so both platforms divide the same way on the same inputs. + * + * RSSI is NOT clamped. It is negative by nature, so `maxOf(0, ...)` would erase every real reading; + * implausible values are the caller's to reject at the stash, where the read status is known. + * + * 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. + * [rssiAgeMillis] is Long for the same reason and shares the line's units, so a reader can compare it + * with [upMillis] directly instead of converting. + * + * Twin of Swift `ConnectionReadout.linkEpitaph`. Declared because the two must print the SAME bytes: + * one strap log per platform describing one drop, and a reader comparing them should be comparing the + * link, not the formatter. */ + fun linkEpitaph(upMillis: Long, inboundFrames: Int, inboundBytes: Int, cmdChannelFrames: Int, + realtimeArmed: Boolean, ended: String, + rssiDbm: Int?, rssiAgeMillis: Long?, + rssiReads: Int = 0, rssiWorstDbm: Int? = null, rssiSumDbm: Int = 0): String { + // #2397: the SHAPE of the link's signal, not just its last reading. Two readings is the floor: + // with one, worst and mean are the value already printed and the clause is noise. + val shape = if (rssiReads >= 2 && rssiWorstDbm != null) { + // Integer division, truncating toward zero on both platforms (Kotlin and Swift agree on + // negatives), so the two logs cannot round apart. The mean of a handful of dBm readings is a + // shape, not a measurement, and one dB of truncation does not change what it says. + "; n=$rssiReads worst=${rssiWorstDbm}dBm mean=${rssiSumDbm / rssiReads}dBm" + } else { + "" + } + val signal = when { + rssiDbm == null -> "never read on this link" + rssiAgeMillis == null -> "${rssiDbm}dBm (age unknown$shape)" + else -> "${rssiDbm}dBm (read ${maxOf(0L, rssiAgeMillis)}ms before the drop$shape)" + } + 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"}, signal=$signal, 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/DaytimeBaselines.kt b/android/app/src/main/java/com/noop/analytics/DaytimeBaselines.kt index 5a15384fc8..a07bfdb590 100644 --- a/android/app/src/main/java/com/noop/analytics/DaytimeBaselines.kt +++ b/android/app/src/main/java/com/noop/analytics/DaytimeBaselines.kt @@ -136,11 +136,24 @@ object DaytimeBaselines { * scorer honestly runs HR-only rather than z-scoring against a 1–2-day, untrustworthy HRV * baseline — the whole-baseline grain of the per-hour graceful-null already in rawScore. */ - fun foldDaytimeBaselines(days: List): DaytimeBaselineStates { - val hrAggs = ArrayList(days.size) - val rmssdAggs = ArrayList(days.size) - for (d in days) { - val agg = dayDaytimeAggregate(d.hr, d.rr, d.tzOffsetSeconds) + fun foldDaytimeBaselines(days: List): DaytimeBaselineStates = + foldAggregates(days.map { dayDaytimeAggregate(it.hr, it.rr, it.tzOffsetSeconds) }) + + /** + * The same fold, taking days already reduced to their [DayAggregate] pair. + * + * This exists so a CALLER can reduce each day as it reads it and let that day's raw samples go, + * instead of holding thirty days of them at once (#2107). A day collapses to two Doubles here and + * its streams are never read again, so keeping them alive bought nothing and cost a heap: thirty + * days of a worn 5.0 is millions of sample objects, which is how a 256MB heap ran out. + * + * [foldDaytimeBaselines] is now literally this with the reduction done eagerly, so the streaming + * caller and the list caller cannot drift apart: there is one fold, reached two ways. + */ + fun foldAggregates(aggregates: List): DaytimeBaselineStates { + val hrAggs = ArrayList(aggregates.size) + val rmssdAggs = ArrayList(aggregates.size) + for (agg in aggregates) { hrAggs.add(agg.hr) rmssdAggs.add(agg.rmssd) } @@ -157,8 +170,13 @@ object DaytimeBaselines { * a cold start, or a trailing window that is all sparse/imported days, keeps EXACTLY today's * pre-existing day-relative behaviour. */ - fun scoringMode(days: List): DaytimeStress.ScoringMode { - val baselines = foldDaytimeBaselines(days) + fun scoringMode(days: List): DaytimeStress.ScoringMode = + scoringModeFromAggregates(days.map { dayDaytimeAggregate(it.hr, it.rr, it.tzOffsetSeconds) }) + + /** [scoringMode] for days already reduced to aggregates. See [foldAggregates] for why (#2107). + * Twin of Swift `DaytimeStress.scoringModeFromAggregates`. */ + fun scoringModeFromAggregates(aggregates: List): DaytimeStress.ScoringMode { + val baselines = foldAggregates(aggregates) if (!baselines.hr.usable) return DaytimeStress.ScoringMode.DayRelative // RMSSD stays out of the LIVE score until it has its own validation pass — see // DaytimeStress.daytimeRMSSDScoringEnabled. The baseline is still folded above (so the diff --git a/android/app/src/main/java/com/noop/analytics/DaytimeStress.kt b/android/app/src/main/java/com/noop/analytics/DaytimeStress.kt index f51c953b45..0bc55806e6 100644 --- a/android/app/src/main/java/com/noop/analytics/DaytimeStress.kt +++ b/android/app/src/main/java/com/noop/analytics/DaytimeStress.kt @@ -45,6 +45,22 @@ object DaytimeStress { const val minHourHrSamples: Int = 300 /** Bucket width for the timeline, in seconds (one hour). */ const val bucketSeconds: Long = 3_600L + /** + * How far the DISPLAY timeline slides its window between points. + * + * The scored unit stays a full [bucketSeconds] hour. This only decides how often that hour is + * re-read, so a half-step gives two points an hour, each still an hour of data, rather than + * half-hours of thinner data. Shrinking [bucketSeconds] itself would have been a scoring change + * wearing a display change's clothes: the calm reference is a quartile ACROSS buckets, the + * post-exercise shadow looks back exactly one bucket, and [sustainedHours] counts them. + * + * Half rather than a quarter because adjacent points then share half their samples instead of + * three quarters. The curve is smoother either way, and a denser line invites the reader to see + * detail it cannot resolve: a 30-minute spike still moves an hour's worth of weight, just sooner. + * Half is the least overlap that still doubles the resolution, and it keeps every on-the-hour + * point exactly where the hourly pass put it. + */ + const val timelineStepSeconds: Long = 1_800L /** Band floor for "high" on the shared 0–3 scale (matches StressBand.High). */ const val highBandFloor: Double = 2.0 /** Consecutive most-recent covered hours that must all be HIGH to flag sustained stress. */ @@ -232,6 +248,17 @@ object DaytimeStress { * [rawScore], not flagged day-wide) and false for [EMPTY]. */ val hrOnlyFallback: Boolean = false, + /** + * DISPLAY-ONLY sliding read of the same day: [hours] plus a point every + * [timelineStepSeconds], each still scored over a full [bucketSeconds] window against the + * SAME reference [hours] used. Defaults to [hours] so a caller that never asked for it, and + * every existing test, sees exactly what it saw before. + * + * Deliberately NOT the input to anything that counts hours. [sustainedHigh], + * [highStressMinutes], [dayMean] and [peak] all stay on the non-overlapping [hours], because + * overlapping windows would count the same minute more than once. + */ + val timeline: List = hours, ) { /** The scored hours only (level non-null), in time order. */ val scored: List get() = hours.filter { it.level != null } @@ -320,58 +347,81 @@ object DaytimeStress { gravity: List = emptyList(), tzOffsetSeconds: Long = 0L, mode: ScoringMode = ScoringMode.DayRelative, + /** + * Also compute [Result.timeline], the sliding read is OPT-IN because half the callers do not want it. + + The Stress screen reads `hours` and draws its own interactive timeline; making it pay for a + second pass of bucketing and one RMSSD per extra window, on the screen that already does three + 200 000-row reads, would be cost for nothing. The widget and the Today card ask for it. + */ + includeTimeline: Boolean = false, ): Result { if (hr.isEmpty()) return Result.EMPTY // 1) Bucket HR + R-R into LOCAL hour-of-day buckets, keyed by the bucket start // (floored to the hour on the local clock). - val hrByBucket = HashMap>() - for (s in hr) { - val localTs = s.ts + tzOffsetSeconds - val bucket = floorDiv(localTs, bucketSeconds) * bucketSeconds - hrByBucket.getOrPut(bucket) { ArrayList() }.add(s.bpm.toDouble()) + fun hrBuckets(phase: Long): HashMap> { + val m = HashMap>() + for (s in hr) m.getOrPut(bucketOf(s.ts + tzOffsetSeconds, phase)) { ArrayList() } + .add(s.bpm.toDouble()) + return m } - val rrByBucket = HashMap>() - for (s in rr) { - val localTs = s.ts + tzOffsetSeconds - val bucket = floorDiv(localTs, bucketSeconds) * bucketSeconds - rrByBucket.getOrPut(bucket) { ArrayList() }.add(s.rrMs.toDouble()) + fun rrBuckets(phase: Long): HashMap> { + val m = HashMap>() + for (s in rr) m.getOrPut(bucketOf(s.ts + tzOffsetSeconds, phase)) { ArrayList() } + .add(s.rrMs.toDouble()) + return m } + val hrByBucket = hrBuckets(0L) + val rrByBucket = rrBuckets(0L) // 2) Per-hour mean HR + RMSSD (RMSSD via the shared HRV cleaner, so ectopic beats // can't fabricate variability). An hour with < minHourHrSamples HR is left // unscored (null level) — never invented. data class HourAgg(val bucket: Long, val meanHr: Double?, val rmssd: Double?) - val orderedBuckets = hrByBucket.keys.sorted() - val aggs = ArrayList(orderedBuckets.size) - for (b in orderedBuckets) { - val hrs = hrByBucket[b] ?: emptyList() - val mHr = if (hrs.size >= minHourHrSamples) mean(hrs) else null - val rrRes = HrvAnalyzer.analyzeRaw(rrByBucket[b] ?: emptyList()) - aggs.add(HourAgg(b, mHr, rrRes.rmssd)) + fun aggregate( + hrGrid: Map>, + rrGrid: Map>, + ): List { + val ordered = hrGrid.keys.sorted() + val out = ArrayList(ordered.size) + for (b in ordered) { + val hrs = hrGrid[b] ?: emptyList() + val mHr = if (hrs.size >= minHourHrSamples) mean(hrs) else null + val rrRes = HrvAnalyzer.analyzeRaw(rrGrid[b] ?: emptyList()) + out.add(HourAgg(b, mHr, rrRes.rmssd)) + } + return out } + val aggs = aggregate(hrByBucket, rrByBucket) // 2b) Motion gate: bucket the day's gravity-derived activity by the SAME local hour and mark // each hour AMBULATORY when at least [activityMaskFraction] of its records clear the // calibrated walk floor ([WorkoutDetector.motionThreshold]) — reusing the exact activity // series SedentaryDetector / WorkoutDetector already trust. Empty gravity → no active // buckets → nothing masked below (byte-identical to the pre-motion behaviour). - val activeFracByBucket = HashMap() - if (gravity.isNotEmpty()) { + // Derived ONCE and re-bucketed per grid. `activitySeries` walks the whole day's gravity, so + // recomputing it for the second grid would have doubled the most expensive part of the motion + // gate to answer the same question about the same samples. + val activity = if (gravity.isEmpty()) emptyList() else WorkoutDetector.activitySeries(gravity) + fun activeFractions(phase: Long): HashMap { + val out = HashMap() + if (activity.isEmpty()) return out val activeCounts = HashMap() val totalCounts = HashMap() - for (p in WorkoutDetector.activitySeries(gravity)) { - val localTs = p.ts + tzOffsetSeconds - val bucket = floorDiv(localTs, bucketSeconds) * bucketSeconds + for (p in activity) { + val bucket = bucketOf(p.ts + tzOffsetSeconds, phase) totalCounts[bucket] = (totalCounts[bucket] ?: 0) + 1 if (p.intensity > WorkoutDetector.motionThreshold) { activeCounts[bucket] = (activeCounts[bucket] ?: 0) + 1 } } for ((b, total) in totalCounts) { - if (total > 0) activeFracByBucket[b] = (activeCounts[b] ?: 0).toDouble() / total + if (total > 0) out[b] = (activeCounts[b] ?: 0).toDouble() / total } + return out } + val activeFracByBucket = activeFractions(0L) fun isAmbulatory(bucket: Long): Boolean = (activeFracByBucket[bucket] ?: 0.0) >= activityMaskFraction @@ -443,32 +493,57 @@ object DaytimeStress { } // 4) Score each waking-hour bucket on the shared 0–3 curve. - val points = ArrayList(aggs.size) - for (a in aggs) { - if (!isWakingHour(a.bucket)) continue - val hourOfDay = (floorDiv(a.bucket, bucketSeconds) % 24).toInt() - // The wall-clock bucket start (undo the local shift applied above). - val wallStart = a.bucket - tzOffsetSeconds - // Motion gate: an AMBULATORY hour — or the post-exercise shadow hour whose HR has not - // yet recovered to the calm reference — is EXERTION, so its elevated HR is masked out of - // the score instead of read as stress. The shadow is gated on refHr so it self-limits to - // genuine cardiac recovery (a following hour already back at baseline scores normally). - // Only meaningful when the hour actually HAD a reading to withhold — a no-HR hour is - // plain no-data, not "masked". - val shadow = isAmbulatory(a.bucket - bucketSeconds) && - a.meanHr != null && refHr != null && a.meanHr > refHr + postActivityShadowBpm - val masked = a.meanHr != null && (isAmbulatory(a.bucket) || shadow) - // Score only when HR cleared the count gate AND the hour was not motion-masked (HR is - // the always-available anchor; RMSSD enriches it when beats allow). - val level: Double? = if (a.meanHr != null && !masked) { - squash(rawScore(a.meanHr, refHr, sdHr, a.rmssd, refRmssd, sdRmssd)) - } else { - null + // + // Written against a supplied bucket grid so the SAME expression scores the on-the-hour pass + // and the half-step display pass. One copy, so the two can never drift into scoring the same + // hour differently — which is the whole reason the sliding read reuses the references + // computed above rather than deriving its own. + fun scoreGrid(gridAggs: List, activeFrac: Map): List { + fun ambulatory(bucket: Long): Boolean = + (activeFrac[bucket] ?: 0.0) >= activityMaskFraction + val out = ArrayList(gridAggs.size) + for (a in gridAggs) { + if (!isWakingHour(a.bucket)) continue + val hourOfDay = (floorDiv(a.bucket, bucketSeconds) % 24).toInt() + // The wall-clock bucket start (undo the local shift applied above). + val wallStart = a.bucket - tzOffsetSeconds + // Motion gate: an AMBULATORY hour — or the post-exercise shadow hour whose HR has not + // yet recovered to the calm reference — is EXERTION, so its elevated HR is masked out + // of the score instead of read as stress. The shadow is gated on refHr so it + // self-limits to genuine cardiac recovery (a following hour already back at baseline + // scores normally). Only meaningful when the hour actually HAD a reading to withhold + // — a no-HR hour is plain no-data, not "masked". The look-back is one FULL window on + // either grid, so the half-step pass shadows the same hour of exertion. + val shadow = ambulatory(a.bucket - bucketSeconds) && + a.meanHr != null && refHr != null && a.meanHr > refHr + postActivityShadowBpm + val masked = a.meanHr != null && (ambulatory(a.bucket) || shadow) + // Score only when HR cleared the count gate AND the hour was not motion-masked (HR is + // the always-available anchor; RMSSD enriches it when beats allow). + val level: Double? = if (a.meanHr != null && !masked) { + squash(rawScore(a.meanHr, refHr, sdHr, a.rmssd, refRmssd, sdRmssd)) + } else { + null + } + out.add(HourPoint(hourOfDay, wallStart, level, a.meanHr, a.rmssd, masked)) } - points.add(HourPoint(hourOfDay, wallStart, level, a.meanHr, a.rmssd, masked)) + return out } + val points = scoreGrid(aggs, activeFracByBucket) val activityMaskedHours = points.count { it.maskedForActivity } + // 4b) The half-step DISPLAY timeline: the same hour-long window re-read every + // [timelineStepSeconds], scored against the SAME references, and merged with the + // on-the-hour points. Every hourly point survives untouched; only the straddling + // midpoints are new, so the curve still passes through exactly the values scored above. + // Nothing that counts hours reads this — see [Result.timeline]. + val timeline = if (includeTimeline && timelineStepSeconds in 1 until bucketSeconds) { + val midAggs = aggregate(hrBuckets(timelineStepSeconds), rrBuckets(timelineStepSeconds)) + (points + scoreGrid(midAggs, activeFractions(timelineStepSeconds))) + .sortedBy { it.startTs } + } else { + points + } + val scored = points.mapNotNull { p -> p.level?.let { p to it } } if (scored.isEmpty()) { // No scorable waking hour — still return the (unscored) timeline so the UI can @@ -478,7 +553,7 @@ object DaytimeStress { return if (points.isEmpty()) Result.EMPTY else Result(points, sustainedHigh = false, sustainedRun = 0, dayMean = null, peak = null, activityMaskedHours = activityMaskedHours, - highStressMinutes = 0, hrOnlyFallback = hrOnlyFallback) + highStressMinutes = 0, hrOnlyFallback = hrOnlyFallback, timeline = timeline) } // 5) Sustained-high flag: walk back from the latest SCORED hour while each is HIGH. @@ -497,6 +572,7 @@ object DaytimeStress { val highStressMinutes = scored.count { it.second >= highBandFloor } * (bucketSeconds / 60L).toInt() return Result(points, sustained, run, dayMean, peak, + timeline = timeline, activityMaskedHours = activityMaskedHours, highStressMinutes = highStressMinutes, hrOnlyFallback = hrOnlyFallback) } @@ -513,15 +589,35 @@ object DaytimeStress { return if (r != 0L && (r < 0L) != (b < 0L)) q - 1 else q } + /** + * The bucket a local timestamp falls in for a grid offset by [phase]. + * + * `phase = 0` is the on-the-hour grid every existing reading uses. `phase = timelineStepSeconds` + * is the same grid slid forward, so its windows straddle the hour boundaries rather than + * replacing them. + */ + private fun bucketOf(localTs: Long, phase: Long): Long = + floorDiv(localTs - phase, bucketSeconds) * bucketSeconds + phase + + /** + * Whether a LOCAL hour-of-day falls inside the waking window the timeline scores (06:00-22:00). + * + * Split out from [isWakingHour] so a caller holding a wall-clock hour rather than a bucket can ask + * the same question of the same constants. The stress widget needs exactly that: to say whether an + * empty curve means "nothing is coming until morning" or "today has not produced a scorable hour + * yet", it has to know the window, and reimplementing the comparison there would be a second copy + * of a rule whose whole point is having one. + */ + internal fun isWakingHourOfDay(hourOfDay: Int): Boolean = + hourOfDay >= wakingStartHour && hourOfDay < wakingEndHour + /** * Whether a local hour-bucket start falls inside the waking window the timeline scores * (06:00–22:00). The single source of truth for "waking" — used both to build the calm * reference and to pick the hours to score, so the two can never drift apart. */ - internal fun isWakingHour(bucket: Long): Boolean { - val hourOfDay = (floorDiv(bucket, bucketSeconds) % 24).toInt() - return hourOfDay >= wakingStartHour && hourOfDay < wakingEndHour - } + internal fun isWakingHour(bucket: Long): Boolean = + isWakingHourOfDay((floorDiv(bucket, bucketSeconds) % 24).toInt()) /** * The day's "calm" reference for a signal: the quartile toward the calm end (lower 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 22c031b80e..82501e07a7 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 @@ -69,6 +80,12 @@ object IntelligenceEngine { * same reason [dayScanCache] is: every pass runs under [analyzeGate], so there is no concurrent access. */ private val skippedSleepDays = SleepSkipCollector() + /** #2073: why each night missed the reuse cache this pass, by cause. A FIELD rather than a local for + * the same reason [dayScanCache] is: `analyzeRecentOnCpu` sits on a JaCoCo bytecode ratchet, and a + * local plus the parameter passing it needs cost more there than the diagnostic is worth. Cleared at + * the top of every pass; safe because every pass runs under `analyzeGate`. */ + private val dayCacheMissBy = LinkedHashMap() + /** * #1005 BATTERY: in-memory per-day reuse for [analyzeRecent]'s pass-1 loop, keyed by day. On a heavy user * (21 nights, ~178 k HR rows/night, a 1.26 GB store) every re-score re-read *every* night's raw streams @@ -88,6 +105,23 @@ object IntelligenceEngine { private var dayScanCache = HashMap() private var dayScanCacheConfigSig = "" + /** Whether this process has already seeded [stepsMotionCache] from the persisted payload. The read + * happens once per process, not once per pass: after the first pass the in-memory cache is at least as + * fresh as the payload, so re-reading it could only ever put back what the pass just pruned. Latched + * only when a store was actually consulted, so a caller that does not wire one cannot silently spend + * the single load on nothing and leave the rest of the process unpersisted. */ + private var stepsMotionCacheLoaded = false + + /** The payload currently in the store, so an unchanged cache does not rewrite it. A pass that reused + * every day renders the string it read, and those passes are the back-to-back ones an offload storm is + * made of. Not an assumption about what the store does with an identical value: it is not asked. */ + private var stepsMotionCachePersisted = "" + + /** Per-day steps-calibration motion folds, `day -> (key, motion)`, keyed by [StepsMotionCache.cacheKey]. + * In-memory and per-process exactly like [dayScanCache]; see [StepsMotionCache] for why this one needs no + * config signature. Pruned to the calibration window each pass so it cannot grow without bound. */ + private var stepsMotionCache = HashMap>() + /** One reused night: its per-day cache [key], the scored [res], and everything the pass-1 loop otherwise * writes into function-scoped per-day maps that pass 2 reads (owner/hrRows/primary-session RHR/SpO₂ * candidate/HRV over-count), plus the always-on per-day [diagLines] to replay so a reused pass logs the @@ -119,8 +153,21 @@ object IntelligenceEngine { * in by the UI scoring pass. Mirrors the Swift IntelligenceEngine.resolveDayOwner read-through (1B-4). */ interface DayOwnerSource { - /** Non-archived paired devices, each as a [DayOwnerResolver.Candidate] WITHOUT its hasData flag - * resolved yet (priority only: 0 = active strap, 1 = other live straps, 2 = imports). */ + /** EVERY paired device, each as a [DayOwnerResolver.Candidate] WITHOUT its hasData flag resolved + * yet (priority only: 0 = active strap, 1 = other live straps, 2 = whole-day imports, + * 3 = activity files (#137), 4 = archived). + * + * Archived rows ARE included, and [resolveDayOwner] USES them rather than merely inheriting + * them from the day-cycle/step engines that read the same list: + * `RegistryDayOwnerSourceTest.archivedDeviceRemainsALowestPriorityHistoricalCandidate` pins an + * archived device winning a day nothing live covers, so removing and later re-adding a strap + * cannot erase its earlier coverage. A live or import source always outranks it. + * + * Swift builds its two lists separately and they differ in SHAPE, not only in membership: the + * cycle list carries these same five priorities, while the day-owner half filters archived out + * first and ranks the remainder FOUR ways, with no archived branch at all + * (`IntelligenceEngine.resolveDayOwner`). So for a day covered only by a removed strap the two + * platforms name a different owner. Which rule is the intended one is open in #2026. */ suspend fun candidatePriorities(): List> /** A locked owner override for [day] from the dayOwnership table, or null. Wins outright. */ @@ -159,8 +206,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 @@ -173,6 +227,33 @@ object IntelligenceEngine { * consumed by pass 2's universal dayOwner emit. */ private data class OwnerRead(val owner: String, val hrRows: Int) + /** + * #2013: the census beside `re-score: done`, saying what the pass is carrying rather than how many + * nights it visited. + * + * A day that is scored but comes back with a null metric vanishes from that metric's detail screen + * without a word, and the reported case looked identical to a day that was never scored at all. The + * split between "the pass never produced it" and "the pass produced it and it was lost downstream" is + * the first question in that investigation and the log could not answer it. + * + * This counts what the pass PRODUCED, taken from its own DayResult rather than from a stored row, so + * it answers one half of the split. If a metric matches the night count and days are still absent + * from the screen, the loss is downstream of here and wants its own line at the write. That is the + * useful outcome either way: it says which half to look in, which is what #2013 lacked. + * + * Pure so the wording is pinned without running a pass. Names the day SPAN too, since a window that + * quietly shrank is the other way days go missing. + */ + internal fun reScoreCensusLine(scored: List): String { + if (scored.isEmpty()) return "re-score census: 0 night(s), nothing to carry" + val days = scored.map { it.day }.sorted() + return "re-score census: ${scored.size} night(s) ${days.first()}..${days.last()} " + + "charge=${scored.count { it.recovery != null }} effort=${scored.count { it.strain != null }} " + + "sleep=${scored.count { it.sleepMin != null }} hrv=${scored.count { it.hrv != null }} " + + "rhr=${scored.count { it.rhr != null }} (a metric short of the night count is one the pass " + + "produced nothing for)" + } + /** Summary of one scored day (for logging / a future on-device intelligence screen). */ data class Computed( val day: String, @@ -391,7 +472,7 @@ object IntelligenceEngine { universalSink: ((String) -> Unit)? = null, // Workouts & GPS test-mode trace sink (Test Centre, #975). Context-free layer, so the caller reads // TestCentre.active(WORKOUTS) and passes a non-null sink ONLY when the mode is on, routing each - // detected-bout persist/drop decision to the .workouts-tagged strap log. null (the default) = + // detected-bout analytics/backfill decision to the .workouts-tagged strap log. null (the default) = // byte-identical default path (no lines). Mirrors the Swift workoutsTraceActive wiring. workoutsTraceSink: ((String) -> Unit)? = null, // HRV & Autonomic test-mode sink (#141). Context-free layer, so the caller reads TestCentre.active(HRV) @@ -410,6 +491,14 @@ 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, + // Persisted backing for [stepsMotionCache]. Context-free like the rest of this layer, mirroring + // manualStepCoefficient / persistStepsCalibration above: the Context-aware caller (AppViewModel) + // reads and writes SharedPreferences and passes the accessors down. The defaults are no-ops, so a + // caller that does not wire them keeps exactly today's per-process-only behaviour, which is what + // every existing test relies on. Read/written under [analyzeGate] with the cache they back. + stepsMotionCacheGet: (() -> String?)? = null, + stepsMotionCacheSet: ((String) -> Unit)? = null, ): 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). @@ -418,12 +507,31 @@ object IntelligenceEngine { // [analyzeGate]). The heavy scoring already ran off the caller's thread via withContext above; the // lock is held only for this engine's own passes, never across an unrelated suspension. val scored = analyzeGate.withLock { + // Seed the fold cache from storage on the first pass of the process. Without this the sixty-day + // fold is re-paid in full after every relaunch — the cache's whole win is a repeat, and a + // relaunch is a repeat the process boundary hid. Nothing pass-global feeds the fold, so a + // payload written by a previous launch is as good as one written by the previous pass; see + // [StepsMotionCache]. Inside the lock because it writes the gate-guarded cache. + // Zero the per-day probe counters so the line below describes THIS pass and never accumulates + // across the back-to-back passes an offload storm is made of. Reset and emit both live in this + // wrapper, never in `analyzeRecentOnCpu`, whose ratchet margin has no room for either. + StoreProbeTally.reset() + if (!stepsMotionCacheLoaded && stepsMotionCacheGet != null) { + stepsMotionCacheLoaded = true + val raw = stepsMotionCacheGet() + if (raw != null) { + stepsMotionCache = StepsMotionCache.deserialize(raw) + // Seed the write guard with what is actually stored. A payload this build renders + // identically then costs no write; one carrying a line we dropped is rewritten clean. + stepsMotionCachePersisted = raw + } + } val (out, healed) = analyzeRecentOnCpu(repo, profile, maxDays, importedDeviceId, maxHROverride, nowSeconds, ownerSource, manualStepCoefficient, persistStepsCalibration, baselineEpoch, recoveryEpoch, diag, useExperimentalSleepV2, useMotionAwareWake, sleepTraceSink, recoveryTraceSink, stepsTraceSink, universalSink, workoutsTraceSink, hrvTraceSink, deepHrvWindow, - spo2CandidateDisplay, effortMethod) - if (healed == 0) out + spo2CandidateDisplay, effortMethod, dayCycleMode) + val result = 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 // detections weren't banked yet), so its survivor can differ from the heal's. ONE bounded re-pass @@ -433,9 +541,28 @@ 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 + // Write the pruned cache back, after the heal re-pass so the payload reflects whichever pass ran + // last, and only when it moved. `serialize` renders sorted, so a pass that reused every day + // produces the string already stored and skips the write entirely. + if (stepsMotionCacheSet != null) { + val payload = StepsMotionCache.serialize(stepsMotionCache) + if (payload != stepsMotionCachePersisted) { + stepsMotionCachePersisted = payload + stepsMotionCacheSet(payload) + } + } + // What the pass spent on its per-day probe queries, beside the `stepsMotion reused=N/M` line. + // Together they say whether a warm pass that folded nothing still went into the round trips. + diag(StoreProbeTally.line()) + result } diag("re-score: done — scored ${scored.size} night(s) in ${(System.nanoTime() - reScoreStart) / 1_000_000} ms (#1005)") + // #2013: what the pass actually CARRIES, beside how many nights it touched. "scored N nights" is + // silent about a day that was scored and came back empty, which is exactly the shape reported: the + // detail screen omitted days the log showed being scored, and nothing in between said which half + // lost them. A census of the results makes that answerable from one shared log. + diag(reScoreCensusLine(scored)) scored } @@ -521,7 +648,7 @@ object IntelligenceEngine { // scored day emits the verbatim `dayOwner …` line. See the public overload's doc. universalSink: ((String) -> Unit)? = null, // Workouts & GPS test-mode trace sink (#975). null = byte-identical default (no lines); when non-null - // each detected bout emits a `detectedBout verdict=persisted|droppedOverlap …` line to the .workouts- + // each detected bout emits a `detectedBout verdict=analyticsOnly|droppedOverlap…` line to .workouts- // tagged strap log, so an "auto workout appeared then vanished" is explainable from an export. Swift twin. workoutsTraceSink: ((String) -> Unit)? = null, // HRV & Autonomic test-mode sink (#141). null = byte-identical default (no lines); when non-null, @@ -537,6 +664,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 +725,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 +778,8 @@ 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, + finishedBefore = nowLocalMidnight, offsetSec = 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 +809,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() @@ -716,6 +847,10 @@ object IntelligenceEngine { // 2 s (#755). So this fires once per completed backfill, not once per chunk. That coalescing is // load-bearing for the cache — removing it would reintroduce the #1402 storm in a form no signature // change can fix. + // Field names live in [DAY_CACHE_CONFIG_FIELDS], in this exact order, so that this stays a plain + // value list and costs the bytecode ratchet nothing. Add or reorder here and add or reorder there: + // [changedConfigField] refuses to name anything when the counts disagree, but it cannot see a + // REORDER, which would quietly label the wrong field. val dayCacheConfigSig = listOf( baselines1.hrv.toString(), baselines1.restingHR.toString(), profile.age.toString(), profile.sex.toString(), profile.stepTicksPerStep.toString(), @@ -728,14 +863,13 @@ 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. - if (dayCacheConfigSig != dayScanCacheConfigSig) { - dayScanCache = HashMap() - dayScanCacheConfigSig = dayCacheConfigSig - } var dayCacheReused = 0 + dayCacheMissBy.clear() + dropDayCacheIfConfigChanged(dayCacheConfigSig) // #1538: per-phase cost tally. `prep` brackets the nine windowed store reads plus the session // matching that sits between them and [AnalyticsEngine.analyzeDay]; `score` brackets analyzeDay // itself. Emitted once per pass beside the reuse line. Byte-identical line to the Swift twin. @@ -746,6 +880,16 @@ 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 activeWhoop5RR = activeWhoop5RrPolicy(repo, candidatePriorities, importedDeviceId) + val rrWindow = rrReadWindow(repo, activeWhoop5RR) + // #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 +905,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 +946,14 @@ 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], + val key = rrAwareDayCacheKey( + repo, owner, from, to, skinAnchorByOwner[owner], + unlabelledAliasOfWhoop5 = activeWhoop5RR && owner == com.noop.data.WhoopRepository.WHOOP_SOURCE, // #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 @@ -816,8 +961,8 @@ object IntelligenceEngine { // makes the cost what this change claims: paid only while a trace is on. hrvWindowDetail = hrvTraceSink != null && dayStart == nowLocalMidnight) dayCacheKey = key - val cached = dayScanCache[day] - if (cached != null && cached.key == key) { + val cached = reusableDayScan(day, key) + if (cached != null) { // Repopulate every per-day map pass 2 reads (mirror of the loop's own writes below), // replay the day's diag lines, and continue — the reused night is downstream- // indistinguishable from a freshly-scored one. readOwnerByDay is the universal CAPTURE-B @@ -848,7 +993,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 +1020,8 @@ object IntelligenceEngine { skippedSleepDays.add(day, hr.size) continue } - val rr = repo.rrIntervals(owner, from, to, STREAM_LIMIT) + val rr = readRrWindow(rrWindow, repo, owner, from, to, + activeWhoop5RR && owner == com.noop.data.WhoopRepository.WHOOP_SOURCE) // 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 +1032,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 +1063,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 +1071,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 +1093,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( + day = day, 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( + day = day, 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( + day = day, attempted = true, reason = "no-motion-no-hypnogram", + gravRows = grav.size, storedNights = 0, + )) + SleepStager.hrOnlySessions(day, 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, @@ -1049,8 +1211,19 @@ object IntelligenceEngine { // `nInput` is set before the min-beats gate, so a sparse night still shows its count with // rmssd=nil). A SEPARATE analyzeRaw pass over the in-sleep R-R — does NOT touch the shipped // windowed avgHrv. Emitted here where `rr` is in scope; byte-identical to the Swift line. - val sleepRrRows = rr.filter { r -> res.sleepSessions.any { r.ts >= it.start && r.ts < it.end } } + // #2425: the MAIN night the #1118 gate judged, not every session of the day pooled over the gaps + // between them; see AnalyticsEngine.hrvDiagnosticRows. hrvOverCounted and the RSA resp gate read + // the same rows, so they now agree with the gate too. + val sleepRrRows = AnalyticsEngine.hrvDiagnosticRows( + rr, res.mainNightBlocks, + res.sleepSessions.map { SleepStageTotals.NightBlock(it.start, it.end) }, + ) val sleepRr = sleepRrRows.map { it.rrMs.toDouble() } + // #1331: the RSA gate's inputs, carried to the resp diagnostic below. Declared out here because + // the HRV block is one scope deeper; a night with no sleep R-R leaves them null and the resp + // line falls back to its original one-field form. + var respGateAcc: Double? = null + var respGateIntegrity: String? = null if (sleepRr.isNotEmpty()) { val h = HrvAnalyzer.analyzeRaw(sleepRr) val ms = { v: Double? -> v?.let { String.format(java.util.Locale.US, "%.0f", it) } ?: "nil" } @@ -1079,9 +1252,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 @@ -1090,10 +1267,12 @@ object IntelligenceEngine { // right to ~1% (meanNN and RHR stay correct and WHOOP-validated) while the individual // intervals are not. Gate on that too. Twin of the Swift line. val accVal = HrvAnalyzer.beatAccurateFraction(ts, sleepRr) + respGateAcc = accVal val acc = String.format(java.util.Locale.US, "%.2f", accVal) val sdnnField = if (HrvAnalyzer.beatSpreadIsTrustworthy(verdict) && HrvAnalyzer.beatValuesAreTrustworthy(accVal)) "${ms(h.sdnn)}ms" else "withheld" + respGateIntegrity = verdict.raw dayDiag("hrv diag day=${res.daily.day} rmssd=${ms(h.rmssd)}ms sdnn=$sdnnField meanNN=${ms(h.meanNN)}ms " + "rr=${h.nInput}/${h.nClean} rejected=$rej% coverage=$cov collapsedCov=$colCov dupBeats=$dup " + "beatAccurate=$acc " + @@ -1182,7 +1361,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, ), ) } @@ -1208,8 +1387,8 @@ object IntelligenceEngine { nightlyRespByDay[day] = res.daily.respRateBpm // #1331 respiratory diagnostic: log each night's breaths/min (or "nil") so a "respiratory not // showing" report is explainable from the strap log — a run of nil nights localises when it - // stopped. Logging only; no scoring change. The Swift diag twin lands with the iOS carry (#1331 follow-up). - dayDiag(respRateLogLine(day, res.daily.respRateBpm)) + // stopped. Logging only; no scoring change. The Swift twin exists and emits the same line. + dayDiag(respRateLogLine(day, res.daily.respRateBpm, respGateAcc, respGateIntegrity)) // ── RHR floor-vs-mean diagnostic (#691) ──────────────────────────────────────────────── // Make the recurring "NOOP's resting HR reads LOWER than my sleeping-HR app" reports // explainable from the strap log instead of a guess. The two numbers measure different @@ -1221,12 +1400,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) @@ -1286,8 +1460,10 @@ object IntelligenceEngine { // unreadable fingerprint, or a night under the >=200-sample floor — can never be reused, so counting // it against the ratio made a healthy cache look broken and put a floor under how good the number // could ever get. Byte-identical string to the Swift twin. + // #2073: the miss tally rides the same line. Absent when nothing was cached to miss, so a first + // pass and a healthy pass both read exactly as before. diag("analyzeRecent dayCache reused=$dayCacheReused/${dayCacheReused + dayCacheCacheable} " + - "size=${dayScanCache.size} days=$maxDays") + "size=${dayScanCache.size} days=$maxDays${missByField()}") // #1538: where the pass actually goes. `prep` is the nine windowed store reads plus the session // matching between them; `score` is analyzeDay. The two do NOT sum to the pass total — pass 2, the // baseline folds and the reconciliation are outside this loop — so read them as a RATIO, which is @@ -1295,6 +1471,14 @@ 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, + hrWindow.ownerFlips, rrWindow.ownerFlips, + hrWindow.reuseOffReads, rrWindow.reuseOffReads, + ), + ) // ── Seed the baseline from the UNION of imported nightly history + the nightly // values just computed. This is the recovery fix: the "-noop" nightly avgHrv/ @@ -1389,7 +1573,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) @@ -1402,7 +1586,11 @@ object IntelligenceEngine { val out = ArrayList() val dailies = ArrayList() val sleepRows = ArrayList() - val workoutRows = ArrayList() + // #2187: the analytics detector remains useful for enriching an already logged workout, but it + // no longer publishes generic sport="detected" rows. Keep this list intentionally limited to + // backfills of real manual/imported rows; the opt-in Today confirmation path is the sole producer + // of a new user-visible workout. + val workoutBackfills = ArrayList() // Rest composite (0–100) per night → persisted as the sleep_performance metric series so the // dashboard Rest score reflects the new composite, not raw efficiency. Swift parity. val restRows = ArrayList() @@ -1451,6 +1639,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 @@ -1460,15 +1655,15 @@ 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 editedDaily = editedCycleDaily( + res, dayEditedRows, tzOffsetSeconds, habitualMidsleepSec, + physiologicalSteps, computedId, restRows, ) - val recovery = recomputeRecovery(daily, baselines2) + val daily = recomputeRecoveryDaily(editedDaily, res.nightlySkinTempC, baselines2) + val recovery = daily.recovery + val skinTempDevC = daily.skinTempDevC // Charge term-breakdown trace (Test Centre Group G): only when the Recovery test mode is on // (recoveryTraceSink non-null). Emits which term moved Charge and which was nil and forced the // renorm, tagged .recovery. The trace's score is RecoveryScorer.recovery verbatim, so the @@ -1477,7 +1672,6 @@ object IntelligenceEngine { if (recoveryTraceSink != null) { for (line in recoveryTraceLines(daily, baselines2)) recoveryTraceSink(line) } - val skinTempDevC = recomputeSkinTempDev(res.nightlySkinTempC, baselines2.skinTemp) RestScorer.restFromDaily(daily)?.let { rest -> restRows.add(MetricSeriesRow(deviceId = computedId, day = daily.day, key = "sleep_performance", value = rest)) } @@ -1542,8 +1736,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 @@ -1573,7 +1768,7 @@ object IntelligenceEngine { ) } // Stamp the computed source id + the re-scored recovery & skin-temp deviation onto the row. - dailies.add(daily.copy(deviceId = computedId, recovery = recovery, skinTempDevC = skinTempDevC)) + dailies.add(scoredDailyRow(daily, computedId, recovery, skinTempDevC, res.nightlySkinTempC)) // Map the rich DetectedSleep sessions → Room SleepSession cache rows. for (s in res.sleepSessions) { sleepRows.add( @@ -1591,9 +1786,9 @@ object IntelligenceEngine { ), ) } - // Persist the detected workouts the pipeline already computes (previously discarded). - // Skip any bout overlapping a real imported/manual workout so import+wear users don't - // double-count. sport="detected"; energyKcal is the APPROXIMATE Keytel/BMR total. + // The daily analytics detector still computes bouts for daily analytics and can enrich + // missing fields on an overlapping real imported/manual workout. It does NOT publish a new + // generic workout: the opt-in Today card remains the only creation path, and requires Save. // #1545: where the detector lost every candidate workout on this day, emitted BEFORE the // per-bout loop so it is present even when that loop runs zero times — which is exactly the // report it exists for. The `effort bout` line below explains a bout that exists; a strap log @@ -1631,7 +1826,7 @@ object IntelligenceEngine { collider, avgBpm = avgBpm, peakHR = s.peakHR, caloriesKcal = s.caloriesKcal, strain = s.strain, ) val didBackfill = backfilled != collider - if (didBackfill) workoutRows.add(backfilled) + if (didBackfill) workoutBackfills.add(backfilled) workoutsTraceSink?.invoke( WorkoutsTrace.detectedBoutLine( verdict = if (didBackfill) "droppedOverlapBackfilled" else "droppedOverlap", @@ -1641,22 +1836,8 @@ object IntelligenceEngine { ) continue } - workoutRows.add( - WorkoutRow( - deviceId = computedId, - startTs = s.start, - endTs = s.end, - sport = "detected", - source = computedId, - durationS = s.durationS, - energyKcal = s.caloriesKcal, - avgHr = avgBpm, - maxHr = s.peakHR, - strain = s.strain, - ), - ) workoutsTraceSink?.invoke( - WorkoutsTrace.detectedBoutLine(verdict = "persisted", durMin = durMin, avgBpm = avgBpm), + WorkoutsTrace.detectedBoutLine(verdict = "analyticsOnly", durMin = durMin, avgBpm = avgBpm), ) } } @@ -1749,31 +1930,12 @@ 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, + IntelligencePersistence.LegacyScoreClock(nowLocalMidnight, nowSeconds, tzOffsetSeconds), out, ) + repo.replaceComputedScoreWindow(computedWindow) persistFitnessVitalityAndSteps( repo = repo, @@ -1799,8 +1961,8 @@ object IntelligenceEngine { // mergeSleep / daily aggregate would DOUBLE-COUNT both into an inflated time-in-bed AND the edit // would visually revert. The edited row is already stored (it carries userEdited=1 and is never // re-emitted here , the engine only writes detected twins), so we simply don't re-insert its - // detected twin. Sleep has no delete-reinsert pass (unlike dailyMetric/workout), so this IS the - // idempotency guard for the edited case. Overlap uses the edit's EFFECTIVE window. (#318) + // detected twin. Sleep has no window replacement pass, so this IS the idempotency guard for the + // edited case. Overlap uses the edit's EFFECTIVE window. (#318) val editedWindows = editedRows.map { it.effectiveStartTs to it.endTs } // #33: also drop any re-detected night the user has DELETED: a dismissedSleep tombstone keeps it // from regenerating, mirroring the dismissedWorkout guard. Overlap (not exact startTs) because a @@ -1812,37 +1974,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 @@ -1860,8 +1993,16 @@ object IntelligenceEngine { // "naps". Dedup each device's rows AMONG THEMSELVES and delete stale copies under that SAME id // (deleteSleepSessionRowOnly deletes under the row's own deviceId), never across ids, so a survivor // is never orphaned under an id the day-owner read skips. `freshStarts` (this pass's computed bank - // witness) only matches the computedId rows; the others fall back to longest-wins, the read-side - // dedup's own default. Sorted for a deterministic order. Mirrors the Swift analyzeRecent heal. + // witness) is handed ONLY to the computedId sweep; every other id falls back to longest-wins, the + // read-side dedup's own default. It used to be passed to every id on the claim that it "only + // matches the computedId rows" — false on an Oura day, where the pass's sessions ARE the ring's + // `providedSleep` rows with `startTs` copied verbatim. The ring row the pass had READ was then + // ranked "fresh" in the ring's own sweep and outranked every fuller re-serve the ring banked while + // the pass was in flight (hours, when the OS suspends the app between the read and this heal): on + // 09-19/20 the heal deleted the 598-min full night one second after it landed and kept the 337-min + // row read at 04:14, so the day ended at 04:48 instead of 08:21. `SleepSessionDedup.healWitness` is + // the one shared rule (twin of Swift's). Sorted for a deterministic order. Mirrors the Swift + // analyzeRecent heal. val healDeviceIds = healDeviceIds(computedId, candidatePriorities.map { it.first }) // Compact shape of a row for the #1284 heal log — the two measures that adjudicate WHICH copy is // fuller (stage-segment count + decoded JSON length), in the SAME format as the dup-gen diagnostic @@ -1874,11 +2015,12 @@ 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 } - val sweep = SleepSessionDedup.dedupe(healable, freshStarts = keptStarts) + val witness = SleepSessionDedup.healWitness(healId, computedId, keptStarts) + val sweep = SleepSessionDedup.dedupe(healable, freshStarts = witness) // Row-only delete: the user-facing deleteSleepSession writes a #33 dismissal tombstone, which // would overlap the SURVIVING night's window and permanently suppress its re-detection. for (stale in sweep.dropped) { @@ -1905,11 +2047,11 @@ object IntelligenceEngine { "session(s) re-banked under a shifted strap timebase; re-scoring the affected days.", ) } - // Make re-detection idempotent across runs: clear the prior computed detected workouts - // in the scored window (a bout's startTs can drift as more HR arrives, which would - // otherwise orphan stale rows under the (deviceId,startTs,sport) key), then re-insert. - repo.deleteComputedWorkouts(computedId, "detected", windowStart, nowSeconds) - if (workoutRows.isNotEmpty()) repo.upsertWorkouts(workoutRows) + // #2187 data-safety invariant: analytics reconciliation must never delete grandfathered detected + // history and no longer inserts fresh generic rows. Only write real rows whose missing metrics were + // enriched above. Turning detection off, a partial stream, or a failed/cancelled pass is therefore + // incapable of erasing previously visible workouts. + repo.persistWorkoutBackfills(workoutBackfills) // #137: a manually-started workout is scored from sparse live HR at save time , near-zero // calories/strain on a 5/MG. Now that offloaded HR may cover the window, re-score the @@ -1946,6 +2088,11 @@ object IntelligenceEngine { persistStepsCalibration: (StepsEstimateEngine.Calibration) -> Unit, stepsTraceSink: ((String) -> Unit)?, ) { + // #1538: the pass after the day loop was never measured — the cost line brackets the day loop and is + // emitted the moment it returns, so the steps calibration re-folding sixty days of gravity every pass + // sat outside every number the pass printed. This brackets the two phases inside THIS helper; see + // AnalysisPhaseMarks for why Android stops here and Swift does not. + val postLoop = AnalysisPhaseMarks() // ── Fitness Age (Phase 2) , weekly, keyed to the week's Saturday ── val fa7 = dailies.sortedBy { it.day }.takeLast(7) val faRHRs = fa7.mapNotNull { it.restingHr }.map { it.toDouble() } @@ -1992,6 +2139,7 @@ object IntelligenceEngine { MetricSeriesRow(deviceId = computedId, day = satKey, key = "body_age", value = vRes.bodyAge))) } + postLoop.mark("weekly") // ── Steps ESTIMATE (WHOOP 4.0) , DAILY, keyed to each strap-only day ── // A WHOOP 4.0 sends no step count over BLE, so for days the phone DIDN'T also count steps we // estimate them: calibrate the strap's daily MOTION VOLUME against the phone's real step count on @@ -2022,16 +2170,57 @@ object IntelligenceEngine { } // Per-day motion volume over the calibration window, read from the owner-resolved strap streams. // (Owner resolution mirrors the scoring loop; a single-device install resolves to importedDeviceId.) + // + // Each day is re-folded only when its own gravity witness moved. The fold is pure over that one + // stream, so an unchanged witness means an unchanged volume — see [StepsMotionCache]. The fingerprint + // is a COUNT/MAX aggregate over the same (deviceId, ts) index the read walks, so a hit replaces a + // read capped at STREAM_LIMIT rows with one that returns a single row. val motionByDay = HashMap() + var motionReused = 0 + var motionFolded = 0 + val motionWindow = HashSet() for (off in 0 until stepsCalDays) { val dayMid = midnightLocal(nowLocalMidnight - off * SECONDS_PER_DAY, tzOffsetSeconds) val dayEnd = dayMid + SECONDS_PER_DAY - 1 val dayKey = AnalyticsEngine.dayString(dayMid, tzOffsetSeconds) + motionWindow.add(dayKey) val owner = resolveDayOwner(repo, ownerSource, candidatePriorities, dayKey, dayMid, dayEnd, importedDeviceId) - val grav = repo.gravitySamples(owner, dayMid, dayEnd, STREAM_LIMIT) - val m = StepsEstimateEngine.dayMotionIntensity(grav) + // Unlike the Swift twin there is no soft-failure branch here, and that is deliberate rather + // than an omission. Swift's store reads throw and this whole block wraps them in `try?`, so it + // has to say what a read it could not make means: a witness it cannot read bypasses the cache + // in both directions, and a zero fold from a FAILED read is not cached. Kotlin's repo reads + // propagate, so a failure aborts the pass before anything is written, which reaches the same + // place by a shorter route. Adding a catch here would not add safety; it would swallow an + // abort and start caching zeros that only mean "we could not look". + val fp = repo.gravityFingerprintWindow(owner, dayMid, dayEnd) + val key = StepsMotionCache.cacheKey(owner, fp.first, fp.second) + val cached = stepsMotionCache[dayKey] + val m: Double + if (cached != null && cached.first == key) { + m = cached.second + motionReused++ + } else { + val grav = repo.gravitySamplesForDevice(owner, dayMid, dayEnd, STREAM_LIMIT) + m = StepsEstimateEngine.dayMotionIntensity(grav) + motionFolded++ + // A ZERO fold is cached too. Storing only the days that moved would leave every unworn gap + // re-reading its whole stream on every pass to rediscover that it is empty, which is most of + // the window on exactly the sparse libraries this is worst for. + stepsMotionCache[dayKey] = key to m + } + // Unchanged: only a positive volume becomes a calibration/estimation input. The cache holds the + // fold, this holds the filter, so [motionByDay] is byte-identical to the old loop's — and #1816's + // stepsHasMotionSink, which reads its emptiness, is untouched. if (m > 0) motionByDay[dayKey] = m } + stepsMotionCache.keys.retainAll(motionWindow) + diag(StepsMotionCache.logLine(motionReused, motionFolded, stepsMotionCache.size)) + // #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) } @@ -2070,6 +2259,8 @@ object IntelligenceEngine { } } } + postLoop.mark("steps") + diag(AnalysisPhaseTally.logLine("persistSteps", postLoop.phases)) } /** @@ -2137,7 +2328,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 @@ -2178,6 +2369,20 @@ object IntelligenceEngine { ) } + /** + * Pass 1 has no seeded skin baseline. Attach the deviation before scoring so the score, + * trace and persisted row all consume the same temperature. Mirrors Swift recomputeRecoveryDaily. + */ + internal fun recomputeRecoveryDaily( + daily: DailyMetric, nightlySkinTempC: Double?, baselines: ProfileBaselines, + ): DailyMetric { + val input = daily.copy( + skinTempDevC = recomputeSkinTempDev(nightlySkinTempC, baselines.skinTemp), + skinTempC = nightlySkinTempC, + ) + return input.copy(recovery = recomputeRecovery(input, baselines)) + } + /** One day's source-only (daily-aggregate) recovery output, keyed by day. Mirrors Swift WatchScoredDay. */ data class WatchScoredDay(val day: String, val recovery: Double?, val confidence: ScoreConfidence) @@ -2303,7 +2508,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 @@ -2315,6 +2520,10 @@ object IntelligenceEngine { return samples } + /** The sessions the sleep habits may learn from: those that ended before [before]. */ + internal fun finishedSessions(sessions: List, before: Long): List = + sessions.filter { it.endTs < before } + /** * Habitual midsleep (local seconds) AND the trailing per-night sleep DURATIONS (hours, * chronological) from the stored sessions over the window — the longest block per LOCAL day, so @@ -2322,6 +2531,11 @@ object IntelligenceEngine { * sleep-need + regularity that thread into `analyzeDay` (Wave 0 · SL1/T1). The midsleep result is * byte-identical to before; the nightly-hours output is the extension. Mirrors Swift * `IntelligenceEngine.computeHabitualSleep`. + * + * Only sessions that ended before [finishedBefore] (the pass's local midnight) are learned from. Tonight's + * session is re-banked by every sync while it is still growing, and each time it moved the learned + * consistency and midsleep, so the day-cache signature changed and every pass re-scored the whole window. + * A night still being slept joins the history the day after (`computeHabitualSleep(finishedBefore:)`). */ private suspend fun computeHabitualSleep( repo: WhoopRepository, @@ -2329,17 +2543,18 @@ object IntelligenceEngine { computedId: String, windowStart: Long, windowEnd: Long, + finishedBefore: 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 // then steered the main-night pick (day assignment) to the stale block. The same collapse also // covers an imported night and its computed twin (the longest capture wins, exactly what the // per-day length rule chose anyway). Mirrors Swift. - val merged = SleepSessionDedup.dedupe(imported + computed).kept + val merged = finishedSessions(SleepSessionDedup.dedupe(imported + computed).kept, finishedBefore) // Longest block per LOCAL day (naps drop out), chosen by in-bed SPAN — reused for BOTH the // midsleep learner and the per-night durations (Wave 0 · SL1/T1), so the two can never read a // different history. For the DURATIONS we keep TST (span × efficiency), NOT the in-bed span: @@ -2393,6 +2608,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, @@ -2523,6 +2756,30 @@ object IntelligenceEngine { return rows.isNotEmpty() } + /** + * The scored row as persisted: the day's metrics plus this pass's Charge and BOTH thermal values. + * + * Extracted rather than inlined at the call site because [analyzeRecentOnCpu] sits within ~4 KB of + * the 64 KB JVM method ceiling once JaCoCo instruments it (guarded by IntelligenceEngineJacocoBudgetTest, + * which caught this addition going 25 bytes over). A named `copy` argument is cheap in source and not + * in bytecode; keeping row assembly out of that method is what buys the headroom back. + * + * The absolute (#1636) is written in the SAME call as the deviation derived from it, so the two can + * never describe different nights and no second derivation exists to drift. + */ + private fun scoredDailyRow( + daily: DailyMetric, + computedId: String, + recovery: Double?, + skinTempDevC: Double?, + skinTempC: Double?, + ): DailyMetric = daily.copy( + deviceId = computedId, + recovery = recovery, + skinTempDevC = skinTempDevC, + skinTempC = skinTempC, + ) + private fun recomputeSkinTempDev(nightly: Double?, base: BaselineState?): Double? { val v = nightly ?: return null val b = base?.takeIf { it.usable } ?: return null @@ -2584,13 +2841,21 @@ object IntelligenceEngine { if (candidatePriorities.size == 1 && candidatePriorities.first().first == importedDeviceId) { return importedDeviceId } - 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() - DayOwnerResolver.Candidate(deviceId = id, priority = priority, hasData = hasData) + // Probe in PRIORITY ORDER and stop at the first candidate that has data. + // + // [DayOwnerResolver.resolve] is `candidates.filter { hasData }.minByOrNull { priority }`, so the + // winner is the lowest-priority-number candidate with data, and every probe after that one cannot + // change the answer. Probing them anyway cost a query per candidate per day: on the 60-day steps + // window with two straps that is 120 per pass, and the active strap usually answers on the first. + // `DayOwnerResolverEquivalenceTest` pins this against the resolver itself rather than trusting + // the reasoning, because the two must not be allowed to drift. + // + // The probe is a scalar EXISTS now, not a fetched LIMIT 1 row: the question is one bit, and it + // used to be answered by materialising a cursor and an HrSample to test a list for emptiness. + for ((id, _) in candidatePriorities.sortedBy { it.second }) { + if (repo.hasHrInWindow(id, from, to)) return id } - return DayOwnerResolver.resolve(day, lockedOwner = null, candidates = candidates) ?: importedDeviceId + return importedDeviceId } /** @@ -2640,6 +2905,211 @@ object IntelligenceEngine { return if (dayStart < nowLocalMidnight) nextMidnight else minOf(nextMidnight, now) } + /** Names of the config-signature fields, in the exact order `dayCacheConfigSig` builds them. + * + * Kept beside the reader rather than at the construction site on purpose: that site sits inside + * `analyzeRecentOnCpu`, which is on a JaCoCo bytecode ratchet, so labelling it there would spend + * budget to say something only this diagnostic needs. The cost is that the two must stay in step, + * which [changedConfigField] refuses to guess about when they are not. */ + internal val DAY_CACHE_CONFIG_FIELDS: List = listOf( + "hrvBaseline", "rhrBaseline", "age", "sex", "stepTicksPerStep", "maxHROverride", + "tzOffset", "sleepNeedHours", "sleepConsistency", "habitualMidsleep", + "experimentalSleepV2", "motionAwareWake", "deepHrvWindow", "spo2CandidateDisplay", + "effortMethod", "dayCycleMode", + ) + + /** Which config field(s) changed between two signatures, for the `configDropped` tally. + * + * `configDropped` says a 21-day re-score happened because the pass-global config moved, and stops + * exactly there: a field log pointed at a rolling BASELINE as the likeliest mover, but naming it + * from the outside is a guess. This names the mover from the data. + * + * Never guesses: "first" on the first drop of a process, and "unknown" when the two + * signatures do not have the field count this list describes, because a mislabelled field would send + * a reader somewhere the data never pointed. */ + internal fun changedConfigField(previous: String, current: String): String { + // The field starts EMPTY, not null, so the very first drop of a process has nothing to diff + // against. That is "first", not "unknown": naming it unknown would report a shape mismatch that + // never happened and send a reader looking for a bug in the signature. + if (previous.isEmpty()) return "first" + val a = previous.split('|') + val b = current.split('|') + if (a.size != b.size || b.size != DAY_CACHE_CONFIG_FIELDS.size) return "unknown" + val moved = b.indices.filter { a[it] != b[it] }.map { DAY_CACHE_CONFIG_FIELDS[it] } + return if (moved.isEmpty()) "none" else moved.joinToString("+") + } + + /** Drop the whole day cache when the pass-global config changed, recording that it happened (#2073) + * and WHICH field moved. + * + * The recording is the point. A dropped cache leaves NO entry to compare, so the miss tally below + * would stay silent and the line would read `reused=0/21` with nothing saying why, which is the exact + * silence this diagnostic exists to remove. Out here for the bytecode ratchet, and it replaces the + * inline branch that used to sit in the scoring method rather than adding to it. */ + private fun dropDayCacheIfConfigChanged(configSig: String) { + if (configSig == dayScanCacheConfigSig) return + val moved = changedConfigField(dayScanCacheConfigSig, configSig) + dayScanCache = HashMap() + dayScanCacheConfigSig = configSig + dayCacheMissBy["configDropped($moved)"] = 1 + } + + /** The cached scan for [day] when it is still reusable, else null, tallying WHY it was not (#2073). + * + * Folded into the lookup rather than added beside it: `analyzeRecentOnCpu` sits on a JaCoCo bytecode + * ratchet, and doing the key compare here REPLACES the null-check and comparison that used to be + * inline instead of adding to them. */ + private fun reusableDayScan(day: String, freshKey: String): CachedDayScan? { + val cached = dayScanCache[day] ?: run { + // No entry at all: a first pass, a night new to the window, or a cache just dropped wholesale. + // Counted so a total miss always carries a cause. + dayCacheMissBy["absent"] = (dayCacheMissBy["absent"] ?: 0) + 1 + return null + } + if (cached.key == freshKey) return cached + val why = AnalyzeRecentDayCache.missReason(cached.key, freshKey) + dayCacheMissBy[why] = (dayCacheMissBy[why] ?: 0) + 1 + return null + } + + /** The miss tally as it rides the reuse line, or empty when nothing was cached to miss (#2073). Same + * budget reason as [noteDayCacheMiss]. */ + private fun missByField(): String = if (dayCacheMissBy.isEmpty()) "" else + // SORTED, not insertion order: which cause happens to be seen first depends on which day missed + // first, and a diagnostic that reorders itself between passes is harder to diff. Matches the twin. + " missBy=" + dayCacheMissBy.entries.sortedBy { it.key }.joinToString(",") { "${it.key}:${it.value}" } + + /** 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) + } + + /** Resolve the active source outside the bytecode-constrained scoring method. */ + private suspend fun activeWhoop5RrPolicy( + repo: com.noop.data.WhoopRepository, candidates: List>, fallback: String, + ): Boolean = repo.isWhoop5RrSource(candidates.firstOrNull { it.second == 0 }?.first ?: fallback) + + /** Keep both stream witnesses and the R-R alias policy in the nightly cache key (#29). + * The two database awaits live here to preserve the scoring method's instrumentation budget. */ + private suspend fun rrAwareDayCacheKey( + repo: com.noop.data.WhoopRepository, owner: String, from: Long, to: Long, + skinAnchor: Double?, unlabelledAliasOfWhoop5: Boolean, hrvWindowDetail: Boolean, + ): String { + val (count, maxTs) = repo.hrFingerprintWindow(owner, from, to) + val streams = repo.dayStreamFingerprint(owner, from, to) + return AnalyzeRecentDayCache.cacheKey(owner, count, maxTs, skinAnchor, + streams = streams + "|rrAlias5=$unlabelledAliasOfWhoop5", hrvWindowDetail = hrvWindowDetail) + } + + /** The pass-1 R-R sliding read window. Same reason as [hrReadWindow] for living out here. */ + private fun rrReadWindow(repo: com.noop.data.WhoopRepository, activeWhoop5: Boolean) = + SlidingStreamWindow({ it.ts }, StreamReadCap.RR) { o, f, t -> + repo.rrIntervalsForDevice(o, f, t, StreamReadCap.RR, + unlabelledAliasOfWhoop5 = activeWhoop5 && o == com.noop.data.WhoopRepository.WHOOP_SOURCE) + } + + /** Keep owner-policy lookup out of the bytecode-constrained main scoring method. */ + private suspend fun readRrWindow( + window: SlidingStreamWindow, repo: com.noop.data.WhoopRepository, + owner: String, from: Long, to: Long, unlabelledAliasOfWhoop5: Boolean, + ) = window.rows(owner, from, to, + allowReuse = !repo.isWhoop5RrSource(owner, unlabelledAliasOfWhoop5)) + + + /** + * 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 @@ -2712,10 +3182,69 @@ object IntelligenceEngine { "hrRows=$hrRows provenance=$provenance" } - /** #1331 diagnostic line: the night's computed respiratory rate (breaths/min) or "nil". Format kept - * simple so the planned Swift twin (iOS #1331 follow-up) can match it byte-for-byte. */ - internal fun respRateLogLine(day: String, respRateBpm: Double?): String = - "resp day=$day rpm=${respRateBpm?.let { String.format(Locale.US, "%.1f", it) } ?: "nil"}" + /** + * #1331 diagnostic line: the night's computed respiratory rate (breaths/min) or "nil". + * + * When the rate is nil the line now carries WHY, because "nil" on its own sent an investigation + * across two subsystems to find out. The RSA estimate needs per-beat-accurate R-R, so + * [HrvAnalyzer.beatValuesAreTrustworthy] refuses a stream whose intervals are not beat-to-beat + * measurements — and on a WHOOP 4.0 carrying the #1008/#1118 over-count that gate is what empties + * the card, on nearly every night, silently. Printing the fraction beside the boundary turns + * "Respiratory: No data" from a mystery into a reading. + * + * It distinguishes the two cases rather than asserting one: below the boundary the gate refused the + * R-R; at or above it the gate passed and the cause is one of the estimator's other exits (too few + * beats, too short a span, too coarse a grid), which this deliberately does not guess between. + * Omitting [beatAccurate] restores the original one-field line exactly, so a night that never + * reached the HRV block reads as it always did. + * + * Swift twin: `IntelligenceEngine.respRateLogLine`. + */ + internal fun respRateLogLine( + day: String, + respRateBpm: Double?, + beatAccurate: Double? = null, + rrIntegrity: String? = null, + ): String { + val base = "resp day=$day rpm=${respRateBpm?.let { String.format(Locale.US, "%.1f", it) } ?: "nil"}" + if (respRateBpm != null || beatAccurate == null) return base + // "NaN" explicitly rather than via %.2f: the JVM renders a non-finite as "NaN" and C-style + // %f renders it "nan", so leaving it to the formatter would make the twin lines differ on + // exactly the input the gate treats specially. One spelling, chosen here, on both platforms. + val acc = if (beatAccurate.isNaN()) "NaN" else String.format(Locale.US, "%.2f", beatAccurate) + val gate = String.format(Locale.US, "%.2f", HrvAnalyzer.BEAT_ACCURACY_MIN_FRACTION) + val integrity = rrIntegrity ?: "unknown" + return if (beatAccurate < HrvAnalyzer.BEAT_ACCURACY_MIN_FRACTION) { + "$base beatAccurate=$acc<$gate rrIntegrity=$integrity — RSA gate refused the R-R" + } else { + "$base beatAccurate=$acc>=$gate rrIntegrity=$integrity — gate passed, cause is elsewhere" + } + } + + /** + * 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 @@ -2748,10 +3277,70 @@ 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]. + // + // Which is why grav=0 alone can no longer name the outcome. A 5/MG overnight capture showed this + // line reading `no-motion` while the HR-only spine on the SAME pass kept four sessions, the + // longest 311 minutes, and handed them over as `provided=3`. The reader was told nothing could + // stage a night while the log above said something had. So the no-gravity case splits by whether + // anything was actually provided: + // + // no-motion no gravity, and nothing was provided either + // no-motion-provided-unused no gravity, sessions WERE provided, and the night is still empty - + // they went in and no night came out, which is a question about what + // dropped them rather than about the strap + // + // Note "provided", not "HR-only". With no gravity `providedSleep` is the HR-only spine's output + // in the no-hypnogram branch, but it is STORED sessions in the stored-hypnogram one, and from + // here the two are indistinguishable. Naming the source would repeat the very over-claim this + // split exists to remove. The `[sleep] hr-only gate` trace is what says which branch ran. + // + // With motion present the inputs were there and staging still produced nothing, which remains the + // case most worth investigating. + val reason = when { + gravCount > 0 -> "staged-none" + providedCount > 0 -> "no-motion-provided-unused" + else -> "no-motion" + } + // #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..25482d0026 --- /dev/null +++ b/android/app/src/main/java/com/noop/analytics/IntelligencePersistence.kt @@ -0,0 +1,195 @@ +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 LegacyScoreSnapshot( + val avgHrv: Double, + val recovery: Double?, + val respRateBpm: Double?, + val avgSdnn: Double?, + val recoverySource: String?, + ) + + data class LegacyScoreClock( + val nowLocalMidnight: Long, + val nowSeconds: Long, + val tzOffsetSeconds: Long, + ) + + 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, + legacyClock: LegacyScoreClock, + computed: MutableList, + ): ComputedWindow { + val mutableDailies = dailies.toMutableList() + val snapshots = preserveLegacyScores( + repo, computedId, from, to, mutableDailies, ownerByDay, + legacyClock, + ) + applyLegacyScoresToOutput(computed, snapshots) + val provenance = scoreProvenance(computedId, mutableDailies, metricRows, ownerByDay, snapshots) + return ComputedWindow( + deviceId = computedId, + from = from, + to = to, + dailies = mutableDailies, + 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, + legacySnapshots: Map = emptyMap(), + ): List { + val byCell = LinkedHashMap, ScoreInputProvenanceRow>() + for (daily in dailies) { + val source = ownerByDay[daily.day] ?: continue + if (daily.recovery != null) { + val legacy = legacySnapshots[daily.day] + val recoverySource = legacy?.recoverySource ?: if (legacy == null) source else null + if (recoverySource != null) { + byCell[daily.day to "recovery"] = ScoreInputProvenanceRow( + computedId, daily.day, "recovery", recoverySource, + ) + } + } + 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() + } + + private suspend fun preserveLegacyScores( + repo: WhoopRepository, + computedId: String, + from: String, + to: String, + dailies: MutableList, + ownerByDay: Map, + clock: LegacyScoreClock, + ): Map { + val existingByDay = repo.dailyMetrics(computedId, from, to).associateBy { it.day } + val snapshots = LinkedHashMap() + for (index in dailies.indices) { + val fresh = dailies[index] + val existing = existingByDay[fresh.day] ?: continue + val oldHrv = existing.avgHrv ?: continue + if (fresh.avgHrv != null || (fresh.totalSleepMin ?: 0.0) <= 0.0) continue + val owner = ownerByDay[fresh.day] ?: continue + val dayStart = try { + java.time.LocalDate.parse(fresh.day).toEpochDay() * 86_400L - clock.tzOffsetSeconds + } catch (_: java.time.format.DateTimeParseException) { continue } + val readFrom = dayStart - StreamReadCap.LOOKBACK_SECONDS + val readTo = IntelligenceEngine.sleepReadWindowEnd( + dayStart, clock.nowLocalMidnight, clock.nowSeconds, + ) + if (!repo.legacyWhoop5RrWithheld(owner, readFrom, readTo)) continue + val snapshot = LegacyScoreSnapshot( + avgHrv = oldHrv, + recovery = existing.recovery, + respRateBpm = existing.respRateBpm, + avgSdnn = existing.avgSdnn, + recoverySource = repo.scoreInputSource(computedId, fresh.day, "recovery"), + ) + dailies[index] = fresh.copy( + avgHrv = snapshot.avgHrv, + recovery = snapshot.recovery, + respRateBpm = snapshot.respRateBpm, + avgSdnn = snapshot.avgSdnn, + ) + snapshots[fresh.day] = snapshot + } + return snapshots + } + + private fun applyLegacyScoresToOutput( + computed: MutableList, + snapshots: Map, + ) { + for (index in computed.indices) { + val snapshot = snapshots[computed[index].day] ?: continue + computed[index] = computed[index].copy(hrv = snapshot.avgHrv, recovery = snapshot.recovery) + } + } + + 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/LiftMetrics.kt b/android/app/src/main/java/com/noop/analytics/LiftMetrics.kt new file mode 100644 index 0000000000..7865efdd84 --- /dev/null +++ b/android/app/src/main/java/com/noop/analytics/LiftMetrics.kt @@ -0,0 +1,308 @@ +package com.noop.analytics + +import com.noop.data.LiftMuscle + +/** + * Training metrics for the Lift Log. + * + * Kotlin twin of `StrandAnalytics.LiftMetrics` (#2099), so both platforms report the same numbers + * from the same sets. Pinned by `LiftMetricsParityOracleTest`, whose expected values are the Swift + * build's own stdout. + * + * Every figure here is arithmetic the user can redo by hand from their own logged sets. That is the + * design constraint: NOOP shows a few honest numbers rather than one invented score. + * + * PURE. No store, no clock, no UI — rows in, numbers out. + * + * WHAT IS DELIBERATELY ABSENT, and must stay absent: + * + * - Anything feeding `workout.strain` or daily Effort. NOOP's strain is HR-measured (Karvonen + * %HRR into Edwards TRIMP). There is no validated public path from typed sets/reps/weight to a + * cardiovascular-strain equivalent, and inventing one is the case CLAUDE.md warns about after + * the withdrawn PPG-to-HR estimate (#194). + * - Per-exercise muscle weightings ("bench press = 0.7 triceps"). No published table exists to + * take them from. The direct/indirect split is the resolution the evidence supports. + * - Acute:chronic workload ratios or any injury-risk warning. The construct's validity is disputed, + * and a health warning from a non-medical app is either ignored or believed, both bad. + */ +object LiftMetrics { + + /** + * The fields of a logged set this file needs. Mirrors Swift `LiftSetRow`'s countable surface, + * INCLUDING its invariant: the secondary list is normalised at construction, so it never + * repeats a muscle and never contains the primary. + * + * Swift gets that from `LiftSetRow.init`, which runs the secondaries it is handed through + * `decodeList(encodeList(_:excluding:))` before storing them. The normalisation has to live + * here rather than in [muscleCounts] because it is the row that carries the guarantee on the + * other side, and any future reader of `secondaryMuscles` should see the same list Swift would. + * + * Measured rather than assumed: for a set with `[triceps, triceps, chest]` secondary and + * `chest` primary, Swift credits triceps 0.5 across one indirect set. Before this step Kotlin + * credited it 1.0 across two, double-counting the muscle. + * + * Not a `data class`: the generated `copy()` would rebuild the object through the constructor + * it bypasses, reintroducing an unnormalised list. + */ + class Row( + val ord: Int, + val exercise: String, + val isWarmup: Boolean, + val weightKg: Double? = null, + val reps: Int? = null, + val rpe: Double? = null, + val primaryMuscle: LiftMuscle? = null, + secondaryMuscles: List = emptyList(), + ) { + val secondaryMuscles: List = + LiftMuscle.decodeList(LiftMuscle.encodeList(secondaryMuscles, primaryMuscle)) + } + + // MARK: - Performed or not + + /** + * Whether a set was performed, from its rep count. A set with ZERO reps was not: that is how a + * finished session keeps the sets the user discards, so they can still be filled in later, and it + * is what a user types for a planned set they skipped. Every figure leaves such a set out, since + * counting it would add a set nobody did. A set with no rep count (null) still counts: it was + * done, the number just was not typed. + * The Swift twin is `LiftMetrics.isPerformed`. + */ + fun isPerformed(reps: Int?): Boolean = reps != 0 + + // MARK: - Volume load (tonnage) + + /** + * Sum of (weight x reps) over WORKING sets, in kilograms. Null when nothing countable was logged. + * + * Warm-ups are excluded because the literature counts working sets, and a warm-up double counted + * as volume would flatter every session. A set missing either weight or reps contributes nothing + * rather than a guess. + * The Swift twin is `LiftMetrics.volumeLoadKg`. + */ + fun volumeLoadKg(sets: List): Double? { + var total = 0.0 + for (s in sets) { + if (s.isWarmup) continue + val w = s.weightKg ?: continue + val r = s.reps ?: continue + if (w <= 0 || r <= 0) continue + total += w * r.toDouble() + } + return if (total > 0) total else null + } + + // MARK: - Session load (Foster sRPE-TL) + + /** + * Session RPE x duration in minutes. Foster's session-RPE training load. + * + * It earns its place next to volume because it is validated across BOTH resistance and endurance + * training, making it the only figure here that puts a leg day and a run on one scale. + * + * Null when the session was not rated: a skipped rating must never read as an effortless 0. + * The Swift twin is `LiftMetrics.sessionLoad`. + */ + fun sessionLoad(sessionRpe: Double?, durationSec: Int): Double? { + val rpe = sessionRpe ?: return null + if (rpe <= 0 || durationSec <= 0) return null + return rpe * (durationSec.toDouble() / 60.0) + } + + // MARK: - Estimated one-rep max (Epley) + + /** + * The rep ceiling above which a 1RM estimate stops being worth showing. Every 1RM formula is a + * straight-line fit to a curved relationship and the error grows with reps; twelve is the + * conventional bound where the formulas are least unreliable. + */ + const val oneRepMaxRepCeiling: Int = 12 + + /** + * Epley: `w * (1 + reps/30)`. Null for a set that cannot support an estimate. + * + * A single rep returns the weight itself. The formula's own +3.3% at one rep is an artefact of + * the fit, not a claim that a single you just completed was really 3% heavier. + * The Swift twin is `LiftMetrics.estimatedOneRepMaxKg`. + */ + fun estimatedOneRepMaxKg(weightKg: Double?, reps: Int?): Double? { + val w = weightKg ?: return null + val r = reps ?: return null + if (w <= 0 || r <= 0 || r > oneRepMaxRepCeiling) return null + if (r <= 1) return w + return w * (1.0 + r.toDouble() / 30.0) + } + + // MARK: - Per-exercise summary + + data class ExerciseSummary( + val exercise: String, + /** Working sets only: the tally the dose-response literature is built on. */ + val workingSets: Int, + val warmupSets: Int, + val volumeKg: Double?, + /** + * The session's best set for this exercise, ranked by ESTIMATED 1RM rather than raw weight: + * 90 kg x 10 is a better set than 100 kg x 5, and ranking by weight alone would hide that. + * Falls back to the heaviest set when no set supports an estimate. + */ + val bestWeightKg: Double?, + val bestReps: Int?, + val bestEstimatedOneRepMaxKg: Double?, + ) + + /** + * One summary per exercise, in the order the exercises were first performed, because that is how + * a session reads back. A set that was not performed ([isPerformed]) appears nowhere, and neither + * does an exercise with only such sets. + * + * Assumes `ord` is unique across the rows handed in, which holds because it is assigned 0-based + * within one session and the only caller passes one session's sets. If that ever stops being + * true the two platforms can disagree on ties: `sortedBy` is stable, Swift's `sorted(by:)` is + * not, so Swift would also stop agreeing with itself. Fix it by making `ord` unique rather than + * by matching an unspecified order here. + * The Swift twin is `LiftMetrics.perExercise`. + */ + fun perExercise(sets: List): List { + val order = ArrayList() + val grouped = LinkedHashMap>() + for (s in sets.sortedBy { it.ord }) { + if (!isPerformed(s.reps)) continue + if (grouped[s.exercise] == null) { + order.add(s.exercise) + grouped[s.exercise] = ArrayList() + } + grouped.getValue(s.exercise).add(s) + } + return order.map { name -> + val rows = grouped[name] ?: emptyList() + val working = rows.filter { !it.isWarmup } + + // Rank by estimated 1RM where possible, otherwise raw weight, so an exercise logged only + // at high reps still reports a best set rather than nothing. `maxWithOrNull` keeps the + // FIRST of equals, matching Swift's `max(by:)`. + val best = working.maxWithOrNull { a, b -> + val ea = estimatedOneRepMaxKg(a.weightKg, a.reps) + val eb = estimatedOneRepMaxKg(b.weightKg, b.reps) + when { + ea != null && eb != null -> ea.compareTo(eb) + ea != null -> 1 // a ranks above b + eb != null -> -1 // b ranks above a + else -> (a.weightKg ?: 0.0).compareTo(b.weightKg ?: 0.0) + } + } + ExerciseSummary( + exercise = name, + workingSets = working.size, + warmupSets = rows.size - working.size, + volumeKg = volumeLoadKg(rows), + bestWeightKg = best?.weightKg, + bestReps = best?.reps, + bestEstimatedOneRepMaxKg = estimatedOneRepMaxKg(best?.weightKg, best?.reps), + ) + } + } + + // MARK: - RPE profile + + data class RpeProfile( + val mean: Double?, + val ratedSets: Int, + val unratedSets: Int, + val setsAtOrAboveThreshold: Int, + val threshold: Double, + ) + + /** The default "this set was close to failure" line. Informational only. */ + const val hardSetRpeThreshold: Double = 8.0 + + /** + * How close to failure the working sets were. + * + * Reported SEPARATELY from the set counts and never as a filter on them. The tempting move is to + * count only sets at RPE >= 7 toward a muscle's weekly total, since proximity to failure is what + * makes a set count biologically. Doing that would compare a smaller number against reference + * doses derived from UNFILTERED working-set counts, quietly changing the scale. `unratedSets` is + * surfaced so a mean computed from three of twelve sets is visibly thin. + * The Swift twin is `LiftMetrics.rpeProfile`. + */ + fun rpeProfile(sets: List, threshold: Double = hardSetRpeThreshold): RpeProfile { + val working = sets.filter { !it.isWarmup && isPerformed(it.reps) } + val rated = working.mapNotNull { it.rpe } + val mean = if (rated.isEmpty()) null else rated.sum() / rated.size.toDouble() + return RpeProfile( + mean = mean, + ratedSets = rated.size, + unratedSets = working.size - rated.size, + setsAtOrAboveThreshold = rated.count { it >= threshold }, + threshold = threshold, + ) + } + + // MARK: - Sets per muscle + + data class MuscleCounts( + /** direct x 1.0 + indirect x 0.5, the published fractional method. */ + val fractional: Map, + val direct: Map, + val indirect: Map, + ) + + /** + * Fractional set counts per muscle over the given sets. + * + * The 0.5 for an indirect set is NOT a house convention: the 2025 Sports Medicine dose-response + * meta-regression compared counting a secondary mover's set as 1.0 ("total"), 0.5 ("fractional") + * and 0.0 ("direct"), found the evidence strongest for fractional, and used it in its primary + * models. The reference doses in [ReferenceDose] were derived under that same operationalisation, + * so the credit and the doses have to move together or the comparison stops meaning anything. + * + * Warm-ups and sets never performed ([isPerformed]) are excluded; nothing else is. An unclassified + * exercise (null primary) contributes to volume and session load but claims no muscle it was never + * assigned. + * The Swift twin is `LiftMetrics.muscleCounts`. + */ + fun muscleCounts(sets: List): MuscleCounts { + val fractional = LinkedHashMap() + val direct = LinkedHashMap() + val indirect = LinkedHashMap() + for (s in sets) { + if (s.isWarmup || !isPerformed(s.reps)) continue + s.primaryMuscle?.let { p -> + direct[p] = (direct[p] ?: 0) + 1 + fractional[p] = (fractional[p] ?: 0.0) + LiftMuscle.directSetCredit + } + for (m in s.secondaryMuscles) { + if (m == s.primaryMuscle) continue + indirect[m] = (indirect[m] ?: 0) + 1 + fractional[m] = (fractional[m] ?: 0.0) + LiftMuscle.indirectSetCredit + } + } + return MuscleCounts(fractional = fractional, direct = direct, indirect = indirect) + } + + // MARK: - The reference band + + /** + * Weekly fractional sets per muscle, from the same dose-response meta-regression the 0.5 credit + * comes from. + * + * PRESENTED AS A BAND WITH ITS SOURCE NAMED, NEVER AS A PERSONAL PRESCRIPTION. NOOP is not a + * medical device and does not tell anyone what their body needs; it says what the research + * associates with growth and leaves the conclusion to the reader. + */ + object ReferenceDose { + /** Below roughly this, hypertrophy is not reliably detectable. */ + const val hypertrophyMinimumSetsPerWeek: Double = 4.0 + + /** Strength keeps improving from a single weekly set. */ + const val strengthMinimumSetsPerWeek: Double = 1.0 + + /** + * Beyond roughly this, added volume stops reliably beating the smallest detectable effect + * FOR STRENGTH. Hypertrophy has no identified ceiling: gains continue with strongly + * diminishing returns, and the uncertainty widens as volume rises. + */ + const val strengthPlateauSetsPerWeek: Double = 4.0 + } +} 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..b017af3bb1 --- /dev/null +++ b/android/app/src/main/java/com/noop/analytics/PhysiologicalStepCycleEngine.kt @@ -0,0 +1,386 @@ +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() + + /** A cycle's Effort and calories, with the key of the inputs they were computed from. */ + private data class CachedLoad(val key: String, val strain: Double?, val calories: Double?) + + /** Same lifetime and serialization as [cache]. */ + private val loadCache = HashMap() + + /** + * The inputs a cycle's Effort and calories depend on. Every pass re-read each cycle's full day of heart + * rate and re-scored it for all 21 cycles, although only the open one gains samples between syncs; the + * index-only HR witness ([WhoopRepository.hrUnionFingerprint]) lets a closed cycle be scored once. Twin of + * the Swift `DayCycleIntelligenceIntegration` load cache. + */ + internal fun loadCacheKey( + onset: Long, endExclusive: Long, hrWitness: String, restingHr: Double, maxHr: Double?, + effortMethod: StrainScorer.Method, profile: UserProfile, + ): String = "$onset-$endExclusive|$hrWitness|rhr=$restingHr|max=${maxHr ?: "nil"}|$effortMethod|${profile.cacheKey}" + + 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 }) + loadCache.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 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()) } + val loadKey = loadCacheKey( + window.onset, window.endExclusive, + repo.hrUnionFingerprint(fallbackOwner, window.onset, window.endExclusive - 1L), + restingHr, effectiveMaxHr, effortMethod, profile, + ) + val load = loadCache[window.sleepId]?.takeIf { it.key == loadKey } ?: run { + val cycleHr = repo.hrSamplesUnion( + fallbackOwner, window.onset, window.endExclusive - 1L, 200_000, + ) + CachedLoad( + key = loadKey, + strain = StrainScorer.strain(cycleHr, effectiveMaxHr, restingHr, effortMethod, profile.sex), + calories = if (cycleHr.isNotEmpty()) { + Calories.estimateDayCalories(cycleHr, profile, effectiveMaxHr, restingHr) + } else null, + ).also { loadCache[window.sleepId] = it } + } + load.strain?.let { strainByWakeDay[wakeDay] = it } + load.calories?.let { caloriesByWakeDay[wakeDay] = it } + // 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/PrimarySessionRestingHR.kt b/android/app/src/main/java/com/noop/analytics/PrimarySessionRestingHR.kt index 95c5c0f5e6..20e8d98e56 100644 --- a/android/app/src/main/java/com/noop/analytics/PrimarySessionRestingHR.kt +++ b/android/app/src/main/java/com/noop/analytics/PrimarySessionRestingHR.kt @@ -11,11 +11,17 @@ package com.noop.analytics * split, no fitted offset) found the primary-session sample mean tracked both far better: rounded MAE vs the * official target 6.0->2.0 (dev) / 7.5->0.8 (holdout). * - * ## Deliberately PURE and UNWIRED - * This computes the metric and is unit-tested, but nothing consumes it yet. The shipped headline AND the - * recovery / strain / workout-detection / energy inputs all read the floor `restingHRDaily`, so switching - * them is a re-baselining of core scores that the issue itself says needs a larger multi-participant, - * pre-declared holdout first. Out of scope here; this lands the transparent, testable definition. + * ## WIRED as of #2358 — this sets the shipped daily resting HR + * It was landed pure and unwired, on the reasoning that switching the consumers is a re-baselining of + * core scores and that the issue asks for a larger multi-participant holdout first. #2358 made the + * switch anyway, as a maintainer call: `AnalyticsEngine.restingHRDaily` now prefers a device-provided + * primary-session value, then THIS mean, and falls back to the old `restingHR.min()` floor only when + * coverage is sparse. So the headline resting HR and everything reading it — recovery, strain, workout + * detection, energy — come from here on any day with a covered primary session. + * + * What that means for the evidence below: the MAE figures are from ONE participant over five nights + * against a pre-declared split, which is the holdout the issue says is not yet large enough. #2284 + * separately replaces what a session's `restingHR` IS, which moves the fallback under this. * * ## Definition * - **Primary session**: the LONGEST by duration; ties resolve to the FIRST. A nap never replaces the night. diff --git a/android/app/src/main/java/com/noop/analytics/RangeReport.kt b/android/app/src/main/java/com/noop/analytics/RangeReport.kt index c2ee6fb354..e2c628e53e 100644 --- a/android/app/src/main/java/com/noop/analytics/RangeReport.kt +++ b/android/app/src/main/java/com/noop/analytics/RangeReport.kt @@ -131,6 +131,35 @@ enum class ReportMetric { } } +/** + * The user's display preferences, resolved by the app layer and passed in (#1637). + * + * NOOP stores everything in SI and on its own native scales; two settings change only how a number is + * *shown* — the temperature unit (°C / °F) and the Effort axis (native 0–100 / WHOOP 0–21). Every + * in-app screen honours both, but the exported trends report rendered raw stored values with a + * hardcoded unit, so a reader comparing the export against the app saw two different numbers for the + * same night with nothing on the page to explain it. + * + * This engine stays pure and never reads prefs; the app resolves the settings and hands them over. + * [STORED] is the identity — °C and the native 0–100 axis, i.e. exactly what is on disk — and is the + * default everywhere, so an untouched call site is byte-identical to before. + * + * Twin of Swift `ReportDisplayUnits`. + * + * @property fahrenheit render temperatures in °F. A DEVIATION scales ×9/5 with no +32 offset. + * @property effortFactor multiplier onto the stored 0–100 Effort value: 1.0 for NOOP's native axis, + * 21/100 for WHOOP's 0–21 Day Strain axis. Matches `UnitFormatter.EFFORT_SCALE_FACTOR`. + */ +data class ReportDisplayUnits( + val fahrenheit: Boolean, + val effortFactor: Double, +) { + companion object { + /** The identity: values exactly as stored (°C, native 0–100 Effort). */ + val STORED = ReportDisplayUnits(fahrenheit = false, effortFactor = 1.0) + } +} + /** Which way a metric moved across the range (by OLS slope vs a small threshold). */ enum class ReportTrend { RISING, FALLING, FLAT } @@ -200,6 +229,11 @@ object RangeReportEngine { * missing days are simply absent; this is robust to sparse data. * @param start inclusive range start, "yyyy-MM-dd". * @param end inclusive range end, "yyyy-MM-dd". + * @param units the user's display preferences, applied to the rendered [RangeReport.headlines] + * only (#1637). Defaults to [ReportDisplayUnits.STORED], which reproduces the pre-#1637 + * strings exactly. Every [MetricRangeStat] below stays in STORED units regardless — callers + * convert at render time via [displayValue], so the statistics remain comparable across + * settings and nothing on disk depends on a toggle. * * If [end] sorts before [start] the range is treated as empty (no metrics, 0 days). */ @@ -207,6 +241,7 @@ object RangeReportEngine { metrics: Map>, start: String, end: String, + units: ReportDisplayUnits = ReportDisplayUnits.STORED, ): RangeReport { // A valid window requires start <= end (ISO string compare == chronological). if (start > end) { @@ -264,11 +299,46 @@ object RangeReportEngine { ) } - val headlines = makeHeadlines(stats) + val headlines = makeHeadlines(stats, units) return RangeReport(start = start, end = end, totalDays = totalDays, metrics = stats, headlines = headlines) } + // Display conversion + + /** + * A stored value rendered on the user's chosen axis (#1637). + * + * The ONE place the report converts, shared by the headline sentences below and the app layer's + * metric cards, so the two surfaces of the same document can never disagree about a number. + * Metrics with no user-configurable presentation pass through untouched. + * + * Skin temp is a signed DEVIATION from the personal baseline, so °F scales by 9/5 with NO +32 + * offset — adding it would be wrong for a difference. Matches [SkinTempDisplay.numberString]. + */ + fun displayValue(v: Double, metric: ReportMetric, units: ReportDisplayUnits): Double = + when (metric) { + ReportMetric.SKIN_TEMP_DEV -> if (units.fahrenheit) v * 9.0 / 5.0 else v + ReportMetric.STRAIN -> v * units.effortFactor + else -> v + } + + /** + * The unit suffix for a metric under the user's settings — [ReportMetric.unit] for everything + * that has no display preference (#1637). + * + * Two metrics differ from the static suffix. Skin temp carries the `Δ` the whole app shows, + * because the row is a deviation and a bare "°C" reads as an absolute wrist temperature. Effort + * is unitless on its native 0–100 axis (unchanged), but names its denominator once rescaled — a + * bare "12.4" where the reader expects 0–100 is the one thing a rescale must not produce. + */ + fun displayUnit(metric: ReportMetric, units: ReportDisplayUnits): String = + when (metric) { + ReportMetric.SKIN_TEMP_DEV -> if (units.fahrenheit) "Δ°F" else "Δ°C" + ReportMetric.STRAIN -> if (units.effortFactor == 1.0) "" else "/ 21" + else -> metric.unit + } + // Headlines /** @@ -276,8 +346,11 @@ object RangeReportEngine { * the absolute first→second-half change scaled by the metric's trend threshold, so * movers on different units are comparable. Folds in good/bad framing. */ - internal fun makeHeadlines(stats: List): List = - stats.sortedByDescending { salience(it) }.map { headline(it) } + internal fun makeHeadlines( + stats: List, + units: ReportDisplayUnits = ReportDisplayUnits.STORED, + ): List = + stats.sortedByDescending { salience(it) }.map { headline(it, units) } /** |half delta| normalised by the metric's trend threshold (a units-agnostic move). */ internal fun salience(s: MetricRangeStat): Double { @@ -285,8 +358,17 @@ object RangeReportEngine { return if (t > 0) abs(s.halfDelta) / t else abs(s.halfDelta) } - /** Render one metric's headline. Trend word + good/bad framing + the two half means. */ - internal fun headline(s: MetricRangeStat): String { + /** + * Render one metric's headline. Trend word + good/bad framing + the two half means. + * + * The two means are rendered through [displayValue] / [displayUnit], so the sentence agrees with + * the metric cards beside it and with the app (#1637). The TREND WORD and the good/bad framing + * come from `s.trend`, computed on stored values, so a display toggle can never flip a verdict. + */ + internal fun headline( + s: MetricRangeStat, + units: ReportDisplayUnits = ReportDisplayUnits.STORED, + ): String { val word = when (s.trend) { ReportTrend.RISING -> "trending up" ReportTrend.FALLING -> "trending down" @@ -300,9 +382,12 @@ object RangeReportEngine { val good = up == s.metric.higherIsBetter if (good) " - a good sign" else " - worth a look" } - val unit = if (s.metric.unit.isEmpty()) "" else " ${s.metric.unit}" - return "${s.metric.label} is $word (avg ${round1(s.firstHalfMean)}$unit → " + - "${round1(s.secondHalfMean)}$unit)$frame." + val rawUnit = displayUnit(s.metric, units) + val unit = if (rawUnit.isEmpty()) "" else " $rawUnit" + val first = displayValue(s.firstHalfMean, s.metric, units) + val second = displayValue(s.secondHalfMean, s.metric, units) + return "${s.metric.label} is $word (avg ${round1(first)}$unit → " + + "${round1(second)}$unit)$frame." } // Trend diff --git a/android/app/src/main/java/com/noop/analytics/RecoveryDrivers.kt b/android/app/src/main/java/com/noop/analytics/RecoveryDrivers.kt index ee923dc318..b91876dc48 100644 --- a/android/app/src/main/java/com/noop/analytics/RecoveryDrivers.kt +++ b/android/app/src/main/java/com/noop/analytics/RecoveryDrivers.kt @@ -91,7 +91,8 @@ object RecoveryDrivers { * @param resp tonight's respiration (rpm); null drops the resp row. * @param hrvBaseline HRV baseline (required; an unusable one yields an empty list, matching the * recovery cold-start gate). - * @param rhrBaseline resting-HR baseline; null drops the RHR row. + * @param rhrBaseline resting-HR baseline; null drops the RHR row, and an UNUSABLE one (#1988) is + * treated as null, so a synthetic cold-start midpoint never scores a row. * @param respBaseline respiration baseline; null drops the resp row. * @param sleepPerf rest-quality proxy in 0..1 (Rest composite / 100, or efficiency); null drops the * Sleep row. @@ -110,9 +111,15 @@ object RecoveryDrivers { ): List { // No score => no real contributions to attribute (cold-start). recovery(...) enforces the usable // gate; mirror it so a nil headline never yields fabricated driver rows. + // #1988: the ROW is built from this baseline directly (its z, its mean, its verdict), not only + // through recovery(...), so gating the scorer alone would emit an RHR row scored against the + // synthetic midpoint while the headline excluded it. Normalised once here so every use below, + // score and row alike, sees the same thing. Named rather than shadowing the parameter, which + // would warn. + val rhrB = rhrBaseline?.takeIf { it.usable } val full = RecoveryScorer.recovery( hrv = hrv, rhr = rhr, resp = resp, - hrvBaseline = hrvBaseline, rhrBaseline = rhrBaseline, + hrvBaseline = hrvBaseline, rhrBaseline = rhrB, respBaseline = respBaseline, sleepPerf = sleepPerf, skinTempDev = skinTempDev, ) ?: return emptyList() @@ -133,7 +140,7 @@ object RecoveryDrivers { // the full, unguarded HRV penalty. The verdict merely NAMES the detected pattern so the UI can // surface it while real firings accumulate. See the header in RecoveryScorer.kt. val hrvZFull = RecoveryScorer.zScore(hrv, hrvBaseline.baseline, hrvBaseline.spread) - val rhrZFull: Double? = rhrBaseline?.let { RecoveryScorer.zScore(it.baseline, rhr, it.spread) } + val rhrZFull: Double? = rhrB?.let { RecoveryScorer.zScore(it.baseline, rhr, it.spread) } val hrvSaturationDetected = RecoveryScorer.parasympatheticSaturation(hrvZ = hrvZFull, rhrZ = rhrZFull).active @@ -149,7 +156,7 @@ object RecoveryDrivers { deltaPoints = points( RecoveryScorer.recovery( hrv = hrvBaseline.baseline, rhr = rhr, resp = resp, - hrvBaseline = hrvBaseline, rhrBaseline = rhrBaseline, + hrvBaseline = hrvBaseline, rhrBaseline = rhrB, respBaseline = respBaseline, sleepPerf = sleepPerf, skinTempDev = skinTempDev, ), ), @@ -164,21 +171,21 @@ object RecoveryDrivers { ), ) // Resting HR (lower vs baseline supports recovery). Neutral = resting HR at the baseline mean. - if (rhrBaseline != null) { + if (rhrB != null) { drivers.add( ChargeDriver( label = ChargeDriverLabel.RESTING_HEART_RATE, deltaPoints = points( RecoveryScorer.recovery( - hrv = hrv, rhr = rhrBaseline.baseline, resp = resp, - hrvBaseline = hrvBaseline, rhrBaseline = rhrBaseline, + hrv = hrv, rhr = rhrB.baseline, resp = resp, + hrvBaseline = hrvBaseline, rhrBaseline = rhrB, respBaseline = respBaseline, sleepPerf = sleepPerf, skinTempDev = skinTempDev, ), ), value = rhr, - baseline = rhrBaseline.baseline, + baseline = rhrB.baseline, unit = ChargeDriverUnit.BEATS_PER_MINUTE, - verdict = rhrVerdict(value = rhr, baseline = rhrBaseline.baseline), + verdict = rhrVerdict(value = rhr, baseline = rhrB.baseline), ), ) } @@ -190,7 +197,7 @@ object RecoveryDrivers { deltaPoints = points( RecoveryScorer.recovery( hrv = hrv, rhr = rhr, resp = resp, - hrvBaseline = hrvBaseline, rhrBaseline = rhrBaseline, + hrvBaseline = hrvBaseline, rhrBaseline = rhrB, respBaseline = respBaseline, sleepPerf = RecoveryScorer.sleepPerfCenter, skinTempDev = skinTempDev, ), @@ -210,7 +217,7 @@ object RecoveryDrivers { deltaPoints = points( RecoveryScorer.recovery( hrv = hrv, rhr = rhr, resp = respBaseline.baseline, - hrvBaseline = hrvBaseline, rhrBaseline = rhrBaseline, + hrvBaseline = hrvBaseline, rhrBaseline = rhrB, respBaseline = respBaseline, sleepPerf = sleepPerf, skinTempDev = skinTempDev, ), ), @@ -230,7 +237,7 @@ object RecoveryDrivers { deltaPoints = points( RecoveryScorer.recovery( hrv = hrv, rhr = rhr, resp = resp, - hrvBaseline = hrvBaseline, rhrBaseline = rhrBaseline, + hrvBaseline = hrvBaseline, rhrBaseline = rhrB, respBaseline = respBaseline, sleepPerf = sleepPerf, skinTempDev = 0.0, ), ), 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..299037668e 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 // ───────────────────────────────────────────────────────────────────────── @@ -429,8 +292,9 @@ object RecoveryScorer { * @param rhr tonight's resting HR (bpm). * @param resp tonight's respiration (raw or calibrated — z is scale-invariant); * null drops the term. - * @param hrvBaseline HRV baseline (required for a score). - * @param rhrBaseline resting-HR baseline; null drops the RHR term. + * @param hrvBaseline HRV baseline (required for a score — null returns null). + * @param rhrBaseline resting-HR baseline; null drops the RHR term. On the BaselineState overload an + * UNUSABLE baseline (#1988) is treated as null, so a synthetic cold-start midpoint never scores. * @param respBaseline respiration baseline; null drops the resp term. * @param sleepPerf sleep-performance proxy (Rest composite 0..1, or efficiency * 0..1 for legacy callers); null drops the term. @@ -440,8 +304,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. @@ -471,6 +335,11 @@ object RecoveryScorer { // Cold-start gate: HRV is the dominant driver; if its baseline isn't // usable, refuse to score (more honest than a fabricated value). if (!hrvBaselineUsable) return null + // Required-driver gate: the HRV baseline is REQUIRED for a score. It is nullable here only + // for callers that may not have one yet, and hrvBaselineUsable defaults to true, so without + // this an absent baseline let any other optional term (sleepPerf alone, say) produce a + // Charge score carrying no HRV term at all. + val hrvB = hrvBaseline ?: return null val terms = ArrayList>() // (z, weight) @@ -481,9 +350,7 @@ object RecoveryScorer { // signature is still detected and reported out-of-band (Charge trace + RecoveryDrivers verdict) // so real firings can be counted first. See the header above for why, and swap in // parasympatheticSaturation(hrvZ, rhrZ).easedHrvZ here to enable it. - hrvBaseline?.let { b -> - terms.add(zScore(hrv, b.mean, b.spread) to wHRV) - } + terms.add(zScore(hrv, hrvB.mean, hrvB.spread) to wHRV) // RHR term: lower is better → (μ − x) / σ. rhrBaseline?.let { b -> terms.add(zScore(b.mean, rhr, b.spread) to wRHR) @@ -557,7 +424,13 @@ object RecoveryScorer { rhr = rhr, resp = resp, hrvBaseline = DriverBaseline(hrvBaseline), - rhrBaseline = rhrBaseline?.let { DriverBaseline(it) }, + // #1988: an UNUSABLE resting-HR baseline is treated as absent. foldHistory returns the + // config's synthetic midpoint (about 75 bpm) for an empty or all-implausible history, which is + // nobody's resting HR, so scoring against it moved Charge on a baseline the user never had. + // Gated here, in the one place every BaselineState caller passes through, rather than at each + // call site: the headline and the driver breakdown then agree by construction. Mirrors how + // hrvBaselineUsable is already derived below. + rhrBaseline = rhrBaseline?.takeIf { it.usable }?.let { DriverBaseline(it) }, respBaseline = respBaseline?.let { DriverBaseline(it) }, sleepPerf = sleepPerf, skinTempDev = skinTempDev, diff --git a/android/app/src/main/java/com/noop/analytics/RecoveryScorerTrace.kt b/android/app/src/main/java/com/noop/analytics/RecoveryScorerTrace.kt index e1635f8be3..7925bf44a9 100644 --- a/android/app/src/main/java/com/noop/analytics/RecoveryScorerTrace.kt +++ b/android/app/src/main/java/com/noop/analytics/RecoveryScorerTrace.kt @@ -15,23 +15,35 @@ import kotlin.math.abs object RecoveryScorerTrace { /** - * Trace numbers use nearest rounding with half-ties away from zero on both platforms. + * Trace numbers use nearest rounding with half-ties away from zero on both platforms. Twin of the + * Swift RecoveryScorer.traceRound2, which is the contract this reproduces. * * Round the MAGNITUDE and reapply the sign, rather than branching on `scaled < 0.0`. Two traps sit * here, and Swift's `.rounded(.toNearestOrAwayFromZero)` avoids both for free: * - * * Math.round returns a Long, which has no negative zero, so negating it before the division - * collapses -0.0 to +0.0 for any value that rounds to zero; + * * a Long has no negative zero, so negating a rounded Long before the division collapses -0.0 to + * +0.0 for any value that rounds to zero; * * `-0.0 < 0.0` is FALSE, so a sign test cannot even route an exact -0.0 to a negating branch — * and -0.0 is reachable here, since a skin-temp deviation of exactly 0.0 gives z = -|dev| = -0.0. * * These values are interpolated straight into the trace, so either trap printed `z=0.0` on Android * against `z=-0.0` on Apple. copySign carries the IEEE sign bit itself and handles both (#1437 * follow-up). + * + * The rounding itself stays in the DOUBLE domain, because Math.round returns a Long and therefore + * SATURATES: |x * 100| >= 2^63 came back as Long.MAX_VALUE, so 1e20 rendered as 9.223372036854776e16 + * where Swift kept 1e20 (#47). rint is half-to-EVEN, so an exact tie (the fraction is exactly 0.5, + * only possible below 2^52, where floor is exact) is stepped up by hand to reach half-away-from-zero; + * `floor(m + 0.5)` is not usable for that, since m + 0.5 is itself rounded and would push + * 0.49999999999999994 up to 1. Nothing is clamped: any finite input round-trips, and a non-finite one + * (or a finite one whose x * 100 overflows) passes through as the matching infinity or NaN. */ - private fun r2(x: Double): Double { + internal fun r2(x: Double): Double { val scaled = x * 100.0 - return Math.copySign(Math.round(abs(scaled)) / 100.0, scaled) + val magnitude = abs(scaled) + val floored = Math.floor(magnitude) + val rounded = if (magnitude - floored == 0.5) floored + 1.0 else Math.rint(magnitude) + return Math.copySign(rounded / 100.0, scaled) } /** @@ -58,10 +70,16 @@ object RecoveryScorerTrace { val lines = ArrayList() val nilTerms = ArrayList() + // #1988: the trace reads this baseline DIRECTLY for its own `charge baseline rhr` line, its + // rhrZ and the saturation guard, not only through recovery(). recovery() now drops an + // unusable one, so without the same gate here the trace would list an rhr term the score + // did not use, which is precisely the divergence the line below promises cannot happen. + val rhrB = rhrBaseline?.takeIf { it.usable } + // The score the dashboard reads, verbatim, so the trace cannot diverge from it. val score = RecoveryScorer.recovery( hrv = hrv, rhr = rhr, resp = resp, - hrvBaseline = hrvBaseline, rhrBaseline = rhrBaseline, + hrvBaseline = hrvBaseline, rhrBaseline = rhrB, respBaseline = respBaseline, sleepPerf = sleepPerf, skinTempDev = skinTempDev, ) @@ -80,7 +98,7 @@ object RecoveryScorerTrace { "charge baseline hrv mean=${r2(hrvBaseline.baseline)} spread=${r2(hrvBaseline.spread)} " + "nValid=${hrvBaseline.nValid} status=${hrvBaseline.status.raw}", ) - rhrBaseline?.let { b -> + rhrB?.let { b -> lines.add( "charge baseline rhr mean=${r2(b.baseline)} spread=${r2(b.spread)} " + "nValid=${b.nValid} status=${b.status.raw}", @@ -99,7 +117,7 @@ object RecoveryScorerTrace { // Resting-HR z, computed up front so the saturation guard can read the HRV<->RHR coupling before // the HRV term is built. null when there is no RHR baseline. Numerically identical to the z // recovery() builds for the RHR term (same expression, same inputs). - val rhrZForGuard: Double? = rhrBaseline?.let { RecoveryScorer.zScore(it.baseline, rhr, it.spread) } + val rhrZForGuard: Double? = rhrB?.let { RecoveryScorer.zScore(it.baseline, rhr, it.spread) } // L9: every WEIGHT / SCALE / centre constant goes through r2() too (not just the z-scores), so a // future non-round weight (e.g. 0.333) renders identically on Swift and Kotlin and the parity 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/SleepSessionDedup.kt b/android/app/src/main/java/com/noop/analytics/SleepSessionDedup.kt index 7b0a4d63dc..f8422f5222 100644 --- a/android/app/src/main/java/com/noop/analytics/SleepSessionDedup.kt +++ b/android/app/src/main/java/com/noop/analytics/SleepSessionDedup.kt @@ -133,6 +133,20 @@ object SleepSessionDedup { return Result(kept.sortedBy { it.startTs }, dropped.sortedBy { it.startTs }) } + /** + * The bank-recency witness the post-upsert heal may hand [dedupe] for ONE device id's rows. + * + * [keptStarts] are the startTs of the sessions the analyze pass just banked under [computedId]. They + * witness recency ONLY there: on a day scored from a device-provided hypnogram (an Oura ring) the pass's + * sessions are that device's own stored rows with startTs copied verbatim, so the same keys name the row + * the pass READ in the device's own table — the stalest row of the night by the time the heal runs, not + * the freshest. Handing them to that sweep ranked the read row above every fuller re-serve banked while + * the pass was in flight, and the heal deleted the full night. Every id but [computedId] therefore gets + * no witness and falls back to longest-wins, the read-side default. Twin of Swift's `healWitness`. + */ + fun healWitness(healId: String, computedId: String, keptStarts: Set): Set = + if (healId == computedId) keptStarts else emptySet() + // ── #1284 residual 3: generation-side 0x49-onset keying (at-persist, no schema migration) ───── /** @@ -154,6 +168,32 @@ object SleepSessionDedup { return ((onsetUnixSeconds + g / 2) / g) * g } + /** + * Guards the onset rekey against `OuraHypnogramBurst.codesWithTimes`'s own safety net: that + * assembler silently falls back to the UNCLIPPED lay when clipping to the ring's `0x49` onset + * would empty the burst entirely (a mis-paired window's onset landing after every real code) — + * "so a mis-paired window can never empty the night." The caller has no signal that fallback + * fired, and blindly re-keying startTs to that onset anyway wrote a NEGATIVE-DURATION session + * (item 22, 2026-09-12: startTs 16 min after its own endTs on a 2-minute nap, because the matched + * 0x49 window's onset was ~16 min later than the only two codes the ring actually wrote). + * + * A clip that genuinely bound leaves `mapped.startTs >= onset` by construction — the surviving + * codes are exactly those with `ts >= onset`, so the first one can only sit at or after it. A clip + * the fallback ignored leaves `mapped.startTs < onset` just as reliably, since EVERY written code + * was earlier than onset (that is precisely why clipping to it would have emptied the burst). Also + * rejects the rarer case where 30 s grid-rounding pushes a nearly-adjacent onset to or past endTs, + * which would otherwise mint a zero/negative-duration session on its own. + * + * Returns the keyed start to bank, or null to keep the mapped `startTs` unchanged (no rekey). + * Takes plain bounds rather than a session type so it works against either platform's + * pre-persist mapping shape (`com.noop.oura.OuraSleepSession` here, `CachedSleepSession` in Swift). + */ + fun safeKeyedStart(onset: Long, mappedStartTs: Long, mappedEndTs: Long): Long? { + if (onset > mappedStartTs) return null + val keyed = keyedStart(onset) + return if (keyed < mappedEndTs) keyed else null + } + /** The bank decision, mirroring the Swift twin. */ data class BankPlan(val bank: Boolean, val supersededStarts: List) 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..775545720c 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,291 @@ 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( + day: String, + 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( + day = day, + 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( + day = day, + 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 +931,144 @@ 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, + ) + + /** + * True when this night's sleep runs will ALL be dropped by `minSleepMin` yet add up to a plausible + * night. Pure, so the rule can be tested without building gravity. + * + * The predicate is deliberately "today's answer is zero". Every run being under the floor means the + * survival pass keeps none of them, so a bridge enabled on this basis can only turn nothing into + * something; if any single run already clears the floor this is false, the sparse rule decides as + * before, and a night that currently scores cannot change. + * + * Two runs at minimum, because one fragment is not a fragmented night. The SUM must clear the floor + * so a handful of brief stirs does not become a night. Neither condition weakens what the bridge + * itself checks — gap length, intervening active length and HR staying in the sleep band all still + * apply, so this enables an attempt rather than granting its outcome. + */ + internal fun isFragmentedToNothing(sleepRunSpansS: List, minSleepS: Long): Boolean = + sleepRunSpansS.size >= 2 && + sleepRunSpansS.none { it >= minSleepS } && + sleepRunSpansS.sum() >= minSleepS + + /** + * 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 ──────────────────────────────────────────────────────── @@ -1146,20 +1596,74 @@ object SleepStager { val flags = classifyStill(grav, deltas) var runs = buildRuns(grav, flags, sparse = sparse, hr = hrS, baseline = baseline) runs = mergePeriods(runs) - // Re-stitch sleep runs fragmented by pure gravity dropouts (sparse only) before minSleepMin. + val minSleepS = (minSleepMin * 60).toLong() + // #1937: a night whose sleep runs are ALL shorter than minSleepMin yields NO session at all, + // however much sleep they add up to. A real capture: five runs, 298 minutes of detected sleep, + // every one dropped for being about a minute short, and the night vanished. + // + // The bridge that exists to re-stitch fragments was switched off, because it is gated on the + // night being SPARSE (#308, so a dense night kept its original path byte-for-byte) and the night + // was dense. On the reporting device gravity coverage was 99.9%: the rescue was declined + // precisely because the data was good. + // + // So the bridge also runs when the night is fragmented to nothing. The gate is deliberately + // "today's answer is zero": if any single run already clears the floor this is false and the + // sparse rule decides exactly as before, so a night that currently scores cannot change. It can + // only turn nothing into something. + // + // Requiring the SUM to clear the floor keeps a handful of brief stirs from becoming a night, and + // the bridge's own rules still apply underneath — a gap longer than sparseBridgeGapMin, an + // intervening active run that is too long, or HR above the sleep band all still refuse. This + // enables the attempt; it does not weaken what the attempt checks. + // + // Deliberately NOT passed to buildRuns above, which takes its own `sparse` for the HR-vouched + // gap rule. That decides how runs are FORMED, so widening it would change the input to + // everything downstream including nights that currently score. This only re-stitches runs that + // are already built, and only when every one of them was about to be discarded. + // Evaluated unconditionally rather than short-circuited behind `sparse`, so the trace can + // report what it actually was on a sparse night too. It is a filter/map/sum over a handful of + // runs. + val fragmentedToNothing = isFragmentedToNothing( + runs.filter { it.stage == "sleep" }.map { it.end - it.start }, minSleepS) + val bridgeEnabled = sparse || fragmentedToNothing + // Re-stitch sleep runs fragmented by gravity dropouts, before minSleepMin. val runsBeforeBridge = if (traceSink == null) 0 else runs.count { it.stage == "sleep" } - runs = bridgeSparseSleep(runs, sparse = sparse, hr = hrS, baseline = baseline) - // 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) { + val bridged = bridgeSparseSleepTraced(runs, sparse = bridgeEnabled, hr = hrS, baseline = baseline) + runs = bridged.first + // Sleep & Rest test mode (E10): record the bridge result, so a night rescued from fragmentation + // is visible. Emitted whenever the bridge was ENABLED (sparse, or #1937's fragmented-to-nothing + // night) and only when tracing. + if (traceSink != null && bridgeEnabled) { val runsAfterBridge = runs.count { it.stage == "sleep" } + // BOTH gates are reported, always, as their own k=v keys. The line used to hardcode + // `sparse=true`, which since #1937 could contradict the `sparse=false` on the summary line + // beside it. Emitting both (rather than one "why" naming the winner) keeps the key set + // identical on every night, which is what a reader diffing two nights, and any k=v parser, + // needs. 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")) + "sparseBridge", "sparse=$sparse fragmentedToNothing=$fragmentedToNothing " + + "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() - val sessions = ArrayList() // Continuous-sleep chain tracking so a real overnight sleep that runs PAST the daytime-band // start (a late wake, or a brief morning stir then back to sleep that leaves the tail as its @@ -2451,12 +2955,21 @@ object SleepStager { } /** - * Read-only REM-funnel triage for ONE in-bed window [start, end] (#688). Re-runs the SAME - * Stage-0→3 staging seam [stageSession] uses (epoch grid → Cole–Kripke → features → classify → - * smooth → re-impose), but instead of emitting a hypnogram it COUNTS where REM was lost. Changes - * NOTHING: no label, no score, no session. Returns null only when the window has too little gravity - * to grid (mirroring [stageSession]'s degenerate fallback, which carries no REM to explain). The - * caller logs `.summary`; tests assert the counts. Pure + deterministic. Mirrors Swift. (#688) + * Read-only REM-funnel triage for ONE in-bed window [start, end] (#688). Re-runs THIS object's + * Stage-0→3 seam (epoch grid → Cole–Kripke → features → classify → smooth → re-impose), but + * instead of emitting a hypnogram it COUNTS where REM was lost. Changes NOTHING: no label, no + * score, no session. Returns null only when the window has too little gravity to grid (mirroring + * [stageSession]'s degenerate fallback, which carries no REM to explain). The caller logs + * `.summary`; tests assert the counts. Pure + deterministic. Mirrors Swift. (#688) + * + * WHICH HYPNOGRAM THIS EXPLAINS. V1's, always — this is [SleepStager]'s own seam. It used to say it + * explained "the SAME hypnogram" as the screen, and that has been false since V2 became the + * default: the shipped hypnogram is staged by `SleepStagerV2` whenever `experimentalSleepV2` says + * so, which is by default. On a 5/MG the two can be far apart, because V1's primary REM gate needs + * the raw respiratory channel the hardware never emits while V2 recovers respiration regularity + * from R-R: one field pair reported ~46 min REM here against hours on the screen for the same night + * (#2365). The caller's line names both stagers for that reason (#2366); do not restore the claim + * that they are one. */ fun remFunnelDiagnostic( start: Long, end: Long, grav: List, @@ -2644,20 +3157,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() } @@ -2669,17 +3281,58 @@ object SleepStager { /** * Mean RMSSD over 5-min tumbling windows across the session (ms), or null. - * Uses the same range-filter + ≥2-valid-interval rule as hrv.rmssd(). + * A window counts only with [HrvAnalyzer.MIN_BEATS] clean intervals (see [sessionHrvWindows]). */ 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. + if (sessionHrvOverCounted(start, end, rr)) return null + return vals.sum() / vals.size.toDouble() + } + + /** + * Whether the #1118 coverage gate refuses a session's HRV: its own R-R, windowed [start, end] exactly as + * [sessionAvgHRV] windows it, banks more beat-time than the wall clock allows. Pure. The ONE definition + * of "refused": [sessionAvgHRV] gates on it, and AnalyticsEngine asks it whether a main night's missing + * HRV was refused rather than never measured, so the two cannot disagree about which nights were + * refused. Byte-parity twin of Swift `SleepStager.sessionHrvOverCounted`. + */ + internal fun sessionHrvOverCounted(start: Long, end: Long, rr: List): Boolean { + 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) + return !HrvAnalyzer.successiveDiffIsTrustworthy(verdict) } /** * 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 +3346,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 @@ -2703,12 +3360,17 @@ object SleepStager { // spurious successive difference, which is the exact spike the rejection above is meant to // remove. See HrvAnalyzer.rmssdGapAware. val cleaned = HrvAnalyzer.cleanRRGapAware(bucket) - val rmssd = if (cleaned.nn.size >= 2) HrvAnalyzer.rmssdGapAware(cleaned.nn, cleaned.contiguous) else null + // The same MIN_BEATS floor the spot reading and the SDNN index apply. The night is a plain mean + // over windows, so without it a window left with a handful of clean beats (a dropout, a + // movement-shredded stretch, a short final window) weighed as much as a full five minutes. + val rmssd = if (cleaned.nn.size >= HrvAnalyzer.MIN_BEATS) { + HrvAnalyzer.rmssdGapAware(cleaned.nn, cleaned.contiguous) + } else null val center = t + windowS / 2 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..ec2299c7e7 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,68 @@ 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. + * + * `day` leads the line (#2397). A re-score emits one of these per night, twenty-one of them inside + * two seconds in one field log, and without the date they are an unattributable block: asked which + * band last night used, a reader can only count lines and hope the emission order matches the day + * order. The other funnel lines in this file already carry `day=`. + */ + fun hrOnlyGateLine(day: String, attempted: Boolean, reason: String, gravRows: Int, storedNights: Int): String = + "[sleep] hr-only gate day=$day 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( + day: String, + 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 day=$day 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..1c653b1e2a --- /dev/null +++ b/android/app/src/main/java/com/noop/analytics/SlidingStreamWindow.kt @@ -0,0 +1,174 @@ +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 + + /** How many reads were forced by the OWNER changing since the last one (#2073). Diagnostic only. + * + * `served` near zero already tells a reader the windows are declining, and this file's own plan doc + * names the three ways that happens: truncation, an owner flip, or a gap. `truncated` is already on + * the line, so counting the flips separates the remaining two instead of leaving them as one + * unanswerable number. A field log showed a stream reading 4,084 rows and serving none with + * `truncated=0`, and there was no way to tell which of the other two it was. */ + var ownerFlips = 0L + private set + + /** Reads that bypassed reuse entirely because the caller passed `allowReuse = false` (#2073). + * + * This is the answer [ownerFlips] alone would get WRONG. A WHOOP 5 R-R read passes false, because + * transport selection is range-dependent and a slice could mix transports, so that window can never + * serve from its buffer: `served=0` there is BY DESIGN, not a decline. Without this count the line + * said `served=0 truncated=0 ownerFlips=0` and invited a reader to conclude "a gap", which is the + * opposite of true. The bypass also clears the buffer, so a genuine owner change on such a stream is + * never even seen as a flip. */ + var reuseOffReads = 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, allowReuse: Boolean = true): List { + // Range-dependent transport selection cannot safely reuse a slice or independently read head. + if (!allowReuse) { + reuseOffReads++ + failedRead() + val full = read(owner, from, to) ?: return emptyList() + rowsRead += full.size + if (full.size >= limit) truncatedReads++ + return full + } + if (this.owner != null && this.owner != owner) ownerFlips++ + 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/Spo2ReTrace.kt b/android/app/src/main/java/com/noop/analytics/Spo2ReTrace.kt index b8fa40805e..7f3a82d903 100644 --- a/android/app/src/main/java/com/noop/analytics/Spo2ReTrace.kt +++ b/android/app/src/main/java/com/noop/analytics/Spo2ReTrace.kt @@ -22,9 +22,39 @@ package com.noop.analytics */ object Spo2ReTrace { - /** Max records dumped per offload session. A handful is enough for an offline correlation pass and - * keeps the strap log bounded; the Backfiller counter spans chunks and resets per session. */ - const val MAX_SAMPLES = 8 + /** Max records dumped per offload session, across all layout versions. A handful is enough for an + * offline correlation pass and keeps the strap log bounded; the Backfiller counter spans chunks and + * resets per session. */ + const val MAX_SAMPLES = 12 + + /** + * Max records dumped per DISTINCT layout version, so the budget cannot be spent entirely on the + * layout we already understand. + * + * The dump used to take the first [MAX_SAMPLES] decodable records in arrival order. On a strap that + * emits a dominant layout plus a rarer one, that spends the whole budget inside the first chunk on + * the dominant layout - and the rare one is precisely the one still unmapped. The log would then + * announce "historical records use layout vN" while carrying no bytes of vN at all: proof a thing + * exists, with no material to work on it. Stratifying guarantees every layout the strap emits gets + * samples, including one at 1% of traffic, and stops re-dumping a layout already at parity. + */ + const val MAX_PER_VERSION = 3 + + /** + * Max records this dump may EXAMINE per session, separate from how many it may dump. + * + * The dump budget alone stopped bounding the work the moment [MAX_PER_VERSION] arrived. The loop's + * outer guard counts DUMPS, so on a strap emitting one layout the per-version cap is reached at 3, + * the dump count sticks below [MAX_SAMPLES] forever, and every later chunk re-decodes every frame to + * rediscover a version already at cap. That is a full second decode of the whole offload, on top of + * the one the extractor already did, for a dump that can never fire again. + * + * Bounding examinations instead keeps the stratified search working - several chunks' worth of + * frames is plenty to turn up a layout at a few percent of traffic - while making the cost fixed + * rather than proportional to offload length. The pre-stratification code examined barely more + * frames than it dumped; this is the ceiling that restores that property. + */ + const val MAX_EXAMINED = 512 /** * One record's RE line: the mapped SpO2 channels + timestamp + layout version, then the FULL frame 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/StepsDetailDensity.kt b/android/app/src/main/java/com/noop/analytics/StepsDetailDensity.kt new file mode 100644 index 0000000000..44d1076a0d --- /dev/null +++ b/android/app/src/main/java/com/noop/analytics/StepsDetailDensity.kt @@ -0,0 +1,143 @@ +package com.noop.analytics + +import java.time.LocalDate +import kotlin.math.floor + +/** The eight step-detail windows. Swift twin: `StepsDetailRange`. */ +enum class StepsDetailRange(val label: String) { + WEEK("W"), + TWO_WEEKS("2W"), + THREE_WEEKS("3W"), + MONTH("M"), + THREE_MONTHS("3M"), + SIX_MONTHS("6M"), + YEAR("1Y"), + ALL("ALL"); + + /** Inclusive calendar-day count, or null for all history. + * Swift twin: `StepsDetailRange.dayCount`. */ + fun dayCount(): Int? = when (this) { + WEEK -> 7 + TWO_WEEKS -> 14 + THREE_WEEKS -> 21 + MONTH -> 30 + THREE_MONTHS -> 90 + SIX_MONTHS -> 180 + YEAR -> 365 + ALL -> null + } + + /** Swift twin: `StepsDetailRange.granularity`. */ + internal fun granularity(): StepsDetailGranularity = when (this) { + WEEK, TWO_WEEKS, THREE_WEEKS, MONTH -> StepsDetailGranularity.DAILY + THREE_MONTHS -> StepsDetailGranularity.WEEKLY + SIX_MONTHS, YEAR, ALL -> StepsDetailGranularity.MONTHLY + } +} + +/** One already-resolved daily step observation. Swift twin: `StepsDetailReading`. */ +data class StepsDetailReading(val day: String, val value: Double) + +/** One chart bucket, retaining the raw sum and observed-day denominator. + * Swift twin: `StepsDetailBucket`. */ +data class StepsDetailBucket( + val key: String, + val displayDay: String, + val sum: Double, + val observedDayCount: Int, + val mean: Int, +) + +internal enum class StepsDetailGranularity { DAILY, WEEKLY, MONTHLY } + +/** Pure calendar projection for step-detail charts. Swift and Kotlin deliberately expose the same + * inputs and outputs, and both are pinned by the one Android-hosted JSON oracle. + * Swift twin: `StepsDetailDensity`. */ +object StepsDetailDensity { + /** Window, deduplicate and aggregate readings. The explicit anchor is optional so callers that + * already know the latest valid measurement can reuse it without changing the contract. + * Swift twin: `StepsDetailDensity.project`. */ + fun project( + readings: List, + range: StepsDetailRange, + anchorDay: String? = null, + ): List { + val byDay = linkedMapOf() + for (reading in readings) { + val day = strictDay(reading.day) ?: continue + if (!reading.value.isFinite() || reading.value < 0.0) continue + byDay[day] = reading.value + } + + val anchor = if (anchorDay != null) strictDay(anchorDay) ?: return emptyList() + else byDay.keys.maxOrNull() ?: return emptyList() + val lower = range.dayCount()?.let { anchor.minusDays((it - 1).toLong()) } + + data class Accumulator( + val key: String, + val displayDay: String, + var sum: Double, + var observedDayCount: Int, + ) + val buckets = linkedMapOf() + for (day in byDay.keys.sorted()) { + if (day > anchor || (lower != null && day < lower)) continue + val value = byDay.getValue(day) + val identity = bucketIdentity(day, range.granularity()) + val existing = buckets[identity.first] + if (existing == null) { + buckets[identity.first] = Accumulator(identity.first, identity.second, value, 1) + } else { + existing.sum += value + existing.observedDayCount += 1 + } + } + + return buckets.values.sortedBy { it.displayDay }.map { + StepsDetailBucket( + key = it.key, + displayDay = it.displayDay, + sum = it.sum, + observedDayCount = it.observedDayCount, + mean = positiveHalfUp(it.sum / it.observedDayCount), + ) + } + } + + /** Parse exactly yyyy-MM-dd and reject impossible proleptic-Gregorian dates. + * Swift twin: `StepsDetailDensity.strictDay`. */ + private fun strictDay(text: String): LocalDate? { + if (text.length != 10 || text[4] != '-' || text[7] != '-' || + text.withIndex().any { (index, char) -> index !in setOf(4, 7) && char !in '0'..'9' } + ) return null + val year = text.substring(0, 4).toInt() + val month = text.substring(5, 7).toInt() + val day = text.substring(8, 10).toInt() + if (month !in 1..12 || day !in 1..daysInMonth(year, month)) return null + return LocalDate.of(year, month, day) + } + + /** Proleptic-Gregorian month length. Swift twin: `StepsDetailDensity.daysInMonth`. */ + private fun daysInMonth(year: Int, month: Int): Int = when (month) { + 2 -> if (isLeapYear(year)) 29 else 28 + 4, 6, 9, 11 -> 30 + else -> 31 + } + + /** Gregorian leap-year rule. Swift twin: `StepsDetailDensity.isLeapYear`. */ + private fun isLeapYear(year: Int): Boolean = + year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) + + /** Canonical bucket key and period-start display anchor. + * Swift twin: `StepsDetailDensity.bucketIdentity`. */ + private fun bucketIdentity(day: LocalDate, granularity: StepsDetailGranularity): Pair = + when (granularity) { + StepsDetailGranularity.DAILY -> day.toString() to day.toString() + StepsDetailGranularity.WEEKLY -> day.minusDays((day.dayOfWeek.value - 1).toLong()).toString().let { it to it } + StepsDetailGranularity.MONTHLY -> day.withDayOfMonth(1).toString().let { it.substring(0, 7) to it } + } + + /** Positive half-up rounding, independent of platform banker-rounding defaults. + * Swift twin: `StepsDetailDensity.positiveHalfUp`. */ + private fun positiveHalfUp(value: Double): Int = floor(value + 0.5).toInt() +} diff --git a/android/app/src/main/java/com/noop/analytics/StepsMotionCache.kt b/android/app/src/main/java/com/noop/analytics/StepsMotionCache.kt new file mode 100644 index 0000000000..81cbeaa5a0 --- /dev/null +++ b/android/app/src/main/java/com/noop/analytics/StepsMotionCache.kt @@ -0,0 +1,119 @@ +package com.noop.analytics + +/** + * Per-day reuse identity for the steps-calibration motion fold in [IntelligenceEngine.analyzeRecent]. + * Kotlin twin of the Swift `StepsMotionCache`. + * + * The drain this closes: the calibration fits one coefficient from sixty days of strap motion, and it + * re-folded all sixty on EVERY pass. Each day meant a `gravitySamplesForDevice` read capped at + * `STREAM_LIMIT` rows, so a worn library paid millions of materialised rows per pass to re-derive numbers + * that had not moved. The phase does not scale with the days being re-scored — it always reads the same + * sixty — so a two-day pass could cost more than a twenty-one-day one, which is what made it hard to see in + * a cost line keyed on the day loop. + * + * [StepsEstimateEngine.dayMotionIntensity] is a pure fold over one day's gravity stream. Nothing else + * reaches it: no profile field, no baseline, no toggle, no other stream. So unlike [AnalyzeRecentDayCache] + * there is no pass-config signature to invalidate against — the value changes exactly when that one day's + * gravity changes, and the key below is the whole story. + * + * Unlike the day-scan cache this one PERSISTS across launches, and the paragraph above is why it can. A + * persisted day scan would have to carry `dayScanCacheConfigSig`, which folds in baselines1 and the habitual + * sleep terms - and `sleepConsistency` (1-CV over 28 nights) and `habitualMidsleepSec` (a circular mean) + * shift with ANY night moving, so it would invalidate wholesale on exactly the passes it would need to + * survive. Nothing pass-global reaches this fold, so the payload stays valid while gravity stands still and + * the sixty-day fold is paid once per install instead of once per launch: measured 32.9 s -> 3.3 s on a + * worn 60-day library, which the cold pass otherwise repaid after every relaunch. + * + * It stays a DERIVED cache and nothing else reads it, so the whole failure surface is one re-fold: a payload + * that is missing, unreadable, or written by an older fold is discarded rather than repaired. + * + * It still does NOT cross the `.noopbak` boundary, and must not start: the key is deliberately absent from + * [com.noop.data.BackupSettingsCodec.WHITELIST] and its Apple twin. A restore carries the settings and the + * record store, and this describes neither - it describes gravity rows AS THEY WERE ON ONE DEVICE. Shipping + * it to another device would be the one way to serve a fold whose key no longer witnesses anything, which is + * the failure every other guard here exists to prevent. A restored device simply re-folds once. + */ +object StepsMotionCache { + /** + * The per-day reuse key. Reuse a cached motion volume iff this string is unchanged. + * + * - [owner]: the resolved owning device the fold was measured against. A day whose owner flips between + * straps must re-fold, and the fingerprint below is device-scoped, so this makes that explicit rather + * than relying on two devices never producing an identical count and newest timestamp for one window. + * - [gravityCount] / [gravityMaxTs]: the day window's gravity witness. Any gravity row added or removed + * moves one of the two. Deliberately NOT the wider `dayStreamFingerprint`: that also counts HR, R-R, + * respiration, SpO2, steps, skin temp and sleep state, so an ordinary HR offload would invalidate a + * motion volume that cannot have changed by it. + */ + fun cacheKey(owner: String, gravityCount: Int, gravityMaxTs: Long): String = + "$owner|$gravityCount|$gravityMaxTs" + + /** + * The pass's one-line reuse readout, beside the phase cost line. + * + * [reused] and [folded] sum to the days scanned, so the ratio is readable without a second line. A pass + * reporting `folded=60` every time means the key is moving when it should not, which is the failure this + * cache can have and the reason the number is reported at all rather than assumed. + */ + fun logLine(reused: Int, folded: Int, size: Int): String = + "analyzeRecent stepsMotion reused=$reused/${reused + folded} size=$size" + + /** + * The version of the FOLD the persisted values were produced by. Bump on any change to + * [StepsEstimateEngine.dayMotionIntensity] that moves what it returns for the same samples. + * + * In memory this could not exist: a process cannot outlive the binary that filled it, so the fold that + * produced a cached value is always the fold that would reproduce it. A persisted entry outlives its + * build, and [cacheKey] witnesses the INPUTS only - a day whose gravity has not moved keys identically + * across an app update, so without this the old volume would be served until that day's stream happened + * to change. A bump discards every entry and costs one full re-fold, once, which is the cheap side. + */ + const val FOLD_VERSION: Int = 1 + + /** + * Render the cache for storage. Days are emitted in sorted order so an unchanged cache renders to an + * identical payload and the write is a no-op rather than churn. + * + * One line per day, `day\tkey\tmotion`, under a header naming [FOLD_VERSION]. Tab-separated because the + * key itself contains `|`; the motion is written by raw bit pattern so it round-trips exactly and + * locale-free, the same reason the Swift key encodes its skin anchor that way. The bits are rendered + * UNSIGNED so the payload is byte-identical to the Swift twin's for the same cache, which is what makes + * the shared-vector parity test meaningful - the two sides never read each other's storage. + */ + fun serialize(entries: Map>): String { + val sb = StringBuilder(header()) + for (day in entries.keys.sorted()) { + val e = entries[day] ?: continue + sb.append('\n').append(day).append('\t').append(e.first).append('\t') + .append(e.second.toRawBits().toULong().toString()) + } + return sb.toString() + } + + /** + * Parse a stored payload. Returns empty on anything it cannot vouch for - a wrong/absent header (an + * older [FOLD_VERSION] included), a malformed line, or an implausible entry count. Every rejection costs + * one re-fold, so this discards rather than salvages: a half-trusted cache is the one failure mode that + * could feed a stale volume into the calibration fit. + */ + fun deserialize(raw: String): HashMap> { + val out = HashMap>() + val lines = raw.split('\n') + if (lines.firstOrNull() != header()) return out + if (lines.size - 1 > MAX_ENTRIES) return out + for (i in 1 until lines.size) { + val f = lines[i].split('\t') + if (f.size != 3 || f[0].isEmpty() || f[1].isEmpty()) continue + val bits = f[2].toULongOrNull() ?: continue + out[f[0]] = f[1] to Double.fromBits(bits.toLong()) + } + return out + } + + /** Payload header. Carries [FOLD_VERSION] so a fold change invalidates by failing the equality check. */ + private fun header(): String = "stepsMotion v$FOLD_VERSION" + + /** Upper bound on entries a payload may declare. The writer prunes to the calibration window every pass, + * so a payload far above it did not come from this cache and is not worth parsing. */ + private const val MAX_ENTRIES = 512 +} diff --git a/android/app/src/main/java/com/noop/analytics/StoreProbeTally.kt b/android/app/src/main/java/com/noop/analytics/StoreProbeTally.kt new file mode 100644 index 0000000000..6f9780a67d --- /dev/null +++ b/android/app/src/main/java/com/noop/analytics/StoreProbeTally.kt @@ -0,0 +1,112 @@ +package com.noop.analytics + +import java.util.concurrent.atomic.AtomicLong +import kotlin.math.roundToLong + +/** + * How much of an `analyzeRecent` pass goes into its per-day PROBE queries, as calls and milliseconds. + * Kotlin twin of the Swift `StoreProbeTally`. + * + * The blind spot this closes: with the steps-calibration fold now reused rather than recomputed, a warm + * pass still reported ~3.3 s in the steps phase with `stepsMotion reused=60/60` beside it, so the fold was + * provably not the cost and nothing said what was. The phase is bracketed as one number and sits outside + * the #1538 day-loop tally, so the remaining candidates — the sixty per-day probe queries, the one + * `appleDaily` read, and the in-memory calibration fit — were indistinguishable from each other. + * + * These are counted one level down rather than at the call site, so the counts attribute themselves with no + * bookkeeping. Every one of them has exactly ONE caller here, since [IntelligenceEngine.resolveDayOwner] is + * private to this object, so a Kotlin line describes exactly one pass. The Swift twin is one-sidedly + * looser: its resolver is also called by a manual Test Centre skin-temp backfill, which can land in a + * concurrent pass's line. The call count is what reveals that, which is one reason it prints. + * - `dayOwner`: [com.noop.data.DeviceRegistry.dayOwner], the resolver's LOCKED-override lookup, which runs + * on every call before any presence probe. Measured because the first cut of this instrumentation counted + * the probe and not the lookup in front of it, and so reported the cheap half of owner resolution while a + * warm pass still had seconds unaccounted for. + * - `ownerHr`: [com.noop.data.WhoopRepository.hasHrInWindow], the day-owner resolver's per-candidate + * presence probe. Runs in BOTH the scoring loop and the sixty-day steps loop, and is skipped entirely on + * a default single-strap install (#970), so a two-strap library is the only one that pays it. + * - `gravityFp`: [com.noop.data.WhoopRepository.gravityFingerprintWindow], the steps loop's per-day motion + * witness. Steps-only. + * + * Read with the `stepsMotion reused=N/M` line, this is decisive rather than suggestive: a warm pass that + * folded nothing and still spent its time here means the round trips ARE the cost and batching them into a + * grouped query is worth building; one that does not means the cost is the `appleDaily` read or the fit, + * and batching would have bought nothing. Measured, not guessed. + * + * Counted in the repository rather than at the call site for a mechanical reason: `analyzeRecentOnCpu` sits + * 210 bytes under the JaCoCo method-size ratchet [IntelligenceEngineJacocoBudgetTest] holds, and the steps + * loop is inside it. Timing there would cost more than the margin, and the ratchet's own note says to + * extract rather than raise it. Counting one level down costs the budgeted method nothing at all, and the + * reset and the emit both live in the unbudgeted `analyzeRecent` wrapper. + * + * Instrumentation only: nothing reads these counts but the log line. + */ +object StoreProbeTally { + private val dayOwnerCalls = AtomicLong() + private val dayOwnerNanos = AtomicLong() + private val ownerHrCalls = AtomicLong() + private val ownerHrNanos = AtomicLong() + private val gravityFpCalls = AtomicLong() + private val gravityFpNanos = AtomicLong() + + /** Record one day-owner LOCKED-override lookup. Runs on every resolver call, ahead of any presence + * probe, and is counted separately because the first cut of this instrumentation counted only the + * probe and so reported the cheap half of owner resolution. */ + fun recordDayOwner(nanos: Long) { + dayOwnerCalls.incrementAndGet() + dayOwnerNanos.addAndGet(nanos) + } + + /** Record one day-owner HR presence probe. Called from the repository, off the pass's hot method. */ + fun recordOwnerHr(nanos: Long) { + ownerHrCalls.incrementAndGet() + ownerHrNanos.addAndGet(nanos) + } + + /** Record one per-day gravity witness read. */ + fun recordGravityFp(nanos: Long) { + gravityFpCalls.incrementAndGet() + gravityFpNanos.addAndGet(nanos) + } + + /** Zero the counters at the start of a pass, so a line describes ONE pass and never accumulates across + * the back-to-back passes an offload storm is made of. */ + fun reset() { + dayOwnerCalls.set(0); dayOwnerNanos.set(0) + ownerHrCalls.set(0); ownerHrNanos.set(0) + gravityFpCalls.set(0); gravityFpNanos.set(0) + } + + /** This pass's line, in the shared format. */ + fun line(): String = logLine( + listOf( + Triple("dayOwner", dayOwnerCalls.get(), dayOwnerNanos.get() / 1_000_000_000.0), + Triple("ownerHr", ownerHrCalls.get(), ownerHrNanos.get() / 1_000_000_000.0), + Triple("gravityFp", gravityFpCalls.get(), gravityFpNanos.get() / 1_000_000_000.0), + ) + ) + + /** + * Render ordered `(name, calls, seconds)` probes as one line. + * + * [calls] is printed beside the time because the two answer different questions: the time says whether + * this is where the pass went, and the count says whether the loop issued the number of round trips it + * was believed to (sixty per steps pass, one per day). A count that is right with a time that is large + * is a slow query; a count that is wrong is a different bug entirely, and one line should not be able + * to hide either. Byte-identical to the Swift `StoreProbeTally.logLine`. + */ + fun logLine(probes: List>): String { + val parts = probes.map { "${it.first}=${it.second}/${millis(it.third)}ms" } + val total = probes.sumOf { it.third } + return "analyzeRecent storeProbes total=${millis(total)}ms " + + if (parts.isEmpty()) "(no probes)" else parts.joinToString(" ") + } + + /** Seconds to whole milliseconds, floored at zero. The clamp is the parity contract, not defensive + * habit: a negative or non-finite input is where Swift's round-half-away-from-zero and Kotlin's + * round-half-up disagree, at exactly -0.5 ms, and both sides render into one shared strap log. The + * callers here cannot produce one (they count monotonic nanoseconds), which makes this a guarantee + * about the renderer rather than a repair of its inputs. */ + private fun millis(seconds: Double): Long = + if (!seconds.isFinite() || seconds <= 0.0) 0L else (seconds * 1000).roundToLong() +} 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 16cb5a57cb..fa6b0502eb 100644 --- a/android/app/src/main/java/com/noop/analytics/StrainScorer.kt +++ b/android/app/src/main/java/com/noop/analytics/StrainScorer.kt @@ -82,6 +82,38 @@ object StrainScorer { */ fun banisterDailyCeiling(b: Double): Double = 24.0 * 60.0 * 1.0 * banisterScale * kotlin.math.exp(b) + /** + * The %HRR a waking, sedentary body sits at — the "cost of being alive", not training load. + * + * Banister pays at EVERY intensity by design, which is the point: it catches the intermittent work + * Edwards zeroes. The side effect is that sixteen waking hours of doing nothing accumulate real TRIMP, + * so a desk day cannot score zero however still you are — while the same day under Edwards, whose + * first zone starts at 50% HRR, scores exactly zero. A 24 h day held at 5% HRR scores 0 under Edwards + * and 45 under Banister on the shipped constants. That is not two recipes on one axis; that is two + * axes (#1624). + * + * THIS IS THE ONE TUNED CONSTANT here, and it is a judgement rather than a measurement: low enough not + * to erase genuine light activity, high enough that ordinary sitting nets to nothing. Resting HR is + * measured asleep, so a waking body sits above it even at complete rest — which is precisely the gap + * this closes. Treat it as calibratable, not as physiology. + */ + const val banisterSedentaryHRR: Double = 0.10 + + /** TRIMP per minute at [banisterSedentaryHRR] — the rate subtracted from every day. */ + fun banisterBaselineRatePerMinute(b: Double): Double = + banisterScale * banisterSedentaryHRR * kotlin.math.exp(b * banisterSedentaryHRR) + + /** + * The sedentary TRIMP accrued over [minutes] — subtracted from a day's Banister TRIMP so the axis + * starts where Edwards' does. + * + * Subtracted from the DENOMINATOR too (see [logMapDenominator]), so the top of the axis is unmoved: a + * theoretical maximum day still maps to exactly [maxStrain]. Anchoring only the bottom would trade one + * mismatched end for the other. + */ + fun banisterBaseline(minutes: Double, b: Double): Double = banisterBaselineRatePerMinute(b) * minutes + + /** * The log-map denominator for a method, so a caller never has to know which constant belongs to * which recipe. Ceiling + 1 in both cases, mirroring how [strainDenominator] was derived, so a @@ -89,8 +121,12 @@ object StrainScorer { */ fun logMapDenominator(method: Method, sex: String): Double = when (method) { Method.EDWARDS -> strainDenominator - Method.BANISTER -> - banisterDailyCeiling(if (sex.lowercase().startsWith("f")) banisterBWomen else banisterBMen) + 1.0 + Method.BANISTER -> { + val b = if (sex.lowercase().startsWith("f")) banisterBWomen else banisterBMen + // Ceiling MINUS a full day of sedentary baseline, matching what is subtracted from the day + // itself, so both ends of the axis line up with Edwards (#1624). + banisterDailyCeiling(b) - banisterBaseline(24.0 * 60.0, b) + 1.0 + } } val lnStrainDenominator: Double get() = ln(strainDenominator) @@ -214,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) } @@ -277,11 +314,24 @@ object StrainScorer { hrReserve: Double, durations: List, b: Double, + /** Per-minute rate treated as "no effort" and subtracted from EVERY sample, floored at zero + * (#1624). Zero — the default — is the original, unfloored Banister recipe, so every existing + * caller and test is byte-identical. Pass [banisterBaselineRatePerMinute] to score the excess + * over a sedentary day, which is what the daily scorer does. + * + * Per SAMPLE, never as one lump off the total: a day quieter than the floor would otherwise run + * a deficit that eats into real work done on top, and 90 minutes at 35% HRR inside an otherwise + * still day would net negative and clamp to zero — erasing exactly the intermittent effort this + * recipe exists to capture. */ + floorRatePerMinute: Double = 0.0, ): Double { var acc = 0.0 for (i in hr.indices) { val x = pctHRR(hr[i].bpm.toDouble(), restingHR, hrReserve) / 100.0 - if (x > 0) acc += durations[i] * x * banisterScale * exp(b * x) + if (x > 0) { + val rate = x * banisterScale * exp(b * x) + acc += durations[i] * (rate - floorRatePerMinute).coerceAtLeast(0.0) + } } return acc } @@ -290,7 +340,8 @@ object StrainScorer { /** * Map accumulated TRIMP onto [0, 100] via 100 × ln(TRIMP+1) / ln(D), 2 dp. - * TRIMP ≤ 0 → 0. + * TRIMP ≤ 0 → 0, and D ≤ 1 (or NaN) → 0, being outside the map's domain. The output is + * unbounded as D → 1⁺ — an upper clamp is tracked separately. * * The default D is **Edwards'**. A Banister TRIMP passed here without an explicit denominator is * scored against the wrong ceiling and reads low — prefer [strain], which resolves the method's own @@ -298,8 +349,22 @@ object StrainScorer { */ fun trimpToStrain(trimp: Double, denominator: Double = strainDenominator): Double { if (trimp <= 0) return 0.0 + // D ≤ 1 (and NaN) is outside the map's domain: ln(1) = 0 divides to ±∞, ln(D) < 0 below 1 + // flips the sign, and ln(D) is NaN at or below 0. Out-of-domain D is no score, like + // TRIMP ≤ 0 — before this guard D = 1 returned a saturated 9.2e16 here and +Inf on Swift + // for the same input (the denominator-domain fix). The default 7201 is unaffected. + if (!(denominator > 1)) return 0.0 val value = maxStrain * ln(trimp + 1.0) / ln(denominator) - return (value * 100).roundToLong() / 100.0 + val scaled = value * 100 + // Round in Double space: roundToLong() clips anything past Long.MAX_VALUE, which Swift's + // .rounded() does not, so a D just above 1 still disagreed across platforms (the + // denominator-domain fix). Above 2^53 every Double is already an integer, so passing it + // through IS the rounded value. + // Keep this comparison in this direction: abs(NaN) < 2^53 is false, so NaN intentionally + // takes the pass-through; reversing it to `>= 2^53` would send NaN through `roundToLong()`, + // collapse it to 0.0, and break parity with Swift. + val rounded = if (abs(scaled) < 9007199254740992.0) scaled.roundToLong().toDouble() else scaled + return rounded / 100.0 } // ---- Denominator calibration ---- @@ -324,6 +389,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 ---- /** @@ -349,6 +446,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() @@ -362,7 +464,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 @@ -370,12 +480,20 @@ object StrainScorer { val trimp: Double = when (method) { Method.BANISTER -> { val b = if (sex.lowercase().startsWith("f")) banisterBWomen else banisterBMen - banisterTRIMP(hr, restingHR, hrReserve, durations, b) + // Excess over the sedentary baseline for the SAME span, floored at zero. Without this a + // desk day scores ~45 on a 0-100 axis whose bottom is supposed to be no exertion (#1624). + banisterTRIMP(hr, restingHR, hrReserve, durations, b, + floorRatePerMinute = banisterBaselineRatePerMinute(b)) } Method.EDWARDS -> { 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/TrainingLoadEngine.kt b/android/app/src/main/java/com/noop/analytics/TrainingLoadEngine.kt index 33c12ec088..1a21ce9dfc 100644 --- a/android/app/src/main/java/com/noop/analytics/TrainingLoadEngine.kt +++ b/android/app/src/main/java/com/noop/analytics/TrainingLoadEngine.kt @@ -163,7 +163,9 @@ object TrainingLoadEngine { /** Convenience for dense load arrays. Day labels are synthetic but deterministic; math is identical. */ fun evaluateDense(loads: List, configuration: Configuration = standard): Result { val baseOrdinal = dayOrdinal("2000-01-01")!! - val days = loads.mapIndexed { index, load -> DailyLoad(dayString(baseOrdinal + index), load) } + val days = loads.mapIndexed { index, load -> + DailyLoad(LocalCalendarDate(daysSinceEpoch = baseOrdinal + index).key, load) + } return evaluate(days, configuration = configuration) } @@ -195,20 +197,6 @@ object TrainingLoadEngine { return era * 146_097 + dayOfEra - 719_468 } - private fun dayString(ordinal: Int): String { - val z = ordinal + 719_468 - val era = floorDiv(z, 146_097) - val dayOfEra = z - era * 146_097 - val yearOfEra = (dayOfEra - dayOfEra / 1_460 + dayOfEra / 36_524 - dayOfEra / 146_096) / 365 - var year = yearOfEra + era * 400 - val dayOfYear = dayOfEra - (365 * yearOfEra + yearOfEra / 4 - yearOfEra / 100) - val monthPrime = (5 * dayOfYear + 2) / 153 - val day = dayOfYear - (153 * monthPrime + 2) / 5 + 1 - val month = monthPrime + if (monthPrime < 10) 3 else -9 - if (month <= 2) year += 1 - return "%04d-%02d-%02d".format(java.util.Locale.US, year, month, day) - } - private fun isLeapYear(year: Int) = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) private fun floorDiv(value: Int, divisor: Int): Int { diff --git a/android/app/src/main/java/com/noop/analytics/V5HealthSignals.kt b/android/app/src/main/java/com/noop/analytics/V5HealthSignals.kt index 400dcd7082..2a13782299 100644 --- a/android/app/src/main/java/com/noop/analytics/V5HealthSignals.kt +++ b/android/app/src/main/java/com/noop/analytics/V5HealthSignals.kt @@ -41,7 +41,7 @@ object V5HealthSignals { * already raised. Nullable so an absent illness pass leaves it null. (Augment-only, Option A.) */ val illnessDistance: IllnessDistance.Result?, - /** True once there are enough trusted nights for any of these to be more than "learning". */ + /** True once an HRV or resting-HR baseline can honestly gate an illness heads-up. */ val baselineTrusted: Boolean, ) @@ -60,7 +60,18 @@ object V5HealthSignals { activityBins: List = emptyList(), daysObserved: Int = 0, ): Snapshot { - val baselineTrusted = days.count { hasAnyVital(it) } >= MIN_BASELINE_NIGHTS + // #2131: trust belongs to a SIGNAL, not to a row. Counting rows with any vital let fourteen + // unrelated or mixed measurements lend confidence to an HRV/RHR illness claim even though no + // one baseline had fourteen observations. Use the same trailing window zAgainst uses for the + // newest night and mirror iOS's illness gate: a trusted RHR OR HRV baseline may raise. Keep the + // cycle classifier's existing availability gate separate so this illness fix cannot change it. + val cycleBaselineUsable = days.count { hasAnyVital(it) } >= MIN_BASELINE_NIGHTS + val baselineWindow = days.dropLast(1).takeLast(BASELINE_WINDOW).filter { hasAnyVital(it) } + val rhrBaselineTrusted = + baselineWindow.count { it.restingHr != null } >= MIN_BASELINE_NIGHTS + val hrvBaselineTrusted = + baselineWindow.count { it.avgHrv != null } >= MIN_BASELINE_NIGHTS + val baselineTrusted = rhrBaselineTrusted || hrvBaselineTrusted // ── Per-night z-scores against each signal's trailing rolling baseline ── val nights = ArrayList(days.size) @@ -78,7 +89,11 @@ object V5HealthSignals { // ── Cycle awareness (opt-in) ── val cycle = if (cycleOptedIn) { - CyclePhaseEngine.classify(nights, baselineUsable = baselineTrusted, loggedPeriodStarts = loggedPeriodStarts) + CyclePhaseEngine.classify( + nights, + baselineUsable = cycleBaselineUsable, + loggedPeriodStarts = loggedPeriodStarts, + ) } else { CyclePhaseEngine.Result( phase = CyclePhaseEngine.Phase.LEARNING, @@ -151,7 +166,7 @@ object V5HealthSignals { * twin's `guard bins.count >= 6` in AppModel.computeCircadianPhase. */ internal const val MIN_CIRCADIAN_BINS = 6 - /** A day is "usable" for the baseline if it carries at least one of the four illness/cycle vitals. */ + /** Whether a row can contribute to any signal's baseline; empty rows do not enter trust counts. */ private fun hasAnyVital(d: DailyMetric): Boolean = d.restingHr != null || d.avgHrv != null || d.skinTempDevC != null || d.respRateBpm != null diff --git a/android/app/src/main/java/com/noop/analytics/WatchRecovery.kt b/android/app/src/main/java/com/noop/analytics/WatchRecovery.kt index e9869bf4a7..6c9d19aa9d 100644 --- a/android/app/src/main/java/com/noop/analytics/WatchRecovery.kt +++ b/android/app/src/main/java/com/noop/analytics/WatchRecovery.kt @@ -18,7 +18,7 @@ package com.noop.analytics * here, so RecoveryScorer renormalises the remaining HRV + RHR weights. The HRV term stays dominant. * * Honesty rule: return null recovery + CALIBRATING when today's HRV is missing, OR the HRV baseline isn't - * usable yet, OR there are fewer than [minBaselineNights] nights of history. We NEVER fabricate a number to + * usable yet, OR the baseline has accepted fewer than [minBaselineNights] nights. We NEVER fabricate a number to * fill a sparse week. Confidence comes from the existing [ScoreConfidence.forCharge]. */ object WatchRecovery { @@ -27,7 +27,8 @@ object WatchRecovery { data class Result(val recovery: Double?, val confidence: ScoreConfidence) /** - * Minimum nights of HRV history before we score recovery from a daily-aggregate source. Sits ABOVE the + * Minimum VALID nights (nights the baseline accepted, `BaselineState.nValid`) before we score recovery + * from a daily-aggregate source — not raw history entries. Sits ABOVE the * baseline's own seed gate (4) deliberately: a strap user crosses the seed faster on dense data, but a * sparse daily HRV deserves a longer warm-up before we trust it. Mirrors the Swift constant. */ @@ -37,7 +38,8 @@ object WatchRecovery { * Compute recovery/Charge from a daily HRV + resting HR vs the person's own baseline. * * @param todayHrv today's HRV reading (ms), or null if the source logged none. - * @param todayRhr today's resting HR (bpm), or null to drop the RHR term. + * @param todayRhr today's resting HR (bpm), or null to drop the RHR term. The term is also dropped when + * [rhrHistory] has not yet produced a usable RHR baseline. * @param hrvHistory ordered nightly HRV values (oldest -> newest), the baseline input. * @param rhrHistory ordered nightly resting-HR values (oldest -> newest). */ @@ -58,19 +60,27 @@ object WatchRecovery { // Honesty gate: no number unless we have today's HRV, a usable baseline, AND at least a week of // nights. Any miss -> null recovery + calibrating, never a fabricated value. - if (todayHrv == null || !hrvBase.usable || hrvHistory.size < minBaselineNights) { + // The week is counted in nights the baseline ACCEPTED (nValid), not in raw history entries: a + // physiologically implausible reading is skip-and-held by [Baselines.update] and contributes + // nothing to the baseline, so counting it would let rejected values buy a score a week early. + if (todayHrv == null || !hrvBase.usable || hrvBase.nValid < minBaselineNights) { return Result(recovery = null, confidence = ScoreConfidence.CALIBRATING) } // Reuse the canonical Charge engine. Drop the resp / sleep / skin-temp terms (the daily aggregate - // doesn't carry them here) -> RecoveryScorer renormalises to HRV + RHR. RHR is optional: a missing - // resting HR today passes the at-baseline value (z~0, neutral term) and drops the RHR term entirely. + // doesn't carry them here) -> RecoveryScorer renormalises to HRV + RHR. RHR is optional: the term + // needs BOTH today's reading AND a usable personal RHR baseline. An empty or all-implausible RHR + // history folds to foldHistory's synthetic midpoint (the config's min/max mean, e.g. 75 bpm), which + // is nobody's resting HR — scoring against it would move Charge on a fabricated baseline. Mirrors + // the `usable ? state : nil` gate the macOS Charge driver breakdown applies (Swift TodayView / + // CoupledView); without a usable RHR baseline we fall back to the HRV-only path, exactly as when + // today's reading is missing. val recovery = RecoveryScorer.recovery( hrv = todayHrv, rhr = todayRhr?.toDouble() ?: rhrBase.baseline, resp = null, hrvBaseline = hrvBase, - rhrBaseline = if (todayRhr != null) rhrBase else null, + rhrBaseline = if (todayRhr != null && rhrBase.usable) rhrBase else null, respBaseline = null, sleepPerf = null, ) ?: return Result(recovery = null, confidence = ScoreConfidence.CALIBRATING) 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..57b464eca4 --- /dev/null +++ b/android/app/src/main/java/com/noop/analytics/WindowedStreamPlan.kt @@ -0,0 +1,110 @@ +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, + hrOwnerFlips: Long = 0, + rrOwnerFlips: Long = 0, + hrReuseOff: Long = 0, + rrReuseOff: Long = 0, + ): String = + // #2073: `served` near zero says the windows are declining, and the doc above names three causes. + // `truncated` covered one. `ownerFlips` separates the other two, and `reuseOff` covers the case + // none of them describe: a stream the caller told not to reuse at all, where served=0 is by design + // and reading it as a decline is simply wrong. + "analyzeRecent windows hr[read=$hrRead served=$hrServed truncated=$hrTruncated " + + "ownerFlips=$hrOwnerFlips reuseOff=$hrReuseOff] " + + "rr[read=$rrRead served=$rrServed truncated=$rrTruncated " + + "ownerFlips=$rrOwnerFlips reuseOff=$rrReuseOff]" + + /** 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..94eb47670c 100644 --- a/android/app/src/main/java/com/noop/ble/Backfiller.kt +++ b/android/app/src/main/java/com/noop/ble/Backfiller.kt @@ -1,6 +1,7 @@ package com.noop.ble import android.content.Context +import android.content.SharedPreferences import com.noop.data.DynAccelDiag import com.noop.data.InsertCounts import com.noop.data.StreamBatch @@ -15,6 +16,7 @@ import com.noop.protocol.decodeHistorical import com.noop.protocol.extractHistoricalStreams import com.noop.protocol.isEmptyRecordFrame import com.noop.protocol.rejectedHistoricalRecords +import java.io.IOException import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -63,6 +65,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 @@ -219,6 +229,18 @@ class Backfiller( */ var sessionSkinTempRows = 0 private set + + /** #2019: this session's v26 optical windows, and what they carried. See [ppgWaveformCensusLine]. */ + var sessionPpgWindows = 0 + private set + var sessionPpgWithBase = 0 + private set + var sessionPpgSaturated = 0 + private set + var sessionPpgBaseMin: Long? = null + private set + var sessionPpgBaseMax: Long? = null + private set private val sessionNightKeys = HashSet() val sessionNights: Int get() = sessionNightKeys.size @@ -228,6 +250,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 @@ -247,6 +275,19 @@ class Backfiller( var lastAckedTrim: Long? = null private set + /** + * #1992: reject frames still allowed to hex-dump (see [hexDumpAllowance]). + * + * Deliberately NOT reset in [begin], for the same reason as [lastAckedTrim]: the thing being + * protected is the ROLLING LOG, which belongs to the process, not to one offload session. The + * auto-continue re-kicks up to 24 sessions per connection, so a per-session budget would allow + * 24 x 24 frames and flood the buffer exactly as before, which is the shape the reporter hit. + */ + private var rejectHexBudget: Int = REJECT_HEX_DUMP_BUDGET + /** Reject frames seen this session, so the suppression line can say what it stopped showing. */ + private var rejectFramesSeen: Int = 0 + private var rejectHexSuppressedNoted: Boolean = false + /** * Distinct historical record-layout versions logged this session. Before this, only the unmapped/ * reject path surfaced a version, so a HEALTHY log never revealed which layout the strap emits @@ -262,6 +303,16 @@ class Backfiller( * [com.noop.analytics.Spo2ReTrace.MAX_SAMPLES]. Session-scoped so the cap spans chunks; reset in begin. */ private var spo2Dumped = 0 + /** SpO2 RE dump: how many records this session dumped for each layout version, so one layout cannot + * spend the whole session budget. Key -1 buckets a record whose `hist_version` did not decode. + * Session-scoped alongside [spo2Dumped]; reset in begin. Twin of the Swift `spo2DumpedByVersion`. */ + private val spo2DumpedByVersion = HashMap() + + /** SpO2 RE dump: records EXAMINED this session, bounded by [com.noop.analytics.Spo2ReTrace.MAX_EXAMINED]. + * Counts the decode attempts the search costs, which the dump counter stopped bounding once the + * per-version cap could hold dumps back indefinitely. Reset in begin. Twin of Swift `spo2Examined`. */ + private var spo2Examined = 0 + /** * #547: logged once per session the first time the #547 ingest gate drops an implausible-timestamp * record (a bad strap clock/flash emitting far-past / year-2027-spike / future-dated `unix` values). @@ -304,6 +355,11 @@ class Backfiller( this.continuedAfterRows = continuedAfterRows isBackfilling = true sessionRowsPersisted = 0 + sessionPpgWindows = 0 + sessionPpgWithBase = 0 + sessionPpgSaturated = 0 + sessionPpgBaseMin = null + sessionPpgBaseMax = null sessionRrOffered = 0 sessionRrInserted = 0 sessionRrSumMs = 0 @@ -321,6 +377,8 @@ class Backfiller( loggedLayoutVersions.clear() chunkIndex = 0 spo2Dumped = 0 + spo2DumpedByVersion.clear() + spo2Examined = 0 loggedImplausibleClock = false sessionDroppedImplausible = 0 sessionUnhandledPacketTypes.clear() // #891: a second offload must re-log its first sighting @@ -413,24 +471,60 @@ class Backfiller( // reject path logged a version before, so a healthy sync never revealed v24/v25 (4.0) or // v18/v26 (5/MG). Sample the chunk's first genuine record (null ⇒ console/CRC-fail); log // each distinct layout once per session. - frames.firstNotNullOfOrNull { decodeHistorical(it, family)?.get("hist_version") as? Int } - ?.let { v -> - if (loggedLayoutVersions.add(v)) { - log("Backfill: historical records use layout v$v") - firmwareLayout(v) - // Connection test mode: the firmware layout as a compact tagged line. A layout that - // decoded a signature field (heart_rate / gravity_x / ppg_waveform) is decodable. - // Gated zero-cost. Twin of the Swift Backfiller emit. - emitConnection { - val decodable = frames.any { - val d = decodeHistorical(it, family) - d != null && (d.containsKey("heart_rate") || d.containsKey("gravity_x") || - d.containsKey("ppg_waveform")) - } - com.noop.analytics.ConnectionTrace.firmwareLine(v, decodable) - } - } + // EVERY distinct layout in the chunk, not just the first record's. The comment above says + // "log each distinct layout once per session" and the old form did not do that: it sampled the + // first decodable record and stopped, so a strap emitting v18 AND an unscoreable layout logged + // only the v18 and never mentioned the other one. That is precisely the mixed-layout strap the + // guidance below exists for. Swift reads its already-parsed frames for this and pays nothing; + // here it costs one decode pass over the chunk, beside the one `extractHistoricalStreams` just + // did, which is immaterial next to the transfer that delivered the chunk. + val layoutsInChunk = LinkedHashMap() // version -> any record carried a signal + for (f in frames) { + val d = decodeHistorical(f, family) ?: continue + val v = d["hist_version"] as? Int ?: continue + val carries = d.containsKey("heart_rate") || d.containsKey("gravity_x") || + d.containsKey("ppg_waveform") + layoutsInChunk[v] = (layoutsInChunk[v] ?: false) || carries + } + // `firmwareLayout` sets ONE state value and the connection trace names ONE layout, so both stay + // bound to the chunk's first version exactly as before; only the logging widened. + val firstLayout = layoutsInChunk.keys.firstOrNull() + for ((v, carriesSignal) in layoutsInChunk) { + if (!loggedLayoutVersions.add(v)) continue + log("Backfill: historical records use layout v$v") + // #1992: and say what that MEANS when it is not a layout NOOP can score from. Android used + // to print the bare version and stop, so a user whose nights were not staging had the fact + // in their log and none of the explanation, while the same strap on iOS was told why. + // Asked of the LAYOUT: it counts as carrying a signal when ANY of the chunk's records of + // that version did, so one thin record cannot condemn it. Twin of the Swift emit. + when (com.noop.protocol.historicalLayoutSupport( + version = v, family = family, hasHeartRate = carriesSignal, + hasGravity = false, hasPpgWaveform = false, + )) { + com.noop.protocol.HistoricalLayoutSupport.UNMAPPED -> + log( + "Historical records use firmware layout v$v, which NOOP doesn't decode yet: " + + "those records carry no heart rate or motion, so any night made only of them " + + "can't be staged from the strap. A strap emitting a mix of layouts still " + + "stages the nights it can. Please report this (issue #1992).", + ) + com.noop.protocol.HistoricalLayoutSupport.DECODES_WITHOUT_NAMED_SIGNAL -> + log( + "Historical records use firmware layout v$v. NOOP decodes it, but these records " + + "carry no per-second heart rate and no motion (they hold raw sensor channels " + + "nothing scores yet), so any night made only of them can't be staged from the " + + "strap. A strap emitting a mix of layouts still stages the nights it can. " + + "Please report this (issue #1992).", + ) + com.noop.protocol.HistoricalLayoutSupport.SUPPORTED -> Unit + } + if (v == firstLayout) { + firmwareLayout(v) + // Connection test mode: the firmware layout as a compact tagged line. Gated zero-cost. + // Twin of the Swift Backfiller emit. + emitConnection { com.noop.analytics.ConnectionTrace.firmwareLine(v, carriesSignal) } } + } // SpO2 RE dump (PR #945, reimplemented): while the Connection test mode is on, dump a few FULL // historical records + their mapped raw SpO2 channels so an offline pass can tell whether the // strap banks a COMPUTED SpO2 (a byte tracking the WHOOP app's nightly %) vs only the raw @@ -440,13 +534,25 @@ class Backfiller( // the strap's type-50 console frames carry no record bytes to correlate. Records dump whether // or not they carry SpO2 channels, so "nothing banked" is provable too. Never a user-facing // number (never-fabricate; the #194 lesson). Twin of the Swift Backfiller emit. - if (spo2Dumped < com.noop.analytics.Spo2ReTrace.MAX_SAMPLES && connectionActive()) { + if (spo2Dumped < com.noop.analytics.Spo2ReTrace.MAX_SAMPLES && + spo2Examined < com.noop.analytics.Spo2ReTrace.MAX_EXAMINED && + connectionActive() + ) { for (f in frames) { if (spo2Dumped >= com.noop.analytics.Spo2ReTrace.MAX_SAMPLES) break + // The decode below is a SECOND decode of a frame the extractor already decoded, so the + // search has to be bounded by what it examines and not only by what it dumps. + if (spo2Examined >= com.noop.analytics.Spo2ReTrace.MAX_EXAMINED) break + spo2Examined++ val d = decodeHistorical(f, family) ?: continue // `as? Long`, not `as? Int`: the decoder carries unix in the unsigned domain, so an // Int cast would miss on EVERY record and silently stop the dump. See `histU32`. val recUnix = d["unix"] as? Long ?: continue + // Stratify by layout: without this the first chunk's dominant layout eats the whole + // budget and the rare, still-unmapped one never gets a single frame. See MAX_PER_VERSION. + val ver = d["hist_version"] as? Int ?: -1 + val dumpedForVer = spo2DumpedByVersion[ver] ?: 0 + if (dumpedForVer >= com.noop.analytics.Spo2ReTrace.MAX_PER_VERSION) continue connectionLog( com.noop.analytics.Spo2ReTrace.recordLine( frame = f, @@ -457,6 +563,7 @@ class Backfiller( skinRaw = d["skin_temp_raw"] as? Int, ), ) + spo2DumpedByVersion[ver] = dumpedForVer + 1 spo2Dumped++ } } @@ -499,6 +606,13 @@ class Backfiller( "you recognise, this is a firmware record type NOOP has never mapped: please " + "report it on #891 with the strap model and firmware build.", ) + // #891: and the bytes, so the report is actionable. Without this the line above asks a + // reporter to raise an issue about a record that exists nowhere else: the else branch + // drops the frame and the reject archive only ever holds type-47. First sighting only, + // so a long offload of one unmapped type still costs exactly one dump. + decoded.unhandledPacketSamples[typeName]?.let { hex -> + log(unmappedTypeDumpLine(typeName, hex)) + } } } // #324: the strap RTC-state events (RTC_LOST / BOOT / SET_RTC) the #547 gate dropped for a bad @@ -538,7 +652,10 @@ class Backfiller( // records run ~84 B and the truncated tail is exactly where the unmapped motion/HR // fields sit), and sample a few more so one log carries enough records to triangulate // offsets. These only ever fire for unmapped firmware. - val sample = rejected.take(8) + rejectFramesSeen += rejected.size + // #1992: spend from a SESSION budget, not a fresh 8 per chunk. See [hexDumpAllowance]. + val allowance = hexDumpAllowance(rejected.size, rejectHexBudget) + val sample = rejected.take(allowance) var emptySkipped = 0 sample.forEachIndexed { i, f -> // #1007: an all-zero frame has no record layout to map, so its hex dump is pure log @@ -546,10 +663,21 @@ class Backfiller( if (isEmptyRecordFrame(f)) { emptySkipped++; return@forEachIndexed } val hex = f.joinToString("") { "%02x".format(it) } log("Backfill: rejected frame[$i] ${f.size}B: $hex") + rejectHexBudget-- } if (emptySkipped > 0) { log("Backfill: #1007 $emptySkipped/${sample.size} sampled frame(s) all-zero (empty payload) - hex dump skipped") } + // Say ONCE that the sample is capped, so a reader knows the dump is a sample rather than + // everything the strap sent, and where the rest lives. + if (rejectHexBudget <= 0 && !rejectHexSuppressedNoted) { + rejectHexSuppressedNoted = true + log( + "Backfill: hex dumps capped at $REJECT_HEX_DUMP_BUDGET frame(s) while this connection lasts " + + "($rejectFramesSeen reject frame(s) seen so far); the complete records are in the " + + "reject archive. Sample is enough to map a layout (#1992)", + ) + } } // Commit the decoded rows FIRST (durable) — BEFORE the reject archive (#1006, matching the // Swift twin). Insert-first means a rare insert failure — which returns below and re-sends @@ -563,11 +691,24 @@ 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. val (rows, motion, nights) = chunkTally(counts, decoded.gravity.map { it.ts } + decoded.hr.map { it.ts }) sessionRowsPersisted += rows + // #2019: the v26 optical census, folded per chunk. Counted on what the DECODER produced + // rather than on what the store kept, because an un-reconstructable window is a decode + // fact: the base is either on the wire or it is not. + for (w in decoded.ppgWaveform) { + sessionPpgWindows += 1 + w.baseCode?.let { b -> + sessionPpgWithBase += 1 + sessionPpgBaseMin = minOf(sessionPpgBaseMin ?: b, b) + sessionPpgBaseMax = maxOf(sessionPpgBaseMax ?: b, b) + } + if (w.samples.any { com.noop.protocol.isSaturatedPpgDelta(it) }) sessionPpgSaturated += 1 + } // #1008/#1118 census accumulation (pre-storage offered vs post-key inserted). sessionRrOffered += rrCensus.intervals sessionRrInserted += counts.rr @@ -704,6 +845,38 @@ class Backfiller( } companion object { + /** + * Reject frames one connection may hex-dump (#1992). Three chunks worth at the per-chunk + * cap: enough distinct records to triangulate field offsets (v25 was mapped from 45, spread + * across many logs), while leaving room in a 2000-line rolling buffer for the lines that + * give the dump its context. The complete records are always in the reject archive. + */ + internal const val REJECT_HEX_DUMP_BUDGET = 24 + + /** + * #891: the dump line for the first frame of an unmapped packet type. Byte-identical to the Swift + * `Backfiller.unmappedTypeDumpLine`; [hex] is the FULL frame, so the length is derived from it + * rather than passed separately and able to disagree with the bytes beside it. + */ + internal fun unmappedTypeDumpLine(typeName: String, hex: String): String = + "Backfill: unmapped type $typeName first frame ${hex.length / 2}B: $hex" + + /** + * How many reject frames this chunk may hex-dump, given what the session has already spent (#1992). + * + * The dump is the only channel carrying an unmapped layout's raw bytes to someone who can map it, + * and it was bounded PER CHUNK with no session budget. On the straps it exists for that defeats + * itself: a strap rejecting ~25 records per chunk, across many chunks and many sessions per + * connection, emits 8 long hex lines each time, floods the 2000-line rolling log, and evicts its own + * earlier dumps along with the context needed to read them. A session budget keeps a usable sample + * rather than a flood that rolls itself away. + * + * Pure, so the arithmetic is testable without constructing a Backfiller (which needs a repository + * over a 152-method DAO). Swift twin: `hexDumpAllowance`. + */ + internal fun hexDumpAllowance(rejectedCount: Int, budgetRemaining: Int, perChunkCap: Int = 8): Int = + maxOf(0, minOf(rejectedCount, perChunkCap, budgetRemaining)) + /** Cursor name for the strap's safe-trim watermark. Matches the Swift `setCursor("strap_trim", ...)`. */ const val STRAP_TRIM_CURSOR = "strap_trim" @@ -797,6 +970,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." } } @@ -835,13 +1092,22 @@ interface TrimCursorStore { } /** Default [TrimCursorStore] backed by a private SharedPreferences file. */ -class PrefsTrimCursorStore(context: Context) : TrimCursorStore { - private val prefs = context.applicationContext - .getSharedPreferences("noop_backfill_cursors", Context.MODE_PRIVATE) +class PrefsTrimCursorStore(private val prefs: SharedPreferences) : TrimCursorStore { + + /** Production entry point: the app's private cursor prefs file. */ + constructor(context: Context) : this( + context.applicationContext.getSharedPreferences("noop_backfill_cursors", Context.MODE_PRIVATE), + ) override suspend fun set(name: String, value: Long) { // commit() (synchronous) so durability is established before we ack the strap. - prefs.edit().putLong(name, value).commit() + // #8: commit() reports a FAILED write (full storage, unwritable prefs file) by RETURNING + // false — it does not throw. Discarding that result made a failed persist look identical to + // a successful one, so the caller's try/catch never fired and the strap got acked without a + // durable cursor. Surface it as the throw that guard already handles by HOLDING the ack. + if (!prefs.edit().putLong(name, value).commit()) { + throw IOException("SharedPreferences.commit() returned false for cursor '$name'=$value") + } } override suspend fun get(name: String): Long? = diff --git a/android/app/src/main/java/com/noop/ble/BleStatusLabels.kt b/android/app/src/main/java/com/noop/ble/BleStatusLabels.kt new file mode 100644 index 0000000000..eb9f3e5f09 --- /dev/null +++ b/android/app/src/main/java/com/noop/ble/BleStatusLabels.kt @@ -0,0 +1,69 @@ +package com.noop.ble + +/* + * BleStatusLabels.kt - names for the BLE integers the strap log used to print bare. + * + * The strap log is the only evidence a remote report carries, and a bare number in it is evidence + * nobody can read. `gattStatusLabel` already established the discipline for GATT operation statuses: + * name the codes that answer a question, leave everything else as a bare number rather than guessing, + * because a confidently wrong name in a failure line is worse than no name at all. These extend the + * same treatment to the two other integer spaces the log emits. + * + * Deliberately three separate functions rather than one: a scan error, a GATT operation status and a + * disconnect reason are DIFFERENT number spaces that happen to share the Int type. Forcing them + * through one table is exactly how a code acquires a name from the wrong space. + * + * Pure formatters: no IO, no state, no PII (these are protocol integers), unit-tested without a radio. + * Android-only. CoreBluetooth reports scan and disconnect problems as central-manager state changes and + * NSErrors, not as status integers; the Apple side tokenises those via `BLEManager.bleErrorToken`. + */ + +/** + * A `ScanCallback.SCAN_FAILED_*` code, named. + * + * Worth naming even though a scan failure is rare, because one of these codes is both common and + * entirely self-inflicted: Android permits an app 5 scan starts per 30 seconds and answers the sixth + * with [SCAN_FAILED_SCANNING_TOO_FREQUENTLY], after which scanning simply stops working for the rest of + * the window. That is the leading cause of "it will not find my strap", it is fixable once seen, and + * the log reported it as the character "6". + */ +internal fun scanFailureLabel(code: Int): String = when (code) { + 1 -> "ALREADY_STARTED(1)" + 2 -> "APPLICATION_REGISTRATION_FAILED(2) - the BLE stack rejected our scanner; a Bluetooth restart usually clears it" + 3 -> "INTERNAL_ERROR(3)" + 4 -> "FEATURE_UNSUPPORTED(4) - this device's radio cannot do the scan we asked for" + 5 -> "OUT_OF_HARDWARE_RESOURCES(5) - too many scanners open across all apps" + SCAN_FAILED_SCANNING_TOO_FREQUENTLY -> + "SCANNING_TOO_FREQUENTLY(6) - Android throttles an app to 5 scan starts per 30s and this one was refused; scanning stays dead until the window rolls" + else -> "$code" +} + +/** The throttle code, named because callers reason about it and not just log it. */ +internal const val SCAN_FAILED_SCANNING_TOO_FREQUENTLY = 6 + +/** + * A DISCONNECT status, named. + * + * A different space from [gattStatusLabel]'s: these arrive as HCI disconnect reasons surfaced through + * the same `status` Int, so 8 here means "link supervision timeout" and not the GATT code 8. That + * overlap is the whole reason this is separate rather than another branch of one shared table. + * + * Only the reasons that are unambiguous and long-established are named. 133 stays described as the + * catch-all it is rather than being given a specific cause it does not have. + */ +internal fun disconnectStatusLabel(status: Int): String = when (status) { + 0 -> "status=0 (clean)" + 8 -> "status=8 (link supervision timeout - the strap went out of range or stopped responding)" + 19 -> "status=19 (the STRAP terminated the connection)" + // NOT simply "this phone hung up", however much the name suggests it. `HelloSuppression` records + // why: 22 covers our own `gatt.disconnect()` and the bond-watchdog bounce, AND an SMP refusal, + // where the strap answers a write with Insufficient Authentication, the local stack tries to + // elevate security, the strap refuses, and the stack tears the ACL down. Not our teardown, same + // code. That third case is exactly where a 5 or MG which cannot bond lands, so the old wording + // told those wearers their phone had hung up on a strap that was refusing them. + 22 -> "status=22 (the local stack ended it: our own teardown, a bond-watchdog bounce, or an SMP " + + "refusal after the strap challenged a write)" + 62 -> "status=62 (connection failed to establish)" + 133 -> "status=133 (GATT_ERROR, Android's catch-all - no specific cause reported)" + else -> "status=$status" +} 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 5451eb3e5d..3accb9b4f9 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 } @@ -78,6 +86,126 @@ class BondRefusalGiveUp( "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 hint for a strap whose CLIENT_HELLO is never acknowledged, where NOOP now stays + * connected with the handshake switched off rather than pausing. + * + * Deliberately does NOT say "paused" (nothing is paused) and does NOT name a cause. An unanswered + * 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 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. + * + * 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 Swift `BondRefusalGiveUp.helloSuppressedEpitaph`. + */ + fun 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." + + /** + * 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. + * + * So this describes what was observed and offers the one action that is definitely theirs to + * take, without asserting why. Pure; no em-dash. + */ + fun 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." + + /** + * #1997: the three inputs the guide choice turns on, logged at the pause. + * + * The held-link branch is now deliberately conservative: it fires only when the OS itself reports + * the connection still held. Nothing in the field log that prompted it actually shows that signal + * on the silent links, because those took the give-up path, which prints no ACL marker. So the + * branch may be right and may simply never fire, and without this line the next log would not say + * which. + * + * The counters ship rather than just the behaviour, for the same reason the windowed-read ones do. + * + * [held] is PASSED IN rather than re-derived here. Restating `heldLinkWithoutTraffic`'s rule in a + * second file would let the line report a verdict the code did not reach the moment either copy + * changed. The caller evaluates the predicate once and hands the answer to both this and the guide, + * so all three cannot disagree. + */ + fun heldLinkDiagLine(aclHeld: Boolean, inboundFrames: Int, held: Boolean): String = + "held-link check: aclHeld=$aclHeld inbound=$inboundFrames -> ${if (held) "held" else "not held"}" + + /** + * #1997: which reconnect guide the never-bonded pause should show. + * + * Pure so the SELECTION is pinned and not just the two texts. Testing a predicate and a string + * separately proves neither is wired to the other, and an inline `if` in a BLE callback cannot be + * reached from a JVM test at all. Same shape as the other decisions in this file. + */ + fun reconnectGuideFor(heldLink: Boolean): String = + if (heldLink) heldLinkGuide() else stalePairingGuide() + + /** + * The long-standing guide: a stale pairing, or the official app holding the strap. Correct advice + * when the strap really is refusing, and the default whenever there is no evidence of a held link. + */ + fun stalePairingGuide(): String = + """ + Your strap connects but never finishes pairing with NOOP, so it drops and retries in a loop. This is almost always a stale Bluetooth pairing, usually after a WHOOP firmware update, or the official WHOOP app holding the strap. NOOP works fine once it's re-paired: + + 1. Quit the official WHOOP app (or turn off Bluetooth on that phone). + 2. Open Settings → Bluetooth, find your WHOOP, and Forget / Unpair it. + 3. Tap the band repeatedly until its LEDs flash blue (pairing mode). + 4. Come back here and tap Connect. + """.trimIndent() + + /** + * #1997: the reconnect GUIDE for a held link, replacing the re-pair steps for this state only. + * + * The re-pair guide's four steps are the right advice when a stale pairing is the cause. They are + * actively harmful here: nothing was exchanged for the strap to refuse, so forgetting the pairing + * and re-pairing changes nothing, and the reporter was doing it several times a day. + * + * Auto-reconnect still pauses, which is what stops both batteries draining on a retry loop. Only + * the explanation and the actions change. + */ + fun heldLinkGuide(): String = + """ + Your phone is still holding a Bluetooth connection to your strap, and your strap is not + answering on it: the connection size negotiation is refused and no data arrives. Re-pairing + will not change this, so it is not worth doing. + + 1. Turn Bluetooth off and back on. That releases the held connection. + 2. If you have the official WHOOP app installed, quit it too. A strap talks to one phone at a time. + 3. Come back here and tap Connect. + """.trimIndent() + /** * #750: a short OPAQUE token for the epitaph, derived from the strap's device id. * diff --git a/android/app/src/main/java/com/noop/ble/BondStateTrace.kt b/android/app/src/main/java/com/noop/ble/BondStateTrace.kt new file mode 100644 index 0000000000..d3f25cee53 --- /dev/null +++ b/android/app/src/main/java/com/noop/ble/BondStateTrace.kt @@ -0,0 +1,199 @@ +package com.noop.ble + +import android.bluetooth.BluetoothDevice + +/** A `BluetoothDevice.BOND_*` constant, named. Unknown values print as-is rather than as a guess. */ +internal fun bondStateName(state: Int): String = when (state) { + BluetoothDevice.BOND_NONE -> "BOND_NONE" + BluetoothDevice.BOND_BONDING -> "BOND_BONDING" + BluetoothDevice.BOND_BONDED -> "BOND_BONDED" + else -> "BOND_$state" +} + +/** + * The `ACTION_BOND_STATE_CHANGED` extra carrying WHY a bond ended, and the values it takes. + * + * Both the extra and the `UNBOND_REASON_*` constants are `@hide` in AOSP, so they are mirrored here as + * literals rather than referenced. That is the same trade the GATT status constants in `WhoopBleClient` + * already make; the values have been stable across every release that ships this broadcast, and a + * hidden-API reference would not compile. + */ +internal const val EXTRA_BOND_REASON = "android.bluetooth.device.extra.REASON" + +/** What `getIntExtra` returns when the OS supplied no reason. Not a code the broadcast ever sends. */ +internal const val NO_BOND_REASON = -1 + +/** + * A `BluetoothDevice.UNBOND_REASON_*` value, named. + * + * Deliberately mirrors [gattStatusLabel]: name the codes that answer the question and leave + * anything else as a bare number. A confidently wrong name in the line whose whole job is explaining a + * pairing failure is worse than no name, and this broadcast's vendor stacks do emit values AOSP does + * not define. + * + * The distinction this exists to preserve: AUTH_FAILED ("the strap refused authentication") and + * AUTH_TIMEOUT ("nobody answered") and REMOTE_DEVICE_DOWN ("the link went away") are three different + * findings for #1635, and a trace that only says "pairing did NOT complete" cannot tell them apart. + */ +internal fun bondFailureReasonLabel(reason: Int): String = when (reason) { + 1 -> "reason=AUTH_FAILED(1)" + 2 -> "reason=AUTH_REJECTED(2)" + 3 -> "reason=AUTH_CANCELED(3)" + 4 -> "reason=REMOTE_DEVICE_DOWN(4)" + 5 -> "reason=DISCOVERY_IN_PROGRESS(5)" + 6 -> "reason=AUTH_TIMEOUT(6)" + 7 -> "reason=REPEATED_ATTEMPTS(7)" + 8 -> "reason=REMOTE_AUTH_CANCELED(8)" + 9 -> "reason=REMOVED(9)" + else -> "reason=$reason" +} + +/** + * One line per OS bond-state transition, with how long after the CLIENT_HELLO write it happened. + * + * NOOP has never observed `ACTION_BOND_STATE_CHANGED`, so the OS pairing flow has been invisible. That + * is the gap that leaves #1635 undecided: a WHOOP 5/MG shows every CLIENT_HELLO going unacknowledged and + * the link torn down locally on a clockwork timer (mean 3158 ms, 10 ms spread over nine attempts), and + * two explanations fit equally well — + * + * - the confirmed write to an encryption-requiring characteristic triggers OS pairing, which fails or + * times out and takes the link with it. Then this line shows `BOND_NONE -> BOND_BONDING` shortly + * after the write and `BOND_BONDING -> BOND_NONE` at the teardown, and the question is answered. + * - the write never triggers pairing at all. Then the device never enters `BOND_BONDING`, the absence + * is just as conclusive, and the cause lies elsewhere entirely. + * + * Both readings need the same evidence and neither can be inferred from what the log carries today, + * which is why this observes rather than concludes. + * + * [sinceHelloMs] is null when no CLIENT_HELLO is outstanding — a transition from an unrelated pairing + * (another app, another device) then carries no elapsed time rather than a misleading one measured from + * a write it has nothing to do with. + * + * [reason] is the OS's own `UNBOND_REASON_*` for a bond that ENDED, and is the difference between + * "the strap refused authentication" and "nobody answered in time" - two findings this line previously + * rendered identically as "pairing did NOT complete". + * + * Appended ONLY to a failed transition that actually carries one. A successful pairing has no reason to + * give, the broadcast leaves the extra absent on transitions INTO bonding, and a stack that supplies + * nothing gets the wording it had before rather than a "reason=n/a" that says less than silence. The + * existing failure line is therefore unchanged whenever there is no new information to add. + * + * Pure so the wording is unit-tested without a radio. Android-only: CoreBluetooth performs pairing + * opaquely and exposes no equivalent transition, so there is nothing to twin. + */ +internal fun bondStateTraceLine( + previous: Int, + current: Int, + address: String?, + sinceMs: Long?, + sinceLabel: String = "CLIENT_HELLO", + reason: Int? = null, +): String { + val who = address?.takeIf { it.isNotBlank() } ?: "unknown" + val since = sinceMs?.let { " ${it}ms after $sinceLabel" } ?: "" + // A bond ends two ways, and BOTH carry a reason. Scoping this to the failed-pairing transition would + // have left the other one, a bond that EXISTED and then went away, as a bare state change with no + // explanation - which is where REMOVED and REMOTE_DEVICE_DOWN actually show up. + val why = reason?.takeIf { it != NO_BOND_REASON }?.let { ", " + bondFailureReasonLabel(it) } ?: "" + val note = when { + previous == BluetoothDevice.BOND_BONDING && current == BluetoothDevice.BOND_NONE -> + " — pairing did NOT complete$why" + // "ended" rather than "was removed": the transition says the bond is gone, the reason code says + // why, and naming a cause here would be the line concluding rather than observing. + previous == BluetoothDevice.BOND_BONDED && current == BluetoothDevice.BOND_NONE -> + " — the bond ended$why" + current == BluetoothDevice.BOND_BONDED -> " — paired" + else -> "" + } + return "bond state: ${bondStateName(previous)} -> ${bondStateName(current)} device=$who$since$note" +} + +/** + * Should this bond-state transition be traced at all? + * + * The receiver hears EVERY pairing on the phone - headphones, a car kit, a colleague's keyboard - and + * the strap log is a file people attach to public issues. Recording unrelated pairings there is both + * noise in a fixed-size rolling buffer and information about devices that have nothing to do with NOOP, + * so the trace is scoped to the strap this app is talking to. + * + * Matched case-insensitively for the same reason [SourceCoordinator] matches addresses that way: the + * stored form and the broadcast form can differ in case, and a case-sensitive compare would silently + * trace nothing at all - the failure mode that looks exactly like "the pairing never happened", which is + * one of the two answers this trace exists to distinguish. + * + * An event with NO address is traced only while a CLIENT_HELLO is outstanding: that is the window this + * exists to observe, and outside it an anonymous transition cannot be attributed to us. + * + * Pure so the scoping rule is unit-tested without a radio. + * + * PARITY: Android-only, and permanently so. CoreBluetooth exposes no bond state and no pairing API at + * all — there is no `BOND_BONDING` to observe on Apple platforms and nothing to mirror. An audit finding + * this file one-sided should leave it rather than delete it as untwinned. + */ +internal fun shouldTraceBondState( + eventAddress: String?, + strapAddress: String?, + helloOutstanding: Boolean, +): Boolean { + val ev = eventAddress?.trim().orEmpty() + val ours = strapAddress?.trim().orEmpty() + if (ev.isEmpty()) return helloOutstanding + if (ours.isEmpty()) return false + return ev.equals(ours, ignoreCase = true) +} + +/** The one-per-link readout of the OS bond state the link STARTED with (#1635). + * + * Without it a capture cannot tell an already-paired link from an unpaired one, which is the single fact + * that decides how to read everything after it: a CLIENT_HELLO that fails on an unencrypted link and one + * that fails on an encrypted link are completely different findings, and they have been printing + * identically. It also makes the explicit-pairing experiment legible — "did the pairing from last connect + * actually survive?" is answered on the next connect line rather than inferred. + */ +internal fun bondStateAtConnectLine(bondState: Int, address: String?): String { + val who = address?.takeIf { it.isNotBlank() } ?: "unknown" + return "bond state at connect: ${bondStateName(bondState)} device=$who" +} + +/** + * What `BluetoothDevice.bondState` says a short while AFTER `createBond()` was accepted. + * + * This exists because absence of evidence kept being read as evidence. A capture showed `createBond` + * returning true and then no bond-state transition of any kind — and that has two very different causes + * which the log could not tell apart: + * + * - Android genuinely did nothing, or + * - it did something and NOOP failed to hear it, because the broadcast receiver or its device filter is + * wrong. That receiver is ours and was added in the same week; it is a live suspect, not a given. + * + * 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. + * + * 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. + */ +internal fun bondStatePollLine(bondState: Int, sawTransitionLine: Boolean): String { + val name = bondStateName(bondState) + val verdict = when { + bondState == android.bluetooth.BluetoothDevice.BOND_BONDING && !sawTransitionLine -> + " — pairing IS underway and no transition line was logged, so the bond-state receiver missed it" + + " (a NOOP bug, not the strap)" + bondState == android.bluetooth.BluetoothDevice.BOND_BONDING -> + " — pairing underway, as the transition line already said" + bondState == android.bluetooth.BluetoothDevice.BOND_BONDED -> " — paired" + !sawTransitionLine -> + " — 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/BondStateTraceHelpers.kt b/android/app/src/main/java/com/noop/ble/BondStateTraceHelpers.kt new file mode 100644 index 0000000000..2a1878659e --- /dev/null +++ b/android/app/src/main/java/com/noop/ble/BondStateTraceHelpers.kt @@ -0,0 +1,30 @@ +package com.noop.ble + +import com.noop.protocol.DeviceFamily + +/** + * 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 until now, because the gate tested only the status — and + * an unanswered handshake presents as a plain local terminate (status 22). The consequence, measured on + * a real strap: eight consecutive connect attempts all logging "attempt 1", retrying every ~11 seconds + * indefinitely, because the streak never incremented and the give-up never latched. + * + * 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. + */ +internal fun countsAsBondRefusal( + isAuthRefusalStatus: Boolean, + helloUnacked: Boolean, + alreadyBonded: Boolean, + family: DeviceFamily, +): Boolean { + if (alreadyBonded) return false // bonded already — not a pairing problem + if (family != DeviceFamily.WHOOP5) return false // the 4.0 bonds cleanly; this is the 5/MG handshake + return isAuthRefusalStatus || helloUnacked +} diff --git a/android/app/src/main/java/com/noop/ble/ClientHelloOutcome.kt b/android/app/src/main/java/com/noop/ble/ClientHelloOutcome.kt new file mode 100644 index 0000000000..ad6649f91e --- /dev/null +++ b/android/app/src/main/java/com/noop/ble/ClientHelloOutcome.kt @@ -0,0 +1,200 @@ +package com.noop.ble + +/** + * What became of a WHOOP 5/MG CLIENT_HELLO write. + * + * A capture could not previously distinguish the three ways the 5/MG bond fails, because two of them + * produce no line at all. In one field capture 14 of 16 CLIENT_HELLO writes went out and were never + * acked, 1 was rejected by the stack, and 1 produced an "ack" — from a completion the code never checked + * the characteristic of (#1635). From the log those look the same: silence, then a link drop. + * + * The three outcomes, and why each matters: + * - [helloAcked]: the completion came from the CLIENT_HELLO characteristic itself. The only one that is + * genuinely an ack. + * - [foreignAck]: a completion arrived from a DIFFERENT characteristic while the bond was still + * pending. The ack branch matches on family alone, so this is what silently sets `encryptedBond` on a + * strap that never bonded — and the line names the characteristic that did it. + * - [noCallback]: the write was accepted by the stack and no completion ever arrived before the link + * dropped. This is the dominant case in the field capture, and the one with no evidence at all today. + * + * Reports only what it observed; it does not attribute blame between the strap and the local stack, + * because a write callback that never arrives cannot distinguish "the strap declined to respond" from + * "the frame never reached the air". Naming the gap is what makes that answerable next. + * + * [status] is passed pre-rendered so each platform supplies its own (Android's BluetoothStatusCodes + * label, CoreBluetooth's error description), leaving the line shape identical. Pure. Swift twin: + * `ClientHelloOutcome.line`. + */ +internal fun clientHelloOutcomeLine( + isHelloChar: Boolean, + charUuid: String?, + elapsedMs: Long, + status: String?, +): String { + val where = charUuid?.takeIf { it.isNotBlank() } ?: "unknown" + val st = status?.takeIf { it.isNotBlank() }?.let { " $it" } ?: "" + return when { + charUuid == null -> + "CLIENT_HELLO outcome: NO write callback after ${elapsedMs}ms — the link dropped before the" + + " stack reported, so the strap may never have seen it" + isHelloChar -> + "CLIENT_HELLO outcome: acked by $where after ${elapsedMs}ms$st" + else -> + "CLIENT_HELLO outcome: bond declared from a DIFFERENT characteristic $where after" + + " ${elapsedMs}ms$st — this is NOT a CLIENT_HELLO ack (#1635)" + } +} + +/** + * A `BluetoothGatt.GATT_*` status from `onCharacteristicWrite`, labelled. + * + * NOT [WhoopBleClient.writeStatusLabel], which maps `BluetoothStatusCodes` — the return value of + * `writeCharacteristic`, a different enumeration that collides with this one on small integers. Passing a + * callback status through that mapper renders GATT_INVALID_HANDLE(1) as "ERROR_BLUETOOTH_NOT_ENABLED", + * GATT_READ_NOT_PERMITTED(2) as "ERROR_BLUETOOTH_NOT_ALLOWED" and GATT_WRITE_NOT_PERMITTED(3) as + * "ERROR_DEVICE_NOT_BONDED" — confidently wrong names in a line whose whole job is explaining a bond + * failure, which is worse than printing no name at all. + * + * Names the codes that matter for a bond and leaves the rest as bare numbers rather than guessing: + * INSUFFICIENT_AUTHENTICATION and INSUFFICIENT_ENCRYPTION are exactly "the strap refused the encrypted + * bond" (the pair [BondRefusalGiveUp] already keys on), and 133 is the catch-all Android returns for a + * link that went away underneath the operation. + * + * Named for GATT statuses generally, not just writes: the same integers arrive from service discovery, + * a CCCD write and a characteristic read, and they mean the same thing in each. A DISCONNECT status is + * NOT this space (see [disconnectStatusLabel]) - 8 there is an HCI link-supervision timeout, not a GATT + * code, and running one through the other's table is how a number acquires a name from the wrong space. + * + * Android-only by design: CoreBluetooth reports `didWriteValueFor` with an `Error`, not a status code, + * which is why the outcome line takes its status pre-rendered. + */ +internal fun gattStatusLabel(status: Int?): String = when (status) { + null -> "status=n/a" + 0 -> "status=GATT_SUCCESS(0)" + 3 -> "status=GATT_WRITE_NOT_PERMITTED(3)" + 5 -> "status=GATT_INSUFFICIENT_AUTHENTICATION(5)" + 13 -> "status=GATT_INVALID_ATTRIBUTE_LENGTH(13)" + 15 -> "status=GATT_INSUFFICIENT_ENCRYPTION(15)" + 133 -> "status=GATT_ERROR(133)" + 257 -> "status=GATT_FAILURE(257)" + else -> "status=$status" +} + +/** + * Is this write completion genuinely the CLIENT_HELLO's ack, and therefore proof of an encrypted bond? + * + * The bond branch used to match on family alone: on a 5/MG link, ANY with-response completion that + * arrived while `didBond` was false was taken as the ack and set `encryptedBond`. A characteristic check + * on its own does not fix that, because the puffin command characteristic (fd4b0002) carries BOTH the + * CLIENT_HELLO and every ordinary command - DISABLE_ALARM is written there on the same connect - so the + * hello and an unrelated command are indistinguishable by uuid. + * + * What separates them is whether a hello is actually OUTSTANDING. The hello is written straight past the + * command queue while sharing its in-flight slot, so when a queued command is already in flight the write + * is rejected by the stack and no callback is owed (the caller zeroes its stopwatch to say so). That is + * exactly the case seen in the field: the hello was rejected, DISABLE_ALARM's completion landed a moment + * later, and the link was declared bonded on the strength of it (#1635). + * + * Both conditions are required, and neither is redundant: + * - [isHelloChar] alone admits DISABLE_ALARM and every other puffin command. + * - [helloOutstanding] alone admits a completion from a different characteristic that happens to land + * inside the hello's window. + * + * Declining does not claim the strap refused anything - it says only that THIS completion is not evidence + * of a bond. A real ack still arrives on its own callback and still bonds. + * + * Pure, so the rule is tested without a radio. Swift twin: `ClientHelloOutcome.isAck`. + */ +internal fun completionIsClientHelloAck( + isHelloChar: Boolean, + helloOutstanding: Boolean, + alreadyBonded: Boolean, + isWhoop5: Boolean, +): Boolean { + if (alreadyBonded) return false + if (!isWhoop5) return false + 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. + * + * [completionIsClientHelloAck] declines these, and without a line it declines in SILENCE — which is worse + * for a capture than the wrong answer it replaces. The field log for #1635 said "CLIENT_HELLO acked — + * link established" here; the honest replacement is not nothing, it is "a completion arrived and it was + * not the hello's". Without it the reader cannot tell a completion arrived at all, and the evidence that + * identified this bug in the first place would not appear in the next capture. + * + * Names the characteristic because the whole point is that fd4b0002 carries traffic other than the hello, + * and the reader needs to see WHICH command completed to match it against the `→ CMD` line above it. + * + * Bounded: this branch is 5/MG-only and runs only while unbonded, and the field capture shows about one + * such completion per connect attempt. + * + * Pure. Swift twin: `ClientHelloOutcome.declinedLine`. + */ +internal fun clientHelloDeclinedLine(charUuid: String?, status: String?): String { + // trim(), not just isNotBlank(): the Swift twin trims, and an untrimmed uuid would render with its + // padding intact — a byte-level parity break the oracle test below catches. + val where = charUuid?.trim()?.takeIf { it.isNotEmpty() } ?: "unknown" + val st = status?.trim()?.takeIf { it.isNotEmpty() }?.let { " $it" } ?: "" + return "CLIENT_HELLO outcome: completion from $where$st with NO hello outstanding — not a bond," + + " so the link stays unbonded (#1635)" +} + diff --git a/android/app/src/main/java/com/noop/ble/DisUnbondedRead.kt b/android/app/src/main/java/com/noop/ble/DisUnbondedRead.kt new file mode 100644 index 0000000000..cef66ddd48 --- /dev/null +++ b/android/app/src/main/java/com/noop/ble/DisUnbondedRead.kt @@ -0,0 +1,76 @@ +package com.noop.ble + +/** + * Should NOOP try reading the Device Information Service on a 5/MG that has NOT bonded? + * + * Today `readDisIdentity` is issued only inside the post-bond handshake, so a strap that never bonds never + * reads DIS at all — which is why a field capture of the reconnect loop contains zero `DIS:` lines and 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 to 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 the Swift side carries a note (#490) + * 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 that + * #1635 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 — the failure mode of "keeps trying something + * that will never work" is the one this whole area keeps producing. + * + * A refusal is a RESULT, not a defeat: it settles #490 for Android, which no capture has ever done. + */ +internal fun shouldReadDisUnbonded( + isWhoop5: Boolean, + bonded: Boolean, + alreadyReadThisLink: Boolean, + previouslyRefused: Boolean, +): Boolean { + 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 [firmwarePrefKey] is. */ +internal fun disRefusedPrefKey(peripheralId: String?): String? = + peripheralId?.trim()?.takeIf { it.isNotEmpty() }?.let { "noop.disRefused.${it.lowercase()}" } + +/** + * The line for a DIS read that came back with a failure status. + * + * Without it the read fails in SILENCE — `onCharacteristicRead` drops any non-success status on the floor + * — 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 / INSUFFICIENT_ENCRYPTION are the interesting answers: they say the strap + * requires an encrypted link for this read, which confirms #490 on Android and means the firmware simply + * cannot be had without a bond. + */ +internal fun 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. The Devices screen therefore shows a WHOOP 4.0 + * with its firmware beside a 5/MG with none — which reads as a missing feature and is really a missing + * READ: DIS `0x2A26` sits in the same service NOOP already reads the serial and hardware revision from, + * unbonded, on every 5/MG connect (#520). It was simply never asked for. + * + * 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. + */ +internal fun shouldPublishDisFirmware( + disFirmware: String?, + alreadyDecoded: String?, +): Boolean = !disFirmware.isNullOrBlank() && alreadyDecoded.isNullOrBlank() diff --git a/android/app/src/main/java/com/noop/ble/ExplicitBond.kt b/android/app/src/main/java/com/noop/ble/ExplicitBond.kt new file mode 100644 index 0000000000..f2dad72418 --- /dev/null +++ b/android/app/src/main/java/com/noop/ble/ExplicitBond.kt @@ -0,0 +1,160 @@ +package com.noop.ble + +/** + * Should NOOP ask Android to pair with this strap, rather than hoping a write provokes it? + * + * NOOP has never called `BluetoothDevice.createBond()` on either platform. The entire 5/MG pairing + * strategy has been IMPLICIT: write the CLIENT_HELLO to the encrypted `fd4b0002` and rely on the stack + * noticing that the characteristic needs encryption and starting pairing by itself. + * + * The bond-state trace (#1639) showed that mechanism has never once fired. Across two field captures the + * device never enters `BOND_BONDING` at all — no pairing is attempted, the write never completes, and the + * local stack drops the ACL ~3.15s later. So the only route NOOP had to an encrypted bond on a 5/MG was + * one that does not work, with no fallback, because the explicit route was never built. + * + * This is that route, as an experiment. Asking directly is the obvious thing nobody has tried, and #1639 + * is what makes the answer readable: if `createBond()` works, the trace shows `BOND_NONE -> BOND_BONDING + * -> BOND_BONDED`; if the strap refuses, it shows `BOND_BONDING -> BOND_NONE`. Either is a real answer, + * which is more than the implicit path ever gave. + * + * OFF by default and its own switch, per the rule every strap-affecting probe in [PuffinExperiment] + * follows: this one asks the OS to form a persistent pairing and can surface a system pairing dialog, so + * it must not ride in on consent given for something else. + * + * [alreadyBondedAtOsLevel] is the REAL `BluetoothDevice.bondState`, not NOOP's `encryptedBond` flag. The + * two are unrelated: `encryptedBond` only ever meant "a handshake write was acked", which is why a strap + * can read Bonded in the UI while the OS holds no pairing at all. + * + * [alreadyRequestedThisLink] keeps it to one attempt per connection. Re-issuing `createBond()` while a + * pairing is in flight is how you get a dialog per retry, and the retry cadence here is seconds. + * + * PARITY: deliberately Android-only, and not a gap to be filled later. CoreBluetooth exposes no explicit + * 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, + isWhoop5: Boolean, + 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 +} + +/** + * Once `createBond()` has been asked for, should this connection still write the CLIENT_HELLO? + * + * No — and this is the point of the experiment rather than an implementation detail. Writing to the + * encrypted characteristic while a pairing is in flight is the behaviour that has been dropping the link + * for eleven weeks, so doing both at once would test nothing and reproduce the bug. + * + * 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, + helloOverride: Boolean = false, + priorDeferrals: Int = 0, +): Boolean = requestedThisLink && !helloOverride && priorDeferrals < 1 + +/** + * The outcome line when `createBond()` THREW rather than returning. + * + * Almost always a missing BLUETOOTH_CONNECT permission. Reporting that as "Android refused to start + * pairing" would blame the strap for something entirely local, and a capture would carry a confident wrong + * answer about hardware — the exact failure this whole investigation kept producing. Names the throwable + * instead and claims nothing about the strap. + */ +internal fun explicitBondThrewLine(throwableName: String, bondStateName: String): String = + "WHOOP 5/MG: could not ask Android to pair — createBond threw $throwableName from $bondStateName." + + " 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 + * [explicitBondThrewLine]. */ +internal fun explicitBondRequestLine(initiated: Boolean, bondStateName: String): String = + if (initiated) { + "WHOOP 5/MG: asked Android to pair (createBond from $bondStateName) — watch the bond state lines" + + " for the answer; the CLIENT_HELLO waits for the next connect (#1635, experimental)" + } else { + "WHOOP 5/MG: Android refused to START pairing (createBond returned false from $bondStateName) —" + + " no pairing was attempted (#1635, experimental)" + } diff --git a/android/app/src/main/java/com/noop/ble/FirmwareAttribution.kt b/android/app/src/main/java/com/noop/ble/FirmwareAttribution.kt new file mode 100644 index 0000000000..984b07fb90 --- /dev/null +++ b/android/app/src/main/java/com/noop/ble/FirmwareAttribution.kt @@ -0,0 +1,44 @@ +package com.noop.ble + +/** + * Which firmware string belongs to a device, given what is known about it. + * + * The persisted firmware used to live under one global key, written on any WHOOP connect and read back + * for whichever strap was active. On a single-strap install those are the same strap, which is why it + * went unnoticed; with two straps paired it reports the OTHER strap's firmware — a WHOOP 5/MG showing a + * 4.0's 41.17.6.0 because the 4.0 connected last. + * + * The rule, in order: + * - [live] wins: it came from THIS connection's handshake. + * - [perDevice] next: this device's own persisted value, from a previous connection. + * - [legacyGlobal] ONLY when [pairedCount] is 1. The old global key cannot say which strap it belongs + * to, so it is trustworthy exactly when there is only one strap it could have come from. This keeps a + * single-strap install reading correctly across the upgrade instead of showing "unknown" until the + * next connect; a multi-strap install refuses it, which is the bug. + * - otherwise null — "not known yet" is the honest answer, and better than another strap's number. + * + * Pure so the attribution is unit-tested without prefs, a strap, or a registry. Swift twin: + * `FirmwareAttribution.resolve`. + */ +internal fun resolveFirmware( + live: String?, + perDevice: String?, + legacyGlobal: String?, + pairedCount: Int, +): String? = live?.takeIf { it.isNotBlank() } + ?: perDevice?.takeIf { it.isNotBlank() } + ?: legacyGlobal?.takeIf { it.isNotBlank() && pairedCount == 1 } + +/** + * The per-device preference key for a persisted firmware string. + * + * Keyed on the BLE peripheral address rather than the registry id: the BLE client knows the address it + * connected to, and resolving it to a registry row there would repeat the mis-mapping #1527 fixed for + * `lastSeen` (stamping "the active row" records a sighting of a strap that was never connected). The + * registry row carries the same address, so the read side resolves without guessing. + * + * 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 firmwarePrefKey(peripheralId: String?): String? = + peripheralId?.trim()?.takeIf { it.isNotEmpty() }?.let { "noop.lastFirmware.${it.lowercase()}" } 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..e384cd0626 --- /dev/null +++ b/android/app/src/main/java/com/noop/ble/FrameRejectTally.kt @@ -0,0 +1,170 @@ +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. Structural failures retain a null CRC diagnostic when the + * declared payload cannot be checked; only a computed, disagreeing checksum is a payload mismatch. + * + * 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 + + /** + * The reassembler's monotonic header-checksum drop count already folded into + * [FrameRejectReason.HEADER_CHECKSUM_MISMATCH], so repeated folds cannot double-count. + */ + private var absorbedReassemblerHeaderDrops = 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 + } + + /** + * Fold a reassembler's [Reassembler.headerChecksumDrops] into the + * [FrameRejectReason.HEADER_CHECKSUM_MISMATCH] bucket. + * + * Same reasoning as [absorbReassemblerDrops] above, for the other thing the reassembler drops + * before any parser sees it. A false start-of-frame rejected by its own header checksum reaches + * neither a parser nor the evidence-preserving reader, so without this fold the only record that a + * link was resyncing at all would be a counter nothing reads. Monotonic total; only the growth + * since the last fold is added, so calling this once per notification is idempotent. + */ + fun absorbReassemblerHeaderDrops(monotonicTotal: Int) { + if (monotonicTotal <= absorbedReassemblerHeaderDrops) return + val reason = FrameRejectReason.HEADER_CHECKSUM_MISMATCH + counts[reason] = (counts[reason] ?: 0) + (monotonicTotal - absorbedReassemblerHeaderDrops) + absorbedReassemblerHeaderDrops = 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 + // Both absorbed totals, for the same reason. The reassembler's counters are monotonic for ITS + // lifetime and this tally is per connection, so a counter left behind here reads as "already + // folded" against a total that never goes down: the fold's guard returns early and that reason + // never reaches another connection's tally again. Silent, and permanent. + absorbedReassemblerHeaderDrops = 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 new file mode 100644 index 0000000000..20f3384d33 --- /dev/null +++ b/android/app/src/main/java/com/noop/ble/GattCapability.kt @@ -0,0 +1,171 @@ +package com.noop.ble + +import android.bluetooth.BluetoothGattCharacteristic + +/** + * What a characteristic actually DECLARES it can do, and whether we are honouring that. + * + * Android gives an app no way to read the link's encryption state, so debugging an encrypted connection + * has to be done through proxies. The two available are the OS bond state (see [bondStateAtConnectLine]) + * and this: the property bitmask the strap published for the characteristic in question. + * + * The reason it matters here is specific. The 5/MG CLIENT_HELLO is written to `fd4b0002` WITH RESPONSE, + * and has been since the June change that swapped `WRITE_TYPE_NO_RESPONSE` for `WRITE_TYPE_DEFAULT`. If + * that characteristic declares only `PROPERTY_WRITE_NO_RESPONSE` and not `PROPERTY_WRITE`, then a + * with-response write is not something it supports — the stack may accept the call, never produce a + * completion, and the link goes away. Which is exactly the shape of the failure: 16 writes, 0 acks, no + * pairing attempted, drop ~3.15s later. + * + * That is a HYPOTHESIS, not a conclusion. The point of this line is that one capture settles it, and no + * capture so far could, because the properties have never been printed. If `Write` is present the + * hypothesis is dead and the with-response write is fine; if it is absent, the June regression has a + * mechanism rather than just a correlation. + * + * Pure, and cheap: a bitmask decode on one characteristic at discovery. + */ +internal fun characteristicCapabilityLine( + uuid: String, + properties: Int, + writingWithResponse: Boolean, +): String { + val declared = characteristicPropertyNames(properties) + val supportsWithResponse = properties and BluetoothGattCharacteristic.PROPERTY_WRITE != 0 + val verdict = when { + !writingWithResponse -> "" + supportsWithResponse -> " — with-response writes are supported" + else -> + " — MISMATCH: we write WITH RESPONSE but this characteristic does not declare Write," + + " so no completion is owed and the write may never be answered (#1635)" + } + return "characteristic $uuid properties=0x${properties.toString(16)} ($declared)$verdict" +} + +/** The property bitmask as names, shared by the single-characteristic line and the whole-tree dump so + * the two can never describe the same bits differently. "none" for an empty mask. */ +internal fun characteristicPropertyNames(properties: Int): String { + val names = buildList { + if (properties and BluetoothGattCharacteristic.PROPERTY_BROADCAST != 0) add("Broadcast") + if (properties and BluetoothGattCharacteristic.PROPERTY_READ != 0) add("Read") + if (properties and BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE != 0) add("WriteNoResponse") + if (properties and BluetoothGattCharacteristic.PROPERTY_WRITE != 0) add("Write") + if (properties and BluetoothGattCharacteristic.PROPERTY_NOTIFY != 0) add("Notify") + if (properties and BluetoothGattCharacteristic.PROPERTY_INDICATE != 0) add("Indicate") + if (properties and BluetoothGattCharacteristic.PROPERTY_SIGNED_WRITE != 0) add("SignedWrite") + if (properties and BluetoothGattCharacteristic.PROPERTY_EXTENDED_PROPS != 0) add("Extended") + } + return if (names.isEmpty()) "none" else names.joinToString("+") +} + +/** + * The strap's whole GATT tree, one line per characteristic. + * + * NOOP has never asked a strap what it offers. Every characteristic in this file is looked up by a UUID + * someone hardcoded, so anything the 5/MG exposes that nobody guessed is invisible — and the 5/MG protocol + * is exactly the thing still being reverse-engineered. + * + * The reason this is the probe worth adding, rather than another puffin command: it needs no bond and + * sends nothing. Service discovery has already happened by the time this runs, so walking the result is a + * read of a local cache — no GATT operation, no traffic, and no way to provoke the teardown that a write + * to an encrypted characteristic provokes. On a strap that never bonds, every puffin probe is unreachable + * and this one still works, which is the whole distinction. + * + * Bounded and unsurprising: a handful of services on a strap, emitted once per connect and gated on the + * Test Centre connection domain, since it is a per-connect readout rather than rare-event evidence. + */ +internal fun gattTreeLines(services: List>>>): List { + if (services.isEmpty()) return listOf("GATT tree: no services discovered") + return buildList { + add("GATT tree: ${services.size} service(s)") + for ((svc, chars) in services) { + add(" service $svc (${chars.size} char)") + for ((uuid, props) in chars) { + add(" $uuid props=0x${props.toString(16)} (${characteristicPropertyNames(props)})") + } + } + } +} + +/** + * 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 new file mode 100644 index 0000000000..cfcccfb922 --- /dev/null +++ b/android/app/src/main/java/com/noop/ble/HelloSuppression.kt @@ -0,0 +1,212 @@ +package com.noop.ble + +/** + * 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. 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 + * 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. + * + * 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 + * answer would have the latch bypassed on every connect for good: hello, drop at ~4.8s, reconnect, + * forever, with the give-up powerless to stop it. That is the unbounded loop this suppression exists to + * end, reintroduced through the back door. The explicit-bond experiment instead clears the latch ONCE at + * the moment it asks for a pairing, which is self-limiting: the next failure re-latches and the condition + * to clear it again does not recur. + */ +internal fun shouldSendClientHello( + suppressedForDevice: Boolean, + userInitiated: Boolean, + 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? + * + * 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. + */ +internal fun giveUpSuppressesHello(authRefusal: Boolean): Boolean = !authRefusal + +/** + * SharedPreferences key holding the hello-suppression latch for one device. + * + * Per device, and lowercased for the same reason [firmwarePrefKey] is: the same strap can present its + * address 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. + */ +internal fun helloSuppressionPrefKey(peripheralId: String?): String? = + peripheralId?.trim()?.takeIf { it.isNotEmpty() }?.let { "noop.helloUnanswered.${it.lowercase()}" } 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..0982b3fa8a 100644 --- a/android/app/src/main/java/com/noop/ble/OuraLiveSource.kt +++ b/android/app/src/main/java/com/noop/ble/OuraLiveSource.kt @@ -24,6 +24,7 @@ import com.noop.data.OuraStreamMapping import com.noop.data.StreamBatch import com.noop.data.StreamPersistence import com.noop.oura.OuraAuth +import com.noop.oura.OuraAuthWatchdog import com.noop.oura.OuraCommand import com.noop.oura.OuraDriver import com.noop.oura.OuraDriverPhase @@ -40,6 +41,8 @@ import com.noop.oura.OuraOuterFrame import com.noop.oura.OuraReassembler import com.noop.oura.OuraRingGen import com.noop.oura.OuraSleepSession +import com.noop.oura.OuraSpO2Channel +import com.noop.oura.channel import com.noop.oura.OuraSleepSessionMapping import com.noop.oura.OuraTransition import com.noop.oura.OuraWearState @@ -51,6 +54,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). * @@ -108,6 +137,11 @@ class OuraLiveSource( /** #1284 residual 3 (default OFF): read live at persist — when true, an Oura hypnogram night is keyed on * its rounded 0x49 onset (stable per-night anchor) instead of the end-anchored first-code time. */ private val onsetKeying: () -> Boolean = { false }, + /** Packed-notification A/B (default OFF): read once per connect — when true the session's SetNotification + * is the official app's `ff` instead of `3f` (OURA_PROTOCOL.md s2.3). The next connect re-reads it, so + * switching the toggle off restores the default with nothing left on the ring. Twin of Swift's + * `notifyMaskFull`. */ + private val notifyMaskFull: () -> Boolean = { false }, /** Diagnostic sink for the connect/auth/stream lifecycle - the SAME exportable strap log (#421). * Every line is prefixed "Oura: ". Statuses / UUIDs / counts only, NEVER a device address. Default * no-op keeps existing call sites compiling and tests silent. */ @@ -184,6 +218,19 @@ class OuraLiveSource( * to [AdoptPhase.Idle] on every connect/stop/disconnect so a stale outcome never drives a transition. */ val adoptPhase: StateFlow = _adoptPhase.asStateFlow() + /** + * Where the ring's session is, for the Live console (#2305). `LiveState.connected` is one flag every + * live source writes into and, for the ring, is only raised by the first live HR push — so under a + * ring the console could only ever read WHOOP state or nothing, and "connected, authenticating" was + * indistinguishable from "connected and dead" (#2303, #2304). This names the phase the ring is actually + * in. [LinkPhase.AUTHENTICATED] is `auth OK` reached — the stream itself is `LiveState.streamingLiveHR`. + * Swift twin: `OuraLiveSource.LinkPhase` / `linkPhase`. + */ + enum class LinkPhase { DISCONNECTED, CONNECTING, AUTHENTICATING, AUTHENTICATED } + + private val _linkPhase = MutableStateFlow(LinkPhase.DISCONNECTED) + val linkPhase: StateFlow = _linkPhase.asStateFlow() + // MARK: - Live wear/charge indicator (#628 twin) — On wrist / Off wrist / charging // // The ring emits no "worn" event, so wear is inferred: a LIVE-HR push (0x2F) means a finger; a silent @@ -283,8 +330,22 @@ class OuraLiveSource( * stop/disconnect. These are last-night values from the history fetch, not live pushes, but we still * only want one log line, not one per sample. Twin of [loggedFirstHr]. */ private var loggedFirstTemp = false - /** Logs the FIRST SpO2 sample decoded this session only. Twin of [loggedFirstTemp]. */ - private var loggedFirstSpo2 = false + /** Logs the FIRST SpO2 sample decoded this session, PER CHANNEL. Twin of [loggedFirstTemp], except + * that `.spo2` carries two quantities three orders of magnitude apart ([OuraSpO2Channel]), and one + * latch across both reported whichever the drain served first: the same ring printed `value 93 + * (raw)` on one reconnect and `value 101144 (dc_raw)` on the next. A reporter read the second as a + * percentage and filed a defect against SpO2 that was never wrong. One latch per channel, so each + * line names one quantity and a session that only ever saw perfusion says so instead of implying a + * percentage arrived. Twin of Swift's `loggedFirstSpo2` set. */ + private val loggedFirstSpo2 = mutableSetOf() + /** 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 @@ -394,6 +455,36 @@ class OuraLiveSource( private var writeChar: BluetoothGattCharacteristic? = null private var notifyChar: BluetoothGattCharacteristic? = null + // MARK: - Auth watchdog (#2304) + + /** When the most recent `get_nonce` of this session went out; null once the nonce arrived. */ + private var authNonceRequestedAt: Long? = null + /** Escalations already taken this session (0 → resend → toggle → drop). Reset per connection. */ + private var authEscalations = 0 + /** + * CCCD callbacks still expected from the watchdog's off/on toggle: 2 while the disable is in flight, + * 1 while the re-enable is, 0 outside a toggle. [onDescriptorWrite] counts them down so the + * re-enable re-sends `get_nonce` instead of replaying [OuraTransition.Ready]. Swift twin: + * `authToggleInFlight` (Swift reads `isNotifying` on the callback; Android's descriptor callback + * carries no reliable value, hence the count). + */ + private var authToggleCccdPending = 0 + /** Consecutive sessions the watchdog dropped for silence, printed on the drop line. Reset by any nonce. */ + private var silentSessionsInARow = 0 + /** + * Whether the Bluetooth stack accepted the most recent write (`writeCharacteristic` returned + * `BluetoothStatusCodes.SUCCESS` on API 33+, `true` below). Printed raw on the watchdog's evidence + * line — every Oura write is WRITE_TYPE_NO_RESPONSE and nothing else records the return, so "the ring + * ignored `get_nonce`" and "the stack never sent it" otherwise produce the identical `→ get_nonce`. + * The Apple side prints `CBPeripheral.canSendWriteWithoutResponse` on the same line for the + * same reason. That is CoreBluetooth's send-capacity flag rather than a write's return value, + * so the two are counterparts in intent and not the same reading, and neither is a declaration + * of ours to resolve. + */ + private var lastWriteAccepted: Boolean? = null + /** The nonce timer. One-shot; re-posted by [authWatchdogRunnable] after an escalation. */ + private val authWatchdogRunnable = Runnable { authWatchdogFired() } + /** Periodic live-HR re-engage: daytime HR auto-reverts after ~20 s, so while streaming we re-send the * enable+subscribe every ~15 s (OURA_PROTOCOL.md s5.7). The token lets stop() cancel it. */ private var reengageScheduled = false @@ -431,12 +522,22 @@ class OuraLiveSource( /** * Periodic re-fetch while connected, so an overnight-connected session (or one left open after a nap) - * picks up freshly-banked sleep data without needing a reconnect. Mirrors the WHOOP ~15 min periodic - * history-offload floor. Held as a NAMED runnable so [stop]/disconnect can remove it from the handler, - * matching [reengageRunnable] / [reconnectRunnable]. + * picks up freshly-banked sleep data without needing a reconnect. Held as a NAMED runnable so + * [stop]/disconnect can remove it from the handler, matching [reengageRunnable] / [reconnectRunnable]. */ private var historyFetchScheduled = false - private val historyFetchIntervalMs = 900_000L + /** + * MEASURED 2026-08-10 (capture `…-260810-1556`, 2h31m at 96.6% connected): this interval is not just a + * backstop, it is the ACTUAL DATA CADENCE. Between fetches the ring delivered nothing at all — seven + * arrival gaps of 893-928 s inside live sessions, clustering exactly on the old 900 s value, while the + * app was awake (59-60 live-HR re-arms per gap), the link was up and the ring was worn. Only 2.1% of + * `0x80` "live HR" arrived within 15 s of its own second; the median record was 385 s old on arrival. + * + * 900 -> 300 s at a marginal cost: while live HR is on the app already writes dhr_enable+dhr_subscribe + * every 15 s (462 commands/h measured) against 21 commands/h for the whole history-fetch machinery. + * Payload is unchanged — the same records, in smaller chunks. Swift twin: `historyFetchInterval`. + */ + private val historyFetchIntervalMs = 300_000L private val historyFetchRunnable = object : Runnable { override fun run() { fetchHistoryIfIdle() @@ -465,6 +566,28 @@ class OuraLiveSource( /** Where this fetch sought from (reboot detection floor); armed per drain. */ private var resumeCursorAtFetchStart = 0L + /** + * The SyncTime anchor persisted from the END of the PREVIOUS connection to this ring (#2097), + * loaded fresh at the start of THIS session — never an anchor this session itself adopts. Compared + * against [currentSyncAnchorTicks]/[currentSyncAnchorUnixSeconds] in `commitResumeCursor` to tell a + * genuine ring reboot from a second BLE client (e.g. the Oura app) having served the ring in + * between: [OuraHistoryDrain.sawPreResumeData] alone cannot, since both produce the same "stored + * sample older than the fetch cursor" signature. Null on a first-ever connect to this ring, or when + * no previous session ever adopted an anchor — the comparison then declines and the existing + * "treat as reboot" behavior stands. + */ + private var previousSyncAnchorTicks: Long? = null + private var previousSyncAnchorUnixSeconds: Long? = null + + /** + * The SyncTime anchor THIS session has adopted (freshest wins, mirroring + * [OuraDriver.adoptSyncTimeAnchor]), persisted via [OuraSyncAnchorStore] for the NEXT connection to + * compare against as its own previous anchor. Null until `adoptSyncTimeAnchor` first succeeds this + * session. + */ + private var currentSyncAnchorTicks: Long? = null + private var currentSyncAnchorUnixSeconds: Long? = null + /** Wall-clock start of the current drain; feeds the deadline guard. */ private var drainStartedAtMs: Long? = null @@ -628,8 +751,13 @@ class OuraLiveSource( pendingContinuation = false handler.removeCallbacks(batchQuietRunnable) hypnogramAssembler.flush()?.let { persistHypnogramBurst(it) } - val rebootFullPullPending = drain.sawPreResumeData - commitResumeCursor(completed) + // Ask the cursor commit whether it actually reset for a reboot rather than reading + // `drain.sawPreResumeData` directly: that flag is also raised by a stale replay from a second BLE + // client's serve position, which the #2097 judge inside `commitResumeCursor` rejects as "not a + // reboot" and answers by keeping the cursor. Snapshotting the flag here (as this did until + // 2026-09-13) had the scheduler print "ring reboot detected - starting the honest full re-pull" + // two lines after "not a reboot (#2097)" and burn a chained pass that only re-read the kept cursor. + val rebootFullPullPending = commitResumeCursor(completed) advance(OuraTransition.HistoryCursorAdvanced(cursor = historyCursor, moreData = false)) if (rebootFullPullPending || resumeBacklog) { if (chainedDrainPasses >= MAX_CHAINED_DRAIN_PASSES) { @@ -653,19 +781,35 @@ class OuraLiveSource( /** * Commit the durable resume cursor at drain end. Only a cursor that (a) moved forward, (b) is * below the plausibility ceiling, and (c) resolves to a real time under the CURRENT anchor is - * persisted; a reboot (`sawPreResumeData`) resets to 0 so next connect does an honest full pull. + * persisted; a reboot (`sawPreResumeData`) resets to 0 so next connect does an honest full pull — + * UNLESS the ring's own SyncTime anchor proves its clock never paused across the gap (#2097: + * [OuraHistoryDrain.anchorsAreContinuous]), in which case the stale replay is treated like ordinary + * stale data instead (dropped; cursor follows the same forward-only-if-resolving rule as any other + * drain) rather than forcing a full re-pull of the ring's entire history. + * + * Returns `true` only when the cursor WAS reset to 0 for a reboot — the one outcome that leaves a + * full re-pull pending for [finishDrain] to chain. A stale replay the judge rejected returns `false`. */ - private fun commitResumeCursor(drainCompleted: Boolean) { + private fun commitResumeCursor(drainCompleted: Boolean): Boolean { val how = if (drainCompleted) "caught up (bytes_left 0)" else "stopped early" val resolves = drain.maxStoredRingTime > 0 && driver?.unixSeconds(forRingTimestamp = drain.maxStoredRingTime) != null - val newCursor = drain.resumeCursorAtDrainEnd(historyCursor, resolves) - if (drain.sawPreResumeData) { + val anchorConfirmsContinuity = drain.sawPreResumeData && syncAnchorsConfirmContinuity() + val newCursor = drain.resumeCursorAtDrainEnd(historyCursor, resolves, anchorConfirmsContinuity) + if (drain.sawPreResumeData && !anchorConfirmsContinuity) { log("Oura: history $how but the ring served data older than cursor $resumeCursorAtFetchStart" + " - clock reset/seek ignored; next connect does a full pull") historyCursor = 0 OuraHistoryCursorStore.save(appContext, deviceId, 0) - } else if (newCursor != historyCursor) { + return true + } + if (drain.sawPreResumeData) { + log("Oura: history $how but the ring served data older than cursor $resumeCursorAtFetchStart" + + " - the ring's own SyncTime clock never paused across the gap, so this is a second BLE" + + " client's serve position, not a reboot (#2097); discarding the stale replay instead of" + + " a full re-pull") + } + if (newCursor != historyCursor) { historyCursor = newCursor OuraHistoryCursorStore.save(appContext, deviceId, newCursor) log("Oura: history $how - resume cursor advanced to $historyCursor") @@ -675,6 +819,21 @@ class OuraLiveSource( } else { log("Oura: history $how (resume cursor unchanged $historyCursor)") } + return false + } + + /** + * Whether this drain's `sawPreResumeData` flag should be trusted as a genuine ring reboot, or + * whether the ring's own clock proves otherwise (#2097). Requires BOTH a previous-session anchor + * (persisted by an earlier connect) and an anchor THIS session adopted; either missing means there + * is nothing to compare, so this declines (`false`) and the existing reboot handling stands. + */ + private fun syncAnchorsConfirmContinuity(): Boolean { + val prevTicks = previousSyncAnchorTicks ?: return false + val prevSeconds = previousSyncAnchorUnixSeconds ?: return false + val curTicks = currentSyncAnchorTicks ?: return false + val curSeconds = currentSyncAnchorUnixSeconds ?: return false + return OuraHistoryDrain.anchorsAreContinuous(prevTicks, prevSeconds, curTicks, curSeconds) } /** @@ -688,6 +847,21 @@ class OuraLiveSource( private fun persistHypnogramBurst(burst: OuraHypnogramBurst) { val d = driver ?: return if (burst.totalCodes <= 0) return + // STALE-REPLAY GATE (2026-09-13, Oura-app handoff on a Gen 3): a second BLE client on the same + // phone makes the ring re-serve from ITS position, and the #2097 judge rightly keeps our cursor — + // but by then every replayed record has been ingested. Time-series rows dedupe on their keys; a + // burst does not: the last two sleep-phase records of a night came back without their 0x49 window + // and were end-anchored at their write time into a 52-minute `[no-0x49-onset]` session whose codes + // were not the night's tail. A burst written BEFORE where this fetch resumed was already banked + // from its own drain, so it is refused here rather than handed to the persist path's dedup. A + // genuine reboot is unaffected: its judge resets the cursor to 0 and the chained full pull + // re-serves the same records with no floor. Twin of the Swift gate. + if (OuraHistoryDrain.predatesResume(burst.lastRingTimestamp, resumeCursorAtFetchStart)) { + log("Oura: hypnogram burst (${burst.totalCodes} codes, written at rt ${burst.lastRingTimestamp})" + + " predates the resume cursor $resumeCursorAtFetchStart - a re-serve from a second BLE client's" + + " position (#2097), already banked from its own drain; not persisted") + return + } val writeEnd = d.unixSeconds(forRingTimestamp = burst.lastRingTimestamp) if (writeEnd == null) { pendingUnanchoredBursts.add(burst) @@ -751,9 +925,15 @@ class OuraLiveSource( // startTs on the ROUNDED onset (stable across the ring's re-serves) rather than the end-anchored // first-code time — so re-serves of one night share a PK and the bedtime is the true onset. The // completeness guard in the persist closure then suppresses/replaces any duplicate. + // safeKeyedStart refuses the rekey when `sleepStart` didn't actually bind in the assembler's own + // clip (item 22, 2026-09-12: a mis-paired 0x49 window otherwise wrote a startTs 16 min AFTER its + // own endTs) — see its doc comment for why `onset <= mapped.startTs` is the exact test. val onset = sleepStart - val session = if (onsetKeying() && onset != null) { - mapped.copy(startTs = com.noop.analytics.SleepSessionDedup.keyedStart(onset)) + val keyed = onset?.let { + com.noop.analytics.SleepSessionDedup.safeKeyedStart(it, mapped.startTs, mapped.endTs) + } + val session = if (onsetKeying() && keyed != null) { + mapped.copy(startTs = keyed) } else { mapped } @@ -808,28 +988,82 @@ 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") + } + // The freshest pair this session has adopted (mirrors `d.adoptSyncTimeAnchor` always + // overwriting), kept for `commitResumeCursor`'s continuity check and persisted so the NEXT + // connection can compare against it as its own previous anchor (#2097). + currentSyncAnchorTicks = rt + currentSyncAnchorUnixSeconds = receivedAt + OuraSyncAnchorStore.save(appContext, deviceId, rt, receivedAt) + 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 ring-time (raw ticks vs seconds×10: exactly one must be plausible, or, on a ring under ~5 days + * of clock where both are, exactly one must sit within an hour of the floor). 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). The retry's floor (`drain.maxSeenRingTime`) is fed by records that land AFTER the reply, + * so it may sit a few ticks past it — the candidate rule allows that trail; the old rule did not, and + * adopted ×10 on a young ring (#2239). 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 +1104,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() @@ -897,14 +1132,52 @@ class OuraLiveSource( // is never the intentional-teardown case, so clear the suppression flag. reconnectAddress = address intentionalDisconnect = false + _linkPhase.value = LinkPhase.CONNECTING // every branch below is an attempt to reach the ring val device = seen[address] ?: runCatching { adapter?.getRemoteDevice(address) }.getOrNull() if (device == null) { pendingConnectAddress = address; return } connectToDevice(device) } + /** + * The user asked for the ring to be reconnected, from the Live console (#2305). Until now there was NO + * user-facing ring reconnect anywhere: [connect] is only reached from the coordinator on activation, + * and the only "connect" button a ring user could find on the Live screen was the WHOOP one — which is + * what the #2303 reporter tapped, and which ran a full 5/MG handshake under an active ring. This is + * the ring's own affordance: drop whatever link exists and connect again, through the same + * [connectToDevice] every activation uses (it already tears the prior GATT down first, so no second + * GATT ever runs). Nothing new goes to the ring. Also clears a needs-pairing latch — a user reconnect + * is the documented way out of that dead end — and never scans past a known ring. Kotlin twin of the + * Swift `reconnect()`. + */ + fun reconnect() { + _needsPairing.value = null + intentionalDisconnect = false + failedReconnectAttempts = 0 + handler.removeCallbacks(reconnectRunnable) + handler.removeCallbacks(retry133Runnable) + val device = lastDevice + val address = reconnectAddress + when { + device != null -> { + log("Oura: reconnect requested - dropping the current link, if any, and connecting again") + _linkPhase.value = LinkPhase.CONNECTING + connectToDevice(device) + } + address != null -> { + log("Oura: reconnect requested") + connect(address) + } + else -> { + log("Oura: reconnect requested - no known ring, scanning") + scan() + } + } + } + private fun connectToDevice(device: BluetoothDevice) { lastDevice = device // remembered so a status-133 disconnect can auto-retry the same ring log("Oura: connecting to ${device.address}") + _linkPhase.value = LinkPhase.CONNECTING // Tear down any prior link first so we never run two GATTs for this source. gatt?.let { runCatching { it.disconnect(); it.close() } } // A fresh driver per connection: the app key is session-scoped (the proof handshake re-runs on @@ -916,17 +1189,28 @@ class OuraLiveSource( // actually sends (raw bytes per kind, decoded MET for 0x50) so the layouts can be validated // against real captures. It can never leak a value into scoring: OuraStreamMapping drops // TierB/ActivityInfo unconditionally - the Tier-discipline gate that matters lives there, not here. + // Packed-notification A/B: the mask is decided here, once per session, and named on its own line + // ONLY when it is not the default - the `-> notify_all(ff)` write line then confirms it went out. + val notificationMask = if (notifyMaskFull()) OuraCommands.NOTIFICATION_MASK_FULL else OuraCommands.NOTIFICATION_MASK_DEFAULT + if (notificationMask != OuraCommands.NOTIFICATION_MASK_DEFAULT) { + log("Oura: SetNotification mask %02x this session (packed-notification A/B, Test Centre) - default is %02x" + .format(notificationMask, OuraCommands.NOTIFICATION_MASK_DEFAULT)) + } driver = OuraDriver(ringGen = ringGen, authKey = authKey(), allowTierB = true, - allowKeyInstall = adoptIntent) + allowKeyInstall = adoptIntent, notificationMask = notificationMask) reassembler.reset() pendingInstallKey = null // a new connection starts with no install in flight _adoptPhase.value = AdoptPhase.Idle // a stale outcome must never drive the wizard's transition resetWear() // #628: fresh session — clear any stale worn/charging badge // A fresh session: reset the one-shot streaming/anchor state, and never replay a stale-anchor guess. reachedStreaming = false + clearAuthWatchdog() // a fresh session starts with a clean escalation count + authEscalations = 0 + authToggleCccdPending = 0 loggedFirstTemp = false - loggedFirstSpo2 = false + loggedFirstSpo2.clear() loggedAnchor = false + pendingSyncTime = null loggedTierBKinds.clear() loggedFeatureStatuses.clear() loggedProductInfo.clear() @@ -955,6 +1239,14 @@ class OuraLiveSource( log("Oura: persisted resume cursor $loadedCursor exceeds the plausibility ceiling (pre-fix garbage) - full pull") OuraHistoryCursorStore.save(appContext, deviceId, 0) } + // The anchor from whatever session last adopted one — loaded BEFORE this session gets a chance + // to adopt its own, so `commitResumeCursor` can compare the two (#2097). The current-session + // anchor starts null each session; nothing carries a stale in-memory anchor into a fresh connect. + val loadedAnchor = OuraSyncAnchorStore.read(appContext, deviceId) + previousSyncAnchorTicks = loadedAnchor?.first + previousSyncAnchorUnixSeconds = loadedAnchor?.second + currentSyncAnchorTicks = null + currentSyncAnchorUnixSeconds = null // connectGatt can throw (SecurityException if BLUETOOTH_CONNECT was revoked mid-session, // IllegalArgumentException on a stale device) - never let that crash the app; a failed start // simply leaves the previous source in place (mirrors [StandardHrSource]). @@ -984,6 +1276,7 @@ class OuraLiveSource( handler.removeCallbacks(retry133Runnable) stopScan() pendingConnectAddress = null + _linkPhase.value = LinkPhase.DISCONNECTED cancelReengage() cancelHistoryFetch() handler.removeCallbacks(batchQuietRunnable) @@ -1017,12 +1310,15 @@ class OuraLiveSource( reassembler.reset() loggedFirstHr = false // a later reconnect should log its first sample again loggedFirstTemp = false - loggedFirstSpo2 = false + loggedFirstSpo2.clear() loggedAnchor = false + pendingSyncTime = null loggedTierBKinds.clear() loggedFeatureStatuses.clear() loggedProductInfo.clear() reachedStreaming = false + clearAuthWatchdog() + authToggleCccdPending = 0 // A stop MID-install is an honest failure (no ack will come); a stop after streaming leaves the // completed Streaming outcome intact so the wizard's success transition is not undone. if (_adoptPhase.value == AdoptPhase.InstallingKey) _adoptPhase.value = AdoptPhase.Failed @@ -1099,16 +1395,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, ) @@ -1136,14 +1437,18 @@ class OuraLiveSource( retried133 = false // a real connection clears the one-shot 133 retry guard failedReconnectAttempts = 0 // a real connection clears the reconnect backoff (#912) log("Oura: connected (status=$status) - discovering services") + _linkPhase.value = LinkPhase.AUTHENTICATING // link up; discovery + the nonce handshake follow g.discoverServices() } BluetoothProfile.STATE_DISCONNECTED -> { log("Oura: disconnected (status=$status)") + _linkPhase.value = LinkPhase.DISCONNECTED loggedFirstHr = false // a reconnect should log its first sample again _batteryPct.value = null resetWear() // #628: the wear badge must not survive the link dropping cancelReengage() + clearAuthWatchdog() // a link that drops mid-handshake takes this path, never the escalation + authToggleCccdPending = 0 cancelHistoryFetch() handler.removeCallbacks(batchQuietRunnable) handler.removeCallbacks(chainedDrainRunnable) @@ -1157,8 +1462,9 @@ class OuraLiveSource( dropUnanchoredHypnogramBursts() reassembler.reset() loggedFirstTemp = false - loggedFirstSpo2 = false + loggedFirstSpo2.clear() loggedAnchor = false + pendingSyncTime = null loggedTierBKinds.clear() loggedFeatureStatuses.clear() loggedProductInfo.clear() @@ -1223,6 +1529,27 @@ class OuraLiveSource( status: Int, ) = guardedCallback("descriptor-write") { if (descriptor.uuid != CCCD) return@guardedCallback + // The auth watchdog's off/on toggle (#2304) lands here twice — the disable, then the + // re-enable — and must NOT replay Ready: the driver is still Authenticating, so the only thing + // to do on the re-enable is ask for the nonce again on the freshly re-established + // subscription. The Ready replay below is for the first enable of a session only. + if (authToggleCccdPending > 0) { + authToggleCccdPending -= 1 + if (status != BluetoothGatt.GATT_SUCCESS) { + // Attributable only as "the CCCD write did not succeed"; the running nonce clock takes + // the session to the drop step, which is the honest outcome for a toggle that failed. + log("Oura: WARNING auth toggle CCCD write status=$status - leaving the nonce clock to the drop step") + authToggleCccdPending = 0 + return@guardedCallback + } + if (authToggleCccdPending == 1) { + log("Oura: notifications disabled for the auth toggle - re-enabling in ${AUTH_TOGGLE_GAP_MS / 1000.0}s") + } else { + log("Oura: notifications re-enabled (CCCD write status=$status) after the auth toggle - re-sending get_nonce") + resendAuthNonce() + } + return@guardedCallback + } if (status == BluetoothGatt.GATT_SUCCESS) { log("Oura: notifications enabled (CCCD write status=$status) - beginning auth") // Notifications are live: tell the driver we are Ready. It returns the enable-notify + @@ -1296,6 +1623,9 @@ class OuraLiveSource( val d = driver ?: return@guardedCallback val commands = d.nextStep(transition) for (cmd in commands) write(cmd) + // Ready is the step that writes `get_nonce`; from here the session waits on the ring, and nothing + // else is armed until `auth OK`. Start the clock on that wait (#2304). + if (transition == OuraTransition.Ready && d.phase == OuraDriverPhase.Authenticating) armAuthWatchdog() when (d.phase) { OuraDriverPhase.Streaming -> { // The driver returns to Streaming after EACH history-fetch pass completes, so gate all the @@ -1303,6 +1633,7 @@ class OuraLiveSource( // must run exactly once per connection, not on every history summary. if (!reachedStreaming) { reachedStreaming = true + _linkPhase.value = LinkPhase.AUTHENTICATED // Re-auth after an install (or a normal auth) reached the stream: adoption is complete. // The OK ack already persisted the key; nothing is left in flight. _adoptPhase.value = AdoptPhase.Streaming @@ -1408,6 +1739,7 @@ class OuraLiveSource( // Re-auth with the freshly-installed key. The driver returns enable-notify + get-nonce; the // nonce response then flows through the normal routeSecure -> advance path to streaming. for (cmd in d.keyInstallAcknowledged()) write(cmd) + if (d.phase == OuraDriverPhase.Authenticating) armAuthWatchdog() } else { log("Oura: the ring did not accept the key (status=${status ?: "none"}) - cannot adopt this ring") announceNeedsPairing(KEY_INSTALL_MESSAGE) @@ -1438,13 +1770,14 @@ class OuraLiveSource( val bytes = ByteArray(cmd.bytes.size) { cmd.bytes[it].toByte() } log("Oura: → ${cmd.label}") if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - g.writeCharacteristic(ch, bytes, BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE) + val rc = g.writeCharacteristic(ch, bytes, BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE) + lastWriteAccepted = rc == android.bluetooth.BluetoothStatusCodes.SUCCESS } else { @Suppress("DEPRECATION") run { ch.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE ch.value = bytes - g.writeCharacteristic(ch) + lastWriteAccepted = g.writeCharacteristic(ch) } } } @@ -1475,12 +1808,15 @@ class OuraLiveSource( val ascii = String(CharArray(frame.body.size) { i -> val b = frame.body[i]; if (b in 0x20..0x7e) b.toChar() else '.' }) - log("Oura: product-info reply op=0x%02x (%dB) raw: %s | ascii: %s".format(frame.op, frame.body.size, hex, ascii)) + // #2092: decode BEFORE logging (was after) so the log line can tell a serial page from + // a hardware page - decode itself is unchanged, only reordered. + val str = OuraDecoders.productInfoString(frame.body) + val (safeHex, safeAscii) = logSafeProductInfo(hex, ascii, str) + log("Oura: product-info reply op=0x%02x (%dB) raw: %s | ascii: %s".format(frame.op, frame.body.size, safeHex, safeAscii)) // The two GetProductInfo pages both arrive under op 0x19; tell them apart by content: // • hardware page ("BLB_03") -> resolves a generation -> correct the model (#772). // • serial page ("2H3B2405003655", no "_NN" gen marker) -> the ring's STABLE identity -> // surface it so the app can re-point onto its `oura-` id (#771). - val str = OuraDecoders.productInfoString(frame.body) if (str != null) { val gen = OuraRingGen.fromHardwareId(str) if (gen != null) { @@ -1542,6 +1878,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) @@ -1554,7 +1894,11 @@ class OuraLiveSource( /** Route a 0x2F secure sub-frame to the driver and turn its result into a transition or live events. */ private fun routeSecure(d: OuraDriver, secure: com.noop.oura.OuraSecureFrame) = guardedCallback("secure-route") { when (val routing = d.handleSecureFrame(secure)) { - is OuraDriver.SecureRouting.Nonce -> advance(OuraTransition.NonceReceived(routing.nonce)) + is OuraDriver.SecureRouting.Nonce -> { + clearAuthWatchdog() // the ring answered: the healthy path, and the watchdog's only exit + silentSessionsInARow = 0 + advance(OuraTransition.NonceReceived(routing.nonce)) + } is OuraDriver.SecureRouting.AuthStatus -> { log("Oura: auth status = ${routing.status.name}") advance(OuraTransition.AuthCompleted(routing.status)) @@ -1724,9 +2068,11 @@ class OuraLiveSource( } } is OuraEvent.Spo2 -> { - if (!loggedFirstSpo2) { - loggedFirstSpo2 = true - log("Oura: first SpO2 decoded (last night) - value ${e.value.value} (${e.value.unit})") + if (loggedFirstSpo2.add(e.value.channel)) { + log( + "Oura: " + + OuraSpO2Channel.firstDecodedLogLine(e.value.value, e.value.unit), + ) } enqueueAnchoredOrPark(e, e.value.ringTimestamp, d) } @@ -1809,8 +2155,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") @@ -1963,6 +2311,130 @@ class OuraLiveSource( handler.removeCallbacks(reengageRunnable) } + // MARK: - Auth watchdog (#2304) + + /** + * Start (or restart) the nonce clock: called on every `get_nonce` write of the session — the Ready + * step, the post-install re-auth, and the watchdog's own re-sends — so each wait is measured from the + * request it belongs to. One-shot; [authWatchdogFired] re-posts it after an escalation. Kotlin twin + * of the Swift `armAuthWatchdog`. + */ + private fun armAuthWatchdog() { + handler.removeCallbacks(authWatchdogRunnable) + authNonceRequestedAt = System.currentTimeMillis() + handler.postDelayed(authWatchdogRunnable, OuraAuthWatchdog.NONCE_TIMEOUT_MS) + } + + /** The nonce arrived, or the session ended: nothing left to watch. */ + private fun clearAuthWatchdog() { + handler.removeCallbacks(authWatchdogRunnable) + authNonceRequestedAt = null + } + + /** Ask the driver for the `get_nonce` again (only while it is still Authenticating) and restart the + * clock on it. A driver that has moved on returns null and the watchdog stays cleared. */ + private fun resendAuthNonce() { + val d = driver + val cmd = d?.authNonceRetryCommand() + if (cmd == null) { clearAuthWatchdog(); return } + write(cmd) + armAuthWatchdog() + } + + /** + * The nonce timer expired with no nonce (#2304). Print the one line that says what was not answered + * — always-on, because this is exactly the rare-event evidence a report without Test Centre lacks — + * then take the next bounded step from [OuraAuthWatchdog]: re-send once, toggle the subscription and + * re-send once, then drop the link and let the ordinary reconnect backoff take over. Never + * [announceNeedsPairing]: silence is not an auth verdict, and the "re-pair it in the Oura app" dead + * end is reserved for an explicit non-success status. Kotlin twin of the Swift `authWatchdogFired`. + */ + private fun authWatchdogFired(): Unit = guardedCallback("auth-watchdog") { + val d = driver + val requestedAt = authNonceRequestedAt + val g = gatt + // Only a session still waiting on the nonce has anything to escalate. A driver that moved on (the + // nonce landed between the post and this running, a stop, a disconnect) is left alone. + if (d == null || d.phase != OuraDriverPhase.Authenticating || requestedAt == null || g == null) { + clearAuthWatchdog() + return@guardedCallback + } + val elapsedMs = System.currentTimeMillis() - requestedAt + val silent = "Oura: no auth nonce ${elapsedMs / 1000}s after get_nonce - ring silent on the notify channel " + + "(last write accepted by the stack=$lastWriteAccepted)" + when (OuraAuthWatchdog.step(elapsedMs, authEscalations)) { + OuraAuthWatchdog.Step.WAIT -> { + // Fired early (clock adjustment / re-arm race): wait out the remainder, no escalation. + val remaining = (OuraAuthWatchdog.NONCE_TIMEOUT_MS - elapsedMs).coerceAtLeast(500L) + handler.postDelayed(authWatchdogRunnable, remaining) + } + OuraAuthWatchdog.Step.RESEND_NONCE -> { + authEscalations = 1 + log("$silent - re-sending get_nonce (1/3)") + resendAuthNonce() + } + OuraAuthWatchdog.Step.TOGGLE_NOTIFY -> { + authEscalations = 2 + val notify = notifyChar + val cccd = notify?.getDescriptor(CCCD) + if (notify == null || cccd == null) { + // No subscription handle to toggle: nothing to re-establish, so go straight to the last + // step rather than pretend a toggle happened. + log("$silent - no notify characteristic to toggle, dropping the link (3/3)") + authEscalations = 3 + clearAuthWatchdog() + g.disconnect() + return@guardedCallback + } + log("$silent - toggling the notify subscription and re-sending get_nonce (2/3)") + authToggleCccdPending = 2 + writeCccd(g, notify, cccd, enabled = false) + // The re-enable's callback ([onDescriptorWrite]) re-sends `get_nonce` and re-arms the clock; + // until then the clock keeps running from THIS moment so a toggle the ring never acknowledges + // still reaches the drop step instead of parking the session again. + armAuthWatchdog() + handler.postDelayed({ + val g2 = gatt ?: return@postDelayed + if (authToggleCccdPending != 1) return@postDelayed // disconnected, or the disable never acked + writeCccd(g2, notify, cccd, enabled = true) + }, AUTH_TOGGLE_GAP_MS) + } + OuraAuthWatchdog.Step.DROP_LINK -> { + authEscalations = 3 + silentSessionsInARow += 1 + log("$silent - dropping the link; the ordinary reconnect takes over (3/3, silent session " + + "$silentSessionsInARow in a row)") + clearAuthWatchdog() + authToggleCccdPending = 0 + // NOT an intentional teardown: `intentionalDisconnect` stays false and `reconnectAddress` + // stays set, so onConnectionStateChange(DISCONNECTED) schedules the normal backoff (#912). + g.disconnect() + } + } + } + + /** One CCCD write for the watchdog's toggle — the same enable path [setUpNotifications] takes, plus + * its disable twin, across API levels. */ + private fun writeCccd( + g: BluetoothGatt, + notify: BluetoothGattCharacteristic, + cccd: BluetoothGattDescriptor, + enabled: Boolean, + ) = guardedCallback("auth-toggle") { + g.setCharacteristicNotification(notify, enabled) + val value = if (enabled) BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + else BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + g.writeDescriptor(cccd, value) + } else { + @Suppress("DEPRECATION") + run { + cccd.value = value + g.writeDescriptor(cccd) + } + } + } + // MARK: - Honest fallback /** @@ -2009,6 +2481,10 @@ class OuraLiveSource( val WRITE_UUID: UUID = UUID.fromString(OuraGatt.writeCharacteristicUUID) val NOTIFY_UUID: UUID = UUID.fromString(OuraGatt.notifyCharacteristicUUID) + /** Settle time between disabling and re-enabling the notify subscription on the watchdog's + * TOGGLE_NOTIFY step — open_ring's value for the same CCCD round-trip. Swift: `authToggleGap`. */ + const val AUTH_TOGGLE_GAP_MS = 2_500L + /** The standard client-characteristic-configuration descriptor (0x2902). */ private val CCCD: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") @@ -2069,6 +2545,18 @@ class OuraLiveSource( * Twin of Swift's `isPlausibleSerial`. */ private fun isPlausibleSerial(s: String): Boolean = s.length in 8..24 && s.all { it.isLetterOrDigit() } + + /** What the product-info reply log line (below) may show (#2092): a serial page identifies the + * ring's owner, so only its SHAPE is logged, via [com.noop.data.OuraSerialIdentity.logSafe] - + * the same 3-character prefix [com.noop.data.WhoopSerialIdentity.logSafe] uses for a WHOOP + * serial (#1303). A hardware-generation page ("BLB_03", never [isPlausibleSerial]) identifies no + * one and is still logged in full; either way a masked value still confirms the decode happened, + * which is all this line exists to do. Masks BOTH halves - logging a masked ascii beside the + * unmasked hex would still leak the serial encoded. Twin of Swift's `logSafeProductInfo`. */ + internal fun logSafeProductInfo(hex: String, ascii: String, decoded: String?): Pair { + if (decoded == null || !isPlausibleSerial(decoded)) return hex to ascii + return "" to com.noop.data.OuraSerialIdentity.logSafe(decoded) + } private const val SET_AUTH_KEY_OK = 0x00 /** Generate a fresh cryptographically-random 16-byte install key as unsigned bytes 0..255 @@ -2130,3 +2618,46 @@ object OuraHistoryCursorStore { runCatching { prefs(ctx).edit().putLong(prefKey(deviceId), cursor and 0xFFFF_FFFFL).apply() } } } + +// MARK: - Oura SyncTime anchor persistence (#2097) + +/** + * Persists the Oura ring's `0x13 SyncTime` (ring-ticks, wall-clock) anchor pair across connects, so a + * later connection can check whether the ring's own clock ran continuously since the last one — + * distinguishing a genuine power-cycle from a second BLE client (e.g. the Oura app) having served the + * ring in between, which otherwise looks identical to [OuraHistoryDrain.sawPreResumeData] + * ([OuraHistoryDrain.anchorsAreContinuous] does the actual comparison; this object only persists the + * inputs). Kotlin twin of Swift's `OuraSyncAnchorStore`. Not sensitive — an opaque clock pairing, not a + * credential — so plain [SharedPreferences], same reasoning as [OuraHistoryCursorStore]. + */ +object OuraSyncAnchorStore { + private const val FILE_NAME = "noop_oura_sync_anchor" + private const val TICKS_KEY_PREFIX = "sync_anchor_ticks_" + private const val SECONDS_KEY_PREFIX = "sync_anchor_seconds_" + + private fun prefs(ctx: Context): SharedPreferences = + ctx.applicationContext.getSharedPreferences(FILE_NAME, Context.MODE_PRIVATE) + + /** The persisted anchor for [deviceId] as (ringTicks, unixSeconds), or null if none is stored yet + * (a ring never anchored before, or an install that predates this feature). */ + fun read(ctx: Context, deviceId: String): Pair? = runCatching { + val p = prefs(ctx) + val ticksKey = "$TICKS_KEY_PREFIX$deviceId" + val secondsKey = "$SECONDS_KEY_PREFIX$deviceId" + if (!p.contains(ticksKey) || !p.contains(secondsKey)) { + null + } else { + p.getLong(ticksKey, 0L) to p.getLong(secondsKey, 0L) + } + }.getOrNull() + + /** Store the freshest anchor for [deviceId]. */ + fun save(ctx: Context, deviceId: String, ringTicks: Long, unixSeconds: Long) { + runCatching { + prefs(ctx).edit() + .putLong("$TICKS_KEY_PREFIX$deviceId", ringTicks and 0xFFFF_FFFFL) + .putLong("$SECONDS_KEY_PREFIX$deviceId", unixSeconds) + .apply() + } + } +} diff --git a/android/app/src/main/java/com/noop/ble/PendingSyncDiagnostic.kt b/android/app/src/main/java/com/noop/ble/PendingSyncDiagnostic.kt new file mode 100644 index 0000000000..ce77b5a2ce --- /dev/null +++ b/android/app/src/main/java/com/noop/ble/PendingSyncDiagnostic.kt @@ -0,0 +1,63 @@ +package com.noop.ble + +/** + * Why the Rest card's "Pending sync" state is on or off (#2012). + * + * That state is `backfilling || historyPendingSync`, and only the first half leaves a trace: an offload + * announces itself with "Backfill: session started" and a run of bursts. The second half flipped in + * silence, so a report of the note showing hours after waking could not be answered from a strap log at + * all. #2012 is exactly that: a log arrived, and it could only be read by inferring from what else was + * happening at the timestamp, which settles nothing. + * + * So the line names the DECIDING input rather than just the verdict. Four things can decide it, and they + * mean different bugs: a future-dated strap clock, a phantom gap that advertises records it never banks, + * being genuinely caught up, or being genuinely behind. Reading "behind by 34210s" in the afternoon is + * the evidence that the trigger is not scoped to the night being scored, which is the open half of #2012. + * + * Pure so it is unit-tested directly; byte-identical to the Swift twin. + */ +object PendingSyncDiagnostic { + + /** Where the flag was recomputed. The two sites weigh different evidence, so the line says which. */ + const val SITE_CONNECT = "connect" + const val SITE_POST_OFFLOAD = "post-offload" + + /** + * One line for a CHANGE of the flag; callers log it only on a flip, never per evaluation. + * + * @param persistedRows null at [SITE_CONNECT], which cannot weigh it: no offload has run, so there is + * no row evidence yet. Naming that rather than printing a misleading "no". + */ + fun line( + pending: Boolean, + site: String, + newestUnix: Long?, + frontierUnix: Long?, + futureDated: Boolean, + persistedRows: Boolean?, + thresholdSec: Long, + ): String { + // NULLABLE deliberately. The flag flips to false when either input is missing, so requiring them + // here would have left that flip silent, which is the exact hole this whole line exists to close. + // It is reachable: an unanswered GET_DATA_RANGE ("requesting history anyway (fail-open)") leaves + // no newest to compare, and a first-ever session leaves no frontier. + if (newestUnix == null || frontierUnix == null) { + return "pending-sync ${if (pending) "ON" else "OFF"} ($site): no range to compare " + + "[newest=${newestUnix ?: "unknown"} frontier=${frontierUnix ?: "unknown"}]" + } + val gap = newestUnix - frontierUnix + val why = when { + futureDated -> "strap clock reads ahead of now, so the gap could never close (#928/#1012)" + persistedRows == false -> "strap advertises newer records but banked no rows — phantom gap (#1144)" + gap > thresholdSec -> "strap is ${gap}s ahead of our frontier, over the ${thresholdSec}s threshold" + else -> "caught up — ${gap}s gap, within the ${thresholdSec}s threshold" + } + val rows = when (persistedRows) { + null -> "n/a at connect" + true -> "yes" + false -> "no" + } + return "pending-sync ${if (pending) "ON" else "OFF"} ($site): $why " + + "[newest=$newestUnix frontier=$frontierUnix gap=${gap}s futureDated=$futureDated rowsBanked=$rows]" + } +} 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 528bb4d2e7..bdfd14e36f 100644 --- a/android/app/src/main/java/com/noop/ble/PuffinExperiment.kt +++ b/android/app/src/main/java/com/noop/ble/PuffinExperiment.kt @@ -16,7 +16,13 @@ import android.content.SharedPreferences * The macOS app stored this in `UserDefaults` under the key `noopPuffinExperiments`; the Android * equivalent is [SharedPreferences]. The same key name is reused for parity. */ -class PuffinExperiment(private val prefs: SharedPreferences) { +class PuffinExperiment( + private val prefs: SharedPreferences, + /** NoopPrefs, where the per-strap refusal latch lives. Required, not defaulted: a caller that omitted + * it would silently re-arm into the very state #2135 is about, and [from] is the only construction + * site there is, so nothing is served by making it skippable. */ + private val noopPrefs: SharedPreferences, +) { /** True if the user opted in to the WHOOP 5/MG protocol probes (default false). */ var isEnabled: Boolean @@ -38,9 +44,9 @@ class PuffinExperiment(private val prefs: SharedPreferences) { get() = prefs.getBoolean(KEY_DEEP_DATA, false) set(v) = prefs.edit().putBoolean(KEY_DEEP_DATA, v).apply() - /** True if the user opted in to "Broadcast heart rate": NOOP writes the device-config flag - * whoop_live_hr_in_adv_ind_pkt="1" so the strap advertises the standard Heart Rate Service - * (0x180D) + its live HR, pairable by a Garmin/Zwift/gym HR client. Reversible. Default false. + /** True if the user opted in to "Broadcast heart rate": NOOP enables the family's reversible + * direct-broadcast control so the strap advertises the standard Heart Rate Service (0x180D), + * pairable by a Garmin/Zwift/gym HR client. Default false. * Mirrors the macOS `PuffinExperiment.broadcastHrKey`. (#181) */ var broadcastHr: Boolean get() = prefs.getBoolean(KEY_BROADCAST_HR, false) @@ -112,13 +118,149 @@ class PuffinExperiment(private val prefs: SharedPreferences) { set(v) = prefs.edit().putBoolean(KEY_MOTION_AWARE_WAKE, 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. + * Send the CLIENT_HELLO even when the suppression latch says not to (default false, #1635). * - * 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 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) } + // #2135: and the refusal latch, the one retirement reason a sweep of THIS file cannot + // reach. Same prefix rule, same edge, other file, because it is written at connect time + // with a device in hand and so cannot live here. + val ne = noopPrefs.edit() + noopPrefs.all.keys + .filter { it.startsWith(UNBONDED_OFFLOAD_REFUSED_KEY_PREFIX) } + .forEach { ne.remove(it) } + ne.apply() + } + 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. + * + * The refusal latch could not move here, being written at connect time with a device in hand, and it + * is exactly the "retired forever, silently" case this paragraph warns about: #2135. The setter is + * now handed `NoopPrefs` as well and sweeps the latch by its own prefix, so both files are reachable + * from the one place the intent is unambiguous. + * + * 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. + * Its own switch, like every other probe that changes state outside the app: this one asks the OS to + * form a PERSISTENT pairing and can surface a system pairing dialog. Android-only; CoreBluetooth has + * no equivalent explicit API, which is likely why the implicit route was chosen originally. */ + var explicitBond: Boolean + get() = prefs.getBoolean(KEY_EXPLICIT_BOND, false) + set(v) = prefs.edit().putBoolean(KEY_EXPLICIT_BOND, v).apply() + + /** + * 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 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 @@ -130,6 +272,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" @@ -150,10 +301,25 @@ class PuffinExperiment(private val prefs: SharedPreferences) { * `PuffinExperiment.ecgRawDataKey`). (#891) */ const val KEY_ECG_RAW_DATA = "noopEcgRawDataGate" + /** "Ask Android to pair" opt-in — the explicit `createBond()` experiment (#1635). Android-only, + * 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) + 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" @@ -168,6 +334,9 @@ class PuffinExperiment(private val prefs: SharedPreferences) { const val KEY_MOTION_AWARE_WAKE = "noopMotionAwareWake" fun from(context: Context): PuffinExperiment = - PuffinExperiment(context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)) + PuffinExperiment( + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE), + context.getSharedPreferences(com.noop.ui.NoopPrefs.NAME, Context.MODE_PRIVATE), + ) } } diff --git a/android/app/src/main/java/com/noop/ble/RawHistoryArchive.kt b/android/app/src/main/java/com/noop/ble/RawHistoryArchive.kt index 990c70d333..8b2d40f7de 100644 --- a/android/app/src/main/java/com/noop/ble/RawHistoryArchive.kt +++ b/android/app/src/main/java/com/noop/ble/RawHistoryArchive.kt @@ -4,6 +4,7 @@ import android.content.Context import com.noop.data.WhoopRepository import com.noop.protocol.DeviceFamily import com.noop.protocol.extractHistoricalStreams +import com.noop.protocol.toHexLower import java.io.File import java.io.FileOutputStream @@ -115,7 +116,9 @@ class RawHistoryArchive( private fun familyTag(family: DeviceFamily): String = if (family == DeviceFamily.WHOOP5) "whoop5" else "whoop4" - private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } + // Shared fast encoder: the archive writes one line per rejected frame, which on an unmapped-layout + // strap is every frame of every chunk. + private fun ByteArray.toHex(): String = toHexLower() /** * Every archived frame with its strap family, oldest first — the read-back of the JSONL that 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..6da2a18d45 100644 --- a/android/app/src/main/java/com/noop/ble/SourceCoordinator.kt +++ b/android/app/src/main/java/com/noop/ble/SourceCoordinator.kt @@ -146,6 +146,18 @@ class SourceCoordinator( private val _ouraWearState = MutableStateFlow(null) val ouraWearState: StateFlow = _ouraWearState.asStateFlow() + /** The RING's own charge while a live Oura source is up, for the Live Console's battery read (#2075). + * Separate from `LiveState.batteryPct`, which is the WHOOP's: one shared LiveState means a bonded + * strap leaves its charge sitting there, and a console that read it while a ring was active + * reported the wrong band's battery under the right band's name. Mirrors [ouraWearState]. */ + private val _ouraBatteryPct = MutableStateFlow(null) + val ouraBatteryPct: StateFlow = _ouraBatteryPct.asStateFlow() + + /** The live ring's link phase for the Live console's ring status + reconnect affordance (#2305). + * [OuraLiveSource.LinkPhase.DISCONNECTED] when no ring source is live. Mirrors [ouraBatteryPct]. */ + private val _ouraLinkPhase = MutableStateFlow(OuraLiveSource.LinkPhase.DISCONNECTED) + val ouraLinkPhase: StateFlow = _ouraLinkPhase.asStateFlow() + /** Collects the active Oura source's adoptPhase / needsPairing into the mirrors above; cancelled and * nulled on teardown so a forgotten ring never leaks a stale outcome. */ private var ouraStateJob: kotlinx.coroutines.Job? = null @@ -438,8 +450,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 @@ -452,6 +467,13 @@ class SourceCoordinator( // #polar-debug: read the toggle live at connect so a Polar strap logs its identified // model (default off; the Test Centre only exposes the toggle when a Polar strap is paired). polarDebug = { NoopPrefs.polarDebugLogging(ctx) }, + // The per-sample host-received readout belongs to the modes that exist for it; without one, + // the log carries the summary instead (twin of BLEManager's wiring). + hostReceivedDetail = { + val tc = com.noop.testcentre.TestCentre.from(ctx) + tc.active(com.noop.testcentre.TestDomain.HRV) || + tc.active(com.noop.testcentre.TestDomain.CONNECTION) + }, ) } } @@ -531,7 +553,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 +566,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 @@ -565,6 +587,7 @@ class SourceCoordinator( } }, onsetKeying = { NoopPrefs.ouraOnsetKeying(ctx) }, // #1284 residual 3 + notifyMaskFull = { NoopPrefs.ouraNotifyMaskFull(ctx) }, // packed-notification A/B log = straplog, // Oura connect/auth/stream lifecycle → the SAME exported strap log (#421) onBattery = batterySink, // ring battery → the same live state the WHOOP strap battery uses onModel = { model -> scope.launch { runCatching { registry.setModel(id, model) } } }, // #772: correct a name-guessed gen @@ -587,10 +610,22 @@ class SourceCoordinator( launch { source.adoptPhase.collect { _ouraAdoptPhase.value = it } } launch { source.needsPairing.collect { _ouraNeedsPairing.value = it } } launch { source.ouraWearState.collect { _ouraWearState.value = it } } + launch { source.batteryPct.collect { _ouraBatteryPct.value = it } } // #2075 + launch { source.linkPhase.collect { _ouraLinkPhase.value = it } } // #2305 } return source } + /** + * The user asked the Live console to reconnect the active ring (#2305). Forwarded to the live Oura + * source's own [OuraLiveSource.reconnect]; a no-op when no ring is the live source, so the console can + * only ever reconnect the device it is showing — never a WHOOP, never a ring that is not active. + * Twin of the Swift `reconnectActiveRing`. + */ + fun reconnectActiveRing() { + (activeSource as? OuraLiveSource)?.reconnect() + } + /** * #771 (twin of Swift's `SourceCoordinator.adoptOuraSerial`): the live Oura source read the ring's stable * SERIAL on connect. Re-point this device from its transient address-based id ([currentId]) onto its @@ -625,6 +660,8 @@ class SourceCoordinator( _ouraAdoptPhase.value = OuraLiveSource.AdoptPhase.Idle _ouraNeedsPairing.value = null _ouraWearState.value = null // #628: no live Oura source -> no wear badge + _ouraBatteryPct.value = null // #2075: nor a stale ring charge + _ouraLinkPhase.value = OuraLiveSource.LinkPhase.DISCONNECTED // #2305 // A stale speed/cadence/power readout must not outlive the strap session (the source's own stop() // already pushes an empty SensorMetrics, but reset here too so leaving for WHOOP / FTMS / Huami — // none of which feed this flow — is clean and immediate). @@ -644,9 +681,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..2c46fae791 --- /dev/null +++ b/android/app/src/main/java/com/noop/ble/StalledLinkDiagnostics.kt @@ -0,0 +1,304 @@ +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 + // Reaching this branch PROVES the switch is off: the opted-in case above short-circuits to + // an empty hint. So "turn it off and on", which reads as advice to someone whose switch is + // on, is the one thing every reader of this sentence cannot do. Turning it ON is the whole + // action, and it is the off-to-ON edge `unbondedProbeBudgetRearms` looks for (#2135). + unbondedProbeRetired -> " The \"Try history sync without pairing\" experiment has retired" + + " for this strap and its switch is off, so turning it ON in Test Centre is what re-arms" + + " it (#1804 fixed a false negative that could have latched it, and #2135 made the" + + " re-arm clear that latch too)." + 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" +} + +/** + * #2406: how long a PASSIVE reconnect waited before the OS answered. + * + * When the direct attempts are exhausted the client hands the strap to Android with + * `autoConnect = true` and stops doing anything: no scan, no timer, no line. In one field log that was + * 26 minutes of silence between "reconnecting passively in 12s (attempt 3)" and the next "Connected", + * and a strap log cannot tell that apart from the app having given up. Both look like nothing. + * + * Written when the wait ENDS rather than while it runs, which is the whole point: a heartbeat during a + * reconnect is a timer firing on a phone that is doing nothing else, and the question it answers is a + * question about the past, which one line at the end answers just as well. + * + * `attempts` is the involuntary-reconnect count the backoff had reached, so a long wait can be read + * against how hard the link had already been trying. + */ +internal fun passiveReconnectAnsweredLine(waitedSeconds: Long, attempts: Int): String = + "Reconnect: a passive reconnect was outstanding for ${waitedSeconds}s before the link came up" + + " (attempt $attempts, autoConnect: no scan, no timer, nothing logged while it waits)" + +/** + * 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..540cda2ff2 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). * @@ -15,12 +17,14 @@ package com.noop.ble * - flags bit4 (0x10): one or more R-R intervals follow, each a u16 in 1/1024-second units * * R-R is converted to milliseconds as `round(raw / 1024 * 1000)` to match the Swift parser exactly - * (the WHOOP store keeps R-R in ms). + * (the WHOOP store keeps R-R in ms). `rrRawTicks` carries the unconverted u16 values; WHOOP 5 + * (firmware 50.41.1.0) sends milliseconds directly (non-compliant with BLE spec), so callers that + * know they have a WHOOP 5 should use `rrRawTicks` instead of `rr`. */ 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) + /** The parsed reading: heart rate (bpm), R-R intervals (ms), raw R-R ticks (u16), and contact state. */ + data class Reading(val hr: Int, val rr: List, val rrRawTicks: List, val contact: StandardHrContact) /** * Parse one 0x2A37 notification payload. Returns null on an empty or truncated packet (a packet @@ -29,6 +33,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 @@ -45,13 +50,15 @@ object StandardHeartRate { if (flags and 0x08 != 0) idx += 2 // skip Energy Expended (bit 3) val rr = ArrayList() + val rrRawTicks = ArrayList() if ((flags shr 4) and 0x01 != 0) { // R-R present (bit 4) while (idx + 1 < data.size) { val raw = (data[idx].toInt() and 0xFF) or ((data[idx + 1].toInt() and 0xFF) shl 8) + rrRawTicks.add(raw) rr.add(Math.round(raw / 1024.0 * 1000.0).toInt()) // 1/1024 s → ms (rounded) idx += 2 } } - return Reading(hr, rr) + return Reading(hr, rr, rrRawTicks, contact) } } diff --git a/android/app/src/main/java/com/noop/ble/StandardHrHostReceivedTrace.kt b/android/app/src/main/java/com/noop/ble/StandardHrHostReceivedTrace.kt new file mode 100644 index 0000000000..d86d9a63dc --- /dev/null +++ b/android/app/src/main/java/com/noop/ble/StandardHrHostReceivedTrace.kt @@ -0,0 +1,141 @@ +package com.noop.ble + +/** + * The host-received line, summarised: what a strap log carries about this transport when no Test Centre mode is on. + * + * WHY. [standardHrHostReceivedLine] is written for EVERY standard-HR sample, which a streaming strap produces once + * a second. In one 75-minute gym session on 22 Sep 2026 that was 3,009 of the log's 5,704 lines — 52.8% of + * everything the log had to say about that session — and a 5/MG export in the #2386 review had the same shape, + * half its transport lines crowding out the rest. Since #2386 the log is kept on disk within 2 MB, so this ratio + * now decides how much history a bug report carries: about three hours of a streaming strap, where a summarised + * stream carries a day. + * + * What the line exists for is kept. A sample the host REFUSED — an HR outside 30..220, an R-R outside 250..3000 ms + * — is still written the moment it happens: that is the rare event, and it costs nothing while nothing is wrong. + * The routine ones are counted and rendered once a window as a line that says how many arrived, over how long, the + * widest gap between two of them (a stall a reader used to have to find by eye), what was accepted and refused, + * and what is still pending. Full per-sample detail comes back while the Test Centre's HRV or Connection mode is + * on, which is what those modes are for: gate the per-sample readout behind the domain, leave the rare-event + * evidence always on (`AGENTS.md`). + * + * Twin of Swift `LivePersistTrace.StandardHRHostReceivedTrace` (Packages/StrandAnalytics), line for line: same + * window, same rules, same rendered text. Not thread-safe by itself; [StandardHrSource] records under its buffer + * lock, as it already builds the per-sample line there. + */ +class StandardHrHostReceivedTrace { + + /** One host-received observation: the same fields the per-sample line renders. */ + data class Sample( + val hostUnixSeconds: Int, + val acceptedHrRows: Int, + val acceptedRrRows: Int, + val rejectedHrRows: Int, + val rejectedRrRows: Int, + val pendingHrRows: Int, + val pendingRrRows: Int, + ) + + private var firstSecond: Int? = null + private var lastSecond = 0 + private var samples = 0 + private var acceptedHr = 0 + private var acceptedRr = 0 + private var rejectedHr = 0 + private var rejectedRr = 0 + private var pendingHr = 0 + private var pendingRr = 0 + private var widestGap = 0 + + /** + * The lines to write for this sample, in the order they should appear: a finished window's summary first, + * because it describes the samples before this one. + * + * Twin of Swift `StandardHRHostReceivedTrace.record`. + */ + fun record(sample: Sample, detailed: Boolean): List { + val lines = ArrayList(2) + val first = firstSecond + if (first != null && + // 60 s: a window long enough to turn a streaming hour into 60 lines, short enough that a stall + // still shows in one of them. The Swift twin uses the same literal. + (sample.hostUnixSeconds - first >= 60 || sample.hostUnixSeconds < first) + ) { + // A clock that went backwards ends the window too: a span is only honest within one clock. + summaryLine()?.let { lines.add(it) } + val previousLast = lastSecond + val forwards = sample.hostUnixSeconds >= previousLast + reset() + // #2405: the gap that CROSSED this boundary is the one worth reporting, and it used to be the + // one gap that could not be. A stall of a minute or more forces this roll on the next sample, + // so it fell between two windows and appeared in neither, leaving gapMaxSec able to describe + // only stalls shorter than the window — the opposite of what it is read for. + // + // Seeded into the NEW window rather than added to the summary just emitted: that summary + // describes the samples BEFORE the gap, and this window is the one the gap opens. Not seeded + // across a backwards clock (no honest span) nor across close(), where a disconnect or a + // termination already accounts for the silence. Twin of the Swift `record`. + if (forwards) widestGap = sample.hostUnixSeconds - previousLast + } + if (firstSecond == null) { + firstSecond = sample.hostUnixSeconds + } else { + widestGap = maxOf(widestGap, sample.hostUnixSeconds - lastSecond) + } + lastSecond = sample.hostUnixSeconds + samples += 1 + acceptedHr += sample.acceptedHrRows + acceptedRr += sample.acceptedRrRows + rejectedHr += sample.rejectedHrRows + rejectedRr += sample.rejectedRrRows + pendingHr = sample.pendingHrRows + pendingRr = sample.pendingRrRows + if (detailed || sample.rejectedHrRows > 0 || sample.rejectedRrRows > 0) { + lines.add( + standardHrHostReceivedLine( + hostUnixSeconds = sample.hostUnixSeconds, + acceptedHrRows = sample.acceptedHrRows, acceptedRrRows = sample.acceptedRrRows, + rejectedHrRows = sample.rejectedHrRows, rejectedRrRows = sample.rejectedRrRows, + pendingHrRows = sample.pendingHrRows, pendingRrRows = sample.pendingRrRows, + ) + ) + } + return lines + } + + /** + * The window so far, for a disconnect, a background flush or a termination: the last minute of a session is + * exactly the part a report is taken for, and it must not leave with the process. + * + * Twin of Swift `StandardHRHostReceivedTrace.close`. + */ + fun close(): List { + val summary = summaryLine() ?: return emptyList() + reset() + return listOf(summary) + } + + /** Twin of Swift `StandardHRHostReceivedTrace.summaryLine`. */ + private fun summaryLine(): String? { + val first = firstSecond + if (first == null || samples == 0) return null + return "standard-hr transport host-received summary" + + " windowSec=${lastSecond - first} samples=$samples gapMaxSec=$widestGap" + + " acceptedHRRows=$acceptedHr acceptedRRRows=$acceptedRr" + + " rejectedHRRows=$rejectedHr rejectedRRRows=$rejectedRr" + + " pendingHRRows=$pendingHr pendingRRRows=$pendingRr" + } + + /** Twin of Swift `StandardHRHostReceivedTrace.reset`. */ + private fun reset() { + firstSecond = null + lastSecond = 0 + samples = 0 + acceptedHr = 0 + acceptedRr = 0 + rejectedHr = 0 + rejectedRr = 0 + pendingHr = 0 + pendingRr = 0 + widestGap = 0 + } +} 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..3aaf49feaa 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. @@ -79,6 +98,12 @@ class StandardHrSource( * the Test Centre "Polar debug logging" toggle (only shown when a Polar strap is paired). Diagnostic- * only — nothing gates behaviour on it. Default off keeps existing call sites / tests silent. */ private val polarDebug: () -> Boolean = { false }, + /** + * Whether the per-sample host-received readout is wanted. The app wires this to the Test Centre's HRV and + * Connection modes, the modes that exist for it; with none on, the log carries [StandardHrHostReceivedTrace]'s + * summary instead. Twin of Swift `Collector.hostReceivedDetail`. + */ + private val hostReceivedDetail: () -> Boolean = { false }, ) : LiveHrSource { /** Live instantaneous fitness-sensor metrics surfaced via [sensorSink]. Any field is null when it @@ -148,10 +173,26 @@ 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() + /** The host-received lines, summarised — see [StandardHrHostReceivedTrace]. Recorded under [bufferLock], + * where the per-sample line was already built. */ + private val hostReceived = StandardHrHostReceivedTrace() + /** 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 +270,9 @@ 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) + // The stream is ending: write the window so far, or the last minute of it leaves with the link. + synchronized(bufferLock) { hostReceived.close() }.forEach { log(it) } clearSensorState() // a stale speed/cadence/power panel must not outlive the link } @@ -243,31 +286,111 @@ 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 hostLines: List + 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) + // One line a minute, not one a second: a refusal is written at once, the routine ones are + // counted, and the full per-sample readout comes back while a Test Centre mode is on + // ([hostReceivedDetail]). Twin of Collector.ingestStandardHR. + hostLines = hostReceived.record( + StandardHrHostReceivedTrace.Sample( + hostUnixSeconds = ts.toInt(), + acceptedHrRows = acceptedHr, acceptedRrRows = acceptedRr, + rejectedHrRows = 1 - acceptedHr, rejectedRrRows = rr.size - acceptedRr, + pendingHrRows = pendingHr.size, pendingRrRows = pendingRr.size, + ), + detailed = hostReceivedDetail(), + ) + shouldFlush = buffer.size >= flushCount || System.currentTimeMillis() - lastFlushMs >= flushIntervalMs } + hostLines.forEach { log(it) } 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 +403,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 +447,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 +652,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..91eba05a41 --- /dev/null +++ b/android/app/src/main/java/com/noop/ble/UnbondedOffloadProbe.kt @@ -0,0 +1,632 @@ +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, + /** No default: it had one, and BOTH consumers in WhoopBleClient silently took it, so a strap + * retired only by the inconclusive budget (#1804's case, where every link is a local teardown) + * reported NOT retired. The handshake stayed suppressed for a probe that would never run, which is + * the exact harm `unbondedProbeSupersedesHandshake.probeRetired` exists to prevent. Required, so + * the next consumer cannot omit it in silence. */ + inconclusiveLinksSoFar: Int, +): 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)" + +/** Prefix of every persisted refusal latch, so opting back in can clear them all without knowing which + * straps carry one (#2135). Same reason [UNBONDED_PROBE_SILENT_LINKS_KEY_PREFIX] is a constant, and the + * key below is built FROM it so the writer and the sweeper cannot drift apart. */ +internal const val UNBONDED_OFFLOAD_REFUSED_KEY_PREFIX = "noop.unbondedOffloadRefused." + +/** 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 { UNBONDED_OFFLOAD_REFUSED_KEY_PREFIX + 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 f4c1cf8285..3d1084cd6a 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,15 @@ 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.protocol.toHexLower 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 +97,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 +167,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 +231,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 +252,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 @@ -222,6 +288,15 @@ data class LiveState( * once empty offloads are SUSTAINED; cleared on connect or once the strap banks real records. Twin of * macOS LiveState.historySyncExperimental. */ val historySyncExperimental: Boolean = false, + /** #689/#815: the strap's ring-buffer page backlog, sampled ONCE from the connect-time + * GET_DATA_RANGE reply and never re-polled mid-offload — the link is already firmware-paced, and + * #377 rules out re-polling for a readout. So this is a figure AT CONNECT, not a live one, and the + * Today sync chip's copy says so rather than letting a static number read as a stalled live one. + * A bounded ring measure (write pointer − read pointer against the ring size), never a percentage: + * the strap never reveals a total record count. Confirmed against real captures on WHOOP 4.0 and + * 5.0/MG. null before the first reply this session, or when the frame did not decode. Twin of + * macOS LiveState.pagesBehindAtConnect. */ + val pagesBehindAtConnect: Int? = null, /** #612: TRUE when the WHOOP-4/generic empty-offload streak ([emptySyncTracker]) is currently * SUSTAINED (3+ consecutive completed-but-empty offloads). Not 5/MG-specific and not coupled to HR: * a connected strap that keeps handing over nothing has this true regardless of live-HR status. @@ -392,6 +467,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 +498,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 +510,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 +527,65 @@ 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" + + /** #2384: the `HR notify:` line, so a strap log says whether the standard 0x2A37 profile is + * delivering anything at all, and whether what it delivered was usable. + * + * iOS has printed this line since #14, the day after its standard-profile parser landed; Android + * never did, and the difference is not cosmetic. A reporter's 5/MG banked `live hr=0 rr=0` on ten consecutive links + * while the historical offload ran perfectly, and their Android log could not distinguish the two + * explanations: the strap never notified on 0x2A37, or it notified with a value + * [parseStandardHr] drops on its 30..220 plausibility check without a word. Those call for + * opposite fixes, and the epitaph's link-wide frame tally cannot tell them apart either. + * + * ` ignored` marks the second case, and is the SAME range the value gate uses: if this line says + * ignored, the reading did not reach the UI or the store, and if the line is absent entirely the + * characteristic is silent. Twin of the Swift `BLEManager.standardHrNotifyLine`, so one strap log + * per platform describing one stream reads identically. */ + internal fun standardHrNotifyLine(hr: Int, rrCount: Int): String { + val plausibility = if (hr in 30..220) "" else " ignored" + return "HR notify: $hr bpm$plausibility, rr=$rrCount" + } + + /** Rate-limit for [standardHrNotifyLine]: the profile runs at about 1 Hz, so an unconditional + * line would bury the rest of the capture. One every 30s answers what it exists to answer, which + * is whether the stream is alive and what shape its readings are, not every beat. + * + * Emits on a zero [lastEmitMs] (the first reading of a link is the one most worth having) and on + * a BACKWARDS clock, matching [shouldEmitLiveInsertFailure] next door: `currentTimeMillis` is + * wall time and can step back, and comparing only forwards would strand the stamp in the future + * and silence the line indefinitely. Apple's twin compares `Date`s inline and silences on that + * step instead; the WORDING is pinned across platforms, this cadence policy deliberately is not. */ + internal fun shouldLogStandardHrNotify( + lastEmitMs: Long, + nowMs: Long, + minGapMs: Long = 30_000L, + ): Boolean = lastEmitMs <= 0L || nowMs < lastEmitMs || nowMs - lastEmitMs >= minGapMs + + /** #2387: the outcome token on a `session ended` line, so a timeout says whether it achieved anything. + * + * A WHOOP 4.0 routinely ends a PRODUCTIVE offload on the idle timeout, because that firmware + * finishes without emitting HISTORY_COMPLETE. The line said `reason=timeout` either way and the + * rows landed on the NEXT line, so the alarming half read first and the outcome second. A reporter + * read six of these as six interrupted syncs; so did the maintainer reviewing their log, after + * reading the code that says otherwise. Four of those timeouts had banked 56,879, 183,266, 37,645 + * and 40,386 rows. + * + * Rows, not frames: a stalled session still receives frames, and rows is what the summary line + * beside this one reports, so the two cannot disagree. Same test [shouldNotifySuccessfulOffload] + * already applies. Empty for any other reason, so HISTORY_COMPLETE and the disconnect paths are + * byte-identical to before. Twin of the Swift `BLEManager.sessionEndedOutcome`. */ + internal fun sessionEndedOutcome(reason: String, bankedRows: Boolean): String = + if (reason != "timeout") "" else if (bankedRows) " outcome=drained" else " outcome=nothing-banked" + + 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 — @@ -443,22 +595,11 @@ class WhoopBleClient( */ private const val LOG_BUFFER_MAX = 5000 - /** - * #1263: durable strap-log tail + generation ring (Android parity for iOS `LiveState`). The in-memory - * [logBuffer] dies with the process, so an export taken after a restart would begin at the restart and - * lose the lines that explain it. We mirror a durable tail to SharedPreferences every - * [LOG_TAIL_PERSIST_EVERY] lines and, at the first log line of each process (and at export time), roll - * the surviving tail into a bounded generation ring so an export STILL carries the previous session. - * Keys mirror the iOS UserDefaults keys; the tail is newline-joined, the generations a JSON array of - * newline-joined blocks. Pure ring math lives in [com.noop.ui.StrapLogGenerations]. - */ - private const val STRAP_LOG_TAIL_KEY = "strapLog.tail" - private const val STRAP_LOG_GENERATIONS_KEY = "strapLog.generations" - /** Persist the durable tail every N lines (batched, not per-line — mirrors iOS `persistEveryNLines`). */ - private const val LOG_TAIL_PERSIST_EVERY = 32 - /** How many recent lines the durable tail retains — a sensible day's worth, larger than a single - * generation's cap so a session's whole tail is available to roll. Mirrors iOS `tailLimit`. */ - private const val LOG_DURABLE_TAIL_LIMIT = 2_000 + /** Where the #1263 ring kept the log in SharedPreferences, read once to carry it over to disk + * ([com.noop.ui.StrapLogArchive]). The same keys as iOS's UserDefaults ring: the tail newline-joined, + * the runs a JSON array of newline-joined blocks, each beginning with its header. */ + private const val LEGACY_STRAP_LOG_TAIL_KEY = "strapLog.tail" + private const val LEGACY_STRAP_LOG_GENERATIONS_KEY = "strapLog.generations" /** * Fallback device id when the registry has no active device yet (fresh install before the v8 @@ -543,6 +684,27 @@ class WhoopBleClient( private val DIS_SERIAL_CHAR: UUID = UUID.fromString("00002a25-0000-1000-8000-00805f9b34fb") private val DIS_HW_REV_CHAR: UUID = UUID.fromString("00002a27-0000-1000-8000-00805f9b34fb") + /** Standard Firmware Revision String. Readable UNBONDED, like the serial and hardware revision + * beside it — which is the whole point: a 5/MG that never completes the puffin handshake has no + * other firmware source, because the decoded one rides a framed command that needs the bond. */ + private val DIS_FW_REV_CHAR: UUID = UUID.fromString("00002a26-0000-1000-8000-00805f9b34fb") + + /** The rest of the Device Information Service, which sits in the SAME service NOOP already reads + * the serial, hardware revision and firmware from — and which has simply never been asked for. + * All read-only, all standard, and reachable on an unbonded link (a standard 0x2A19 read succeeded + * on one in the field), so they cost one round-trip each and can name a strap that will not pair. */ + private val DIS_MANUFACTURER_CHAR: UUID = UUID.fromString("00002a29-0000-1000-8000-00805f9b34fb") + private val DIS_MODEL_NUMBER_CHAR: UUID = UUID.fromString("00002a24-0000-1000-8000-00805f9b34fb") + private val DIS_SW_REV_CHAR: UUID = UUID.fromString("00002a28-0000-1000-8000-00805f9b34fb") + + /** Every DIS characteristic NOOP reads, in ONE place. [noteReadFailure] tested three of them by + * name and would have gone silent on the three added beside them — the exact "a refused read is + * indistinguishable from one never issued" gap that reporter exists to close. */ + private val DIS_CHARS: Set = setOf( + DIS_SERIAL_CHAR, DIS_HW_REV_CHAR, DIS_FW_REV_CHAR, + DIS_MANUFACTURER_CHAR, DIS_MODEL_NUMBER_CHAR, DIS_SW_REV_CHAR, + ) + // Client Characteristic Configuration Descriptor — written to enable notifications // (CoreBluetooth does this implicitly via setNotifyValue; Android requires the explicit write). private val CCCD: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") @@ -607,6 +769,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. * @@ -641,6 +808,24 @@ class WhoopBleClient( else -> "status$status" } + /** #2332: is a link RSSI reading worth recording? + * + * -127..20 dBm is the LE spec's valid range for a read RSSI, and 127 is its reserved "RSSI is not + * available". Stashing that sentinel would put an impossibly strong reading on a link that died, + * which argues the radio was fine and sends the next reader of the log anywhere but at range. So + * the band is the spec's own range, which rejects 127 and any other out-of-spec value a stack + * invents, and keeps every reading the spec says is real. + * + * What it does NOT catch, deliberately: a stack that reports "unavailable" as an IN-BAND value. + * 0 dBm is not reachable on a real link, but the spec permits it, so it is indistinguishable here + * from a genuine reading and is kept. Widening the rejection to cover it would start discarding + * spec-valid readings on a guess, which is the worse trade for a diagnostic. The age printed + * beside the value in the epitaph is what a reader uses to judge a suspicious one. + * + * Pure so the filter is testable without a BLE stack, like [batteryPollDue] beside it. Twin of + * the Swift `rssiReadingIsUsable`. */ + fun rssiReadingIsUsable(rssi: Int): Boolean = rssi in -127..20 + /** #battery: is a keep-alive tick due to poll the strap's battery? * * Normally every SECOND 30 s tick (~60 s), which is plenty while the charge only creeps downward. @@ -658,7 +843,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 @@ -821,6 +1013,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 @@ -845,6 +1048,11 @@ class WhoopBleClient( * this is now just the FIRST window — [bondWatchdogBackoff] escalates it per consecutive bounce so * a slower-but-healthy WHOOP 4.0 handshake gets more time before being bounced again. */ private const val BOND_WATCHDOG_MS = 7_000L + + /** How long after an accepted `createBond()` to read the device's bond state directly (#1635). + * Long enough that a pairing which started has reached BOND_BONDING, short enough to land while + * the link is still up on a strap whose links have historically lasted ~4.8s. */ + private const val EXPLICIT_BOND_POLL_MS = 2_000L /** OnePlus-only settle delay before the FIRST CCCD descriptor write after service discovery * (#50). The OnePlus Nord 2 GATT stack needs a beat to settle post-discovery; writing the first * descriptor immediately races the still-unsettled stack and the subscribe returns BUSY. ~450ms @@ -885,6 +1093,14 @@ class WhoopBleClient( * stale-direct-bond scan-fallback ([staleDirectBond]), it was not our OWN localTerminate bounce * (already counted by [onBondWatchdog]), and we are not already paused. A healthy strap that bonds on * the first connect has didBond == true, so it is never counted and its behaviour is unchanged. + * + * [helloSuppressed] excludes the #1635 strap, and it is not optional. Suppression makes `didBond` + * PERMANENTLY false by design — the handshake is deliberately not being sent — so every ordinary + * involuntary drop (out of range, radio off, a walk away from the phone) would otherwise look like + * "connects but never pairs" and march this counter toward a pause. That would undo the suppression + * a few drops later and hand the user a re-pair guide blaming a stale pairing, for a strap whose + * unbonded state NOOP chose on purpose. Not-bonding is only evidence of a fault when we were + * actually trying to bond. */ internal fun shouldCountNeverBondedSelfDrop( wasConnected: Boolean, @@ -893,8 +1109,31 @@ class WhoopBleClient( staleDirectBond: Boolean, status: Int, alreadyPausedForBondLoop: Boolean, + helloSuppressed: Boolean, ): Boolean = wasConnected && !didBond && !intentionalDisconnect && !staleDirectBond && - status != GATT_CONN_TERMINATE_LOCAL_HOST && !alreadyPausedForBondLoop + status != GATT_CONN_TERMINATE_LOCAL_HOST && !alreadyPausedForBondLoop && !helloSuppressed + + /** + * #1997: was this link one the strap never spoke on, on a connection the OS was already holding? + * + * The OS reports the connection still held ([isStrapAclHeld]) and not a single frame arrived on it. + * + * That is the whole claim the guide makes, so it is the whole predicate. An earlier version also + * required the MTU exchange to have been refused, which was how this inferred a held link BEFORE + * asking the OS directly. Once the direct signal is required the refusal adds no part of the claim + * and only narrows when it can fire, which on a branch with no field sighting yet is a cost with + * no benefit. The MTU refusal still reads as a refusal in the log, where it helps triage. On the reporting device every re-attach looked exactly like this while the + * one link that negotiated 247 carried traffic, so this is the shape of attaching to a stale + * connection rather than of a strap declining to pair. + * + * This selects the GUIDE, and deliberately does NOT gate the pause. Excluding these links from + * [shouldCountNeverBondedSelfDrop] was the first shape of this fix and it was wrong: CLIENT_HELLO + * is 5/MG only, so on the reporter's WHOOP 4.0 nothing else would have bounded the loop, and the + * connect-drop-retry cycle would have run forever draining both batteries. That is the exact + * failure #982 exists to prevent. The pause is right; only the explanation was wrong. + */ + internal fun heldLinkWithoutTraffic(aclHeld: Boolean, inboundFrames: Int): Boolean = + aclHeld && inboundFrames == 0 /** Pure guard for a delayed service-discovery kick. The operation belongs only to the exact * connection that scheduled it, and a temporarily-missing GATT wrapper must not consume the @@ -990,6 +1229,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 @@ -1149,6 +1399,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 @@ -1167,6 +1439,9 @@ class WhoopBleClient( // #580: the 5/MG "history experimental" note is per-link — a fresh connect re-derives it // from the next offload, so it must not outlive the dropped link. historySyncExperimental = false, + // #689/#815: the backlog sample is "at connect" by definition, so it must not survive the + // link it was taken on — a stale figure under a fresh connection would be a plain lie. + pagesBehindAtConnect = null, // #612: the display flag only, not the underlying emptySyncTracker streak (that counter // deliberately survives a reconnect, unchanged existing behaviour). sustainedEmptyOffload = false, @@ -1214,6 +1489,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. @@ -1412,6 +1693,131 @@ 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 + + /** #2338: how long the read-only advertising-name probe waits before calling silence a result. */ + const val ADVERTISING_NAME_PROBE_TIMEOUT_MS = 8_000L + + /** #2338: how long opcode 140 stays admitted after a confirmed rename write. Short on purpose: + * the frame is queued immediately, so this only has to cover the write queue's drain, and a + * window that outlived the send would widen what a default install can form. */ + const val ADVERTISING_NAME_WRITE_WINDOW_MS = 3_000L + + /** In-flight sentinel for the #2338 probe. The 5/MG allow-list admits opcode 141 ONLY while this + * is in place, so a default install can never form those bytes. */ + const val WAITING_ADVERTISING_NAME_PROBE = "__waiting__" + + /** + * 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__" @@ -1723,11 +2129,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) @@ -1785,6 +2202,22 @@ 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() + + /** #2338: the read-only GET_ADVERTISING_NAME(141) probe result, or the in-flight sentinel. Drives the + * Devices dialog copy; null when no probe has run. */ + private val _advertisingNameProbe = MutableStateFlow(null) + val advertisingNameProbe: StateFlow = _advertisingNameProbe.asStateFlow() + + /** #2338: true only while a user-confirmed 5/MG rename write is actually in flight. The allow-list + * admits opcode 140 on this alone, so a default install can never form those bytes, and the window + * is closed again a few seconds later whatever the strap does. */ + @Volatile + private var advertisingNameWriteArmed = false + // #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) @@ -1902,6 +2335,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). * @@ -2099,7 +2535,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. @@ -2110,7 +2552,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. @@ -2122,6 +2564,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") } @@ -2130,6 +2573,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() @@ -2151,6 +2705,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. */ @@ -2159,6 +2827,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 @@ -2171,20 +2842,103 @@ class WhoopBleClient( /** Address of the strap we last connected to — for persisting it + auto-reconnecting on launch (#67). */ val lastDeviceAddress: String? get() = lastDevice?.address - /// Has [connectedFamily] been established from THIS connection's service discovery? - /// - /// [connectedFamily] defaults to WHOOP4 and is only ever written in onServicesDiscovered, and is NOT - /// cleared between connections (this flag is - clearing [connectedFamily] itself would change the - /// handleDisconnect paths that read it after teardown). So before discovery it holds either that - /// default or the family of the PREVIOUS link. Both are guesses, and the source must not come from a - /// guess: the 4.0's standard 0x2A19 characteristic is a stub (#77), so a 4.0 reached while this still - /// said WHOOP5 read the stub and BANKED it. In one field log that stub read 81% against a true 39.2%, - /// and banked samples feed the discharge-slope estimate (#713), so the error reaches the readout and - /// not just the log line. Cleared in [reset] with the rest of the per-connection state. - /// @Volatile and always read BEFORE [connectedFamily] (Kotlin evaluates arguments left to right, so - /// `batterySource(familyEstablished, connectedFamily)` is the correct order): seeing this true then - /// establishes happens-before for the [connectedFamily] write that precedes it at discovery. Swapping - /// the two parameters would silently drop that guarantee. + /** + * The model established from THIS connection's discovered service, or null before discovery. + * + * The picker is only a request: scan fallback and easy-connect can establish the other family, so + * persisting the picker beside [lastDeviceAddress] makes the saved pair untrue and feeds that wrong + * family back into the next direct reconnect (#2068). [familyEstablished] must be read BEFORE + * [connectedFamily]: it is the volatile publication edge for the family write in service discovery. + * Kotlin evaluates arguments left to right, so this call preserves that ordering and never exposes + * the default or the previous link's family as evidence for the current one. + */ + internal val establishedModel: WhoopModel? + get() = establishedWhoopModel(familyEstablished, connectedFamily) + + /** #1634: last firmware-gate line logged, so a stable per-connection value is not repeated on every + * hello. Cleared in [reset] with the rest of the per-connection state. */ + @Volatile private var loggedFirmwareGate: String? = null + + /** #1635: when the 5/MG CLIENT_HELLO write was issued, so its completion - or the absence of one - + * 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 + /** #2384: when the `HR notify:` line last went out, for [shouldLogStandardHrNotify]. Volatile for the + * same reason the stamps beside it are: 0x2A37 notifications arrive on a binder thread. */ + @Volatile private var lastStandardHrNotifyLogMs = 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. */ + val msSinceClientHello: Long? + get() = clientHelloWriteAtMs.takeIf { it > 0L }?.let { System.currentTimeMillis() - it } + + /** #1635: record an OS bond-state transition in the strap log. The OS pairing flow has never been + * observed, which is what leaves the 5/MG bond failure undecided - see [bondStateTraceLine]. */ + fun onBondStateChanged(previous: Int, current: Int, address: String?, reason: Int? = null) { + // Time against whichever request is actually outstanding. The hello takes precedence when both + // are, but the explicit-pairing experiment deliberately sends no hello, so without the second + // marker every transition it causes would print untimed — and how long a pairing took is most of + // what makes it diagnosable (#1635). + val helloMs = msSinceClientHello + val bondMs = explicitBondRequestedAtMs + .takeIf { it > 0L }?.let { System.currentTimeMillis() - it } + val since = helloMs ?: bondMs + val label = if (helloMs != null) "CLIENT_HELLO" else "the pairing request" + if (!shouldTraceBondState(address, lastDeviceAddress, since != null)) return + sawBondTransitionThisLink = true + log(bondStateTraceLine(previous, current, address, since, label, reason)) + } + @Volatile private var familyEstablished = false /// The family actually discovered on the connected peripheral. Drives family-aware frame @@ -2229,101 +2983,27 @@ class WhoopBleClient( // PII scrubbers for the shareable strap log (#445) live at file scope as [redactStrapLogPii] // so they're unit-testable without constructing this Android-only client (#421). - // ── #1263 Durable strap-log tail + generation ring (Android parity for iOS LiveState) ─────────── - // The in-memory [logBuffer] dies with the process. To make an export taken AFTER a restart still carry - // the previous session's tail (issues #1259/#1264), we mirror a durable tail to SharedPreferences and, - // once per process, roll the surviving tail into a bounded generation ring. The ring MATH is the pure, - // JVM-tested [com.noop.ui.StrapLogGenerations]; this is the thin, untested SharedPreferences wrapper - // (the same split iOS has with UserDefaults). All of it runs inside log()'s no-throw guard, and the - // roll latch + persistence are serialised on [genLock] because log() is called from BOTH the GATT binder - // thread and the main looper — the roll must happen exactly once and must not race the tail mirror. - private val genLock = Any() - /** Once-per-process latch: the roll must run BEFORE this process's first durable-tail mirror overwrites - * the surviving tail, and exactly once, or a second roll would push this process's own partial tail in - * as a "previous" session. Guarded by [genLock]. */ - private var didRollGenerations = false - - /** #1468 follow-up: the PREVIOUS-sessions half of [exportLogText], memoised for the process. - * - * It is invariant once [rollLogGenerationsIfNeeded] has run: [persistLogGenerations] is called from - * exactly one place, inside that latched roll, so nothing rewrites the generations again while the app - * lives. Recomputing it was pure waste — and not free, since it re-reads SharedPreferences and - * re-formats every past session. - * - * That waste only became visible with the Test Centre live readouts (#1468), which call [exportLogText] - * on every 250 ms coalesce tick while a panel is open, so a screen the user leaves open during an - * offload re-read prefs and re-rendered historical sessions four times a second. Guarded by [genLock], - * the same lock the roll uses, so the memo cannot be filled from a pre-roll read. */ - private var cachedPreviousSessionsText: String? = null - - /** Line-form twin of [cachedPreviousSessionsText], memoised for the same reason and under - * the same [genLock]. Built from the generations directly — see [buildPreviousSessionsLines]. */ - private var cachedPreviousSessionsLines: List? = null - /** Durable-tail mirror counter, mutated only under [logBuffer]'s monitor (like [logBuffer] itself). */ - private var logsSincePersist = 0 - - private fun strapLogPrefs() = context.getSharedPreferences("noop_prefs", Context.MODE_PRIVATE) - - /** The persisted durable tail, newest-last. Empty when nothing has been logged on this device. */ - private fun persistedLogTail(): List { - val s = strapLogPrefs().getString(STRAP_LOG_TAIL_KEY, null) - return if (s.isNullOrEmpty()) emptyList() else s.split('\n') - } - - /** Mirror the most recent [LOG_DURABLE_TAIL_LIMIT] lines to SharedPreferences (newline-joined). */ - private fun persistLogTail(lines: List) { - val tail = if (lines.size > LOG_DURABLE_TAIL_LIMIT) - lines.subList(lines.size - LOG_DURABLE_TAIL_LIMIT, lines.size) else lines - strapLogPrefs().edit().putString(STRAP_LOG_TAIL_KEY, tail.joinToString("\n")).apply() - } - - /** The stored generations, oldest-first — each a newline-joined block whose first line is its own header. - * Persisted as a JSON array of strings; a corrupt/absent value reads as none. */ - private fun persistedLogGenerations(): List> { - val s = strapLogPrefs().getString(STRAP_LOG_GENERATIONS_KEY, null) ?: return emptyList() - return runCatching { - val arr = org.json.JSONArray(s) - (0 until arr.length()).map { i -> - val block = arr.getString(i) - if (block.isEmpty()) emptyList() else block.split('\n') - } - }.getOrDefault(emptyList()) - } - - private fun persistLogGenerations(gens: List>) { - val arr = org.json.JSONArray() - for (g in gens) arr.put(g.joinToString("\n")) - strapLogPrefs().edit().putString(STRAP_LOG_GENERATIONS_KEY, arr.toString()).apply() - } - - /** - * Roll the surviving durable tail into the generation ring. Idempotent per process (latched) and a NO-OP - * when the tail is empty — so a launch that logs nothing (or a run right after a roll) never pushes an - * empty generation and never evicts a real one. Runs on the FIRST log() append of the process AND in the - * export path, so an export taken before the first append isn't empty (iOS #1264). Serialised on [genLock]. - */ - private fun rollLogGenerationsIfNeeded() { - synchronized(genLock) { - if (didRollGenerations) return - didRollGenerations = true - val tail = persistedLogTail() - if (tail.isEmpty()) return - val gens = com.noop.ui.StrapLogGenerations.roll(tail, persistedLogGenerations(), System.currentTimeMillis()) - persistLogGenerations(gens) - // Clear the live slot: this tail now belongs to a generation, and leaving it would duplicate it - // in every export until the next mirror overwrites it. Empty string reads back as no tail. - strapLogPrefs().edit().putString(STRAP_LOG_TAIL_KEY, "").apply() - } - } - - /** Flush the current in-memory tail to the durable slot (mirroring is batched every N lines, so a - * disconnect / shutdown flushes the last partial batch — the twin of iOS flushing in clearBiometrics). - * No-throw; called off the per-line path. */ - private fun flushDurableLogTail() { + // ── The strap log on disk ────────────────────────────────────────────────────────────────────────── + // The in-memory [logBuffer] dies with the process and keeps 5,000 lines. Every line also goes to disk + // ([com.noop.ui.StrapLogArchive], twin of iOS's), so an export carries the runs before a restart and all of + // this one. Opened at the first line or export of the process; that first use also carries over what the + // #1263 SharedPreferences ring kept, then drops its keys. + private val strapLogArchive: com.noop.ui.StrapLogArchive by lazy { + val archive = com.noop.ui.StrapLogArchive(java.io.File(context.filesDir, "strap-log")) runCatching { - val snapshot = synchronized(logBuffer) { logsSincePersist = 0; logBuffer.toList() } - if (snapshot.isNotEmpty()) persistLogTail(snapshot) + val prefs = context.getSharedPreferences("noop_prefs", Context.MODE_PRIVATE) + val tail = prefs.getString(LEGACY_STRAP_LOG_TAIL_KEY, null).orEmpty() + .let { if (it.isEmpty()) emptyList() else it.split('\n') } + val runs = prefs.getString(LEGACY_STRAP_LOG_GENERATIONS_KEY, null)?.let { json -> + runCatching { + val arr = org.json.JSONArray(json) + (0 until arr.length()).map { i -> arr.getString(i).let { if (it.isEmpty()) emptyList() else it.split('\n') } } + }.getOrNull() + }.orEmpty() + archive.importLegacy(com.noop.ui.StrapLogArchive.legacyRingLines(runs, tail, System.currentTimeMillis())) + prefs.edit().remove(LEGACY_STRAP_LOG_TAIL_KEY).remove(LEGACY_STRAP_LOG_GENERATIONS_KEY).apply() } + archive } /** Fired if a scan finds nothing in [SCAN_TIMEOUT_MS]; stops scanning and explains why. */ @@ -2363,6 +3043,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 @@ -2371,6 +3080,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 @@ -2393,6 +3103,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 @@ -2431,7 +3142,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 @@ -2442,17 +3166,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 @@ -2463,13 +3187,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, @@ -2495,6 +3224,12 @@ class WhoopBleClient( profileStore.stepsCalibrationConfidence = cal.confidence profileStore.stepsCalibrationManual = cal.manual }, + // Persisted steps-calibration motion folds. The analytics layer is Context-free, so + // the payload is read and written here; without it the sixty-day fold is re-paid in + // full after every relaunch. A derived cache — a missing or unreadable payload just + // re-folds (see StepsMotionCache). + stepsMotionCacheGet = { NoopPrefs.stepsMotionCache(context) }, + stepsMotionCacheSet = { NoopPrefs.setStepsMotionCache(context, it) }, // Manual "Recalibrate baseline" anchor (noop.hrvBaselineEpoch, whole seconds in a // Long). The analytics layer is Context-free, so read it here and thread it down so // the post-backfill scoring pass honours the recalibration too — not just the UI's @@ -2554,10 +3289,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 @@ -2576,6 +3319,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 @@ -2584,7 +3329,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() + } } } } @@ -2630,6 +3385,45 @@ class WhoopBleClient( * CONNECTED. null between attempts; set when connect() kicks the radio. */ private var connectAttemptStartedAtMs: Long? = null + /** + * #2332: the most recent link RSSI and the wall time it was read, both scoped to the CURRENT link. + * + * The epitaph's end status names range as the leading suspect on every supervision timeout, and + * before this the log had nothing to say about range at the moment of the drop: RSSI was read once, + * [RSSI_READ_DELAY_MS] after connect, and never again. A field report with 21 disconnects carried 11 + * readings, none of them near a death. + * + * Cleared on teardown alongside [linkUpSinceMs], and for the same reason: a reading carried over from + * the PREVIOUS link would put a number on this link's drop that was never measured on it, which is + * the failure mode the epitaph exists to prevent rather than commit. The pair travels to + * [ConnectionReadout.linkEpitaph] together so the age is always printed with the value. + * + * Diagnostic only — nothing reads these to make a decision. + */ + @Volatile + /** #2397: how many RSSI readings this link produced, and their worst and total, so the epitaph can + * report a SHAPE rather than a point. #2332 added the ~60s periodic read and kept only the latest + * value, so a link that took 200 readings reported one of them: a field log showed 467 reads across + * three links and two epitaphs naming two numbers. Last alone cannot separate "marginal all along" + * from "walked out of range", which is the question a supervision timeout raises. + * + * Free: these are fed by readings the ~60s read ALREADY takes. No extra radio work, no new timer. + * + * Plain, not @Volatile, for the reason the frame counters below carry: `+=` is not atomic whatever + * it is annotated with, and an approximate count still answers the question. The stash above is + * volatile because a torn read there would misattribute ONE reading to the wrong link; being one + * reading out in a mean of two hundred changes nothing a reader would act on. */ + private var rssiReads = 0 + private var rssiWorstDbm: Int? = null + private var rssiSumDbm = 0 + + private var lastRssiDbm: Int? = null + + /** Wall time (ms) [lastRssiDbm] was read. Meaningless unless [lastRssiDbm] is non-null; the two are + * written together under the GATT callback and cleared together on teardown. */ + @Volatile + private var lastRssiAtMs: Long = 0L + /** * Wall time (ms) this link reached STATE_CONNECTED, or null while down. Read only to LOG how long a * connection was held before it dropped. @@ -2703,6 +3497,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 @@ -2710,8 +3505,58 @@ 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). */ + private var disModelNumber: String? = null /** #364 auto-continue: consecutive immediate re-kicks after a 60s idle-cap OR HISTORY_COMPLETE exit on * THIS connection. Bounded by [MAX_AUTO_CONTINUES] so a pathological strap can't pin the radio. Reset @@ -2761,9 +3606,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) --- @@ -2805,6 +3651,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. */ @@ -2887,12 +3748,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. 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. @@ -2935,13 +3826,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 } @@ -2956,7 +3880,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 @@ -3100,7 +4033,7 @@ class WhoopBleClient( connectToDevice(dev, autoConnect = true) } else { log("Bluetooth radio back on — rescanning for your ${selectedModel.displayName}") - connect(selectedModel) + connectFromSystem(selectedModel) } } } @@ -3127,7 +4060,7 @@ class WhoopBleClient( intentionalDisconnect = false val dev = lastDevice if (dev != null && isPreferred(dev)) connectToDevice(dev, autoConnect = true) - else connect(selectedModel) + else connectFromSystem(selectedModel) } } @@ -3206,7 +4139,12 @@ class WhoopBleClient( */ fun releaseStrap() { noteLocalTeardown("releaseStrap") // #1020 + // #1635: a forgotten strap re-added later deserves a fresh tree dump — its firmware may have + // 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) @@ -3219,6 +4157,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). @@ -3254,6 +4202,109 @@ 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 + + /** + * Whether a WHOOP is the ACTIVE device, for readouts that must describe the right band (#2075). + * + * Read-only view of the flag [setWhoopIsActiveDevice] maintains from the coordinator's own start/stop + * closures, which derive it from `SourceIdentity.isWhoop` on the active row. Exposed so a producer on + * a hot path can ask without a registry read: the widget push rides a collector driven by live heart + * rate, and reading the DB there to answer a battery label would be absurd. + */ + val activeDeviceIsWhoop: Boolean get() = whoopIsActiveDevice + + /** 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 @@ -3362,7 +4413,15 @@ class WhoopBleClient( fun send(cmd: CommandNumber, payload: ByteArray = byteArrayOf(0), withResponse: Boolean = false) { val ch = cmdCharacteristic if (gatt == null || ch == null) { - log("send(${cmd.name}) ignored — not connected") + // Two very different states read identically before this. "not connected" is only true for + // the first: the second is a live link whose service discovery has not yet produced the + // command characteristic, and calling that a disconnection sent at least one investigation + // looking for a dropped link that was never dropped. A diagnostic may only assert what it + // can attribute. + log( + "send(${cmd.name}) ignored — " + + if (gatt == null) "no link" else "link up, command characteristic not ready yet", + ) return } // WHOOP 5.0/MG uses puffin (CRC16) command framing, not the WHOOP4 frame. The realtime-HR toggle @@ -3374,7 +4433,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 — @@ -3396,6 +4461,27 @@ 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) && + // SET_ADVERTISING_NAME (140) over puffin: the #2338 rename WRITE. Reversible (rename + // again), but NOT hardware-confirmed on a 5/MG and carrying a payload shape mirrored from + // the 4.0 Harvard form rather than observed. Admitted ONLY while a user-confirmed write is + // in flight, the 151 tier, so a default install can never form these bytes. Same standing + // as REBOOT_STRAP(29) above; that one has no body, this one does. + !(cmd == CommandNumber.SET_ADVERTISING_NAME_5MG && advertisingNameWriteArmed) && + // GET_ADVERTISING_NAME (141) over puffin: the #2338 read-only name probe. Gated like 151 + // above, the hardest of the three tiers, because this opcode has never been sent to any + // strap by this app: allowed ONLY while a probe is actually in flight, so a default + // install cannot form these bytes at all. The SET side (140) is not in CommandNumber, so + // no code path can express a write no matter what this gate says. + !(cmd == CommandNumber.GET_ADVERTISING_NAME && + _advertisingNameProbe.value == WAITING_ADVERTISING_NAME_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 @@ -3602,7 +4688,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( @@ -3653,7 +4739,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 @@ -3707,9 +4793,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 { @@ -3756,6 +4842,43 @@ class WhoopBleClient( */ fun renameStrap(rawName: String) { val name = rawName.trim() + // #2338: a 5/MG takes the non-Harvard opcode 140 over puffin framing. Reversible, so the BLE + // contract admits it, but NOT hardware-confirmed: no strap has been sent this, and the payload + // below mirrors the 4.0 Harvard shape rather than anything observed on this family. Test Centre + // Connection gated in the CLIENT as well as at the call site, because a gate that lives only in + // the UI is one refactor away from being gone. The COMMAND_RESPONSE is logged, so a strap log is + // what settles whether the frame was accepted. + if (connectedFamily == DeviceFamily.WHOOP5) { + if (!testCentre.active(com.noop.testcentre.TestDomain.CONNECTION)) { + _state.update { it.copy(renameStatus = "5/MG renaming is experimental. Turn on Test Centre, Connection first.") } + log("Strap rename: 5/MG write refused, Test Centre Connection is off (#2338)") + return + } + if (!_state.value.connected || !_state.value.bonded) { + _state.update { it.copy(renameStatus = "Connect and pair your strap first.") } + return + } + if (name.isEmpty()) { + _state.update { it.copy(renameStatus = "Enter a name first.") } + return + } + // Same 24-byte clamp on a whole-character boundary as the 4.0 path: never split a multibyte + // char, and leave room for the rest of the advertising structure. + var clamped5 = name + while (clamped5.toByteArray(Charsets.UTF_8).size > 24) clamped5 = clamped5.dropLast(1) + val payload5 = byteArrayOf(0, 0) + clamped5.toByteArray(Charsets.UTF_8) + byteArrayOf(0) + // Arm immediately BEFORE send(): the allow-list admits 140 only while this is true, so arming + // afterwards would have our own gate drop our own write. + advertisingNameWriteArmed = true + send(CommandNumber.SET_ADVERTISING_NAME_5MG, payload5, withResponse = true) + handler.postDelayed({ advertisingNameWriteArmed = false }, ADVERTISING_NAME_WRITE_WINDOW_MS) + // Redacted like the 4.0 path (#2337): the name is user-chosen and routinely a person's. + log("Strap rename: 5/MG write sent, name=${logSafeDeviceName(clamped5)} (opcode 140, unconfirmed, #2338)") + _state.update { it.copy( + renameStatus = "Sent on an unconfirmed command. Use Check current name to see whether it took.", + ) } + return + } if (connectedFamily != DeviceFamily.WHOOP4) { _state.update { it.copy(renameStatus = "Renaming is WHOOP 4.0 only.") } log("Strap rename: WHOOP 4.0 only — ignored.") @@ -3775,7 +4898,12 @@ class WhoopBleClient( while (clamped.toByteArray(Charsets.UTF_8).size > 24) clamped = clamped.dropLast(1) val payload = byteArrayOf(0, 0) + clamped.toByteArray(Charsets.UTF_8) + byteArrayOf(0) send(CommandNumber.SET_ADVERTISING_NAME, payload, withResponse = true) - log("Strap rename: wrote advertising name=$clamped") + // #2337: through [logSafeDeviceName], never raw. This name is USER-CHOSEN, so it is the one + // string in the rename path that can carry a person's name, and strap logs get attached to public + // issues. [redactStrapLogPii] masks MACs, WHOOP serials and hex dumps, none of which this is, so + // it would go out verbatim. The scan path already routes the very same value through the helper + // ("Discovered $safeName"), which made this the one place the same data was handled two ways. + log("Strap rename: wrote advertising name=${logSafeDeviceName(clamped)}") _state.update { it.copy( renameStatus = "Sent - your strap will reboot to apply, then reconnect with the new name.", ) } @@ -3888,6 +5016,98 @@ 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) + } + + /** + * #2338 READ-ONLY probe: ask the strap for its BLE advertising name over `GET_ADVERTISING_NAME(141)`. + * + * Renaming a strap is WHOOP 4.0 only today, over the Harvard pair 76/77. A second-hand 5/MG keeps the + * previous owner's name with no way to change it. The schema names a non-Harvard pair, 140 set and 141 + * get, but nothing in this app has ever sent either, so the opcode numbers rest on the schema alone. + * + * This asks, and writes nothing. Whether a 5/MG answers 141 at all is the hardware question, and + * SILENCE IS A RESULT: it says the number is wrong, or the command is unsupported, either of which + * settles whether a rename is reachable before anyone writes 140 at firmware. The set side is not in + * [CommandNumber], so no code path can express the write until this returns something. + * + * User-initiated, Test Centre gated at the call site, and admitted by the 5/MG allow-list only while + * the sentinel below is in place. Same shape as `probeBatteryPackInfo` (151) and the #690 / #592 + * probes. The reply is redacted before it is shown: an advertising name routinely carries a person's + * name, which is the whole reason #2338 exists. + */ + fun probeAdvertisingName() { + if (!_state.value.connected) { + log("Advertising-name probe (141) ignored — not connected") + return + } + // 5/MG only, checked HERE and not just at the call site, for the reason [renameStrap] states: a + // gate living only in the UI is one refactor from gone. This matters more than it looks. A 4.0 + // has NO send allow-list, so on that family the frame would actually reach the wire, and 141 is + // the wrong opcode for it anyway — a 4.0 reads its name on 76. Deliberately unlike + // [probeBatteryPackInfo], which is offered on both families because a 4.0's silence there is + // itself the evidence; here the 4.0 answer is already known. + if (connectedFamily != DeviceFamily.WHOOP5) { + _advertisingNameProbe.value = "The name probe is WHOOP 5.0/MG only. A 4.0 reads its name on opcode 76." + log("Advertising-name probe: 5/MG only — ignored (family=$connectedFamily, #2338)") + return + } + // #2338: drop any standing rename status first. The two share one line in the UI, which prefers + // renameStatus, and a rename leaves behind "use Check current name to see whether it took" — so + // without this the status would sit there hiding the answer to the very question it asked. The + // probe is the newer action, so it owns the line. + _state.update { it.copy(renameStatus = null) } + // MUST be set before send(): the allow-list admits 141 only while this sentinel is in place, so + // setting it afterwards would have our own gate drop our own probe. + _advertisingNameProbe.value = WAITING_ADVERTISING_NAME_PROBE + log("Advertising-name probe: sending GET_ADVERTISING_NAME(141, read-only) on family=$connectedFamily") + send(CommandNumber.GET_ADVERTISING_NAME) + // 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 = "Advertising-name probe: no COMMAND_RESPONSE for opcode 141 within " + + "${ADVERTISING_NAME_PROBE_TIMEOUT_MS / 1000}s — the strap served no reply. That is the " + + "answer for now: renaming stays WHOOP 4.0 only (#2338)." + if (_advertisingNameProbe.compareAndSet(WAITING_ADVERTISING_NAME_PROBE, msg)) log(msg) + }, ADVERTISING_NAME_PROBE_TIMEOUT_MS) + } + + // No clearAdvertisingNameProbe(): unlike the 151 and #690 probes, this result renders INLINE in the + // Settings strap-name section rather than in a dialog, so there is no dismiss to hang a clear on. The + // next probe replaces it, and a rename masks it (the UI prefers renameStatus). Adding the symmetric + // clearer would only add an entry point nothing reaches. + + /** 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 @@ -4202,17 +5422,368 @@ 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 + } + + /** + * A characteristic read that came back with a failure status. + * + * Both `onCharacteristicRead` overloads used to drop a non-success status on the floor, so a refused + * read produced no line at all — indistinguishable from one that was never issued. That is exactly the + * ambiguity that made the CLIENT_HELLO failure unreadable for eleven weeks (#1635). + * + * Scoped to the DIS characteristics: they are the reads whose refusal is a FINDING (it would confirm + * #490 on Android and mean the firmware cannot be had without a bond). A refusal is also latched per + * device, so a strap that declines says so once instead of on every reconnect forever. + */ + 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(), gattStatusLabel(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 + // negotiation can fail for reasons that have nothing to do with the strap's policy. Latching that + // would disable the firmware read for this strap for good, and blame the strap for our own timing. + if (explicitBondRequestedThisLink) { + log("DIS: not latching that refusal — a pairing was requested on this link, so the failure is" + + " not attributable to the strap") + return + } + runCatching { + disRefusedPrefKey(lastDeviceAddress)?.let { + context.getSharedPreferences(com.noop.ui.NoopPrefs.NAME, android.content.Context.MODE_PRIVATE) + .edit().putBoolean(it, true).apply() + } + } + } + + /** + * Schedule the unbonded DIS attempt on a link that will carry NO CLIENT_HELLO. + * + * Called from BOTH no-hello paths, and that is the point. There are two of them — the hello suppressed + * after the give-up, and the hello deferred because an explicit pairing was just requested — and the + * second one returns early, so scheduling this in only the first meant the DIS read never happened for + * anyone running the pairing experiment. The two features shipped in the same build and were mutually + * exclusive in exactly the configuration a tester would use. + * + * Both paths leave the same stable state: no handshake outstanding, watchdog cancelled, link holding. + * That is the only state this read is safe to attempt in. + */ + private fun scheduleUnbondedDisRead() { + handler.postDelayed({ gatt?.let { readDisIdentityUnbonded(it) } }, BATTERY_ON_CONNECT_DELAY_MS * 2) + } + + /** + * The UNBONDED DIS attempt (#1635 follow-up). Deliberately separate from [readDisIdentity], which runs + * only inside the post-bond handshake and therefore never runs at all on a strap that will not bond. + */ + private fun readDisIdentityUnbonded(g: BluetoothGatt) { + val refused = runCatching { + disRefusedPrefKey(g.device.address)?.let { + context.getSharedPreferences(com.noop.ui.NoopPrefs.NAME, android.content.Context.MODE_PRIVATE) + .getBoolean(it, false) + } ?: false + }.getOrDefault(false) + if (!shouldReadDisUnbonded( + isWhoop5 = connectedFamily == DeviceFamily.WHOOP5, + bonded = didBond, + alreadyReadThisLink = disRead, + previouslyRefused = refused, + ) + ) 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)") + 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(), gattStatusLabel(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) } } @@ -4226,6 +5797,56 @@ class WhoopBleClient( } } + /** Chained third read — issued after the hardware revision lands. Gives an unbonded 5/MG a firmware + * string it otherwise has no source for at all. */ + private fun readDisFirmwareRevision() { + val g = gatt ?: return + val ops = gattOps ?: return + // A strap that does not publish a firmware revision must not cost us the rest of DIS. The extras + // chain is kicked when this read LANDS, so returning here without starting it would mean a strap + // with a manufacturer string but no 0x2A26 yielded nothing at all — and "not implemented" is + // per-characteristic, not per-service. Same skip-and-carry-on rule readNextDisExtra applies + // further down the chain; this is just its head. + val ch = g.getService(DIS_SERVICE)?.getCharacteristic(DIS_FW_REV_CHAR) + if (ch == null || (ch.properties and BluetoothGattCharacteristic.PROPERTY_READ) == 0) { + readNextDisExtra(DIS_FW_REV_CHAR) + return + } + safeGatt("readCharacteristic(dis-fwrev)") { ops.readCharacteristicCompat(ch) } + } + + /** + * Walk the remaining DIS identity strings, one read per callback. + * + * Chained rather than fired together because Android runs ONE GATT operation at a time: issuing four + * reads at once means three of them return false and are silently lost. Each read is kicked by the + * previous one landing, which is the same shape the serial -> hardware-revision -> firmware chain + * already uses. + * + * A missing characteristic ends the chain rather than stalling it — a strap need not implement all of + * DIS, and the ones it does implement should still be read. + */ + @SuppressLint("MissingPermission") + private fun readNextDisExtra(after: java.util.UUID) { + val next = when (after) { + DIS_FW_REV_CHAR -> DIS_MANUFACTURER_CHAR + DIS_MANUFACTURER_CHAR -> DIS_MODEL_NUMBER_CHAR + DIS_MODEL_NUMBER_CHAR -> DIS_SW_REV_CHAR + // 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 ?: 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. + readNextDisExtra(next) + return + } + safeGatt("readCharacteristic(dis-extra)") { ops.readCharacteristicCompat(ch) } + } + /** * Resolve + log the 5/MG hardware variant from whatever DIS strings have landed (#520). Diagnostic * only — nothing gates on it yet. @@ -4234,11 +5855,15 @@ class WhoopBleClient( * information content here) — never the full string, which would end up in a shareable strap log. */ private fun noteWhoop5VariantFromDis() { - val variant = Whoop5Variant.from(disSerial, disHwRev) + val variant = Whoop5Variant.from(disSerial, disHwRev, disModelNumber) _whoop5Variant.value = variant // #520/#891: publish so MG-only UI can gate on it 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 @@ -4250,11 +5875,47 @@ class WhoopBleClient( * Twin of Swift `reconcileModelFromAttestation`. */ private fun reconcileModelFromAttestation(variant: Whoop5Variant) { if (variant == Whoop5Variant.UNKNOWN) return + val attestingAddress = lastDeviceAddress + if (attestingAddress == null) { + // Should not happen - the attestation arrives on a connected link - but a silent return here + // would be one more path that goes quiet exactly when something is off. + log("DIS attestation (variant=${variant.label}) not applied — the connected strap's address is unknown") + return + } ioScope.launch { - val active = repository.pairedDevices().firstOrNull { it.status == "active" } ?: return@launch - if (DeviceFamily.forRegistryDevice(active.model, active.brand) == DeviceFamily.WHOOP4) { - repository.setDeviceModel(active.id, "WHOOP 5.0 / MG") - log("Corrected device model \"${active.model}\" -> \"WHOOP 5.0 / MG\" from DIS attestation (variant=${variant.label})") + // Correct the row the attestation CAME FROM, never merely "whichever is active". Those are the + // same device on a single-strap install, and different ones the moment somebody pairs a 4.0 + // alongside a 5/MG and leaves the 4.0 active - at which point relabelling by status would + // rewrite the 4.0's row to "WHOOP 5.0 / MG" on the strength of the OTHER strap's DIS block. + // + // This mattered from the first day it could run: the variant only became resolvable once the + // model number was read (#520), so before that this path returned early on UNKNOWN every time + // and the mis-targeting was never reachable. Widening the resolver is what makes it live. + val devices = repository.pairedDevices().filter { it.status != "archived" } + val byAddress = devices.firstOrNull { + it.peripheralId?.equals(attestingAddress, ignoreCase = true) == true + } + // Fall back to the sole paired strap when no row carries the address yet. That is not a guess: + // with exactly one non-archived device there is nothing else the attestation could have come + // from. It matters because `peripheralId` is NULL on the seeded row until the strap is adopted, + // which is precisely the single-strap install this correction was written for - matching on + // address alone would have quietly stopped correcting the case it exists to fix. + // The fallback requires the sole row to be UNADOPTED. "One device, so it must be the one + // attesting" holds only while that row has never been bound to an address; a row that HAS one + // and does not match is a different strap, and relabelling it is the corruption this guard + // exists to prevent. That is not hypothetical - adding a 5/MG to an install whose only row is + // a 4.0 reaches here before the new strap is registered. + val attesting = byAddress ?: devices.singleOrNull()?.takeIf { it.peripheralId == null } + if (attesting == null) { + // Several straps and none owns this address: the attestation cannot be attributed, and + // correcting SOMETHING would be worse than correcting nothing. Say which, and stop. + log("DIS attestation (variant=${variant.label}) not applied — ${devices.size} paired devices" + + " and none carries this strap's address, so it cannot be attributed") + return@launch + } + if (DeviceFamily.forRegistryDevice(attesting.model, attesting.brand) == DeviceFamily.WHOOP4) { + repository.setDeviceModel(attesting.id, "WHOOP 5.0 / MG") + log("Corrected device model \"${attesting.model}\" -> \"WHOOP 5.0 / MG\" from DIS attestation (variant=${variant.label})") } } } @@ -4287,6 +5948,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 @@ -4335,6 +6017,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 @@ -4441,6 +6126,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). @@ -4463,11 +6175,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 @@ -4489,10 +6201,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) @@ -4503,12 +6215,12 @@ 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) { scanning = false - log("Scan failed: $errorCode") + log("Scan failed: ${scanFailureLabel(errorCode)}") } } @@ -4570,6 +6282,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). * @@ -4599,6 +6315,37 @@ class WhoopBleClient( * against the teardown it is about to perform. None of the five is on a per-record path. */ private fun noteLocalTeardown(origin: String) { lastLocalTeardown = origin } + /** Address of the strap 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 process is + * exactly when the tree is worth seeing again, and a strap that changed firmware between runs may + * legitimately expose something new. */ + @Volatile + private var gattTreeDumpedFor: String? = null + + /** True once `createBond()` has been asked for on THIS link (#1635 explicit-bond experiment). One + * attempt per connection: re-issuing while a pairing is in flight gives a system dialog per retry, and + * the retry cadence here is seconds. Cleared with the rest of the per-link state on teardown. */ + @Volatile + private var explicitBondRequestedThisLink = false + + /** When `createBond()` was asked for, so the bond-state trace can time the pairing (#1635). The trace + * otherwise measures only from the CLIENT_HELLO, which this experiment deliberately does not send — + * leaving every pairing transition untimed, so a 200ms pairing and an 8s one read identically. */ + @Volatile + private var explicitBondRequestedAtMs = 0L + + /** True once a bond-state TRANSITION was logged on this link, so the poll can say whether its reading + * agrees with what the receiver heard — or convicts the receiver of hearing nothing (#1635). */ + @Volatile + private var sawBondTransitionThisLink = false + + /** Set by an explicit user Connect so the NEXT 5/MG session re-attempts a suppressed CLIENT_HELLO + * (#1635). Consumed on use, so it grants exactly one retry: someone who put the strap in pairing mode + * gets a fresh attempt, and a strap that still will not answer latches straight back off instead of + * resuming the five-second loop the suppression exists to end. */ + @Volatile + private var helloRetryRequested = false + /** Consecutive involuntary reconnect attempts, feeding the capped-exponential [ReconnectBackoff] * (3, 6, 12, 24, 48, 60s…). Replaces the old fixed [RECONNECT_DELAY_MS] rescan loop so a strap * that's genuinely out of range stops hammering BLE — the Android twin of the iOS @@ -4607,6 +6354,16 @@ class WhoopBleClient( * GATT callbacks (where it's read/reset) land on binder-pool threads on API 26/27. (#48, adopt * from ryanbr — reimplemented under NoopApp) */ @Volatile + /** #2406: when a PASSIVE reconnect was handed to Android, or null when none is outstanding. The + * client does nothing while such a wait runs — no scan, no timer — so without this the log cannot + * tell a long wait from the app having stopped trying. Read once, when the link comes up. + * + * Stamped where [passiveReconnectDecision] chooses the passive handoff, NOT wherever + * `autoConnect = true` appears: the radio-on re-arm, the two bond-loop probes and the launch + * auto-reconnect all pass that flag, and none of them is a wait the app chose to sit out. Another + * path can still win the race and bring the link up first, which is why the line says a wait was + * OUTSTANDING rather than claiming which connect answered it. */ + private var passiveReconnectSinceMs: Long? = null private var failedReconnectAttempts = 0 /** Bump the attempt counter and return the next backoff delay. Called from the disconnect path @@ -4655,6 +6412,12 @@ class WhoopBleClient( private fun cancelPendingReconnect() { pendingReconnectRunnable?.let { handler.removeCallbacks(it) } pendingReconnectRunnable = null + // #2406: a passive wait that was called off is not one that was outstanding when a link came up. + // The STATE_CONNECTED handler reports BEFORE it calls this, so the wait it describes is the one + // that was still pending at that moment; every other caller (a user Connect, the launch + // auto-reconnect, a radio-on re-arm) is superseding the wait, and the log should not later credit + // it to whichever connect happened to win. + passiveReconnectSinceMs = null } /** Run [action] after [delayMs], but ONLY if the SAME continuous connection is still up when it fires. @@ -4673,8 +6436,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 @@ -4849,7 +6618,7 @@ class WhoopBleClient( val pinned = preferredAddress ?: return // single-WHOOP: nothing to re-adopt if (failedAddress == null || !failedAddress.equals(pinned, ignoreCase = true)) return pinnedBondRefusals++ - log("Multi-WHOOP: pinned strap $pinned refused the encrypted bond (status=$status, refusal $pinnedBondRefusals/$PIN_BOND_REFUSAL_LIMIT)") + log("Multi-WHOOP: pinned strap $pinned refused the encrypted bond (${gattStatusLabel(status)}, refusal $pinnedBondRefusals/$PIN_BOND_REFUSAL_LIMIT)") val working = lastBondedAddress if (pinnedBondRefusals >= PIN_BOND_REFUSAL_LIMIT && working != null && !working.equals(pinned, ignoreCase = true)) { readoptWorkingStrap(working = working, awayFrom = pinned) @@ -4901,10 +6670,17 @@ class WhoopBleClient( * iOS BLEManager, which only sets pairingHint on the puffin link). Independent of the multi-WHOOP * pin recovery in [noteBondRefusalIfPinned], which is left untouched. The guidance is mirrored into * [statusNote] (already rendered on the Live screen) so it surfaces with no UI-layer change. */ - private fun noteBondRefusalForPairingHint(status: Int, failedAddress: String?) { - if (!isInsufficientAuthStatus(status)) return - if (didBond) return // already bonded — not a pairing problem - if (connectedFamily != DeviceFamily.WHOOP5) return // WHOOP 4 bonds cleanly; hint is 5/MG-only + private fun noteBondRefusalForPairingHint( + status: Int, + failedAddress: String?, + helloUnacked: Boolean = false, + ) { + // #1635: an unanswered CLIENT_HELLO is as much a refusal as an auth rejection, for the purpose of + // giving up. It was invisible here because the gate tested only the status, and an unanswered + // handshake presents as a plain local terminate - so the streak never grew and the loop never + // stopped. The two signals stay SEPARATE below: only one of them supports naming a cause. + val authRefusal = isInsufficientAuthStatus(status) + if (!countsAsBondRefusal(authRefusal, helloUnacked, didBond, connectedFamily)) return bondRefusalStreak++ if (bondGiveUp.gaveUp) { // #78 hole-4: a refusal during a paused-state salvage probe must not stomp the paused hint @@ -4918,22 +6694,49 @@ class WhoopBleClient( if (_state.value.pairingHint == null) { log("WHOOP 5/MG: encrypted bond refused $bondRefusalStreak times — surfacing pairing guidance (#78)") } - _state.update { it.copy(pairingHint = PAIRING_HINT_TEXT, statusNote = PAIRING_HINT_TEXT) } + // PAIRING_HINT_TEXT names a specific cause (still bonded to the official app). Only an auth + // refusal is evidence for that; an unanswered handshake is not, so it waits for the give-up's + // honest paused hint rather than being told to close an app that may be irrelevant. + if (authRefusal) { + _state.update { it.copy(pairingHint = PAIRING_HINT_TEXT, statusNote = PAIRING_HINT_TEXT) } + } } // #747 / #750: feed the same refusal into the give-up tracker. Once it crosses the higher 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 hashed 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 - bondLoopPausedAtMs = System.currentTimeMillis() // starts the #78 hole-4 salvage-probe floor + // #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. + val opaque = BondRefusalGiveUp.opaqueId(failedAddress ?: "device") + val suppress = giveUpSuppressesHello(authRefusal) + if (suppress) { + runCatching { com.noop.ui.NoopPrefs.setHelloSuppressed(context, failedAddress, true) } + log(BondRefusalGiveUp.helloSuppressedEpitaph(bondGiveUp.refusals, opaque)) + } else { + autoReconnectPausedForBondLoop = true + bondLoopPausedAtMs = System.currentTimeMillis() // #78 hole-4 salvage-probe floor starts here // #1539: park the connect in the same breath as the pause, so this can end while backgrounded. standingConnectWhilePausedIfDue(justTripped = true) - val opaque = BondRefusalGiveUp.opaqueId(failedAddress ?: "device") - log(BondRefusalGiveUp.epitaphLine(bondGiveUp.refusals, opaque)) - _state.update { it.copy(pairingHint = BondRefusalGiveUp.pausedHint()) } + log(BondRefusalGiveUp.epitaphLine(bondGiveUp.refusals, 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). + _state.update { + it.copy(pairingHint = when { + suppress -> BondRefusalGiveUp.helloSuppressedHint() + authRefusal -> BondRefusalGiveUp.pausedHint() + else -> BondRefusalGiveUp.pausedHintHandshakeUnanswered() + }) + } if (testCentre.active(com.noop.testcentre.TestDomain.CONNECTION)) { - log("bond gaveUp refusals=${bondGiveUp.refusals} id=$opaque (auto-reconnect paused)", + log("bond gaveUp refusals=${bondGiveUp.refusals} id=$opaque " + + if (suppress) "(hello suppressed, staying connected)" else "(auto-reconnect paused)", com.noop.testcentre.TestDomain.CONNECTION) } } @@ -4941,8 +6744,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. + // + // 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 @@ -4971,6 +6787,7 @@ class WhoopBleClient( * the duplicate could re-enter discovery and leave every later CCCD write BUSY. Keep the dedup for * stable telemetry and to avoid repeating any future callback-side work. */ private var lastMtuValue = -1 + private var lastMtuAtMs = 0L /** #1066 follow-up: wall-clock of the `requestMtu` attempt, so `onMtuChanged` can log how long the MTU @@ -5013,7 +6830,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). @@ -5073,9 +6904,21 @@ class WhoopBleClient( BluetoothProfile.STATE_CONNECTED -> { // Port of didConnect: mark connected, negotiate a larger ATT MTU, THEN discover. handler.removeCallbacks(scanTimeoutRunnable) + // #2406: how long a passive wait had been outstanding, reported BEFORE + // cancelPendingReconnect clears it and before the backoff counter below is reset, + // since both are part of what the line says. + passiveReconnectSinceMs?.let { since -> + passiveReconnectSinceMs = null + log(passiveReconnectAnsweredLine( + waitedSeconds = (System.currentTimeMillis() - since) / 1000, + attempts = failedReconnectAttempts)) + } // #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.centralManager(_: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 @@ -5090,6 +6933,21 @@ 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 + // #2397: and the per-link signal shape, for the same reason. + rssiReads = 0; rssiWorstDbm = null; rssiSumDbm = 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 @@ -5198,7 +7056,16 @@ class WhoopBleClient( // capture reveals how much headroom that 1.5s has before discovery. -1 when no request preceded // this callback (a bare connection-event MTU). val settledMs = if (mtuRequestedAtMs > 0L) now - mtuRequestedAtMs else -1L - log("MTU negotiated: $mtu (status=$status)" + + // #1997: a refused exchange has to READ as refused. "MTU negotiated: 23 (status=4)" looks like + // a result, and a triager has to already know that 23 is the default the link falls back to. + val mtuOutcome = if (status == BluetoothGatt.GATT_SUCCESS) { + // Unchanged on the healthy path: this line appears in every connection log and dropping + // its status would be a change nobody asked for. Only the refusal renders differently. + "MTU negotiated: $mtu (status=$status)" + } else { + "MTU exchange REFUSED (${gattStatusLabel(status)}) — link stays at the $mtu-byte default" + } + log(mtuOutcome + if (settledMs >= 0L) " — settled ${settledMs}ms after request (fixed wait ${MTU_DISCOVERY_SETTLE_MS}ms)" else "") } @@ -5212,15 +7079,42 @@ class WhoopBleClient( } override fun onReadRemoteRssi(g: BluetoothGatt, rssi: Int, status: Int) { - // Signal strength at connect — diagnoses weak-link syncs (drops/busy storms/timeouts) that - // otherwise look mysterious in the log. Only on a clean read; a failure just stays silent. - if (status == BluetoothGatt.GATT_SUCCESS) log("Signal: RSSI $rssi dBm") + // Signal strength — diagnoses weak-link syncs (drops/busy storms/timeouts) that otherwise look + // mysterious in the log. Only on a clean read; a failure just stays silent. + if (status != BluetoothGatt.GATT_SUCCESS) return + // #2332: a read issued on the PREVIOUS link can answer after that link ended and the next one + // began. Logging it either way is harmless, but the stash below is per-link state the epitaph + // attributes to the drop, so a late answer would put the old link's reading on the new link's + // death - the exact fabrication the epitaph's own doc warns about. `gatt` is the current link; + // anything else is stale. Kept as a WRITE guard only: the line still gets logged, because a + // reading that arrived really did arrive. + val stale = g !== gatt + // #2332: reject implausible readings HERE rather than in the formatter, where the read status + // is known. See [rssiReadingIsUsable] for why it is a band. + if (!rssiReadingIsUsable(rssi)) { + log("Signal: RSSI read returned $rssi dBm (out of band) — not recorded") + return + } + log("Signal: RSSI $rssi dBm" + if (stale) " (from a link that has already ended)" else "") + if (stale) return + // Timestamp FIRST, value second. The epitaph snapshots the value and then reads this clock, so + // publishing in the other order leaves a window where a fresh value is paired with the PREVIOUS + // reading's time - an age up to a full read interval too old, on the one number whose whole job + // is to say how close to the drop the reading was. Both are @Volatile, so this order is the + // guarantee: a reader that sees the new value cannot then see an older clock. + lastRssiAtMs = System.currentTimeMillis() + lastRssiDbm = rssi + // #2397: fold into the per-link shape. AFTER the stale guard, for the same reason the stash + // is: a late answer from a dead link must not be counted against the live one's summary. + rssiReads += 1 + rssiSumDbm += rssi + rssiWorstDbm = rssiWorstDbm?.let { minOf(it, rssi) } ?: rssi } @SuppressLint("MissingPermission") override fun onServicesDiscovered(g: BluetoothGatt, status: Int) { if (status != BluetoothGatt.GATT_SUCCESS) { - log("Service discovery failed: $status") + log("Service discovery failed: ${gattStatusLabel(status)}") return } // Port of didDiscoverServices → didDiscoverCharacteristicsFor, collapsed: Android @@ -5265,6 +7159,82 @@ class WhoopBleClient( "sleep) for 5/MG are still being figured out. WHOOP 4.0 is fully supported today.", ) } cmdCharacteristic = whoop5.getCharacteristic(WHOOP5_CMD_WRITE_CHAR) + // #1635: dump what the strap actually OFFERS, once per strap. Every characteristic in this + // file is looked up by a hardcoded UUID, so anything the 5/MG exposes that nobody guessed + // has never been visible. Reads the already-discovered tree — no GATT operation, no + // traffic, and nothing that can provoke the teardown a write to an encrypted + // characteristic provokes, which is why it works on a strap that never bonds. + // + // ONCE PER STRAP, not once per connect. The tree is static for a given device, and this is + // ~30 lines — on a strap in a reconnect loop, re-emitting it every few seconds would evict + // the connect/drop/bond evidence from a fixed-size rolling buffer with a verbatim repeat of + // something that has not changed. Keyed on the address so switching straps still dumps. + if (testCentre.active(com.noop.testcentre.TestDomain.CONNECTION) && + gattTreeDumpedFor != g.device.address + ) { + gattTreeDumpedFor = g.device.address + val tree = runCatching { + g.services.map { svc -> + svc.uuid.toString() to svc.characteristics.map { it.uuid.toString() to it.properties } + } + }.getOrDefault(emptyList()) + for (line in gattTreeLines(tree)) { + log(line, com.noop.testcentre.TestDomain.CONNECTION) + } + } + // #1635: print what fd4b0002 actually declares. Android exposes no link-encryption state, + // so the property bitmask and the OS bond state are the only proxies available — and the + // CLIENT_HELLO has been written WITH RESPONSE since June without anyone checking whether + // this characteristic supports that. One capture settles it. + // Descriptive only. Whether we will actually write with response is not known here — the + // #1635 suppression decision happens later in startSession — so asserting it would be a + // confidently wrong line on exactly the straps this is about. The verdict is emitted at + // the write itself, where the write type is a fact rather than an assumption. + if (testCentre.active(com.noop.testcentre.TestDomain.CONNECTION)) { + cmdCharacteristic?.let { + log(characteristicCapabilityLine( + uuid = it.uuid.toString(), + properties = it.properties, + writingWithResponse = false, + ), 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") } @@ -5302,7 +7272,29 @@ class WhoopBleClient( ) { // Port of didWriteValueFor: a CONFIRMED-write completion (no error) == bonding succeeded. if (status != BluetoothGatt.GATT_SUCCESS) { - log("Confirmed write failed: status=$status") + log("Confirmed write failed: ${gattStatusLabel(status)}") + // #1635: a FAILED completion is still a completion, and it carries two obligations. + // + // Report it: before this, a failed callback produced no line at all and then a false + // "NO write callback" at disconnect — the outcome line tells the truth instead. + // + // And release the window, but ONLY for the hello's own write. Clearing it stops the + // disconnect path counting the same cycle twice (which would trip the give-up at half the + // intended streak on exactly the auth refusals that already worked), and leaving it open on + // a hello that definitively failed would let the NEXT completion on fd4b0002 — DISABLE_ALARM + // and every other puffin command share it — satisfy both halves of the bond gate below and + // declare a bond the strap never granted. A FOREIGN write failing says nothing about the + // hello, so it must not consume the window a genuine ack is still owed. + val failedHelloChar = characteristic.uuid == WHOOP5_CMD_WRITE_CHAR + if (clientHelloWriteAtMs > 0L) { + log(clientHelloOutcomeLine( + isHelloChar = failedHelloChar, + charUuid = characteristic.uuid.toString(), + elapsedMs = System.currentTimeMillis() - clientHelloWriteAtMs, + status = gattStatusLabel(status), + )) + } + if (failedHelloChar) clientHelloWriteAtMs = 0L // Multi-WHOOP stale-pin recovery (#52). A status of INSUFFICIENT_AUTHENTICATION (5) / // INSUFFICIENT_ENCRYPTION (15) on the bond write == the strap refused the encrypted bond // (the Android twin of the iOS "Encryption/Authentication is insufficient" error). When a @@ -5330,36 +7322,123 @@ class WhoopBleClient( ) } } else if (!didBond && connectedFamily == DeviceFamily.WHOOP5) { - // EXPERIMENTAL (issue #17): the CLIENT_HELLO is now a confirmed write, so this ACK means - // 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. - 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 - 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) - 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).") - g.getService(WHOOP5_SERVICE)?.let { svc -> - for (u in WHOOP5_NOTIFY_CHARS) svc.getCharacteristic(u)?.let { cccdQueue.add(it) } + // #1635: report WHICH characteristic completed, then gate the bond on it. This branch used + // to match on family alone, so ANY completion on a 5/MG link was taken as the CLIENT_HELLO + // ack. The field capture that sized it saw exactly that: the hello was rejected by the stack + // (nothing owed), DISABLE_ALARM's completion landed on the SAME characteristic a moment + // later, and the link was declared bonded on the strength of it. + val isHelloChar = characteristic.uuid == WHOOP5_CMD_WRITE_CHAR + // Read BEFORE the diagnostic below zeroes it — the gate needs the pre-clear value. + val helloOutstanding = clientHelloWriteAtMs > 0L + if (helloOutstanding) { + log(clientHelloOutcomeLine( + isHelloChar = isHelloChar, + charUuid = characteristic.uuid.toString(), + elapsedMs = System.currentTimeMillis() - clientHelloWriteAtMs, + status = gattStatusLabel(status), + )) + // 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 — the one regression this gate must not introduce. + // + // An UNACKED hello leaves the window open, and no later command can inherit it: the + // hello holds `writeInFlight` until its callback fires, and drainWriteQueue refuses to + // start a write while one is in flight (#1095 keys off exactly that stuck state). + if (isHelloChar) clientHelloWriteAtMs = 0L + } else { + // Declining must not be silent — see [clientHelloDeclinedLine]. + log(clientHelloDeclinedLine( + charUuid = characteristic.uuid.toString(), + status = gattStatusLabel(status), + )) + } + // Only the hello's OWN completion is evidence of a bond. Declining here withholds the + // bond declaration and nothing else: the in-flight slot is released and the write queue + // drains at the end of this callback either way. + if (completionIsClientHelloAck( + isHelloChar = isHelloChar, + helloOutstanding = helloOutstanding, + alreadyBonded = didBond, + isWhoop5 = true, + ) + ) { + // EXPERIMENTAL (issue #17): the CLIENT_HELLO is now a confirmed write, so this ACK means + // 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() // the hello is answered either way — nothing left to time out (#50) + bondedDirectAttempt = false // fast-path connect reached a real session (#78 fork) + // 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 + 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) } + } + // The 5/MG handshake tail (SET_CLOCK/GET_CLOCK + the offload kick) now runs when THIS + // CCCD drain completes — see drainCccdQueue's queue-empty branch. Clock-before-history + // is mandatory: an un-clocked WHOOP 5 doesn't save sensor data to flash at all + // ("RTC timestamp … is invalid; not saving data to flash"), so history offloads + // "succeed" with zero body frames. Hardware-validated ordering: CLIENT_HELLO → + // subscribe puffin chars → clock → history. (#78 fork) + drainCccdQueue(g) + // #927: RE-DERIVE the want at arm time, never the precomputed [wantsRealtime]: that value + // can be up to a keep-alive tick (30 s) stale, and a reconnect just OUTSIDE the overnight + // 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; realtimeArmedThisLink = true; send(CommandNumber.TOGGLE_REALTIME_HR, byteArrayOf(1)) } + // #2384: start the live-stream keep-alive HERE, on the hello-acked branch, exactly as the + // Swift twin does in its own `.whoop5` branch. Its only other Android caller is + // [runConnectHandshake], which is WHOOP4-only (the guard is a few lines below), and the + // live-HR latch in [parseStandardHr] is guarded by `!state.bonded` — which the update at + // the top of THIS block has just closed. So a 5/MG that bonds by CLIENT_HELLO started no + // keep-alive at all, on any link, and with it none of what that tick owns: the one-shot + // 0x2A37 re-subscribe when the stream goes quiet, the stall bounce, the #1865 realtime + // re-arm, the #927 overnight-window re-derivation, the #2332 periodic RSSI read and the + // ~60s battery poll. Every recovery for a dead live stream sat behind a start condition + // that a dead live stream cannot meet. + // + // A reporter's log shows the shape of the absence rather than the bug itself: ten links, + // `live hr=0 rr=0` on every one, exactly one `Signal: RSSI` line per link (the read at + // connect, never the periodic one) and a battery cadence with a 13-minute hole in it. + // + // Idempotent and cheap to reach twice: [startKeepAlive] cancels its own callback before + // re-posting, and [keepAliveFire] returns unless the link is connected and bonded, stands + // aside entirely while backfilling, and carries the wide 5/MG stall fuse (#580/#1414). + startKeepAlive() } - // The 5/MG handshake tail (SET_CLOCK/GET_CLOCK + the offload kick) now runs when THIS - // CCCD drain completes — see drainCccdQueue's queue-empty branch. Clock-before-history - // is mandatory: an un-clocked WHOOP 5 doesn't save sensor data to flash at all - // ("RTC timestamp … is invalid; not saving data to flash"), so history offloads - // "succeed" with zero body frames. Hardware-validated ordering: CLIENT_HELLO → - // subscribe puffin chars → clock → history. (#78 fork) - drainCccdQueue(g) - // #927: RE-DERIVE the want at arm time, never the precomputed [wantsRealtime]: that value - // can be up to a keep-alive tick (30 s) stale, and a reconnect just OUTSIDE the overnight - // 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)) } } else if (!didBond && connectedFamily == DeviceFamily.WHOOP4) { didBond = true cancelBondWatchdog() // secure handshake completed — stand the watchdog down (#50) @@ -5377,6 +7456,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() } @@ -5410,9 +7490,20 @@ class WhoopBleClient( status: Int, ) { if (status != BluetoothGatt.GATT_SUCCESS) { - log("Notify enable failed for ${descriptor.characteristic?.uuid}: status=$status") + log("Notify enable failed for ${descriptor.characteristic?.uuid}: ${gattStatusLabel(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 @@ -5450,6 +7541,7 @@ class WhoopBleClient( status: Int, ) { if (status == BluetoothGatt.GATT_SUCCESS) onInbound(characteristic.uuid, value) + else noteReadFailure(characteristic.uuid, status) } @Deprecated("Deprecated in API 33; retained for API 26..32 where the value-bearing overload isn't called") @@ -5460,6 +7552,7 @@ class WhoopBleClient( ) { @Suppress("DEPRECATION") if (status == BluetoothGatt.GATT_SUCCESS) characteristic.value?.let { onInbound(characteristic.uuid, it) } + else noteReadFailure(characteristic.uuid, status) } } @@ -5467,7 +7560,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 { @@ -5492,6 +7656,39 @@ class WhoopBleClient( uuid == DIS_HW_REV_CHAR -> { disHwRev = bytes.toString(Charsets.UTF_8).trim { it == '\u0000' || it.isWhitespace() } noteWhoop5VariantFromDis() + readDisFirmwareRevision() + } + uuid == DIS_MANUFACTURER_CHAR || uuid == DIS_MODEL_NUMBER_CHAR || uuid == DIS_SW_REV_CHAR -> { + val label = when (uuid) { + DIS_MANUFACTURER_CHAR -> "manufacturer" + DIS_MODEL_NUMBER_CHAR -> "modelNumber" + else -> "softwareRev" + } + val v = bytes.toString(Charsets.UTF_8).trim { it == '\u0000' || it.isWhitespace() } + // #520: the model number is the ONE DIS extra that is not merely diagnostic. A field + // capture showed serial prefix "MGB" and hwRev "WS50_r03" matching none of the variant + // heuristics, on a strap whose model number said "MG" — so this is what finally resolves it. + if (uuid == DIS_MODEL_NUMBER_CHAR) { + disModelNumber = v.ifBlank { null } + noteWhoop5VariantFromDis() + } + // Log only — nothing gates on these. They are identity strings for a strap that may never + // pair, and the point is to have them in a capture at all. + log("DIS: $label=${v.ifBlank { "(empty)" }}") + readNextDisExtra(uuid) + } + uuid == DIS_FW_REV_CHAR -> { + val fw = bytes.toString(Charsets.UTF_8).trim { it == '\u0000' || it.isWhitespace() } + if (shouldPublishDisFirmware(fw, _state.value.strapFirmware)) { + _state.update { it.copy(strapFirmware = fw) } + runCatching { NoopPrefs.setFirmwareFor(context, lastDeviceAddress, fw) } + // Named as the DIS source, because it is not the same reading as the decoded one the + // 4.0 shows and a capture must not have to guess which it is looking at. + log("DIS: firmware=$fw (standard profile, no bond required)") + } else { + log("DIS: firmware=${fw.ifBlank { "?" }} not published — a decoded value already stands") + } + readNextDisExtra(DIS_FW_REV_CHAR) } // WHOOP4 custom notify chars, OR the WHOOP 5/MG puffin notify chars (fd4b0003/4/5/7) once // bonded — both carry framed records (REALTIME_DATA etc.) through the family-aware reassembler. @@ -5499,7 +7696,16 @@ 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 `FrameRouter.noteReassemblerDrops`. + val completedFrames = reassembler.feed(bytes) + rejectTally.absorbReassemblerDrops(reassembler.belowMinimumLengthDrops) + rejectTally.absorbReassemblerHeaderDrops(reassembler.headerChecksumDrops) + 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 @@ -5509,14 +7715,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 @@ -5560,6 +7778,37 @@ 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) + } + // #2338: a reply to the read-only advertising-name probe (141). In-flight-guarded like + // 151 above, for the same reason: 0x8D could coincidentally be some data frame's + // cmd-offset byte, and a stray match must never pop a user-triggered dialog. + if (frame.size > cmdOff && (frame[cmdOff].toInt() and 0xFF) == CommandNumber.GET_ADVERTISING_NAME.rawValue && + _advertisingNameProbe.value == WAITING_ADVERTISING_NAME_PROBE) { + val decoded = advertisingNameFromWhoop5Response(frame) + // Redact BEFORE anything is stored or logged. An advertising name carries a + // person's name (#2337), and this value also renders behind a copy button, a sink + // the log scrubber never sees. + val text = if (decoded != null) { + "the strap answered 141 with a name: ${logSafeDeviceName(decoded)}" + } else { + "the strap answered 141, but the payload held no printable name" + } + log("Advertising-name probe (141): $text") + _advertisingNameProbe.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 && @@ -5605,6 +7854,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 @@ -5617,16 +7878,52 @@ 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)") + // #815: confirmed on both WHOOP 4.0 and 5.0/MG, so bank it unconditionally. + // The Today sync chip reads this while backfilling is true. + _state.update { it.copy(pagesBehindAtConnect = pagesBehind.toInt()) } + } else { + log( + "Strap backlog pages behind: not decodable from this frame (#689 — offsets may " + + "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). @@ -5676,6 +7973,78 @@ class WhoopBleClient( ) log(line, com.noop.testcentre.TestDomain.UNIVERSAL) } + // #2117: the R-R TRANSPORT picture, beside the clock-drift line and for the + // same reason. A WHOOP 5 window is pinned to one transport, so a window with + // no beat on a scorable channel reads back empty and everything beat-derived + // (HRV, respiratory rate) blanks while heart-rate values carry on. The HRV + // analyzer cannot explain that: handed nothing it honestly says nInput=0, with + // no way to tell "banked nothing" from "banked beats the policy refused". + // These two facts separate those, and the store already has both. Rides EVERY + // export, because the wearer who needs it is the one who did not know to turn + // a mode on. Pure formatter, no behaviour change. Twin of the Apple emit. + if (testCentre.active(com.noop.testcentre.TestDomain.UNIVERSAL)) { + // Three indexed reads, so OFF the BLE callback thread: this is + // observability and must never sit in front of the connection path. + ioScope.launch { + val rrLine = runCatching { + // The two MINs only feed a line the formatter suppresses unless + // this is strict, so a device the policy does not govern stops + // after the one registry read. Not hypothetical: a 4.0 in a + // reconnect burst (#1120) runs this repeatedly, and would other- + // wise fetch them from the store the backfill is writing through, + // to discard them every time. Twin of the Apple early-out. + if (!repository.isWhoop5RrSource(deviceId)) { + null + } else { + com.noop.analytics.ConnectionTrace.rrTransportLine( + strictWhoop5 = true, + firstRecordedUnix = repository.firstRecordedRrTs(deviceId), + firstScorableUnix = + repository.firstScorableWhoop5RrTs(deviceId), + ) + } + }.getOrNull() + // Silent when it cannot read its inputs: a diagnostic that cannot + // measure says nothing rather than guessing, so the line is absent. + if (rrLine != null) { + log(rrLine, 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) { + // #2012: say WHY, on the flip only. This half of the Rest + // "Pending sync" state used to change in total silence. + log( + PendingSyncDiagnostic.line( + pending = p, + site = PendingSyncDiagnostic.SITE_CONNECT, + newestUnix = newestForPending, + frontierUnix = f, + futureDated = isFutureDatedNewest(newestForPending, wallNowP), + persistedRows = null, + thresholdSec = AUTO_CONTINUE_BEHIND_GAP_SECONDS, + ), + ) + _state.value = _state.value.copy(historyPendingSync = p) + } + } + } } } @@ -5691,17 +8060,27 @@ 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 — + // the offload flag lets analysis filter), BEFORE routing so frames are retained + // before the trim ack deletes the strap's copy. No-op (single null check) when the + // toggle is off. (#78 fork) + // + // Deliberately OUTSIDE the `backfilling` gate, where it used to sit. The comment + // already claimed "every frame of the session" and the gate made that false: live and + // event frames arriving between syncs were never recorded at all. + // + // This does NOT rescue a strap that never bonds, and it is worth saying so here so the + // next reader does not assume it did. The frames that reach this path are the puffin + // notify characteristics, and those are subscribed only in the CLIENT_HELLO-ack + // branch — so an unbonded 5/MG still produces an empty capture. Making that case + // yield anything means capturing the standard-profile notifications instead, which is + // a different feature and mostly already-decoded data (#1635). + if (connectedFamily == DeviceFamily.WHOOP5 && captureWriter != null) { + writeWhoop5BackfillCapture(uuid.toString(), frame) + } if (backfilling) { - // Opt-in raw capture: record EVERY frame of the session (offload AND live - // flood — the offload flag lets analysis filter), BEFORE routing so frames - // are retained before the trim ack deletes the strap's copy. No-op (single - // null check) when the toggle is off. (#78 fork) - if (connectedFamily == DeviceFamily.WHOOP5 && captureWriter != null) { - writeWhoop5BackfillCapture(uuid.toString(), frame) - } // Historical offload: route ONLY genuine offload frames (47/48/49/50) through // the serial drain (preserves chunk order) + re-arm the idle watchdog on them. // The live type-40/43 flood is dropped here (extractHistoricalStreams ignores @@ -5783,6 +8162,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:)`. @@ -5796,9 +8212,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 @@ -5820,6 +8242,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 @@ -5842,7 +8270,21 @@ class WhoopBleClient( if (_state.value.strapFirmware != fw) { _state.update { it.copy(strapFirmware = fw) } // Persist so the debug export can name the firmware offline (state clears on disconnect). - runCatching { NoopPrefs.setLastFirmware(context, fw) } + // Keyed by the address THIS connection used, not globally: a second strap would otherwise + // overwrite the first's value and both would report whichever connected last - a 5/MG showing + // a 4.0's 41.17.6.0. + runCatching { NoopPrefs.setFirmwareFor(context, lastDeviceAddress, fw) } + } + + } + + // #1634: the 5/MG hello decoded no firmware. The guards fail closed by design, so this is the + // only place that can say WHY - a different generation byte vs a MOVED offset. Logged once per + // connection (the value is stable), so a capture from an undecoded strap carries the evidence. + (parsed.parsed["fw_gate"] as? String)?.let { gate -> + if (loggedFirmwareGate != gate) { + loggedFirmwareGate = gate + log(gate) } } val respCmd = parsed.parsed["resp_cmd"] as? String @@ -5859,9 +8301,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, @@ -5869,6 +8316,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 @@ -5895,6 +8343,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 @@ -5909,6 +8386,31 @@ 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) }}", + ) + } + if (connectedFamily == DeviceFamily.WHOOP4 && + respCmd?.startsWith("TOGGLE_GENERIC_HR_PROFILE") == true + ) { + // #2400: an acknowledgement is evidence that opcode 14 was answered, not a read-back + // of the advertising state. Keep the decoded result and full frame for comparison + // across firmware without presenting either as confirmation of the physical effect. + log( + "Broadcast HR: WHOOP 4 command response received " + + "result=${result ?: "none"}, effect not confirmed " + + "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 && @@ -5984,6 +8486,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() } @@ -6022,13 +8531,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) @@ -6080,6 +8603,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). @@ -6142,6 +8738,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 @@ -6160,16 +8757,25 @@ class WhoopBleClient( val rr = mutableListOf() if (rrPresent) { + val isWhoop5 = connectedFamily == DeviceFamily.WHOOP5 while (idx + 1 < data.size) { val raw = (data[idx].toInt() and 0xFF) or ((data[idx + 1].toInt() and 0xFF) shl 8) idx += 2 - // Convert 1/1024 s units to milliseconds (matches the WHOOP store's R-R in ms). ROUNDED, - // byte-identical to StandardHeartRate.parse + the Swift twin; plain integer division - // truncated, diverging up to ~0.5 ms per interval into RMSSD/HRV. (ryanbr, #1032) - rr.add(Math.round(raw / 1024.0 * 1000.0).toInt()) + // WHOOP 5 sends milliseconds directly on 0x2A37 (non-compliant with BLE spec's + // 1/1024-s unit). For other devices, convert per spec. + rr.add(if (isWhoop5) raw else Math.round(raw / 1024.0 * 1000.0).toInt()) } } + // #2384: one line per 30s saying the standard profile delivered a reading, and whether it was + // usable. Emitted BEFORE the gates below, which is the whole point: a value they drop leaves no + // other trace. Port of the iOS `HR notify:` line. + val hrNotifyNowMs = System.currentTimeMillis() + if (shouldLogStandardHrNotify(lastStandardHrNotifyLogMs, hrNotifyNowMs)) { + lastStandardHrNotifyLogMs = hrNotifyNowMs + log(standardHrNotifyLine(hr, rr.size)) + } + // R-R: the standard profile is the reliable source — surface whenever present. withRRIntervals // also feeds the Live console's rolling rrRecent buffer. if (rr.isNotEmpty()) _state.update { it.withRRIntervals(rr) } @@ -6178,6 +8784,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 — @@ -6195,7 +8802,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), connectedFamily) } /** The Test Centre gate, bound once to the app's single "noop_testcentre" prefs file. Lazily built so @@ -6257,6 +8864,8 @@ class WhoopBleClient( handler.postDelayed({ requestSync(BackfillTrigger.CONNECT) }, INITIAL_BACKFILL_DELAY_MS) startBackfillTimer() startKeepAlive() + // WHOOP 4's broadcast mode is link/runtime state, so restore an opted-in mode after reconnect. + if (PuffinExperiment.from(context).broadcastHr) setBroadcastHr(true) // Arm realtime HR now if a screen already wants it (Live/Health Monitor opened before the bond // completed) OR the continuous-capture preference wants it — otherwise the stream would only // start at the next keep-alive tick (issue #18). Mark it armed so reconcileRealtime() tracks the @@ -6265,18 +8874,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) } @@ -6310,8 +8943,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). @@ -6355,6 +8987,24 @@ class WhoopBleClient( // Advance the tick for both families so the ~60s battery cadence also fires on 5/MG (it // previously incremented only inside the WHOOP 4 branch). keepAliveTick += 1 + // #2332: re-read link RSSI on the ODD tick (~60s), for BOTH families. Before this the only + // reading came from the one-shot [RSSI_READ_DELAY_MS] read at connect, so a link that + // degraded and died at the supervision timeout left a single number from its third second + // and nothing about the drop — for the end status that literally says "went out of range". + // A periodic read also gives the log a SLOPE rather than a point, which is what separates a + // link that was always marginal from one that walked out of range. + // + // Odd phase, which keeps it off the same tick as the capture's phone-battery line and, on a + // DISCHARGING strap, off the battery poll too. It does NOT guarantee a tick to itself, and + // it is not meant to: [batteryPollDue] returns true on EVERY tick while charging, and the + // WHOOP4 branch re-sends TOGGLE_REALTIME_HR every tick whenever the Live screen wants it. + // Sharing is harmless — Android runs one GATT op at a time, so a read that loses the slot + // just produces no line this minute, which is why nothing here retries or reports. + // safeGatt for the #314 dead-binder case, matching the connect-time read: a signal reading + // must never be able to tear the link down. + if (keepAliveTick % 2 == 1) { + gattOps?.let { safeGatt("readRemoteRssi") { it.readRemoteRssiCompat() } } + } // #1121: ONLY while a detailed capture is running (zero work otherwise — one volatile read): // sample the PHONE battery on the same ~60s cadence as the strap poll, so the capture carries // a phone-battery curve on the offload/connection timeline ("phone dropped N% across this @@ -6365,17 +9015,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() + } } } } @@ -6491,26 +9181,27 @@ 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())) refreshConnectionPriority() // #477: live-HR on → HIGH, off → back to idle. No-op unless enabled. } - /** - * EXPERIMENTAL (#181): make the strap advertise its heart rate as a standard BLE HR sensor by - * writing the device-config flag whoop_live_hr_in_adv_ind_pkt = "1" (on) / "0" (off) via - * SET_DEVICE_CONFIG (0x77). Validated on real hardware: with it on, the strap advertises 0x180D + - * the live HR in its manufacturer data, so a Garmin (Edge/watch), Zwift or gym HR client pairs to it - * directly. Reversible; opt-in. Mirrors `BLEManager.setBroadcastHr`. (Broadcast HR) - */ + /** Make the strap advertise as a standard BLE HR sensor. WHOOP 4 uses its reversible + * TOGGLE_GENERIC_HR_PROFILE command; WHOOP 5/MG keeps the existing device-config path. */ fun setBroadcastHr(on: Boolean) { - if (connectedFamily != DeviceFamily.WHOOP5) { - log("Broadcast HR: needs a WHOOP 5.0/MG strap — ignored."); return - } val s = _state.value if (!s.connected || !s.bonded) { - log("Broadcast HR: connect and bond a 5/MG strap first — ignored."); return + log("Broadcast HR: connect and bond the strap first — ignored."); return + } + if (connectedFamily == DeviceFamily.WHOOP4) { + send(CommandNumber.TOGGLE_GENERIC_HR_PROFILE, byteArrayOf(if (on) 1.toByte() else 0.toByte())) + log("Broadcast HR: WHOOP 4 ${if (on) "enable" else "disable"} command sent (14); effect not confirmed.") + return + } + if (connectedFamily != DeviceFamily.WHOOP5) { + log("Broadcast HR: strap family is not known yet — ignored."); return } // Mutually exclusive with the ECG gate: both verify over the SAME 121 read-back opcode, so if both // were in flight one strap reply would be consumed by both handlers and cross-contaminate the other's @@ -6603,7 +9294,7 @@ class WhoopBleClient( * UNKNOWN is never MG. This is the gate an MG-only capability asks (#891); deliberately independent of * [DeviceFamily], which describes the WIRE PROTOCOL and treats MG and 5.0 as one family. Mirrors the * Swift `BLEManager.whoop5Variant`. */ - fun whoop5Variant(): Whoop5Variant = Whoop5Variant.from(disSerial, disHwRev) + fun whoop5Variant(): Whoop5Variant = Whoop5Variant.from(disSerial, disHwRev, disModelNumber) private val _whoop5Variant = MutableStateFlow(Whoop5Variant.UNKNOWN) /** #520/#891: the attested variant as observable state, for UI that gates an MG-only action. Set from @@ -7017,6 +9708,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 @@ -7079,14 +9771,34 @@ class WhoopBleClient( // stream HR (even over the standard 0x2A37 profile) on an UNauthenticated link, so the old // unacknowledged write left it bond-less and silent — CLIENT_HELLO written, then nothing (#17). // Hold the slot until the ACK; the opt-in puffin probe now fires post-bond (onCharacteristicWrite). + // UNgated, unlike the descriptive line at discovery: this fires only when the characteristic does + // not declare Write, which is a decisive, actionable, once-per-link finding — and the one most + // likely to be missing from a report by someone who never turned Test Centre on. + if (ch.properties and BluetoothGattCharacteristic.PROPERTY_WRITE == 0) { + log(characteristicCapabilityLine(ch.uuid.toString(), ch.properties, writingWithResponse = true)) + } log("WHOOP 5/MG: writing CLIENT_HELLO to fd4b0002 with response (to trigger bonding, experimental).") writeInFlight = true + clientHelloWriteAtMs = System.currentTimeMillis() 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 } } @@ -7108,7 +9820,293 @@ class WhoopBleClient( } when (connectedFamily) { DeviceFamily.WHOOP4 -> writeBondFrame(g, cmd) - DeviceFamily.WHOOP5 -> writeClientHello(g, cmd) + DeviceFamily.WHOOP5 -> { + // #1635: open the raw capture HERE, not only on entering backfill, so frames arriving + // outside a sync window are recorded. Idempotent, so the existing call in + // enterBackfilling still covers a capture switched on mid-session. Opening appends (never + // truncates — see startWhoop5BackfillCapture), so doing it per connect cannot lose a + // previous session's material. + if (PuffinExperiment.from(context).isCaptureEnabled) startWhoop5BackfillCapture() + + // #1635 experiment: ask Android to pair, instead of hoping the encrypted write provokes + // it — which the bond-state trace showed never happens. Runs BEFORE the hello decision + // because the two must not overlap: writing while a pairing is in flight is the behaviour + // that has been dropping the link, so doing both would test nothing. + val osBonded = g.device.bondState == BluetoothDevice.BOND_BONDED + // Once per link, before any decision: a hello that fails on an unencrypted link and one + // that fails on an ENCRYPTED link are completely different findings, and they have been + // printing identically (#1635). + // + // Gated, unlike the bond-state TRANSITION lines. This one fires on every connect, so on a + // strap in a reconnect loop it is a line every few seconds into a fixed-size rolling + // buffer for someone who is not debugging. A transition line fires only when the OS bond + // actually changes — zero times on the straps this is about — so it costs nothing to leave + // always-on and is the evidence most likely to be missing when someone reports a problem. + if (testCentre.active(com.noop.testcentre.TestDomain.CONNECTION)) { + 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, + inconclusiveLinksSoFar = unbondedProbeInconclusiveLinks, + ), + ) + ) { + 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 + explicitBondRequestedAtMs = System.currentTimeMillis() + val where = bondStateName(g.device.bondState) + // A THROW is not a refusal. createBond needs BLUETOOTH_CONNECT, and swallowing a + // SecurityException into `false` would print a confident claim about the strap for a + // problem that is entirely local. + runCatching { g.device.createBond() }.fold( + onSuccess = { initiated -> + log(explicitBondRequestLine(initiated, where)) + // 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) { + // Ask the device directly rather than waiting on our own broadcast + // receiver, which is a suspect in exactly the silence being investigated. + handler.postDelayed({ + runCatching { + log(bondStatePollLine(g.device.bondState, sawBondTransitionThisLink)) + } + }, EXPLICIT_BOND_POLL_MS) + } + }, + 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 + } + 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 + // thing most likely to abort the pairing we just asked for, so the experiment would + // sabotage itself ~7s in and report a refusal that never happened. + cancelBondWatchdog() + // This link carries no hello either, so it is the same stable state the suppression + // 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 + } + + // #1635: the hello is what ends the link on a strap that never answers it. Once the + // give-up has latched, skip it and let the standard-profile HR stream keep running. + // + // Consumed unconditionally, so the single retry an explicit Connect grants 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. + val userAsked = helloRetryRequested + helloRetryRequested = false + 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 + // bounces the link whenever didBond is still false. Suppressing the hello guarantees + // 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). 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 + // prove nothing, and adding a read to a handshake that is already failing would make + // 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() + } + } } } @@ -7123,6 +10121,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 @@ -7131,6 +10141,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) @@ -7140,7 +10151,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) @@ -7242,46 +10257,136 @@ 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, + family: DeviceFamily) { 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 + val source = if (family == DeviceFamily.WHOOP5) + com.noop.protocol.RrSourceChannel.WHOOP5_STANDARD else null + for (r in rr) if (r in 250..3000) stdRr.add(RrRow(ts, r, source)) + 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, + )) + } } } @@ -7299,7 +10404,28 @@ 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, + inconclusiveLinksSoFar = unbondedProbeInconclusiveLinks, + ), + )) return } if (backfilling) return @@ -7340,8 +10466,16 @@ class WhoopBleClient( refreshConnectionPriority() // #477: escalate to HIGH for the offload burst (faster sync). No-op unless enabled. applyPreferredPhy() // #533: prefer LE 2M for the burst (halves air-time). No-op unless enabled. // Opt-in raw capture (research aid): pref read fresh per session, like the probes gate. + // Normally already open from the connect hook; this covers a capture switched on mid-session. if (connectedFamily == DeviceFamily.WHOOP5 && PuffinExperiment.from(context).isCaptureEnabled) { startWhoop5BackfillCapture() + // Give the offload a FULL line budget. Since the capture was hoisted out of the `backfilling` + // gate it also records the live flood, and on a link that stays up overnight that flood can + // exhaust the 40k cap before the morning sync — pausing capture at exactly the moment the + // offload arrives, and losing the material this feature exists to collect. The line cap is a + // runaway guard, not a quota the live stream is entitled to spend, so the offload gets it + // back. The BYTE cap still bounds the file and is untouched. + captureLines = 0 } if (connectedFamily == DeviceFamily.WHOOP5) { // Re-apply the Broadcast-HR device-config flag if the user opted in (#181). @@ -7400,10 +10534,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 } @@ -7616,6 +10761,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" @@ -7624,6 +10781,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 @@ -7646,15 +10808,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 @@ -7689,8 +10859,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. @@ -7738,9 +10918,13 @@ class WhoopBleClient( handler.removeCallbacks(backfillTimeoutRunnable) 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. + log("Backfill: session ended — reason=$reason" + sessionEndedOutcome(reason, persistedSensorRows)) + // 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 @@ -7763,6 +10947,13 @@ class WhoopBleClient( // #1008/#1118: the pre-storage R-R census for this offload, next to the persisted tally so one // line pair says what the decoder OFFERED and what the store KEPT. Twin of the Swift emit. backfiller.sessionRrEmissionLine()?.let { rrLine -> log(rrLine) } + // #2019: and the v26 optical census, in the same place, so one block says what the offload + // banked AND whether those optical windows can be reconstructed at all. + com.noop.protocol.ppgWaveformCensusLine( + backfiller.sessionPpgWindows, backfiller.sessionPpgWithBase, + backfiller.sessionPpgSaturated, backfiller.sessionPpgBaseMin, + backfiller.sessionPpgBaseMax, + )?.let { ppgLine -> log(ppgLine) } // #990: fold this session's drained rows into the persisted ALL-TIME tally at the single // summary emit point, so the Connection readout can show install-lifetime progress beside // the per-session count (which resets on every reconnect). Unconditional, like the summary @@ -7849,6 +11040,41 @@ 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) { + // #2012: say WHY, on the flip only (see the connect site). + log( + PendingSyncDiagnostic.line( + pending = pending, + site = PendingSyncDiagnostic.SITE_POST_OFFLOAD, + newestUnix = newest, + frontierUnix = frontier, + // Only meaningful with a newest to test; the formatter ignores it without one. + futureDated = newest != null && isFutureDatedNewest(newest, wallNow), + persistedRows = persistedSensorRows, + thresholdSec = AUTO_CONTINUE_BEHIND_GAP_SECONDS, + ), + ) + _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. @@ -7993,21 +11219,124 @@ 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) { + // ALWAYS-ON, and deliberately before every guard below. A link ending is rare-event evidence, + // which the house rule keeps out of the Test Centre gate precisely because it is what is missing + // when someone reports a problem without one enabled. It was missing anyway: the rich epitaph + // below is wrapped in `linkUpSinceMs?.let`, and this method clears that field, so a second pass + // for the same drop emits NOTHING. A field export with the Connection domain ON carried three + // connects, three reconnects and not one line saying a link had ended or why. The question "what + // dropped the connection" was unanswerable from a log that had been asked for specifically to + // answer it. + // + // Counts only: the GATT status, whether we asked for it, and whether a link had been up. No + // traffic figures here, because those are what the epitaph guard exists to avoid fabricating for + // a link that never existed. + log( + "link down: ended=" + (if (intentionalDisconnect) "intentional" else "status=$status") + + " " + disconnectStatusLabel(status) + " hadLink=" + (linkUpSinceMs != null), + ) + val helloWasUnacked = clientHelloWriteAtMs > 0L + // #1635: a CLIENT_HELLO that was accepted by the stack and never completed leaves no trace at + // all - the dominant shape in the field capture (14 of 16). Say so before the state is reset. + if (clientHelloWriteAtMs > 0L) { + log(clientHelloOutcomeLine(false, null, System.currentTimeMillis() - clientHelloWriteAtMs, null)) + clientHelloWriteAtMs = 0L + } + + // #1635: and count it as a refusal so the give-up can latch. Only when the status is NOT an auth + // rejection - those already reach noteBondRefusalForPairingHint from onCharacteristicWrite, and + // counting the same cycle twice would trip the give-up at half the intended streak. + if (helloWasUnacked && !isInsufficientAuthStatus(status)) { + noteBondRefusalForPairingHint(status, lastDeviceAddress, helloUnacked = true) + } + // #1151: flush any pending frame-timing window so the frames right before this drop are recorded // (not stranded), and the next connection starts a fresh window rather than spanning the gap. No-op // when capture is off. Do it BEFORE the connect-down line so the summary reads before the drop. flushFrameTimingSummary() - // #1263: flush the durable strap-log tail so a completed session's last partial batch survives to a - // later export even if the process is killed before the next 32-line mirror (twin of iOS's flush on - // disconnect). The connect-down line logged just below still mirrors again once it crosses a batch. - flushDurableLogTail() // 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 -> + // #2332: ONE read of the stash, not two. [lastRssiDbm] is written from the GATT callback + // (a binder thread) and read here, so taking the value and its timestamp as separate volatile + // reads would let a reading land between them and pair a value with the wrong age - which is + // exactly what the field's doc promises cannot happen. Snapshot the value, then derive the age + // from it, so the pair the epitaph prints is always one reading. Read BEFORE the clear below, + // for the same reason `since` is: the age is measured against THIS drop, and a null value + // yields a null age, so a link that ended before any read prints "never read" rather than an + // invented number. + val rssiAtDrop = lastRssiDbm + val rssiAgeAtDrop = rssiAtDrop?.let { System.currentTimeMillis() - lastRssiAtMs } + log(ConnectionReadout.linkEpitaph( + upMillis = System.currentTimeMillis() - since, + inboundFrames = inboundFrames, inboundBytes = inboundBytes, + cmdChannelFrames = cmdChannelFrames, realtimeArmed = realtimeArmedThisLink, + rssiDbm = rssiAtDrop, + rssiAgeMillis = rssiAgeAtDrop, + rssiReads = rssiReads, rssiWorstDbm = rssiWorstDbm, rssiSumDbm = rssiSumDbm, + // #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 + rssiReads = 0; rssiWorstDbm = null; rssiSumDbm = 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 + // #2332: scoped to the link, so it dies with it. Leaving it set would hand the NEXT link's epitaph + // a reading taken on this one. + lastRssiDbm = null + lastRssiAtMs = 0L // Reboot trail: if a user reboot is in flight, this drop is the strap acting on it. Log how long the // link stayed up (a real reboot drops within ~1-2s) and cancel the no-disconnect watchdog. The // reconnect time is logged separately once the handshake completes; rebootRequestedAtMs stays set so @@ -8093,6 +11422,22 @@ 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) && + !helloOverrideActive( + PuffinExperiment.from(context).helloDespiteBondRefusal, + helloOverrideAttempts, + ) + }.getOrDefault(false), ) && bondWatchdogBackoff.recordBounce() ) { log("Strap connects and subscribes but never finishes pairing, then self-drops before the bond watchdog fires (${bondWatchdogBackoff.consecutiveBounces} cycles) " + @@ -8102,16 +11447,19 @@ class WhoopBleClient( // #1539: park the connect in the same breath as the pause, so this can end while backgrounded. standingConnectWhilePausedIfDue(justTripped = true) if (_state.value.reconnectGuide == null) { - _state.update { it.copy( - reconnectGuide = """ - Your strap connects but never finishes pairing with NOOP, so it drops and retries in a loop. This is almost always a stale Bluetooth pairing, usually after a WHOOP firmware update, or the official WHOOP app holding the strap. NOOP works fine once it's re-paired: - - 1. Quit the official WHOOP app (or turn off Bluetooth on that phone). - 2. Open Settings → Bluetooth, find your WHOOP, and Forget / Unpair it. - 3. Tap the band repeatedly until its LEDs flash blue (pairing mode). - 4. Come back here and tap Connect. - """.trimIndent() - ) } + // #1997: the pause is right either way, it stops both batteries draining on a loop. What + // was wrong is blaming the strap. When the MTU exchange was refused and nothing arrived, + // the phone is holding a connection the strap is not on, and re-pairing cannot change + // that: the reporter had been doing it several times a day on this guide's advice. + // Read the OS signal ONCE: isStrapAclHeld is a binder call, and the diagnostic and the + // guide must agree about what was observed anyway. + val aclHeldNow = lastDeviceAddress?.let { isStrapAclHeld(it) } == true + // Say what the choice turned on, so a shared log can tell whether the held-link branch + // fired or was simply never reachable in the field. + val heldLink = heldLinkWithoutTraffic(aclHeldNow, inboundFrames) + log(BondRefusalGiveUp.heldLinkDiagLine(aclHeldNow, inboundFrames, heldLink)) + val guide = BondRefusalGiveUp.reconnectGuideFor(heldLink) + _state.update { it.copy(reconnectGuide = guide) } } } @@ -8128,6 +11476,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, @@ -8165,6 +11514,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, @@ -8178,7 +11566,7 @@ class WhoopBleClient( // #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 // re-arms it by tapping Connect (clearPairingHintForUserConnect). We do NOT schedule a reconnect. - log("Disconnected (status=$status); auto-reconnect paused (strap keeps refusing to pair; tap Connect once it's free)") + log("Disconnected ${disconnectStatusLabel(status)}; auto-reconnect paused (strap keeps refusing to pair; tap Connect once it's free)") // #1539: a connect attempt CONSUMES the parked request, so re-park it — floored, so a reachable // strap that keeps refusing gets one attempt per window instead of a connect/refuse spin. standingConnectWhilePausedIfDue() @@ -8212,7 +11600,27 @@ class WhoopBleClient( } if (staleDirectBond) { staleDirectFailures++ - log("Disconnected (status=$status) before the bonded fast-path reached a session — stale OS bond (attempt $staleDirectFailures); falling back to a scan") + // 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 ${disconnectStatusLabel(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 // update / official WHOOP app re-bond), not a one-off transient drop. Surface the same @@ -8232,7 +11640,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 @@ -8261,14 +11669,22 @@ class WhoopBleClient( // count — keep it DIRECT; only a genuinely-out-of-range band escalates to PASSIVE for power. val aclHeld = isStrapAclHeld(dev.address) val passiveReconnect = passiveReconnectDecision(failedReconnectAttempts, aclHeld) - log("Disconnected (status=$status); reconnecting ${if (passiveReconnect) "passively" else "directly"} in ${directDelay / 1000}s (attempt $failedReconnectAttempts$heldSuffix${if (aclHeld) ", ACL-held" else ""})") + log("Disconnected ${disconnectStatusLabel(status)}; reconnecting ${if (passiveReconnect) "passively" else "directly"} in ${directDelay / 1000}s (attempt $failedReconnectAttempts$heldSuffix${if (aclHeld) ", ACL-held" else ""})") // #1030 (ryanbr): cancellable backoff timer (see scheduleReconnect). scheduleReconnect(directDelay) { connectToDevice(dev, autoConnect = passiveReconnect) } + // #2406: only the PASSIVE handoff is the silence worth timing. `autoConnect = true` is + // not the same thing: the radio-on re-arm, the two bond-loop probes and the launch + // auto-reconnect all pass it, and none of them is a wait the app chose to sit out. + // + // Stamped AFTER scheduleReconnect, which opens with cancelPendingReconnect() and so + // clears this field. Stamping first set it and wiped it microseconds later, leaving the + // line unreachable: a 23 Sep field log has two passive reconnects and none of it. + if (passiveReconnect) passiveReconnectSinceMs = System.currentTimeMillis() } else { val rescanDelay = nextReconnectDelayMs() - log("Disconnected (status=$status); rescanning in ${rescanDelay / 1000}s (attempt $failedReconnectAttempts$heldSuffix)") + log("Disconnected ${disconnectStatusLabel(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)") @@ -8284,8 +11700,44 @@ class WhoopBleClient( /** Clear per-connection state. Port of the flag resets in didConnect / didDisconnectPeripheral. */ private fun reset() { didBond = false + explicitBondRequestedThisLink = false // #1635: one createBond attempt per link + sawBondTransitionThisLink = false + // explicitBondRequestedAtMs is deliberately NOT cleared here. An OS pairing routinely completes + // AFTER the GATT link that asked for it has dropped, so clearing on teardown would leave the + // BOND_BONDED transition — the one that matters most — arriving with no timing at all, which is + // 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 seq.set(0) writeQueue.clear() cccdQueue.clear() @@ -8322,6 +11774,7 @@ class WhoopBleClient( disRead = false disSerial = null disHwRev = null + disModelNumber = null // #1007: a burst cut short by a disconnect never reaches exitBackfilling — that path is only // HISTORY_COMPLETE / timeout / user-abort — so without this the throughput line simply would not // appear, and its ABSENCE is ambiguous: no offload at all, or one that was interrupted? For a @@ -8369,7 +11822,6 @@ class WhoopBleClient( * (e.g. AppViewModel.onCleared) AFTER [disconnect]. Idempotent. */ fun shutdown() { - flushDurableLogTail() // #1263: persist the last partial tail batch before we go away ioScope.cancel() } @@ -8388,7 +11840,9 @@ class WhoopBleClient( /** Coerce a parsed value to a Double (battery_pct may arrive as Double or Int). */ private fun doubleValue(v: Any?): Double? = (v as? Number)?.toDouble() - private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } + // Delegates to the shared fast encoder: this runs once per captured frame for a whole offload, and + // the per-byte String.format it used to do allocated a Formatter for every byte of a 2140-B frame. + private fun ByteArray.toHex(): String = toHexLower() // MARK: 5/MG raw backfill capture (opt-in research aid, #78 fork) // @@ -8398,6 +11852,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 @@ -8429,7 +11895,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(), @@ -8441,16 +11913,31 @@ class WhoopBleClient( size = frame.size, parsed = parsed.parsed, hex = frame.toHex(), + rejectReason = parsed.rejectReason, ), ) + var checkBytes = false synchronized(w) { w.write(line) w.newLine() - if (++captureLines % 100 == 0) w.flush() + if (++captureLines % 100 == 0) { w.flush(); checkBytes = true } } if (captureLines >= WHOOP5_CAPTURE_MAX_LINES) { log("Capture: line cap reached — capture paused until next session") closeWhoop5BackfillCapture(flushSummary = false) + return@runCatching + } + // The byte cap used to be enforced only when the file was OPENED, which was sufficient while + // the line cap was a hard per-session bound. It no longer is: entering backfill hands the line + // budget back (so the live flood cannot starve the offload), and a session that auto-continues + // several offloads resets it several times. Without a check here the file could grow well past + // the cap inside one long-lived connection and never rotate. Only on the flush boundary, so + // this is one stat per 100 frames, not per frame. + if (checkBytes && + java.io.File(context.filesDir, WHOOP5_CAPTURE_FILE).length() > WHOOP5_CAPTURE_MAX_BYTES + ) { + log("Capture: byte cap reached — capture paused until next session (it rotates on reopen)") + closeWhoop5BackfillCapture(flushSummary = false) } }.onFailure { captureDisabled = true @@ -8507,27 +11994,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") } } @@ -8573,6 +12052,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 @@ -8581,10 +12067,6 @@ class WhoopBleClient( // in here may propagate. (The regex bug itself is also fixed; this guarantees the class can't // recur.) try { - // #1263: FIRST append of this process — rescue the previous process's durable tail into the - // generation ring BEFORE this process's own mirror overwrites it. Latched + a no-op on an empty - // tail, so this is one guarded check per line after the first. - rollLogGenerationsIfNeeded() // Scrub personal identifiers FIRST so a user can safely share the strap log (#445), THEN // apply the optional Test Centre domain tag in front of the already-safe line. val safe = taggedStrapLogLine(redactPii(s), domain) @@ -8592,23 +12074,16 @@ class WhoopBleClient( // emit the strap log to the system log. The in-app ring buffer below always records. if (debugLogcat) Log.d(TAG, safe) // Mirror into the in-app ring buffer (format under the lock — SimpleDateFormat isn't - // thread-safe and log() is called from both the GATT binder thread and the main looper). - // #1263: while under the lock, snapshot the tail for the durable mirror every N lines (so the - // SharedPreferences write itself happens OUTSIDE the monitor, off the hot per-line path). - var tailToPersist: List? = null + // thread-safe and log() is called from both the GATT binder thread and the main looper), and onto + // disk under the same lock, so the file keeps the buffer's order. One ~100-byte write per line. val stamped = synchronized(logBuffer) { val line = "${logTimeFmt.format(System.currentTimeMillis())} $safe" logBuffer.addLast(line) while (logBuffer.size > LOG_BUFFER_MAX) logBuffer.removeFirst() - if (++logsSincePersist >= LOG_TAIL_PERSIST_EVERY) { - logsSincePersist = 0 - tailToPersist = logBuffer.toList() - } + strapLogArchive.append(line) line } _logRevision.update { it + 1 } - // #1263: durable-tail mirror (batched), OUTSIDE the logBuffer monitor. - tailToPersist?.let { persistLogTail(it) } // #1121: when detailed capture is on, ALSO append the (already PII-scrubbed) line to the // rolling on-device file, so a long-running issue is captured for hours rather than only the // ~5000-line (~50 min) in-memory ring. No-op + near-zero cost when capture is off, and inside @@ -8774,47 +12249,16 @@ class WhoopBleClient( * would be a stronger claim than the code makes. * * Only the share/export paths, which genuinely need one string, pay for the join. */ - fun exportLogLines(): List { - rollLogGenerationsIfNeeded() - val previous = synchronized(genLock) { - cachedPreviousSessionsLines - ?: buildPreviousSessionsLines().also { cachedPreviousSessionsLines = it } - } - val snapshot = synchronized(logBuffer) { logBuffer.toList() } - return if (previous.isEmpty()) snapshot else previous + snapshot - } - - /** The previous-session lines exactly as [com.noop.ui.StrapLogGenerations.previousSessionsText] would - * render them, taken from the generations themselves so no string is built and re-split. Empty when - * there are no previous sessions, matching that function's empty-string case. */ - private fun buildPreviousSessionsLines(): List = - com.noop.ui.StrapLogGenerations.previousSessionsLines(persistedLogGenerations()) + fun exportLogLines(): List = strapLogArchive.exportLines() /** - * Snapshot of the recent strap log, newest last, for the "Share strap log" diagnostics export. - * - * #1263: previous app sessions come FIRST (oldest-first, each with its own header, then a - * "===== current app session =====" marker), so `report.txt` stays chronological and the log-parsing - * tools read it unchanged — they just get the session a restart used to erase. We roll here too, not only - * in [log], because a user can open the app and export BEFORE this process logs its first line — at which - * point the surviving tail is still unrolled and [logBuffer] is empty. The roll is latched + a no-op on an - * empty tail, so it's harmless when [log] already ran. + * The strap log for the "Share strap log" diagnostics export: the earlier runs oldest-first, each under its + * own header, then a "===== current app session =====" marker and the whole of this run — from disk + * ([com.noop.ui.StrapLogArchive]), so the runs before a restart are kept, and this run is not cut to the + * 5,000 lines [logBuffer] holds. `report.txt` stays chronological and the log-parsing tools read it + * unchanged. An export taken before this process logs its first line still carries the run before it. */ - fun exportLogText(): String { - rollLogGenerationsIfNeeded() - val previous = synchronized(genLock) { - cachedPreviousSessionsText - ?: com.noop.ui.StrapLogGenerations.previousSessionsText(persistedLogGenerations()) - .also { cachedPreviousSessionsText = it } - } - // #1468 follow-up: COPY the buffer under the lock and join outside it. The join allocates one string - // per line plus the result — thousands of lines during an offload — and `log()` is called from the - // GATT binder thread, which blocks on this same lock for every line it writes. Holding it only for a - // reference copy keeps a readout refresh from throttling the writer it is reading. Output is - // byte-identical either way. - val snapshot = synchronized(logBuffer) { logBuffer.toList() } - return previous + snapshot.joinToString("\n") - } + fun exportLogText(): String = strapLogArchive.exportText() } // PII scrubbers for the shareable strap log (#445). Kept at FILE scope (not inside WhoopBleClient) so @@ -8827,10 +12271,67 @@ 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,}") + +/** + * #2092: an Oura device id (`oura-`) is the same #1303 gap as [PII_ADOPTED_ID_RE], for the OTHER + * brand. Neither WHOOP rule above matches it, since the prefix isn't "whoop-". Exact same shape (3-char + * prefix + "…", matching [com.noop.data.OuraSerialIdentity.logSafe]) and the same `-noop`-suffix + * pair, since the computed-sibling suffix is brand-agnostic — an Oura device gets a `oura--noop` + * sibling the same way a WHOOP strap does. + */ +private val PII_OURA_ADOPTED_ID_NOOP_RE = Regex("oura-([A-Za-z0-9]{3})[A-Za-z0-9-]{3,}(-noop)") +private val PII_OURA_ADOPTED_ID_RE = Regex("oura-([A-Za-z0-9]{3})[A-Za-z0-9-]{3,}") /** * Builds the 9-byte WHOOP 4.0 SET_ALARM_TIME (cmd 66) payload. @@ -8888,6 +12389,29 @@ internal fun whoop5CommandResponsePayload(frame: ByteArray): ByteArray? { return frame.copyOfRange(start, length) } +/** + * #2338: the advertising name carried by a GET_ADVERTISING_NAME COMMAND_RESPONSE from a 5/MG. + * + * Printable ASCII out of the 5/MG payload, trimmed. Mirrors the shape of Swift + * `FrameRouter.advertisingName`, but off [whoop5CommandResponsePayload] rather than the 4.0 envelope: + * reading a 5/MG frame with the 4.0 helper does not fail, it returns four bytes of envelope dressed as + * payload, which is the exact mistake bhelm/noop#4 was. + * + * null when the frame carries no payload; null ALSO when the payload holds no printable bytes at all, + * which is the answer to "did the strap reply with something that is not a name". An empty string + * would read as "the strap says its name is blank", a claim this cannot support. + * + * Pure and file-scope so the decode is unit-testable without a BLE stack, the [whoop4AlarmPayload] + * idiom. What the bytes MEAN on a 5/MG is unverified: no strap has answered 141 yet, and this exists + * to find out. + */ +internal fun advertisingNameFromWhoop5Response(frame: ByteArray): String? { + val payload = whoop5CommandResponsePayload(frame) ?: return null + val printable = payload.filter { it >= 32 && it < 127 }.toByteArray() + if (printable.isEmpty()) return null + return String(printable, Charsets.UTF_8).trim().takeIf { it.isNotEmpty() } +} + /** Space-separated lowercase hex of a COMMAND_RESPONSE payload, for the raw-hex diagnostic fallback * when a readback payload doesn't decode. null when the frame carries no payload. */ internal fun whoop4AlarmReadbackPayloadHex(frame: ByteArray): String? = @@ -8901,10 +12425,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? { @@ -8916,6 +12442,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 } } @@ -8924,8 +12462,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`. @@ -8939,6 +12479,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 } @@ -8951,13 +12494,114 @@ 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…") + // #2092: the Oura twin of the two rules above. Order vs. the WHOOP pair is not load-bearing - the + // two prefixes never overlap. + .replace(PII_OURA_ADOPTED_ID_NOOP_RE, "oura-$1…$2") + .replace(PII_OURA_ADOPTED_ID_RE, "oura-$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 7e3cb2847c..d492e875aa 100644 --- a/android/app/src/main/java/com/noop/ble/WhoopConnectionService.kt +++ b/android/app/src/main/java/com/noop/ble/WhoopConnectionService.kt @@ -6,6 +6,7 @@ import android.app.NotificationManager import android.app.PendingIntent import android.app.Service import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothDevice import android.content.BroadcastReceiver import android.content.Context import android.content.Intent @@ -29,8 +30,10 @@ import com.noop.location.GpsSession import com.noop.location.LocationTracker import com.noop.notif.BatteryAlertNotifier import com.noop.notif.IllnessAlertNotifier +import com.noop.ui.LiveConsoleReadout import com.noop.ui.NoopPrefs import com.noop.ui.appLaunchIntent +import com.noop.widget.StressWidgetProducer import com.noop.widget.WidgetSnapshot import com.noop.widget.WidgetSnapshotStore import kotlinx.coroutines.CoroutineScope @@ -198,6 +201,22 @@ class WhoopConnectionService : Service() { /** Wall-clock ms of the last [refreshHabitualMidsleep] attempt; 0 = never. */ private var habitualMidsleepCachedAtMs: Long = 0L + /** + * Wall-clock ms of the last stress scoring done for the widget; 0 = never this process. + * + * The widget's stress curve used to be scored ONLY by `AppViewModel`, which runs while the app is + * open. `load` drops any curve that is not today's, so the widget reset to a bare dash every + * midnight and nothing refilled it until the app happened to be opened during waking hours. For + * anyone who uses the widget INSTEAD of opening the app, which is the point of a widget, it read as + * permanently broken. + * + * This service is the widget's heartbeat, so it scores here too. It cannot do so on every emission: + * this collector runs on `ble.state`, which moves at the live HR rate, while scoring reads a day of + * HR rows. [STRESS_RESCORE_INTERVAL_MS] is the gate, and it is matched to the data rather than to + * the stream, since the curve resolves to half-hours. + */ + private var lastStressScoreAtMs: Long = 0L + /** Smart-alarm light-sleep watcher (#207). Feeds the live HR while we're inside the wake window * and, on a lighter-phase reading, advances the GUARANTEED alarm earlier. It can only ever move * the alarm earlier within the window — the hard deadline scheduled via AlarmManager is the floor @@ -205,6 +224,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 @@ -212,6 +235,33 @@ class WhoopConnectionService : Service() { private val ble get() = (application as NoopApplication).ble private val repo get() = (application as NoopApplication).repository + /** + * Watches the OS PAIRING flow (#1635). NOOP has never observed ACTION_BOND_STATE_CHANGED, so whether a + * CLIENT_HELLO triggers pairing at all - and whether that pairing fails - has been invisible. A WHOOP + * 5/MG shows every CLIENT_HELLO unacknowledged and the link torn down locally on a clockwork timer; + * seeing BOND_NONE -> BOND_BONDING -> BOND_NONE across that window decides it, and seeing no transition + * at all decides it the other way. Registered and unregistered alongside [bluetoothStateReceiver]. + */ + private val bondStateReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action != BluetoothDevice.ACTION_BOND_STATE_CHANGED) return + 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) + // WHY a bond ended, which the transition alone cannot say: refused, timed out and link-lost + // all render as BOND_BONDING -> BOND_NONE. [NO_BOND_REASON] when the OS supplied nothing. + // [EXTRA_BOND_REASON] documents why the extra's name is a literal rather than a reference. + val reason = intent.getIntExtra(EXTRA_BOND_REASON, NO_BOND_REASON) + runCatching { ble.onBondStateChanged(prev, cur, dev?.address, reason) } + } + } + /** * Watches the OS Bluetooth radio so turning it off immediately tears down NOOP's orphaned GATT * link (#314). Without this there is no ACTION_STATE_CHANGED listener at all, so the radio going off @@ -233,8 +283,9 @@ class WhoopConnectionService : Service() { } } - /** True once [bluetoothStateReceiver] is registered, so repeat onStartCommands don't double-register - * (which would later throw on a single unregister). */ + /** True once [bluetoothStateReceiver] and [bondStateReceiver] are registered, so repeat + * onStartCommands don't double-register (which would later throw on a single unregister). Both + * register together and unregister together, so one flag covers the pair. */ private var bluetoothReceiverRegistered = false override fun onBind(intent: Intent?): IBinder? = null @@ -267,6 +318,16 @@ class WhoopConnectionService : Service() { IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED), ContextCompat.RECEIVER_NOT_EXPORTED, ) + + // #1635: same lifecycle and the same registration form as the radio receiver above - the FGS + // owns the connection, so the pairing flow is only interesting while it is alive, and both are + // torn down together in onDestroy. + ContextCompat.registerReceiver( + this, + bondStateReceiver, + IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED), + ContextCompat.RECEIVER_NOT_EXPORTED, + ) }.onSuccess { bluetoothReceiverRegistered = true } } @@ -405,6 +466,65 @@ class WhoopConnectionService : Service() { // while the app UI is closed. Throttled + no-op without a placed widget (the store // checks both); runCatching so a Glance hiccup never tears down the connection. runCatching { + // #2034 sibling: score today's stress HERE too, not only in the app. A null day is + // the "this push says nothing about stress" signal `save` honours, so a throttled + // tick leaves the stored curve alone instead of blanking it, and the widget keeps + // whatever the last scoring pass produced. + val nowMs = System.currentTimeMillis() + val stressCurve = if ( + StressWidgetProducer.shouldRescore( + nowMs, lastStressScoreAtMs, STRESS_RESCORE_INTERVAL_MS, + ) + ) { + // Stamped BEFORE the placement check and the read, then CORRECTED once the + // outcome is known. Stamping inside the placement branch would leave the gate + // permanently open for anyone WITHOUT the widget, so `hasStressWidget` — which + // crosses into GlanceAppWidgetManager — would run on every emission of a + // collector driven by live heart rate; and a failing pass must not retry on the + // very next sample. Both still hold. + // + // #2120: what did NOT hold is spending the whole interval on an attempt that + // produced nothing. The placement check used to arrive here as a plain Boolean + // that had already collapsed its own failure into `false`, so one Glance hiccup + // read as "no widget" and the curve was skipped; `todayCurve` returns null on a + // blank device id or a caught failure. Either left a placed widget blank for + // fifteen minutes, and the wearer fixed it by opening the app. This now reads + // the placement as a TRI-STATE and rewinds the stamp to a short retry floor for + // both of those, while a settled "no widget" still keeps the full interval. + lastStressScoreAtMs = nowMs + // Tri-state: null means the widget host did not answer, which is NOT the same + // as a settled "no widget" and must not spend the interval like one. + val widgetPlaced = + WidgetSnapshotStore.stressWidgetPlacement(this@WhoopConnectionService) + // `!= false` rather than `== true`: an UNANSWERED placement check scores anyway. + // Skipping on unknown meant a device whose Glance check fails persistently never + // scored at all, it just failed faster, and the widget the wearer is looking at + // stayed blank. Pushing a curve nobody displays is harmless, it is stored and + // unread; withholding one from a widget that IS placed is the reported bug. + val curve = if (widgetPlaced != false) { + StressWidgetProducer.todayCurve( + repo, (application as NoopApplication).activeDeviceId, + ) + } else { + null + } + lastStressScoreAtMs = StressWidgetProducer.stampAfterAttempt( + nowMs = nowMs, + producedCurve = curve != null, + widgetPlaced = widgetPlaced, + intervalMs = STRESS_RESCORE_INTERVAL_MS, + ) + // Published so the periodic worker can see it (#2185). Without this the two + // rescore on the same cadence with no knowledge of each other, and an install + // with background connection on pays two full passes a quarter hour for one + // curve. The stamp is the one the memo logic already computed. + WidgetSnapshotStore.noteStressScored( + this@WhoopConnectionService, lastStressScoreAtMs, + ) + curve + } else { + null + } WidgetSnapshotStore.push( this@WhoopConnectionService, WidgetSnapshot( @@ -415,9 +535,21 @@ class WhoopConnectionService : Service() { restPct = dayState.widgetRest, effortPct = dayState.widgetEffort, heartRate = state.heartRate, - batteryPct = state.batteryPct?.roundToInt(), + // The ACTIVE device's charge (#2075). This service is the widget's + // HEARTBEAT, so publishing the WHOOP's field here would have overwritten the + // app-side fix within a minute and left the ring showing the strap's charge. + // `activeDeviceIsWhoop` is the coordinator's own flag, so no registry read + // lands on a collector driven by live heart rate. + batteryPct = LiveConsoleReadout.batteryPercent( + activeIsWhoop = ble.activeDeviceIsWhoop, + whoopPct = state.batteryPct, + ringPct = (application as NoopApplication) + .sourceCoordinator.ouraBatteryPct.value, + ), connected = state.connected, - updatedAtMs = System.currentTimeMillis(), + stressSeries = stressCurve?.points ?: emptyList(), + stressDay = stressCurve?.epochDay, + updatedAtMs = nowMs, ), ) } @@ -483,15 +615,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) } } @@ -650,6 +838,7 @@ class WhoopConnectionService : Service() { // unregisterReceiver throws if it was never registered; the flag guards that, and runCatching // covers the rare case the OS already reclaimed it. runCatching { unregisterReceiver(bluetoothStateReceiver) } + runCatching { unregisterReceiver(bondStateReceiver) } bluetoothReceiverRegistered = false } scope.cancel() @@ -657,6 +846,11 @@ class WhoopConnectionService : Service() { } companion object { + /** How often this service rescores the widget's stress curve. Matched to the curve's own + * half-hour resolution and to the in-app analytics cadence, not to the live HR stream that + * drives the collector it sits in. */ + private val STRESS_RESCORE_INTERVAL_MS = StressWidgetProducer.RESCORE_INTERVAL_MS + private const val CHANNEL_ID = "noop_strap_connection" private const val NOTIF_ID = 4201 const val ACTION_STOP = "com.noop.ble.action.STOP_CONNECTION" diff --git a/android/app/src/main/java/com/noop/ble/WhoopModel.kt b/android/app/src/main/java/com/noop/ble/WhoopModel.kt index 7cea138374..f01fc910fa 100644 --- a/android/app/src/main/java/com/noop/ble/WhoopModel.kt +++ b/android/app/src/main/java/com/noop/ble/WhoopModel.kt @@ -1,5 +1,6 @@ package com.noop.ble +import com.noop.protocol.DeviceFamily import java.util.UUID /** @@ -27,3 +28,21 @@ enum class WhoopModel(val displayName: String, val service: UUID) { WHOOP5_MG -> WHOOP4 } } + +/** + * The picker model backed by service-discovery evidence from this connection. + * + * [family] is deliberately ignored until [familyEstablished] is true. The BLE client keeps its last + * discovered family across a disconnect (and starts with WHOOP4), so reading the family alone would + * turn either a default or the previous link into a claim about the current strap. (#2068) + */ +internal fun establishedWhoopModel( + familyEstablished: Boolean, + family: DeviceFamily, +): WhoopModel? { + if (!familyEstablished) return null + return when (family) { + DeviceFamily.WHOOP4 -> WhoopModel.WHOOP4 + DeviceFamily.WHOOP5 -> WhoopModel.WHOOP5_MG + } +} 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..82ee87c7e3 100644 --- a/android/app/src/main/java/com/noop/data/DataBackup.kt +++ b/android/app/src/main/java/com/noop/data/DataBackup.kt @@ -3,12 +3,15 @@ package com.noop.data import android.content.Context import android.database.sqlite.SQLiteDatabase import android.net.Uri +import android.provider.DocumentsContract import android.util.Log import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.SupportSQLiteOpenHelper import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory import java.io.File +import java.io.FileInputStream import java.io.FileOutputStream +import java.io.InputStream import java.io.IOException import java.util.zip.ZipEntry import java.util.zip.ZipInputStream @@ -50,9 +53,18 @@ 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 + /** "PK\u0005\u0006", the ZIP End Of Central Directory signature. */ + private val ZIP_EOCD_SIGNATURE: ByteArray = byteArrayOf(0x50, 0x4B, 0x05, 0x06) + + /** The fixed part of an End Of Central Directory record, before any comment. */ + private const val ZIP_EOCD_MIN_BYTES: Int = 22 + + /** A ZIP comment can push the EOCD record up to 65535 bytes from the end, plus its own fixed part. */ + private const val ZIP_EOCD_SEARCH_BYTES: Int = 65_535 + ZIP_EOCD_MIN_BYTES + /** First 16 bytes of every SQLite 3 file: "SQLite format 3\0". */ private val SQLITE_MAGIC: ByteArray = byteArrayOf( @@ -61,33 +73,130 @@ object DataBackup { ) /** - * #1014 (write-side): cheaply confirm a JUST-WRITTEN `.noopbak` at [uri] is structurally intact — its - * DB entry is present and begins with the SQLite magic header. A torn write (truncated ZIP / a - * half-flushed SAF document on a full disk or flaky provider) otherwise leaves a `.noopbak` that - * silently "restores" into an empty store, caught only by the import-side quick_check much later. Fail - * HERE at write time instead. Twin of the Apple post-write check in `writeVerifiedBackupZip`. - * Best-effort: any read/format error returns false (treated as not-intact). + * What a post-write check concluded about the file just produced. + * + * [UNVERIFIABLE] exists so that failing to READ a backup is never mistaken for evidence against it. + * The caller deletes a [TORN] file, and deleting on "we could not look" would mean a provider having + * a bad moment could destroy a backup that was perfectly good. + */ + internal enum class BackupWriteVerdict { INTACT, TORN, UNVERIFIABLE } + + /** + * The decision itself, separated from the reading of the file so the whole table can be pinned by a + * plain JVM test: [tail] is null when the provider would not say how big the file is, and + * [entryIntact] is null when it could not be opened for reading at all. */ - fun isWrittenBackupIntact(context: Context, uri: Uri): Boolean = runCatching { - context.contentResolver.openInputStream(uri)?.use { stream -> - ZipInputStream(stream).use { zip -> - var entry = zip.nextEntry - while (entry != null) { - if (!entry.isDirectory && entry.name.substringAfterLast('/') == ZIP_ENTRY_NAME) { - val header = ByteArray(SQLITE_MAGIC.size) - var got = 0 - while (got < header.size) { - val r = zip.read(header, got, header.size - got) - if (r < 0) break - got += r - } - return@runCatching got == header.size && header.contentEquals(SQLITE_MAGIC) + internal fun writeVerdict(tail: ByteArray?, entryIntact: Boolean?): BackupWriteVerdict = when { + // A tail we COULD read that holds no end record is positive evidence of a cut-short write. + tail != null && !hasEndOfCentralDirectory(tail) -> BackupWriteVerdict.TORN + entryIntact == null -> BackupWriteVerdict.UNVERIFIABLE + entryIntact -> BackupWriteVerdict.INTACT + else -> BackupWriteVerdict.TORN + } + + /** + * Re-read the JUST-WRITTEN `.noopbak` at [uri] and say what it looks like. + * + * #1014 (write-side): a torn write, a truncated ZIP or a half-flushed SAF document on a full disk or + * a flaky provider, otherwise leaves a `.noopbak` that silently "restores" into an empty store, and + * is caught only by the import-side quick_check much later, when the original may be long gone. Fail + * HERE at write time instead. Twin of the Apple `writtenBackupIsIntact`. + * + * The tail is the cheap half and the half that actually catches a torn write; it is SKIPPED, not + * failed, when the provider will not report a size, since refusing a good backup because a document + * provider is coy would trade a rare corruption for a common false alarm. + */ + internal fun verifyWrittenBackup(context: Context, uri: Uri): BackupWriteVerdict { + val resolver = context.contentResolver + val tail = readTail(resolver, uri, ZIP_EOCD_SEARCH_BYTES) + val entryIntact = runCatching { + resolver.openInputStream(uri)?.use { backupStreamIsIntact(it) } + }.getOrNull() + return writeVerdict(tail, entryIntact) + } + + /** The last [limit] bytes of [uri], or null when the provider will not say how big it is. */ + private fun readTail(resolver: android.content.ContentResolver, uri: Uri, limit: Int): ByteArray? = + runCatching { + resolver.openFileDescriptor(uri, "r")?.use { pfd -> + val len = pfd.statSize + if (len <= 0L) return@use null + val window = minOf(len, limit.toLong()).toInt() + // Deliberately NOT closed: the descriptor belongs to the ParcelFileDescriptor, whose + // own `use` closes it. Wrapping this in `use` too would close the same fd twice, and + // the second close lands on whatever has since been handed that number. + val fis = FileInputStream(pfd.fileDescriptor) + fis.channel.position(len - window) + val buf = ByteArray(window) + var got = 0 + while (got < window) { + val r = fis.read(buf, got, window - got) + if (r < 0) break + got += r + } + if (got == window) buf else null + } + }.getOrNull() + + /** + * Whether [tail], the last bytes of a file, contains a ZIP End Of Central Directory record. + * + * This is the check that catches the #1014 shape. A ZIP's index lives at its END, so a write cut + * short by a full disk or a flaky provider loses it. [backupStreamIsIntact] cannot see that at all: + * `ZipInputStream` walks LOCAL entry headers front to back and never looks for the central + * directory, so a file truncated to a few hundred bytes still presents a DB entry whose first + * sixteen bytes are a perfectly good SQLite header, and passes. The Apple twin never had this hole + * because opening an `Archive` for reading parses the central directory or fails. + * + * Scans rather than checking a fixed offset, because the record sits [ZIP_EOCD_MIN_BYTES] from the + * end only when the archive carries no comment. + * + * Finding the signature is NOT enough, and a first pass here that stopped there was wrong: lopping a + * single byte off a good archive leaves the signature untouched and only damages the fields behind + * it. So the record has to ADD UP. Its trailing comment-length field must account for exactly the + * bytes that follow it, which a file cut short cannot do, whether it was cut by a byte or a + * megabyte. + */ + internal fun hasEndOfCentralDirectory(tail: ByteArray): Boolean { + if (tail.size < ZIP_EOCD_MIN_BYTES) return false + // From the end backwards: the LAST complete record is the real one. + for (i in tail.size - ZIP_EOCD_MIN_BYTES downTo 0) { + var hit = true + for (k in ZIP_EOCD_SIGNATURE.indices) { + if (tail[i + k] != ZIP_EOCD_SIGNATURE[k]) { hit = false; break } + } + if (!hit) continue + // Comment length is the last field of the record, little-endian. + val commentLength = (tail[i + 20].toInt() and 0xFF) or ((tail[i + 21].toInt() and 0xFF) shl 8) + if (i + ZIP_EOCD_MIN_BYTES + commentLength == tail.size) return true + } + return false + } + + /** + * Whether a `.noopbak` stream carries a DB entry that begins with the SQLite magic header. + * + * Stream-shaped so it can be driven from a plain JVM test without a Context: the Uri-taking + * [isWrittenBackupIntact] is the thin wrapper over it. + */ + internal fun backupStreamIsIntact(stream: InputStream): Boolean = runCatching { + ZipInputStream(stream).use { zip -> + var entry = zip.nextEntry + while (entry != null) { + if (!entry.isDirectory && entry.name.substringAfterLast('/') == ZIP_ENTRY_NAME) { + val header = ByteArray(SQLITE_MAGIC.size) + var got = 0 + while (got < header.size) { + val r = zip.read(header, got, header.size - got) + if (r < 0) break + got += r } - entry = zip.nextEntry + return@runCatching got == header.size && header.contentEquals(SQLITE_MAGIC) } - false + entry = zip.nextEntry } - } ?: false + false + } }.getOrDefault(false) /** First 4 bytes of every ZIP file: "PK\x03\x04". */ @@ -101,8 +210,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 +239,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. @@ -132,9 +260,9 @@ object DataBackup { // connection (WAL allows concurrent readers). Twin of the Apple writeVerifiedBackupZip. sqliteQuickCheckFailure(dbFile)?.let { complaint -> throw IOException( - "Couldn't export: the NOOP database failed its integrity check (SQLite reports: " + - "$complaint). A backup of it would not restore. Export the WHOOP-format CSV " + - "instead to save what's still readable." + "Couldn't export: the NOOP database failed its integrity check, so a backup of it " + + "would not restore. Export the WHOOP-format CSV instead to save what's still " + + "readable. (SQLite: ${readableComplaint(complaint)})" ) } @@ -155,6 +283,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 +296,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 +314,51 @@ object DataBackup { } } } + // #1014 (write-side): the SOURCE was verified before archiving, but the PRODUCED file can still + // be torn by a full disk, a dying card, or a provider that drops the tail, and such a .noopbak + // "restores" into an empty store, caught only by the import-side quick_check much later, when + // the original may be long gone. So check what was just written, here, while it can still be + // called a failed export rather than a bad backup. + // + // This check existed but was wired only into the SCHEDULED sync path, so the manual Settings + // export, the one taken deliberately before wiping a phone, never ran it. It belongs inside + // exportTo the way Apple's lives inside writeVerifiedBackupZip, so that no caller has to + // remember to ask. + when (verifyWrittenBackup(appContext, uri)) { + BackupWriteVerdict.INTACT -> Unit + // Never leave a corrupt file behind masquerading as a good snapshot. Which of the two + // outcomes happened changes what the reader should do, so say which. + BackupWriteVerdict.TORN -> { + val removed = runCatching { DocumentsContract.deleteDocument(resolver, uri) } + .getOrDefault(false) + throw IOException( + if (removed) { + "Couldn't finish the backup: the file written was incomplete, so it was " + + "removed rather than left looking like a good backup. The destination may " + + "be out of space. Try again, or choose somewhere else." + } else { + "Couldn't finish the backup: the file written was incomplete, and it could " + + "not be removed either, so delete it yourself rather than trust it. The " + + "destination may be out of space. Try again, or choose somewhere else." + }, + ) + } + // Failing to READ the file back is not evidence against it, so it is LEFT ALONE. Deleting + // here would let a storage provider having a bad moment destroy a good backup. + BackupWriteVerdict.UNVERIFIABLE -> throw IOException( + "The backup was written, but couldn't be read back to check it, so it has been left " + + "in place rather than deleted on a guess. Open it before you rely on it, or " + + "export again somewhere else.", + ) + } + + // #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 +370,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 +394,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 +405,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." @@ -286,8 +474,8 @@ object DataBackup { tempSqlite.delete() tempSettings.delete() return ImportResult.Failed( - "This backup file is damaged and can't be restored (SQLite reports: $complaint). " + - "Your current data is untouched. Try an earlier backup file." + "This backup file is damaged, so nothing was restored. Your current data is " + + "untouched. Try an earlier backup file. (SQLite: ${readableComplaint(complaint)})" ) } @@ -338,21 +526,22 @@ object DataBackup { if (rollbackFile.exists()) { if (runCatching { rollbackFile.copyTo(dbFile, overwrite = true) }.isSuccess) { rollbackFile.delete() - message = "The backup failed its integrity check after the copy (SQLite reports: " + - "$complaint). Your previous data was rolled back automatically and is unchanged." + message = "The backup failed its integrity check after the copy. Your previous " + + "data was rolled back automatically and is unchanged. " + + "(SQLite: ${readableComplaint(complaint)})" } else { // The roll-back copy itself failed: KEEP the snapshot on disk — it is now the // only good copy of the user's data — and tell the user exactly where it is. - message = "The backup failed its integrity check after the copy (SQLite reports: " + - "$complaint), and rolling back also failed. Your previous data is preserved at " + - "${rollbackFile.name} next to the app's database." + message = "The backup failed its integrity check after the copy, and rolling " + + "back also failed. Your previous data is preserved at ${rollbackFile.name} " + + "next to the app's database. (SQLite: ${readableComplaint(complaint)})" } } else { // Fresh install: nothing existed before the import, so removing the damaged file // returns to the exact pre-import (empty) state. dbFile.delete() - message = "The backup failed its integrity check after the copy (SQLite reports: " + - "$complaint). There was no previous data to roll back." + message = "The backup failed its integrity check after the copy. There was no " + + "previous data to roll back. (SQLite: ${readableComplaint(complaint)})" } return ImportResult.Failed(message) } @@ -405,6 +594,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 +610,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 +637,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 +649,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 @@ -605,6 +810,46 @@ object DataBackup { ?: "quick_check returned no verdict" } + /** + * SQLite's banner line, which names the database being reported on and says nothing about what is + * wrong with it. quick_check emits it ahead of the real diagnosis, joined into the SAME row. + */ + private fun isBanner(line: String): Boolean = + line.startsWith("*** in database") && line.endsWith("***") + + /** + * The part of a [quickCheckVerdict] worth showing a person, as one line. + * + * The verdict is kept VERBATIM on purpose: it is the forensic value, and a reporter pasting it into + * an issue should paste what SQLite actually said rather than a summary of it. (It reaches no log + * today: the only copy is the one shown, which is why the dialog can copy it.) What SQLite says + * begins with a banner and a newline: + * + * *** in database main *** + * Page 5 is never used + * + * Rendered into a sentence, that spends the whole line on "*** in database main ***" and pushes the + * only informative half ("Page 5 is never used") out of sight, which is exactly what a truncating + * Toast showed. This drops the banner, joins what is left onto one line, and caps the length, so the + * detail a person sees is the diagnosis. + * + * Falls back to the raw verdict, newlines flattened, whenever stripping would leave nothing: a + * verdict made ENTIRELY of banner is still better shown than shown as an empty parenthesis. + * Mirrors the Apple `DatabaseIntegrity.readableComplaint` on the same golden vectors. + */ + fun readableComplaint(verdict: String, limit: Int = 140): String { + val kept = verdict.split('\n') + .map { it.trim() } + .filter { it.isNotEmpty() && !isBanner(it) } + val joined = if (kept.isEmpty()) { + verdict.replace('\n', ' ').trim() + } else { + kept.joinToString("; ") + } + val cap = limit.coerceAtLeast(1) + return if (joined.length <= cap) joined else joined.take(cap - 1).trimEnd() + "\u2026" + } + /** * A [android.database.DatabaseErrorHandler] that closes the handle and PRESERVES the file. The * framework default ([android.database.DefaultDatabaseErrorHandler]) DELETES the file it was 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..455cf39430 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 scored days, legacy detected workouts and metric + // series. 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,46 @@ 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) + dao.reKeyLiftExercises(from, to); dao.deleteLiftExercisesFor(from) + dao.reKeyLiftPrograms(from, to); dao.deleteLiftProgramsFor(from) + dao.reKeyLiftProgramItems(from, to); dao.deleteLiftProgramItemsFor(from) + dao.reKeyLiftSessions(from, to); dao.deleteLiftSessionsFor(from) + dao.reKeyLiftSets(from, to); dao.deleteLiftSetsFor(from) + } + /** Archive a device — keeps its row and samples (invariant I4). */ suspend fun archive(id: String) = dao.archiveDevice(id) @@ -188,7 +211,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) @@ -206,6 +228,11 @@ class DeviceRegistry( dao.deleteLiveSessionsFor(id) dao.deleteDismissedWorkoutsFor(id) dao.deleteDismissedSleepsFor(id) + dao.deleteLiftExercisesFor(id) + dao.deleteLiftProgramsFor(id) + dao.deleteLiftProgramItemsFor(id) + dao.deleteLiftSessionsFor(id) + dao.deleteLiftSetsFor(id) } } @@ -214,5 +241,13 @@ class DeviceRegistry( dao.setDayOwner(DayOwnershipRow(day = day, deviceId = deviceId, locked = locked)) /** The owner override for a day, or null if none. */ - suspend fun dayOwner(day: String): DayOwnershipRow? = dao.dayOwner(day) + suspend fun dayOwner(day: String): DayOwnershipRow? { + // Timed here rather than at the call site, matching the other per-day probes: the resolver that + // drives these sits inside `analyzeRecentOnCpu`, which has no ratchet margin for a stopwatch. + // See StoreProbeTally. Instrumentation only. + val started = System.nanoTime() + val row = dao.dayOwner(day) + com.noop.analytics.StoreProbeTally.recordDayOwner(System.nanoTime() - started) + return row + } } 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..84d272e850 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) @@ -112,6 +111,24 @@ interface DeviceRegistryDao { @Query("DELETE FROM liveSession WHERE deviceId = :deviceId") suspend fun deleteLiveSessionsFor(deviceId: String) @Query("DELETE FROM dismissedWorkout WHERE deviceId = :deviceId") suspend fun deleteDismissedWorkoutsFor(deviceId: String) @Query("DELETE FROM dismissedSleep WHERE deviceId = :deviceId") suspend fun deleteDismissedSleepsFor(deviceId: String) + // v46-lift-log: the five strength-log tables, including the CHILD rows. `liftProgramItem` and + // `liftSet` join to their parents by id rather than by a foreign key, so deleting only the parents + // would strip the programs and sessions and leave every logged set behind — the same shape as the + // audit finding above. Landed WITH the schema rather than after it: no Android DAO writes these + // tables yet, so nothing would notice the gap until something did, and `deleteDeviceDataCallsEvery\ + // DaoDeleteMethod` cannot see a table that has no method at all. + @Query("DELETE FROM liftExercise WHERE deviceId = :deviceId") suspend fun deleteLiftExercisesFor(deviceId: String) + @Query("DELETE FROM liftProgram WHERE deviceId = :deviceId") suspend fun deleteLiftProgramsFor(deviceId: String) + @Query("DELETE FROM liftProgramItem WHERE deviceId = :deviceId") suspend fun deleteLiftProgramItemsFor(deviceId: String) + @Query("DELETE FROM liftSession WHERE deviceId = :deviceId") suspend fun deleteLiftSessionsFor(deviceId: String) + @Query("DELETE FROM liftSet WHERE deviceId = :deviceId") suspend fun deleteLiftSetsFor(deviceId: String) + + /** + * Delete individual sets by id, as editing a finished session does when a set is removed. The + * session itself stays. Ported ahead of its consumer: Android has no Lift Log screen yet. + * The Swift twin is `WhoopStore.deleteLiftSets`. + */ + @Query("DELETE FROM liftSet WHERE id IN (:ids)") suspend fun deleteLiftSets(ids: List) // #771 adopt-serial: re-key one device's rows onto the serial id across every device-scoped table. // `UPDATE OR IGNORE` so the canonical (serial) row wins any (deviceId, ts…) primary-key clash; the @@ -126,7 +143,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) @@ -143,6 +159,11 @@ interface DeviceRegistryDao { @Query("UPDATE OR IGNORE liveSession SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyLiveSessions(from: String, to: String) @Query("UPDATE OR IGNORE dismissedWorkout SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyDismissedWorkouts(from: String, to: String) @Query("UPDATE OR IGNORE dismissedSleep SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyDismissedSleeps(from: String, to: String) + @Query("UPDATE OR IGNORE liftExercise SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyLiftExercises(from: String, to: String) + @Query("UPDATE OR IGNORE liftProgram SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyLiftPrograms(from: String, to: String) + @Query("UPDATE OR IGNORE liftProgramItem SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyLiftProgramItems(from: String, to: String) + @Query("UPDATE OR IGNORE liftSession SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyLiftSessions(from: String, to: String) + @Query("UPDATE OR IGNORE liftSet SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyLiftSets(from: String, to: String) /** The registry row for [id], or null. (#771 adopt-serial needs the active row's fields to clone/carry.) */ @Query("SELECT * FROM pairedDevice WHERE id = :id") 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 082a796fb3..50319c32d4 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 @@ -80,6 +81,24 @@ data class PpgHrSample( data class HrBucket( val bucket: Long, val avgBpm: Double, + /** The lowest and highest sample IN the bucket, not the bucket's mean. + * + * The card plots [avgBpm], which is what makes a day read as a curve rather than a spike field, but + * a Min/Max readout taken from that series describes the calmest and busiest FIVE MINUTES rather than + * the day. A forty-second interval is averaged against the four minutes around it before the reader + * ever sees it, which is why a workout's max could exceed the day's (#2032). Same scan, same + * grouping, so carrying them costs nothing. */ + val minBpm: Double, + val maxBpm: Double, +) + +/** The per-day gravity witness the steps-calibration motion cache keys on: [c] rows in the window and + * [m] the newest timestamp among them. Query result of [WhoopDao.gravityWitnessInWindow], not a table. + * Both columns come from ONE aggregate so the pair always describes a state the day was actually in; + * mirrors the tuple Swift's `WhoopStore.gravityFingerprint` returns. */ +data class GravityWitness( + val c: Int, + val m: Long, ) /** Aggregate HR over a time window, sample count + avg/max bpm. Query result of @@ -139,7 +158,8 @@ data class HrWindowStats( * as `v30-rr-ord`, and `srcChannel` as `v32-rr-src-channel`. (An earlier revision of this note said the * Swift widening was still pending; it had already shipped.) */ -@Entity(tableName = "rrInterval", primaryKeys = ["deviceId", "ts", "rrMs", "seq"]) +@Entity(tableName = "rrInterval", primaryKeys = ["deviceId", "ts", "rrMs", "seq"], + indices = [Index(value = ["srcChannel", "tsSuspect"], name = "rrInterval_source_suspect")]) data class RrInterval( val deviceId: String, val ts: Long, @@ -321,6 +341,22 @@ data class DailyMetric( // Five-minute SDNN index (ms), separate from avgHrv (RMSSD). Strap rows compute it from in-bed R-R; // Apple Health rows mirror the source SDNN. Health Connect RMSSD does not populate this column. val avgSdnn: Double? = null, + // Nightly ABSOLUTE skin temperature (°C): the wear-gated mean over the night's detected sleep, the + // value skinTempDevC is derived FROM (#1636). Appended LAST so the column order matches the Room + // CREATE TABLE and the Swift row. Distinct from skinTempDevC, which is bimodal — CSV/Health imports + // write an absolute wrist °C into that column and SkinTempDisplay separates them by magnitude. This + // 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, ) /** @@ -360,7 +396,7 @@ data class SleepSession( // Interpreter's `(sb shr 4) and 3` (H2 persist half). // Both nullable TEXT (no SQL DEFAULT, a Kotlin construction default never reaches the schema), so old // rows read back null. HONESTY: an absent signal stays null, never a fabricated zero series. Written/read - // through the targeted DAO methods (not the @Upsert path, which never names them and so preserves them). + // through targeted DAO methods; [SleepSessionUpsertPolicy] carries the stored values through cache refreshes. val motionJSON: String? = null, val sleepStateJSON: String? = null, // v34 (Swift WhoopStore v34-sleep-staging-sparse parity, MIGRATION_27_28). True when this night was @@ -540,12 +576,10 @@ data class WorkoutRow( ) /** - * Durable "this detected bout is not a workout" marker (#107). The IntelligenceEngine wipes + - * re-derives sport="detected" rows under "-noop" every run, so a plain delete only hides a - * bout until the next re-detect recreates it. This table is INDEPENDENT of that churn: a detected row - * is filtered out at read time whenever it OVERLAPS a marker's [startTs, endTs] span, so dismissal is - * permanent, and span-overlap (not an exact-key match) survives the small startTs DRIFT a bout's - * boundary can take as more HR arrives, matching the macOS dismissed-span semantics exactly. + * Durable "this detected bout is not a workout" marker (#107). The engine no longer creates or + * reconciles generic detected rows (#2187), but grandfathered rows and their dismissal controls remain. + * A detected row and the opt-in suggestion path are filtered whenever they OVERLAP a marker's + * [startTs, endTs] span, so span drift cannot resurrect a candidate the user rejected. * * PK (deviceId, startTs), one marker per detected start; `endTs` is the span end. Android-only table * (no GRDB twin): the macOS read model can't add a column to its shared workout struct, so macOS @@ -616,6 +650,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 @@ -630,6 +686,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( @@ -637,13 +700,26 @@ data class PpgWaveformSampleEntity( val ts: Long, val samples: ByteArray, val burstIndex: Int? = null, + /** + * #2019: the absolute optical ADC code that [samples] are DELTAS from. The v26 window is 25 samples + * encoded as this code plus 24 deltas; sample 0 is the code and delta i produces sample i+1. + * + * Stored beside the deltas rather than folded into them because [samples] is a little-endian i16 blob + * and a real code (about 378,000 on the captured fixture) does not fit in an i16. Keeping the wire's + * own shape is also the honest one: the strap sends a base and deltas. + * + * Null on a row written before this was read, and that null is TRUE rather than merely missing: the + * base was discarded at decode, and a delta series cannot be inverted without it, so those rows have + * no recoverable absolute level. Use it to tell a reconstructable window from one that never can be. + */ + val baseCode: Long? = null, ) { // 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 PpgWaveformSampleEntity) return false return deviceId == other.deviceId && ts == other.ts && samples.contentEquals(other.samples) && - burstIndex == other.burstIndex + burstIndex == other.burstIndex && baseCode == other.baseCode } override fun hashCode(): Int { @@ -651,42 +727,7 @@ data class PpgWaveformSampleEntity( result = 31 * result + ts.hashCode() result = 31 * result + samples.contentHashCode() result = 31 * result + (burstIndex ?: 0) - return result - } -} - -/** - * 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() + result = 31 * result + (baseCode?.hashCode() ?: 0) return result } } diff --git a/android/app/src/main/java/com/noop/data/LiftEntities.kt b/android/app/src/main/java/com/noop/data/LiftEntities.kt new file mode 100644 index 0000000000..21f50b9211 --- /dev/null +++ b/android/app/src/main/java/com/noop/data/LiftEntities.kt @@ -0,0 +1,194 @@ +package com.noop.data + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * The in-app strength log — the Room half of GRDB's `v46-lift-log`. + * + * Five `deviceId`-keyed tables holding saved programs and the sessions run from them. The Apple side + * ships the screens that read and write these; this is the SCHEMA twin, landed with the migration + * rather than after it, because `docs/CONTRIBUTING.md` is explicit that a migration cannot land + * until its twin does and that the oracle's divergence ledger should only ever shrink. + * + * WHAT THIS DELIBERATELY IS NOT. There is no DAO and no Compose UI here yet, so nothing on Android + * reads or writes these tables today. That is a stated gap, not an oversight: the value of a gym log + * book is entirely in how it feels to tap through between sets with the phone face-down, and screens + * written without a device to try them on would compile and be bad to use. An Android user should + * take that half. What is landed here is the half that CAN be proved correct without a device — + * `SchemaOracleTest` compares Room's KSP-exported schema against the same `schema_oracle.json` the + * GRDB suite checks, so a column, order, affinity, nullability, default, key or index that drifts + * from Apple's fails this build. + * + * FIELD ORDER IS THE CONTRACT. Room emits columns in declaration order, so each class below follows + * its GRDB `create(table:)` exactly; a reordering here is a real schema divergence even though every + * column still exists (`CLAUDE.md`, and the `battery-alter-append-order` ledger entry that records + * what it costs to fix one after the fact). + * + * Two shapes worth naming: + * - `archived` and `isWarmup` carry `@ColumnInfo(defaultValue = "0")`. A Kotlin constructor default + * never reaches the schema — only the annotation does — and GRDB declares both NOT NULL DEFAULT 0. + * Leaving the annotation off would reproduce the `room-omits-sql-default` divergence on brand-new + * tables, which is the one chance there is to simply not have it. + * - Each `id` is a non-null Kotlin `String`, so Room writes `id TEXT NOT NULL, PRIMARY KEY(id)` + * while SQLite's legacy quirk leaves GRDB's `id TEXT PRIMARY KEY` nullable. That is the existing + * `sqlite-text-pk-nullable` ledger entry, which every TEXT-keyed table on both platforms already + * carries; no writer on either side ever supplies a null id. + */ + +/** + * The user's own exercise vocabulary. NOOP ships **no** exercise catalogue and no exercise→muscle + * mapping: the name is whatever the user types, and the muscles are the ones they assigned. A closed + * catalogue silently mis-attributes everything it fails to recognise. + */ +@Entity( + tableName = "liftExercise", + indices = [ + Index(name = "idx_liftExercise_natural", value = ["deviceId", "name"], unique = true), + ], +) +data class LiftExerciseRow( + @PrimaryKey + val id: String, + val deviceId: String, + val name: String, + /** A `LiftMuscle` raw value. A stored-data contract: never rename or remove a token. */ + val primaryMuscle: String? = null, + /** Comma-separated `LiftMuscle` raw values, same contract. */ + val secondaryMuscles: String? = null, + val createdAt: Long, + /** Unix seconds; recency, so the picker can offer what was used most recently. */ + val lastUsedTs: Long? = null, +) + +/** A reusable program, e.g. "Upper A". Archived rather than deleted, so history keeps resolving. */ +@Entity( + tableName = "liftProgram", + indices = [ + Index(name = "idx_liftProgram_device_updatedAt", value = ["deviceId", "updatedAt"]), + ], +) +data class LiftProgramRow( + @PrimaryKey + val id: String, + val deviceId: String, + val name: String, + val note: String? = null, + val createdAt: Long, + /** Unix seconds; drives most-recent-first ordering in the hub. */ + val updatedAt: Long, + @ColumnInfo(defaultValue = "0") + val archived: Boolean = false, +) + +/** + * One exercise line inside a program: the TARGETS. What actually happened lives in [LiftSetEntity]. + * + * A line plans a WEIGHT, not only a rep range — `targetWeightKg` — because a program that cannot say + * how heavy is not a program anyone follows. + */ +@Entity( + tableName = "liftProgramItem", + indices = [ + Index(name = "idx_liftProgramItem_device", value = ["deviceId"]), + Index(name = "idx_liftProgramItem_program_ord", value = ["programId", "ord"]), + ], +) +data class LiftProgramItemRow( + @PrimaryKey + val id: String, + val deviceId: String, + val programId: String, + /** Position within the program, 0-based. */ + val ord: Int, + val exercise: String, + val targetSets: Int? = null, + /** Rep-range low end — the 8 of "8-10". */ + val targetRepsLow: Int? = null, + val targetRepsHigh: Int? = null, + /** Max RPE on the user's own 1-10 scale: the ceiling the session shows grey, and what a set left unrated records. */ + val targetRpe: Double? = null, + val targetWeightKg: Double? = null, + /** Intended rest after each set, seconds. */ + val restSec: Int? = null, + /** The user's own technique cue, stored and shown back verbatim. */ + val note: String? = null, +) + +/** + * One gym session. Pairs 1:1 with a `workout` row through the natural key + * `(deviceId, startTs, sport)`, which is why that index is UNIQUE. + * + * `sessionRpe` is a NUMBER rather than text appended to the note: Foster's session load is + * sRPE x duration, so the rating has to be computable or the metric cannot be derived at all. + */ +@Entity( + tableName = "liftSession", + indices = [ + Index(name = "idx_liftSession_natural", value = ["deviceId", "startTs", "sport"], unique = true), + ], +) +data class LiftSessionRow( + @PrimaryKey + val id: String, + val deviceId: String, + /** Unix seconds; the same instant as the paired `workout.startTs`. */ + val startTs: Long, + /** Nil while the session is still running. */ + val endTs: Long? = null, + /** The same token as the paired `workout.sport`. */ + val sport: String, + /** Nil for a freehand session with no program behind it. */ + val programId: String? = null, + /** The program's name AS IT WAS, so a later rename never rewrites history. */ + val programName: String? = null, + val sessionRpe: Double? = null, + val note: String? = null, +) + +/** + * One set as performed — rows, not a JSON blob, because "what did I lift for this exercise last + * time" is the read the whole feature exists for, and against a blob it is not answerable by an + * index. + * + * `primaryMuscle` / `secondaryMuscles` are snapshotted AS THEY WERE at log time, so reclassifying an + * exercise later never silently rewrites what past weeks were counted as. + * + * Named `LiftSetEntity` rather than `LiftSetRow` only because `Row` is already this package's name + * for several unrelated types; the TABLE is `liftSet`, which is what the oracle compares. + */ +@Entity( + tableName = "liftSet", + indices = [ + Index(name = "idx_liftSet_device_exercise", value = ["deviceId", "exercise"]), + Index(name = "idx_liftSet_session_ord", value = ["sessionId", "ord"]), + ], +) +data class LiftSetEntity( + @PrimaryKey + val id: String, + val deviceId: String, + val sessionId: String, + /** Order within the session — COMPLETION order, which with out-of-order work is not plan order. */ + val ord: Int, + val exercise: String, + val primaryMuscle: String? = null, + val secondaryMuscles: String? = null, + /** 1-based within its exercise. */ + val setIndex: Int, + /** Kilograms. Display units convert; storage does not. */ + val weightKg: Double? = null, + val reps: Int? = null, + /** 1-10 as rated by the user, and never carried from another set. */ + val rpe: Double? = null, + /** Warm-ups are excluded from volume and from the per-muscle counts. */ + @ColumnInfo(defaultValue = "0") + val isWarmup: Boolean = false, + val startTs: Long? = null, + val endTs: Long? = null, + /** Rest ACTUALLY taken after this set, measured from the taps rather than planned. */ + val restSec: Int? = null, + val note: String? = null, +) diff --git a/android/app/src/main/java/com/noop/data/LiftMuscle.kt b/android/app/src/main/java/com/noop/data/LiftMuscle.kt new file mode 100644 index 0000000000..fd2cbc7aaf --- /dev/null +++ b/android/app/src/main/java/com/noop/data/LiftMuscle.kt @@ -0,0 +1,106 @@ +package com.noop.data + +/** + * The muscle vocabulary a logged set is classified under. + * + * Kotlin mirror of the macOS/iOS source of truth + * Packages/WhoopStore/Sources/WhoopStore/LiftMuscle.swift + * + * THE TOKENS ARE A STORED-DATA CONTRACT. `name` is what lands in the `primaryMuscle` column and in + * the comma-joined `secondaryMuscles` column, and the same tokens cross the `.noopbak` boundary to + * an Apple install. Never rename or remove one; adding is safe because [decodeList] skips what it + * does not recognise. + */ +enum class LiftMuscle { + // Push + chest, + frontDelts, + sideDelts, + rearDelts, + triceps, + + // Pull + lats, + upperBack, + traps, + biceps, + forearms, + + // Legs + quads, + hamstrings, + glutes, + adductors, + abductors, + calves, + + // Trunk + abs, + obliques, + lowerBack, + neck, + ; + + /** + * Coarse section, used only to group the picker. Not stored, not counted — purely presentation + * scaffolding, so changing it is free. + */ + enum class Region { push, pull, legs, trunk } + + val region: Region + get() = when (this) { + chest, frontDelts, sideDelts, rearDelts, triceps -> Region.push + lats, upperBack, traps, biceps, forearms -> Region.pull + quads, hamstrings, glutes, adductors, abductors, calves -> Region.legs + abs, obliques, lowerBack, neck -> Region.trunk + } + + companion object { + /** + * A set credits its PRIMARY muscle in full and each SECONDARY at a half. + * + * The 0.5 is not a house convention: the dose-response meta-regression the reference doses + * come from compared 1.0, 0.5 and 0.0 for a secondary mover and found the evidence + * strongest for fractional, which its primary models use. Change this and the reference + * doses stop meaning what they claim. + */ + const val directSetCredit: Double = 1.0 + const val indirectSetCredit: Double = 0.5 + + /** + * Raw token to case, or null when the token is not one this build knows. + * + * Kotlin-only by design: Swift gets this free from `RawRepresentable.init(rawValue:)`, so + * there is no Swift declaration to pair with rather than a missing one. + */ + fun fromRaw(raw: String?): LiftMuscle? = + raw?.let { token -> entries.firstOrNull { it.name == token } } + + /** + * Encode a secondary list for storage. Returns null for an empty list so the column stays + * NULL rather than holding an empty string: two spellings of "none" is a bug waiting. + * Duplicates and the primary are stripped so one set can never be counted twice for one + * muscle, and the user's ordering is preserved. + * + * The Swift twin is `LiftMuscle.encodeList`. + */ + fun encodeList(muscles: List, primary: LiftMuscle? = null): String? { + val seen = LinkedHashSet() + if (primary != null) seen.add(primary) + val kept = ArrayList() + for (m in muscles) if (seen.add(m)) kept.add(m) + return if (kept.isEmpty()) null else kept.joinToString(",") { it.name } + } + + /** + * Decode a stored secondary list. Unknown tokens are skipped rather than failing the read: + * a database written by a newer build must stay readable by an older one. + * + * The Swift twin is `LiftMuscle.decodeList`. + */ + fun decodeList(stored: String?): List { + if (stored.isNullOrEmpty()) return emptyList() + return stored.split(",").mapNotNull { fromRaw(it) } + } + } +} diff --git a/android/app/src/main/java/com/noop/data/OuraSerialIdentity.kt b/android/app/src/main/java/com/noop/data/OuraSerialIdentity.kt new file mode 100644 index 0000000000..2cccff5da1 --- /dev/null +++ b/android/app/src/main/java/com/noop/data/OuraSerialIdentity.kt @@ -0,0 +1,25 @@ +package com.noop.data + +/** + * What may be written to a SHAREABLE strap log for an Oura ring's serial (#2092). Byte-parity twin of + * Swift `OuraSerialIdentity`. + * + * Mirrors [WhoopSerialIdentity.logSafe]'s shape exactly (same 3-character prefix + "…") so a masked + * WHOOP serial and a masked Oura serial read identically in a shared log — the reader should not need to + * know which brand a masked id came from to trust it is masked. + * + * Kept as its OWN type rather than a shared call into [WhoopSerialIdentity]: Oura's serial identity has + * its own home and does not share WHOOP's `adoptedId`/`mayAdopt`/`isAlreadyAdopted` machinery — only the + * LOG-SAFETY shape is common, not the identity model. + */ +object OuraSerialIdentity { + /** The one place the Oura id namespace is spelled — matches the brand catalog's "oura" entry. */ + const val ID_PREFIX = "oura" + + /** Never log "$ID_PREFIX-$serial" (or the bare serial) directly — only this. */ + 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/data/OuraStreamMapping.kt b/android/app/src/main/java/com/noop/data/OuraStreamMapping.kt index d44fab7c0b..ab72610f8d 100644 --- a/android/app/src/main/java/com/noop/data/OuraStreamMapping.kt +++ b/android/app/src/main/java/com/noop/data/OuraStreamMapping.kt @@ -113,6 +113,14 @@ object OuraStreamMapping { } is OuraEvent.Spo2 -> { + // Only persist the 0x6F channel (unit == "raw"; 0x70 is Tier B and never reaches Spo2, + // and 0x7B tags itself "stable_raw" because its scale is unpinned): a real overnight capture + // (2026-07-30/31, 22516 samples) clusters tightly at 95-105, matching a genuine %SpO2 + // reading. The 0x77 DC channel (unit == "dc_raw") is a wildly different-scale raw + // PPG/perfusion signal (-9K to +11.7M in the same capture) - not SpO2 at all, so + // blending it into `red` would corrupt any consumer that averages the stream with no + // unit filter. Mirrors the Swift OuraStreamMapping twin. + if (ev.value.unit != "raw") continue // The ring exposes ONE combined SpO2 reading (not separate red/ir channels): its // raw value goes in `red`; `ir` stays 0 (an unread channel, never a fabricated // second reading). `unit` carries the decoder's own scale tag so downstream never 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/SleepSessionUpsertPolicy.kt b/android/app/src/main/java/com/noop/data/SleepSessionUpsertPolicy.kt new file mode 100644 index 0000000000..6c3336c078 --- /dev/null +++ b/android/app/src/main/java/com/noop/data/SleepSessionUpsertPolicy.kt @@ -0,0 +1,43 @@ +package com.noop.data + +import com.noop.analytics.HypnogramCoverage + +/** + * Decides how a cache refresh may replace an existing sleep-session row. + * + * A strap can serve the same night more than once, and a resumed drain can carry a thinner stage + * timeline than the row already on disk. Dropping that candidate whole keeps `endTs`, `efficiency` + * and `stagesJSON` describing one coherent decode. The 0/1/2 rank is the same rule the display merge + * uses and mirrors `WhoopStore.SleepMerge.richness`. + * + * Hand-edited bounds and stages belong to the user, so a normal refresh preserves them while still + * updating derived vitals and `stagingSparse`. Motion and band-state arrays have dedicated writers; + * they are retained on every cache refresh just as the Swift upsert leaves those columns untouched. + */ +internal object SleepSessionUpsertPolicy { + /** The accepted replacement row, or null when [candidate] is poorer than [existing]. */ + fun merge(existing: SleepSession, candidate: SleepSession): SleepSession? { + if (!candidate.userEdited && !existing.userEdited && + richness(candidate) < richness(existing) + ) { + return null + } + + return candidate.copy( + endTs = if (existing.userEdited) existing.endTs else candidate.endTs, + stagesJSON = if (existing.userEdited) existing.stagesJSON else candidate.stagesJSON, + userEdited = existing.userEdited, + startTsAdjusted = if (existing.userEdited) existing.startTsAdjusted else candidate.startTsAdjusted, + motionJSON = existing.motionJSON, + sleepStateJSON = existing.sleepStateJSON, + ) + } + + /** 0 = no stages, 1 = stages with holes, 2 = stages covering the claimed span. */ + fun richness(session: SleepSession): Int { + val stages = session.stagesJSON?.trim() ?: return 0 + if (stages.isEmpty() || stages == "[]") return 0 + val span = (session.endTs - session.startTs).toDouble() + return if (HypnogramCoverage.isHoled(stages, span)) 1 else 2 + } +} 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..17815abaf8 100644 --- a/android/app/src/main/java/com/noop/data/WhoopDao.kt +++ b/android/app/src/main/java/com/noop/data/WhoopDao.kt @@ -5,9 +5,144 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction +import androidx.room.Update 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 'v3|' || " + + "'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) || " + + "'|w5' || (SELECT COUNT(*) FROM rrInterval WHERE srcChannel = 5 AND (tsSuspect IS NULL OR tsSuspect <> 1)) || " + + "'|w7' || (SELECT COUNT(*) FROM rrInterval WHERE srcChannel = 7 AND (tsSuspect IS NULL OR tsSuspect <> 1)) || " + + "'|tagged' || (SELECT COUNT(*) FROM rrInterval WHERE srcChannel IN (5, 6, 7)) || " + + "'|registry' || (SELECT COALESCE(GROUP_CONCAT(identity, ';'), '') FROM " + + "(SELECT QUOTE(id) || ':' || QUOTE(brand) || ':' || QUOTE(model) || ':' || QUOTE(status) AS identity FROM pairedDevice ORDER BY id))" + +/** 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 's2|' || " + + "'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) || " + + "'|w5' || (SELECT COUNT(*) FROM rrInterval WHERE deviceId = :deviceId AND ts >= :from AND ts <= :to " + + "AND srcChannel = 5 AND (tsSuspect IS NULL OR tsSuspect <> 1)) || " + + "'|w7' || (SELECT COUNT(*) FROM rrInterval WHERE deviceId = :deviceId AND ts >= :from AND ts <= :to " + + "AND srcChannel = 7 AND (tsSuspect IS NULL OR tsSuspect <> 1)) || " + + "'|ownerTagged' || EXISTS(SELECT 1 FROM rrInterval WHERE deviceId = :deviceId AND srcChannel IN (5, 6, 7)) || " + + "'|registry' || COALESCE((SELECT QUOTE(brand) || ':' || QUOTE(model) FROM pairedDevice WHERE id = :deviceId), 'absent')" + +/** Shared production SQL used by Room and the SQLite repository contract tests. */ +internal const val RR_INTERVALS_SQL = + "SELECT * 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) " + + "ORDER BY ts ASC, ord ASC, rrMs ASC, seq ASC LIMIT :limit" + +/** The transports a WHOOP 5 window may be SCORED through, as a SQL list. + * + * One constant rather than a literal per query. [WHOOP5_RR_INTERVALS_SQL] pins a window to the lowest + * of these present, and [FIRST_SCORABLE_WHOOP5_RR_SQL] reports when the first of them was banked, so + * the day the app tells a wearer its scoring begins is derived from the same set the scoring uses. Two + * literals would let those drift apart silently, and the drift would show as an explanation that names + * the wrong date. Type-40 live (6) is deliberately absent: it is a labelling channel that standard BLE + * (7) already covers beat for beat. Twin of Swift `WhoopStore.scorableWhoop5Channels`. */ +internal const val SCORABLE_WHOOP5_CHANNELS = "(5, 7)" + +internal const val WHOOP5_RR_INTERVALS_SQL = + "SELECT * FROM rrInterval WHERE deviceId = :deviceId AND ts >= :from AND ts <= :to " + + "AND (tsSuspect IS NULL OR tsSuspect <> 1) " + + "AND srcChannel = (SELECT MIN(srcChannel) FROM rrInterval " + + "WHERE deviceId = :deviceId AND ts >= :from AND ts <= :to AND srcChannel IN " + + SCORABLE_WHOOP5_CHANNELS + " " + + "AND (tsSuspect IS NULL OR tsSuspect <> 1)) " + + "ORDER BY ts ASC, ord ASC, rrMs ASC, seq ASC LIMIT :limit" + +/** The earliest beat a device has banked AT ALL, labelled or not, or null when it has none. The lower + * bound on the "cannot be scored" explanation: it separates history this strap actually recorded from + * history imported from somewhere else, and only the former can have lost anything to a labelling + * change. Twin of Swift `firstRecordedRRTimestamp`. */ +internal const val FIRST_RECORDED_RR_SQL = + "SELECT MIN(ts) FROM rrInterval WHERE deviceId = :deviceId " + + "AND (tsSuspect IS NULL OR tsSuspect <> 1)" + +/** The earliest beat a device has banked that the unit policy can actually score, or null when it has + * none at all. Cheap and device-level, not per-day: one indexed MIN over the beats already on disk. + * Carries the same suspect-timestamp exclusion as the scoring read (#1073), so it cannot name a day + * that scoring would then refuse. Twin of Swift `firstScorableWhoop5RRTimestamp`. */ +internal const val FIRST_SCORABLE_WHOOP5_RR_SQL = + "SELECT MIN(ts) FROM rrInterval WHERE deviceId = :deviceId AND srcChannel IN " + + SCORABLE_WHOOP5_CHANNELS + " AND (tsSuspect IS NULL OR tsSuspect <> 1)" + +internal const val HAS_WHOOP5_RR_SOURCE_SQL = + "SELECT EXISTS(SELECT 1 FROM rrInterval WHERE deviceId = :deviceId AND srcChannel IN (5, 6, 7))" + +internal const val LEGACY_WHOOP5_RR_WITHHELD_SQL = + "SELECT EXISTS(SELECT 1 FROM rrInterval WHERE deviceId = :deviceId " + + "AND ts >= :from AND ts <= :to AND srcChannel IS NULL " + + "AND (tsSuspect IS NULL OR tsSuspect <> 1)) " + + "AND NOT EXISTS(SELECT 1 FROM rrInterval WHERE deviceId = :deviceId " + + "AND ts >= :from AND ts <= :to AND srcChannel IN " + SCORABLE_WHOOP5_CHANNELS + " " + + "AND (tsSuspect IS NULL OR tsSuspect <> 1))" + +internal const val PROMOTE_WHOOP5_RR_SOURCE_SQL = + "UPDATE rrInterval SET srcChannel = :source, ord = :ord " + + "WHERE deviceId = :deviceId AND ts = :ts AND rrMs = :rrMs AND seq = :seq " + + "AND ((:source = 5 AND (srcChannel IS NULL OR srcChannel IN (6, 7))) " + + "OR (:source = 7 AND (srcChannel IS NULL OR srcChannel = 6)))" + /** * Data-access for the local store. Mirrors the GRDB reads/writes in WhoopStore * (StreamStore.swift, Reads.swift, MetricsCache.swift). @@ -124,10 +259,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 +267,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 +291,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 @@ -171,6 +299,14 @@ interface WhoopDao : DeviceRegistryDao { @Upsert suspend fun upsertSleepSessions(rows: List) + /** Exact row read for the repository's transactional sleep-cache replacement guard (#1976). */ + @Query("SELECT * FROM sleepSession WHERE deviceId = :deviceId AND startTs = :startTs") + suspend fun sleepSession(deviceId: String, startTs: Long): SleepSession? + + /** Update an existing row after [SleepSessionUpsertPolicy] has preserved its protected fields. */ + @Update + suspend fun updateSleepSession(row: SleepSession): Int + /** Remove one sleep session by its full primary key (deviceId, startTs) — used by the * bed/wake-time edit, which deletes then re-inserts because startTs is part of the PK. */ @Query("DELETE FROM sleepSession WHERE deviceId = :deviceId AND startTs = :startTs") @@ -210,8 +346,8 @@ interface WhoopDao : DeviceRegistryDao { * v18 (H8): write the per-epoch motion magnitudes (compact JSON array) for one session, banked beside * `stagesJSON` on the same row. Keyed by the IMMUTABLE detected key (deviceId, startTs). `null` clears * the column (no series). Port of iOS WhoopStore.persistSessionMotion (the repository encodes the array). - * Returns rows changed (0 when no such session). Targeted UPDATE so the @Upsert recompute/import path — - * which never names this column — preserves it. */ + * Returns rows changed (0 when no such session). Targeted UPDATE owns this column; + * [SleepSessionUpsertPolicy] preserves it through recompute/import refreshes. */ @Query( "UPDATE sleepSession SET motionJSON = :json WHERE deviceId = :deviceId AND startTs = :sessionStart" ) @@ -267,6 +403,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 +423,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 +437,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) @@ -344,7 +491,8 @@ interface WhoopDao : DeviceRegistryDao { * selects are UNION ALL'd into one bpm stream, then bucket-averaged exactly as before. Matches * the Swift hrBuckets COALESCE union. */ @Query( - "SELECT (ts / :bucketSeconds) * :bucketSeconds AS bucket, AVG(bpm) AS avgBpm FROM (" + + "SELECT (ts / :bucketSeconds) * :bucketSeconds AS bucket, AVG(bpm) AS avgBpm, " + + "MIN(bpm) AS minBpm, MAX(bpm) AS maxBpm FROM (" + "SELECT ts, bpm FROM hrSample " + "WHERE deviceId = :deviceId AND ts >= :from AND ts <= :to " + "UNION ALL " + @@ -417,7 +565,6 @@ interface WhoopDao : DeviceRegistryDao { ) suspend fun hrWindowStats(primaryId: String, secondaryId: String, from: Long, to: Long): HrWindowStats - @Query( // #823: `ord` FIRST, so same-second beats come back in EMISSION order. Ordering by rrMs made // successive beats similar by construction and biased RMSSD (all successive differences) down. // Pre-v24 rows have ord NULL and SQLite sorts NULL first in ASC, so an all-NULL second ties here @@ -434,8 +581,8 @@ interface WhoopDao : DeviceRegistryDao { // // The predicate EXCLUDES the one channel proven redundant (SPO2_IBI, 0x6E) rather than // whitelisting the one preferred (GREEN_QUALITY, 0x80), which matters for what it does NOT drop: - // - NULL is kept. Every WHOOP row is NULL by construction (one beat source), as is every row - // written before v26. A whitelist would delete every WHOOP night from scoring. + // - NULL is kept for WHOOP 4 and unlabelled legacy owners. The repository routes strict + // WHOOP 5 owners to whoop5RrIntervals instead. // - IBI_AMPLITUDE (0x60/0x44) is kept. It does not fire on the Gen-3 hardware this was measured // on, so there is no evidence it duplicates green — and dropping a ring's ONLY beat source on // an untested assumption is the more expensive mistake. If a capture ever shows 0x60 and 0x80 @@ -445,23 +592,53 @@ interface WhoopDao : DeviceRegistryDao { // is on — so scoring off it would make HRV coverage a function of the SpO2 duty cycle. // // Rows are FILTERED, never deleted: the 0x6E stream stays on disk as the cross-check on green. - // Every R-R consumer reads through this one query, so the `hrv diag` trace moves with the scores - // rather than reporting a coverage nobody can reproduce. The literal 2 is RrSourceChannel.SPO2_IBI + // Legacy and non-WHOOP5 readers use this query. Strict WHOOP 5 uses the source-selected query below. The literal 2 is RrSourceChannel.SPO2_IBI // .code — a Room @Query is a compile-time constant string and cannot reference it; RrChannelTest // pins the two together. - "SELECT * 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) " + // #1073: exclude future-stamped beats - "ORDER BY ts ASC, ord ASC, rrMs ASC, seq ASC LIMIT :limit" - ) + @Query(RR_INTERVALS_SQL) suspend fun rrIntervals(deviceId: String, from: Long, to: Long, limit: Int): List + /** WHOOP 5: one eligible transport over the entire requested interval, selected before LIMIT. */ + @Query(WHOOP5_RR_INTERVALS_SQL) + suspend fun whoop5RrIntervals(deviceId: String, from: Long, to: Long, limit: Int): List + + @Query(HAS_WHOOP5_RR_SOURCE_SQL) + suspend fun hasWhoop5RrSource(deviceId: String): Boolean + + /** Exact-window twin of Swift `legacyWhoop5RRWithheld`; source-family gating stays in Repository. */ + @Query(LEGACY_WHOOP5_RR_WITHHELD_SQL) + suspend fun legacyWhoop5RrWithheld(deviceId: String, from: Long, to: Long): Boolean + + /** Nullable because MIN over no rows is SQL NULL: a device with nothing scorable yet. */ + @Query(FIRST_SCORABLE_WHOOP5_RR_SQL) + suspend fun firstScorableWhoop5RrTs(deviceId: String): Long? + + /** Nullable for the same reason: a device that has banked no beats at all. */ + @Query(FIRST_RECORDED_RR_SQL) + suspend fun firstRecordedRrTs(deviceId: String): Long? + + /** Newly observed canonical source wins exact-key collisions: history > standard > native/legacy. */ + @Query(PROMOTE_WHOOP5_RR_SOURCE_SQL) + suspend fun promoteWhoop5RrSource(deviceId: String, ts: Long, rrMs: Int, seq: Int, ord: Int, source: Int) + @Query( "SELECT * FROM event WHERE deviceId = :deviceId AND ts >= :from AND ts <= :to " + "ORDER BY ts ASC, kind ASC LIMIT :limit" ) 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 +663,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 +731,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 +802,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 +914,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 @@ -867,8 +1136,8 @@ interface WhoopDao : DeviceRegistryDao { @Query("SELECT COUNT(*) FROM appleDaily WHERE deviceId = :deviceId AND day >= :from AND day <= :to") suspend fun appleDailyCount(deviceId: String, from: String, to: String): Int - /** Delete a computed source's workouts of a given [sport] whose startTs is in [from, to] - * (makes detected-workout re-derivation idempotent). (#78) */ + /** Delete a computed source's workouts of a given [sport] whose startTs is in [from, to]. + * Retained for explicit maintenance; automatic reconciliation no longer calls it (#2187). */ @Query("DELETE FROM workout WHERE deviceId = :deviceId AND sport = :sport AND startTs >= :from AND startTs <= :to") suspend fun deleteWorkoutsBySport(deviceId: String, sport: String, from: Long, to: Long) @@ -877,7 +1146,7 @@ interface WhoopDao : DeviceRegistryDao { @Query("DELETE FROM workout WHERE deviceId = :deviceId AND startTs = :startTs AND sport = :sport") suspend fun deleteWorkoutByKey(deviceId: String, startTs: Long, sport: String) - // MARK: - Dismissed detected bouts (durable #107 marker; survives engine re-detection) + // MARK: - Dismissed detected bouts (durable #107 marker; retained for legacy history/suggestions) /** Record a dismissed detected bout. IGNORE so re-dismissing the same bout is a no-op. */ @Insert(onConflict = OnConflictStrategy.IGNORE) @@ -941,6 +1210,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 +1221,44 @@ 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 + // Does this device have ANY heart rate in the window? A scalar EXISTS, not a row. + // + // The day-owner resolver asks this once per candidate per day, so a 60-day steps-calibration window + // on a two-strap install asks it 120 times per pass. It used to be answered by fetching a LIMIT 1 + // ROW from [hrSamples] and testing the list for emptiness, which materialises a cursor and an + // HrSample for a question whose answer is one bit. + // + // BOTH tables, because [hrSamples] is a UNION and "has heart rate" has always meant either of them. + // A WHOOP 4.0 v25 record stores no per-second HR at all — it is PPG-derived and lands in + // `ppgHrSample` — so checking `hrSample` alone would have quietly stopped those days from owning + // themselves. The union's `NOT EXISTS` de-dupe does not affect PRESENCE: a ppgHr row suppressed + // because an hrSample shares its ts implies hrSample is non-empty, so "union non-empty" is exactly + // "hrSample non-empty OR ppgHrSample non-empty". OR short-circuits, so the common case is one probe. + @Query( + "SELECT EXISTS(SELECT 1 FROM hrSample WHERE deviceId = :deviceId AND ts >= :from AND ts <= :to) " + + "OR EXISTS(SELECT 1 FROM ppgHrSample WHERE deviceId = :deviceId AND ts >= :from AND ts <= :to)" + ) + suspend fun hasHrInWindow(deviceId: String, from: Long, to: Long): Boolean + + // Per-day (device + window) GRAVITY witness for the steps-calibration motion cache. It is exactly the + // `g` segment of DAY_STREAM_FINGERPRINT_SQL below, on its own: that one counts gravity alongside eight + // other streams, so a new HR row would invalidate a motion volume that cannot have changed. + // dayMotionIntensity folds one day's gravity and nothing else, so its key must move when that stream + // moves and at no other time. Mirrors Swift WhoopStore.gravityFingerprint. + // + // ONE query returning both columns, not two returning one each. Two would let an insert land between + // them and yield a count from before it beside a newest-timestamp from after — a witness describing a + // state the day was never in. The pair has to be read atomically to mean anything. + @Query( + "SELECT COUNT(*) AS c, COALESCE(MAX(ts), 0) AS m FROM gravitySample " + + "WHERE deviceId = :deviceId AND ts >= :from AND ts <= :to" + ) + suspend fun gravityWitnessInWindow(deviceId: String, from: Long, to: Long): GravityWitness + // #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 +1271,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 +1333,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 +1354,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 09558f0158..34bb40c131 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,16 @@ import androidx.sqlite.db.SupportSQLiteDatabase LabMarkerRow::class, LiveSessionRow::class, PpgWaveformSampleEntity::class, - RawImuSampleEntity::class, V18AuxSampleEntity::class, AppleStepHour::class, + CoachMessageRow::class, + LiftExerciseRow::class, + LiftProgramRow::class, + LiftProgramItemRow::class, + LiftSessionRow::class, + LiftSetEntity::class, ], - version = 33, + version = 40, // #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 +72,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 = 33 + const val SCHEMA_VERSION = 40 @Volatile private var instance: WhoopDatabase? = null @@ -125,9 +134,10 @@ abstract class WhoopDatabase : RoomDatabase() { } /** - * v4 -> v5: ADDITIVE, adds the `dismissedWorkout` table (#107): a durable marker that keeps a - * dismissed auto-detected bout hidden after the engine re-derives it. CREATE TABLE only (no - * data touched), so existing workouts/history are untouched. The SQL MUST match Room's + * v4 -> v5: ADDITIVE, adds the `dismissedWorkout` table (#107): a durable marker for a rejected + * detected bout. Generic detected-row reconciliation is retired, but the table remains part of + * grandfathered history and suggestion suppression. CREATE TABLE only (no data touched), so existing + * workouts/history are untouched. The SQL MUST match Room's * generated schema for the [DismissedWorkout] entity exactly, all three PK columns NOT NULL, * composite PRIMARY KEY in declaration order. Guarded by MigrationRoundTripTest like the others. */ @@ -544,6 +554,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 +583,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`))", @@ -899,6 +914,183 @@ abstract class WhoopDatabase : RoomDatabase() { } } + /** + * #1636: keep the nightly ABSOLUTE skin temperature beside the deviation derived from it. + * + * The engine computed this mean on every scoring pass and discarded it the moment `skinTempDevC` + * was taken, so the app could show "+0.5 Δ°C" with no way to learn what it moved from — and a + * febrile night reads as a small delta where the absolute reads as a fever. Nullable and additive: + * existing rows stay null and refill on the next scoring pass, because the value is re-derived + * from raw `skinTempSample` rows still on disk. No backfill statement, and therefore no second + * derivation that could disagree with the live one. + * + * Twin of the Swift `v40-daily-skin-temp-absolute` GRDB migration. + */ + internal val DAILY_SKIN_TEMP_ABSOLUTE_MIGRATION_SQL: List = listOf( + "ALTER TABLE `dailyMetric` ADD COLUMN `skinTempC` REAL", + ) + + internal val MIGRATION_33_34 = object : Migration(33, 34) { + override fun migrate(db: SupportSQLiteDatabase) { + for (stmt in DAILY_SKIN_TEMP_ABSOLUTE_MIGRATION_SQL) db.execSQL(stmt) + } + } + + 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) + } + } + + /** + * #2019: carry the v26 optical window's ABSOLUTE base code beside its deltas. + * + * The 25-sample window is one absolute ADC code plus 24 deltas, and only the deltas were read, + * so the stored `samples` blob is a derivative and the DC level was thrown away. Nullable and + * additive: an existing row keeps its deltas and gets a null base, which is the true statement + * about it. A delta series cannot be inverted without the base, so those windows have no + * recoverable absolute level and no backfill can invent one. + */ + internal val MIGRATION_37_38 = object : Migration(37, 38) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `ppgWaveformSample` ADD COLUMN `baseCode` INTEGER") + } + } + + /** 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`) + )""" + ) + } + } + + /** Covers the source-promotion cache witnesses. Twin of GRDB v45-rr-source-index. */ + internal const val RR_SOURCE_INDEX_SQL = + "CREATE INDEX IF NOT EXISTS rrInterval_source_suspect ON rrInterval(srcChannel, tsSuspect)" + + internal val MIGRATION_38_39 = object : Migration(38, 39) { + override fun migrate(db: SupportSQLiteDatabase) { db.execSQL(RR_SOURCE_INDEX_SQL) } + } + + /** + * The in-app strength log. Twin of GRDB `v46-lift-log`; see `LiftEntities.kt` for what these + * tables hold and for why the Android half is schema-only for now. + * + * Column order matches the entity declaration order, which matches GRDB's `create(table:)`, + * and `SchemaOracleTest` fails on any of the three drifting apart. Every statement is + * `IF NOT EXISTS`, matching the GRDB side: a database that already carries these tables + * converges rather than throwing. + */ + internal val LIFT_LOG_SQL: List = listOf( + """CREATE TABLE IF NOT EXISTS `liftExercise` ( + `id` TEXT NOT NULL, + `deviceId` TEXT NOT NULL, + `name` TEXT NOT NULL, + `primaryMuscle` TEXT, + `secondaryMuscles` TEXT, + `createdAt` INTEGER NOT NULL, + `lastUsedTs` INTEGER, + PRIMARY KEY(`id`) + )""", + "CREATE UNIQUE INDEX IF NOT EXISTS `idx_liftExercise_natural` ON `liftExercise` (`deviceId`, `name`)", + """CREATE TABLE IF NOT EXISTS `liftProgram` ( + `id` TEXT NOT NULL, + `deviceId` TEXT NOT NULL, + `name` TEXT NOT NULL, + `note` TEXT, + `createdAt` INTEGER NOT NULL, + `updatedAt` INTEGER NOT NULL, + `archived` INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY(`id`) + )""", + "CREATE INDEX IF NOT EXISTS `idx_liftProgram_device_updatedAt` ON `liftProgram` (`deviceId`, `updatedAt`)", + """CREATE TABLE IF NOT EXISTS `liftProgramItem` ( + `id` TEXT NOT NULL, + `deviceId` TEXT NOT NULL, + `programId` TEXT NOT NULL, + `ord` INTEGER NOT NULL, + `exercise` TEXT NOT NULL, + `targetSets` INTEGER, + `targetRepsLow` INTEGER, + `targetRepsHigh` INTEGER, + `targetRpe` REAL, + `targetWeightKg` REAL, + `restSec` INTEGER, + `note` TEXT, + PRIMARY KEY(`id`) + )""", + "CREATE INDEX IF NOT EXISTS `idx_liftProgramItem_device` ON `liftProgramItem` (`deviceId`)", + "CREATE INDEX IF NOT EXISTS `idx_liftProgramItem_program_ord` ON `liftProgramItem` (`programId`, `ord`)", + """CREATE TABLE IF NOT EXISTS `liftSession` ( + `id` TEXT NOT NULL, + `deviceId` TEXT NOT NULL, + `startTs` INTEGER NOT NULL, + `endTs` INTEGER, + `sport` TEXT NOT NULL, + `programId` TEXT, + `programName` TEXT, + `sessionRpe` REAL, + `note` TEXT, + PRIMARY KEY(`id`) + )""", + "CREATE UNIQUE INDEX IF NOT EXISTS `idx_liftSession_natural` ON `liftSession` (`deviceId`, `startTs`, `sport`)", + """CREATE TABLE IF NOT EXISTS `liftSet` ( + `id` TEXT NOT NULL, + `deviceId` TEXT NOT NULL, + `sessionId` TEXT NOT NULL, + `ord` INTEGER NOT NULL, + `exercise` TEXT NOT NULL, + `primaryMuscle` TEXT, + `secondaryMuscles` TEXT, + `setIndex` INTEGER NOT NULL, + `weightKg` REAL, + `reps` INTEGER, + `rpe` REAL, + `isWarmup` INTEGER NOT NULL DEFAULT 0, + `startTs` INTEGER, + `endTs` INTEGER, + `restSec` INTEGER, + `note` TEXT, + PRIMARY KEY(`id`) + )""", + "CREATE INDEX IF NOT EXISTS `idx_liftSet_device_exercise` ON `liftSet` (`deviceId`, `exercise`)", + "CREATE INDEX IF NOT EXISTS `idx_liftSet_session_ord` ON `liftSet` (`sessionId`, `ord`)", + ) + + internal val MIGRATION_39_40 = object : Migration(39, 40) { + override fun migrate(db: SupportSQLiteDatabase) { LIFT_LOG_SQL.forEach(db::execSQL) } + } + /** * Every migration the builder registers, as a VALUE rather than an argument list. * @@ -924,10 +1116,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_30_31, MIGRATION_31_32, MIGRATION_32_33, MIGRATION_33_34, MIGRATION_34_35, MIGRATION_35_36, + MIGRATION_36_37, MIGRATION_37_38, MIGRATION_38_39, MIGRATION_39_40, ) - 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 b80e3b4c16..4ecb79e8d1 100644 --- a/android/app/src/main/java/com/noop/data/WhoopRepository.kt +++ b/android/app/src/main/java/com/noop/data/WhoopRepository.kt @@ -85,6 +85,16 @@ data class StreamBatch( * (excluded from [isEmpty]); empty when nothing fell through. Mirrors Swift `Streams.unhandledPacketTypes`. */ val unhandledPacketTypes: Map = emptyMap(), + /** + * #891: one full-frame hex sample per type in [unhandledPacketTypes], keyed the same way, taken from + * the first such frame in the batch. + * + * The census alone tells a reporter that their firmware banks a record NOOP cannot read, and then + * asks them to report it — while carrying none of the bytes anyone would need to map it. The dropped + * frame is not archived either: `rejectedHistoricalRecords` only ever holds type-47, so these bytes + * existed nowhere. Diag only (excluded from [isEmpty]). Mirrors Swift `Streams.unhandledPacketSamples`. + */ + val unhandledPacketSamples: Map = emptyMap(), /** * #520 diagnostic: a summary of `dynamic_acceleration@41` (the strap's own gravity-removed motion * magnitude) over this batch's v18 records. The field has been decoded on both platforms all along @@ -224,21 +234,22 @@ data class RrRow(val ts: Long, val rrMs: Int, val srcChannel: RrSourceChannel? = * and ON CONFLICT DO NOTHING keeps whichever row landed first. The historical path delivers a second * atomically, so the authoritative copy is correctly ordered. * - * And carries `srcChannel` (Room v26, #1071): the sensor channel that measured the beat, as reported by - * the decoder that produced it. NULL for every WHOOP row (one beat source — there is no channel to name, - * and that is honest rather than a placeholder). Like `ord` it is OUTSIDE the key: two channels measuring - * the same beat can yield the same (ts, rrMs), and keying on the label would store both — which is - * precisely the double-count this fixes. Twin of the Swift StreamStore insert. + * `srcChannel` labels the sensor or transport. WHOOP 5 tags name the transport; WHOOP 4 and legacy + * rows stay null. Like `ord`, it stays outside the key: two observations may name the same beat. + * WHOOP 5 transport counters are independent; historical collisions promote source and order. + * Twin of the Swift StreamStore insert. */ internal fun assignRrSeq(deviceId: String, rows: List): List { - val seqByBeat = HashMap, Int>() - val ordByTs = HashMap() + val seqByBeat = HashMap, Int>() + val ordByTs = HashMap, Int>() return rows.map { row -> - val key = row.ts to row.rrMs + val transport = row.srcChannel?.takeIf { it.isWhoop5Transport }?.code ?: 0 + val key = Triple(row.ts, row.rrMs, transport) val s = seqByBeat.getOrDefault(key, 0) seqByBeat[key] = s + 1 - val o = ordByTs.getOrDefault(row.ts, 0) - ordByTs[row.ts] = o + 1 + val second = row.ts to transport + val o = ordByTs.getOrDefault(second, 0) + ordByTs[second] = o + 1 RrInterval( deviceId = deviceId, ts = row.ts, rrMs = row.rrMs, seq = s, ord = o, srcChannel = row.srcChannel?.code, @@ -302,7 +313,14 @@ data class PpgHrRow(val ts: Long, val bpm: Int, val conf: Double) * unix second, [samples] the raw i16 ADC counts (usually 24, fewer on a truncated frame). deviceId is * attached on insert; the samples are packed to a little-endian i16 BLOB by [StreamPersistence.packPpgSamples]. */ -data class PpgWaveformRow(val ts: Long, val samples: List, val burstIndex: Int? = null) +data class PpgWaveformRow( + val ts: Long, + val samples: List, + val burstIndex: Int? = null, + /** #2019: the absolute optical code [samples] are deltas from; null on a legacy row, whose absolute + * level is gone for good because a delta series cannot be inverted without it. */ + val baseCode: Long? = null, +) /** Count of rows ACTUALLY inserted per stream (mirrors WhoopStore.insert return tuple). */ data class InsertCounts( @@ -317,11 +335,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 +354,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 +448,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 +465,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 +522,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,17 +552,35 @@ 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 - dao.insertRr(assignRrSeq(deviceId, streams.rr)) + val rrRows = assignRrSeq(deviceId, streams.rr) + val rrIds = if (rrRows.isEmpty()) emptyList() else dao.insertRr(rrRows) + for ((index, row) in rrRows.withIndex()) { + if (rrIds[index] == -1L && (row.srcChannel == RrSourceChannel.WHOOP5_HISTORICAL.code || + row.srcChannel == RrSourceChannel.WHOOP5_STANDARD.code)) { + // Counts stay separate; the canonical observation owns this key's order and provenance. + dao.promoteWhoop5RrSource(row.deviceId, row.ts, row.rrMs, row.seq, row.ord!!, row.srcChannel) + } + } val evIds = if (streams.events.isEmpty()) emptyList() else dao.insertEvents(streams.events.map { EventRow(deviceId, it.ts, it.kind, it.payloadJSON) }) val batIds = if (streams.battery.isEmpty()) emptyList() else @@ -545,9 +627,22 @@ class WhoopRepository( dao.insertPpgWaveform( streams.ppgWaveform.map { PpgWaveformSampleEntity(deviceId, it.ts, StreamPersistence.packPpgSamples(it.samples), - it.burstIndex) + it.burstIndex, it.baseCode) }, ) + // #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 +656,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 +679,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 +690,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,16 +701,73 @@ 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)) + /** Whether [deviceId] has ANY heart-rate row in the window, as a scalar EXISTS rather than a fetched + * row. The day-owner resolver's per-candidate-per-day probe; see [WhoopDao.hasHrInWindow]. */ + suspend fun hasHrInWindow(deviceId: String, from: Long, to: Long): Boolean { + // Timed HERE rather than at the call site: the steps loop that drives most of these sits inside + // `analyzeRecentOnCpu`, which has 210 bytes of JaCoCo ratchet margin and no room for a stopwatch. + // See StoreProbeTally. Instrumentation only. + val started = System.nanoTime() + val present = dao.hasHrInWindow(deviceId, from, to) + com.noop.analytics.StoreProbeTally.recordOwnerHr(System.nanoTime() - started) + return present + } + + /** Per-day (device + window) gravity fingerprint as (count, newestTs) for the steps-calibration + * motion cache. Narrower than [dayStreamFingerprint] on purpose: dayMotionIntensity folds gravity + * alone, so a new HR row must not invalidate it. Mirrors Swift WhoopStore.gravityFingerprint. */ + suspend fun gravityFingerprintWindow(deviceId: String, from: Long, to: Long): Pair { + // Timed here for the same budget reason as [hasHrInWindow]; see StoreProbeTally. + val started = System.nanoTime() + val witness = dao.gravityWitnessInWindow(deviceId, from, to) + com.noop.analytics.StoreProbeTally.recordGravityFp(System.nanoTime() - started) + return witness.c to witness.m + } + + /** #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) + "|rr5=${isWhoop5RrSource(deviceId)}" + // MARK: - Server-derived caches (latest value wins on conflict) suspend fun upsertDailyMetrics(days: List) = dao.upsertDailyMetrics(days) - suspend fun upsertSleepSessions(sessions: List) = dao.upsertSleepSessions(sessions) + + /** + * Upsert cached sleep sessions without letting a partial re-serve replace a fuller night. + * + * The read and write share one transaction so every caller gets the same store-level guarantee. + * Existing hand-edited bounds/stages and the arrays owned by targeted writers are retained by + * [SleepSessionUpsertPolicy]; derived vitals still refresh. Mirrors WhoopStore's cache upsert. + */ + suspend fun upsertSleepSessions(sessions: List) { + if (sessions.isEmpty()) return + transactor.run { + for (candidate in sessions) { + val existing = dao.sleepSession(candidate.deviceId, candidate.startTs) + if (existing == null) { + dao.insertSleepSession(candidate) + } else { + SleepSessionUpsertPolicy.merge(existing, candidate)?.let { + dao.updateSleepSession(it) + } + } + } + } + } /** Delete the computed source's cached daily rows whose day-key is in [from, to] (inclusive, * yyyy-MM-dd). The #277 local-day re-bucketing migration clears the computed UTC-keyed rows over @@ -627,10 +782,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 +971,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) @@ -872,8 +1047,8 @@ class WhoopRepository( dao.updateSleepStages(deviceId, detectedStartTs, stagesJSON) // MARK: - Per-epoch sleep analytics (v18: motionJSON / sleepStateJSON). Banked beside stagesJSON on - // the sleepSession row; written/read through targeted methods so the @Upsert recompute/import path - // (which never names these columns) preserves them. Port of iOS WhoopStore.persist/sessionMotion + + // the sleepSession row; written/read through targeted methods, then carried through recompute/import + // refreshes by SleepSessionUpsertPolicy. Port of iOS WhoopStore.persist/sessionMotion + // persist/sessionSleepState. HONESTY: an absent signal is stored as NULL and read back as null, never // a fabricated zero series; an EMPTY input array clears the column. @@ -886,19 +1061,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 } @@ -919,6 +1106,15 @@ class WhoopRepository( dao.deleteMetricSeries(deviceId, key) suspend fun upsertJournal(rows: List) = dao.upsertJournal(rows) suspend fun upsertWorkouts(rows: List) = dao.upsertWorkouts(rows) + + /** + * Persist analytics-derived fields onto workouts the user already logged. This is deliberately + * append/update-only: unlike the retired detected-row reconciliation, an empty or interrupted pass + * performs no DAO call and can never delete grandfathered `sport="detected"` history (#2187). + */ + suspend fun persistWorkoutBackfills(rows: List) { + if (rows.isNotEmpty()) dao.upsertWorkouts(rows) + } suspend fun upsertAppleDaily(rows: List) = dao.upsertAppleDaily(rows) // MARK: - Live Sessions (silent guardian, v22). The runner banks the row at start (endTs null) and @@ -947,7 +1143,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 @@ -958,10 +1154,19 @@ class WhoopRepository( if (deviceIds.isEmpty()) emptyList() else mergeHrByTs(deviceIds.map { dao.hrSamples(it, from, to, limit) }) + /** Count and newest timestamp of measured HR per source [hrSamplesUnion] reads, as one string: an + * index-only witness of whether a window's heart rate changed, without fetching a row. */ + suspend fun hrUnionFingerprint(activeDeviceId: String, from: Long, to: Long): String { + val parts = ArrayList() + for (id in rawWhoopSourceIds(activeDeviceId)) { + parts += "$id=${dao.countHrInWindow(id, from, to)}:${dao.maxHrTsInWindow(id, from, to)}" + } + return parts.joinToString(",") + } + /** - * 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 +1175,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 +1209,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 +1223,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 +1334,86 @@ class WhoopRepository( } } - suspend fun rrIntervals(deviceId: String, from: Long, to: Long, limit: Int = DEFAULT_LIMIT) = + suspend fun isWhoop5RrSource(deviceId: String, unlabelledAliasOfWhoop5: Boolean = false): Boolean { + val owner = dao.pairedDevice(deviceId) + val known = com.noop.protocol.DeviceFamily.confirmedRegistryFamily(owner?.model, owner?.brand) + val nonWhoop = !owner?.brand.isNullOrEmpty() && !owner?.brand.equals("WHOOP", ignoreCase = true) + var tagged = known == null && !nonWhoop && dao.hasWhoop5RrSource(deviceId) + // Re-pairing leaves some callers and historical rows under the canonical alias. Resolve + // its active strap here so sleep edits, manual naps and nightly reads share one policy. + // Confirmed WHOOP 4 and physical owners retain their own identity. + if (known == null && !nonWhoop && !tagged && !unlabelledAliasOfWhoop5 && deviceId == WHOOP_SOURCE) { + val active = dao.activeDeviceId() + if (active != null && active != deviceId) tagged = isWhoop5RrSource(active) + } + return com.noop.protocol.Whoop5RR.usesCanonicalSource(owner?.model, owner?.brand, tagged || unlabelledAliasOfWhoop5) + } + + /** The earliest beat this device has banked that the unit policy can actually score, or null when + * it has none. See [FIRST_SCORABLE_WHOOP5_RR_SQL]; the caller turns it into a local day key, since + * the calendar is the app's policy and not the store's. Twin of Swift + * `firstScorableWhoop5RRTimestamp`. */ + suspend fun firstScorableWhoop5RrTs(deviceId: String): Long? = + dao.firstScorableWhoop5RrTs(deviceId) + + /** The earliest beat this device has banked at all, or null when it has none. See + * [FIRST_RECORDED_RR_SQL]. Twin of Swift `firstRecordedRRTimestamp`. */ + suspend fun firstRecordedRrTs(deviceId: String): Long? = + dao.firstRecordedRrTs(deviceId) + + /** True only when strict WHOOP 5 policy withheld unlabelled legacy beats in this exact scoring window. */ + suspend fun legacyWhoop5RrWithheld( + deviceId: String, + from: Long, + to: Long, + unlabelledAliasOfWhoop5: Boolean = false, + ): Boolean = transactor.run { + isWhoop5RrSource(deviceId, unlabelledAliasOfWhoop5) && + dao.legacyWhoop5RrWithheld(deviceId, from, to) + } + + /** Diagnostic export keeps all WHOOP transports and legacy values without scoring selection. + * Existing quarantine and Oura SpO2-IBI exclusions still apply. */ + suspend fun rawRrIntervalsForDevice(deviceId: String, from: Long, to: Long, + limit: Int = DEFAULT_LIMIT): List = dao.rrIntervals(deviceId, from, to, limit) + suspend fun rrIntervalsForDevice(deviceId: String, from: Long, to: Long, + limit: Int = DEFAULT_LIMIT, + unlabelledAliasOfWhoop5: Boolean = false): List = transactor.run { + if (isWhoop5RrSource(deviceId, unlabelledAliasOfWhoop5)) dao.whoop5RrIntervals(deviceId, from, to, limit) + else 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 = transactor.run { + val activeWhoop5 = isWhoop5RrSource(activeDeviceId) + // Preserve archived physical straps; guard only the ambiguous canonical alias's units. + mergeRrByIdentity(rawWhoopSourceIds(activeDeviceId).map { + rrIntervalsForDevice(it, from, to, limit, + unlabelledAliasOfWhoop5 = activeWhoop5 && it == "my-whoop") + }) + } + 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 +1426,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 @@ -1186,21 +1466,28 @@ class WhoopRepository( suspend fun stepActivityClassLatestUnion(activeDeviceId: String, from: Long, to: Long, limit: Int = DEFAULT_LIMIT): Int? = latestActivityClass(importedSourceIds(activeDeviceId).map { dao.stepSamples(it, from, to, limit) }) - /** Delete a computed source's [sport] workouts in [from, to] (makes re-detection idempotent). (#78) */ - suspend fun deleteComputedWorkouts(deviceId: String, sport: String, from: Long, to: Long) = - dao.deleteWorkoutsBySport(deviceId, sport, from, to) - // MARK: - Workout editing (manual add/edit · relabel · dismiss · delete) (#107) // // Mirrors macOS Repository's workout-editing surface. Manual workouts live under the strap source - // ([strapDeviceId], source "manual") , the same place live-tracked sessions land. Detected bouts - // live under "-noop" with sport "detected" and are wiped + re-derived each engine - // run, so a durable dismissal is recorded in the independent `dismissedWorkout` table. + // ([strapDeviceId], source "manual") , the same place live-tracked sessions land. Grandfathered + // detected bouts live under "-noop" with sport "detected"; the engine no longer + // creates or reconciles them, but their durable dismissal path remains supported. /** Dismissed detected-bout markers for the computed source of [strapDeviceId]. */ suspend fun dismissedDetected(strapDeviceId: String = "my-whoop"): List = dao.dismissedWorkouts(computedDeviceId(strapDeviceId)) + /** + * Dismissed detected-bout markers across the same active + archived + canonical WHOOP union used by + * raw telemetry and workout reads. A rejection recorded before a strap remove/re-add must keep + * suppressing the corresponding suggestion after the active namespace changes. + */ + suspend fun dismissedDetectedUnion(activeDeviceId: String): List = + rawWhoopSourceIds(activeDeviceId) + .map { computedDeviceId(it) } + .flatMap { dao.dismissedWorkouts(it) } + .distinctBy { Triple(it.deviceId, it.startTs, it.endTs) } + /** Deleted-sleep tombstones for BOTH the imported and computed sources of [strapDeviceId] (#33/#65). * * HAZARD FIX (#65 3A): [deleteSleepSession] writes the tombstone under the deleted row's OWN @@ -1226,10 +1513,11 @@ class WhoopRepository( /** * Persist a retroactive / edited manual workout under the strap source. [replacing] is the row the * edit started from: - * - editing a DETECTED bout replaces it with this manual row , the detected original is dismissed - * durably so the re-detector doesn't bring it back (else both would show); - * - editing a MANUAL row whose PRIMARY KEY moved deletes the stale row first (the - * (deviceId, startTs, sport) PK upsert would otherwise orphan it). deviceId is part of that key, + * - editing a grandfathered DETECTED bout writes the manual replacement first, then records its + * legacy dismissal marker and deletes the original. A failed replacement write therefore leaves + * the user's existing history intact; + * - editing a MANUAL row whose PRIMARY KEY moved writes the replacement first, then retires the + * stale row. A failed write therefore preserves the original. deviceId is part of that key, * so a row stored under a re-paired strap's active id counts as moved even when startTs and sport * are untouched: the edit lands on the "my-whoop" seed while the original stays put, and * `workoutsUnion` reads [activeDeviceId, "my-whoop"] keeping the FIRST row per (startTs, sport), @@ -1239,43 +1527,50 @@ class WhoopRepository( */ suspend fun saveManualWorkout(row: WorkoutRow, replacing: WorkoutRow? = null) { if (replacing != null && replacing.source.lowercase().endsWith("-noop")) { + dao.upsertWorkouts(listOf(row)) dismissDetected(replacing) - } else if (replacing != null && supersedesStoredRow(replacing, row)) { + return + } + if (replacing != null && supersedesStoredRow(replacing, row)) { + // A failed insert must not erase history. If the later delete fails, retaining both rows is + // safer and recoverable; the caller can retry the edit. + dao.upsertWorkouts(listOf(row)) dao.deleteWorkoutByKey(replacing.deviceId, replacing.startTs, replacing.sport) + return } dao.upsertWorkouts(listOf(row)) } /** - * Re-label a detected bout: copy it to a manual strap row with the chosen [sport], then delete the - * detected original. Survives analyzeRecent , the engine re-derives only sport="detected" rows AND - * skips any re-derived bout overlapping a real strap workout, which this copy now is , so the same - * session is never re-created as a duplicate. (#107) + * Re-label a grandfathered detected bout: write a manual strap row with the chosen [sport], then retain + * the legacy dismissal marker and delete the detected original. Writing first preserves history on a + * failed upsert; the marker keeps the decision durable if deletion fails or the manual copy is removed. + * (#107/#2187) */ suspend fun relabelDetected(row: WorkoutRow, sport: String, strapDeviceId: String = "my-whoop") { val trimmed = sport.trim() if (trimmed.isEmpty()) return val manual = row.copy(deviceId = strapDeviceId, sport = trimmed, source = "manual") dao.upsertWorkouts(listOf(manual)) - dao.deleteWorkoutsBySport(computedDeviceId(strapDeviceId), "detected", row.startTs, row.startTs) + dismissDetected(row) } /** - * Dismiss a DETECTED bout the user says isn't a workout: record a durable marker (so a re-detect - * that recreates the same PK stays hidden) AND delete the current row so it disappears now. - * No-op when the row isn't a detected bout. (#107) + * Dismiss a grandfathered DETECTED bout the user says isn't a workout: retain its durable marker for + * historical compatibility and suggestion suppression, then delete the current row so it disappears + * now. No-op when the row isn't a detected bout. (#107/#2187) */ suspend fun dismissDetected(row: WorkoutRow) { if (!row.source.lowercase().endsWith("-noop")) return - // Marker carries the bout's [startTs, endTs] span so a re-detected bout whose boundary drifts - // still overlaps it and stays hidden (matches macOS dismissed-span semantics). + // Marker carries the bout's [startTs, endTs] span; the suggestion path preserves its historical + // half-open overlap semantics even though the analytics engine no longer creates generic rows. dao.insertDismissed(listOf(DismissedWorkout(row.deviceId, row.startTs, row.endTs))) dao.deleteWorkoutsBySport(row.deviceId, row.sport, row.startTs, row.startTs) } /** - * Delete ONE workout. A detected bout is dismissed durably (so it doesn't come back on the next - * re-detect); everything else is removed by its exact natural key. (#107) + * Delete ONE workout. A grandfathered detected bout also retains a durable dismissal marker; + * everything else is removed by its exact natural key. (#107/#2187) */ suspend fun deleteWorkout(row: WorkoutRow) { if (row.source.lowercase().endsWith("-noop")) { dismissDetected(row); return } @@ -1327,10 +1622,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 +1675,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 +1889,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 +1907,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 +2037,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 +2046,56 @@ 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) }) + } + + /** + * ALL sleep sessions across every registered WHOOP (active first, archived included, canonical + * last) over the last [days], imported [sleepSessionsUnion] merged with the computed + * [computedSleepSessionsUnion] twin: a computed session is kept only when its LOCAL wake-day (the + * same `AnalyticsEngine.dayString` keyer `mergeSleep` uses) is NOT already covered by an imported + * session that day — no richness exception, unlike `mergeSleepRichness`/[sleepSessionsMerged]. + * Sorted by [SleepSession.effectiveStartTs] ascending, so the caller's `.lastOrNull()` is the most + * recent night. Robust to a stale/wrong [deviceId] (e.g. no strap currently connected) because + * [rawWhoopSourceIds] enumerates every registered WHOOP regardless of which id is passed in. + * Mirrors Swift `Repository.allSleepSessions(days:)` exactly. + */ + suspend fun allSleepSessionsUnion(deviceId: String, days: Int = 4000): List { + val now = System.currentTimeMillis() / 1000L + val lo = now - days * 86_400L + val hi = now + 86_400L + val imported = sleepSessionsUnion(deviceId, lo, hi) + val computed = computedSleepSessionsUnion(deviceId, lo, hi) + fun endDay(s: SleepSession): String { + val offsetSec = (java.util.TimeZone.getDefault().getOffset(s.endTs * 1000) / 1000).toLong() + return com.noop.analytics.AnalyticsEngine.dayString(s.endTs, offsetSec) + } + val importedDays = imported.mapTo(HashSet(), ::endDay) + val computedKept = computed.filter { endDay(it) !in importedDays } + return (imported + computedKept).sortedBy { it.effectiveStartTs } + } - /** 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. */ + /** The COMPUTED ("-noop") twin of [workoutsUnion], including grandfathered detected sessions retained + * 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 +2174,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 +2345,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 +2430,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 +2444,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,12 +2744,19 @@ 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. restingHr = winner.restingHr ?: filler.restingHr, avgHrv = winner.avgHrv ?: filler.avgHrv, avgSdnn = winner.avgSdnn ?: filler.avgSdnn, + // Strap-only, like raw SpO2: an imported winner carries no absolute skin temp, so + // take the filler's rather than blank a value the strap did record (#1636). + skinTempC = winner.skinTempC ?: filler.skinTempC, recovery = winner.recovery ?: filler.recovery, strain = winner.strain ?: filler.strain, exerciseCount = winner.exerciseCount ?: filler.exerciseCount, @@ -2336,6 +2781,40 @@ 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) + } + if (byBeat.values.any { it.srcChannel in 5..7 }) { + // Kotlin's stable sort preserves owner precedence and captured within-second order. + return byBeat.values.sortedBy { it.ts } + } + 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 @@ -2414,6 +2893,7 @@ class WhoopRepository( restingHr = d.restingHr ?: c.restingHr, avgHrv = d.avgHrv ?: c.avgHrv, avgSdnn = d.avgSdnn ?: c.avgSdnn, + skinTempC = d.skinTempC ?: c.skinTempC, recovery = d.recovery ?: c.recovery, strain = d.strain ?: c.strain, exerciseCount = d.exerciseCount ?: c.exerciseCount, @@ -2538,7 +3018,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, @@ -2549,8 +3037,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) } @@ -2559,12 +3047,18 @@ class WhoopRepository( return out } - /** True when the session carries a non-empty stage payload; null, "", and "[]" carry none. - * Twin of WhoopStore.SleepMerge.hasStages. */ - private fun hasStages(s: SleepSession): Boolean { - 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 = SleepSessionUpsertPolicy.richness(s) + + /** 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/ExerciseTypes.kt b/android/app/src/main/java/com/noop/ingest/ExerciseTypes.kt index 13ee2fbe70..5acdbbddf1 100644 --- a/android/app/src/main/java/com/noop/ingest/ExerciseTypes.kt +++ b/android/app/src/main/java/com/noop/ingest/ExerciseTypes.kt @@ -122,6 +122,36 @@ object ExerciseTypes { "Wheelchair" to EX.EXERCISE_TYPE_OTHER_WORKOUT, "Gaming" to EX.EXERCISE_TYPE_OTHER_WORKOUT, "Motor racing" to EX.EXERCISE_TYPE_OTHER_WORKOUT, + // Asked for by a user. Rides WALKING for writeback, as Rucking and Treadmill walk do: Health + // Connect has no pole-walking type, and WALKING is what it actually is. Keeps its own label + // because the strain is not a plain walk. + "Nordic walking" to EX.EXERCISE_TYPE_WALKING, + // WHOOP-parity batch 2: the rest of the sports in WHOOP's activity list that NOOP had no + // entry for. A specific HC type only where the activity genuinely belongs to it; everything + // else rides OTHER_WORKOUT rather than overstating the match. All keep their own label. + // Ballet: dance, so it writes back as dance. + "Ballet" to EX.EXERCISE_TYPE_DANCING, + "Billiards" to EX.EXERCISE_TYPE_OTHER_WORKOUT, + "Breakdancing" to EX.EXERCISE_TYPE_DANCING, + // Cheerleading: tumbling and stunts are the gymnastic half of it. + "Cheerleading" to EX.EXERCISE_TYPE_GYMNASTICS, + "Darts" to EX.EXERCISE_TYPE_OTHER_WORKOUT, + // Disc golf: a disc sport, and HC has the type. + "Disc golf" to EX.EXERCISE_TYPE_FRISBEE_DISC, + // Hurling/Camogie: one entry for both codes, as WHOOP lists them. + "Hurling/Camogie" to EX.EXERCISE_TYPE_OTHER_WORKOUT, + "Jiu jitsu" to EX.EXERCISE_TYPE_MARTIAL_ARTS, + "Judo" to EX.EXERCISE_TYPE_MARTIAL_ARTS, + // Kiteboarding: board on water; the nearest HC has. + "Kiteboarding" to EX.EXERCISE_TYPE_SURFING, + "Motocross" to EX.EXERCISE_TYPE_OTHER_WORKOUT, + "Muay Thai" to EX.EXERCISE_TYPE_MARTIAL_ARTS, + "Paintball" to EX.EXERCISE_TYPE_OTHER_WORKOUT, + // Parkour: no close HC type; calisthenics would overstate it. + "Parkour" to EX.EXERCISE_TYPE_OTHER_WORKOUT, + // Polo: as Horseback riding already does. + "Polo" to EX.EXERCISE_TYPE_OTHER_WORKOUT, + "Skydiving" to EX.EXERCISE_TYPE_OTHER_WORKOUT, ) /** Types where a route makes sense -> GPS defaults on. */ 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/HevyApi.kt b/android/app/src/main/java/com/noop/ingest/HevyApi.kt new file mode 100644 index 0000000000..1392ab881b --- /dev/null +++ b/android/app/src/main/java/com/noop/ingest/HevyApi.kt @@ -0,0 +1,305 @@ +package com.noop.ingest + +import org.json.JSONArray +import org.json.JSONObject +import java.time.ZoneId + +/** + * Parsers for the Hevy Web API endpoints beyond `GET /v1/workouts`, which [LiftingImporter] already + * folds into Strength sessions. + * + * Kotlin mirror of the macOS/iOS source of truth + * Packages/StrandImport/Sources/StrandImport/HevyAPI.swift + * + * Verified against Hevy's published OpenAPI spec (api.hevyapp.com/docs). Every endpoint takes the + * user's own `api-key` header, and the paginated ones take `page` / `pageSize` - note the defaults + * are SMALL (5, or 10 for body measurements), so a caller that reads one page has almost certainly + * not read the account. + * + * Parsing only, deliberately: there is no client, and the transport question (whether an + * offline-first app should hold an API key at all) is unanswered. These are pure so the shape can be + * pinned by tests now and the decision taken later. Tolerant throughout, because this runs over + * another server's data: a malformed element is skipped and counted, never fatal. + */ +object HevyApi { + + /** + * Hevy's published muscle-group vocabulary, attached to an exercise TEMPLATE rather than to a + * logged set. + * + * This matters more than it looks: an exercise carries `exercise_template_id`, so this is + * authoritative attribution for the exercise a set was performed on, from the people who own the + * catalogue. Anything NOOP derives by matching exercise TITLES is guessing at data published + * here. + * + * An unrecognised value parses to null rather than to [OTHER], because Hevy defines `other` as a + * real category and folding a newly-added group into it would silently mislabel it. + */ + enum class MuscleGroup(val wire: String) { + ABDOMINALS("abdominals"), + SHOULDERS("shoulders"), + BICEPS("biceps"), + TRICEPS("triceps"), + FOREARMS("forearms"), + QUADRICEPS("quadriceps"), + HAMSTRINGS("hamstrings"), + CALVES("calves"), + GLUTES("glutes"), + ABDUCTORS("abductors"), + ADDUCTORS("adductors"), + LATS("lats"), + UPPER_BACK("upper_back"), + TRAPS("traps"), + LOWER_BACK("lower_back"), + CHEST("chest"), + CARDIO("cardio"), + NECK("neck"), + FULL_BODY("full_body"), + OTHER("other"), + ; + + companion object { + private val byWire = entries.associateBy { it.wire } + + /** Resolve a wire value, or null when Hevy has added a group this build does not know. */ + fun from(raw: String?): MuscleGroup? = raw?.lowercase()?.let { byWire[it] } + } + } + + /** + * One page of a paginated Hevy response. [pageCount] is the total number of pages, so a caller + * keeps going while [hasMore]. + */ + data class Page( + val items: List, + val page: Int, + val pageCount: Int, + /** + * Elements that were present but unusable. Non-zero means the response was understood and + * something in it was not, which is different from an empty page. + */ + val skipped: Int, + ) { + val hasMore: Boolean get() = page < pageCount + } + + /** + * An entry in Hevy's exercise catalogue. [equipment] and [type] stay raw strings: nothing in + * NOOP consumes them yet, and an enum would only add a drift risk when Hevy adds a value. + */ + data class ExerciseTemplate( + val id: String, + val title: String, + val type: String?, + val primaryMuscleGroup: MuscleGroup?, + val secondaryMuscleGroups: List, + val equipment: String?, + val isCustom: Boolean, + ) + + /** + * A body-composition entry. + * + * The date is kept as Hevy writes it (a bare `yyyy-MM-dd`, no time and no zone) rather than + * resolved to an instant here. Choosing a moment inside that day is a real decision with a real + * failure mode - NOOP already re-buckets nights when an offset changes - and it belongs to + * whatever stores the sample, not to the parser. + * + * Hevy also returns fourteen circumference fields (neck, chest, each bicep, each thigh, and so + * on). They are deliberately not modelled: nothing in NOOP consumes them, and an unused field is + * a schema commitment with no payer. + */ + data class BodyMeasurement( + val date: String, + val weightKg: Double?, + val fatPercent: Double?, + val leanMassKg: Double?, + ) { + /** + * True when the entry carries none of the three figures NOOP could use, which is the case + * for an entry that only recorded circumferences. + */ + val isEmpty: Boolean get() = weightKg == null && fatPercent == null && leanMassKg == null + } + + /** + * Whose account a key belongs to. Worth a call before an import, so a wrong key fails with + * "that key isn't yours" rather than with an empty history. + */ + data class UserInfo(val id: String, val name: String?, val url: String?) + + /** + * A change to the account since a given moment: either a workout in its current form, or the id + * of one that is gone. + * + * This is the endpoint that would make an API import worth having over the CSV export, because + * it is the only one that reports DELETIONS. It is also the one that exposes what NOOP does not + * yet store: a workout row is keyed by (deviceId, startTs), and neither event can be applied + * through that key. A delete names a Hevy id NOOP never kept, and an edit that moved the start + * time would land as a second session rather than replacing the first. Persisting Hevy's id + * alongside the row is the missing piece, and it is a schema change on both platforms. + */ + sealed interface WorkoutEvent { + /** The Hevy workout id an event refers to, which is the key NOOP would have to have stored. */ + val workoutId: String + + data class Updated(override val workoutId: String, val session: LiftingImporter.Session) : WorkoutEvent + data class Deleted(override val workoutId: String, val deletedAtTs: Long?) : WorkoutEvent + } + + // MARK: - GET /v1/workouts/events + + /** + * Parse a workout-events page (`since` defaults to the epoch, so an unqualified call replays the + * whole account). + * + * An `updated` event carries the same workout shape a page does and is folded by the same code + * path, so an edit cannot arrive with different arithmetic from the workout it edits. An update + * whose sets all fold away - every set deleted, or every set turned into a warm-up - yields no + * session and is counted as skipped; it is NOT a deletion, and treating it as one would drop a + * workout the user still has. + */ + fun parseWorkoutEvents(data: ByteArray, zone: ZoneId = ZoneId.systemDefault()): Page = + parsePage(data, "events") { dict -> + when (dict.optString("type").lowercase()) { + "updated" -> { + val w = dict.optJSONObject("workout") + val id = w?.optString("id")?.ifEmpty { null } + val session = w?.let { LiftingImporter.hevyAccumulator(it, zone) }?.toSession() + if (id != null && session != null) WorkoutEvent.Updated(id, session) else null + } + "deleted" -> dict.optString("id").ifEmpty { null }?.let { id -> + WorkoutEvent.Deleted( + id, + dict.optString("deleted_at").ifEmpty { null } + ?.let { LiftingImporter.parseEpochSeconds(it, zone) }, + ) + } + else -> null + } + } + + // MARK: - GET /v1/exercise_templates + + /** + * Parse an exercise-template page. A template with no id or no title is unusable as a join + * target, so it is skipped rather than stored half-formed. + */ + fun parseExerciseTemplates(data: ByteArray): Page = + parsePage(data, "exercise_templates") { dict -> + val id = dict.optString("id").ifEmpty { null } + val title = dict.optString("title").ifEmpty { null } + if (id == null || title == null) return@parsePage null + val secondaryRaw = dict.optJSONArray("secondary_muscle_groups") ?: JSONArray() + val secondary = (0 until secondaryRaw.length()) + .mapNotNull { MuscleGroup.from(secondaryRaw.optString(it).ifEmpty { null }) } + ExerciseTemplate( + id = id, + title = title, + type = dict.optString("type").ifEmpty { null }, + primaryMuscleGroup = MuscleGroup.from(dict.optString("primary_muscle_group").ifEmpty { null }), + secondaryMuscleGroups = secondary, + equipment = dict.optString("equipment").ifEmpty { null }, + isCustom = dict.optBoolean("is_custom", false), + ) + } + + // MARK: - GET /v1/body_measurements + + /** + * Parse a body-measurement page. An entry with no date cannot be filed, and one carrying only + * circumferences has nothing NOOP can store, so both are skipped and counted. + */ + fun parseBodyMeasurements(data: ByteArray): Page = + parsePage(data, "body_measurements") { dict -> + val date = dict.optString("date").trim().ifEmpty { null } ?: return@parsePage null + val entry = BodyMeasurement( + date = date, + weightKg = positive(dict.opt("weight_kg")), + fatPercent = LiftingImporter.jsonDouble(dict.opt("fat_percent")), + leanMassKg = positive(dict.opt("lean_mass_kg")), + ) + if (entry.isEmpty) null else entry + } + + // MARK: - GET /v1/workouts/count and /v1/user/info + + /** + * Parse `{ "workout_count": 42 }`. A cheap way to ask whether anything has changed before paging + * the whole history at a default page size of five. + */ + fun parseWorkoutCount(data: ByteArray): Int? { + val root = obj(data) ?: return null + val n = LiftingImporter.jsonDouble(root.opt("workout_count")) ?: return null + return if (n >= 0 && n < 1e9) n.toInt() else null + } + + /** Parse `{ "data": { "id": …, "name": …, "url": … } }`. */ + fun parseUserInfo(data: ByteArray): UserInfo? { + val root = obj(data) ?: return null + // The payload nests under `data`; tolerate a bare object too, since a caller that has already + // unwrapped one should not get a silent null. + val dict = root.optJSONObject("data") ?: root + val id = dict.optString("id").ifEmpty { null } ?: return null + return UserInfo(id, dict.optString("name").ifEmpty { null }, dict.optString("url").ifEmpty { null }) + } + + // MARK: - Shared + + /** + * The `page` / `page_count` pair off any paginated Hevy body, for a caller that wants to know how + * far it has to go before it has parsed the elements. + */ + fun parsePagination(data: ByteArray): Pair? { + val root = obj(data) ?: return null + return pageNumber(root.opt("page"), 1) to pageNumber(root.opt("page_count"), 1) + } + + private fun text(data: ByteArray): String = + Bom.stripString(String(Bom.stripUtf8(data), Charsets.UTF_8)).trim() + + private fun obj(data: ByteArray): JSONObject? = + runCatching { JSONObject(text(data)) }.getOrNull() + + /** + * Fold a `{ page, page_count, : [...] }` envelope. A bare array is accepted as well, so a + * caller that already unwrapped a page is not forced back into the envelope. + */ + private fun parsePage(data: ByteArray, key: String, element: (JSONObject) -> T?): Page { + val body = text(data) + var page = 1 + var pageCount = 1 + val raw: JSONArray = if (body.startsWith("[")) { + runCatching { JSONArray(body) }.getOrNull() ?: JSONArray() + } else { + val root = runCatching { JSONObject(body) }.getOrNull() + page = pageNumber(root?.opt("page"), 1) + pageCount = pageNumber(root?.opt("page_count"), 1) + root?.optJSONArray(key) ?: JSONArray() + } + + val items = ArrayList(raw.length()) + var skipped = 0 + for (i in 0 until raw.length()) { + val dict = raw.optJSONObject(i) + val parsed = dict?.let { element(it) } + if (parsed == null) skipped++ else items.add(parsed) + } + return Page(items, page, pageCount, skipped) + } + + /** + * A page number, bounded. Hevy sends integers, but this runs over another server's data and the + * value ends up in a loop condition. + */ + private fun pageNumber(any: Any?, fallback: Int): Int { + val d = LiftingImporter.jsonDouble(any) ?: return fallback + return if (d >= 1 && d < 1e7) d.toInt() else fallback + } + + /** + * A measurement that must be positive to mean anything. A zero body weight is a cleared field, + * not a reading, and storing it would drag an average down. + */ + private fun positive(any: Any?): Double? = LiftingImporter.jsonDouble(any)?.takeIf { it > 0 } +} diff --git a/android/app/src/main/java/com/noop/ingest/LabMarkerCsvImport.kt b/android/app/src/main/java/com/noop/ingest/LabMarkerCsvImport.kt index 127d31d4b4..91fb06df92 100644 --- a/android/app/src/main/java/com/noop/ingest/LabMarkerCsvImport.kt +++ b/android/app/src/main/java/com/noop/ingest/LabMarkerCsvImport.kt @@ -445,11 +445,30 @@ object LabMarkerCsvImport { /** * The `custom_` key for an unrecognised marker name. MUST stay byte-identical * to the manual editor's MarkerUnits.slug (ui/MarkerEditorScreen.kt) so a CSV custom - * marker folds onto a hand-added one. Returns "" for a name with no usable characters. + * marker folds onto a hand-added one (the editor delegates here). Returns "" for a name + * with no usable characters. + * + * "%" becomes its own `pct` token: a lab export lists a white-cell differential as both a + * count and a percentage ("LYMPH" 1.9 and "LYMPH %" 31.2), and mapping "%" to "_" like any + * other punctuation gave both `custom_lymph`, so the second row of the day overwrote the + * first. The token is joined to a letter/digit neighbour by one "_" (a non-alphanumeric + * neighbour already maps to "_"), so "LYMPH %", "LYMPH%" and "Lymph (%)" all give + * `custom_lymph_pct`. Names without "%" keep the keys they always had. Byte-identical to + * Swift `LabMarkerCsvImport.customKey`. */ internal fun customKey(name: String): String { val lowered = Normalizer.normalize(name, Normalizer.Form.NFC).trim().lowercase() - val mapped = lowered.map { if (it.isLetterOrDigit()) it else '_' }.joinToString("") + val mapped = buildString { + lowered.forEachIndexed { i, ch -> + if (ch == '%') { + if (i > 0 && lowered[i - 1].isLetterOrDigit()) append('_') + append("pct") + if (i + 1 < lowered.length && lowered[i + 1].isLetterOrDigit()) append('_') + } else { + append(if (ch.isLetterOrDigit()) ch else '_') + } + } + } val collapsed = mapped.replace("__", "_").trim('_') return if (collapsed.isEmpty()) "" else "custom_$collapsed" } diff --git a/android/app/src/main/java/com/noop/ingest/LiftingImporter.kt b/android/app/src/main/java/com/noop/ingest/LiftingImporter.kt index 83272cd38c..95ac529987 100644 --- a/android/app/src/main/java/com/noop/ingest/LiftingImporter.kt +++ b/android/app/src/main/java/com/noop/ingest/LiftingImporter.kt @@ -21,10 +21,12 @@ import java.util.Locale * * Kotlin mirror of the macOS/iOS source of truth * Packages/StrandImport/Sources/StrandImport/LiftingImporter.swift - * so the same two formats, the same volume-load arithmetic and the same honest labelling apply on + * so the same formats, the same volume-load arithmetic and the same honest labelling apply on * every platform: * * • Hevy CSV export — one row per set; grouped into a session by (title + start_time). + * • Hevy Web API response — `GET /v1/workouts`, already grouped into `workouts[] + * .exercises[].sets[]`. Parsing only ([parseHevyAPI]); no client yet. * • Liftosaur JSON export — a `history` array of records with `startTime`/`endTime` (ms) and * nested `entries[].sets[]`. * @@ -33,7 +35,7 @@ import java.util.Locale * `strain`, so imported lifting never feeds the HR-based Effort score. Weights normalise to * kilograms (Hevy `weight_kg` is kg; lb columns and Liftosaur's `lb` unit convert). Tolerant * throughout: a malformed set / record is skipped and counted, never fatal. Parsing is pure - * ([parseHevy] / [parseLiftosaur]) so it is JVM unit-testable (LiftingImporterTest). + * ([parseHevy] / [parseHevyAPI] / [parseLiftosaur]) so it is JVM unit-testable (LiftingImporterTest). */ object LiftingImporter { @@ -220,8 +222,7 @@ object LiftingImporter { // non-finite/out-of-range value (NaN→0, +inf→MAX) and would store garbage rather than // crash; bound reps to a sane finite range and drop-and-skip otherwise so both platforms // reject the same hostile CSV (e.g. reps "1e9999" → +inf). - val reps = row.double("reps", "rep_count") - ?.takeIf { it.isFinite() && it >= 0 && it < 1e6 }?.toInt() + val reps = boundedReps(row.double("reps", "rep_count")) val key = "${title ?: ""}|$startRaw" val acc = byKey.getOrPut(key) { order.add(key); HevyAcc(start, title, zone) } @@ -229,11 +230,79 @@ object LiftingImporter { acc.add(exercise, setType, weightKg, reps) } - return finish(order.mapNotNull { byKey[it]?.toSession() }, skipped) + return finishHevy(order.mapNotNull { byKey[it] }, skipped) + } + + /** + * Parse a Hevy Web API `GET /v1/workouts` response body into one session per workout. + * + * The envelope is verified against Hevy's published OpenAPI spec: `{ page, page_count, + * workouts[] }`, a workout carrying `start_time` / `end_time` / `title` and + * `exercises[].sets[]` with `type`, `weight_kg` and `reps`. A bare array is also accepted so a + * caller that has already unwrapped a page can hand the list straight over. + * + * Folds through the same [HevyAcc] as the CSV lane, so the two cannot disagree about what counts + * as work. `type` is one of `normal`, `warmup`, `dropset` and `failure`; only `warmup` is + * excluded, because a dropset and a set taken to failure are work and dropping them would + * under-report a hard session. + * + * Kotlin mirror of `LiftingImporter.parseHevyAPI` in the Swift source of truth. Pure, so it is + * JVM-testable without a network client — which is deliberate: there is no client yet, and the + * transport question (where an API key lives in an offline-first app) is unanswered. + */ + internal fun parseHevyAPI(data: ByteArray, zone: ZoneId = ZoneId.systemDefault()): Result { + val text = Bom.stripString(String(Bom.stripUtf8(data), Charsets.UTF_8)).trim() + val workouts: JSONArray = ( + if (text.startsWith("[")) runCatching { JSONArray(text) }.getOrNull() + else runCatching { JSONObject(text) }.getOrNull()?.optJSONArray("workouts") + ) ?: return Result(emptyList(), 0, null, null) + + val accs = ArrayList(workouts.length()) + var skipped = 0 + for (i in 0 until workouts.length()) { + val acc = workouts.optJSONObject(i)?.let { hevyAccumulator(it, zone) } + if (acc == null) { skipped++; continue } + accs.add(acc) + } + return finishHevy(accs, skipped) + } + + /** + * Fold ONE workout object from the Hevy API into an accumulator, or null when it carries no + * usable start (no start means no window to attach a session to). + * + * Shared by [parseHevyAPI] and the workout-events lane, which receives the SAME workout shape + * wrapped in an `updated` event. Factored out so an edit arriving over events cannot fold + * differently from the same workout arriving over a page. + */ + internal fun hevyAccumulator(w: JSONObject, zone: ZoneId): HevyAcc? { + val start = w.optString("start_time").ifEmpty { null }?.let { parseEpochSeconds(it, zone) } + ?: return null + val acc = HevyAcc(start, w.optString("title").trim().ifEmpty { null }, zone) + // Resolved through the same parseEpochSeconds the CSV lane uses, so the API and CSV paths + // cannot disagree about an end time. + acc.endRaw = w.optString("end_time").ifEmpty { null } + + val exercises = w.optJSONArray("exercises") ?: JSONArray() + for (e in 0 until exercises.length()) { + val ex = exercises.optJSONObject(e) ?: continue + val exTitle = ex.optString("title") + val sets = ex.optJSONArray("sets") ?: JSONArray() + for (si in 0 until sets.length()) { + val set = sets.optJSONObject(si) ?: continue + acc.add( + exercise = exTitle, + setType = set.optString("type", "normal").lowercase(), + weightKg = jsonDouble(set.opt("weight_kg")), + reps = boundedReps(jsonDouble(set.opt("reps"))), + ) + } + } + return acc } /** Mutable per-session tally while folding Hevy set rows. */ - private class HevyAcc(val start: Long, val title: String?, val zone: ZoneId) { + internal class HevyAcc(val start: Long, val title: String?, val zone: ZoneId) { var endRaw: String? = null var volume = 0.0 var sets = 0 @@ -351,17 +420,22 @@ object LiftingImporter { private fun liftosaurWeightKg(set: JSONObject, entryUnit: String?): Double? { val raw = if (set.has("weight")) set.opt("weight") else set.opt("weightValue") if (raw is JSONObject) { - val v = liftosaurDouble(raw.opt("value")) ?: return null + val v = jsonDouble(raw.opt("value")) ?: return null val unit = raw.optString("unit", "").lowercase().ifEmpty { entryUnit } return if (unit == "lb" || unit == "lbs") v * LB_TO_KG else v } - val v = liftosaurDouble(raw) ?: return null + val v = jsonDouble(raw) ?: return null return if (entryUnit == "lb" || entryUnit == "lbs") v * LB_TO_KG else v } // MARK: - JSON scalar coercion - private fun liftosaurDouble(any: Any?): Double? = when (any) { + /** + * A JSON number arrives as a [Number] or a quoted string depending on the encoder. Accept both + * and reject anything else, so a nulled weight stays absent rather than becoming zero volume. + * Shared by the Liftosaur, Hevy CSV and Hevy API lanes. + */ + internal fun jsonDouble(any: Any?): Double? = when (any) { is Number -> any.toDouble() is String -> any.toDoubleOrNull() else -> null @@ -387,7 +461,7 @@ object LiftingImporter { * tolerated as a fallback. A 13-digit value is ms, a 10-digit value is seconds. */ private fun liftosaurDate(any: Any?): Long? { - val ms = liftosaurDouble(any) + val ms = jsonDouble(any) if (ms != null && ms > 0) { return if (ms > 1_000_000_000_000.0) (ms / 1000).toLong() else ms.toLong() } @@ -430,6 +504,25 @@ object LiftingImporter { return null } + /** + * Bound a rep count before narrowing to Int. Kotlin's `Double.toInt()` SATURATES a non-finite or + * out-of-range value (NaN to 0, +inf to MAX_VALUE) and would store garbage; Swift's `Int(_:)` + * traps outright. Both Hevy lanes route through this so they cannot drift apart, and so a + * `{"reps": 1e9999}` in a response body is skipped on both platforms. + */ + private fun boundedReps(d: Double?): Int? = + if (d != null && d.isFinite() && d >= 0 && d < 1e6) d.toInt() else null + + /** + * Finish a Hevy parse from its accumulators. A workout whose sets all folded away (an all + * warm-up session) yields no [Session] and counts as SKIPPED, matching the Swift lane, which is + * why this takes accumulators rather than the sessions they produce. + */ + private fun finishHevy(accs: List, skipped: Int): Result { + val sessions = accs.mapNotNull { it.toSession() } + return finish(sessions, skipped + (accs.size - sessions.size)) + } + /** Build the result: sort sessions oldest-first and compute the day span. */ private fun finish(sessions: List, skipped: Int): Result { val sorted = sessions.sortedBy { it.startTs } diff --git a/android/app/src/main/java/com/noop/ingest/MuscleGroups.kt b/android/app/src/main/java/com/noop/ingest/MuscleGroups.kt new file mode 100644 index 0000000000..c607e6b1c3 --- /dev/null +++ b/android/app/src/main/java/com/noop/ingest/MuscleGroups.kt @@ -0,0 +1,187 @@ +package com.noop.ingest + +// MARK: - Exercise → muscle attribution +// +// Groundwork for a per-muscle strength view. NOOP already imports lifting sessions (Hevy CSV, +// Liftosaur JSON) but aggregates them to a set count and a volume figure, so nothing downstream can +// say WHICH muscles did the work. This maps an exercise name to the muscles that move it. +// +// Kotlin mirror of the macOS/iOS source of truth +// Packages/StrandImport/Sources/StrandImport/MuscleGroups.swift +// The rule table is the logic here, so the two must stay identical ROW FOR ROW AND IN ORDER: the +// first match wins, and a row moved on one platform silently attributes a different muscle there. +// +// Keyword matching on a normalised name, NOT an exhaustive catalogue. Lifting trackers let people +// type anything, so a closed list would silently mis-attribute everything it did not recognise. The +// rule here is the project's usual one: an exercise this cannot place returns NOTHING, and the +// caller shows no attribution rather than a guess. A wrong muscle is worse than a blank one, because +// a blank invites the user to look while a wrong one does not. +// +// PRIMARY movers only. A barbell squat loads glutes and hamstrings too, and a bench press loads +// triceps and shoulders — but attributing every synergist would make every session light up the +// whole body and stop distinguishing a push day from a leg day. The screen this feeds is about where +// the work went, not about what was innervated. + +/** + * The muscle groups a strength view can attribute work to. + * + * Deliberately coarse. These are the granularity a lifting log can actually support: an exercise + * name can tell you "lats", it cannot honestly tell you which head of which muscle. + */ +enum class MuscleGroup { + CHEST, + UPPER_BACK, + LATS, + SHOULDERS, + BICEPS, + TRICEPS, + FOREARMS, + ABS, + LOWER_BACK, + GLUTES, + QUADRICEPS, + HAMSTRINGS, + CALVES, +} + +object MuscleAttribution { + + /** + * Normalise a logged exercise name for matching. + * + * Trackers append equipment in parentheses ("Squat (Barbell)"), use hyphens and en-dashes + * inconsistently ("Seated Cable Row - V Grip"), and vary in case. The parenthetical is KEPT + * rather than stripped, because it can carry the distinguishing word: a "Row (Machine)" and a + * "Row (Dumbbell)" are the same muscles, but "Curl (Barbell)" and "Leg Curl" are not. + */ + fun normalise(raw: String): String { + val out = StringBuilder(raw.length) + var lastWasSpace = false + for (ch in raw.lowercase()) { + if (ch.isLetterOrDigit()) { + out.append(ch) + lastWasSpace = false + } else if (!lastWasSpace) { + out.append(' ') + lastWasSpace = true + } + } + return out.toString().trim() + } + + /** + * Real exercises this vocabulary has no group for, which must attribute NOTHING rather than fall + * through to a generic rule that would be wrong. + * + * "Neck Curl" contains "curl" and came out as BICEPS. These thirteen groups have no neck, so a + * blank is the only honest answer, and a blank is what the whole table is supposed to prefer: it + * invites a look, where a wrong muscle does not. + * + * Kept separate from [rules] because a rule must name at least one group. An entry here is the + * deliberate absence of one, which is a different statement from "not recognised". + */ + internal val unattributable: List = listOf("neck") + + /** + * The primary movers for a logged exercise name, or an empty list when it cannot be placed. + * + * Order matters: the first rule that matches wins, so the more specific phrase has to be tested + * before the word it contains. "leg curl" is hamstrings and must be decided before "curl" sends + * it to biceps; "front raise" is shoulders and must beat "raise"; "calf raise" likewise. + */ + fun muscles(exercise: String): List { + val n = normalise(exercise) + if (n.isEmpty()) return emptyList() + if (unattributable.any { n.contains(it) }) return emptyList() + for ((needle, groups) in rules) if (n.contains(needle)) return groups + return emptyList() + } + + /** + * Longest-phrase-first, so a specific lift is decided before the generic word inside it. Held as + * an ordered list rather than a map precisely because that order is the logic. + */ + internal val rules: List>> = listOf( + // legs — the specific curls and raises must precede the generic ones + "leg curl" to listOf(MuscleGroup.HAMSTRINGS), + "romanian deadlift" to listOf(MuscleGroup.HAMSTRINGS, MuscleGroup.GLUTES), + "stiff leg deadlift" to listOf(MuscleGroup.HAMSTRINGS, MuscleGroup.GLUTES), + "good morning" to listOf(MuscleGroup.HAMSTRINGS, MuscleGroup.LOWER_BACK), + "leg extension" to listOf(MuscleGroup.QUADRICEPS), + "hack squat" to listOf(MuscleGroup.QUADRICEPS), + "front squat" to listOf(MuscleGroup.QUADRICEPS), + "bulgarian" to listOf(MuscleGroup.QUADRICEPS, MuscleGroup.GLUTES), + "lunge" to listOf(MuscleGroup.QUADRICEPS, MuscleGroup.GLUTES), + "leg press" to listOf(MuscleGroup.QUADRICEPS, MuscleGroup.GLUTES), + "squat" to listOf(MuscleGroup.QUADRICEPS, MuscleGroup.GLUTES), + "hip thrust" to listOf(MuscleGroup.GLUTES), + "glute bridge" to listOf(MuscleGroup.GLUTES), + "calf raise" to listOf(MuscleGroup.CALVES), + "calf press" to listOf(MuscleGroup.CALVES), + // hinge / back + "deadlift" to listOf(MuscleGroup.LOWER_BACK, MuscleGroup.GLUTES, MuscleGroup.HAMSTRINGS), + "back extension" to listOf(MuscleGroup.LOWER_BACK), + "hyperextension" to listOf(MuscleGroup.LOWER_BACK), + "pull up" to listOf(MuscleGroup.LATS, MuscleGroup.BICEPS), + "pullup" to listOf(MuscleGroup.LATS, MuscleGroup.BICEPS), + "chin up" to listOf(MuscleGroup.LATS, MuscleGroup.BICEPS), + "chinup" to listOf(MuscleGroup.LATS, MuscleGroup.BICEPS), + "lat pulldown" to listOf(MuscleGroup.LATS), + "pulldown" to listOf(MuscleGroup.LATS), + "pullover" to listOf(MuscleGroup.LATS), + "face pull" to listOf(MuscleGroup.UPPER_BACK, MuscleGroup.SHOULDERS), + "shrug" to listOf(MuscleGroup.UPPER_BACK), + // Before the generic row: an upright row is a shoulder movement, and every + // upright row contains "row", so the generic rule would swallow it. + "upright row" to listOf(MuscleGroup.SHOULDERS, MuscleGroup.UPPER_BACK), + "row" to listOf(MuscleGroup.UPPER_BACK, MuscleGroup.LATS), + // push + "bench press" to listOf(MuscleGroup.CHEST, MuscleGroup.TRICEPS), + "chest press" to listOf(MuscleGroup.CHEST, MuscleGroup.TRICEPS), + "chest fly" to listOf(MuscleGroup.CHEST), + "pec deck" to listOf(MuscleGroup.CHEST), + // Before the generic fly: a reverse fly and a rear-delt fly are REAR movements, and both + // contain "fly", so the generic rule would file the opposite side of the body. Order is the + // whole contract here, not a stylistic choice. + "rear delt" to listOf(MuscleGroup.SHOULDERS, MuscleGroup.UPPER_BACK), + "reverse fly" to listOf(MuscleGroup.SHOULDERS, MuscleGroup.UPPER_BACK), + "fly" to listOf(MuscleGroup.CHEST), + "push up" to listOf(MuscleGroup.CHEST, MuscleGroup.TRICEPS), + "pushup" to listOf(MuscleGroup.CHEST, MuscleGroup.TRICEPS), + "dip" to listOf(MuscleGroup.CHEST, MuscleGroup.TRICEPS), + "overhead press" to listOf(MuscleGroup.SHOULDERS, MuscleGroup.TRICEPS), + "shoulder press" to listOf(MuscleGroup.SHOULDERS, MuscleGroup.TRICEPS), + "military press" to listOf(MuscleGroup.SHOULDERS, MuscleGroup.TRICEPS), + "arnold press" to listOf(MuscleGroup.SHOULDERS), + "lateral raise" to listOf(MuscleGroup.SHOULDERS), + "front raise" to listOf(MuscleGroup.SHOULDERS), + // arms + // Hevy writes it as one word; the two-word rule missed the spelling the catalogue uses. + "skullcrusher" to listOf(MuscleGroup.TRICEPS), + "skull crusher" to listOf(MuscleGroup.TRICEPS), + "tricep" to listOf(MuscleGroup.TRICEPS), + "pushdown" to listOf(MuscleGroup.TRICEPS), + "kickback" to listOf(MuscleGroup.TRICEPS), + "hammer curl" to listOf(MuscleGroup.BICEPS, MuscleGroup.FOREARMS), + "preacher curl" to listOf(MuscleGroup.BICEPS), + "bicep" to listOf(MuscleGroup.BICEPS), + // Before the generic curl: a nordic curl is a hamstring movement and a jefferson curl is a + // spinal one. Both contain "curl", so the generic rule would call them biceps. + "nordic curl" to listOf(MuscleGroup.HAMSTRINGS), + "jefferson curl" to listOf(MuscleGroup.LOWER_BACK, MuscleGroup.HAMSTRINGS), + // Before the generic curl: a wrist curl is forearms, and "Wrist Curl" is how the exercise is + // normally written, so the rule below was unreachable for the title it exists to catch. + "wrist" to listOf(MuscleGroup.FOREARMS), + "curl" to listOf(MuscleGroup.BICEPS), + "farmer" to listOf(MuscleGroup.FOREARMS), + // trunk + "plank" to listOf(MuscleGroup.ABS), + "crunch" to listOf(MuscleGroup.ABS), + "sit up" to listOf(MuscleGroup.ABS), + "situp" to listOf(MuscleGroup.ABS), + "leg raise" to listOf(MuscleGroup.ABS), + "hanging raise" to listOf(MuscleGroup.ABS), + "ab wheel" to listOf(MuscleGroup.ABS), + "russian twist" to listOf(MuscleGroup.ABS), + ) +} 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..91c17b06ec 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.rawRrIntervalsForDevice(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 cdca0fb4c8..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") @@ -670,6 +676,7 @@ object WhoopCsvImporter { restingHr = base.restingHr ?: fill.restingHr, avgHrv = base.avgHrv ?: fill.avgHrv, avgSdnn = base.avgSdnn ?: fill.avgSdnn, + skinTempC = base.skinTempC ?: fill.skinTempC, recovery = base.recovery ?: fill.recovery, strain = base.strain ?: fill.strain, exerciseCount = base.exerciseCount ?: fill.exerciseCount, diff --git a/android/app/src/main/java/com/noop/notif/ScheduledReportNotifier.kt b/android/app/src/main/java/com/noop/notif/ScheduledReportNotifier.kt index 0e4e22ac6a..a8d92a6b7e 100644 --- a/android/app/src/main/java/com/noop/notif/ScheduledReportNotifier.kt +++ b/android/app/src/main/java/com/noop/notif/ScheduledReportNotifier.kt @@ -33,17 +33,50 @@ import kotlin.math.roundToInt * by ScheduledReportPolicyTest independently of the notification plumbing. */ object ScheduledReportPolicy { + /** + * Earliest local minute-of-day a "Good morning" may be posted. 05:00. + * + * The recap had no time in it at all: it fired the moment the night's row landed carrying a score, and + * that moment is whenever the strap finished syncing, not whenever the wearer woke. A night banked at + * 00:40 posted a "Good morning" at 00:40, to someone asleep (#2289). + * + * A floor rather than a window, deliberately. The recap is about LAST night, so there is no upper bound + * worth enforcing: someone who opens the app at 16:00 having not synced all day should still get the + * recap for the night they slept, rather than silence. + */ + const val EARLIEST_MORNING_MINUTE = 5 * 60 + /** Fire the morning recap at most once per REPORTED NIGHT: only when enabled, a recap value exists, and * we haven't already posted for [reportDay]. [reportDay] is the day of the banked night the recap is * FOR (the resolved today-row's `day`), NOT the phone's calendar day — keying on the calendar day made * it re-fire at midnight for anyone up late, since the row still resolves to last night's until a new - * night is banked (#567). */ + * night is banked (#567). + * + * [nowMinuteOfDay] holds the recap until [earliestMinuteOfDay]. A suppressed recap posts on the next + * evaluation of this gate, and the once-per-night key is untouched while it waits, so deferring cannot + * produce a second copy. + * + * What deferring CAN do is lose one, and the bound is worth knowing rather than discovering. This gate + * runs from `AppViewModel`'s `recentDays` collector, a Room-backed StateFlow with + * `WhileSubscribed(5_000)`: it re-evaluates on a database write or on re-subscription, NOT on a clock. + * A connected strap writes rows through the night, and backgrounding the app drops the subscription so + * reopening replays, which covers the ordinary paths. But a process that stays alive from 00:40 past + * the floor with no write in between never re-evaluates, and that day's recap does not arrive at all, + * where before it would have arrived at 00:40 to someone asleep. + * + * That is the second cost of a floor, beside a late recap still calling itself "Good morning". Both + * are why this is a stopgap and the wake-based version is the real answer (#2289). */ fun shouldNotifyMorning( enabled: Boolean, chargeOrRestPresent: Boolean, lastNotifiedDay: String?, reportDay: String, - ): Boolean = enabled && chargeOrRestPresent && lastNotifiedDay != reportDay + nowMinuteOfDay: Int, + earliestMinuteOfDay: Int = EARLIEST_MORNING_MINUTE, + ): Boolean = enabled && + chargeOrRestPresent && + lastNotifiedDay != reportDay && + nowMinuteOfDay >= earliestMinuteOfDay /** Fire the post-workout summary only for a workout STRICTLY newer than the last one summarised, so a * re-sync of the same backlog never re-notifies. [lastWorkoutTs] is 0 before the first ever. */ @@ -118,6 +151,9 @@ object ScheduledReportNotifier { chargeOrRestPresent = chargePct != null || restPct != null, lastNotifiedDay = NoopPrefs.reportMorningDay(context), reportDay = reportDay, + // The clock the floor compares against. Read here rather than inside the policy, so the policy + // stays pure and the test can pin 00:40 and 07:00 without a fake clock. + nowMinuteOfDay = java.time.LocalTime.now().let { it.hour * 60 + it.minute }, ) ) return val copy = ScheduledReportPolicy.morningCopy(chargePct, restPct) ?: return 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..778743f4fd 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) @@ -53,9 +57,21 @@ object OuraCommands { // MARK: - Notifications / state - /** SetNotification (enable all): `1c 01 3f`. `00`=none, `3f`/`bf`=all. Per OURA_PROTOCOL.md s4.1. */ - fun enableAllNotifications(): OuraCommand = - OuraCommand("notify_all", intArrayOf(0x1C, 0x01, 0x3F)) + /** The SetNotification mask NOOP has always sent: `3f`. `3f`/`bf`=all per OURA_PROTOCOL.md s4.1. */ + const val NOTIFICATION_MASK_DEFAULT = 0x3F + /** The SetNotification mask the official app sends (`ff`, 08-25 HCI capture). Its only known + * difference from `3f` is the two high bits, and whether those are what makes the ring pack ~10 + * packets per notification (OURA_PROTOCOL.md s2.3, the 9x drain) is the open A/B. Test Centre only. */ + const val NOTIFICATION_MASK_FULL = 0xFF + + /** SetNotification (enable all): `1c 01 `, `3f` by default. `00`=none, `3f`/`bf`=all. Per + * OURA_PROTOCOL.md s4.1. A non-default mask carries its value in the label (`notify_all(ff)`) so the + * `-> notify_all` strap-log line shows which session shape an A/B ran under. Twin of Swift's + * `enableAllNotifications(mask:)`. */ + fun enableAllNotifications(mask: Int = NOTIFICATION_MASK_DEFAULT): OuraCommand { + val label = if (mask == NOTIFICATION_MASK_DEFAULT) "notify_all" else "notify_all(%02x)".format(mask) + return OuraCommand(label, intArrayOf(0x1C, 0x01, mask)) + } /** SetNotification (disable): `1c 01 00`. Per OURA_PROTOCOL.md s4.1. */ fun disableNotifications(): OuraCommand = @@ -168,9 +184,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/Decoders.kt b/android/app/src/main/java/com/noop/oura/Decoders.kt index 3e014a22c8..8c11227aab 100644 --- a/android/app/src/main/java/com/noop/oura/Decoders.kt +++ b/android/app/src/main/java/com/noop/oura/Decoders.kt @@ -354,12 +354,15 @@ object OuraDecoders { /** * Decode the 0x7B spo2_stable_event: a SINGLE uint16 BIG-endian at bytes 6-7. This is the lone * exception to the LE default. Per OURA_PROTOCOL.md s6.6. Returns null on a short body. + * + * Tagged [OuraSpO2Channel.STABLE_UNIT], NOT the 0x6F percentage tag: the u16's scale is unpinned, so + * the sample must not pass the percentage-only persist gate in OuraStreamMapping. Swift matches. */ fun decodeSpO2Stable(rec: OuraRecord): OuraSpO2? { val b = rec.payload if (b.size < 2) return null val value = u16be(b, 0) // BIG-endian footgun - return OuraSpO2(ringTimestamp = rec.ringTimestamp, value = value) + return OuraSpO2(ringTimestamp = rec.ringTimestamp, value = value, unit = OuraSpO2Channel.STABLE_UNIT) } // MARK: - SpO2 DC, sign-magnitude deltas (0x77; s6.7) diff --git a/android/app/src/main/java/com/noop/oura/Framing.kt b/android/app/src/main/java/com/noop/oura/Framing.kt index f851509542..101a17539f 100644 --- a/android/app/src/main/java/com/noop/oura/Framing.kt +++ b/android/app/src/main/java/com/noop/oura/Framing.kt @@ -231,12 +231,36 @@ object OuraFraming { val payload = if (end > 6) bytes.copyOfRange(6, end) else IntArray(0) return OuraRecord(type = type, ringTimestamp = rt, payload = payload) } + + /** + * The records of a notification that tiles EXACTLY into consecutive `tag | len | payload` + * packets — every packet with `len >= minRecordLen`, every declared end inside the value, and + * the last one ending on the value's last byte — or null when it does not. The strict + * counterpart of the lenient single-packet [parseRecord]: never clamps, never guesses, and a + * value that fails the tiling at any packet is rejected whole (the caller then falls back to + * the one lenient packet). Byte-identical twin of Swift `OuraFraming.tiledRecords`. + */ + fun tiledRecords(bytes: IntArray): List? { + val out = ArrayList() + var i = 0 + while (i < bytes.size) { + if (i + 2 > bytes.size) return null + val len = bytes[i + 1] + val end = i + 2 + len + if (len < minRecordLen || end > bytes.size) return null + val rec = parseRecord(bytes.copyOfRange(i, end)) ?: return null + out.add(rec) + i = end + } + return if (out.isEmpty()) null else out + } } /** - * Turn each BLE notification into (at most) one TLV inner record, matching open_oura's - * `Packet::parse` (protocol.rs): ONE packet per notification, parsed leniently, with NO - * cross-notification buffering, NO multi-record loop, and NO byte-drop resync. + * Turn each BLE notification into its TLV inner record(s): ONE lenient packet per notification, + * matching open_oura's `Packet::parse` (protocol.rs), with NO cross-notification buffering and NO + * byte-drop resync — plus the one case the ring has been seen to send that the one-packet model + * loses: a notification that tiles EXACTLY into several complete packets (see [feed]). * * WHY (parity with Swift `dae3d7a4`, the phantom-storm fix): the previous model treated the byte * stream as continuous — it buffered partial trailing bytes and looped extracting `2+len` records. @@ -262,6 +286,16 @@ class OuraReassembler { */ fun feed(fragment: IntArray): List { val rec = OuraFraming.parseRecord(fragment) ?: return emptyList() + // A PACKED notification carries several complete packets back to back. Every NOOP drain + // captured to date arrives one packet per <= 20-byte notification, but the same ring serving + // the official app on the same link (same MTU, same get_events bytes) packs ~10 packets into + // each 196-200-byte notification (2026-09-15: 38,136 packets in 3,613 notifications, every + // value tiling exactly) and the one-packet read kept one in ten. Walk the packets ONLY when + // the whole value tiles into two or more well-formed ones; anything else is the single + // lenient packet, byte-identical to before, so a lone packet whose `len` disagrees with the + // notification length still yields exactly that packet. Twin of Swift `OuraReassembler.feed`. + val packed = OuraFraming.tiledRecords(fragment) + if (packed != null && packed.size >= 2) return packed return listOf(rec) } diff --git a/android/app/src/main/java/com/noop/oura/OuraAuthWatchdog.kt b/android/app/src/main/java/com/noop/oura/OuraAuthWatchdog.kt new file mode 100644 index 0000000000..7e3c6cb68b --- /dev/null +++ b/android/app/src/main/java/com/noop/oura/OuraAuthWatchdog.kt @@ -0,0 +1,59 @@ +package com.noop.oura + +/** + * Escalation policy for an auth handshake the ring does not answer (#2304). Kotlin twin of + * `Packages/OuraProtocol/Sources/OuraProtocol/OuraAuthWatchdog.swift` — same timeout, same table. + * + * The connect sequence is link up → discover → enable notifications → `get_nonce`, and from there the + * app waits for the nonce. Nothing else is armed until `auth OK` reaches Streaming (the re-engage tick, + * the history-fetch timer), so a ring that accepts the link and the subscription but never answers the + * nonce left the session parked as "connected" until the OS dropped the link for its own reasons — on + * the #2303 capture that was 52 and 55 minutes across five consecutive links. Healthy sessions never + * wait: every answered `get_nonce` on file gets its nonce within the same second (one 5 s outlier over + * ~270 handshakes), and the only `get_nonce` without a nonce are the ones where the link itself dropped + * 1–3 s later — which takes the disconnect path and cancels the watchdog before it can fire. + * + * Pure so it is testable without a `BluetoothGatt`; the transport owns the timer and the writes, and + * asks this table what to do when the timer fires. Every step is one the transport already makes on + * every connection (a `get_nonce` write, a CCCD toggle, a disconnect) — nothing new is sent to the + * ring, and silence is never turned into a pairing verdict. + */ +object OuraAuthWatchdog { + /** How long a `get_nonce` may go unanswered before the first escalation. Swift: `nonceTimeout` = 10 s. */ + const val NONCE_TIMEOUT_MS = 10_000L + + /** + * What the transport should do when the nonce timer fires. Ordered: the sequence is + * [RESEND_NONCE] → [TOGGLE_NOTIFY] → [DROP_LINK], at most once each per session, then the link is + * dropped and the ordinary reconnect backoff takes over. No loop. + */ + enum class Step { + /** Still inside the timeout (a timer fired early or was re-armed): keep waiting. */ + WAIT, + /** Re-send `get_nonce` once (1/3) — the write may simply not have reached the ring. */ + RESEND_NONCE, + /** Toggle the notify subscription off and back on, then re-send `get_nonce` (2/3). */ + TOGGLE_NOTIFY, + /** Disconnect and let the normal reconnect path bring the ring back (3/3). */ + DROP_LINK, + } + + /** + * The escalation table. Swift twin of `OuraAuthWatchdog.step` (the Swift side takes seconds against + * `nonceTimeout`; same thresholds, same order, same attempt semantics). + * + * The claim is on the FUNCTION deliberately: file authority is derived from resolved FUNCTION pairs, + * so the twin line already on the object header above pairs nothing on its own. + * + * @param msSinceNonceRequest age of the most recent `get_nonce` write of this session. + * @param attempt how many escalations have already been taken this session (0 = none yet). + */ + fun step(msSinceNonceRequest: Long, attempt: Int): Step { + if (msSinceNonceRequest < NONCE_TIMEOUT_MS) return Step.WAIT + return when { + attempt < 1 -> Step.RESEND_NONCE + attempt == 1 -> Step.TOGGLE_NOTIFY + else -> Step.DROP_LINK + } + } +} 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..1d53c3c04b 100644 --- a/android/app/src/main/java/com/noop/oura/OuraDriver.kt +++ b/android/app/src/main/java/com/noop/oura/OuraDriver.kt @@ -86,6 +86,13 @@ class OuraDriver( * Per OURA_PROTOCOL.md s3.2 (the 0x24 SetAuthKey is a DANGEROUS, one-time provisioning write). */ val allowKeyInstall: Boolean = false, + /** + * The SetNotification mask both handshake paths send (Ready and the post-install re-auth). + * [OuraCommands.NOTIFICATION_MASK_DEFAULT] (`3f`) unless the Test Centre A/B asks for the official + * app's `ff` — see [OuraCommands.NOTIFICATION_MASK_FULL]. Reversible: the next session sends the mask + * it is constructed with, nothing persists on the ring. Twin of Swift's `notificationMask`. + */ + val notificationMask: Int = OuraCommands.NOTIFICATION_MASK_DEFAULT, /** * Wall-clock "now" in unix ms, used ONLY to reject a banked sample that converts to the future * (#1073). Injectable so the gate is testable without touching the system clock; defaults to the @@ -99,6 +106,21 @@ class OuraDriver( /** Tracks how many of the live-HR enable triplet ACKs have been seen. */ private var liveHREnableStep = 0 + /** + * Whether auth success should arm the live-HR enable triplet (`dhr_read` / `dhr_enable` / + * `dhr_subscribe`). Default true — the historical behaviour. The app clears it for a connect it + * makes while its live-HR stream is suspended (screen off overnight): before this flag every + * reconnect ran the triplet unconditionally, the app's suspend guard undid it one second later, + * and the ring logged `DHR_mode:3` → `DHR_mode:0` on each visit. On a Ring 5 overnight capture + * (issue #2075's reporter, 2026-09-16) four of the five interruptions of the ring's own SpO2 + * session started on exactly the second of such a connect (3–49 min each, ≈ 2 h of a 9 h night). + * With the flag false the driver goes straight to `Streaming` — authenticated and idle — so the + * history drain, SyncTime and status reads run as before and no daytime-HR write is made at all. + * Read once, at the auth-success step. Twin of the Swift `OuraDriver.liveHRWanted`; Android's + * `OuraLiveSource` has no screen-off suspend yet (#1546), so nothing clears it there today. + */ + var liveHRWanted: Boolean = true + /** * The most recent ring time seen on any record, used to stamp live-HR pushes (which are not TLV * records and carry no timestamp of their own). @@ -146,7 +168,7 @@ class OuraDriver( phase = OuraDriverPhase.Authenticating // Enable notifications, then request the auth nonce. SyncTime can follow auth. listOf( - OuraCommands.enableAllNotifications(), + OuraCommands.enableAllNotifications(mask = notificationMask), OuraCommand("get_nonce", OuraAuth.getAuthNonceCommand()), ) } @@ -175,10 +197,18 @@ class OuraDriver( is OuraTransition.AuthCompleted -> when (after.status) { OuraAuthStatus.SUCCESS -> { - phase = OuraDriverPhase.EnablingLiveHR - liveHREnableStep = 0 - // Begin the live-HR enable triplet (gen-appropriate; gen3 verified, gen4/5 same path). - listOf(OuraCommands.liveHREnableSequence()[0]) + // A connect the app does not want live HR for (suspended night) skips the triplet + // entirely: `Streaming` here means "authenticated, idle", which is all the history + // fetch / SyncTime / status reads need. Nothing is written to the daytime-HR feature. + if (!liveHRWanted) { + phase = OuraDriverPhase.Streaming + emptyList() + } else { + phase = OuraDriverPhase.EnablingLiveHR + liveHREnableStep = 0 + // Begin the live-HR enable triplet (gen-appropriate; gen3 verified, gen4/5 same path). + listOf(OuraCommands.liveHREnableSequence()[0]) + } } OuraAuthStatus.IN_FACTORY_RESET -> { // Ring needs a key install first; this is an explicit, named provisioning step the app @@ -276,11 +306,23 @@ class OuraDriver( if (phase != OuraDriverPhase.InstallingKey || installedKey == null) return emptyList() phase = OuraDriverPhase.Authenticating return listOf( - OuraCommands.enableAllNotifications(), + OuraCommands.enableAllNotifications(mask = notificationMask), OuraCommand("get_nonce", OuraAuth.getAuthNonceCommand()), ) } + /** + * The `get_nonce` request again, for the auth watchdog (#2304) — and ONLY while the driver is still + * waiting for a nonce. Outside Authenticating there is nothing to retry and this returns null, so the + * transport can never re-open the handshake on a session that already moved on (streaming, a pairing + * dead-end, stopped). Same bytes as the Ready step; the phase is left untouched. Kotlin twin of the + * Swift `authNonceRetryCommand`. + */ + fun authNonceRetryCommand(): OuraCommand? { + if (phase != OuraDriverPhase.Authenticating) return null + return OuraCommand("get_nonce", OuraAuth.getAuthNonceCommand()) + } + /** Stop: reset the flow so a fresh session re-runs auth (the app key is session-scoped, s3.1). */ fun stop() { phase = OuraDriverPhase.Stopped @@ -648,23 +690,82 @@ 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. Both + * readings fit the window only when `window >= 9 × lowerBound`, i.e. a ring under ~5 days of + * clock; that case is settled by [SYNC_TIME_ANCHOR_ADJACENCY_TICKS], never by preference. + * Byte-identical twin of Swift's syncTimeAnchorWindowTicks. + */ + const val SYNC_TIME_ANCHOR_WINDOW_TICKS = 38_880_000L // 45 days of 100 ms ticks + + /** + * How close to [lowerBoundTicks] a reading must sit to be IDENTIFIED rather than merely plausible, + * and how far a ticks reading may trail the bound. One hour of 100 ms ticks. Two facts set it: + * (1) the bound can post-date the reply — [OuraHistoryDrain.maxSeenRingTime] is fed by records + * that land AFTER the 0x13 was answered, and the ring keeps ticking, so the reply's own clock + * legitimately sits a few ticks (or a whole drain's worth) BELOW the newest record; (2) the two + * readings differ by `9 × value`, so once the drain's ring-times are within an hour of one of + * them, the other is days away and the unit is settled by the ring's own records, not by a guess. + * Found on a Ring 5 with under five days of clock (2026-09-15, a user bundle, #2239): a reply 22 + * ticks below the drain's newest record was excluded as "before the floor", the ×10 reading was + * the only one left inside the 45-day window, and the whole session was filed 41 days in the + * past. Byte-identical twin of Swift's syncTimeAnchorAdjacencyTicks. + */ + const val SYNC_TIME_ANCHOR_ADJACENCY_TICKS = 36_000L // 1 hour 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). A candidate is plausible iff it falls in `[lowerBoundTicks − + * adjacency, lowerBoundTicks + SYNC_TIME_ANCHOR_WINDOW_TICKS]` — the ring's clock at the reply + * sits after any ring-time known BEFORE the reply, and at most + * [SYNC_TIME_ANCHOR_ADJACENCY_TICKS] before one learned after it. Then: + * + * - only the ticks reading fits → ticks (the ordinary case on a ring with days of clock: ×10 + * lands beyond the window); + * - both fit (a ring under ~5 days of clock) → the reading within + * [SYNC_TIME_ANCHOR_ADJACENCY_TICKS] of the bound, if exactly one is — the drain's own + * ring-times identify the unit; otherwise null, and the caller parks the reply until the drain + * has caught up to the present; + * - only the ×10 reading fits → it, but ONLY when adjacent. No capture on either ring generation + * has ever produced a seconds-unit reply (every anchor on file resolved as ticks), so the label + * alone does not earn adoption; the drain's ring-times must corroborate it. A ticks reply that + * has fallen more than an hour behind the bound is therefore null, never silently ×10. + * + * [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 - val readings = listOf(responseValue, responseValue * 10) - val fits = readings.filter { it in lower..upper && it <= 0xFFFF_FFFFL } - if (fits.size != 1) return null - return fits[0] + fun syncTimeAnchorCandidate(responseValue: Long, lowerBoundTicks: Long): Long? { + if (lowerBoundTicks <= 0) return null + val lower = lowerBoundTicks + val floor = lower - SYNC_TIME_ANCHOR_ADJACENCY_TICKS + val upper = lower + SYNC_TIME_ANCHOR_WINDOW_TICKS + val ticks = responseValue + val secondsX10 = responseValue * 10 + fun plausible(v: Long) = v >= floor && v <= upper && v <= 0xFFFF_FFFFL + fun adjacent(v: Long) = kotlin.math.abs(v - lower) <= SYNC_TIME_ANCHOR_ADJACENCY_TICKS + val ticksFit = plausible(ticks) + val x10Fit = plausible(secondsX10) + return when { + ticksFit && !x10Fit -> ticks + !ticksFit && x10Fit -> if (adjacent(secondsX10)) secondsX10 else null + ticksFit && x10Fit -> when { + adjacent(ticks) && !adjacent(secondsX10) -> ticks + adjacent(secondsX10) && !adjacent(ticks) -> secondsX10 + else -> null + } + else -> null + } } } } diff --git a/android/app/src/main/java/com/noop/oura/OuraEvents.kt b/android/app/src/main/java/com/noop/oura/OuraEvents.kt index bdf94fb0c8..cc6c99c054 100644 --- a/android/app/src/main/java/com/noop/oura/OuraEvents.kt +++ b/android/app/src/main/java/com/noop/oura/OuraEvents.kt @@ -128,6 +128,106 @@ data class OuraSpO2( val count: Int = 1, ) +/** + * Which physical quantity an [OuraSpO2.unit] tag describes. + * + * The ring sends TWO things down the same `.spo2` event and they are three orders of magnitude apart: + * 0x6F/0x7B carry a firmware-computed PERCENTAGE, and 0x77 carries a raw DC perfusion magnitude + * (-1,016 … 11,709,098 in one overnight capture). Only the unit tag survives decode to tell them + * apart, so anything that reports a value to a human has to ask this before it names it. + * + * WHY THIS TYPE EXISTS RATHER THAN A `unit == "raw"` CHECK AT EACH SITE. The strap log's + * "first SpO2 decoded" line printed whichever sample the drain happened to serve first, with no + * channel in the text — so on one reconnect it read `value 93 (raw)` and on the next + * `value 101144 (dc_raw)`, from the same ring, minutes apart. A reporter read the five-digit one as a + * percentage and opened a defect against SpO2 that was never wrong. A log line may only assert what it + * can attribute; this makes the attribution a value rather than a string comparison. + * + * Both known tags are matched EXACTLY; anything else is UNKNOWN, which names its tag and never claims a + * percentage. Treating "not perfusion" as a percentage would print a case variant or a future tag's + * magnitude with a `%` on it, which is the defect this type exists to stop. OuraStreamMapping (both + * platforms) keeps its own `unit == "raw"` allow-list for the same reason from the other side: it is a + * persistence gate, so an unrecognised unit falls on the "do not store" side there and on the "do not + * call it a percentage" side here. Twin of Swift `OuraSpO2Channel`. + */ +enum class OuraSpO2Channel { + /** + * 0x6F — a firmware-computed SpO2 percentage. (The unit tag is the legacy string `"raw"`, which + * names the CHANNEL, not the quantity; see `decodeSpO2PerSample`.) + */ + PERCENTAGE, + + /** 0x77 — a raw DC perfusion magnitude. Not a percentage, and never stored as one. */ + PERFUSION, + + /** + * A channel whose scale is not pinned (0x7B's [STABLE_UNIT]), or a unit tag no decoder stamps (a case + * variant, or a future tag). Named, never a percentage. + */ + UNKNOWN, + ; + + /** + * How this channel is named in the strap log. Spelled out rather than printed as the unit tag: + * `"raw"` names the CHANNEL, not the quantity, and reads as "unprocessed" to everyone who has not + * read `decodeSpO2PerSample`. Twin of Swift `OuraSpO2Channel.logLabel`. + */ + val logLabel: String get() = when (this) { + PERCENTAGE -> "SpO2 percentage" + PERFUSION -> "SpO2 raw DC perfusion (NOT a percentage)" + UNKNOWN -> "SpO2 sample on an unrecognised channel (NOT known to be a percentage)" + } + + companion object { + /** The unit tag 0x6F stamps on its samples (`OuraSpO2`'s default). */ + const val PERCENTAGE_UNIT = "raw" + + /** The unit tag 0x77 stamps on its samples. */ + const val PERFUSION_UNIT = "dc_raw" + + /** + * The unit tag 0x7B stamps on its sample. It deliberately resolves to UNKNOWN: the value is a + * big-endian u16 whose scale no source pins (OURA_PROTOCOL.md s6.6), and the golden vector decodes + * to 970, not a percentage. It used to share the 0x6F `"raw"` tag, which let it through the + * OuraStreamMapping percentage-only persist gate into the same `Spo2Sample.red` column as the + * ring's real percentages. Promote it to PERCENTAGE only once a capture pins its scale. Twin of + * Swift `OuraSpO2Channel.stableUnit`. + */ + const val STABLE_UNIT = "stable_raw" + + /** + * Resolve a sample's channel from its unit tag. Both known tags match exactly (case-sensitive, + * like the tag); anything else is UNKNOWN rather than a guess. Twin of Swift + * `OuraSpO2Channel.forUnit`. + */ + fun forUnit(unit: String): OuraSpO2Channel = when (unit) { + PERCENTAGE_UNIT -> PERCENTAGE + PERFUSION_UNIT -> PERFUSION + else -> UNKNOWN + } + + /** + * The strap-log body for "the first sample of this channel arrived this session", WITHOUT + * either platform's `Oura: ` prefix (each source adds its own, as it does for every other line). + * + * It lives here, not at the two call sites, for the reason the whole type exists: the two + * platforms must not be able to disagree about what they call these numbers. The unit tag is + * still printed, so a log can be matched back to the decoder, and the `%` is appended ONLY on + * the percentage channel — a perfusion magnitude with a `%` on it is the original bug in a new + * costume. Twin of Swift `OuraSpO2Channel.firstDecodedLogLine`; `OuraSpO2ChannelOracleTest` + * asserts this against that function's own stdout. + */ + fun firstDecodedLogLine(value: Int, unit: String): String { + val c = forUnit(unit) + val pct = if (c == PERCENTAGE) " %" else "" + return "first ${c.logLabel} decoded (last night) - $value$pct (channel \"$unit\")" + } + } +} + +/** What this sample's number actually is. See [OuraSpO2Channel]. */ +val OuraSpO2.channel: OuraSpO2Channel get() = OuraSpO2Channel.forUnit(unit) + /** One decoded skin-temperature sample in degrees C (value already / 100). */ data class OuraTemp(val ringTimestamp: Long, val celsius: Double) diff --git a/android/app/src/main/java/com/noop/oura/OuraHistoryDrain.kt b/android/app/src/main/java/com/noop/oura/OuraHistoryDrain.kt index b220064e73..b790114bdb 100644 --- a/android/app/src/main/java/com/noop/oura/OuraHistoryDrain.kt +++ b/android/app/src/main/java/com/noop/oura/OuraHistoryDrain.kt @@ -84,7 +84,7 @@ class OuraHistoryDrain { fun noteStoredRingTime(rt: Long, resumeCursorAtFetchStart: Long) { if (rt > MAX_PLAUSIBLE_RESUME_TICKS) return if (rt > maxStoredRingTime) maxStoredRingTime = rt - if (resumeCursorAtFetchStart > 0 && rt < resumeCursorAtFetchStart) sawPreResumeData = true + if (predatesResume(rt, resumeCursorAtFetchStart)) sawPreResumeData = true } /** @@ -114,16 +114,41 @@ class OuraHistoryDrain { /** * The cursor to persist at drain end, given the current cursor and whether [maxStoredRingTime] * resolves under the CURRENT anchor. A reboot ([sawPreResumeData]) resets to 0 (honest full pull - * next connect); otherwise the cursor advances to [maxStoredRingTime] only if it moved forward AND - * resolves. Unchanged in every other case. + * next connect) UNLESS [anchorConfirmsContinuity] says the ring's own clock never paused (#2097) — + * in that case the stale replay came from something other than a power-cycle (observed: a second + * BLE client serving the ring in between), so it is treated like ordinary stale data: discarded, + * cursor left to the same forward-only-if-resolving rule as any other drain. Otherwise the cursor + * advances to [maxStoredRingTime] only if it moved forward AND resolves; unchanged in every other + * case. */ - fun resumeCursorAtDrainEnd(currentCursor: Long, resolvesUnderAnchor: Boolean): Long { - if (sawPreResumeData) return 0 + fun resumeCursorAtDrainEnd( + currentCursor: Long, + resolvesUnderAnchor: Boolean, + anchorConfirmsContinuity: Boolean = false, + ): Long { + if (sawPreResumeData && !anchorConfirmsContinuity) return 0 if (maxStoredRingTime > currentCursor && resolvesUnderAnchor) return maxStoredRingTime return currentCursor } companion object { + /** + * Whether a record stamped [ringTime] was served from BEFORE where this fetch resumed — the one + * comparison behind [sawPreResumeData], exposed so a caller can apply it to a whole hypnogram + * burst. A cursor of 0 is a full pull with no floor, so nothing predates it. + * + * WHY A BURST NEEDS THIS (2026-09-13, Gen 3, Oura-app handoff): "discarding the stale replay" + * in the #2097 judge keeps the CURSOR, but every replayed record has already been ingested by + * then. Time-series rows dedupe on their primary keys; the hypnogram assembler does not — it + * received the last two sleep-phase records of two nights, re-served from the second client's + * position without their 0x49 windows, and end-anchored each pair at its write time into a + * 52-minute session whose codes were not the night's tail at all. A burst whose write moment + * predates the resume cursor was already banked from its own drain; the persist refuses it + * instead of handing it to the dedup. Twin of Swift `OuraHistoryDrain.predatesResume`. + */ + fun predatesResume(ringTime: Long, resumeCursorAtFetchStart: Long): Boolean = + resumeCursorAtFetchStart > 0 && ringTime < resumeCursorAtFetchStart + /** * A ring-time above this is corrupt (~1.6 years of ticks) and must not set the resume cursor, * or the next session would seek into nonsense. Bounds the cursor at the source. @@ -145,5 +170,61 @@ class OuraHistoryDrain { */ fun sanitizeLoadedCursor(persisted: Long): Long = if (persisted in 0..MAX_PLAUSIBLE_RESUME_TICKS) persisted else 0 + + /** The ring's clock tick rate (OURA_PROTOCOL.md s5.5: 100 ms/tick by default). Used only by + * [anchorsAreContinuous], never by ring-time -> UTC conversion (that stays in [OuraDriver]). */ + const val RING_TICKS_PER_SECOND = 10.0 + + /** + * Ordinary jitter tolerance for [anchorsAreContinuous] (#2097): how far the ring's clock may + * appear to fall behind wall-clock across a gap before it counts as a real power-cycle pause + * rather than anchor-receipt latency noise (BLE round-trip variance on the two receipt + * timestamps). A genuine power-cycle pause is measured in minutes (13m41s observed on one + * dead-battery reboot) — nowhere close to this margin, so it stays generous without risking a + * false "continuous" read. + */ + const val ANCHOR_CONTINUITY_MAX_PAUSE_SECONDS = 20.0 + + /** Minimum wall-clock gap [anchorsAreContinuous] will judge; below this it declines rather + * than guessing. 10 s, not the original 30: the test is an ABSOLUTE margin + * ([ANCHOR_CONTINUITY_MAX_PAUSE_SECONDS], 20 s), not a rate, so a few seconds of receipt-latency + * noise inside a 10 s gap still leaves most of that margin — and a genuine power-cycle pause is + * minutes, which cannot hide inside a short gap either. The 30 s floor declined on a real 27 s + * reconnect (2026-09-12, 268 ticks / 27 s = 9.93/s, ring clock plainly continuous) and the old + * full re-pull fired — a fast relaunch-and-reconnect is the cheapest way to produce a stale + * replay, so it is the case the floor must not exclude (#2097). Swift twin. */ + const val ANCHOR_CONTINUITY_MIN_GAP_SECONDS = 10.0 + + /** + * Whether two `0x13 SyncTime` anchors observed across a connect-to-connect gap are consistent + * with the ring's clock having ticked continuously the whole time — i.e. NOT a genuine + * power-cycle (#2097). Byte-identical twin of Swift's `OuraHistoryDrain.anchorsAreContinuous`. + * + * [sawPreResumeData] alone cannot tell a real ring reboot from a second BLE client (e.g. the + * Oura app) having served the ring in between and left our resume cursor looking stale: both + * produce the identical "a stored sample is older than where we sought" signature. But a real + * power-cycle does not reset the ring's free-running tick counter toward zero — it PAUSES it + * while the ring is off (measured: a dead-battery reboot lost 13m41s of ticks against + * wall-clock) — so comparing elapsed ring-ticks against elapsed wall-clock across the gap + * distinguishes the two: continuous ticking means nothing paused the ring's clock. + * + * Declines to judge (returns `false`, the safe default that keeps the "treat as reboot" + * behavior) when the gap is too short to measure meaningfully, or when [current] somehow + * precedes [previous] in ring-ticks (never observed; not a continuity claim either way). + */ + fun anchorsAreContinuous( + previousRingTicks: Long, + previousUnixSeconds: Long, + currentRingTicks: Long, + currentUnixSeconds: Long, + ): Boolean { + val wallDelta = (currentUnixSeconds - previousUnixSeconds).toDouble() + if (wallDelta < ANCHOR_CONTINUITY_MIN_GAP_SECONDS || currentRingTicks < previousRingTicks) { + return false + } + val ringSeconds = (currentRingTicks - previousRingTicks).toDouble() / RING_TICKS_PER_SECOND + val fellBehindBy = wallDelta - ringSeconds + return fellBehindBy <= ANCHOR_CONTINUITY_MAX_PAUSE_SECONDS + } } } diff --git a/android/app/src/main/java/com/noop/oura/OuraRingEpochScan.kt b/android/app/src/main/java/com/noop/oura/OuraRingEpochScan.kt new file mode 100644 index 0000000000..ab9e15b0b2 --- /dev/null +++ b/android/app/src/main/java/com/noop/oura/OuraRingEpochScan.kt @@ -0,0 +1,135 @@ +package com.noop.oura + +/** + * Group Oura sidecar rows by the ring epoch each one implies, so a session filed under the wrong + * anchor is visible without needing a trusted anchor to compare against. + * + * Kotlin twin of `WhoopStore.OuraRingEpochScan` (#2252), so both platforms read a bundle the same way. + * + * The ticks x10 defect (#2239) filed whole sessions in the past, and the rows it wrote carry no + * ring-time in the store, so nothing there can say which they were. The decoded sidecars do carry both + * axes and are written on every live-feeding connection, so the evidence is already on the device. + * + * Every row implies `epoch = utc - ringTs / 10`: the UTC at which the ring's tick counter read zero. + * Rows anchored correctly against one boot all imply the SAME epoch; a session whose anchor was adopted + * as seconds x10 implies one `0.9 * anchorTicks` seconds earlier, 41.7 days for the ring in #2239. The + * shift is CONSTANT across that session because it comes from the anchor, not from each record. + * + * This DESCRIBES, it does not classify. A ring that genuinely restarted also starts a new epoch, and + * telling a restart from a mis-anchored session is a judgement the caller makes with the registration + * date in hand. + */ +object OuraRingEpochScan { + + /** One run of rows agreeing on where the ring's clock started. */ + data class Cluster( + /** Implied UTC of ring tick 0, the median of the run so one outlying row cannot move it. */ + val epochUnix: Long, + val rows: Int, + val firstStoredUtc: Long, + val lastStoredUtc: Long, + ) + + /** + * Six hours, the default gap that separates one epoch from the next. + * + * Not tight, deliberately. The ring runs at 9.94 to 10.25 ticks per second rather than exactly 10, + * so dividing by 10 accumulates error WITHIN a correctly anchored session: a 2.5% rate error is + * about eight hours of implied-epoch drift across a fortnight of banked history. A tighter tolerance + * would split one honest boot and report the ring's own tick rate as corruption. The defect this + * surfaces moves the epoch by DAYS. + */ + const val DEFAULT_TOLERANCE_SECONDS = 21_600L + + /** + * One report line when the rows disagree about where the ring's clock started, or null when they do + * not. Null is the healthy answer and the common one, so an absent line keeps a healthy report + * byte-unchanged by this existing. + * + * States the gap in days rather than naming a cause: `0.9 * anchorTicks` for the #2239 ring was 41.7 + * days, so a gap near that is the ticks x10 signature, while a few days is more likely a genuine + * restart. Both look identical from here, and the reader has the registration date. + * + * The Swift twin is `OuraRingEpochScan.summaryLine`. + */ + fun summaryLine(clusters: List): String? { + if (clusters.size <= 1) return null + val newest = clusters.first() + val sb = StringBuilder( + "ouraRingEpoch clusters=${clusters.size} newest=${isoDay(newest.epochUnix)} rows=${newest.rows}" + ) + for (older in clusters.drop(1)) { + val gapDays = (newest.epochUnix - older.epochUnix).toDouble() / 86_400 + sb.append(" | epoch=${isoDay(older.epochUnix)} rows=${older.rows}") + sb.append(" gapDays=${String.format(java.util.Locale.ROOT, "%.1f", gapDays)}") + sb.append(" stored=${isoDay(older.firstStoredUtc)}..${isoDay(older.lastStoredUtc)}") + } + return sb.toString() + } + + /** + * UTC calendar day for a unix second, as the report prints dates elsewhere. + * + * The Swift twin is `OuraRingEpochScan.isoDay`. + */ + internal fun isoDay(unix: Long): String { + val f = java.text.SimpleDateFormat("yyyy-MM-dd", java.util.Locale.US) + f.timeZone = java.util.TimeZone.getTimeZone("UTC") + return f.format(java.util.Date(unix * 1000L)) + } + + /** + * Cluster [rows] by implied ring epoch, newest epoch first. + * + * Rows may arrive in any order and may mix sidecars. A row whose `ringTs` is 0 is dropped: an + * unanchored record has no time axis to imply an epoch from, and including it would invent one. + * + * The Swift twin is `OuraRingEpochScan.cluster`. + */ + fun cluster( + rows: List>, // (ringTs, storedUtc) + toleranceSeconds: Long = DEFAULT_TOLERANCE_SECONDS, + ): List { + val points = rows + .filter { it.first > 0 } + .map { (it.second - it.first / 10) to it.second } + .sortedBy { it.first } + if (points.isEmpty()) return emptyList() + + // Runs are collected first and turned into clusters after, rather than closed by a local helper: + // a nested function is a DECLARATION the parity ledger sees on each side and cannot pair, so the + // twin would owe two one-sided entries for a private detail neither platform exposes. + val runs = ArrayList>>() + var run = mutableListOf(points[0]) + for (point in points.drop(1)) { + // Against the PREVIOUS row, not the run's first: a long session drifts steadily, and measuring + // from the start would eventually exceed any tolerance and split one boot in two. + if (point.first - run[run.size - 1].first <= toleranceSeconds) { + run.add(point) + } else { + runs.add(run) + run = mutableListOf(point) + } + } + runs.add(run) + + val clusters = runs.map { r -> + val epochs = r.map { it.first }.sorted() + val utcs = r.map { it.second } + Cluster( + epochUnix = epochs[epochs.size / 2], + rows = r.size, + firstStoredUtc = utcs.min(), + lastStoredUtc = utcs.max(), + ) + } + // Tie-broken to match Swift, whose `sorted(by:)` is NOT stable where this is: two clusters sharing + // a median epoch must come back in the same order on both platforms, or `summaryLine` names a + // different one "newest". + return clusters.sortedWith( + compareByDescending { it.epochUnix } + .thenByDescending { it.rows } + .thenByDescending { it.firstStoredUtc } + ) + } +} 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..fcf2dd9500 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,47 @@ 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" + } + 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 +67,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/DeviceConfigReadProbe.kt b/android/app/src/main/java/com/noop/protocol/DeviceConfigReadProbe.kt index 14042468ee..559b9f2827 100644 --- a/android/app/src/main/java/com/noop/protocol/DeviceConfigReadProbe.kt +++ b/android/app/src/main/java/com/noop/protocol/DeviceConfigReadProbe.kt @@ -422,7 +422,14 @@ class DeviceConfigReadProbeReport( /** Record one decoded reply. */ fun noteReply(r: DeviceConfigReadProbe.ValueResponse, step: Step) { setStatus(if (r.isUnsupported) VerbStatus.UNSUPPORTED else VerbStatus.ANSWERED, step.opcode) - val value = r.valueFor(step.key) + // Twin of the Swift guard (#2193, @Trillient). A FAILURE reply echoes the requested key back + // with zero padding, so taking valueFor() regardless of the result code renders a rejected read + // as a stored 0, indistinguishable from a key that genuinely holds 0. Found on a WHOOP 5 where + // eight guessed keys came back FAILURE and every one of them was reported as a value. + // + // valueFor() itself is untouched and still answers what the bytes say; this is the report + // declining to claim it. + val value = if (r.resultCode == null || r.resultCode == 1) r.valueFor(step.key) else null _readings.add( Reading(step.group, step.opcode, step.key, value, r.resultCode, r.recordHex), ) @@ -522,7 +529,13 @@ class DeviceConfigReadProbeReport( } val named = _readings.count { it.value != null } if (named == 0) { - return "$answered of 2 read verbs answered, but no reply echoed its key so no value is claimed" + // Two sentences, matching Swift: "every reply was rejected" and "replies succeeded but + // none carried a verified pair" are different findings, and the single sentence said + // only the second, which is the wrong one for a strap that refused every read. + if (_readings.all { it.resultCode != null && it.resultCode != 1 }) { + return "$answered of 2 read verbs answered, but no reply reported success; no value is claimed" + } + return "$answered of 2 read verbs answered, but no successful reply carried a verified key/value pair; no value is claimed" } return "$answered of 2 read verbs answered; read $named config value(s)" } diff --git a/android/app/src/main/java/com/noop/protocol/DeviceFamily.kt b/android/app/src/main/java/com/noop/protocol/DeviceFamily.kt index 58861e4dae..f76ea0daec 100644 --- a/android/app/src/main/java/com/noop/protocol/DeviceFamily.kt +++ b/android/app/src/main/java/com/noop/protocol/DeviceFamily.kt @@ -80,6 +80,16 @@ enum class DeviceFamily { } companion object { + /** Positive registry evidence; unlike the skin-compatible fallback, unknown is not WHOOP 5. */ + fun confirmedRegistryFamily(model: String?, brand: String?): DeviceFamily? { + if (!brand.isNullOrEmpty() && !brand.equals("WHOOP", ignoreCase = true)) return null + return when (model?.lowercase(java.util.Locale.ROOT)) { + "4.0", "whoop 4.0" -> WHOOP4 + "5.0", "5.0 mg", "whoop 5.0", "whoop 5.0 / mg", "mg", "whoop5" -> WHOOP5 + else -> null + } + } + /** * Resolve a device-registry `model` label to the strap family that wrote its rows (#171). * 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..9183308ac6 100644 --- a/android/app/src/main/java/com/noop/protocol/Enums.kt +++ b/android/app/src/main/java/com/noop/protocol/Enums.kt @@ -154,6 +154,11 @@ enum class CommandNumber(val rawValue: Int) { REPORT_VERSION_INFO(7), SET_CLOCK(10), GET_CLOCK(11), + // Opcode/name come from the canonical CommandNumber schema in + // Packages/WhoopProtocol/Sources/WhoopProtocol/Resources/whoop_protocol.json and its WHOOP 4 + // matrix in docs/PROTOCOL_COMMANDS.md. Payload 1 enabled standard BLE HR advertising and payload 0 + // disabled it on the WHOOP 4.0 tested for #2400. Reversible, explicit opt-in; no readable confirmation. + TOGGLE_GENERIC_HR_PROFILE(14), // ABORT_HISTORICAL_TRANSMITS (20) — stop an offload part-way through. NON-DESTRUCTIVE and NOT a // trim: the strap frees banked records when we ack a HISTORY_END, so anything unacked when the // abort lands stays in flash and re-offloads next sync. Body [0x00], matching the only hands-on @@ -202,6 +207,31 @@ enum class CommandNumber(val rawValue: Int) { // strap reboots to apply. WHOOP 4.0 only (a 5/MG uses puffin framing + a different config path). // Port of Swift WhoopCommand.setAdvertisingNameHarvard. SET_ADVERTISING_NAME(77), + // #2338 READ-ONLY. GET_ADVERTISING_NAME (141) — the schema's non-Harvard advertising-name pair is + // 140 (set) / 141 (get), which is where a 5/MG's name would live if it lives anywhere reachable. + // NOTHING in this repo has ever sent either, so the numbers rest on the schema alone, exactly like + // the unconfirmed 96 two entries down. Read 141 first: [probeAdvertisingName] is what tells you + // whether this opcode family is even the right one before the write below goes near a strap. + // + // Note [SET_ADVERTISING_NAME] above is 77, the HARVARD set, despite the bare name. That misnomer + // predates this and `CommandNames.label(77)` already prints the schema's own + // SET_ADVERTISING_NAME_HARVARD, which is what a strap log shows. + GET_ADVERTISING_NAME(141), + // #2338 WRITE, NOT hardware-confirmed. SET_ADVERTISING_NAME (140) — the schema's non-Harvard set. + // + // Reversible: renaming again replaces it, and the 4.0 path has behaved that way since #428. That is + // the test the BLE contract actually applies, and it is why this is admissible at all. + // + // What is NOT established: no strap has been sent this opcode, and the payload below mirrors the 4.0 + // Harvard shape rather than anything observed on a 5/MG. A wrong shape should be refused by the + // strap's own CRC and opcode validation, which is the bet being made. Same standing as + // REBOOT_STRAP(29) over puffin, which is also unconfirmed on this family, with one difference worth + // naming: reboot carries no body, this carries a guessed one. + // + // Gated accordingly: Test Centre Connection, user-initiated, never automatic, and the allow-list + // admits it only while a confirmed write is in flight. The COMMAND_RESPONSE is logged so a strap log + // says whether the frame was accepted. + SET_ADVERTISING_NAME_5MG(140), RUN_HAPTICS_PATTERN(79), GET_ALL_HAPTICS_PATTERN(80), // SET_CONFIG / SET_FF_VALUE (0x78) — write one persistent feature flag. The 5/MG "enable R22 @@ -215,6 +245,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 +257,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/FirmwareGateDiagnostic.kt b/android/app/src/main/java/com/noop/protocol/FirmwareGateDiagnostic.kt new file mode 100644 index 0000000000..1279948cbc --- /dev/null +++ b/android/app/src/main/java/com/noop/protocol/FirmwareGateDiagnostic.kt @@ -0,0 +1,39 @@ +package com.noop.protocol + +/** + * Why a WHOOP 5/MG hello carried no firmware version, in enough detail to re-derive the offset. + * + * The version is read as four bytes at `pay[93]`, guarded by `pay[93] == 50` ("5.0 generation"). Both the + * offset and the guard are anchored to a SINGLE capture at firmware 50.38.1.0, and the decoder's own + * comment says so: the name+token region ahead of the version is fixed-width *on that firmware*, and + * re-verifying the offset across firmwares is explicitly left as future work. The guards fail closed, so + * a strap that does not match simply reports nothing. + * + * That silence has two very different causes and no way to tell them apart from a log: + * - the byte at 93 is not 50 — a different generation marker, offset probably still right; + * - the name+token region is a different width, so byte 93 is not the version at all and the offset has + * MOVED. This is the one the decoder comment warns about, and the one a user cannot diagnose. + * + * So the line carries what distinguishes them: the payload length, the byte actually at 93, where the + * printable-ASCII device name ended (the run whose width shifts everything after it), and a hex window + * spanning the region the version should sit in. A capture from an undecoded strap then locates the real + * offset without another round trip. + * + * Deliberately does NOT loosen the guard. Widening it blindly would render unrelated bytes as a version, + * which is worse than "unknown" because it looks authoritative. + * + * Pure, and the window is clamped to the payload, so a short or malformed frame cannot trap. Swift twin: + * `WhoopProtocol.firmwareGateDiagnostic`. + */ +fun firmwareGateDiagnostic(payload: ByteArray, nameEndIndex: Int): String { + val at93 = if (payload.size > 93) (payload[93].toInt() and 0xFF).toString() else "n/a" + val lo = minOf(88, payload.size) + val hi = minOf(101, payload.size) + val window = if (lo < hi) { + payload.copyOfRange(lo, hi).joinToString("") { "%02x".format(it.toInt() and 0xFF) } + } else { + "" + } + return "fw gate: no version decoded — len=${payload.size} at93=$at93 expected=50" + + " nameEnd=$nameEndIndex hex[$lo..<$hi]=$window" +} 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 d5d203cbbd..1ca3e51cf8 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,25 @@ 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 + + /** + * How many start-of-frame bytes were dropped because the header checksum over their own length + * field did not match: a 0xAA that is not a frame start, being payload data or the tail of a frame + * the stream desynced inside. Counted apart from [belowMinimumLengthDrops] because the two say + * different things about a link — a floor drop is a malformed frame, this is a misplaced read + * cursor. Monotonic for the lifetime of the reassembler. Twin of the Swift `headerChecksumDrops`. + */ + var headerChecksumDrops = 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 +145,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 configured family floor is not accepted: emitting it + // would hand the parser a byte run whose "inner fields" are its own checksum trailer. + // Drop this 0xAA, count it, and resync — 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 @@ -96,6 +161,36 @@ class Reassembler(private val family: DeviceFamily = DeviceFamily.WHOOP4) { head += 1 continue } + // An in-range length is not the same as a real one. indexOfSOF matches a bare 0xAA, which + // occurs inside payload as readily as at a frame start, so a stream that has desynced (a + // subscribe landing mid-frame, or one earlier mis-parse) can put `head` on a payload byte + // whose next bytes read as a PLAUSIBLE length: past the family floor, under the 8 KB + // ceiling, and therefore accepted by both guards above. feed() then waited for that many + // bytes and emitted them as one frame, swallowing whatever valid frames fell inside it. + // Those frames were consumed, and nothing downstream can hand them back: the CRC32 check + // that rejects the bad frame runs after `head` has already advanced past them. + // + // The header checksum decides this without new protocol knowledge, because it already + // covers the length field on both families: CRC-8 over the two length bytes on WHOOP 4.0, + // CRC-16-Modbus over the first six bytes on 5/MG. A real frame always passes. A false SOF + // passes with probability 1/256 on 4.0 and 1/65536 on 5/MG, so this turns a swallowed-frame + // stall into a one-byte resync in almost every case. Twin of the Swift Reassembler gate. + // + // Ordered AFTER the floor and ceiling checks so those keep their existing counters and the + // tests that pin them are untouched: this gate only sees lengths the old code accepted. + val headerBytes = if (family == DeviceFamily.WHOOP5) 8 else 4 + if (avail < headerBytes) break + val headerOk = if (family == DeviceFamily.WHOOP5) { + val got = (data[head + 6].toInt() and 0xFF) or ((data[head + 7].toInt() and 0xFF) shl 8) + Crc.crc16Modbus(data, head, head + 6) == got + } else { + Crc.crc8(data, head + 1, head + 3) == (data[head + 3].toInt() and 0xFF) + } + if (!headerOk) { + headerChecksumDrops += 1 + head += 1 + continue + } if (avail < total) break out.add(data.copyOfRange(head, head + total)) head += total @@ -145,12 +240,87 @@ 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. Structural failures keep any + * unavailable payload CRC as a null diagnostic; [PAYLOAD_CRC_MISMATCH] means the CRC32 was actually + * computed and 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 family 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] + [crc32 u32]` = 11 bytes. The zero-payload + * metadata frames at this bound are valid and intentional; the minimum preserves the old `length >= 7` rule. + * WHOOP 5.0/MG: `[SOF][fmt][declLen u16][hdr u16][crc16 u16] + >=1 payload byte + [crc32 u32]` = 13. + * Unlike the 4.0 bound, 13 is an empirical acceptance policy, not an envelope necessity: Goose's + * `v5Payload` accepts a 12-byte, zero-payload frame (`declaredLength == 4`). NOOP deliberately + * requires the inner type byte. Real fixtures include 20-byte command responses plus 24- and + * 32-byte frames, but no captured 12-byte zero-payload frame; those observations do not prove the + * boundary. 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 two checksum outcomes into ONE integrity reason after the caller has established the + * structural bounds. A non-null payload result makes the evaluation order explicit: an uncomputable + * CRC is represented by the earlier structural reason, not a dead checksum case. + */ +private fun integrityRejectReason( + headerCrcOk: Boolean, + payloadCrcOk: Boolean, +): FrameRejectReason { + if (!headerCrcOk) return FrameRejectReason.HEADER_CHECKSUM_MISMATCH + return if (payloadCrcOk) FrameRejectReason.NONE else FrameRejectReason.PAYLOAD_CRC_MISMATCH +} + +/** + * 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 +328,64 @@ 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) { + // Below the smallest real 4.0 inner record (type + sequence + command): no field 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)) { - // inner record = frame[4 until length], checksummed in place. - val want = Crc.crc32(frame, 4, length) - val got = frame.u32(length) ?: 0L - crc32Ok = want == got + if (total < FrameLimits.WHOOP4_MINIMUM_FRAME_BYTES) { + return FrameCheck( + ok = false, + length = length, + headerCrcOk = headerOk, + reason = FrameRejectReason.BELOW_MINIMUM_LENGTH, + ) + } + if (total != frame.size) { + // A surplus tail does not stop the declared payload CRC from being computed. Preserve + // that diagnostic because the hardware gate reads "payload CRC right, envelope wrong". + val crc32Ok = if (total <= frame.size) { + Crc.crc32(frame, 4, length) == frame.envU32(length) + } else { + null + } + return FrameCheck( + ok = false, + length = length, + headerCrcOk = headerOk, + crc32Ok = crc32Ok, + reason = FrameRejectReason.LENGTH_MISMATCH, + ) + } + // The structural checks prove length >= 7 and leave a complete four-byte trailer in bounds. + val gotCrc32 = checkNotNull(frame.envU32(length)) { + "exact WHOOP 4.0 frame must include its CRC32 trailer" } - return FrameCheck(ok = headerOk && (crc32Ok == true), length = length, headerCrcOk = headerOk, crc32Ok = crc32Ok) + val crc32Ok = Crc.crc32(frame, 4, length) == gotCrc32 + val reason = integrityRejectReason(headerCrcOk = headerOk, payloadCrcOk = crc32Ok) + return FrameCheck( + ok = reason == FrameRejectReason.NONE, + length = length, + headerCrcOk = headerOk, + crc32Ok = crc32Ok, + reason = reason, + ) } /* @@ -184,9 +395,15 @@ 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) + } + // NOOP's empirical 5/MG floor: envelope + at least the inner type byte + CRC32. The Goose + // reference parser permits a 12-byte empty payload, but no such hardware frame is known here. + 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. @@ -194,28 +411,89 @@ object Framing { val gotHeader = (frame[6].toInt() and 0xFF) or ((frame[7].toInt() and 0xFF) shl 8) val headerOk = wantHeader == gotHeader - var crc32Ok: Boolean? = null - if (frame.size >= total) { - 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 - crc32Ok = want == got + if (total < FrameLimits.WHOOP5_MINIMUM_FRAME_BYTES) { + val diagnosticCrc32Ok = if (declaredLength >= 4 && total <= frame.size) { + val payloadEnd = total - 4 + Crc.crc32(frame, 8, payloadEnd) == checkNotNull(frame.envU32(payloadEnd)) + } else { + null + } + return FrameCheck( + ok = false, + length = declaredLength, + headerCrcOk = headerOk, + crc32Ok = diagnosticCrc32Ok, + reason = FrameRejectReason.BELOW_MINIMUM_LENGTH, + ) + } + if (total != frame.size) { + // Preserve a CRC result for a surplus tail; truncation leaves it unavailable. + val diagnosticCrc32Ok = if (total <= frame.size) { + val payloadEnd = total - 4 + Crc.crc32(frame, 8, payloadEnd) == checkNotNull(frame.envU32(payloadEnd)) + } else { + null + } + return FrameCheck( + ok = false, + length = declaredLength, + headerCrcOk = headerOk, + crc32Ok = diagnosticCrc32Ok, + reason = FrameRejectReason.LENGTH_MISMATCH, + ) + } + // Exact size plus the configured 13-byte floor proves at least one byte before the trailer. + val payloadEnd = total - 4 + val gotCrc32 = checkNotNull(frame.envU32(payloadEnd)) { + "exact WHOOP 5.0 frame must include its CRC32 trailer" } - return FrameCheck(ok = headerOk && (crc32Ok == true), length = declaredLength, headerCrcOk = headerOk, crc32Ok = crc32Ok) + val crc32Ok = Crc.crc32(frame, 8, payloadEnd) == gotCrc32 + val reason = integrityRejectReason(headerCrcOk = headerOk, payloadCrcOk = 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 +552,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 +603,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 +629,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 +652,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() @@ -376,6 +679,11 @@ object Framing { if (pay.size >= 97 && (pay[93].toInt() and 0xFF) == 50) { parsed["fw_version"] = "${pay[93].toInt() and 0xFF}.${pay[94].toInt() and 0xFF}." + "${pay[95].toInt() and 0xFF}.${pay[96].toInt() and 0xFF}" + } else { + // The guards fail closed by design, which left a strap reporting no firmware with no way to + // say WHY - a different generation byte and a MOVED offset look identical from a log. Carry + // the evidence instead; see [firmwareGateDiagnostic]. + parsed["fw_gate"] = firmwareGateDiagnostic(pay, i) } } } @@ -388,24 +696,37 @@ object Framing { * "RTC timestamp … is invalid; not saving data to flash"), which is how the clock-before-history * requirement was discovered. Capped at 2 KB (matches the Swift PostHooks console hardening). * - * The record header carries record_index u16@9 (monotonic per-chunk counter — the console is - * one continuous stream chunked into fixed-size pieces, and lines split mid-sentence across - * frames, so consumers reassemble in record_index order), unix u32@12 and subsec u16@16 (batch - * write time). Offsets verified across 3 257 real frames from two nights (all one shape: + * The record header carries a wrapping u8 sequence @9, a separate raw header byte @10, unix + * u32@12 and subsec u16@16 (batch write time). Byte 9 is NOT the low half of a u16 counter: + * on firmware 50.41.1.0, 2,978 CRC-valid night records kept @10 = 2 across nine captured + * 255 -> 0 wraps of @9, which a real u16 could not do. Reading the pair as one u16 jumps + * 767 -> 512 at every wrap. The console is one continuous stream chunked into fixed-size + * pieces and lines split mid-sentence, so reassembly retains ARRIVAL order within one capture, + * link, characteristic and channel, checks continuity modulo 256, and never joins across an + * unexplained gap: a wrapping sequence cannot be a sort key, and captured EVENT records can sit + * between console fragments so a gap does not imply a dropped chunk. Reported by @Trillient + * (#2192); the name `console_sequence` also keeps this clear of the unrelated `record_index` + * that `histU32(11)` decodes on v18 historical frames. + * + * Offsets verified across 3 257 real frames from two nights (all one shape: * 76-byte frame, chunk_len u16@18 = 52, channel u8@20 = 1); the Swift twin is * `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.u8(9, limit)?.let { parsed["console_sequence"] = it } + frame.u8(10, limit)?.let { parsed["console_header_byte_10"] = 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) } /** @@ -416,14 +737,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() } } /** @@ -431,33 +752,40 @@ 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() + val rawTicks = ArrayList() for (i in 0 until rrn) { - val v = frame.u16(18 + i * 2) - if (v != null && v > 0) rrs.add(v) // drop 0 ms placeholders, matching 4.0 / Swift + if (18 + i * 2 + 2 > limit) break + val v = frame.u16(18 + i * 2, limit) + if (v != null && v > 0) { + rawTicks.add(v) + rrs.add(Whoop5RR.milliseconds(v)) + } } parsed["rr_intervals"] = rrs + parsed["rr_raw_ticks"] = rawTicks + parsed["rr_source_channel"] = RrSourceChannel.WHOOP5_REALTIME.code } // 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 @@ -467,17 +795,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 } } } @@ -485,12 +813,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 @@ -541,11 +868,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/Hex.kt b/android/app/src/main/java/com/noop/protocol/Hex.kt new file mode 100644 index 0000000000..668b60e361 --- /dev/null +++ b/android/app/src/main/java/com/noop/protocol/Hex.kt @@ -0,0 +1,34 @@ +package com.noop.protocol + +/* + * Hex.kt - the hex encoder the per-frame diagnostic paths use. + * + * `joinToString("") { "%02x".format(it) }` reads well and is fine for a one-off line, but it runs + * String.format ONCE PER BYTE: each call parses the format string and allocates a Formatter, plus a + * String per byte for joinToString to concatenate. The captures write one line per frame for the whole + * offload, and a deep-buffer frame is 2140 bytes, so that is ~2140 Formatter allocations per frame. + * + * A nibble lookup into one preallocated CharArray does the same job with a single allocation. + * Byte-for-byte identical output: lowercase, two digits, no separator. + */ + +private val HEX_DIGITS = "0123456789abcdef".toCharArray() + +/** + * Lowercase, two digits per byte, no separator. Identical output to the `%02x` join it replaces. + * + * NOT named `toHexString`: the stdlib has an `@ExperimentalStdlibApi ByteArray.toHexString()` with the + * same arity since Kotlin 1.9. An explicit import outranks a default one so ours would still win, but + * that is a resolution subtlety to be relying on the day the stdlib one stabilises, and both return + * lowercase unseparated hex, so a silent swap would not fail a single test. + */ +internal fun ByteArray.toHexLower(): String { + val out = CharArray(size * 2) + var i = 0 + for (b in this) { + val v = b.toInt() and 0xFF + out[i++] = HEX_DIGITS[v ushr 4] + out[i++] = HEX_DIGITS[v and 0x0F] + } + return String(out) +} diff --git a/android/app/src/main/java/com/noop/protocol/HistoricalLayoutSupport.kt b/android/app/src/main/java/com/noop/protocol/HistoricalLayoutSupport.kt new file mode 100644 index 0000000000..7c2ee0e5ac --- /dev/null +++ b/android/app/src/main/java/com/noop/protocol/HistoricalLayoutSupport.kt @@ -0,0 +1,57 @@ +package com.noop.protocol + +/** + * How well this platform handles a historical record's layout version, for the strap-log line that tells a + * user why their nights are not staging. Kotlin twin of the Swift `HistoricalLayoutSupport`. + * + * The two negative cases are kept apart because they are different facts with different remedies, and the + * single line that used to cover both stated one of them wrongly. See [historicalLayoutSupport]. + */ +enum class HistoricalLayoutSupport { + /** The layout decodes and the record carries a named signal NOOP scores from. */ + SUPPORTED, + + /** + * The layout decodes, but this record carries no per-second heart rate and no motion, so a night made + * only of these cannot be staged. What is missing is the meaning of the channels, not the layout. + */ + DECODES_WITHOUT_NAMED_SIGNAL, + + /** This platform has no field map for the layout at all; the record does not decode. */ + UNMAPPED, +} + +/** + * Classify a historical record's layout version. Twin of the Swift `historicalLayoutSupport`. + * + * #1992. This used to be one question, "did the record decode any of `heart_rate`, `gravity_x` or + * `ppg_waveform`?", and one message, which said NOOP could not decode the layout. That is a list which has + * to be extended by hand every time NOOP learns a layout, and twice it silently was not: #156 was v25 and + * v26 being reported as undecodable after they had been decoding for releases. + * + * Simply suppressing the line for a mapped-but-unscoreable layout would be the wrong correction, because + * the SECOND half of what it said is true: those records really do carry no heart rate or motion, and a + * night made only of them really cannot be staged. So the question splits. + * + * Note [MAPPED_WHOOP5_HISTORICAL_VERSIONS] is DELIBERATELY narrower here than Swift's: Android has no + * v20/v21 historical branch, so a v20 record genuinely does not decode on this side and genuinely is + * UNMAPPED, where Swift answers DECODES_WITHOUT_NAMED_SIGNAL for the same version. The platforms differing + * there is the divergence itself, not a bug in this function, and both sides pin their own answer. + */ +fun historicalLayoutSupport( + version: Int, + family: DeviceFamily, + hasHeartRate: Boolean, + hasGravity: Boolean, + hasPpgWaveform: Boolean, +): HistoricalLayoutSupport { + val carriesNamedSignal = hasHeartRate || hasGravity || hasPpgWaveform + return when (family) { + DeviceFamily.WHOOP5 -> + if (version !in MAPPED_WHOOP5_HISTORICAL_VERSIONS) HistoricalLayoutSupport.UNMAPPED + else if (carriesNamedSignal) HistoricalLayoutSupport.SUPPORTED + else HistoricalLayoutSupport.DECODES_WITHOUT_NAMED_SIGNAL + DeviceFamily.WHOOP4 -> + if (carriesNamedSignal) HistoricalLayoutSupport.SUPPORTED else HistoricalLayoutSupport.UNMAPPED + } +} 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..e8a02b996b 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 @@ -342,7 +355,83 @@ fun decodeHistorical(frame: ByteArray, family: DeviceFamily = DeviceFamily.WHOOP * so was already archived by the decode-outcome route. Add a version here only when a decoder for it * lands on this side. Pinned by `UnmappedHistoricalLayoutTest`. */ -val MAPPED_WHOOP5_HISTORICAL_VERSIONS: Set = setOf(18, 26) +val MAPPED_WHOOP5_HISTORICAL_VERSIONS: Set = setOf(18, 20, 21, 26) + +/** + * WHOOP 5/MG historical layouts v20 (optical) and v21 (raw 6-axis IMU). Port of the Swift + * `decodeWhoop5HistoricalV2021`, which this platform has had the decoders for all along: + * [Whoop5RawOptical] and [Whoop5RawImu] existed here but were wired only to the live deep-buffer route, + * never to the type-47 historical dispatch, so an offloaded v20 or v21 record decoded to null. + * + * Both versions reuse the v18 record header: layout version @9, a marker byte @10 (0x81 on v20, 0x80 on + * v21), the monotonic record index @11 and the unix time @15. + * + * What this does NOT do is make those nights stageable. Neither layout carries a per-second heart rate + * or a gravity vector; they carry raw sensor channels that no engine reads, and mapping those channels + * to a physiological value is the open work on #1992. What it does give is the same decode both + * platforms have, a real unix and record index for records that previously yielded nothing at all, and + * the raw arrays on the device for analysis. + * + * The reject archive still keeps these records. Its decode-outcome test asks whether a record yielded a + * unix AND either a heart rate or gravity, not merely whether it decoded, so a record that decodes into + * unread channels is still archived. That ordering is deliberate: had the archive still asked "did it + * decode at all", this port would have silently stopped preserving the very bytes the channel mapping + * needs. `whoop5V20StillArchivedAfterItDecodes` pins it. + */ +private fun decodeWhoop5HistoricalV2021(frame: ByteArray, version: Int, limit: Int): Map? { + val out = LinkedHashMap() + out["hist_version"] = version + frame.histU8(10, limit)?.let { out["layout_marker"] = it } + // Long, not Int, for both: these are UNSIGNED 32-bit fields and Kotlin's Int is 32-bit where Swift's + // is 64-bit, so narrowing makes a value with bit 31 set decode differently on the two platforms from + // byte-identical bytes. Same rule the v18 branch above documents at length. + frame.histU32(11, limit)?.let { out["record_index"] = it } + frame.histU32(15, limit)?.let { out["unix"] = it } + + if (version == 21) { + // TWO blocks of three 100-sample i16 channels: accelerometer (@28/@228/@428) then gyroscope + // (@640/@840/@1040). Emitted as RAW i16 arrays with no scaling, exactly as Swift does; the + // physical scales (1/4096 g/LSB accel, 2000/32768 dps/LSB gyro) belong to Whoop5RawImu.decode. + // A channel is emitted only when all 100 samples are readable, so a truncated record yields the + // header fields and no half-arrays. + for ((name, start) in WHOOP5_V21_CHANNELS) { + val samples = ArrayList(100) + for (i in 0 until 100) { + val v = frame.histI16(start + i * 2, limit) ?: break + samples.add(v) + } + if (samples.size == 100) out[name] = samples + } + out["sensor_channel_samples"] = 100 + return out + } + + // version == 20: five repeated optical-measurement blocks, each one shared header plus two channel + // slots. The two channels in a block are a detector/readout pair for ONE measurement configuration, + // not two wavelengths, and no wavelength or absolute unit is asserted here. See Whoop5RawOptical for + // the evidence behind the 25-sample block length and the 20-bit-in-i32 sample container. + val optical = Whoop5RawOptical.decode(frame) ?: return out + out["sensor_block_count"] = optical.blocks.size + var present = 0 + for (block in optical.blocks) { + out["block_b${block.index}_header"] = block.rawHeader + out["block_b${block.index}_sample_count"] = block.sampleCount + if (block.sampleCount <= 0) continue + for ((channelIndex, channel) in block.channels.withIndex()) { + out["channel_b${block.index}_$channelIndex"] = channel.samples + present++ + } + } + out["sensor_channel_samples"] = optical.blocks.maxOfOrNull { it.sampleCount } ?: 0 + out["sensor_channels_present"] = present + return out +} + +/** v21's six raw IMU channels and their absolute offsets. Twin of the Swift `channels` table. */ +private val WHOOP5_V21_CHANNELS: List> = listOf( + "accel_x" to 28, "accel_y" to 228, "accel_z" to 428, + "gyro_x" to 640, "gyro_y" to 840, "gyro_z" to 1040, +) /** * True when [frame] is a WHOOP 5/MG type-47 record whose layout version has NO field map on this @@ -365,14 +454,15 @@ 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 - // 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 - // what decides whether a record gets archived raw, so the two cannot be allowed to drift; - // `UnmappedHistoricalLayoutTest` pins the lockstep against the decoder's actual behaviour. +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: every version this function maps must appear in + // [MAPPED_WHOOP5_HISTORICAL_VERSIONS], and every version in that set must be handled here or (v26) + // by [decodeWhoop5HistoricalV26] from [extractHistoricalStreams]. That set is what decides whether a + // record gets archived raw, so the two cannot be allowed to drift; `UnmappedHistoricalLayoutTest` + // pins the lockstep against the decoder's actual behaviour. + if (version == 20 || version == 21) return decodeWhoop5HistoricalV2021(frame, version, limit) if (version != 18) return null val out = LinkedHashMap() @@ -385,23 +475,30 @@ 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() + val rawTicks = ArrayList() for (i in 0 until minOf(rrn, 4)) { - val v = frame.histU16(24 + i * 2) - if (v != null && v != 0) rrVals.add(v) + if (24 + i * 2 + 2 > limit) break + val v = frame.histU16(24 + i * 2, limit) + if (v != null && v != 0) { + rawTicks.add(v) + rrVals.add(Whoop5RR.milliseconds(v)) + } } out["rr_intervals"] = rrVals + out["rr_raw_ticks"] = rawTicks + out["rr_source_channel"] = RrSourceChannel.WHOOP5_HISTORICAL.code // 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 +507,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 +571,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 +590,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 +604,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 @@ -534,25 +631,49 @@ private fun decodeWhoop5Historical(frame: ByteArray): Map? { * (`burst_index`, NOT a channel id; PR#553) is carried beside the waveform * so durable rows retain their burst boundaries. The footer after [75] remains intentionally unmapped. */ -private data class V26Record(val unix: Long, val samples: List, val burstIndex: Int?) +private data class V26Record( + val unix: Long, + val samples: List, + val burstIndex: Int?, + val baseCode: Long?, +) -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 + // #2019: the ABSOLUTE optical code the 24 values below are deltas FROM. The window is 25 samples, + // not 24: sample 0 is this code and delta i produces sample i+1. Reading only the deltas and calling + // them the waveform stored a derivative as if it were a signal, and threw away the DC level, which + // is the half an SpO2 ratio-of-ratios needs. Verified on the captured frame in + // `Whoop5PpgWaveformStreamTest`: 378,307 here, a valid 20-bit code, against 24 deltas that are every + // one NEGATIVE, which no absolute optical reading can be. Long, not Int: unsigned 32-bit, see + // [histU32]. + val baseCode = frame.histU32(23, limit) 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) + // Sixteen bits, not eight. The field is a per-burst COUNTER and a u8 wraps the moment it passes + // 255; @21 has already been observed at 65 in a real capture, so wrapping is reachable rather than + // hypothetical, and a wrapped counter is indistinguishable from a genuine low one. + // + // The evidence for the width is the LAYOUT, not our fixtures: 21..23 counter, 23..27 the absolute + // base (#2019), 27..75 the 24 deltas, which accounts for every byte between the record header and + // the samples with nothing left over. An independent decode of this record reads the same two bytes + // as one u16 LE. Every v26 frame held here carries byte 22 = 0 with an index of 1, 2 or 65, so our + // own captures CANNOT discriminate a u16 from a u8 beside a constant zero, and this is recorded as + // the weaker half of the case rather than left implied. Reading it wide is the safe direction: for + // every index under 256 the two readings agree exactly, and above it only the wide one is right. + val rawBurstIndex = frame.histU16(21, limit) return V26Record(unix = unix, samples = samples, - burstIndex = rawBurstIndex?.takeIf { it > 0 }) + burstIndex = rawBurstIndex?.takeIf { it > 0 }, baseCode = baseCode) } /** @@ -567,8 +688,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 +706,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. @@ -610,7 +741,19 @@ fun rejectedHistoricalRecords( // an unmapped layout the v24-fallback plausibility gate rejected. This is precisely what // [extractHistoricalStreams] drops (`decodeHistorical(...) ?: continue`), so the rejected set // matches the silently-lost set exactly. - decodeHistorical(frame, family) == null + // DECODE OUTCOME: archive unless the record yielded something SCOREABLE, not merely something. + // + // This used to be `decodeHistorical(...) == null` — did it decode at all. That is the same wrong + // question the unmapped-layout rule above exists to replace, one step further down: a record that + // decodes into channels no engine reads is not data NOOP has kept, it is data NOOP has looked at + // and dropped, and the strap frees it on the very next trim ack. Today the two questions give the + // same answer here, because every layout mapped on this platform either yields a heart rate (v18) + // or is short-circuited above as having its own durable stream (v26). They stop agreeing the + // moment a layout is mapped whose records carry neither, which is exactly what porting the 5/MG + // optical (v20) and raw IMU (v21) decoders would do: without this, that port would silently stop + // archiving the very bytes needed to work out what those channels mean. Twin of the Swift tail. + val decoded = decodeHistorical(frame, family) ?: return@filter true + decoded["unix"] == null || (decoded["heart_rate"] == null && decoded["gravity_x"] == null) } } @@ -649,12 +792,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 { @@ -715,6 +860,10 @@ fun extractHistoricalStreams( var droppedImplausible = 0 // #891: packet types that reach the `else` branch and are dropped. See StreamBatch.unhandledPacketTypes. val unhandledTypes = mutableMapOf() + // #891: the FIRST frame seen for each of those types, as full hex. The census says a type exists; + // without its bytes the strap log asks a reporter to open an issue about a record nobody can then + // map. One frame per type per batch is enough to place the fields and keeps the log bounded. + val unhandledSamples = mutableMapOf() // #324: oldest/newest own-timestamp among the dropped records (the poisoned-range epoch span), and the // dropped RTC-state events (RTC_LOST / BOOT / SET_RTC) — the ground truth that the clock reset. Declared // before correctedWall so the local function can capture them (Kotlin: no forward reference to locals). @@ -808,7 +957,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 +971,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) @@ -831,7 +987,9 @@ fun extractHistoricalStreams( // corrected wall-second. Guard on non-empty so a truncated frame that decoded // zero samples never banks an empty row (mirrors the Swift `!samples.isEmpty`). if (rec.samples.isNotEmpty()) { - ppgWaveform.add(PpgWaveformRow(baseTs, rec.samples, rec.burstIndex)) + ppgWaveform.add( + PpgWaveformRow(baseTs, rec.samples, rec.burstIndex, rec.baseCode), + ) } } } @@ -851,7 +1009,9 @@ fun extractHistoricalStreams( p.intOrNull("heart_rate")?.let { bpm -> if (bpm != 0) hr.add(HrRow(ts, bpm)) } @Suppress("UNCHECKED_CAST") - (p["rr_intervals"] as? List)?.forEach { rrMs -> rr.add(RrRow(ts, rrMs)) } + (p["rr_intervals"] as? List)?.forEach { rrMs -> + rr.add(RrRow(ts, rrMs, RrSourceChannel.fromCode(p.intOrNull("rr_source_channel")))) + } p.intOrNull("spo2_red")?.let { red -> spo2.add(Spo2Row(ts, red = red, ir = p.intOrNull("spo2_ir") ?: 0)) @@ -980,7 +1140,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 +1159,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 +1182,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,8 +1203,12 @@ 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 + // No prefix cap: an unmapped layout's interesting fields are as likely to sit in + // the tail as the head, and a truncated sample is the one shape that looks like + // evidence without being any. + unhandledSamples.getOrPut(name) { frame.joinToString("") { b -> "%02x".format(b) } } } } } @@ -1064,6 +1228,7 @@ fun extractHistoricalStreams( ppgWaveform = ppgWaveform, v18Aux = v18Aux, unhandledPacketTypes = unhandledTypes, // #891 diag census (not persisted) + unhandledPacketSamples = unhandledSamples, // #891 one frame per unmapped type (not persisted) droppedImplausibleTs = droppedImplausible, droppedImplausibleOldestTs = droppedOldest, // #324 poisoned-range epoch span (diag only) droppedImplausibleNewestTs = droppedNewest, @@ -1084,3 +1249,70 @@ private fun appendHistBattery(out: MutableList, ts: Long, p: Map): List? { + if (baseCode == null) return null + val out = ArrayList(deltas.size + 1) + var acc = baseCode + out.add(acc) + for (d in deltas) { + acc += d + out.add(acc) + } + return out +} + +/** A delta at either i16 rail: the encoder clamped it, so the window reconstructs only approximately. */ +fun isSaturatedPpgDelta(delta: Int): Boolean = delta == Short.MIN_VALUE.toInt() || delta == Short.MAX_VALUE.toInt() + +/** + * The per-session v26 optical census, or null when the session carried no v26 windows (a 4.0, or a + * 5/MG that banked none) so a log that has nothing to say stays quiet. #2019. + * + * Three things a strap log could not previously answer, all of which decide whether the banked windows + * are usable for the channel-mapping work this stream exists for: + * + * - how many windows arrived at all; + * - how many carried the absolute base. A window without one cannot be reconstructed, ever. On a + * well-formed record the base is always readable, so `withBase` below the window count means + * TRUNCATED records or a firmware that does not carry it at frame-abs 23, and either is worth + * knowing rather than silently banking un-reconstructable windows; + * - how many windows hold a SATURATED delta. Those reconstruct only approximately, and the caveat is + * worthless without a way to see whether it ever fires. + * + * The base range is carried because it is the DC level over the session, which is the quantity the + * whole stream is banked for and the one that used to be discarded entirely. + * + * Twin of the Swift `ppgWaveformCensusLine`. + */ +fun ppgWaveformCensusLine( + windows: Int, + withBase: Int, + saturatedWindows: Int, + baseMin: Long?, + baseMax: Long?, +): String? { + if (windows <= 0) return null + val range = if (baseMin != null && baseMax != null) " base $baseMin..$baseMax" else " base n/a" + val note = if (saturatedWindows > 0) " (a saturated window reconstructs only approximately)" else "" + return "Backfill: v26 optical census: $windows window(s), $withBase with a base, " + + "$saturatedWindows saturated,$range$note" +} 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..6b56bfef47 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. * @@ -12,10 +33,10 @@ package com.noop.protocol data class HrSample(val ts: Int, val bpm: Int) /** - * WHICH sensor channel produced an R-R interval (#1071). + * The sensor channel or transport that produced an R-R interval. * - * A WHOOP strap has ONE beat source, so its rows carry no channel (null) and nothing here changes for - * them. An Oura ring has more than one: the green-quality tag (0x80) and the SpO2 tag (0x6E) both + * WHOOP 5 exposes one beat train over several labelled transports. WHOOP 4 and legacy rows keep null. + * An Oura ring has more than one optical channel: the green-quality tag (0x80) and the SpO2 tag (0x6E) both * decode to R-R and both were stored, so the table held roughly TWO complete copies of every night — * not duplicate rows to de-duplicate, but the SAME heartbeats measured twice. Labelling the channel is * what lets scoring read one copy while both stay on disk as each other's cross-check. @@ -51,8 +72,16 @@ enum class RrSourceChannel(val code: Int) { * Labelling only — both are read exactly as before. */ IBI_BARE(4), + /** WHOOP 5 v18 history, converted from wire ticks to milliseconds. */ + WHOOP5_HISTORICAL(5), + /** WHOOP 5 type-40 live transport, converted from wire ticks to milliseconds. */ + WHOOP5_REALTIME(6), + /** WHOOP 5 standard BLE 0x2A37, already converted to milliseconds. */ + WHOOP5_STANDARD(7), ; + val isWhoop5Transport: Boolean get() = code in 5..7 + companion object { /** The channel with this durable storage [code], or null for an unknown/absent one. */ fun fromCode(code: Int?): RrSourceChannel? = entries.firstOrNull { it.code == code } @@ -285,12 +314,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" -> { @@ -299,7 +330,8 @@ fun extractStreams(parsed: List, deviceClockRef: Int, wallClockRef: p.intOrNull("heart_rate")?.let { bpm -> out.hr.add(HrSample(ts, bpm)) } // Drop RR rows when timestamp is absent (a ts-less RR row is unstorable). p.intArrayOrNull("rr_intervals")?.let { rrs -> - for (rr in rrs) out.rr.add(RrInterval(ts, rr)) + val source = RrSourceChannel.fromCode(p.intOrNull("rr_source_channel")) + for (rr in rrs) out.rr.add(RrInterval(ts, rr, source)) } } } 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/Whoop5RR.kt b/android/app/src/main/java/com/noop/protocol/Whoop5RR.kt new file mode 100644 index 0000000000..165e12fda3 --- /dev/null +++ b/android/app/src/main/java/com/noop/protocol/Whoop5RR.kt @@ -0,0 +1,65 @@ +package com.noop.protocol + +/** WHOOP 5 type-40/v18 interval words are already milliseconds. Swift Whoop5RR; WHOOP 4 stays unchanged. */ +object Whoop5RR { + fun milliseconds(ticks: Int): Int { + require(ticks in 0..65535) + return ticks + } + + fun usesCanonicalSource(model: String?, brand: String?, hasTaggedIntervals: Boolean): Boolean { + if (!brand.isNullOrEmpty() && !brand.equals("WHOOP", ignoreCase = true)) return false + return when (DeviceFamily.confirmedRegistryFamily(model, brand)) { + DeviceFamily.WHOOP4 -> false + DeviceFamily.WHOOP5 -> true + null -> hasTaggedIntervals + } + } + + /** + * Whether a stored night is one this unit policy has to leave unscored, rather than one that simply + * has no data. + * + * The policy reads a window through a SINGLE labelled transport. Rows recorded before the label + * existed carry no transport, so they mix millisecond and tick units with nothing on disk to tell + * them apart, and a window holding only those rows yields no beats at all. HRV is null, and Charge, + * which needs a nightly HRV, is null behind it. That is indistinguishable on screen from a night the + * strap was not worn, which is why this exists: it is the one case where the cause is known. + * + * Deliberately CONSERVATIVE, per the rule that a diagnostic may only assert what it can attribute. + * The claim is bounded on BOTH sides, to the window where this strap was recording and nothing was + * yet labelled: + * - [strictWhoop5] gates it, so a WHOOP 4 or another brand can never see this explanation. + * - [firstRecordedDay] is required and bounds it below. Without that bound, a wearer who imported + * years of history before ever owning the strap would be told those nights lost their beats to a + * labelling change that had not been invented yet, which is a fabricated cause. A device that has + * banked no beats at all never qualifies for the same reason. + * - [firstScorableDay] bounds it above: a night on or after it is NOT claimed, even though a + * particular window there can still come up empty. Past that day the app is labelling, so the + * honest general statement stops. Null means nothing labelled has been banked yet, which for a + * confirmed WHOOP 5 is exactly the wearer who has not synced since the units were corrected. + * - [totalSleepMin] must be positive. A night that staged is a night the wearer was wearing it; + * without that, an empty HRV is far more likely a strap left on the charger. + * + * One gap is left un-narrowed and is stated rather than hidden: inside those bounds, a night staged + * purely from an imported source while the strap itself banked nothing would also be claimed. Ruling + * that out needs a per-night beat count, and the statement stays true of the wearer's situation + * either way. Day keys are `yyyy-MM-dd`, so comparing them as strings orders them by date. + * + * Byte-identical twin of Swift `Whoop5RR.legacyUnscorableNight`. + */ + fun legacyUnscorableNight( + strictWhoop5: Boolean, + day: String, + firstRecordedDay: String?, + firstScorableDay: String?, + avgHrv: Double?, + totalSleepMin: Double?, + ): Boolean { + if (!strictWhoop5 || avgHrv != null || (totalSleepMin ?: 0.0) <= 0.0) return false + if (firstRecordedDay.isNullOrEmpty() || day < firstRecordedDay) return false + if (!firstScorableDay.isNullOrEmpty() && day >= firstScorableDay) return false + return true + } + +} 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/protocol/Whoop5Variant.kt b/android/app/src/main/java/com/noop/protocol/Whoop5Variant.kt index a4f188ffee..e0e59f80a5 100644 --- a/android/app/src/main/java/com/noop/protocol/Whoop5Variant.kt +++ b/android/app/src/main/java/com/noop/protocol/Whoop5Variant.kt @@ -38,6 +38,11 @@ enum class Whoop5Variant(val label: String) { */ const val FIVE_ZERO_HARDWARE_ID_TOKEN = "WG50" + /** DIS 0x2A24 Model Number String, as a real WHOOP MG reports it. Read unbonded off the standard + * profile, so it is available on a strap that never pairs — which is exactly the case the prefix + * heuristics were failing on. */ + const val MODEL_NUMBER_MG = "MG" + /** * Resolve the variant from the strap's Device Information Service strings. * @@ -52,7 +57,19 @@ enum class Whoop5Variant(val label: String) { * 3. Serial prefix -> [MG] / [FIVE_ZERO]. * 4. Anything else -> [UNKNOWN]. Never infer from a stray digit in the name (#772). */ - fun from(serial: String?, hardwareRevision: String? = null): Whoop5Variant { + fun from( + serial: String?, + hardwareRevision: String? = null, + modelNumber: String? = null, + ): Whoop5Variant { + // The strap SAYING what it is beats inferring it from a prefix. DIS 0x2A24 reports "MG" on a + // real MG, and a field capture showed serial prefix "MGB" with hardware revision "WS50_r03" — + // matching neither [MG_SERIAL_PREFIX] nor [FIVE_ZERO_HARDWARE_ID_TOKEN], so every heuristic + // below returned UNKNOWN for a strap whose own model number said MG outright (#520). + // + // Only MG is claimed here. No 5.0 has been observed reporting its model number, so inventing a + // token for one would be the guess this function already refuses to make elsewhere. + if ((modelNumber ?: "").trim().uppercase() == MODEL_NUMBER_MG) return MG val hw = (hardwareRevision ?: "").uppercase() var s = (serial ?: "").uppercase().trim() if (s.startsWith("WHOOP ")) s = s.removePrefix("WHOOP ") 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 122bb4855c..948ca2d222 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,88 @@ 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)" + } + + /** + * The note that says the funnel did NOT analyse the latest night, and which one it skipped. + * + * The funnel deliberately walks back to the most recent night carrying skin temperature, because a + * night without it reports "skin=0" and teaches nothing. That fallback is right; printing its result + * under a heading that says "latest night" is not. On #2012 it reported a night four days older than + * the export with no indication, and reading it as the latest night is what a careful reader does. + * + * Empty when the funnel really did take the newest session, so the common case stays unchanged. + */ + internal fun funnelFallbackNote(chosenDay: String, newestDay: String): String = + if (chosenDay == newestDay) "" else " (NOT the latest night: $newestDay carried no skin temperature)" + + /** + * The strap id the report is describing: the registry's ACTIVE device, falling back to the canonical + * spine id when the registry has nothing usable. + * + * A BLANK or absent id must fall back rather than be queried. It matches no rows, so every read + * scoped to it comes back empty and the report states absences that are really just a bad lookup. + * + * Pure so the fallback rule is pinned by a test; the Context-touching resolution stays at the call + * site, which is the split the rest of this file uses. + */ + internal fun activeStrapId(registryId: String?): String = + registryId?.takeIf { it.isNotBlank() } ?: com.noop.data.WhoopRepository.WHOOP_SOURCE + + /** + * 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, + deviceId: String, + ): String { + fun mark(b: Boolean) = if (b) "yes" else "NO" + // The DEVICE rides the value beside the window, for the same reason the window does. This asks + // ONE id, the active one, while every scorer reads the union of the active, canonical and + // computed ids. Those disagree on a re-added strap, an archived spine, or a Health Connect + // import, and the line then reads as "this install has no heart rate" when it means "the active + // strap id delivered none". That misreading cost real triage time on #2012, where the header + // said HR NO while the same export scored a day off 28,141 samples read through the union. + return "Provides: HR ${mark(hr)} · R-R ${mark(rr)} · motion ${mark(motion)} · steps ${mark(steps)}" + + " ($deviceId, 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, @@ -71,23 +153,151 @@ object AndroidDiagnostics { else -> reportedModel(context)?.displayName ?: "unknown (paired, family not yet detected)" }, ) - add("Firmware: ${com.noop.ui.NoopPrefs.lastFirmware(context) ?: "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"}") + // #1633 follow-up: the ACTIVE device's own firmware. This read used to be the bare global key, + // so a two-strap log named whichever strap connected last - a 5/MG log reporting a 4.0's + // 41.17.6.0. resolveFirmware falls back to the legacy key only when one device is paired. + val fwRows = runCatching { + (context.applicationContext as? com.noop.NoopApplication)?.deviceRegistry?.all().orEmpty() + }.getOrDefault(emptyList()) + val fwActive = runCatching { + (context.applicationContext as? com.noop.NoopApplication)?.deviceRegistry?.activeDeviceId() + }.getOrNull() + val fwRow = fwRows.firstOrNull { it.id == fwActive } + val fw = com.noop.ble.resolveFirmware( + live = null, + perDevice = com.noop.ui.NoopPrefs.firmwareFor(context, fwRow?.peripheralId), + legacyGlobal = com.noop.ui.NoopPrefs.lastFirmware(context), + pairedCount = fwRows.size, + ) + add("Firmware: ${fw ?: "unknown (connect to record)"}") + // 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. + // 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. + // + // Resolved once for the three readers in this block ("Provides", "Last sleep", "Last recov."): + // they all describe the same strap and must not be able to name different ones. + // + // Other blocks resolve it for themselves and are deliberately left alone here. `funnelLines` + // uses this same expression; the workout, daily-data and body-clock blocks use a DIFFERENT + // one (the `activeDeviceId` property through an unchecked cast, falling back to "unknown" + // rather than to the spine). Consolidating those is a separate change: the second form has a + // fallback this helper would alter, so folding it in silently would be a behaviour change + // wearing a refactor's clothes. + val activeId = activeStrapId( + runCatching { + (context.applicationContext as? com.noop.NoopApplication)?.deviceRegistry?.activeDeviceId() + }.getOrNull(), + ) + runCatching { + 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, activeId)) + } + // #2117, emitted HERE as well as on the BLE path, so it reaches the export unconditionally. + // The judgement lives in `ConnectionTrace.rrTransportLine`, shared byte for byte with Apple; + // this is the hand-off, and the twin of `TestBundleAssembler.universalRRTransportLine`. + // + // The BLE emission is deliberately left in place: it is correct for a strap that completes an + // offload, and this adds the case it cannot cover rather than relocating it. On such a strap + // both now appear, on different surfaces, from the same formatter over the same two MINs. They + // can differ only in freshness, since an offload landing between connect and export can move + // `firstRecorded` earlier, and the export-time read is then the more current of the two. + // + // That BLE emission rides the GET_DATA_RANGE reply, which is part of the offload + // handshake. An unbonded 5/MG never completes that handshake (#1635) and so never reached it: + // in two full multi-session exports from such a device the line is absent, and so is the + // clock-drift line emitted beside it. That is precisely the wearer it exists for, the one + // whose HRV blanked because the beats on disk predate transport labelling. Apple has never had + // this gap, because it refreshes the same facts whenever a link comes UP and the assembler + // emits from the bundle; reading them here, at export, is the same guarantee reached the + // Android way, and needs no session state that could go stale between connect and export. + // + // Silent when it cannot read its inputs, matching both platforms: a diagnostic that cannot + // measure says nothing rather than guessing. + runCatching { + val repoForRr = com.noop.data.WhoopRepository.from(context) + // The two MINs only feed a line the formatter suppresses unless this is strict, so a + // device the policy does not govern stops after the one registry read. Same early-out + // the BLE caller and the Apple twin both take. + if (repoForRr.isWhoop5RrSource(activeId)) { + com.noop.analytics.ConnectionTrace.rrTransportLine( + strictWhoop5 = true, + firstRecordedUnix = repoForRr.firstRecordedRrTs(activeId), + firstScorableUnix = repoForRr.firstScorableWhoop5RrTs(activeId), + ) + } else { + null + } + }.getOrNull()?.let { add(it) } + // #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") @@ -96,9 +306,32 @@ object AndroidDiagnostics { // strap-only user's freshest scored day lives under the "-noop" computed sibling, so reading the // spine showed a stale value (could be a month old) while the app displayed today's. Twin of // Swift DebugDataDiagnostics, which already reads the merged `repo.days`. - val merged = repo.daysMerged("my-whoop") + // + // Scoped to the ACTIVE strap, not the canonical literal. `importedSourceIdsFor` short-circuits + // on the canonical id to `listOf(WHOOP_SOURCE)`, so passing "my-whoop" on a two-strap install + // read the RETIRED strap's sources and skipped the active one entirely: a field report showed + // "Last sleep: 2026-09-06" from a 4.0 put away a fortnight earlier, while the same log carried + // a full scoring pass for that night under the 5/MG that was actually being worn. Passing the + // active id widens the union to both (the parameter is named `activeDeviceId` for this reason). + val merged = repo.daysMerged(activeId) add("Last sleep: ${merged.lastOrNull { (it.totalSleepMin ?: 0.0) > 0.0 }?.let { "${it.day} · ${it.totalSleepMin?.toInt()} min" } ?: "none"}") add("Last recov.: ${merged.lastOrNull { it.recovery != null }?.let { "${it.day} · ${it.recovery?.toInt()}%" } ?: "none"}") + // #1300 follow-up: the header above describes ONE device because it reads the last-connected + // prefs, not the registry — so a two-strap install produced a log that never mentioned the + // second strap, leaving `dayOwner readId=` and the funnel's orphan check with nothing to be + // checked against. Name the whole set instead. + val invRows = runCatching { + (context.applicationContext as? com.noop.NoopApplication)?.deviceRegistry?.all().orEmpty() + .map { + InventoryRow(it.id, it.brand, it.model, it.status, it.lastSeenAt, + com.noop.ui.NoopPrefs.firmwareFor(context, it.peripheralId)) + } + }.getOrDefault(emptyList()) + val invActive = runCatching { + (context.applicationContext as? com.noop.NoopApplication)?.deviceRegistry?.activeDeviceId() + }.getOrNull() + deviceInventoryLines(invRows, invActive, System.currentTimeMillis() / 1000L) { relTime(it) } + .forEach { add(it) } }.onFailure { add("(strap/data state unavailable: ${it.message})") } } @@ -138,7 +371,8 @@ object AndroidDiagnostics { add("(no sleep session in the last 14 days to analyze)") return@runCatching } - var session = recent.last() // non-null (list checked non-empty), newest by ASC start order + val newestSession = recent.last() // non-null (list checked non-empty), newest by ASC start + var session = newestSession var skin = repo.skinTempSamples(id, session.startTs, session.endTs, Int.MAX_VALUE) if (skin.isEmpty()) { for (s in recent.asReversed()) { @@ -146,11 +380,15 @@ 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}") + add( + "Night ${dayStamp(session.startTs)}" + + funnelFallbackNote(dayStamp(session.startTs), dayStamp(newestSession.startTs)) + + ": grav=${grav.size} hr=${hr.size} rr=${rr.size} resp=${resp.size} skin=${skin.size}", + ) if (grav.isEmpty() && hr.isEmpty()) { // #1617 follow-up: do NOT assert "freshly re-added" without testing the other explanation. // Several ids can hold one physical strap's data (#1193/#740), and when the history spine @@ -164,11 +402,30 @@ 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) - ?.let { add(it.summary) } ?: add("REM funnel: insufficient motion data (<2 gravity samples)") + ?.let { + // The funnel replays the V1 classifier, but the shipped hypnogram is staged by V2 + // whenever the default-on flag says so — name both, or the two totals read as one + // fact disagreeing. On a 5/MG the gap is maximal: V1's primary REM gate needs the + // raw resp channel that hardware never emits, while V2 recovers respiration from + // R-R. Suffix byte-identical to the Swift twin in DebugDataDiagnostics. + val screenStager = + if (com.noop.ble.PuffinExperiment.from(context).experimentalSleepV2) "V2" else "V1" + var summary = it.summary + " · funnel replays V1; screen staged by $screenStager" + if (screenStager != "V1") summary += " — totals can differ" + add(summary) + } ?: add("REM funnel: insufficient motion data (<2 gravity samples)") val det = com.noop.analytics.DetectedSleep( start = session.startTs, end = session.endTs, efficiency = session.efficiency ?: 0.0, stages = emptyList(), @@ -244,6 +501,27 @@ 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/#2187: the Settings toggle governs the opt-in suggestion card, now the only path that + // can create a new user-visible automatic workout, and only after Save. The engine may still + // analyze bouts to enrich an overlapping real workout, but no longer publishes generic rows. + // Counts remain useful because grandfathered sport="detected" history is deliberately retained. + // 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})") } } @@ -328,14 +606,19 @@ 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) if (sent > 0L) { val at = p.getLong("alarm.lastArmAt", 0L) - var line = "Last arm: sent ${alarmStamp(sent)}" - if (at > 0L) line += " · ${relTime(System.currentTimeMillis() - at)}" + // #2322: "for · sent ". The old shape put both clocks on one line as + // "sent · ", which reads as "we sent that time, that long ago" — but the + // stamp is the FUTURE instant armed and the relative time is when the command went out. + var line = "Last arm: for ${alarmStamp(sent)}" + if (at > 0L) line += " · sent ${relTime(System.currentTimeMillis() - at)}" if (!p.getBoolean("alarm.lastArmConnected", false)) line += " · strap NOT connected (queued)" // #34: live HR at arm, logged only to test whether the strap's own sleep/rest detection // (not anything NOOP sends) gates the physical haptic — see recordAlarmArm's doc comment. @@ -343,9 +626,30 @@ 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. + // #2322: and only when the readback ANSWERED this arm. A readback that failed to + // decode leaves the previous one standing, so without the arrival stamps this line + // compared two different arms and blamed the strap for the difference. + val reportedAt = p.getLong("alarm.lastReportedAt", 0L) + val verdict = com.noop.analytics.AlarmReadback.verdict( + sentEpoch = sent, + reportedEpoch = reported, + sentDeviceId = p.getString("alarm.lastArmDeviceId", null), + reportedDeviceId = p.getString("alarm.lastReportedDeviceId", null), + sentAt = at, + reportedAt = reportedAt, + ) + var rl = "Strap reports: ${alarmStamp(reported)}" + com.noop.analytics.AlarmReadback.suffix(verdict) + // When the readback landed, so a reader can see the provenance of both halves rather + // than having to trust that they belong together. + if (reportedAt > 0L) rl += " · read ${relTime(System.currentTimeMillis() - reportedAt)}" + add(rl) + // 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) @@ -363,9 +667,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" @@ -399,6 +824,67 @@ 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" + + /** + * Whether automatic workout suggestions are enabled and how much grandfathered detected history is + * still stored. The suggestion card is the only path that can create a new user-visible automatic + * workout, and it saves nothing until the user confirms. IntelligenceEngine still analyzes bouts for + * daily analytics and overlap enrichment, but never publishes or reconciles generic workout rows. + * + * Reads COUNTS from the store rather than describing intent: it states what is on disk, not what the + * code believes it does. The retained-history clause is emitted only when the card is off and legacy + * rows remain, making clear that disabling stops future suggestions without destructively erasing them. + * [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) { + " (stored rows are retained legacy history; disabling does not delete them)" + } else { + "" + } + return "Auto-detect: suggestion card=$card · new workouts=confirmation only; analytics does not " + + "publish generic rows · stored legacy 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 = @@ -446,13 +932,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/DeviceInventory.kt b/android/app/src/main/java/com/noop/testcentre/DeviceInventory.kt new file mode 100644 index 0000000000..f87ebf0bef --- /dev/null +++ b/android/app/src/main/java/com/noop/testcentre/DeviceInventory.kt @@ -0,0 +1,55 @@ +package com.noop.testcentre + +/** One registry row, reduced to what the inventory prints — see [deviceInventoryLines]. */ +internal data class InventoryRow( + val id: String, + val brand: String, + val model: String, + val status: String, + val lastSeenAt: Long, + val firmware: String? = null, +) + +/** + * The strap log's paired-device inventory: every registry row, which one is ACTIVE, and when each was + * last seen. + * + * The header above it describes a single device because it reads the last-connected PREFS rather than + * the registry, so a two-strap install produces a log that never mentions the second strap. That makes + * the id-bearing lines elsewhere — `dayOwner readId=…`, the funnel's orphan check — impossible to + * cross-check: a reader can see which id a day was read from but not which ids exist, nor which of them + * actually synced. Naming the set is what turns those lines into evidence. + * + * Sorted ACTIVE first, then by most-recently-seen, then by id: a stable order that puts the row a reader + * wants first at the top, and never depends on registry iteration order. + * + * Device ids embed a BLE address for a re-added strap, and the export's redaction masks the middle four + * octets while keeping the first and last — enough to tell two straps apart in a shared log without + * publishing an address. This line carries no data the log did not already carry. + * + * [nowSec] is passed in rather than read, so the output is a pure function of its inputs. Swift twin: + * `DeviceInventory.deviceInventoryLines`. + */ +internal fun deviceInventoryLines( + rows: List, + activeId: String?, + nowSec: Long, + relTime: (Long) -> String, +): List { + if (rows.isEmpty()) return listOf("Devices: none registered") + val active = rows.count { it.status == "active" } + val paired = rows.count { it.status == "paired" } + val archived = rows.count { it.status == "archived" } + val head = "Devices: ${rows.size} registered ($active active, $paired paired, $archived archived)" + val ordered = rows.sortedWith( + compareByDescending { it.id == activeId } + .thenByDescending { it.lastSeenAt } + .thenBy { it.id }, + ) + return listOf(head) + ordered.map { r -> + val marker = if (r.id == activeId) "ACTIVE" else r.status + val seen = if (r.lastSeenAt > 0L) relTime((nowSec - r.lastSeenAt) * 1000L) else "never" + " device id=${r.id} status=$marker brand=${r.brand} model=${r.model} lastSeen=$seen" + + " fw=${r.firmware ?: "unknown"}" + } +} 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/testcentre/TestModeRegistry.kt b/android/app/src/main/java/com/noop/testcentre/TestModeRegistry.kt index 64354d5ae2..5e8c7b7c8f 100644 --- a/android/app/src/main/java/com/noop/testcentre/TestModeRegistry.kt +++ b/android/app/src/main/java/com/noop/testcentre/TestModeRegistry.kt @@ -60,8 +60,8 @@ object TestModeRegistry { // composite, so gateTrace, wristOff (the offWristFrac field of the offWrist gate) and restSubScores // are the whole set. Dropped, all unemitted, same rationale the Import mode used for // firstFailingRow/failingFileSample/dedupMerge: hrDensity, perEpochFeatures, hypnogramV1V2 (flipLine - // has no call site), ppgOnlyNight, gravityCoverage (only the sparseBridge gate's sparse= flag, which - // already rides gateTrace) and skinTempDsp (skin-temp is a Recovery input, never written to the + // has no call site), ppgOnlyNight, gravityCoverage (only the sparseBridge gate's + // sparse=/fragmentedToNothing= flags, which already ride gateTrace) and skinTempDsp (skin-temp is a Recovery input, never written to the // sleep sink). The live-readout keys (hrDensityNow/gravityCoverageNow) are separate and unchanged. captures = listOf("gateTrace", "wristOff", "restSubScores"), questionnaire = listOf( diff --git a/android/app/src/main/java/com/noop/testcentre/TestReportFlow.kt b/android/app/src/main/java/com/noop/testcentre/TestReportFlow.kt index 53a1337da2..3f8dc97270 100644 --- a/android/app/src/main/java/com/noop/testcentre/TestReportFlow.kt +++ b/android/app/src/main/java/com/noop/testcentre/TestReportFlow.kt @@ -27,11 +27,16 @@ object TestReportFlow { return "noop-${profile.id}-$platform-v$version-$stamp.zip" } - /** Identical copy to the Swift toast so testers see the same wording on every platform. */ + /** Identical copy to the Swift toast so testers see the same wording on every platform. + * + * Was "On the next screen tap the paperclip and pick it", which described the GitHub issue + * composer this flow opened. Nothing opens a composer now, so it says where the file went and + * stops, rather than walking the user through a screen they will not see. */ fun attachToast(savedName: String): String = - "Saved as $savedName. On the next screen tap the paperclip and pick it." + "Saved as $savedName. Attach it to your bug report." - /** Android is a mobile platform, so it offers the Copy-report.txt fallback. */ + /** Android is a mobile platform, where pasting a .zip into a web form is awkward, so it offers the + * Copy-report.txt fallback for a reporter writing an issue by hand. */ fun offersCopyFallback(platform: String): Boolean = platform.lowercase() == "android" || platform.lowercase() == "ios" } @@ -39,9 +44,13 @@ object TestReportFlow { /** The review gate is mandatory and not skippable (spec section 12). */ fun shouldProceed(gate: ReportReviewGate): Boolean = gate.isCleared - /** Share the already-redacted bundle, open the prefilled issue, toast, and prime the copy fallback. - * `entries` is the redacted, capped bundle the caller assembled. Review-before-share is mandatory: - * nothing is shared until the gate is cleared (spec section 12). + /** Share the already-redacted bundle, toast, and prime the copy fallback. `entries` is the redacted, + * capped bundle the caller assembled. Review-before-share is mandatory: nothing is shared until the + * gate is cleared (spec section 12). + * + * This used to open a prefilled GitHub issue after sharing, which is why the button said "Report". + * That step is gone: the app names no destination and takes nothing off the device, so this now does + * what the strap-log Share button does. Filing an issue is the reporter's own step. * * Suspend (#646/#651): [LogExport.exportBundle] moved its zip build + write off the caller's * dispatcher, so this now awaits it rather than blocking Main on a multi-MB bundle. */ @@ -53,12 +62,6 @@ object TestReportFlow { if (!shouldProceed(gate)) return@runCatching val name = Plan.bundleName(profile, platform, version) LogExport.exportBundle(context, entries, name) // existing ACTION_SEND chooser - // CAPTURE-A (#812): prefill the issue `log` body from the already-redacted report.txt so a - // submission without the .zip attached still carries the diagnostic trace (incl. the universal - // dayOwner line). The bundle is already v2-redacted; this reuses that text verbatim. - val reportText = entries.firstOrNull { it.first == "report.txt" }?.second?.let { String(it) } - TestReportLink.openReport(context, profile, title, version, platform, osVersion, - reportText = reportText) Toast.makeText(context, Plan.attachToast(name), Toast.LENGTH_LONG).show() if (Plan.offersCopyFallback(platform)) { val report = entries.firstOrNull { it.first == "report.txt" }?.second diff --git a/android/app/src/main/java/com/noop/testcentre/TestReportLink.kt b/android/app/src/main/java/com/noop/testcentre/TestReportLink.kt deleted file mode 100644 index c3a7163f2f..0000000000 --- a/android/app/src/main/java/com/noop/testcentre/TestReportLink.kt +++ /dev/null @@ -1,125 +0,0 @@ -package com.noop.testcentre - -import android.content.Context -import android.content.Intent -import android.net.Uri -import java.net.URLEncoder - -/** - * Builds the prefilled GitHub new-issue URL for a Test Centre report (spec section 5.2), the Kotlin - * twin of Strand/System/TestReportLink.swift. Binds bug_report.yml's id fields and self-applies the - * "bug,test:" labels. reportUrlString is pure and unit-tested; reportUri wraps it for ACTION_VIEW. - * Encoding matches the Swift URLComponents output (comma -> %2C, brackets -> %5B/%5D, space -> %20). - * - * CAPTURE-A (#812): a report submitted WITHOUT the .zip attached used to land empty, because the form's - * `log` / `what_happens` textareas were blank and the user often forgot the paperclip. When the in-app - * Report flow supplies the already-redacted report.txt, the `log` textarea is PREFILLED with its last - * ~150 lines wrapped in a
block, so a submitted report carries the diagnostic trace (incl. the - * universal `dayOwner` line) even if nothing is attached. Both new args default empty so existing callers - * compose the same URL as before. The field ids `log` / `what_happens` match bug_report.yml. - */ -object TestReportLink { - - /** How many trailing lines of the redacted report.txt to prefill into the `log` textarea. Kept SHORT - * (the recent killer-trace tokens) because the prefill rides INSIDE the URL and GitHub silently drops - * a new-issue prefill past ~8 KB (empty form); the full trace travels in the attached/shared .zip. - * Mirrors the Swift logTailLines. */ - const val LOG_TAIL_LINES = 40 - - /** Hard ceiling on the composed new-issue URL (GitHub empties the prefill near 8 KB; stay well under). - * If adding the `log` block would breach this, the URL is rebuilt WITHOUT `log` rather than truncated - * into a broken
. Mirrors the Swift maxURLLength. */ - const val MAX_URL_LENGTH = 6000 - - /** Percent-encode a query value the same way URLComponents does for the characters we emit. - * URLEncoder maps space to "+", so we fix it to "%20"; URLEncoder also encodes ":" to "%3A", but - * URLComponents leaves a colon literal in a query value, so we restore it so "test:sleep" matches - * the Swift output byte-for-byte. */ - private fun enc(v: String): String = - URLEncoder.encode(v, "UTF-8").replace("+", "%20").replace("%3A", ":") - - /** - * CAPTURE-A: the last [tailLines] lines of [reportText], wrapped in a GitHub
block so the - * prefilled log collapses by default and doesn't dominate the issue. Returns null when there is no - * usable text, so an empty report contributes NO `log` param (rather than an empty
). Pure + - * deterministic; byte-aligned with the Swift logDetailsBlock. - */ - fun logDetailsBlock(reportText: String, tailLines: Int = LOG_TAIL_LINES): String? { - val lines = reportText.split("\n").toMutableList() - if (lines.isNotEmpty() && lines.last() == "") lines.removeAt(lines.size - 1) - if (lines.isEmpty()) return null - val tail = lines.takeLast(tailLines).joinToString("\n") - if (tail.isBlank()) return null - val shown = minOf(tailLines, lines.size) - return "
Strap log (last $shown lines, redacted)\n\n" + - "```\n" + tail + "\n```\n
" - } - - /** - * CAPTURE-A: seed text for the `what_happens` textarea from the mode's questionnaire answers, so the - * body opens with the tester's own words instead of a blank box. Joins each answered prompt as - * ": " in the questionnaire's declared order (deterministic). Blank answers are - * skipped; returns null when nothing was answered (no `what_happens` param is added). The prompts - * carry no PII; the strap log (the PII-shaped surface) is the already-redacted `log` block. Mirrors - * the Swift whatHappensSeed. - */ - fun whatHappensSeed(questionnaire: List, answers: Map): String? { - val parts = questionnaire.mapNotNull { q -> - val a = answers[q.id]?.trim() - if (a.isNullOrEmpty()) null else "${q.prompt}: $a" - } - return if (parts.isEmpty()) null else parts.joinToString("\n") - } - - /** The pure URL string (no Android types) so it is testable on the plain JVM. When [reportText] is - * supplied the `log` textarea is prefilled with its
-wrapped tail, and a non-blank - * [whatHappensSeed] seeds the `what_happens` field (#812). */ - fun reportUrlString(profile: TestDomain, title: String, - version: String, platform: String, osVersion: String, - reportText: String? = null, - whatHappensSeed: String? = null): String { - val items = ArrayList>().apply { - add("template" to "bug_report.yml") - add("labels" to "bug,${profile.githubLabel}") - add("version" to version) - add("platform" to platform) - add("os_version" to osVersion) - add("test_profile" to profile.id) - add("title" to "[${profile.id}] $title") - // CAPTURE-A: seed what_happens so the body is never empty (#812). - if (!whatHappensSeed.isNullOrBlank()) add("what_happens" to whatHappensSeed) - } - val base = "https://github.com/ryanbr/noop/issues/new?" - fun render(list: List>) = base + list.joinToString("&") { (k, v) -> "$k=${enc(v)}" } - // Add the log block ONLY if the whole URL stays under the GitHub prefill ceiling; otherwise drop it - // entirely (never truncate into a broken
) and rely on the attached/shared .zip (#812). - if (reportText != null) { - logDetailsBlock(reportText)?.let { block -> - val withLog = items + ("log" to block) - val rendered = render(withLog) - if (rendered.length <= MAX_URL_LENGTH) return rendered - } - } - return render(items) - } - - /** The Uri for an ACTION_VIEW intent. Not unit-tested (Uri needs an Android runtime). */ - fun reportUri(profile: TestDomain, title: String, - version: String, platform: String, osVersion: String, - reportText: String? = null, whatHappensSeed: String? = null): Uri = - Uri.parse(reportUrlString(profile, title, version, platform, osVersion, reportText, whatHappensSeed)) - - /** Open the prefilled issue in the browser. Best-effort: a missing browser is swallowed. [reportText] - * (the already-redacted report.txt) prefills the `log` body so a no-attachment submission still - * carries the trace (CAPTURE-A / #812). */ - fun openReport(context: Context, profile: TestDomain, title: String, - version: String, platform: String, osVersion: String, - reportText: String? = null, whatHappensSeed: String? = null) { - runCatching { - val intent = Intent(Intent.ACTION_VIEW, - reportUri(profile, title, version, platform, osVersion, reportText, whatHappensSeed)) - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - context.startActivity(intent) - } - } -} 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..cf27f5e98a 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") { @@ -830,7 +830,7 @@ private fun OnePhoneWarningCard() { ) Text( uiString(R.string.l10n_add_device_wizard_a_whoop_strap_bonds_to_a_d39bdf4a) + - "to the official WHOOP app, and the other way round. It's reversible: pair it in the " + + " to the official WHOOP app, and the other way round. It's reversible: pair it in the " + "other app whenever you want it back.", style = NoopType.footnote, color = Palette.statusWarning, @@ -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) }, ) @@ -1382,7 +1385,7 @@ private fun OuraConfirmStep( } Text( uiString(R.string.l10n_add_device_wizard_beta_is_an_on_device_estimate_128563e0) + - "are a raw motion count, and HRV needs you to be still. No Oura Readiness or SpO2 " + + " are a raw motion count, and HRV needs you to be still. No Oura Readiness or SpO2 " + "percentage comes off the ring (import an Oura file for those).", style = NoopType.footnote, color = Palette.textTertiary, @@ -1481,7 +1484,7 @@ private fun OuraFailedStep(reason: String?, onTryAgain: () -> Unit, onUseFileImp // Honest recovery reassurance (Swift parity): a failed adopt never bricks the ring. Text( uiString(R.string.l10n_add_device_wizard_the_ring_is_not_bricked_to_5efd1b3d) + - "up in the Oura app.", + " up in the Oura app.", style = NoopType.subhead, color = Palette.textSecondary, ) 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..9b75d69d51 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.8.0" data class Release( val version: String, @@ -37,6 +37,104 @@ object AppChangelog { /** Newest first. */ val releases: List = listOf( + Release( + version = "11.8.0", + title = uiString(R.string.l10n_app_changelog_a_gym_log_book_on_your_e0f00272), + date = "September 2026", + items = listOf( + "**A lift log you advance from the strap (#2098, #2099, #2232, thanks @UtkuDenizAltiok).** An on-device gym log book on iPhone and Mac: build a session, then move between sets with a double-tap on the strap instead of reaching for the phone. Android gets the groundwork this release, the new schema with its Room twin and the same set-metrics engine, but not the log book itself yet (#2327).", + "**A Coach you can turn off completely (#2269, #2254, #2222, #2207).** One master switch retires the AI Coach: the tab, the generated brief, the widget and the tray notification, and every request that would leave the device. Its settings moved to their own screen, Android gained the bottom-bar tab iPhone already had, and Disconnect is now somewhere an iPhone can actually reach.", + "**Sync the strap from a shortcut, and watch it work (#2272, thanks @npapatheodorou).** A Sync Strap App Intent runs a sync from Shortcuts or the Lock Screen, a Live Activity shows the offload as it goes, and an optional setting keeps the screen awake while it runs. Reconnecting off-screen now asks for the strap by name rather than scanning for it, which iOS throttles hard in the background.", + "**Workouts that are easier to keep tidy (#2287, #2286, #2260, #2220, #2213, thanks @tigercraft4).** Sessions under a minute are discarded, and the list splits into Current and Archived. Deleting one now clears it from every place the list reads, including the copy Apple Health kept. The WHOOP sports that were missing are selectable, their icons are fixed, and auto-detection only ever asks rather than saving on its own.", + "**An Oura ring that reads its own packets correctly (#2237, #2240, #2253, thanks @pipiche38).** A notification that tiles exactly into several packets is read in full instead of stopping at the first. A young ring no longer adopts the wrong anchor unit, settled by adjacency rather than by guessing, and a diagnostic reports which ring epoch each row believes in.", + "**WHOOP 5 readings that admit when they failed (#2193, #2223, #1985, #2230, thanks @Trillient, @bhelm and @kavemang).** A failed flag read is no longer reported as though it were a value, on either platform. The frame integrity verdict reaches every consumer rather than being narrowed on the way, and the strap family recorded is the one actually established.", + "**Charts that stop redrawing the whole screen (#2295, #2288, #2263, #2258, thanks @Iskrata and @kavemang).** Workouts and Sleep no longer re-evaluate their entire bodies on every live heart-rate tick. Three charts that had no tooltip gained one, two uncapped series are downsampled, the sleep motion trace computes its peak once, and the Apple stress level rounds the way Android's does.", + "**Steps charts, and an optional 30-day average (thanks @bhelm).** The steps charts are clearer, with a 30-day average card you can turn on.", + "**Apple Health that keeps up (#2279, #2291, #2296, #2251, #2271, thanks @Iskrata).** A deferred re-score now publishes once it lands rather than being missed, workouts are observed for live delivery, and a backgrounded pass paces itself under the CPU limit iOS enforces. A CSV import no longer turns a correct SDNN into RMSSD on the way back out.", + "**Smaller corrections.** The morning recap waits until 05:00 rather than arriving overnight (#2290). Stat labels no longer run into their neighbour (#2292). Battery says whose charge it is on all seven surfaces, not three (#2216). A night is named by the evening it belongs to (#2235). The Deep Timeline source row names the active device (#2276, thanks @pipiche38).", + "**Localization.** Chinese is complete in both scripts across the app and the watch complications (#2226, #2227, #2228, #2244), the French recovery and strain words are repaired (#2247), the theme picker says light rather than lightweight (#2249), and a gap that let UI copy ship English in every locale is closed, taking the Home screen and the terms you agree to with it (#2299, #2250).", + ), + ), + Release( + version = "11.7.0", + title = uiString(R.string.l10n_app_changelog_a_stress_screen_that_keeps_up_f7ec3f36), + date = "September 2026", + items = listOf( + "**A stress screen that keeps up (#2191, #2194, #2202, #2116).** Scoring a day of samples no longer happens on the thread that is drawing the screen, so Today and Stress stay responsive while they load. The baseline is reduced a day at a time instead of holding a month of readings at once, and the unprompted rescore now yields to whatever you are doing rather than competing with it.", + "**A stress widget that fills on its own (#2186, #2121, #2177).** The home-screen curve refreshes without waiting for the app to be opened, retries a rescore that produced nothing instead of spending the whole interval on it, and the Today curve stays as fresh as the screen it links to.", + "**Stress readings that agree with each other (#2165, #2168, #2182, #2190, #2124).** The 0 to 3 level has one spelling across the widget and the card, and is no longer rounded a second time on the way to the widget. The screen draws its line from the same sliding read the number comes from, movement is drawn as the stretches it covers rather than dots on the axis, and an early morning no longer reports Calibrating when the day has simply not started yet.", + "**WHOOP 5 readings in the units the strap actually sends (#2195, #2192, #2196, #2197, thanks @Trillient).** R-R intervals from the standard heart-rate profile were being converted as though they were in the spec's units, leaving them 2.3% low and feeding that error into HRV. The console log's sequence byte was also being read as half of a counter it is not. Both are decoded correctly now, on both platforms.", + "**An Oura ring that stops repeating itself (#2100, #2146, #2147, #2153, #2155, #2170, thanks @pipiche38).** A replayed burst the ring's own clock disproves no longer resets the history cursor or announces a reboot that did not happen. A freshly offloaded night is scored immediately rather than at the next periodic tick, history is fetched every five minutes instead of fifteen, and the anchor check judges a ten second gap rather than only a thirty second one.", + "**Scores that survive a re-score (#2115, #2141, thanks @bhelm).** WHOOP 5 HRV and Recovery, and the vitals derived from R-R, are preserved across a re-score instead of being dropped by a pass that could not recompute them.", + "**Scan and connect where you can reach them (#2178, #2180).** Both sit on the Today header, and the scan control appears only while the strap is actually away rather than occupying the header permanently.", + "**Readings that say when they are at a limit (#2176, #2189, #2122, #2201).** A Fitness Age sitting at the end of its scale says so rather than looking like a measurement, and points at the number that still moves. On Sleep, a night already in hand silences the calculating banner, and browsing to a night with no stages no longer shows the most recent night's date beneath it.", + "**Diagnostics that name the cause (#2118, #2129, #2136, #2160, #2138).** A device with no R-R now says whether the beats were never banked or were refused by the unit policy, and the nightly HRV summary says why it reported nothing. A retired probe tells its reader which switch to turn on, a re-arm clears the refusal latch it could never reach before, and the sync chip surfaces the strap's backlog.", + "**Smaller corrections.** A tile value now shrinks to fit instead of truncating, which it was meant to do all along (#2203, #2204, thanks @kavemang). Illness baselines are trusted per signal on Android (#2157, thanks @kavemang), stress scoring is skipped without a widget to draw it (#2111), sleep session cache upserts are guarded (#2112), and the strain banner is gated on the baseline the score already uses (#2132).", + ), + ), + Release( + version = "11.6.0", + title = uiString(R.string.l10n_app_changelog_a_today_screen_you_arrange_yourself_6194ccd0), + date = "September 2026", + items = listOf( + "**Stress on your home screen (#2044, #2045).** A widget showing today's stress curve, on both platforms. It fills without needing the app opened, and when it has nothing to draw it says why instead of sitting blank (#2074).", + "**A Today screen you arrange yourself (#2047, #2051, #2053, #2054).** Today's stress curve and the Trends charts can now be placed on Today as cards, on both platforms. Tapping a hosted card opens the tab it came from rather than stranding you (#2052), and a hero ring on the classic Today opens its own detail (#2060).", + "**Charts that break where the strap did (#2064, #2083).** A heart-rate line no longer draws straight through hours the strap never recorded, on every surface that draws one. A gap now looks like a gap.", + "**WHOOP 5 readings corrected (#2046, #2042, #2056, thanks @Trillient).** R-R intervals are read in the units the strap actually sends, which feeds HRV. Skin temperature now reaches recovery scoring before the score is computed rather than after. Both platforms carry tests covering the gaps these came from.", + "**Backups that check themselves (#2086).** Every export path now verifies the file it actually wrote, so a truncated backup is caught when it is made instead of when you need it. When SQLite does complain, the message is shown in full and can be copied (#2084).", + "**A coach that holds on to your conversation (#2058, #2061).** A rejected API key can be corrected without losing the thread you were in. The coach is told what the workout was rather than only that one happened, the consent screen says what it actually sends (#2063), and the morning brief reaches the screen it was generated for (#2088).", + "**Diagnostics that say why, not just how many (#2080, #2094).** A night that missed the re-score cache now names the setting that dropped it. A dropped link is always recorded (#2065), the Live Console reads the device you actually selected (#2076), and the Rest card says why it is waiting on a sync (#2077).", + "**Your Oura serial stays out of the logs (#2095, #2090, thanks @pipiche38).** Serial numbers are masked in the Oura redactors on both platforms, and product-info replies no longer land in the raw diagnostics sidecar.", + "**Sharing a Test Centre report (#2096).** The Test Centre now hands you the bundle through the same share sheet the strap log uses, and stops there, instead of steering you into opening an issue.", + "**Smaller corrections.** Live heart rate stays armed while the Breathe screen is open (#2036, thanks @kiesstein). Workout actions stay readable over the daytime scene (#2050, thanks @kavemang). A silently dead duplicate string key is gone and the drifted Info.plist is regenerated (#2029, thanks @UtkuDenizAltiok). A manual workout can be entered by its start and end rather than only a duration (#2070), the strap picker seeds from the family actually recorded (#2067), and Today's heart-rate high and low read the samples rather than the mean curve (#2038).", + ), + ), + Release( + version = "11.5.0", + title = uiString(R.string.l10n_app_changelog_a_coach_that_keeps_the_thread_2ee52e0c), + date = "September 2026", + items = listOf( + "**A coach you can hold a conversation with (#1862, thanks @kggreco11).** Replies stream in as they are written rather than arriving in one lump, the conversation is kept between sessions, and it can speak and be spoken to. A morning brief sums up the night behind you. There are widgets and Siri shortcuts, and an optional Today launcher card that stays off until you turn it on.", + "**Your heart rate on the home screen (#1957).** A widget showing live bpm with the recent trace behind it, on both platforms, laid out by your launcher rather than fighting it.", + "**Charts that claim less (#2007, #2011, #2027, #2028).** Points are spaced by date, so a week you did not wear the strap takes the width it actually spans instead of closing up. Daily scores draw as bars, because a line between two days asserts the value travelled through everything in between and it did not. The Line and Bars setting now applies to the detail charts as well as Trends, and HRV and Resting HR carry a dashed rule at your own baseline, so a number means something without you having to remember what is normal for you.", + "**Skin temperature reads as a temperature (#1845, #622 thanks @bartmuskala, #111 thanks @whisp0).** The Health screen and the explorer now lead with the measured value, with a Settings choice if you prefer the difference from your baseline, and it applies across the whole window rather than only the newest night. A Fahrenheit import is converted on the way in instead of being filed as Celsius.", + "**Sleep that survives a broken night (#1937 thanks @bartmuskala, #1855 thanks @AussieFries).** A night chopped into fragments too short to count on their own is bridged into the night it was, instead of scoring as nothing. Drag across the filled hypnogram to read the clock time under your finger. A night measured from heart rate alone now reports the HRV it actually measured, and the night label counts from today rather than from the newest record you happen to hold.", + "**Steps that know when you were asleep (#1572).** The step count and the day boundary now follow your own sleep rather than midnight, so a late night stops splitting one day's activity across two.", + "**A wake time for each day of the week (#1859).** Set Saturday later than Tuesday. The per-day override drives the strap alarm and the backup notification, not just the phone.", + "**Re-scores that take a fraction of the time (#1538 thanks @justinjor-bit).** The steps calibration no longer re-reads sixty days of movement on every pass, and what it learns now survives a restart. On a worn library that phase went from roughly thirty-three seconds to under three.", + "**A strap log that reports instead of assuming (#1997 thanks @semoi, #1881 thanks @pipiche38).** A connection your phone is already holding is no longer read as the strap refusing to pair, the strap you selected is the one that gets connected and credited, and a bond that ends now says why in language that reads the same in every locale. Serial numbers are masked wherever a log can be shared.", + "**Russian, German that speaks to you as du, and smaller corrections.** Routes now import from Apple Health and Health Connect (#1205). The battery pack reports its own charge without overriding the strap gauge (#1935, thanks @Zebsi235). Body measurements and exercise distance carry separate unit preferences (#1913, thanks @kavemang), and the readings table prints each unit once (#1942, thanks @Geg0r).", + ), + ), + 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..b1b35030c8 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 @@ -93,10 +94,22 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow 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 +117,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 // @@ -114,7 +128,7 @@ import androidx.navigation.compose.rememberNavController // Routes whose screens belong to later waves point at a ComingSoon placeholder so the app compiles today. /** A single drawer destination: stable route, display title (localized via [titleRes]), sidebar icon. */ -private enum class Destination( +internal enum class Destination( val route: String, @StringRes val titleRes: Int, val icon: ImageVector, @@ -141,6 +155,10 @@ private enum class Destination( // Group: Insight Coach("coach", R.string.nav_coach, Icons.Filled.AutoAwesome), + // Coach settings (#2243), reached ONLY from the strip on the Coach page, so like [CoupledView] + // it is deliberately absent from every [DrawerGroup]: the drawer groups mirror the iOS More list + // one-for-one, and the iOS twin hangs off Coach in the same way. + CoachSettings("coach_settings", R.string.coach_settings, Icons.Filled.Tune), InsightsHub("insights_hub", R.string.nav_insights_hub, Icons.Filled.Insights), Insights("insights", R.string.nav_insights, Icons.Filled.Insights), Explore("explore", R.string.nav_explore, Icons.Filled.Explore), @@ -171,6 +189,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 +199,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. @@ -201,19 +222,23 @@ private enum class Destination( // `more.expandedSections` CSV — see [MoreSectionPrefs]); it must NEVER be localized. [headerRes] is the // localized DISPLAY label the More page shows. Decoupling the two lets the label translate without // touching the persisted open/closed state or the iOS parity of the stored string. -private data class DrawerGroup( +internal data class DrawerGroup( val header: String, @StringRes val headerRes: Int, val items: List, val defaultExpanded: Boolean, ) -// Mirrors the iOS RootTabView `moreTab` grouping + order one-for-one. Today / Trends / Sleep are NOT -// listed (they're bottom-bar tabs, exactly as on iOS). Android-only screens (Vital Signs, Wake Window, -// Notifications, Devices) are slotted into the matching iOS group. -private val drawerGroups: List = listOf( +// Mirrors the iOS RootTabView `moreTab` grouping + order one-for-one. Today / Trends / Sleep / Coach +// are NOT listed (they're bottom-bar tabs, exactly as on iOS). Android-only screens (Vital Signs, Wake +// Window, Notifications, Devices) are slotted into the matching iOS group. +internal val drawerGroups: List = listOf( DrawerGroup("Insights", R.string.more_group_insights, listOf( - Destination.InsightsHub, Destination.Intelligence, Destination.Coach, + // Coach is a bottom-bar tab now and is deliberately absent here, matching iOS: "K3: Coach + // promoted to a top-level tab — no longer listed under More." Leaving it would have put the + // same destination in two places at once, which is the duplication the note above says this + // list exists to avoid. (#2218) + Destination.InsightsHub, Destination.Intelligence, Destination.Insights, Destination.Explore, Destination.Compare, ), defaultExpanded = true), DrawerGroup("Body", R.string.more_group_body, listOf( @@ -266,7 +291,215 @@ internal object MoreSectionPrefs { } /** - * App shell: a single [Scaffold] with a floating [GlassBottomBar] (Today · Trends · Sleep · More) + * #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() + } + + /** + * Whether the AI Coach is offered at all. Default ON, so every existing install is unchanged. + * + * Lives here rather than being read straight from prefs at the call site because the bar has to + * RECOMPOSE when it flips: a plain `NoopPrefs.coachEnabled(ctx)` read inside the bar would be a + * snapshot taken once, and the tab would not appear or vanish until the next process start. + */ + var coachEnabled by mutableStateOf(true) + private set + + /** + * Flip the Coach master switch. + * + * Cancels the daily brief here rather than leaving each surface to notice, because the brief is the + * one Coach surface that runs with no UI attached: it is a separate default-off feature with its own + * `enabled` flag that calls a provider from the background and posts a notification. Hiding the tab + * alone would leave a wearer who had switched briefs on still getting AI output from a feature they + * had just turned off. + * + * Called in BOTH directions. `reschedule` already reads the master switch first and the brief's own + * flag second, so off cancels the work and clears the widget, and on re-arms it only if the wearer + * had briefs switched on. Doing this on the flip rather than leaving it to the next app start (where + * MainActivity reschedules anyway) keeps the brief's own settings row honest: it would otherwise read + * ON while nothing was scheduled, until something happened to relaunch the app. + */ + fun setCoachEnabled(ctx: Context, value: Boolean) { + coachEnabled = value + val app = ctx.applicationContext + NoopPrefs.setCoachEnabled(app, value) + // Routed through `reschedule` rather than `cancel`, because cancelling the work is only half of + // switching the brief off: the widget keeps displaying the LAST generated brief, which is AI output + // still on the wearer's home screen after they turned the AI off. `reschedule` sees the master + // switch and does the right thing in both directions, so this is unconditional. + CoachBriefScheduler.reschedule(app) + } + + 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) + coachEnabled = NoopPrefs.coachEnabled(ctx.applicationContext) + // 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 · Coach · More) * driving one [NavHost], mirroring the iOS RootTabView. There is NO global toolbar and no nav drawer * — every screen self-titles via [ScreenScaffold], and the "More" sheet (opened from the bar) reaches * every destination in [drawerGroups], so nothing is lost. A single [AppViewModel] is created here and @@ -290,7 +523,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 +579,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 +655,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 = { @@ -386,7 +693,23 @@ fun AppRoot(viewModel: AppViewModel = viewModel()) { } composable(Destination.Intervals.route) { IntervalsScreen(viewModel) } composable(Destination.Breathe.route) { BreatheScreen(viewModel) } - composable(Destination.Coach.route) { CoachScreen() } + composable(Destination.Coach.route) { + // A normal push, so Back returns to the conversation (#2243). + CoachScreen(onOpenSettings = { nav.navigate(Destination.CoachSettings.route) }) + } + composable(Destination.CoachSettings.route) { + // The SAME CoachViewModel the conversation is using, not a fresh one. + // `viewModel()` resolves against LocalViewModelStoreOwner, which under + // Navigation Compose is the NavBackStackEntry, so the default would hand this + // destination its own instance. CoachViewModel keeps consent in memory + // (`_consent`, seeded once at construction) and `send` passes that value to + // `chatStream`, so a revoke made against a second instance would persist to + // storage and still leave the conversation sending on the old one until its + // entry was destroyed. Coach is always below this on the back stack: this + // destination is reachable only from the strip on that screen. + val coachEntry = remember(it) { nav.getBackStackEntry(Destination.Coach.route) } + CoachSettingsScreen(vm = viewModel(coachEntry)) + } composable(Destination.Explore.route) { TrendsExploreScreen(viewModel) } composable(Destination.Automations.route) { AutomationsScreen(viewModel) } composable(Destination.SmartAlarm.route) { SmartAlarmScreen(viewModel) } @@ -429,10 +752,7 @@ fun AppRoot(viewModel: AppViewModel = viewModel()) { composable(Destination.InsightsHub.route) { InsightsHubScreen(viewModel) } composable(Destination.LabBook.route) { LabBookScreen(viewModel) } composable(Destination.Rhythm.route) { - // EXPERIMENTAL: self-gates on its own consent clickwrap (default OFF). The night - // summary + per-window Poincaré results land with the rhythm capture pipeline; until - // then it renders its honest "no clear reading yet" empty state behind the gate. - RhythmScreen(night = null, windows = emptyList()) + RhythmRoute(viewModel) } composable(Destination.FusedRecord.route) { FusedRecordRoute(viewModel) } composable(Destination.AppleHealth.route) { AppleHealthScreen(viewModel) } @@ -452,6 +772,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 +789,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 +930,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() } + }, + ) } } @@ -749,27 +1102,51 @@ private fun MoreRow(dest: Destination, onClick: () -> Unit) { // same destinations. /** A single bottom-bar nav slot: the destination it switches to, plus the bar-specific icon/label. */ -private data class BarTab(val dest: Destination, val icon: ImageVector, @StringRes val labelRes: Int) +internal data class BarTab(val dest: Destination, val icon: ImageVector, @StringRes val labelRes: Int) -/** The nav slots in iOS order: Today · Trends · Sleep · More. +/** The nav slots in iOS order: Today · Trends · Sleep · Coach · More. * More is special-cased (it opens the sheet rather than a route), so it is appended at the call site. */ -private val barLeadingTabs = listOf( +internal val barLeadingTabs = listOf( BarTab(Destination.Today, Icons.Outlined.GridView, R.string.nav_today), // chart.line.uptrend.xyaxis on iOS — the rising-trend glyph, not a flat bar chart. BarTab(Destination.Trends, Icons.AutoMirrored.Filled.TrendingUp, R.string.nav_trends), ) -private val barTrailingTabs = listOf( +/** + * The trailing tabs, as shipped. [barTrailingTabsFor] is what the bar actually draws: Coach is + * conditional, so this list is the full set rather than the visible one. + */ +internal val barTrailingTabs = listOf( BarTab(Destination.Sleep, Icons.Filled.Bedtime, R.string.nav_sleep), + // #2218: Coach was promoted to a top-level tab on iOS and this side did not follow, so it sat in + // the More list while the comment above claimed the two bars matched. AutoAwesome is the sparkles + // glyph iOS uses, and the same one the More row already shows, so the entry a wearer has learned + // keeps its face when it moves up. + BarTab(Destination.Coach, Icons.Filled.AutoAwesome, R.string.nav_coach), ) +/** + * The trailing tabs to draw for a given Coach setting. + * + * A function rather than a filter written inline at the bar so the Kotlin unit tests can assert the + * two shapes directly, and so every surface that needs "which tabs are there" agrees by construction + * instead of by two copies of the same predicate. + */ +internal fun barTrailingTabsFor(coachEnabled: Boolean): List = + if (coachEnabled) barTrailingTabs else barTrailingTabs.filterNot { it.dest == Destination.Coach } + @Composable private fun GlassBottomBar( current: Destination, onTabSelected: (Destination) -> Unit, + modifier: Modifier = Modifier, ) { + // One binding, used by BOTH the slots and the More-lit predicate below. #2218's note applies here + // twice over: a second copy of "which tabs exist" is what let Coach light two slots at once, and a + // conditional tab makes that failure available again to anyone who filters in one place only. + val visibleTrailing = barTrailingTabsFor(BottomBarStyleStore.coachEnabled) 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 +1161,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 +1175,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), ) { @@ -809,7 +1192,7 @@ private fun GlassBottomBar( onClick = { onTabSelected(tab.dest) }, ) } - barTrailingTabs.forEach { tab -> + visibleTrailing.forEach { tab -> BarSlot( icon = tab.icon, label = stringResource(tab.labelRes), @@ -822,10 +1205,14 @@ private fun GlassBottomBar( icon = Icons.Filled.MoreHoriz, label = stringResource(R.string.nav_more), // Selected on the More page itself, and also kept lit whenever the current screen is - // one reached THROUGH More (i.e. not one of the bar's own three tabs) — so drilling - // into any grouped destination still reads as "you're in More", never "nowhere". - active = current != Destination.Today && current != Destination.Trends && - current != Destination.Sleep, + // one reached THROUGH More (i.e. not one of the bar's own tabs) — so drilling into + // any grouped destination still reads as "you're in More", never "nowhere". + // + // Derived from the bar's own lists rather than restated. Spelling the tabs out here + // is what made adding Coach a two-part change: the slot alone would have lit Coach + // AND More together, because this predicate had never heard of it. (#2218) + active = barLeadingTabs.none { it.dest == current } && + visibleTrailing.none { it.dest == current }, modifier = Modifier.weight(1f), onClick = { onTabSelected(Destination.More) }, ) @@ -853,19 +1240,29 @@ 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, + // #2218: one line, always. A fifth slot takes about a fifth off every label's width, and the + // bar scale goes to 2x, so the longest of them can no longer be assumed to fit on a narrow + // phone. Wrapping would not break anything, since `barHeight` is measured afterwards and + // screens clear whatever it comes to, but a two-line nav bar at one size and a one-line bar + // at the next is the kind of thing nobody reports and everybody notices. + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } } 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..ef3792174f 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 @@ -181,6 +186,33 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { private val _activeDeviceName = MutableStateFlow(null) val activeDeviceName: StateFlow = _activeDeviceName.asStateFlow() + /** Whether the ACTIVE registry device is a WHOOP (#2075). Published beside the name and refreshed in + * the SAME registry read, so the console can never name one device while reading another's state. + * + * For the SCREENS, which need something to recompose on. The widget producers deliberately read + * `WhoopBleClient.activeDeviceIsWhoop` instead: two of them write that one snapshot field, and a + * flow that lags the coordinator's flag would make the widget flicker between the two devices' + * charges. Both derive from `SourceIdentity.isWhoop` on the active row. */ + private val _activeIsWhoop = MutableStateFlow(true) + val activeIsWhoop: StateFlow = _activeIsWhoop.asStateFlow() + + /** Whether the ACTIVE registry device is an Oura ring (#2305) — the Live console's ring-only row. + * Same registry read as [activeIsWhoop], so the two verdicts can never disagree; the opposite default + * (false), because a ring affordance for an unresolvable device would reconnect nothing. */ + private val _activeIsOura = MutableStateFlow(false) + val activeIsOura: StateFlow = _activeIsOura.asStateFlow() + + /** The active Oura ring's own charge, for the Live Console (#2075). Mirrors [ouraWearState]. */ + val ouraBatteryPct: StateFlow get() = noopApp.sourceCoordinator.ouraBatteryPct + + /** The active ring's link phase, for the Live Console's ring status line + reconnect (#2305). */ + val ouraLinkPhase: StateFlow + get() = noopApp.sourceCoordinator.ouraLinkPhase + + /** Reconnect the active ring on the user's request from the Live console (#2305). Routed through the + * coordinator so it can only ever reach the ring that is the live source. */ + fun reconnectOuraRing() = noopApp.sourceCoordinator.reconnectActiveRing() + /** WHOOP-style day streak (#569): consecutive local days that carry a Charge score, computed on * device from the merged daily metrics. A day "qualifies" when its [com.noop.data.DailyMetric] has a * non-null `recovery`. Pure math lives in [com.noop.analytics.StreakCalculator] (Swift/Kotlin twin). */ @@ -234,6 +266,9 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { val all = runCatching { noopApp.deviceRegistry.all() }.getOrDefault(emptyList()) val active = all.firstOrNull { it.status == com.noop.data.DeviceStatus.active.name } _activeDeviceName.value = active?.let { displayName(it) } + // Same read, same row: the name and the "is it a WHOOP" verdict cannot disagree (#2075). + _activeIsWhoop.value = LiveConsoleReadout.activeIsWhoop(all, active?.id) + _activeIsOura.value = LiveConsoleReadout.activeIsOura(all, active?.id) // #2305 } } @@ -254,12 +289,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 +313,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 +474,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. @@ -475,8 +539,21 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { .map { it.lastSyncAt } .stateIn(viewModelScope, SharingStarted.Eagerly, live.value.lastSyncAt) - /** Which strap the user is pairing — drives the scan filter in [connect]. Defaults to WHOOP 4.0. */ - private val _selectedModel = MutableStateFlow(WhoopModel.WHOOP4) + /** + * Which strap the user is pairing — drives the scan filter in [connect]. + * + * Seeded from the family service discovery actually recorded, which `WhoopBleClient.persistSelectedModel` + * writes for BOTH branches at the moment the WHOOP4 or WHOOP5 service is found on the link. It used + * to start at a hardcoded WHOOP4 and never consult that value, so an install whose only strap is a + * 5/MG began every process believing it had a 4.0. + * + * That is not a label. This flows into `ble.connect(...)`, which starts a SERVICE-FILTERED scan, so + * a wrong family makes every scan-based reconnect look for the wrong service and wait + * `SCAN_FALLBACK_DELAY_MS`, eight seconds, before rotating, with the strap sitting right there. + */ + private val _selectedModel = MutableStateFlow( + resolveSelectedModel(noopApp.persistedWhoopModelOrNull(), NoopPrefs.lastDevice(appContext)?.second), + ) val selectedModel: StateFlow = _selectedModel.asStateFlow() fun setSelectedModel(model: WhoopModel) { if (model == _selectedModel.value) return @@ -605,6 +682,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 +766,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 @@ -795,8 +904,15 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { // and the Buzz-WHOOP companion, arming the single slot to the earliest either wants (#5). reconcileStrapAlarm() // Remember this strap so we can reconnect to it directly on the next launch (#67), - // e.g. after an APK update restarts the process. - ble.lastDeviceAddress?.let { NoopPrefs.setLastDevice(appContext, it, _selectedModel.value) } + // e.g. after an APK update restarts the process. The address and model must both + // describe the link that actually bonded: scan fallback / easy-connect can establish + // a different family from the picker, and saving the pick made the pair lie about its + // own address, then fed that lie back into every direct reconnect (#2068). + val establishedModel = ble.establishedModel + val address = ble.lastDeviceAddress + if (establishedModel != null && address != null) { + NoopPrefs.setLastDevice(appContext, address, establishedModel) + } } lastBonded = state.bonded } @@ -811,6 +927,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 @@ -917,6 +1073,15 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { // widget reads the anchor here (the notification's honest-null contract lives in the // service), keeping the two symmetric. val anchorRow = widgetAnchorRow(days, logicalKey, localKey) + // #2040: today's stress curve for the stress widget. Do not even fingerprint the + // day's HR when nobody has placed one; scoring is the only expensive widget field. + // Null also leaves whatever curve is already stored alone, so a newly placed widget + // fills on the next pass without disturbing the other widget snapshots. + val stressCurve = if (WidgetSnapshotStore.hasStressWidget(appContext)) { + com.noop.widget.StressWidgetProducer.todayCurve(repo, activeStrapId) + } else { + null + } WidgetSnapshotStore.push( appContext, WidgetSnapshot( @@ -926,8 +1091,23 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { restPct = anchorRow?.let { RestScorer.restFromDaily(it)?.roundToInt() }, effortPct = anchorRow?.strain?.roundToInt(), heartRate = live.heartRate, - batteryPct = live.batteryPct?.roundToInt(), + // The ACTIVE device's charge (#2075): a ring reports its own and does not + // funnel into live.batteryPct, so publishing that put the strap's number on + // the widget while a ring was active. + // + // Deliberately `ble.activeDeviceIsWhoop` rather than the [activeIsWhoop] flow + // beside it. TWO producers write this one field, this and the BLE service, + // and they must not disagree or the widget would flicker between a strap's + // charge and a ring's. The flow exists for Compose, which needs something to + // recompose on; the producers share the coordinator's flag. + batteryPct = LiveConsoleReadout.batteryPercent( + activeIsWhoop = ble.activeDeviceIsWhoop, + whoopPct = live.batteryPct, + ringPct = noopApp.sourceCoordinator.ouraBatteryPct.value, + ), connected = live.connected, + stressSeries = stressCurve?.points ?: emptyList(), + stressDay = stressCurve?.epochDay, updatedAtMs = System.currentTimeMillis(), ), ) @@ -998,10 +1178,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 +1193,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(), @@ -1033,6 +1218,12 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { profileStore.stepsCalibrationConfidence = cal.confidence profileStore.stepsCalibrationManual = cal.manual }, + // Persisted steps-calibration motion folds. The analytics layer is Context-free, so + // the payload is read and written here; without it the sixty-day fold is re-paid in + // full after every relaunch. A derived cache — a missing or unreadable payload just + // re-folds (see StepsMotionCache). + stepsMotionCacheGet = { NoopPrefs.stepsMotionCache(appContext) }, + stepsMotionCacheSet = { NoopPrefs.setStepsMotionCache(appContext, it) }, // Manual "Recalibrate baseline" anchor (Settings → Charge advanced). The analytics // layer is Context-free, so read the epoch (whole seconds, written as a Long by the // button) here and thread it down — foldHistory drops every HRV night before it. @@ -1091,11 +1282,10 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { .active(com.noop.testcentre.TestDomain.UNIVERSAL)) { line -> ble.externalLog(line, com.noop.testcentre.TestDomain.UNIVERSAL) } else null, - // Workouts & GPS test mode (#975): when the WORKOUTS domain is on, route each detected- - // bout persist/drop decision into the .workouts-tagged strap log so an "auto workout - // appeared then vanished" is explainable from an export (previously the auto path - // produced NO trace). Zero-cost when off: one SharedPreferences bool read and the sink - // stays null, so the detected-bout persist path is byte-identical. Mirrors macOS. + // Workouts & GPS test mode (#975/#2187): when the WORKOUTS domain is on, route each + // analytics-only/backfill decision into the .workouts-tagged strap log. Zero-cost when + // off: one SharedPreferences bool read and the sink stays null, so normal analytics are + // byte-identical. Mirrors macOS. workoutsTraceSink = if (com.noop.testcentre.TestCentre.from(appContext) .active(com.noop.testcentre.TestDomain.WORKOUTS)) @@ -1115,12 +1305,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 @@ -1239,15 +1443,27 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { */ private fun autoReconnectOnLaunch() { val saved = NoopPrefs.lastDevice(appContext) ?: return - // Restore the model selection whenever a strap is remembered — deliberately NOT gated on the - // background-connection pref, so an opted-out 5/MG user's picker and scan family still - // survive restarts. Only the reconnect itself respects the pref. (#78 fork) - _selectedModel.value = saved.second + // No picker restore here any more. This used to assign `_selectedModel` from `saved.second`, + // which is what let a wrong family survive every restart and re-store itself. `_selectedModel` + // is now seeded from the recorded family at construction, and this runs from the tail of the + // same init block, off the same two prefs, so re-deriving it here could only ever recompute the + // identical value. The property the old line was protecting (#78 fork) is preserved and widened: + // a field initializer is unconditional, so the picker and scan family now survive a restart even + // for a user with background connection off AND for one with no saved strap at all, which is + // exactly the 5/MG case, since a strap that cannot bond (#1635) never becomes the saved device + // and so never reached this line in the first place. if (!NoopPrefs.backgroundConnection(appContext)) return // APK updates tear down the old foreground service along with the old process. Re-promote it // on the first launch after update/restart before reconnecting, so the persistent notification // and long-lived connection both come back without the user toggling the setting again. WhoopConnectionService.start(appContext) + // The PAIR, deliberately, not the seeded picker value. `setLastDevice` writes address and family + // in one call, so `saved.second` describes THIS address; the recorded family describes whatever + // advertised last, which need not be the same strap. `reconnectToAddress` assigns the client's + // `selectedModel` for the whole link and service discovery never writes that back (it sets + // `connectedFamily` instead), so a family borrowed from the other source would stick. A household + // with a 4.0 and a 5/MG makes them disagree routinely: the 5/MG cannot bond (#1635) so it never + // becomes the saved device, while every attempt at it re-records WHOOP5_MG. ble.reconnectToAddress(saved.first, saved.second) } @@ -1497,10 +1713,27 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { return } val endMs = System.currentTimeMillis() + // A session under a minute is a start/stop nobody meant to keep. Twin of Swift + // `AppModel.endWorkout`, and the same floor the manual doors enforce, so a workout is treated + // identically whether it was tracked or typed in. Discarded at SAVE rather than pruned later: + // nothing that ever held training data is removed. + val elapsedSeconds = (endMs - w.startMs) / 1000L + if (elapsedSeconds < WorkoutEditing.MIN_MANUAL_SPAN_SECONDS) { + emitWorkoutsTrace { + com.noop.analytics.WorkoutsTrace.sessionLine( + event = "discarded", sportKey = WorkoutEditing.traceSportKey(w.sport.name), + hrSamples = samples.size, durationSec = elapsedSeconds.toInt(), + gpsPoints = if (w.gpsEnabled) track.size else null, + ) + } + _lastWorkout.value = null + return + } val pausedMs = w.pausedDurationMs + (w.pausedAtMs?.let { endMs - it } ?: 0L) val activeDurationMs = (endMs - w.startMs - pausedMs).coerceAtLeast(0L) val avg = if (samples.isNotEmpty()) samples.sumOf { it.bpm } / samples.size else null - val peak = if (samples.isNotEmpty()) samples.maxOf { it.bpm } else null + // `w.peakHr` can exceed every sample: a repeated second's higher reading is folded into it, not recorded. + val peak = if (samples.isNotEmpty()) maxOf(samples.maxOf { it.bpm }, w.peakHr) else null // #983: score the SAVED workout with the wearer's measured resting HR, not the hardcoded // default of 60. %HRR is (bpm - resting) / (max - resting), so the default moves every zone // boundary — at 136 bpm with maxHR 190 it is the difference between zone 1 and zone 2. Today's @@ -1569,12 +1802,24 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { @Suppress("UNNECESSARY_SAFE_CALL") val w = _activeWorkout?.value ?: return if (w.pausedAtMs != null) return - val s = w.samples + HrSample(deviceId = deviceId, ts = System.currentTimeMillis() / 1000, bpm = bpm) + // One sample a second. This runs on every LiveState emission that carries a heart rate — any field + // changing, not only the rate — so a second often arrives more than once with one `ts`, and Effort + // credits a zero gap with a full second (`StrainScorer.sampleDurationsMinutes`): each repeat counted + // as another second of effort, live and in the saved workout. A refused reading still reaches the peak: the + // rate moving is often why this ran, so it can be a within-second high; only the peak is published for it, + // with no rescore and no snapshot. Twin of iOS `ActiveWorkout.recordSample`. + val ts = System.currentTimeMillis() / 1000 + if (w.samples.lastOrNull()?.ts == ts) { + if (bpm > w.peakHr) _activeWorkout.value = w.copy(peakHr = bpm) + return + } + val s = w.samples + HrSample(deviceId = deviceId, ts = ts, bpm = bpm) val strain = StrainScorer.strain( s, maxHR = profileStore.hrMax.toDouble(), method = NoopPrefs.effortMethod(appContext), sex = profileStore.sex) ?: 0.0 val updated = w.copy( - samples = s, avgHr = s.sumOf { it.bpm } / s.size, peakHr = s.maxOf { it.bpm }, liveStrain = strain, + // Grown by comparison, not recomputed from `s`, so a peak folded in from a repeated second is kept. + samples = s, avgHr = s.sumOf { it.bpm } / s.size, peakHr = maxOf(w.peakHr, bpm), liveStrain = strain, ) _activeWorkout.value = updated // Re-snapshot the durable non-GPS session so a process kill keeps the latest accumulated HR (#529). @@ -1585,8 +1830,8 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { // // The screen observes [workouts]; every mutation re-loads it so the list reflects the new state // immediately. Loads ALL sources — strap (imported + manual), Apple Health / Health Connect, and - // the on-device DETECTED bouts under "-noop" — then filters out dismissed detected bouts - // so a duplicate the auto-detector created is visible but removable. Mirrors macOS + // grandfathered DETECTED bouts under "-noop" — then filters out dismissed detected bouts + // so legacy history remains visible and removable. Mirrors macOS // Repository.workoutRows. private val _workouts = MutableStateFlow>(emptyList()) @@ -1713,6 +1958,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(), @@ -1727,6 +1976,12 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { profileStore.stepsCalibrationConfidence = cal.confidence profileStore.stepsCalibrationManual = cal.manual }, + // Persisted steps-calibration motion folds. The analytics layer is Context-free, so + // the payload is read and written here; without it the sixty-day fold is re-paid in + // full after every relaunch. A derived cache — a missing or unreadable payload just + // re-folds (see StepsMotionCache). + stepsMotionCacheGet = { NoopPrefs.stepsMotionCache(appContext) }, + stepsMotionCacheSet = { NoopPrefs.setStepsMotionCache(appContext, it) }, baselineEpoch = NoopPrefs.of(appContext) .getLong(Baselines.hrvBaselineEpochKey, 0L).toDouble(), recoveryEpoch = NoopPrefs.of(appContext) @@ -1743,11 +1998,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 @@ -1767,7 +2022,7 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { // HR-fill below like the imported Apple sessions — a GPX with no HR borrows the strap's, while a // FIT that already carries HR is untouched (fill only fills nulls). val activityFiles = repository.workouts(ActivityFileImporter.SOURCE_ID, 0L, now) - val markers = repository.dismissedDetected(deviceId) + val markers = repository.dismissedDetectedUnion(deviceId) // Fill imported sessions' missing HR from strap samples (#77), same as before; detected / // manual rows already carry their own HR so they pass through unchanged. #961: also backfill a // strap-native row's Effort (strain) from the strap trace when it's null, so a live/manual @@ -1966,7 +2221,7 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { /** Re-label a detected bout to [sport] (becomes a durable manual session), then reload. */ fun relabelDetected(row: WorkoutRow, sport: String) { viewModelScope.launch { - runCatching { repository.relabelDetected(row, sport) } + runCatching { repository.relabelDetected(row, sport, deviceId) } loadWorkouts() } } @@ -2096,6 +2351,20 @@ 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 + + /** #2338: the read-only advertising-name probe result, for the Settings strap-name section. */ + val advertisingNameProbe = ble.advertisingNameProbe + + 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. */ @@ -2187,6 +2456,12 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { NoopPrefs.setOuraOnsetKeying(appContext, enabled) } + /** Packed-notification A/B: toggle the EXPERIMENTAL `ff` SetNotification mask. Read by the Oura source + * once per connect (via [SourceCoordinator]), so it takes effect at the next connect, not the current one. */ + fun setOuraNotifyMaskFull(enabled: Boolean) { + NoopPrefs.setOuraNotifyMaskFull(appContext, enabled) + } + /** #1121: toggle the opt-in rolling "detailed capture" strap-log file. Persisted so it survives a * process kill (re-armed in [init] below). */ fun setDetailedCapture(enabled: Boolean) { @@ -2330,8 +2605,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 +2731,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 +2806,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 +2835,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 +2870,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 +2907,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 +2978,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 +3323,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) @@ -3007,3 +3343,28 @@ internal fun elapsedClock(elapsedS: Long): String { java.lang.String.format(java.util.Locale.US, "%d:%02d", m, s) } } + +/** + * Which strap family the picker should sit on, given the two things the app remembers. + * + * [recorded] is what service discovery actually saw on the link — `WhoopBleClient.persistSelectedModel` + * writes it for BOTH the WHOOP4 and the WHOOP5 branch at the moment that family's service is found, so + * it is positive per-link evidence and it wins. [remembered] is the family half of the saved + * last-device pair, which only ever stores whatever the picker happened to hold when a strap last + * bonded; it is a fallback for installs that predate [recorded], not a source of truth, because reading + * it back into the picker lets a wrong value re-store itself on every restart. + * + * The same precedence, for the same reason, is already reasoned out in `AndroidDiagnostics.reportedModel`, + * which picks `detected ?: remembered` so a report cannot invent a model. Note that `NoopPrefs.lastDevice` + * widens a MISSING family to WHOOP4 before it ever reaches [remembered], so a null here means "no strap + * remembered at all", never "remembered without a family" — this function cannot recover the difference + * and does not try, because both land on WHOOP4 anyway. + * + * WHOOP4 remains the last resort so a fresh install behaves as it always has. + * + * Pure on purpose: both inputs are read from SharedPreferences at the call sites, and there is no + * Robolectric in this module, so the decision itself is only testable once it is separated from the + * `Context` that supplies it. + */ +internal fun resolveSelectedModel(recorded: WhoopModel?, remembered: WhoopModel?): WhoopModel = + recorded ?: remembered ?: WhoopModel.WHOOP4 diff --git a/android/app/src/main/java/com/noop/ui/AutoWorkoutNudge.kt b/android/app/src/main/java/com/noop/ui/AutoWorkoutNudge.kt index fc415dc10f..b34a5cff9a 100644 --- a/android/app/src/main/java/com/noop/ui/AutoWorkoutNudge.kt +++ b/android/app/src/main/java/com/noop/ui/AutoWorkoutNudge.kt @@ -35,6 +35,9 @@ import androidx.compose.ui.unit.dp import com.noop.analytics.AutoWorkoutDetector import com.noop.analytics.AutoWorkoutDetectorTrace import com.noop.data.DailyMetric +import com.noop.data.WorkoutRow +import com.noop.ingest.ActivityFileImporter +import com.noop.ingest.LiftingImporter import kotlinx.coroutines.launch import java.time.Instant import java.time.LocalDate @@ -56,22 +59,48 @@ import java.util.Locale * * SAVE → builds a manual-style "Workout" row over the window (avg HR filled) via the existing * [WorkoutEditing.buildManualRow] + [com.noop.data.WhoopRepository.saveManualWorkout] path. DISMISS - * (× or "Not a workout") → records the window in the durable, SEPARATE [AutoWorkoutPrefs] dismissed set - * so it never re-prompts. It NEVER creates a workout without the user tapping Save. + * (× or "Not a workout") → records the exact window in [AutoWorkoutPrefs]; candidate filtering also honors + * legacy analytics-dismissal markers. It NEVER creates a workout without the user tapping Save. * * Design-Reset compliant: a flat accent-tinted [NoopCard], NoopMetrics tokens, no gold — matching the * other Today cards (matches the iOS source exactly). */ -/** The strap source the scan + saves use, matching the rest of Today ("my-whoop"). */ -private const val AUTO_DETECT_DEVICE = "my-whoop" - /** Generic sport label for a saved auto-detected bout — the user can re-label via Workouts → Edit. */ private const val AUTO_DETECT_SPORT = "Workout" /** Days of HR history the scan covers — matches the iOS `autoDetectCandidate(daysBack: 2)`. */ private const val AUTO_DETECT_DAYS_BACK = 2L +/** + * Compose the same saved-workout source set the Workouts screen/Swift `workoutRows()` expose, then apply + * the shared cross-source duplicate collapse once. The WHOOP arguments are already natural-key-deduped by + * [com.noop.data.WhoopRepository.workoutsUnion] / `detectedWorkoutsUnion`; this final pass collapses a + * physical activity mirrored by another provider without hiding distinct sessions. + */ +internal fun mergeAutoDetectSavedRows( + whoopRows: List, + computedRows: List, + appleRows: List, + healthConnectRows: List, + liftingRows: List, + activityFileRows: List, +): List = WorkoutEditing.dedupCrossSource( + whoopRows + computedRows + appleRows + healthConnectRows + liftingRows + activityFileRows, +) + +/** Shadow ground truth is the already-deduped saved set, excluding legacy detector output. */ +internal fun autoDetectLabelledSpans(savedRows: List): List> = + savedRows + .filter { WorkoutEditing.classify(it.source) != WorkoutSource.DETECTED } + .map { it.startTs to it.endTs } + +/** Compare the published baseline with every proposed shadow alternative in stable order. */ +internal fun autoDetectShadowPolicies(): List = + (AutoWorkoutDetector.shadowSustainedMinutes + AutoWorkoutDetector.minSustainedMin) + .distinct() + .sorted() + private sealed interface AutoWorkoutDay { data object Today : AutoWorkoutDay data object Yesterday : AutoWorkoutDay @@ -246,20 +275,19 @@ private suspend fun autoDetectCandidate( // to iOS `days.last(where: { restingHr != nil })?.restingHr`. val restingHr = days.lastOrNull { it.restingHr != null }?.restingHr - // Exclude EVERY already-saved workout window (any source — strap/manual, Apple Health, Health Connect, - // computed "detected" bouts, imported lifting). Matches the iOS `workoutRows()` source union. - val computed = repo.computedDeviceId(AUTO_DETECT_DEVICE) - val saved = ( - repo.workouts(AUTO_DETECT_DEVICE, fromSec, nowSec) + - // #214: also exclude workouts under the ACTIVE strap id — the id we now SAVE under. Without - // this the just-saved workout wouldn't be seen by the overlap exclusion and the card would - // re-prompt for the same window. (Equals "my-whoop" for a legacy install, a harmless dup.) - repo.workouts(viewModel.deviceId, fromSec, nowSec) + - repo.workouts("apple-health", fromSec, nowSec) + - repo.workouts("health-connect", fromSec, nowSec) + - repo.workouts(computed, fromSec, nowSec) + - repo.workouts("lifting", fromSec, nowSec) - ).map { it.startTs to it.endTs } + // Exclude EVERY already-saved workout window. The repository unions include the active, canonical and + // archived WHOOP ids plus all computed siblings; the explicit sources mirror Swift `workoutRows()` and + // add Android's Health Connect lane. Reusing the product's cross-source collapse prevents mirrored + // imports from inflating shadow labels while retaining every genuinely distinct activity. + val savedRows = mergeAutoDetectSavedRows( + whoopRows = repo.workoutsUnion(viewModel.deviceId, fromSec, nowSec), + computedRows = repo.detectedWorkoutsUnion(viewModel.deviceId, fromSec, nowSec), + appleRows = repo.workouts("apple-health", fromSec, nowSec), + healthConnectRows = repo.workouts("health-connect", fromSec, nowSec), + liftingRows = repo.workouts(LiftingImporter.SOURCE_ID, fromSec, nowSec), + activityFileRows = repo.workouts(ActivityFileImporter.SOURCE_ID, fromSec, nowSec), + ) + val saved = savedRows.map { it.startTs to it.endTs } // Workouts & GPS test mode (Test Centre): when on, run the diagnostic twin which returns the SAME // candidates detect(...) does (it reuses detect verbatim) plus the inputs / thresholds / per-window why @@ -276,6 +304,31 @@ private suspend fun autoDetectCandidate( path = "autoDetect", ) for (line in trace) viewModel.ble.externalLog(line, com.noop.testcentre.TestDomain.WORKOUTS) + + // #2187 PR 1: evaluate the published 12-minute baseline plus proposed 10/15 alternatives in SHADOW. + // Use the exact same detector and inputs as the published 12-minute path, but do not feed either + // result into the card, persistence, or downstream scoring. Compare against real saved sessions + // (manual/imported); legacy computed rows are deliberately not labels. Aggregate local log lines + // are the only output and are emitted only while Workouts Test Centre is explicitly active. + val labelledSpans = autoDetectLabelledSpans(savedRows) + for (policy in autoDetectShadowPolicies()) { + val shadow = AutoWorkoutDetector.detect( + hr = hr, + restingHR = restingHr, + gravity = emptyList(), // preserve the published HR-only input contract in shadow + savedWorkouts = emptyList(), // labels must remain visible to the comparison + minimumSustainedMinutes = policy, + ) + viewModel.ble.externalLog( + AutoWorkoutDetectorTrace.shadowComparisonLine( + policy, + shadow, + labelledSpans, + hrForObservability = hr, + ), + com.noop.testcentre.TestDomain.WORKOUTS, + ) + } results } else { AutoWorkoutDetector.detect( @@ -285,9 +338,15 @@ private suspend fun autoDetectCandidate( savedWorkouts = saved, ) } - // Drop anything the user already dismissed, then take the most recent. Mirrors iOS exactly. - val dismissed = AutoWorkoutPrefs.dismissed(context) + // Honor BOTH historic dismissal stores. The suggestion MVP wrote exact tokens to SharedPreferences; + // the durable analytics path wrote overlap-aware markers under each computed source. Read both active + // and archived namespaces so a strap re-pair cannot resurrect a rejected candidate. Card tokens keep + // their exact-match contract; legacy engine markers keep their half-open interval-overlap contract. + val legacyDismissed = AutoWorkoutPrefs.dismissed(context) + val detectedDismissed = repo.dismissedDetectedUnion(viewModel.deviceId) + .map { it.startTs to it.endTs } + .distinct() return candidates - .filter { AutoWorkoutPrefs.token(it) !in dismissed } + .filterNot { AutoWorkoutPrefs.isDismissed(it, legacyDismissed, detectedDismissed) } .maxByOrNull { it.startSec } } diff --git a/android/app/src/main/java/com/noop/ui/AutoWorkoutPrefs.kt b/android/app/src/main/java/com/noop/ui/AutoWorkoutPrefs.kt index 0215eeace6..76e5a4db57 100644 --- a/android/app/src/main/java/com/noop/ui/AutoWorkoutPrefs.kt +++ b/android/app/src/main/java/com/noop/ui/AutoWorkoutPrefs.kt @@ -8,10 +8,9 @@ import com.noop.analytics.AutoWorkoutDetector * * Byte-mirror of the iOS `Repository.autoDetectDismissedSpans` (UserDefaults key * "workouts.autoDetectDismissed"): a flat list of "startSec:endSec" tokens (the detector's integer - * seconds). Kept DELIBERATELY SEPARATE from the gravity detector's `dismissedWorkout` table - * ([com.noop.data.WhoopRepository.dismissedDetected]) so the two features never cross-suppress each - * other — exactly as the iOS twin does. A dismissed suggestion is remembered here so the same window - * never re-prompts after a relaunch. + * seconds). The store remains intact for compatibility; candidate filtering now honors it together + * with the analytics detector's legacy `dismissedWorkout` markers, so neither prior rejection can + * reappear after the #2187 transition. * * NON-destructive: this only records that the user said "not this one"; no workout row is ever * created or deleted by the auto-detect feature unless the user taps Save. @@ -39,16 +38,33 @@ object AutoWorkoutPrefs { /** Token for one auto-detect span — matches the iOS `autoDetectToken` ("startSec:endSec"). */ fun token(w: AutoWorkoutDetector.DetectedWorkout): String = "${w.startSec}:${w.endSec}" - /** Parse the END time (seconds) out of a "startSec:endSec" token; null if malformed. */ + /** Parse only the suffix after the last colon, exactly like Swift's pruning path. */ private fun tokenEnd(token: String): Long? = token.substringAfterLast(':', "").toLongOrNull() + /** + * Preserve the published semantics of each dismissal store. This card's SharedPreferences entries + * suppress only the exact detector token that was dismissed. The analytics detector's durable legacy + * markers use half-open interval overlap, so a drifted candidate is suppressed but two spans that only + * touch at an endpoint are not treated as the same workout. + */ + internal fun isDismissed( + w: AutoWorkoutDetector.DetectedWorkout, + legacyTokens: Set, + detectedSpans: List>, + ): Boolean { + if (token(w) in legacyTokens) return true + return detectedSpans.any { (start, end) -> + w.startSec < end && start < w.endSec + } + } + /** * Prune the dismissed-span set: drop spans whose END is older than ~30 days (they can never be * re-suggested anyway), then hard-cap to the [DISMISSED_MAX] most-recent (by END) as a backstop. * Malformed tokens are kept (treated as newest) so we never silently lose data on a parse miss. * Byte-mirrored in the iOS `Repository.prunedAutoDetectSpans`. */ - private fun prune(spans: Set, now: Long): Set { + internal fun prune(spans: Set, now: Long): Set { val cutoff = now - DISMISSED_MAX_AGE_SEC // Drop anything that aged out; an unparseable token survives the age filter. val fresh = spans.filter { token -> (tokenEnd(token) ?: return@filter true) >= cutoff } 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..de488f2498 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 @@ -249,7 +250,7 @@ fun AutomationsScreen(viewModel: AppViewModel) { RowDivider() Text( uiString(R.string.l10n_automations_screen_notifications_are_off_so_this_can_b3dba7ee) + - "Settings → Notifications to let it through.", + " Settings → Notifications to let it through.", style = NoopType.footnote, color = Palette.statusWarning, ) } @@ -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/BackupSync.kt b/android/app/src/main/java/com/noop/ui/BackupSync.kt index bda359b9a9..af16378e05 100644 --- a/android/app/src/main/java/com/noop/ui/BackupSync.kt +++ b/android/app/src/main/java/com/noop/ui/BackupSync.kt @@ -190,14 +190,19 @@ object BackupSync { // exportTo throws on failure; on a partial write delete the half-written doc so prune/latest // never picks up a corrupt snapshot. return runCatching { + // #1014 (write-side): exportTo now verifies the file it just wrote and throws if it is torn, + // so the check that used to sit here would be a second full read of the same archive. The + // delete below still runs, and now covers every way exportTo can fail rather than only this one. DataBackup.exportTo(context, fileUri) - // #1014 (write-side): confirm the file we just wrote is structurally intact — a torn write (full - // disk / flaky SAF provider) otherwise leaves a truncated .noopbak that "restores" empty, caught - // only at import. Require the DB entry present + valid SQLite header, else treat as a failure and - // delete it below. Twin of the Apple post-write check in writeVerifiedBackupZip. - require(DataBackup.isWrittenBackupIntact(context, fileUri)) { "backup written incompletely" } fileUri }.getOrElse { + // Deleting here on an UNVERIFIABLE verdict is DELIBERATE, and is the opposite of what the + // manual export does with the same verdict. The two contexts want opposite answers: a file + // the user chose the location for is theirs to keep and inspect, and they are told so, while + // a file in THIS folder silently becomes `latest` in a rotation whose older snapshots are + // still intact, with nobody watching to notice it was never confirmed. A snapshot that was + // never read back is not one to hand the restore path by default; the next run writes + // another. runCatching { DocumentsContract.deleteDocument(resolver, fileUri) } null } 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..8f2d3dea35 100644 --- a/android/app/src/main/java/com/noop/ui/BackupSyncScreen.kt +++ b/android/app/src/main/java/com/noop/ui/BackupSyncScreen.kt @@ -71,6 +71,9 @@ fun BackupSyncScreen() { var auto by remember { mutableStateOf(BackupSyncPrefs.autoEnabled(context)) } var lastMs by remember { mutableStateOf(BackupSyncPrefs.lastBackupMs(context)) } var busy by remember { mutableStateOf(false) } + // #1014 family: these failures carry a next step in their LAST clause, which is exactly what a + // Toast drops. Held here and shown in a dialog, the same way Settings does. + var backupFailure by remember { mutableStateOf(null) } // How many dated snapshots to keep; pruning deletes the oldest beyond this (BackupSync.snapshotsToPrune). var keep by remember { mutableStateOf(BackupSyncPrefs.keepCount(context)) } var keepMenu by remember { mutableStateOf(false) } @@ -86,32 +89,48 @@ 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 -> backupFailure = r.message + // #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. Which is the reason + // it has to be READABLE here: the dialog is where the reader learns where to go. + is DataBackup.ImportResult.TooLarge -> backupFailure = r.message } - is DataBackup.ImportResult.Failed -> - Toast.makeText(context, r.message, Toast.LENGTH_LONG).show() + } finally { + busy = false } } } @@ -158,7 +177,7 @@ fun BackupSyncScreen() { ) Text( uiString(R.string.l10n_backup_sync_screen_tip_a_desktop_drive_dropbox_app_2eaff1e3) + - "folder a sync app (e.g. FolderSync / Autosync) keeps in your cloud.", + " folder a sync app (e.g. FolderSync / Autosync) keeps in your cloud.", style = NoopType.caption, color = Palette.accent, ) // #644: these .noopbak snapshots are a plain, unencrypted ZIP — pointing this folder @@ -199,7 +218,7 @@ fun BackupSyncScreen() { Text(uiString(R.string.l10n_backup_sync_screen_daily_auto_backup_e5627357), style = NoopType.body, color = Palette.textPrimary) Text( uiString(R.string.l10n_backup_sync_screen_writes_a_fresh_dated_backup_to_bd964fc5) + - "the latest $keep. Off by default - flip it on if you want it.", + " the latest $keep. Off by default - flip it on if you want it.", style = NoopType.footnote, color = Palette.textTertiary, ) } @@ -231,7 +250,7 @@ fun BackupSyncScreen() { Text(uiString(R.string.l10n_backup_sync_screen_keep_last_snapshots_cd5c9ea9), style = NoopType.body, color = Palette.textPrimary) Text( uiString(R.string.l10n_backup_sync_screen_older_backups_beyond_this_many_are_00b7daa6) + - "daily backups). For recovery: if data ever corrupts, grab the newest snapshot.", + " daily backups). For recovery: if data ever corrupts, grab the newest snapshot.", style = NoopType.footnote, color = Palette.textTertiary, ) } @@ -329,18 +348,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 + } } }, ) @@ -355,7 +380,7 @@ fun BackupSyncScreen() { Text(uiString(R.string.l10n_backup_sync_screen_restore_3cbe6d6b), style = NoopType.headline, color = Palette.textPrimary) Text( uiString(R.string.l10n_backup_sync_screen_replace_this_device_s_data_with_b8679c51) + - "so back up first if unsure.", + " so back up first if unsure.", style = NoopType.footnote, color = Palette.textTertiary, ) NoopButton( @@ -470,6 +495,10 @@ fun BackupSyncScreen() { }, ) } + + backupFailure?.let { failure -> + BackupFailureDialog(message = failure, onDismiss = { backupFailure = null }) + } } /** 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..90d3a41ecb --- /dev/null +++ b/android/app/src/main/java/com/noop/ui/BodyClockDialCard.kt @@ -0,0 +1,324 @@ +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.runtime.remember +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.nativeCanvas +import androidx.compose.ui.graphics.toArgb +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 androidx.compose.ui.platform.LocalDensity +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, + ) + } + } + + // Hour numerals at 00, 06, 12 and 18. The dial carried one longer midnight tick and nothing + // else, which orients the ring but leaves no time READABLE: the caption claims a night is + // "N h later than your body clock" and there was no way to check that against the picture + // (#2350). Paint hoisted and remembered, the idiom `Charts.kt` uses for canvas text. + val density = LocalDensity.current + val labelPaint = remember(density) { + android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply { + color = Palette.textTertiary.toArgb() + // dp, NOT sp. sp scales with the system font scale, and these numerals sit in a + // fixed-radius canvas with 3.5 dp of clearance to the ticks, so a large font setting + // would grow them straight into the collision this change removes. Twin of the Swift + // fixed point size. + textSize = with(density) { 10.dp.toPx() } + textAlign = android.graphics.Paint.Align.CENTER + } + } + + Canvas( + modifier = Modifier + .fillMaxWidth() + .height(200.dp) + .semantics { contentDescription = dialLabel }, + ) { + val side = min(size.width, size.height) + val centre = Offset(size.width / 2f, size.height / 2f) + // NAMED BANDS, twin of the Swift `DialGeometry`. The previous pair collided: the + // reference arc was stroked at `outer`, the radius the six-hour ticks occupy, so on a + // window spanning midnight the dashes ran into the ticks (#2350). Each band owns a + // radius and a width, and `BodyClockDialLayoutTest` asserts they cannot overlap. + val rim = side / 2f - 10.dp.toPx() + // The band offsets are a FRACTION of the available radius, not fixed subtractions. Fixed + // ones hold the nesting depth at 58 dp whatever the canvas is, so a narrow card drives + // the inner bands through the centre and then negative, which draws nothing here and is + // undefined for the Swift `addArc`. Scaling keeps the tuned proportions at the shipping + // size (the factor is exactly 1 at rim 90 dp) and every band stays positive for any rim. + val scale = rim / 90.dp.toPx() + val outer = rim + val labelRadius = rim - 15.dp.toPx() * scale + val reference = rim - 31.dp.toPx() * scale + val night = rim - 45.dp.toPx() * scale + val glyphRadius = rim - 58.dp.toPx() * scale + + // 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 = night, center = centre, + style = Stroke(width = 9.dp.toPx(), cap = StrokeCap.Round)) + // The rim hairline is the circle the TICKS belong to. It no longer doubles as the + // reference arc's circle: that arc moved inward to its own band (#2350). + 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) 6.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 + } + + // Numerals on their own band, inside the ticks. `drawText` places the BASELINE, so the + // centre is nudged down by roughly half the cap height to sit on the radius. + var label = 0.0 + while (label < 24.0) { + val a = Math.toRadians(hourAngleDegrees(label)) + drawContext.canvas.nativeCanvas.drawText( + String.format("%02d", label.toInt()), + centre.x + (cos(a) * labelRadius).toFloat(), + centre.y + (sin(a) * labelRadius).toFloat() + 3.5.dp.toPx(), + labelPaint, + ) + label += 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) { + // Each dash must be LONGER than the stroke is wide, or it renders as a + // radial hash rather than a dashed line (#2350). The remedy is the DASH, + // not the weight: opacity alone left this arc near-invisible over a + // custom background on a real device, so the 7 dp stroke is legibility + // bought with hardware and is kept. + PathEffect.dashPathEffect(floatArrayOf(10.dp.toPx(), 8.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(reference, ideal.bedHour, ideal.wakeHour, + hue.copy(alpha = 0.55f), 7.dp.toPx(), dashed = true) + arc(night, 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 = 12.dp.toPx() + translate( + // INSIDE the night arc, not on it: centred on the arc the bed covered the very + // onset it exists to mark (#2350). + left = centre.x + (cos(onset) * glyphRadius).toFloat() - glyph / 2f, + top = centre.y + (sin(onset) * glyphRadius).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..8e91062fd0 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) } @@ -241,6 +241,14 @@ fun BreatheScreen(viewModel: AppViewModel) { } val tonePlayer = remember { BreathTonePlayer(context) } DisposableEffect(Unit) { onDispose { tonePlayer.release() } } + // Keep the realtime HR stream on while this screen is visible (ref-counted in the ViewModel, so + // navigating to Live — which also wants it — doesn't stop it). The BPM hero + R-R readouts are + // live-derived and shown before a session starts, so the want is unconditional (not gated on + // `running`). + DisposableEffect(Unit) { + viewModel.requestRealtimeHr() + onDispose { viewModel.releaseRealtimeHr() } + } var phase by remember { mutableStateOf(UiPhase.Inhale) } var phaseLabel by remember { mutableStateOf(null) } var stageIndex by remember { mutableIntStateOf(0) } @@ -446,7 +454,6 @@ fun BreatheScreen(viewModel: AppViewModel) { if (showEdu) { ProtocolEduDialog( pace = pace, - lockedBpm = lockedBpm, onDismiss = { showEdu = false }, ) } @@ -553,7 +560,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 +636,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 +952,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..b3d0791248 100644 --- a/android/app/src/main/java/com/noop/ui/Charts.kt +++ b/android/app/src/main/java/com/noop/ui/Charts.kt @@ -3,6 +3,8 @@ package com.noop.ui import com.noop.analytics.StagePercentages import androidx.compose.foundation.Canvas import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTransformGestures import androidx.compose.foundation.layout.Box @@ -12,7 +14,9 @@ import androidx.compose.foundation.layout.height import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds @@ -24,6 +28,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.nativeCanvas @@ -33,6 +38,8 @@ import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.platform.LocalDensity import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter @@ -104,16 +111,73 @@ private fun hypnogramSummary(stages: List>): String { /** Map a list of values into evenly-spaced points within [bounds], scaling y to the * value range. A flat series (min == max) is centered vertically. Returns an empty * list when there are fewer than two finite points. */ -private fun pointsFor( +internal fun pointsFor( values: List, width: Float, height: Float, topPad: Float, bottomPad: Float, + yDomain: ClosedFloatingPointRange? = null, + timestamps: List? = null, ): List { val clean = values.filter { it.isFinite() } if (clean.size < 2 || width <= 0f || height <= 0f) return emptyList() - return pointsFor(clean, width, height, topPad, bottomPad, clean.min(), clean.max()) + val (lo, hi) = yBounds(clean, yDomain) ?: return emptyList() + return pointsFor(clean, width, height, topPad, bottomPad, timestamps, lo, hi) +} + +/** + * The value range a chart plots into, shared by the series and by anything drawn ON the same scale. + * + * Extracted rather than repeated so a reference rule cannot drift from the series it annotates: a rule + * computed against its own bounds would sit at a plausible-looking wrong height the moment either copy + * changed, and nothing about the picture would say so. + * + * [values] must already be finite-filtered. Returns null when there is nothing to scale. + */ +internal fun yBounds( + values: List, + yDomain: ClosedFloatingPointRange?, +): Pair? { + if (values.isEmpty()) return null + // A supplied domain never HIDES a reading: it widens to contain anything outside it, so anchoring can + // only ever change the scale, never clip a value off the chart. + val lo = yDomain?.let { minOf(it.start, values.min()) } ?: values.min() + var hi = yDomain?.let { maxOf(it.endInclusive, values.max()) } ?: values.max() + // A series that is entirely flat AT the supplied floor would otherwise have zero span, and the + // zero-span fallback puts a flat line mid-chart. That is right when the scale came from the data + // (a steady 70bpm belongs in the middle) and wrong when a floor was asked for: an all-zero Effort + // week must sit ON the floor, which is the whole property the floor exists to give. Widening by one + // unit puts it there. Only when a domain was supplied, so auto-scaled charts keep their behaviour. + if (yDomain != null && hi <= lo) hi = lo + 1.0 + return lo to hi +} + +/** + * The y pixel for one [value] under the same scale [pointsFor] gives the series, or null when the value + * falls outside the plotted range. + * + * Null rather than a clamped edge on purpose: a rule pinned to the top or bottom of the plot would read as + * "your baseline is the highest value here", which is a different claim from "your baseline is off this + * chart". Drawing nothing says the second honestly. + */ +internal fun yForValue( + value: Double, + values: List, + height: Float, + topPad: Float, + bottomPad: Float, + yDomain: ClosedFloatingPointRange? = null, +): Float? { + if (!value.isFinite() || height <= 0f) return null + val clean = values.filter { it.isFinite() } + if (clean.size < 2) return null + val (lo, hi) = yBounds(clean, yDomain) ?: return null + if (value < lo || value > hi) return null + val span = hi - lo + val usableH = (height - topPad - bottomPad).coerceAtLeast(1f) + val norm = if (span > 0.0) ((value - lo) / span).toFloat() else 0.5f + return topPad + (1f - norm) * usableH } private fun pointsFor( @@ -122,6 +186,7 @@ private fun pointsFor( height: Float, topPad: Float, bottomPad: Float, + timestamps: List?, minV: Double, maxV: Double, ): List { @@ -129,16 +194,32 @@ private fun pointsFor( val span = (maxV - minV) val usableH = (height - topPad - bottomPad).coerceAtLeast(1f) - val stepX = if (values.size > 1) width / (values.size - 1) else width + val fractions = xFractions(values.size, timestamps) return values.mapIndexed { i, v -> - val x = stepX * i + val x = fractions[i] * width val norm = if (span > 0.0) ((v - minV) / span).toFloat() else 0.5f val y = topPad + (1f - norm) * usableH Offset(x, y) } } +/** + * A dashed horizontal rule at [y], for a personal-baseline reference drawn UNDER the series. + * + * Dashed and faint on purpose: it is a reference the readings are judged against, not a second series. A + * solid stroke at the line's own weight would read as data. + */ +private fun DrawScope.drawReferenceRule(y: Float, color: Color) { + drawLine( + color = color, + start = Offset(0f, y), + end = Offset(size.width, y), + strokeWidth = 1f, + pathEffect = PathEffect.dashPathEffect(floatArrayOf(6f, 6f), 0f), + ) +} + /** Draw the faint zero/empty baseline used when there is nothing to plot. */ private fun DrawScope.drawBaseline(color: Color = Palette.hairline) { val y = size.height / 2f @@ -198,6 +279,29 @@ fun Sparkline( // MARK: - LineChart +/** + * Segment ids for a bucketed time series, changing wherever the series SKIPS a bucket. + * + * A bucket aggregate only emits rows for buckets that had samples, so an hour the strap was off simply + * is not in the list. The chart spaces points by index, so those two neighbours land side by side and + * the stroke joins them: a straight line drawn across time where nothing was measured, reading as a + * steady climb the wearer never had. Feeding these ids to [LineChart] breaks the stroke there instead, + * the same way an estimator change already breaks the VO2max line, so a gap looks like a gap. + * + * A step of exactly one bucket is contiguous. Anything longer means at least one bucket held nothing, + * and that is the break. No tolerance for "just one missing": a five-minute hole is still five minutes + * of invention, and the stress trace made the same call when it stopped drawing through unscored hours. + * + * Byte-identical twin of Swift `hrGapSegments`. + */ +internal fun hrGapSegmentIds(bucketTs: List, bucketSeconds: Long): List { + var segment = 0 + return bucketTs.mapIndexed { i, ts -> + if (i > 0 && ts - bucketTs[i - 1] > bucketSeconds) segment++ + segment.toString() + } +} + /** Contiguous point-index ranges for a segmented line. A null/misaligned id list preserves the classic * single-line behavior. Equal ids that reappear later become a new range because only adjacency connects. */ internal fun lineChartSegmentRanges(count: Int, segmentIds: List?): List { @@ -246,6 +350,35 @@ fun LineChart( // Optional sequential line-segment ids, index-aligned with [values]. Adjacent unequal ids break the // stroke/fill without dropping either reading; used by VO₂max when its estimator changes. segmentIds: List? = null, + /** + * Optional FIXED y-domain, instead of scaling to the data's own min/max. + * + * Auto-scaling makes every chart fill its full height whatever the metric actually did, so a Rest + * series moving 46..93 on a natural 0..100 scale is drawn exactly as violently as an Effort series + * moving 0..42. A reader cannot tell a calm metric from a volatile one, and a single low day rewrites + * the whole shape. Where a metric HAS a natural domain, saying so is what makes the height mean + * something. + * + * Default null keeps every existing caller byte-identical: only metrics that genuinely have a fixed + * range should pass one. A metric whose interesting variation is a narrow band inside its nominal + * range (blood oxygen lives at 90..100) is WORSE anchored, because the real movement flattens to a + * line at the top, so this is opt-in per metric rather than "percentages get 0..100". + */ + yDomain: ClosedFloatingPointRange? = null, + /** + * Draw a small marker at every reading. + * + * A line alone cannot say WHERE the measurements are. On a daily trend with missing days that matters: + * a long straight run is either a steady week or one reading either side of a gap, and the line looks + * identical. Markers say which, without fragmenting the stroke the way breaking it did. + * + * Opt-in, so the dense live charts (HR at 1 Hz) are untouched, where a dot per sample would be noise. + */ + showsPoints: Boolean = false, + // Optional personal-baseline reference, drawn as a dashed rule under the series. Null (the default) + // draws nothing, so every existing caller is byte-identical. A value outside the plotted range draws + // nothing either: `yForValue` returns null rather than clamping a rule to an edge it does not sit on. + baselineValue: Double? = null, ) { val cleanValues = remember(values) { values.filter { it.isFinite() } } // Timestamps filtered by the SAME finiteness cut as cleanValues so indices stay aligned; @@ -263,14 +396,19 @@ fun LineChart( else values.indices.filter { values[it].isFinite() }.map { segmentIds[it] } } var selectedIndex by remember(cleanValues) { mutableIntStateOf(-1) } + // Hoisted, not recomputed per gesture event: the drag handler fires every frame and this allocates a + // list the length of the series. Keyed on both inputs so the gesture handlers can be keyed on IT: + // keying them on `cleanValues` alone would let a timestamp change leave them hit-testing against + // stale positions, highlighting one day while labelling another. + val xFracs = remember(cleanValues, cleanTimestamps) { xFractions(cleanValues.size, cleanTimestamps) } val interactiveModifier = if (selectionEnabled) { Modifier - .pointerInput(cleanValues) { + .pointerInput(cleanValues, xFracs) { detectTapGestures( onTap = { offset -> if (cleanValues.size >= 2 && size.width > 0) { selectedIndex = nearestIndexForX( - count = cleanValues.size, + fractions = xFracs, width = size.width.toFloat(), x = offset.x, ) @@ -280,12 +418,12 @@ fun LineChart( } .then( if (dragSelectionEnabled) { - Modifier.pointerInput(cleanValues) { + Modifier.pointerInput(cleanValues, xFracs) { detectHorizontalDragGestures( onDragStart = { start -> if (cleanValues.size < 2 || size.width <= 0f) return@detectHorizontalDragGestures selectedIndex = nearestIndexForX( - count = cleanValues.size, + fractions = xFracs, width = size.width.toFloat(), x = start.x, ) @@ -293,7 +431,7 @@ fun LineChart( onHorizontalDrag = { change, _ -> if (cleanValues.size < 2 || size.width <= 0f) return@detectHorizontalDragGestures selectedIndex = nearestIndexForX( - count = cleanValues.size, + fractions = xFracs, width = size.width.toFloat(), x = change.position.x, ) @@ -349,7 +487,12 @@ fun LineChart( val strokePx = 2.5f val topPad = strokePx + 4f val bottomPad = strokePx + 4f - val pts = pointsFor(cleanValues, size.width, size.height, topPad, bottomPad) + val pts = pointsFor(cleanValues, size.width, size.height, topPad, bottomPad, yDomain, cleanTimestamps) + // Same bounds as the series, through the same helper, so the rule cannot end up at a + // plausible-looking wrong height if either scale ever changes. + val baselineY = baselineValue?.let { + yForValue(it, cleanValues, size.height, topPad, bottomPad, yDomain) + } if (pts.isEmpty()) { onDrawBehind { drawBaseline() } } else { @@ -386,12 +529,23 @@ fun LineChart( } val lineStroke = Stroke(width = strokePx, cap = StrokeCap.Round, join = StrokeJoin.Round) onDrawBehind { + // Under the fill and the line: the rule annotates the series rather than + // competing with it. + if (baselineY != null) drawReferenceRule(baselineY, Palette.hairlineStrong) // Soft gradient fill under the curve. if (fillBrush != null) { for (path in fillPaths) drawPath(path = path, brush = fillBrush) } // The line itself. for (path in linePaths) drawPath(path = path, color = color, style = lineStroke) + // Markers only while they can still be told apart. On the ALL range a daily + // series is hundreds of points, and a dot every few pixels merges into a + // thick smear that hides the line it was meant to annotate. + val markerRadius = strokePx * 1.2f + val spacing = if (pts.size > 1) size.width / (pts.size - 1) else size.width + if (showsPoints && spacing >= markerRadius * 3f) { + for (p in pts) drawCircle(color = color, radius = markerRadius, center = p) + } // A one-reading segment has no visible stroke. Method-segmented trends retain // a small point so neither side of a method transition disappears. if (cleanSegmentIds != null) { @@ -408,7 +562,7 @@ fun LineChart( val strokePx = 2.5f val topPad = strokePx + 4f val bottomPad = strokePx + 4f - val pts = pointsFor(cleanValues, size.width, size.height, topPad, bottomPad) + val pts = pointsFor(cleanValues, size.width, size.height, topPad, bottomPad, yDomain, cleanTimestamps) if (selectedIndex in pts.indices) { val p = pts[selectedIndex] drawLine( @@ -474,7 +628,9 @@ fun MultiLineChart( val bottomPad = strokePx + 4f cleanSeries.forEach { line -> - val pts = pointsFor(line.values, size.width, size.height, topPad, bottomPad, minV, maxV) + // MultiLineChart keeps index spacing: its series are index-aligned to each other, not to a + // shared clock, so positioning by time would need a per-series timeline it does not have. + val pts = pointsFor(line.values, size.width, size.height, topPad, bottomPad, null, minV, maxV) if (pts.isEmpty()) return@forEach val path = Path().apply { moveTo(pts.first().x, pts.first().y) @@ -489,12 +645,42 @@ fun MultiLineChart( } } -private fun nearestIndexForX(count: Int, width: Float, x: Float): Int { - if (count <= 1 || width <= 0f) return 0 - val step = width / (count - 1) +/** + * Where each point sits horizontally, as a 0..1 fraction of the width. + * + * Index spacing puts every reading an equal step apart, so a four-day gap is drawn exactly like a one-day + * step. With per-point timestamps the position is proportional to TIME instead, which is what makes a gap + * occupy the width it actually spans, and what makes breaking the stroke across one read as "nothing + * measured here" rather than as a chopped line. + * + * Falls back to index spacing whenever time cannot order the points: no timestamps, a length mismatch, a + * zero span (every reading at the same instant), or a non-ascending sequence. Refusing rather than + * guessing, the same stance the rest of this file takes. + */ +internal fun xFractions(count: Int, timestamps: List?): List { + if (count <= 1) return List(count) { 0f } + val indexFractions = { List(count) { it.toFloat() / (count - 1) } } + if (timestamps == null || timestamps.size != count) return indexFractions() + if (timestamps.zipWithNext().any { (a, b) -> b < a }) return indexFractions() + val span = (timestamps.last() - timestamps.first()).toDouble() + if (span <= 0.0) return indexFractions() + return timestamps.map { ((it - timestamps.first()) / span).toFloat() } +} + +/** + * The nearest point to a tapped x. Takes the SAME fractions the geometry used: deriving it from a uniform + * step independently would select the wrong reading the moment spacing stopped being uniform. + */ +private fun nearestIndexForX(fractions: List, width: Float, x: Float): Int { + if (fractions.size <= 1 || width <= 0f) return 0 val clampedX = x.coerceIn(0f, width) - val raw = (clampedX / step).roundToInt() - return raw.coerceIn(0, count - 1) + var best = 0 + var bestDist = Float.MAX_VALUE + fractions.forEachIndexed { i, f -> + val d = kotlin.math.abs(f * width - clampedX) + if (d < bestDist) { bestDist = d; best = i } + } + return best } /** The tap/drag pinpoint label: the caller's display formatter when supplied (#463), else the raw @@ -548,20 +734,46 @@ fun BarChart( // Optional per-point display labels index-aligned with [values]; when supplied, a tap shows // "