diff --git a/.github/workflows/fork-release.yml b/.github/workflows/fork-release.yml index 240e13be62..3ed70a6a74 100644 --- a/.github/workflows/fork-release.yml +++ b/.github/workflows/fork-release.yml @@ -149,10 +149,14 @@ jobs: echo "First release (no prior release tag) — skipping the auto PR breakdown." fi { - printf '\n---\n\n**Downloads** — NOOP Staging identity, installs **beside** the official app (separate data):\n' - printf -- '- **Android** — `NOOP-android-v%s.apk` (`com.noop.whoop.staging`)\n' "$NEW" - printf -- '- **macOS** — `NOOP-macos-v%s.zip` (unzip, then right-click → Open)\n' "$NEW" - printf -- '- **iOS** — `NOOP-ios-unsigned-v%s.ipa` (AltStore / Sideloadly)\n' "$NEW" + # The beside-install claim is per-platform, not global: 749c94118 gave the iOS family the + # PRODUCTION bundle id, so an iOS sideload REPLACES an installed official app and uses its + # data. Android (.staging via -PstagingRelease) and macOS (.noop.staging, kept per #39) do + # install beside. Saying "separate data" for all three told iOS users the opposite. + printf '\n---\n\n**Downloads:**\n' + printf -- '- **Android** — `NOOP-android-v%s.apk` (`com.noop.whoop.staging`) — installs **beside** the official app, with its own data.\n' "$NEW" + printf -- '- **macOS** — `NOOP-macos-v%s.zip` (unzip, then right-click → Open) — installs **beside** the official app; on first launch it copies any data the official app already has, leaving the original untouched.\n' "$NEW" + printf -- '- **iOS** — `NOOP-ios-unsigned-v%s.ipa` (AltStore / Sideloadly) — carries the **production** bundle id, so it **replaces** an installed official app and uses its data. Not a beside-install.\n' "$NEW" } >> "$BODY" gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --target "$(git rev-parse HEAD)" \ --title "NOOP ${NEW}" --notes-file "$BODY" diff --git a/.github/workflows/fork-testing-build.yml b/.github/workflows/fork-testing-build.yml index 5202752394..ba22c9577a 100644 --- a/.github/workflows/fork-testing-build.yml +++ b/.github/workflows/fork-testing-build.yml @@ -91,12 +91,12 @@ jobs: 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}\`). + --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. Android and macOS install **beside** the official app; the iOS ipa carries the **production** bundle id, so it **replaces** an installed official app and uses its data. Filenames carry the base app version (\`v${VER}\`). - **Android (release)** — \`NOOP-android-v${VER}.apk\` — optimized, non-debuggable build; installs beside the official app. \`com.noop.whoop.staging\`. - **Android (debug)** — \`NOOP-android-debug-v${VER}.apk\` — debuggable build. \`com.noop.whoop.debug\`. - **macOS** — \`NOOP-macos-v${VER}.zip\` — unzip, then **right-click → Open** (ad-hoc signed, not notarized; Gatekeeper warns once). If it says *\"damaged\"*, run \`xattr -dr com.apple.quarantine /Applications/NOOP.app\`. - - **iOS** — \`NOOP-ios-unsigned-v${VER}.ipa\` — sideload via **AltStore / Sideloadly** (re-signs on-device); embedded watch app stripped, widget extension retained for Home/Lock-Screen widgets and Live Activities. + - **iOS** — \`NOOP-ios-unsigned-v${VER}.ipa\` — sideload via **AltStore / Sideloadly** (re-signs on-device). **Replaces** an installed official app (production bundle id, shared data), so back up first if you have one. Embedded watch app stripped, widget extension retained for Home/Lock-Screen widgets and Live Activities. Base app version ${VER} · built ${DATE} · \`${SHA}\`." # Belt-and-braces: a rolling tag is deleted with --cleanup-tag and recreated seconds later, and diff --git a/.github/workflows/noop-security.yml b/.github/workflows/noop-security.yml index 933256ee22..7bf9ea040b 100644 --- a/.github/workflows/noop-security.yml +++ b/.github/workflows/noop-security.yml @@ -149,9 +149,6 @@ jobs: - name: Inspect unsigned app and create immutable handoff run: | set -euo pipefail - # The iOS target's PRODUCT_NAME is "NOOP Staging". Normalize only the - # outer unsigned bundle directory for the IPA/artifact handoff. - mv 'build/dd/Build/Products/Release-iphoneos/NOOP Staging.app' 'build/dd/Build/Products/Release-iphoneos/NOOP.app' app='build/dd/Build/Products/Release-iphoneos/NOOP.app' python3 scripts/security/binary.py "$app" --report build/security/binary.json tar -czf build/security/unsigned-app.tar.gz -C build/dd/Build/Products/Release-iphoneos NOOP.app diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/ChargeDrivers.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/ChargeDrivers.swift index 4e2c88bdc6..e1772e9673 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/ChargeDrivers.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/ChargeDrivers.swift @@ -135,9 +135,11 @@ extension RecoveryScorer { // 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 } + // Only eligible personal baselines may appear in respiration rows or diagnostic terms. + let respB = respBaseline.flatMap { $0.usable ? $0 : nil } guard let full = recovery(hrv: hrv, rhr: rhr, resp: resp, hrvBaseline: hrvBaseline, rhrBaseline: rhrB, - respBaseline: respBaseline, sleepPerf: sleepPerf, + respBaseline: respB, sleepPerf: sleepPerf, skinTempDev: skinTempDev) else { return [] } @@ -172,7 +174,7 @@ extension RecoveryScorer { // Higher HRV vs baseline supports recovery. Neutral = HRV at the baseline mean. let hrvPoints = points(recovery(hrv: hrvBaseline.baseline, rhr: rhr, resp: resp, hrvBaseline: hrvBaseline, rhrBaseline: rhrB, - respBaseline: respBaseline, sleepPerf: sleepPerf, + respBaseline: respB, sleepPerf: sleepPerf, skinTempDev: skinTempDev)) drivers.append(ChargeDriver( label: "Heart rate variability", @@ -188,7 +190,7 @@ extension RecoveryScorer { if let b = rhrB { let rhrPoints = points(recovery(hrv: hrv, rhr: b.baseline, resp: resp, hrvBaseline: hrvBaseline, rhrBaseline: rhrB, - respBaseline: respBaseline, sleepPerf: sleepPerf, + respBaseline: respB, sleepPerf: sleepPerf, skinTempDev: skinTempDev)) drivers.append(ChargeDriver( label: "Resting heart rate", @@ -204,7 +206,7 @@ extension RecoveryScorer { label: "Sleep quality", deltaPoints: points(recovery(hrv: hrv, rhr: rhr, resp: resp, hrvBaseline: hrvBaseline, rhrBaseline: rhrB, - respBaseline: respBaseline, sleepPerf: sleepPerfCenter, + respBaseline: respB, sleepPerf: sleepPerfCenter, skinTempDev: skinTempDev)), valueText: "\(Int((sp * 100).rounded()))%", baselineText: "", // centred on a fixed "good night", not a learned baseline @@ -213,10 +215,10 @@ extension RecoveryScorer { // ── Respiration (lower vs baseline supports recovery) ──────────────────── // Neutral = respiration at the baseline mean. - if let r = resp, let b = respBaseline { + if let r = resp, let b = respB { let respPoints = points(recovery(hrv: hrv, rhr: rhr, resp: b.baseline, hrvBaseline: hrvBaseline, rhrBaseline: rhrB, - respBaseline: respBaseline, sleepPerf: sleepPerf, + respBaseline: respB, sleepPerf: sleepPerf, skinTempDev: skinTempDev)) drivers.append(ChargeDriver( label: "Respiratory rate", @@ -233,7 +235,7 @@ extension RecoveryScorer { if let dev = skinTempDev { let skinTempPoints = points(recovery(hrv: hrv, rhr: rhr, resp: resp, hrvBaseline: hrvBaseline, rhrBaseline: rhrB, - respBaseline: respBaseline, sleepPerf: sleepPerf, + respBaseline: respB, sleepPerf: sleepPerf, skinTempDev: 0)) drivers.append(ChargeDriver( label: "Skin temperature", diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/HRVAnalyzer.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/HRVAnalyzer.swift index be09ab27d0..f7e0057e69 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/HRVAnalyzer.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/HRVAnalyzer.swift @@ -126,11 +126,12 @@ public enum HRVAnalyzer { guard nn.count > ectopicWindowRadius else { return nn } var kept: [Double] = [] kept.reserveCapacity(nn.count) + var neighbours: [Double] = [] + neighbours.reserveCapacity(2 * ectopicWindowRadius) for i in 0..= 2 else { kept.append(nn[i]); continue } let med = median(neighbours) @@ -181,10 +182,12 @@ public enum HRVAnalyzer { // rejectEctopic returns the input unchanged for a series this short. for k in 0.. recovery() returns nil before any term is built. @@ -83,7 +85,7 @@ extension RecoveryScorer { lines.append("charge baseline rhr mean=\(r2(b.baseline)) spread=\(r2(b.spread)) " + "nValid=\(b.nValid) status=\(b.status.rawValue)") } - if let b = respBaseline { + if let b = respB { lines.append("charge baseline resp mean=\(r2(b.baseline)) spread=\(r2(b.spread)) " + "nValid=\(b.nValid) status=\(b.status.rawValue)") } @@ -117,7 +119,7 @@ extension RecoveryScorer { } // Resp term: lower is better, optional (needs BOTH the value and a baseline). - if let r = resp, let b = respBaseline { + if let r = resp, let b = respB { let z = zScore(b.baseline, mean: r, spread: b.spread) terms.append(("resp", z, wResp)) lines.append("charge term resp z=\(r2(z)) w=\(r2(wResp)) (lower resp is better)") diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/RecoveryScorer.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/RecoveryScorer.swift index 2a3354b558..360e07958e 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/RecoveryScorer.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/RecoveryScorer.swift @@ -391,8 +391,8 @@ public enum RecoveryScorer { return max(0.0, min(100.0, score)) } - /// Convenience overload taking BaselineState directly. Enforces the cold-start - /// gate using `hrvBaseline.usable`. + /// Convenience overload taking BaselineState directly. HRV must be usable for a score; + /// unusable optional personal baselines are omitted before weight renormalization. public static func recovery(hrv: Double, rhr: Double, resp: Double?, @@ -415,12 +415,12 @@ public enum RecoveryScorer { // 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), + respBaseline: respBaseline.flatMap { $0.usable ? $0 : nil }.map(DriverBaseline.init), sleepPerf: sleepPerf, skinTempDev: skinTempDev, hrvBaselineUsable: hrvBaseline.usable, recoveryIndexSlope: recoveryIndexSlope, - effortBaseline: effortBaseline.map(DriverBaseline.init), + effortBaseline: effortBaseline.flatMap { $0.usable ? $0 : nil }.map(DriverBaseline.init), priorDayEffort: priorDayEffort) } } diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HRVCleaningPreservationTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HRVCleaningPreservationTests.swift new file mode 100644 index 0000000000..fcfcb96b44 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HRVCleaningPreservationTests.swift @@ -0,0 +1,89 @@ +import XCTest +@testable import StrandAnalytics + +final class HRVCleaningPreservationTests: XCTestCase { + // Generated by compiling original Swift at 8e94d559. Pin raw Double bits and removed-beat adjacency. + private let originalOracle = """ +0|0|cbf29ce484222325|cbf29ce484222325|cbf29ce484222325 +1|0|cbf29ce484222325|cbf29ce484222325|cbf29ce484222325 +2|1|2568834c8601b7df|2568834c8601b7df|af63bd4c8601b7df +3|2|205d8607b4eb6fed|205d8607b4eb6fed|8328707b4eb6e3a +4|3|75289a186c0f2fb7|75289a186c0f2fb7|d949ad186c0c4e41 +5|4|2fe1767f9dce13f5|2fe1767f9dce13f5|447f607f98e8f6c0 +6|5|1d42a1d9252be94f|1d42a1d9252be94f|4d67b9d0d3db49f3 +7|6|8cf0b8fa299d713d|8cf0b8fa299d713d|628aafd7fd9ea636 +8|7|1016fa14b6876aa7|1016fa14b6876aa7|104f0303f4946f75 +9|3|34c79a186c0f2fb7|34c79a186c0f2fb7|d949ad186c0c4e41 +10|1|2532234c8601b7df|2532234c8601b7df|af63bd4c8601b7df +11|7|585f1a14b6876aa7|585f1a14b6876aa7|104f0303f4946f75 +12|3|30cc12186c0f2fb7|30cc12186c0f2fb7|d94d12186c0f2fb7 +13|4|c532367f9dce13f5|c532367f9dce13f5|4d25757f9dce1242 +14|300|ada53892fe7f4315|ada53892fe7f4315|ca507bed0f106018 +15|284|53f2a91c3fc4d5d5|53f2a91c3fc4d5d5|ea13e2a443f8348a +16|36000|39399142eeffafa5|39399142eeffafa5|66887b7d4316484 +17|34097|b9d60d46a4931f|b9d60d46a4931f|e1011fbbd685b430 +18|108000|4529d5bc622088a5|4529d5bc622088a5|275d0339a0103744 +19|102293|d16cc7e6a7b2c70f|d16cc7e6a7b2c70f|f9ef4947521f3074 +""" + + private func cleaningOracleHash(_ words: [UInt64]) -> String { + var hash: UInt64 = 14695981039346656037 + for word in words { hash = (hash ^ word) &* 1099511628211 } + return String(hash, radix: 16) + } + + private func cleaningOracleInput(_ count: Int, mixed: Bool) -> [Double] { + (0.. BaselineState { + BaselineState(baseline: mean, spread: spread, + nValid: status == .calibrating ? 0 : (status == .provisional ? 6 : 14), + nightsSinceUpdate: status == .stale ? 20 : 0, status: status) + } + + private var hrv: BaselineState { optionalBaselineFixture(50, 8) } + private var rhr: BaselineState { optionalBaselineFixture(60, 4) } + private let statuses: [BaselineStatus] = [.calibrating, .provisional, .trusted, .stale] + private let respiratoryValues: [Double?] = [nil, 10, 14, 18, 22] + private let effortValues: [Double?] = [nil, 0, 30, 60, 100] + + private func optionalChargeFixture(respBase: BaselineState?, effortBase: BaselineState? = nil, + resp: Double? = 14, effort: Double? = 60) -> Double? { + RecoveryScorer.recovery(hrv: 55, rhr: 58, resp: resp, + hrvBaseline: hrv, rhrBaseline: rhr, respBaseline: respBase, + sleepPerf: 0.85, skinTempDev: 0.2, recoveryIndexSlope: -0.5, + effortBaseline: effortBase, priorDayEffort: effort) + } + + func testUnusableRespirationScoresExactlyLikeAbsent() { + let states = [Baselines.foldHistory([], cfg: Baselines.respCfg), + Baselines.foldHistory([0, 100], cfg: Baselines.respCfg), + optionalBaselineFixture(16, 2, .stale)] + for base in states { + XCTAssertFalse(base.usable) + for value in respiratoryValues { + XCTAssertEqual(optionalChargeFixture(respBase: base, resp: value)?.bitPattern, + optionalChargeFixture(respBase: nil, resp: value)?.bitPattern) + } + } + } + + func testUnusableEffortScoresExactlyLikeAbsent() { + let states = [Baselines.foldHistory([], cfg: Baselines.strainCfg), + Baselines.foldHistory([-1, 101], cfg: Baselines.strainCfg), + optionalBaselineFixture(45, 10, .stale)] + for base in states { + XCTAssertFalse(base.usable) + for value in effortValues { + XCTAssertEqual(optionalChargeFixture(respBase: nil, effortBase: base, effort: value)?.bitPattern, + optionalChargeFixture(respBase: nil, effort: value)?.bitPattern) + } + } + } + + func testAllOptionalStateAndValuePairsMatchEligibleDriverContract() { + let respStates: [BaselineState?] = [nil] + statuses.map { optionalBaselineFixture(16, 2, $0) } + let effortStates: [BaselineState?] = [nil] + statuses.map { optionalBaselineFixture(45, 10, $0) } + var cases = 0 + var oracleLines: [String] = [] + for respBase in respStates { + for effortBase in effortStates { + for resp in respiratoryValues { + for effort in effortValues { + // The status-free API is the original math, with only eligible personal states supplied. + let expected = RecoveryScorer.recovery( + hrv: 55, rhr: 58, resp: resp, + hrvBaseline: RecoveryScorer.DriverBaseline(hrv), + rhrBaseline: RecoveryScorer.DriverBaseline(rhr), + respBaseline: respBase.flatMap { $0.usable ? RecoveryScorer.DriverBaseline($0) : nil }, + sleepPerf: 0.85, skinTempDev: 0.2, recoveryIndexSlope: -0.5, + effortBaseline: effortBase.flatMap { $0.usable ? RecoveryScorer.DriverBaseline($0) : nil }, + priorDayEffort: effort) + let actual = optionalChargeFixture(respBase: respBase, effortBase: effortBase, + resp: resp, effort: effort) + XCTAssertEqual(actual?.bitPattern, expected?.bitPattern) + if resp == 14, effort == 60 { + let bits = actual.map { String($0.bitPattern, radix: 16) } ?? "nil" + oracleLines.append("\(respBase?.status.rawValue ?? "nil")/\(effortBase?.status.rawValue ?? "nil")|\(bits)") + } + cases += 1 + } + } + } + } + XCTAssertEqual(cases, 625) + // Compiled original Swift output with only eligible states supplied, copied verbatim to Kotlin. + XCTAssertEqual(oracleLines.joined(separator: "\n"), """ + nil/nil|4051b9be0a68921e + nil/calibrating|4051b9be0a68921e + nil/provisional|405119a83eb9de93 + nil/trusted|405119a83eb9de93 + nil/stale|4051b9be0a68921e + calibrating/nil|4051b9be0a68921e + calibrating/calibrating|4051b9be0a68921e + calibrating/provisional|405119a83eb9de93 + calibrating/trusted|405119a83eb9de93 + calibrating/stale|4051b9be0a68921e + provisional/nil|4051e5d635c94772 + provisional/calibrating|4051e5d635c94772 + provisional/provisional|40514d26488c9eb1 + provisional/trusted|40514d26488c9eb1 + provisional/stale|4051e5d635c94772 + trusted/nil|4051e5d635c94772 + trusted/calibrating|4051e5d635c94772 + trusted/provisional|40514d26488c9eb1 + trusted/trusted|40514d26488c9eb1 + trusted/stale|4051e5d635c94772 + stale/nil|4051b9be0a68921e + stale/calibrating|4051b9be0a68921e + stale/provisional|405119a83eb9de93 + stale/trusted|405119a83eb9de93 + stale/stale|4051b9be0a68921e + """) + XCTAssertNotEqual(optionalChargeFixture(respBase: nil)?.bitPattern, + optionalChargeFixture(respBase: optionalBaselineFixture(16, 2))?.bitPattern) + XCTAssertNotEqual(optionalChargeFixture(respBase: nil)?.bitPattern, + optionalChargeFixture(respBase: nil, effortBase: optionalBaselineFixture(45, 10))?.bitPattern) + } + + func testRespirationRowsUseExactlyTheEligibleBaseline() { + for status in statuses { + let base = optionalBaselineFixture(16, 2, status) + for value in respiratoryValues { + let actual = RecoveryScorer.chargeDrivers(hrv: 55, rhr: 58, resp: value, + hrvBaseline: hrv, rhrBaseline: rhr, respBaseline: base, + sleepPerf: 0.85, skinTempDev: 0.2) + let expected = RecoveryScorer.chargeDrivers(hrv: 55, rhr: 58, resp: value, + hrvBaseline: hrv, rhrBaseline: rhr, respBaseline: base.usable ? base : nil, + sleepPerf: 0.85, skinTempDev: 0.2) + XCTAssertEqual(actual, expected) + XCTAssertEqual(actual.contains { $0.label == "Respiratory rate" }, base.usable && value != nil) + XCTAssertTrue(actual.contains { $0.label == "Heart rate variability" }) + } + } + } + + func testRespirationTraceUsesExactlyTheScoredTerms() { + for status in statuses { + let base = optionalBaselineFixture(16, 2, status) + for value in respiratoryValues { + let actual = RecoveryScorer.recoveryTrace(hrv: 55, rhr: 58, resp: value, + hrvBaseline: hrv, rhrBaseline: rhr, respBaseline: base, + sleepPerf: 0.85, skinTempDev: 0.2) + let expected = RecoveryScorer.recoveryTrace(hrv: 55, rhr: 58, resp: value, + hrvBaseline: hrv, rhrBaseline: rhr, respBaseline: base.usable ? base : nil, + sleepPerf: 0.85, skinTempDev: 0.2) + XCTAssertEqual(actual.score?.bitPattern, expected.score?.bitPattern) + XCTAssertEqual(actual.trace, expected.trace) + XCTAssertEqual(actual.trace.contains { $0.hasPrefix("charge term resp ") }, base.usable && value != nil) + XCTAssertEqual(actual.trace.contains { $0.hasPrefix("charge baseline resp ") }, base.usable) + XCTAssertEqual(actual.trace.contains { $0.hasPrefix("charge nilTerm dropped=") && $0.contains("resp") }, + !base.usable || value == nil) + } + } + } + + func testDominantHrvColdStartStillRefusesEveryOptionalState() { + for status in [BaselineStatus.calibrating, .stale] { + let coldHrv = optionalBaselineFixture(50, 8, status) + for optionalStatus in statuses { + let base = optionalBaselineFixture(16, 2, optionalStatus) + XCTAssertNil(RecoveryScorer.recovery(hrv: 55, rhr: 58, resp: 14, + hrvBaseline: coldHrv, rhrBaseline: rhr, respBaseline: base, sleepPerf: 0.85, + effortBaseline: optionalBaselineFixture(45, 10, optionalStatus), priorDayEffort: 60)) + XCTAssertTrue(RecoveryScorer.chargeDrivers(hrv: 55, rhr: 58, resp: 14, + hrvBaseline: coldHrv, rhrBaseline: rhr, respBaseline: base, sleepPerf: 0.85).isEmpty) + let trace = RecoveryScorer.recoveryTrace(hrv: 55, rhr: 58, resp: 14, + hrvBaseline: coldHrv, rhrBaseline: rhr, respBaseline: base, sleepPerf: 0.85) + XCTAssertNil(trace.score) + XCTAssertEqual(trace.trace.count, 1) + XCTAssertTrue(trace.trace.first?.contains("hrvBaselineNotUsable") ?? false) + } + } + } +} diff --git a/README.md b/README.md index 284d79d8ab..0590dc4148 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,11 @@ Pre-built apps you can run right now: Prefer to build it yourself? See [`docs/BUILD.md`](docs/BUILD.md). -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. +Strap collection and analytics work **offline**. NOOP operates no server, account or telemetry service. +The release check is **on by default** and sends no health data; turn off **Check automatically** +in Settings → About to stop automatic checks. Optional features can send data when configured. +See the canonical [network and privacy inventory](docs/PRIVACY_SECURITY.md#11-network-paths-canonical-inventory) +for their defaults, destinations and payloads, including platform services such as Apple's route map. --- @@ -543,11 +547,10 @@ export or optional network feature you deliberately configure, including Android 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). +The [canonical network inventory](docs/PRIVACY_SECURITY.md#11-network-paths-canonical-inventory) +explains what each feature sends and how to control it. The default-on release check sends no +health data and never installs an update. AI requests can include health summaries with your +consent; exports and platform services have their own boundaries. --- diff --git a/Strand/App/RootView.swift b/Strand/App/RootView.swift index 2250c98e8a..e655264dcf 100644 --- a/Strand/App/RootView.swift +++ b/Strand/App/RootView.swift @@ -560,8 +560,11 @@ private struct SidebarStatus: View { // 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. + // The percent goes through the same rounding seam as the Devices card, so the two never + // read 34% and 35% for one charge (this used to truncate). Text(live.connected && live.activeIsWhoop - ? live.batteryPct.map { String(localized: "Battery \(Int($0))%") } ?? String(localized: "Strap not connected") + ? LiveConsoleReadout.batteryPercent(activeIsWhoop: true, whoopPct: live.batteryPct, ringPct: nil) + .map { String(localized: "Battery \($0)%") } ?? String(localized: "Strap not connected") : String(localized: "Strap not connected")) .font(StrandFont.rounded(11)) .foregroundStyle(StrandPalette.textTertiary) diff --git a/Strand/Data/Repository.swift b/Strand/Data/Repository.swift index 11fd7ead0b..bfe7ba7034 100644 --- a/Strand/Data/Repository.swift +++ b/Strand/Data/Repository.swift @@ -1223,9 +1223,10 @@ final class Repository: ObservableObject { /// materialized, against the per-day row fetches a caller would otherwise repeat. Compared only to /// itself in memory, so the format is free to change. Kotlin twin: /// `WhoopRepository.hrUnionFingerprint`, which is a twin in ROLE only: each side compares its own - /// value against its own previous value, neither is persisted or sent anywhere, and the two encode - /// the same facts differently. There is no byte-identity contract here and no oracle asserting one, - /// so do not "align" the encodings on the assumption that there is. + /// value against its own previous value, and the two encode the same facts differently. This one is + /// never stored or sent anywhere; the Kotlin side does persist one, for a widget worker that has no + /// process memory between wakes (#2710). There is no byte-identity contract here and no oracle + /// asserting one, so do not "align" the encodings on the assumption that there is. func hrFingerprintUnion(from: Int, to: Int) async -> String { guard let store = await ensureStore() else { return "" } var parts: [String] = [] @@ -1254,25 +1255,6 @@ 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] { diff --git a/Strand/Data/StressDayCurve.swift b/Strand/Data/StressDayCurve.swift index b14381e724..e11c8024e3 100644 --- a/Strand/Data/StressDayCurve.swift +++ b/Strand/Data/StressDayCurve.swift @@ -9,8 +9,9 @@ import StrandAnalytics /// 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. +/// union heart-rate witness — one COUNT and one MAX per source id, over an indexed column, no rows — +/// says today's heart rate actually moved. A publish that changed nothing costs those queries and +/// reuses the previous curve. /// /// SCORING MODE. Background callers keep the `.dayRelative` default: resolving the personal lens reads /// trailing days of heart rate, which a foreground screen can afford and an unprompted publish cannot. @@ -25,8 +26,7 @@ enum StressDayCurve { /// 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 fingerprint: String let day: Int let personalBaseline: Bool let result: DaytimeStress.Result @@ -61,14 +61,21 @@ enum StressDayCurve { let to = Int(now.timeIntervalSince1970) let day = localDayNumber(now, calendar: calendar) - guard let fingerprint = await repo.hrFingerprint(from: from, to: to) else { return nil } + // UNION, and per source id rather than collapsed into one pair. A gate narrower than the read + // it guards is worse than none: `hrSamples` below walks every raw source, so a witness that + // summed those ids could hold steady while the window's composition changed under it. This is + // the witness `DaytimeStressMode` already gates on and the Kotlin producer's twin of it. + // + // An EMPTY string means there is no store yet, which a caller must read as "cannot tell" + // rather than "unchanged": nil here is the same refusal the summed witness returned. + let fingerprint = await repo.hrFingerprintUnion(from: from, to: to) + guard !fingerprint.isEmpty 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. // Foreground Today and the widget publisher can call in either order. Include the requested // lens so an unchanged HR fingerprint can never replay one surface's result into the other. - if let memo = memos[personalBaseline], memo.day == day, - memo.count == fingerprint.count, memo.maxTs == fingerprint.maxTs { + if let memo = memos[personalBaseline], memo.day == day, memo.fingerprint == fingerprint { return (memo.result, day) } @@ -112,7 +119,7 @@ enum StressDayCurve { } // 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. - memos[personalBaseline] = Memo(count: fingerprint.count, maxTs: fingerprint.maxTs, day: day, + memos[personalBaseline] = Memo(fingerprint: fingerprint, day: day, personalBaseline: personalBaseline, result: scored) return (scored, day) } diff --git a/Strand/Data/StressLoadCancellation.swift b/Strand/Data/StressLoadCancellation.swift new file mode 100644 index 0000000000..2eb173d946 --- /dev/null +++ b/Strand/Data/StressLoadCancellation.swift @@ -0,0 +1,14 @@ +import Foundation + +/// A cancelled screen load must not publish a result from a read or detached calculation that kept +/// running. Check both sides of the suspension so obsolete loads also stop before their next read. +/// Kotlin twin: `com.noop.ui.StressLoadCancellation`. +@MainActor +enum StressLoadCancellation { + static func read(_ work: () async -> Value) async -> Value? { + guard !Task.isCancelled else { return nil } + let value = await work() + guard !Task.isCancelled else { return nil } + return value + } +} diff --git a/Strand/Onboarding/OnboardingWizard.swift b/Strand/Onboarding/OnboardingWizard.swift index e91d24f4af..fb8fdb0002 100644 --- a/Strand/Onboarding/OnboardingWizard.swift +++ b/Strand/Onboarding/OnboardingWizard.swift @@ -715,8 +715,9 @@ private struct BondedStep: View { } private var batteryLine: String { - if let pct = live.batteryPct { - return String(localized: "Your strap is bonded · \(Int(pct))% battery.") + // Rounded through the shared seam so onboarding and the Devices card agree on one charge. + if let pct = LiveConsoleReadout.batteryPercent(activeIsWhoop: true, whoopPct: live.batteryPct, ringPct: nil) { + return String(localized: "Your strap is bonded · \(pct)% battery.") } return String(localized: "Your strap is bonded and ready to stream.") } diff --git a/Strand/Screens/StressView.swift b/Strand/Screens/StressView.swift index 553056c699..7ead63dbae 100644 --- a/Strand/Screens/StressView.swift +++ b/Strand/Screens/StressView.swift @@ -96,7 +96,10 @@ struct StressView: View { } private func load() async { - storedSeries = await repo.series(key: "stress", source: "my-whoop") + guard let series = await StressLoadCancellation.read({ + await repo.series(key: "stress", source: "my-whoop") + }) else { return } + storedSeries = series loaded = true rebuildModelIfNeeded() await loadDaytime() @@ -112,7 +115,9 @@ struct StressView: View { let to = Int(Date().timeIntervalSince1970) let tz = TimeZone.current.secondsFromGMT(for: Date()) - let hr = await repo.hrSamples(from: from, to: to, limit: 200_000) + guard let hr = await StressLoadCancellation.read({ + await repo.hrSamples(from: from, to: to, limit: 200_000) + }) else { return } // Too few HR samples: empty the timeline AND clear the advanced readouts in lockstep. Without this // reset a later refresh that hits this path would leave the Advanced HRV card showing stale values // next to an empty timeline (the readouts are only recomputed past this guard). @@ -122,11 +127,15 @@ struct StressView: View { freqHRV = nil return } - let rr = await repo.rrIntervals(from: from, to: to, limit: 200_000) + guard let rr = await StressLoadCancellation.read({ + await repo.rrIntervals(from: from, to: to, limit: 200_000) + }) else { return } // 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 = await repo.gravitySamplesUnion(from: from, to: to, limit: 200_000) + guard let gravity = await StressLoadCancellation.read({ + await repo.gravitySamplesUnion(from: from, to: to, limit: 200_000) + }) else { return } // 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 @@ -135,14 +144,14 @@ struct StressView: View { // chooseable lens, not a silent default. The mode is resolved only AFTER the HR-count guard above, // so the trailing-history reads are never paid on a day with no scorable timeline — and are never // paid at all while the toggle is OFF (the default), keeping the read byte-identical to before. - let mode = await DaytimeStressMode.selected( - repo: repo, - startOfToday: startOfDay, - calendar: cal, - personalBaseline: PuffinExperiment.stressPersonalBaselineEnabled - ) - if case .baselineRelative = mode { daytimeUsesPersonalBaseline = true } - else { daytimeUsesPersonalBaseline = false } + guard let mode = await StressLoadCancellation.read({ + await DaytimeStressMode.selected( + repo: repo, + startOfToday: startOfDay, + calendar: cal, + personalBaseline: PuffinExperiment.stressPersonalBaselineEnabled + ) + }) else { return } // 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 @@ -159,10 +168,16 @@ struct StressView: View { // 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) - } + guard let timeline = await StressLoadCancellation.read({ + await runUnescalated(priority: .userInitiated) { + DaytimeStress.analyze(hr: hr, rr: rr, gravity: gravity, tzOffsetSeconds: tz, mode: mode, + includeTimeline: true) + } + }) else { return } + daytime = timeline + // Publish the mode with the timeline it describes, after the same cancellation check. + if case .baselineRelative = mode { daytimeUsesPersonalBaseline = true } + else { daytimeUsesPersonalBaseline = false } // 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 @@ -177,9 +192,11 @@ struct StressView: View { // 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)) - } + guard let advanced = await StressLoadCancellation.read({ + await runUnescalated { + (index: StressIndex.components(rr: rr), freq: HRVFreqDomain.freqDomain(rr: rr)) + } + }) else { return } stressIndex = advanced.index freqHRV = advanced.freq } diff --git a/Strand/Screens/TodayView.swift b/Strand/Screens/TodayView.swift index b59a6fbd82..cd62425ed4 100644 --- a/Strand/Screens/TodayView.swift +++ b/Strand/Screens/TodayView.swift @@ -3204,6 +3204,18 @@ struct TodayView: View { } heroRingColumn(section: .effort, domain: .effort, detailRoute: .metric(HeroRingMetric.effort)) { effortRing(d: d, diameter: ring) } + // Use the existing metadata/gap beneath Effort without changing the row's height. + .overlay(alignment: .bottom) { + Image(systemName: "arrow.triangle.2.circlepath") + .font(StrandFont.caption.weight(.semibold)) + .foregroundStyle(StrandPalette.accent) + .frame(width: NoopMetrics.space6, height: NoopMetrics.space3) + .offset(y: NoopMetrics.space3) + .opacity(restIsPendingSync ? 1 : 0) + .help("Pending sync · strap history still offloading") + .accessibilityLabel("Pending sync · strap history still offloading") + .accessibilityHidden(!restIsPendingSync) + } // `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", @@ -3303,22 +3315,10 @@ struct TodayView: View { // Charge ring). Charge's chevron opens the "what shaped it" breakdown (its richest explanation); // Effort / Rest open their scoring-guide section. HStack(spacing: 3) { - // Keep the balancing chevron's existing slot; a sync cue must not move the Rest label. Image(systemName: "chevron.right") .font(.system(size: 9, weight: .bold)) .opacity(0) .accessibilityHidden(true) - .overlay { - if domain == .rest { - Image(systemName: "arrow.triangle.2.circlepath") - .font(StrandFont.overline) - .foregroundStyle(StrandPalette.textTertiary) - .opacity(restIsPendingSync ? 1 : 0) - .help("Pending sync · strap history still offloading") - .accessibilityLabel("Pending sync · strap history still offloading") - .accessibilityHidden(!restIsPendingSync) - } - } Button { if let onOpenBreakdown { onOpenBreakdown() } else { guideSection = section } } label: { HStack(spacing: 3) { // The catalog stores the natural-case domain word; uppercase follows its locale. diff --git a/StrandTests/SmartAlarmWeekdayTests.swift b/StrandTests/SmartAlarmWeekdayTests.swift index a77c489eb3..faed13f6be 100644 --- a/StrandTests/SmartAlarmWeekdayTests.swift +++ b/StrandTests/SmartAlarmWeekdayTests.swift @@ -27,6 +27,44 @@ final class SmartAlarmWeekdayTests: XCTestCase { // MARK: nextSmartAlarmDate + func testDailyScheduleCoversEveryWakeMinuteAndRequiresAStrictlyFutureFire() { + let start = 1_781_654_400 // 2026-06-17 00:00 UTC + let now = 1_781_699_696 // 2026-06-17 12:34:56 UTC + for minute in 0..<1440 { + let today = start + minute * 60 + let expected = today <= now ? today + 86_400 : today + let next = AppModel.nextSmartAlarmDate(minutes: minute, weekdays: [], + from: Date(timeIntervalSince1970: Double(now)), calendar: cal) + XCTAssertEqual(next?.timeIntervalSince1970, Double(expected), "minute \(minute)") + let exact = AppModel.nextSmartAlarmDate(minutes: minute, weekdays: [], + from: Date(timeIntervalSince1970: Double(today)), calendar: cal) + XCTAssertEqual(exact?.timeIntervalSince1970, Double(today + 86_400), + "exact wake minute \(minute) must roll to tomorrow") + } + } + + func testDailySpringRolloverKeepsLocalWakeTimeAcrossTheShortDay() { + var calendar = cal + calendar.timeZone = TimeZone(identifier: "America/Chicago")! + let now = 1_772_892_000 // 2026-03-07 08:00 CST + let expected = 1_772_974_800 // 2026-03-08 08:00 CDT, 23 hours later + let next = AppModel.nextSmartAlarmDate(minutes: 8 * 60, weekdays: [], + from: Date(timeIntervalSince1970: Double(now)), calendar: calendar) + XCTAssertEqual(next?.timeIntervalSince1970, Double(expected)) + XCTAssertEqual(expected - now, 23 * 3600) + } + + func testDailyAutumnRolloverKeepsLocalWakeTimeAcrossTheLongDay() { + var calendar = cal + calendar.timeZone = TimeZone(identifier: "America/Chicago")! + let now = 1_793_451_600 // 2026-10-31 08:00 CDT + let expected = 1_793_541_600 // 2026-11-01 08:00 CST, 25 hours later + let next = AppModel.nextSmartAlarmDate(minutes: 8 * 60, weekdays: [], + from: Date(timeIntervalSince1970: Double(now)), calendar: calendar) + XCTAssertEqual(next?.timeIntervalSince1970, Double(expected)) + XCTAssertEqual(expected - now, 25 * 3600) + } + func testEveryDay_emptySet_picksTodayWhenTimeStillAhead() { // now = Wed 06:00, wake at 07:00 → same day, 07:00. let next = AppModel.nextSmartAlarmDate(minutes: 7 * 60, weekdays: [], from: wed(6, 0), calendar: cal) diff --git a/StrandTests/StressLoadCancellationTests.swift b/StrandTests/StressLoadCancellationTests.swift new file mode 100644 index 0000000000..6a80245cd1 --- /dev/null +++ b/StrandTests/StressLoadCancellationTests.swift @@ -0,0 +1,116 @@ +import XCTest +@testable import Strand + +@MainActor +final class StressLoadCancellationTests: XCTestCase { + func testSuccessfulReadAndTwoPhaseOrderStayIntact() async { + var phases: [Int] = [] + if let core = await StressLoadCancellation.read({ 21 }) { phases.append(core) } + if let lenses = await StressLoadCancellation.read({ 34 }) { phases.append(lenses) } + XCTAssertEqual(phases, [21, 34]) + } + + func testSuccessfulAbsentValueIsNotCancellation() async { + let result: Int?? = await StressLoadCancellation.read({ nil as Int? }) + XCTAssertEqual(result, .some(.none)) + } + + func testAlreadyCancelledLoadDoesNotStartARead() async { + var reads = 0 + let task = Task { @MainActor in + await StressLoadCancellation.read { + reads += 1 + return 7 + } + } + task.cancel() + let result = await task.value + XCTAssertNil(result) + XCTAssertEqual(reads, 0) + } + + func testLateCancelledReadCannotReplaceFreshState() async { + let gate = StressFixtureSuspension() + var published: [String] = [] + let old = Task { @MainActor in + if let value = await StressLoadCancellation.read({ + await gate.holdStressFixture() + return "old" + }) { published.append(value) } + } + await gate.waitForStressFixture() + old.cancel() + if let fresh = await StressLoadCancellation.read({ "fresh" }) { published.append(fresh) } + await gate.releaseStressFixture() + await old.value + XCTAssertEqual(published, ["fresh"]) + } + + func testCancelledCoreDoesNotStartAdvancedWork() async { + let gate = StressFixtureSuspension() + var advancedReads = 0 + var published: [Int] = [] + let task = Task { @MainActor in + guard let core = await StressLoadCancellation.read({ + await gate.holdStressFixture() + return 1 + }) else { return } + published.append(core) + if let lenses = await StressLoadCancellation.read({ + advancedReads += 1 + return 2 + }) { published.append(lenses) } + } + await gate.waitForStressFixture() + task.cancel() + await gate.releaseStressFixture() + await task.value + XCTAssertEqual(published, []) + XCTAssertEqual(advancedReads, 0) + } + + func testLateAdvancedReadCannotRestoreOldLenses() async { + let gate = StressFixtureSuspension() + var published: [String] = [] + let old = Task { @MainActor in + guard let core = await StressLoadCancellation.read({ "old-core" }) else { return } + published.append(core) + if let lenses = await StressLoadCancellation.read({ + await gate.holdStressFixture() + return "old-lenses" + }) { published.append(lenses) } + } + await gate.waitForStressFixture() + old.cancel() + if let fresh = await StressLoadCancellation.read({ "fresh-core" }) { published.append(fresh) } + await gate.releaseStressFixture() + await old.value + XCTAssertEqual(published, ["old-core", "fresh-core"]) + } +} + +/// Deliberately ignores task cancellation, like a detached calculation or a completed database read. +private actor StressFixtureSuspension { + private var held: CheckedContinuation? + private var waiting: CheckedContinuation? + private var started = false + + func holdStressFixture() async { + await withCheckedContinuation { continuation in + held = continuation + started = true + waiting?.resume() + waiting = nil + } + } + + func waitForStressFixture() async { + if started { return } + await withCheckedContinuation { waiting = $0 } + } + + func releaseStressFixture() { + held?.resume() + held = nil + } +} diff --git a/StrandTests/StressPersonalBaselineSurfaceTests.swift b/StrandTests/StressPersonalBaselineSurfaceTests.swift index f75334cd18..2e5849335c 100644 --- a/StrandTests/StressPersonalBaselineSurfaceTests.swift +++ b/StrandTests/StressPersonalBaselineSurfaceTests.swift @@ -18,7 +18,14 @@ final class StressPersonalBaselineSurfaceTests: XCTestCase { let today = try source("Strand/Screens/TodayView.swift") let liquidToday = try source("Strand/Liquid/LiquidTodayView.swift") - XCTAssertTrue(detail.contains("let mode = await DaytimeStressMode.selected(")) + XCTAssertNotNil(detail.range( + of: #"DaytimeStressMode\.selected\([\s\S]*?personalBaseline:\s*PuffinExperiment\.stressPersonalBaselineEnabled"#, + options: .regularExpression + )) + XCTAssertNotNil(detail.range( + of: #"DaytimeStress\.analyze\([\s\S]*?mode:\s*mode,"#, + options: .regularExpression + )) XCTAssertTrue(producer.contains("let mode = await DaytimeStressMode.selected(")) XCTAssertTrue(producer.contains("tzOffsetSeconds: tz, mode: mode,")) for body in [today, liquidToday] { diff --git a/StrandiOSWidgets/LiftLiveActivity.swift b/StrandiOSWidgets/LiftLiveActivity.swift index 3ef949e124..a73be55c9e 100644 --- a/StrandiOSWidgets/LiftLiveActivity.swift +++ b/StrandiOSWidgets/LiftLiveActivity.swift @@ -55,21 +55,26 @@ struct LiftLiveActivity: Widget { } } } 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") + if let bpm = context.state.bpm { + HStack(spacing: NoopMetrics.space1) { + Image(systemName: "heart.fill") + .resizable().scaledToFit() + .frame(width: NoopMetrics.space4, height: NoopMetrics.space4) + Text(bpm, format: .number).monospacedDigit() + } + .font(StrandFont.captionNumber) + .foregroundStyle(StrandPalette.metricRose) + .frame(height: NoopMetrics.space6) + } else { + WorkoutTypeIcon(workoutType: .strength, size: NoopMetrics.space4, color: tint) + .frame(width: NoopMetrics.space6, height: NoopMetrics.space6) } - .font(StrandFont.captionNumber) - .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 + // to their right (Utku, 22 Sep 2026). A hidden "0: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") + Text(verbatim: "0:00:00") .font(StrandFont.captionNumber) .monospacedDigit() .hidden() @@ -77,10 +82,17 @@ struct LiftLiveActivity: Widget { view.clock .font(StrandFont.captionNumber) .multilineTextAlignment(.trailing) + .lineLimit(1).minimumScaleFactor(0.8) } + .frame(height: NoopMetrics.space6) } minimal: { - Image(systemName: context.state.training?.pausedAt == nil ? "dumbbell.fill" : "pause.fill").foregroundStyle(tint) + Image(systemName: context.state.training?.pausedAt == nil ? "dumbbell.fill" : "pause.fill") + .resizable().scaledToFit() + .frame(width: NoopMetrics.space4, height: NoopMetrics.space4) + .foregroundStyle(tint) + .frame(width: NoopMetrics.space6, height: NoopMetrics.space6) } + .contentMargins(.horizontal, NoopMetrics.space4, for: .expanded) } } } diff --git a/StrandiOSWidgets/WorkoutLiveActivity.swift b/StrandiOSWidgets/WorkoutLiveActivity.swift index 39b28fef74..3c476c7262 100644 --- a/StrandiOSWidgets/WorkoutLiveActivity.swift +++ b/StrandiOSWidgets/WorkoutLiveActivity.swift @@ -29,16 +29,20 @@ struct WorkoutLiveActivity: Widget { } compactLeading: { WorkoutTypeIcon(workoutType: context.state.training.sport ?? KnownWorkoutType.other.rawValue, size: NoopMetrics.space4, color: StrandPalette.accent) + .frame(width: NoopMetrics.space6, height: NoopMetrics.space6) } compactTrailing: { Text(verbatim: "0:00:00").font(StrandFont.captionNumber).hidden() .overlay(alignment: .trailing) { TrainingClock(training: context.state.training).font(StrandFont.captionNumber) .multilineTextAlignment(.trailing).lineLimit(1).minimumScaleFactor(0.8) } + .frame(height: NoopMetrics.space6) } minimal: { WorkoutTypeIcon(workoutType: context.state.training.sport ?? KnownWorkoutType.other.rawValue, size: NoopMetrics.space4, color: StrandPalette.accent) + .frame(width: NoopMetrics.space6, height: NoopMetrics.space6) } + .contentMargins(.horizontal, NoopMetrics.space4, for: .expanded) } } } diff --git a/altstore-source.json b/altstore-source.json index a429eb1914..de40713f94 100644 --- a/altstore-source.json +++ b/altstore-source.json @@ -19,6 +19,15 @@ "category": "healthcare-fitness", "screenshotURLs": [], "versions": [ + { + "version": "12.0.0", + "buildVersion": "435", + "date": "2026-10-04", + "localizedDescription": "NOOP 12.0.0. See the release notes for what changed.", + "downloadURL": "https://github.com/ryanbr/noop/releases/download/v12.0.0/NOOP-ios-unsigned-v12.0.0.ipa", + "size": 21936724, + "minOSVersion": "17.0" + }, { "version": "11.8.0", "buildVersion": "400", @@ -1447,12 +1456,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": "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, + "version": "12.0.0", + "buildVersion": "435", + "versionDate": "2026-10-04", + "versionDescription": "NOOP 12.0.0. See the release notes for what changed.", + "downloadURL": "https://github.com/ryanbr/noop/releases/download/v12.0.0/NOOP-ios-unsigned-v12.0.0.ipa", + "size": 21936724, "minOSVersion": "17.0" } ], 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 d29dcf3e33..97cae846a1 100644 --- a/android/app/src/main/java/com/noop/analytics/HrvAnalyzer.kt +++ b/android/app/src/main/java/com/noop/analytics/HrvAnalyzer.kt @@ -160,10 +160,11 @@ object HrvAnalyzer { fun rejectEctopic(nn: List): List { if (nn.size <= ECTOPIC_WINDOW_RADIUS) return nn val kept = ArrayList(nn.size) + val neighbours = ArrayList(2 * ECTOPIC_WINDOW_RADIUS) for (i in nn.indices) { val lo = maxOf(0, i - ECTOPIC_WINDOW_RADIUS) val hi = minOf(nn.size - 1, i + ECTOPIC_WINDOW_RADIUS) - val neighbours = ArrayList(hi - lo) + neighbours.clear() for (j in lo..hi) { if (j != i) neighbours.add(nn[j]) } @@ -221,10 +222,11 @@ object HrvAnalyzer { // rejectEctopic returns the input unchanged for a series this short. for (k in rangedVal.indices) { keptOrig.add(rangedIdx[k]); keptVal.add(rangedVal[k]) } } else { + val neighbours = ArrayList(2 * ECTOPIC_WINDOW_RADIUS) for (i in rangedVal.indices) { val lo = maxOf(0, i - ECTOPIC_WINDOW_RADIUS) val hi = minOf(rangedVal.size - 1, i + ECTOPIC_WINDOW_RADIUS) - val neighbours = ArrayList(hi - lo) + neighbours.clear() for (j in lo..hi) if (j != i) neighbours.add(rangedVal[j]) val keep = when { neighbours.size < 2 -> true 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 cfcb67432e..8a233c32fd 100644 --- a/android/app/src/main/java/com/noop/analytics/RecoveryDrivers.kt +++ b/android/app/src/main/java/com/noop/analytics/RecoveryDrivers.kt @@ -124,10 +124,12 @@ object RecoveryDrivers { // score and row alike, sees the same thing. Named rather than shadowing the parameter, which // would warn. val rhrB = rhrBaseline?.takeIf { it.usable } + // Only eligible personal baselines may appear in respiration rows or diagnostic terms. + val respB = respBaseline?.takeIf { it.usable } val full = RecoveryScorer.recovery( hrv = hrv, rhr = rhr, resp = resp, hrvBaseline = hrvBaseline, rhrBaseline = rhrB, - respBaseline = respBaseline, sleepPerf = sleepPerf, skinTempDev = skinTempDev, + respBaseline = respB, sleepPerf = sleepPerf, skinTempDev = skinTempDev, ) ?: return emptyList() // Marginal-vs-neutral attribution: a term's deltaPoints is the full score minus the score @@ -161,7 +163,7 @@ object RecoveryDrivers { RecoveryScorer.recovery( hrv = hrvBaseline.baseline, rhr = rhr, resp = resp, hrvBaseline = hrvBaseline, rhrBaseline = rhrB, - respBaseline = respBaseline, sleepPerf = sleepPerf, skinTempDev = skinTempDev, + respBaseline = respB, sleepPerf = sleepPerf, skinTempDev = skinTempDev, ), ) drivers.add( @@ -185,7 +187,7 @@ object RecoveryDrivers { RecoveryScorer.recovery( hrv = hrv, rhr = rhrB.baseline, resp = resp, hrvBaseline = hrvBaseline, rhrBaseline = rhrB, - respBaseline = respBaseline, sleepPerf = sleepPerf, skinTempDev = skinTempDev, + respBaseline = respB, sleepPerf = sleepPerf, skinTempDev = skinTempDev, ), ) drivers.add( @@ -208,7 +210,7 @@ object RecoveryDrivers { RecoveryScorer.recovery( hrv = hrv, rhr = rhr, resp = resp, hrvBaseline = hrvBaseline, rhrBaseline = rhrB, - respBaseline = respBaseline, sleepPerf = RecoveryScorer.sleepPerfCenter, + respBaseline = respB, sleepPerf = RecoveryScorer.sleepPerfCenter, skinTempDev = skinTempDev, ), ), @@ -220,12 +222,12 @@ object RecoveryDrivers { ) } // Respiration (lower vs baseline supports recovery). Neutral = respiration at the baseline mean. - if (resp != null && respBaseline != null) { + if (resp != null && respB != null) { val respPoints = points( RecoveryScorer.recovery( - hrv = hrv, rhr = rhr, resp = respBaseline.baseline, + hrv = hrv, rhr = rhr, resp = respB.baseline, hrvBaseline = hrvBaseline, rhrBaseline = rhrB, - respBaseline = respBaseline, sleepPerf = sleepPerf, skinTempDev = skinTempDev, + respBaseline = respB, sleepPerf = sleepPerf, skinTempDev = skinTempDev, ), ) drivers.add( @@ -233,10 +235,10 @@ object RecoveryDrivers { label = ChargeDriverLabel.RESPIRATORY_RATE, deltaPoints = respPoints, value = resp, - baseline = respBaseline.baseline, + baseline = respB.baseline, unit = ChargeDriverUnit.BREATHS_PER_MINUTE, verdict = respVerdict( - value = resp, baseline = respBaseline.baseline, deltaPoints = respPoints, + value = resp, baseline = respB.baseline, deltaPoints = respPoints, ), ), ) @@ -248,7 +250,7 @@ object RecoveryDrivers { RecoveryScorer.recovery( hrv = hrv, rhr = rhr, resp = resp, hrvBaseline = hrvBaseline, rhrBaseline = rhrB, - respBaseline = respBaseline, sleepPerf = sleepPerf, skinTempDev = 0.0, + respBaseline = respB, sleepPerf = sleepPerf, skinTempDev = 0.0, ), ) drivers.add( 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 299037668e..e0ffef16c9 100644 --- a/android/app/src/main/java/com/noop/analytics/RecoveryScorer.kt +++ b/android/app/src/main/java/com/noop/analytics/RecoveryScorer.kt @@ -404,8 +404,9 @@ object RecoveryScorer { } /** - * Convenience overload taking [BaselineState] directly. Enforces the cold-start - * gate using `hrvBaseline.usable`. Mirrors the Swift `recovery(...)` overload. + * Convenience overload taking [BaselineState] directly. HRV must be usable for a score; + * unusable optional personal baselines are omitted before weight renormalization. + * Mirrors the Swift `recovery(...)` overload. */ fun recovery( hrv: Double, @@ -431,12 +432,12 @@ object RecoveryScorer { // 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) }, + respBaseline = respBaseline?.takeIf { it.usable }?.let { DriverBaseline(it) }, sleepPerf = sleepPerf, skinTempDev = skinTempDev, hrvBaselineUsable = hrvBaseline.usable, recoveryIndexSlope = recoveryIndexSlope, - effortBaseline = effortBaseline?.let { DriverBaseline(it) }, + effortBaseline = effortBaseline?.takeIf { it.usable }?.let { DriverBaseline(it) }, priorDayEffort = priorDayEffort, ) } 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 7925bf44a9..c272fe0283 100644 --- a/android/app/src/main/java/com/noop/analytics/RecoveryScorerTrace.kt +++ b/android/app/src/main/java/com/noop/analytics/RecoveryScorerTrace.kt @@ -75,12 +75,14 @@ object RecoveryScorerTrace { // 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 } + // Only eligible personal baselines may appear in respiration rows or diagnostic terms. + val respB = respBaseline?.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 = rhrB, - respBaseline = respBaseline, sleepPerf = sleepPerf, skinTempDev = skinTempDev, + respBaseline = respB, sleepPerf = sleepPerf, skinTempDev = skinTempDev, ) // Cold-start gate: HRV baseline not usable -> recovery() returns null before any term is built. @@ -104,7 +106,7 @@ object RecoveryScorerTrace { "nValid=${b.nValid} status=${b.status.raw}", ) } - respBaseline?.let { b -> + respB?.let { b -> lines.add( "charge baseline resp mean=${r2(b.baseline)} spread=${r2(b.spread)} " + "nValid=${b.nValid} status=${b.status.raw}", @@ -140,8 +142,8 @@ object RecoveryScorerTrace { } // Resp term: lower is better, optional (needs BOTH the value and a baseline). - if (resp != null && respBaseline != null) { - val z = RecoveryScorer.zScore(respBaseline.baseline, resp, respBaseline.spread) + if (resp != null && respB != null) { + val z = RecoveryScorer.zScore(respB.baseline, resp, respB.spread) terms.add(z to RecoveryScorer.wResp) lines.add("charge term resp z=${r2(z)} w=${r2(RecoveryScorer.wResp)} (lower resp is better)") } else { 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 a62b4680c5..3551960cde 100644 --- a/android/app/src/main/java/com/noop/data/WhoopRepository.kt +++ b/android/app/src/main/java/com/noop/data/WhoopRepository.kt @@ -1181,14 +1181,18 @@ class WhoopRepository( * * Cost is one COUNT plus one MAX per id via [hrFingerprintWindow], index range walks that materialise * no rows, against the full per-day row fetches a caller would otherwise repeat. Compared only to - * itself in memory, so the format is free to change, and no caller persists it. The Swift + * itself, so the format carries no contract beyond equality. One caller does persist a value + * ([com.noop.widget.StressWidgetRefresh.storedFingerprint], whose worker has no process memory + * between wakes), and a stored value only ever compares equal to one written in the same format, so + * changing the format costs one extra scoring pass and can never produce a false hit. The Swift * `Repository.hrFingerprintUnion` is a twin in ROLE only, encoding the same facts differently; there * is no byte-identity contract between them and no oracle asserting one. * - * The single union witness for both callers (#2566): the cycle load cache in - * [com.noop.analytics.PhysiologicalStepCycleEngine] and the daytime stress lens memo in - * [com.noop.ui.selectedDaytimeStressMode]. Two of these that were free to disagree is what #2566 - * removed, so route a new caller here rather than adding a third. + * The single union witness for every caller (#2566): the cycle load cache in + * [com.noop.analytics.PhysiologicalStepCycleEngine], the daytime stress lens memo in + * [com.noop.ui.selectedDaytimeStressMode], the Stress screen's periodic re-read gate and the widget + * refresh worker's stored gate (#2710). Two of these that were free to disagree is what #2566 + * removed, so route a new caller here rather than adding another. */ suspend fun hrUnionFingerprint(activeDeviceId: String, from: Long, to: Long): String { // An explicit loop rather than joinToString: the per-id read suspends and that builder's lambda diff --git a/android/app/src/main/java/com/noop/protocol/Crc.kt b/android/app/src/main/java/com/noop/protocol/Crc.kt index ec6e9bd01e..2525482293 100644 --- a/android/app/src/main/java/com/noop/protocol/Crc.kt +++ b/android/app/src/main/java/com/noop/protocol/Crc.kt @@ -1,9 +1,11 @@ package com.noop.protocol +import java.util.zip.CRC32 + /** * Frame checksums for the WHOOP wire protocol. * - * Ported verbatim from the hardware-verified Swift reference (Framing.swift): + * Matches the hardware-verified Swift reference (Framing.swift): * - [crc8]: CRC-8 (poly 0x07), table-driven — guards the Whoop 4.0 frame length header. * - [crc32]: standard zlib CRC-32 (reflected, poly 0xEDB88320) — guards the frame payload. * - [crc16Modbus]: CRC16-Modbus (poly 0xA001, init 0xFFFF, reflected) — Whoop 5.0 header check. @@ -33,17 +35,6 @@ object Crc { 0xDE, 0xD9, 0xD0, 0xD7, 0xC2, 0xC5, 0xCC, 0xCB, 0xE6, 0xE1, 0xE8, 0xEF, 0xFA, 0xFD, 0xF4, 0xF3, ) - // Standard zlib CRC-32 table (reflected, poly 0xEDB88320), built once at class init. - private val crc32Table: LongArray = LongArray(256).also { table -> - for (i in 0 until 256) { - var c = i.toLong() and 0xFFFFFFFFL - repeat(8) { - c = if ((c and 1L) != 0L) 0xEDB88320L xor (c ushr 1) else (c ushr 1) - } - table[i] = c and 0xFFFFFFFFL - } - } - /** * CRC-8 (poly 0x07) over `data[from until to]`. Returns a value in 0..255. The [from]/[to] range * defaults to the whole array (existing callers unchanged); passing a range lets the frame @@ -65,14 +56,9 @@ object Crc { * record or payload in place, skipping the per-frame copyOfRange on the offload path. */ fun crc32(data: ByteArray, from: Int = 0, to: Int = data.size): Long { - var crc = 0xFFFFFFFFL - var i = from - while (i < to) { - val idx = ((crc xor (data[i].toLong() and 0xFFL)) and 0xFFL).toInt() - crc = crc32Table[idx] xor (crc ushr 8) - i++ - } - return (crc xor 0xFFFFFFFFL) and 0xFFFFFFFFL + val checksum = CRC32() + checksum.update(data, from, to - from) + return checksum.value } /** 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 d95c5a482a..c76e2c8ed9 100644 --- a/android/app/src/main/java/com/noop/ui/AppViewModel.kt +++ b/android/app/src/main/java/com/noop/ui/AppViewModel.kt @@ -3047,7 +3047,7 @@ 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 rather than nextDailyEpochSec, which is unconditionally daily: leaving it daily + // smart alarm uses rather than an unconditionally daily schedule: 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. // @@ -3350,32 +3350,6 @@ internal fun nextSmartAlarmEpochSec( return null } -/** - * Next strictly-future occurrence of a daily wake time (today, or tomorrow if already passed), as an - * epoch-second. Pure + clock-injectable so it can be unit-tested. - * - * NO PRODUCTION CALLER as of the phone alarm gaining weekday selection: the "Buzz WHOOP 4/5" companion - * was its only one, and it now routes through [nextSmartAlarmEpochSec] so a day switched off on the - * phone alarm cannot leave the strap buzzing on that morning. Kept because it is the reference for what - * "unconditionally daily" means here — [nextSmartAlarmEpochSec] with an empty weekday set must stay - * equivalent to it, and its own test is what pins that. Delete it only alongside that equivalence. - */ -internal fun nextDailyEpochSec( - minuteOfDay: Int, - nowMs: Long = System.currentTimeMillis(), - calendarFactory: () -> java.util.Calendar = { java.util.Calendar.getInstance() }, -): Long { - val cal = calendarFactory().apply { - timeInMillis = nowMs - set(java.util.Calendar.HOUR_OF_DAY, minuteOfDay / 60) - set(java.util.Calendar.MINUTE, minuteOfDay % 60) - set(java.util.Calendar.SECOND, 0) - set(java.util.Calendar.MILLISECOND, 0) - if (timeInMillis <= nowMs) add(java.util.Calendar.DAY_OF_YEAR, 1) - } - return cal.timeInMillis / 1000 -} - /** * The strap has ONE firmware-alarm slot but two features can want it (#5): the smart wake-alarm and the * "Buzz WHOOP 4/5" companion. Given each feature's requested wake epoch (null = that feature is off or diff --git a/android/app/src/main/java/com/noop/ui/HealthVitalDetailLogic.kt b/android/app/src/main/java/com/noop/ui/HealthVitalDetailLogic.kt index 04deb7f616..f658f6f02e 100644 --- a/android/app/src/main/java/com/noop/ui/HealthVitalDetailLogic.kt +++ b/android/app/src/main/java/com/noop/ui/HealthVitalDetailLogic.kt @@ -379,7 +379,7 @@ internal fun vitalHistorySpanDays(points: List>): Long { return (last.toEpochDay() - first.toEpochDay()).coerceAtLeast(0L) } -/** #943 (ryanbr): which range chips have anything NEW to show. filterVitalPoints windows off the +/** #943 (ryanbr): which range chips have anything NEW to show. filterVitalReadings windows off the * LATEST reading, so with under a week of history every window returned the identical full point set * and all six chips drew the same line (a week of data stretched full-width under a "1Y" label). A * range only differs from its predecessor once the data span EXCEEDS the predecessor's window, so the @@ -417,24 +417,8 @@ internal fun unlockedVitalRanges(spanDays: Long): List { return unlocked } -internal fun filterVitalPoints( - points: List>, - range: VitalDetailRange, -): List> { - val windowDays = range.days ?: return points - val latestDate = points.lastOrNull()?.first?.let { runCatching { LocalDate.parse(it) }.getOrNull() } - ?: return points.takeLast(windowDays.toInt()) - val cutoff = latestDate.minusDays(windowDays - 1) - val filtered = points.filter { (day, _) -> - runCatching { LocalDate.parse(day) }.getOrNull()?.let { !it.isBefore(cutoff) } ?: false - } - return filtered.ifEmpty { points.takeLast(windowDays.toInt()) } -} - -/** [filterVitalPoints] for the source-carrying [VitalReading] list — the SAME latest-relative window, so - * the readings table and the chart always agree on which readings are in view (task #8). Kept as a twin - * of the point filter (identical windowing) rather than shared-generic to preserve the pinned-test shape - * of [filterVitalPoints]. */ +/** Latest-relative window used by the vital chart, reading count, and source-carrying table. + * Unparseable latest dates fall back to the last window-sized set of readings. */ internal fun filterVitalReadings( readings: List, range: VitalDetailRange, diff --git a/android/app/src/main/java/com/noop/ui/StressLoadCancellation.kt b/android/app/src/main/java/com/noop/ui/StressLoadCancellation.kt new file mode 100644 index 0000000000..0d6d48f10f --- /dev/null +++ b/android/app/src/main/java/com/noop/ui/StressLoadCancellation.kt @@ -0,0 +1,24 @@ +package com.noop.ui + +import kotlin.coroutines.coroutineContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.ensureActive + +/** + * Keep failed-read fallback behaviour, but never turn cancellation into a screen update. Check both + * sides of the suspension because CPU work and some reads may finish after their caller is cancelled. + * Swift twin: StressLoadCancellation. + */ +internal object StressLoadCancellation { + suspend fun read(work: suspend () -> Value): Value? { + coroutineContext.ensureActive() + val value = try { + work() + } catch (error: Throwable) { + if (error is CancellationException) throw error + null + } + coroutineContext.ensureActive() + return value + } +} diff --git a/android/app/src/main/java/com/noop/ui/StressScreen.kt b/android/app/src/main/java/com/noop/ui/StressScreen.kt index f3da60c686..c14620f448 100644 --- a/android/app/src/main/java/com/noop/ui/StressScreen.kt +++ b/android/app/src/main/java/com/noop/ui/StressScreen.kt @@ -115,9 +115,9 @@ fun StressScreen(vm: AppViewModel, onBreathe: () -> Unit = {}) { var stored by remember { mutableStateOf>(emptyMap()) } var storedLoaded by remember { mutableStateOf(false) } androidx.compose.runtime.LaunchedEffect(Unit) { - val rows = runCatching { + val rows = StressLoadCancellation.read { vm.repo.metricSeries("my-whoop", "stress", "0000-01-01", "9999-12-31") - }.getOrDefault(emptyList()) + }.orEmpty() stored = rows.associate { it.day to it.value.coerceIn(0.0, 3.0) } storedLoaded = true } @@ -140,20 +140,26 @@ fun StressScreen(vm: AppViewModel, onBreathe: () -> Unit = {}) { // one. Both surfaces now age at the same rate, which is the actual ask in the report. val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current androidx.compose.runtime.LaunchedEffect(vm.activeStrapId, lifecycleOwner) { - // Guarded on the same fingerprint the widget producer memoises against, for the same reason. - // `loadDaytimeStress` is the expensive read on this screen, three windowed row fetches plus the + // Guarded on a witness as WIDE as the read it stands in front of. `loadDaytimeCore` fetches + // hrSamplesUnion, rrIntervalsUnion and gravitySamplesUnion, so a gate keyed on the active strap + // alone is narrower than what it guards: a backfill landing today's rows under an alias id + // (#908, a strap re-added through the device manager) moves the read's answer without moving + // the gate, and this screen then holds the pre-backfill curve for as long as it stays open. + // That is the screen half of #2710. + // + // `loadDaytimeCore` is the expensive read on this screen, three windowed row fetches plus the // two HRV engines, and repeating it was free when it happened once on open. On a timer it is // not: with the strap disconnected, or simply quiet, nothing about today's heart rate has moved - // and re-reading produces a result identical to the one already on screen. An indexed count and - // max answers that for the price of neither. - var lastHrFingerprint: Pair? = null + // and re-reading produces a result identical to the one already on screen. One indexed count + // and max per source id answers that for the price of neither. + var lastHrFingerprint: String? = null lifecycleOwner.lifecycle.repeatOnLifecycle(androidx.lifecycle.Lifecycle.State.STARTED) { while (true) { val nowSeconds = System.currentTimeMillis() / 1000L val window = stressLocalDayWindowContaining(nowSeconds, ZoneId.systemDefault()) - val fingerprint = runCatching { - vm.repo.hrFingerprintWindow(vm.activeStrapId, window.fromEpochSecond, nowSeconds) - }.getOrNull() + val fingerprint = StressLoadCancellation.read { + vm.repo.hrUnionFingerprint(vm.activeStrapId, window.fromEpochSecond, nowSeconds) + } // A failed fingerprint reads as "cannot tell", which loads rather than skips: being // wrong about this costs one pass, being wrong the other way freezes the screen. if (fingerprint == null || fingerprint != lastHrFingerprint) { @@ -169,11 +175,11 @@ fun StressScreen(vm: AppViewModel, onBreathe: () -> Unit = {}) { daytime = core?.daytime ?: DaytimeStress.Result.EMPTY daytimeUsesPersonalBaseline = core?.usesPersonalBaseline == true val beats = core?.rr.orEmpty() - val lenses = if (beats.isEmpty()) null else runCatching { + val lenses = if (beats.isEmpty()) null else StressLoadCancellation.read { withContext(Dispatchers.Default) { StressIndex.components(beats) to HrvFreqDomain.freqDomain(beats) } - }.getOrNull() + } stressIndex = lenses?.first freqHrv = lenses?.second } diff --git a/android/app/src/main/java/com/noop/widget/StressWidgetRefresh.kt b/android/app/src/main/java/com/noop/widget/StressWidgetRefresh.kt index 2f744c84ff..6878f8b7fd 100644 --- a/android/app/src/main/java/com/noop/widget/StressWidgetRefresh.kt +++ b/android/app/src/main/java/com/noop/widget/StressWidgetRefresh.kt @@ -7,6 +7,7 @@ import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters import com.noop.NoopApplication +import com.noop.data.WhoopRepository import com.noop.ui.stressLocalDayWindowContaining import java.time.ZoneId import java.util.concurrent.TimeUnit @@ -58,6 +59,38 @@ object StressWidgetRefresh { /** What one wake-up should do, as a value a test can assert without WorkManager or a Context. */ enum class Action { Retire, Skip, Score } + /** + * The stored gate's identity: the union heart-rate witness over today's window, plus the day that + * window belongs to. + * + * UNION, not the active strap. [StressWidgetProducer.todayCurve] scores from `hrSamplesUnion` and + * its two siblings, so a witness keyed on [deviceId] alone is narrower than the read this gate + * stands in front of. A backfill that lands today's rows under an alias id (#908, a strap re-added + * through the device manager) then leaves the stored value unmoved and the worker returns before + * scoring at all — the widget half of #2710, a curve that stays wrong until something happens to + * move the active strap's own count. + * + * The day is part of the identity because a witness that happened to match across midnight would + * otherwise hold yesterday's published line as today's. [StressWidgetProducer] guards its memo the + * same way, for the same reason. + * + * The one caller that PERSISTS a union witness, because a worker woken after the app was killed has + * no process memory to compare against. A stored value only ever compares equal to one written in + * the same format, so changing the format costs one extra scoring pass and can never produce a + * false hit. + */ + internal suspend fun storedFingerprint( + repo: WhoopRepository, + deviceId: String, + nowMs: Long, + zone: ZoneId, + ): String { + val nowSeconds = nowMs / 1000L + val window = stressLocalDayWindowContaining(nowSeconds, zone) + return repo.hrUnionFingerprint(deviceId, window.fromEpochSecond, nowSeconds) + + "|day=${window.day.toEpochDay()}" + } + /** * The whole of the worker's decision, kept pure for the same reason `shouldRescore` and * `stampAfterAttempt` are: the parts of this that can be wrong are the conditions, not the plumbing, @@ -115,13 +148,13 @@ class StressWidgetRefreshWorker( // killed starts with an empty one — so it would pay a full pass to rebuild the curve already on // screen. With background connection off no rows arrive between wakes at all, which is exactly // the user this feature exists for, so that pass is pure waste in the common case. Two indexed - // queries answer it instead. An empty stored fingerprint means "no idea" and admits the pass. + // queries per source id answer it instead. An empty stored fingerprint means "no idea" and + // admits the pass. val deviceId = app.activeDeviceId val fingerprint = runCatching { - val nowSeconds = nowMs / 1000L - val window = stressLocalDayWindowContaining(nowSeconds, ZoneId.systemDefault()) - val fp = app.repository.hrFingerprintWindow(deviceId, window.fromEpochSecond, nowSeconds) - "${fp.first}:${fp.second}:${window.day.toEpochDay()}" + StressWidgetRefresh.storedFingerprint( + app.repository, deviceId, nowMs, ZoneId.systemDefault(), + ) }.getOrNull() if (fingerprint != null && fingerprint == WidgetSnapshotStore.lastStressFingerprint(applicationContext)) { // Nothing new to score. The stamp still moves, so the next wake measures from this check diff --git a/android/app/src/test/java/com/noop/analytics/HrvCleaningPreservationTest.kt b/android/app/src/test/java/com/noop/analytics/HrvCleaningPreservationTest.kt new file mode 100644 index 0000000000..f0f0f5e14c --- /dev/null +++ b/android/app/src/test/java/com/noop/analytics/HrvCleaningPreservationTest.kt @@ -0,0 +1,89 @@ +package com.noop.analytics + +import org.junit.Assert.assertEquals +import org.junit.Test + +class HrvCleaningPreservationTest { + // Verbatim original Swift stdout at 8e94d559: Double bits and removed-beat adjacency. + private val originalOracle = """ +0|0|cbf29ce484222325|cbf29ce484222325|cbf29ce484222325 +1|0|cbf29ce484222325|cbf29ce484222325|cbf29ce484222325 +2|1|2568834c8601b7df|2568834c8601b7df|af63bd4c8601b7df +3|2|205d8607b4eb6fed|205d8607b4eb6fed|8328707b4eb6e3a +4|3|75289a186c0f2fb7|75289a186c0f2fb7|d949ad186c0c4e41 +5|4|2fe1767f9dce13f5|2fe1767f9dce13f5|447f607f98e8f6c0 +6|5|1d42a1d9252be94f|1d42a1d9252be94f|4d67b9d0d3db49f3 +7|6|8cf0b8fa299d713d|8cf0b8fa299d713d|628aafd7fd9ea636 +8|7|1016fa14b6876aa7|1016fa14b6876aa7|104f0303f4946f75 +9|3|34c79a186c0f2fb7|34c79a186c0f2fb7|d949ad186c0c4e41 +10|1|2532234c8601b7df|2532234c8601b7df|af63bd4c8601b7df +11|7|585f1a14b6876aa7|585f1a14b6876aa7|104f0303f4946f75 +12|3|30cc12186c0f2fb7|30cc12186c0f2fb7|d94d12186c0f2fb7 +13|4|c532367f9dce13f5|c532367f9dce13f5|4d25757f9dce1242 +14|300|ada53892fe7f4315|ada53892fe7f4315|ca507bed0f106018 +15|284|53f2a91c3fc4d5d5|53f2a91c3fc4d5d5|ea13e2a443f8348a +16|36000|39399142eeffafa5|39399142eeffafa5|66887b7d4316484 +17|34097|b9d60d46a4931f|b9d60d46a4931f|e1011fbbd685b430 +18|108000|4529d5bc622088a5|4529d5bc622088a5|275d0339a0103744 +19|102293|d16cc7e6a7b2c70f|d16cc7e6a7b2c70f|f9ef4947521f3074 +""".trimIndent() + + private fun cleaningOracleHash(words: List): String { + var hash = 14695981039346656037uL + for (word in words) hash = (hash xor word) * 1099511628211uL + return hash.toString(16) + } + + private fun cleaningOracleInput(count: Int, mixed: Boolean): List = (0 until count).map { i -> + when { + mixed && i % 31 == 0 -> 50.0 + mixed && i % 47 == 0 -> 1500.0 + else -> 800.0 + ((i * 17) % 101).toDouble() / 4.0 + } + } + + private fun cleaningOracleFixtures(): List> { + val fixtures = (0..8).map { cleaningOracleInput(it, true) }.toMutableList() + fixtures += listOf( + listOf(Double.NaN, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY, 800.0, 801.0, 802.0), + listOf(300.0, 299.0, 800.25, 1500.0, 801.5, 2000.0, 2001.0), + listOf(800.0, 960.0, 800.0, 960.0, 800.0, 960.0, 800.0), + listOf(800.0, 960.0000000000001, 800.0, 960.0000000000001, 800.0), + listOf(2000.0, 2000.0, 1600.0, 1600.0, 300.0, 300.0, 300.0), + ) + for (count in listOf(300, 36000, 108000)) { + fixtures.add(cleaningOracleInput(count, false)) + fixtures.add(cleaningOracleInput(count, true)) + } + return fixtures + } + + @Test + fun cleanRRPreservesOriginalBits() { + val expected = originalOracle.lines().map { it.split("|") } + val fixtures = cleaningOracleFixtures() + assertEquals(expected.size, fixtures.size) + for ((index, input) in fixtures.withIndex()) { + val before = input.map { it.toRawBits() } + val clean = HrvAnalyzer.cleanRR(input) + assertEquals("case $index", expected[index][1], clean.size.toString()) + assertEquals("case $index", expected[index][2], cleaningOracleHash(clean.map { it.toRawBits().toULong() })) + assertEquals(before, input.map { it.toRawBits() }) + } + } + + @Test + fun gapAwareCleaningPreservesOriginalBitsAndAdjacency() { + val expected = originalOracle.lines().map { it.split("|") } + val fixtures = cleaningOracleFixtures() + assertEquals(expected.size, fixtures.size) + for ((index, input) in fixtures.withIndex()) { + val before = input.map { it.toRawBits() } + val clean = HrvAnalyzer.cleanRRGapAware(input) + assertEquals("case $index", expected[index][1], clean.nn.size.toString()) + assertEquals("case $index", expected[index][3], cleaningOracleHash(clean.nn.map { it.toRawBits().toULong() })) + assertEquals("case $index", expected[index][4], cleaningOracleHash(clean.contiguous.map { if (it) 1uL else 0uL })) + assertEquals(before, input.map { it.toRawBits() }) + } + } +} diff --git a/android/app/src/test/java/com/noop/analytics/RecoveryOptionalBaselineUsableTest.kt b/android/app/src/test/java/com/noop/analytics/RecoveryOptionalBaselineUsableTest.kt new file mode 100644 index 0000000000..e1458ed9cc --- /dev/null +++ b/android/app/src/test/java/com/noop/analytics/RecoveryOptionalBaselineUsableTest.kt @@ -0,0 +1,176 @@ +package com.noop.analytics + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Swift twin: RecoveryOptionalBaselineUsableTests. */ +class RecoveryOptionalBaselineUsableTest { + private fun optionalBaselineFixture(mean: Double, spread: Double, + status: BaselineStatus = BaselineStatus.TRUSTED) = BaselineState( + baseline = mean, spread = spread, + nValid = if (status == BaselineStatus.CALIBRATING) 0 else if (status == BaselineStatus.PROVISIONAL) 6 else 14, + nightsSinceUpdate = if (status == BaselineStatus.STALE) 20 else 0, status = status, + ) + + private val hrv = optionalBaselineFixture(50.0, 8.0) + private val rhr = optionalBaselineFixture(60.0, 4.0) + private val statuses = listOf(BaselineStatus.CALIBRATING, BaselineStatus.PROVISIONAL, + BaselineStatus.TRUSTED, BaselineStatus.STALE) + private val respiratoryValues = listOf(null, 10.0, 14.0, 18.0, 22.0) + private val effortValues = listOf(null, 0.0, 30.0, 60.0, 100.0) + + private fun optionalChargeFixture(respBase: BaselineState?, effortBase: BaselineState? = null, + resp: Double? = 14.0, effort: Double? = 60.0) = RecoveryScorer.recovery( + hrv = 55.0, rhr = 58.0, resp = resp, + hrvBaseline = hrv, rhrBaseline = rhr, respBaseline = respBase, + sleepPerf = 0.85, skinTempDev = 0.2, recoveryIndexSlope = -0.5, + effortBaseline = effortBase, priorDayEffort = effort, + ) + + @Test fun unusableRespirationScoresExactlyLikeAbsent() { + val states = listOf(Baselines.foldHistory(emptyList(), Baselines.respCfg), + Baselines.foldHistory(listOf(0.0, 100.0), Baselines.respCfg), + optionalBaselineFixture(16.0, 2.0, BaselineStatus.STALE)) + for (base in states) { + assertFalse(base.usable) + for (value in respiratoryValues) { + assertEquals(optionalChargeFixture(null, resp = value)?.toRawBits(), + optionalChargeFixture(base, resp = value)?.toRawBits()) + } + } + } + + @Test fun unusableEffortScoresExactlyLikeAbsent() { + val states = listOf(Baselines.foldHistory(emptyList(), Baselines.strainCfg), + Baselines.foldHistory(listOf(-1.0, 101.0), Baselines.strainCfg), + optionalBaselineFixture(45.0, 10.0, BaselineStatus.STALE)) + for (base in states) { + assertFalse(base.usable) + for (value in effortValues) { + assertEquals(optionalChargeFixture(null, effort = value)?.toRawBits(), + optionalChargeFixture(null, base, effort = value)?.toRawBits()) + } + } + } + + @Test fun allOptionalStateAndValuePairsMatchEligibleDriverContract() { + val respStates = listOf(null) + statuses.map { optionalBaselineFixture(16.0, 2.0, it) } + val effortStates = listOf(null) + statuses.map { optionalBaselineFixture(45.0, 10.0, it) } + var cases = 0 + val oracleLines = ArrayList() + for (respBase in respStates) for (effortBase in effortStates) { + for (resp in respiratoryValues) for (effort in effortValues) { + val expected = RecoveryScorer.recovery( + hrv = 55.0, rhr = 58.0, resp = resp, + hrvBaseline = RecoveryScorer.DriverBaseline(hrv), + rhrBaseline = RecoveryScorer.DriverBaseline(rhr), + respBaseline = respBase?.takeIf { it.usable }?.let { RecoveryScorer.DriverBaseline(it) }, + sleepPerf = 0.85, skinTempDev = 0.2, recoveryIndexSlope = -0.5, + effortBaseline = effortBase?.takeIf { it.usable }?.let { RecoveryScorer.DriverBaseline(it) }, + priorDayEffort = effort, + ) + val actual = optionalChargeFixture(respBase, effortBase, resp, effort) + assertEquals(expected?.toRawBits(), actual?.toRawBits()) + if (resp == 14.0 && effort == 60.0) { + val bits = actual?.let { java.lang.Long.toHexString(it.toRawBits()) } ?: "nil" + oracleLines.add("${respBase?.status?.raw ?: "nil"}/${effortBase?.status?.raw ?: "nil"}|$bits") + } + cases++ + } + } + assertEquals(625, cases) + // Compiled original Swift stdout; exact Double bits, shared with the Swift regression. + assertEquals(""" + nil/nil|4051b9be0a68921e + nil/calibrating|4051b9be0a68921e + nil/provisional|405119a83eb9de93 + nil/trusted|405119a83eb9de93 + nil/stale|4051b9be0a68921e + calibrating/nil|4051b9be0a68921e + calibrating/calibrating|4051b9be0a68921e + calibrating/provisional|405119a83eb9de93 + calibrating/trusted|405119a83eb9de93 + calibrating/stale|4051b9be0a68921e + provisional/nil|4051e5d635c94772 + provisional/calibrating|4051e5d635c94772 + provisional/provisional|40514d26488c9eb1 + provisional/trusted|40514d26488c9eb1 + provisional/stale|4051e5d635c94772 + trusted/nil|4051e5d635c94772 + trusted/calibrating|4051e5d635c94772 + trusted/provisional|40514d26488c9eb1 + trusted/trusted|40514d26488c9eb1 + trusted/stale|4051e5d635c94772 + stale/nil|4051b9be0a68921e + stale/calibrating|4051b9be0a68921e + stale/provisional|405119a83eb9de93 + stale/trusted|405119a83eb9de93 + stale/stale|4051b9be0a68921e + """.trimIndent(), oracleLines.joinToString("\n")) + assertNotEquals(optionalChargeFixture(null)?.toRawBits(), + optionalChargeFixture(optionalBaselineFixture(16.0, 2.0))?.toRawBits()) + assertNotEquals(optionalChargeFixture(null)?.toRawBits(), + optionalChargeFixture(null, optionalBaselineFixture(45.0, 10.0))?.toRawBits()) + } + + @Test fun respirationRowsUseExactlyTheEligibleBaseline() { + for (status in statuses) { + val base = optionalBaselineFixture(16.0, 2.0, status) + for (value in respiratoryValues) { + val actual = RecoveryDrivers.chargeDrivers(hrv = 55.0, rhr = 58.0, resp = value, + hrvBaseline = hrv, rhrBaseline = rhr, respBaseline = base, + sleepPerf = 0.85, skinTempDev = 0.2) + val expected = RecoveryDrivers.chargeDrivers(hrv = 55.0, rhr = 58.0, resp = value, + hrvBaseline = hrv, rhrBaseline = rhr, respBaseline = base.takeIf { it.usable }, + sleepPerf = 0.85, skinTempDev = 0.2) + assertEquals(expected, actual) + assertEquals(base.usable && value != null, + actual.any { it.label == ChargeDriverLabel.RESPIRATORY_RATE }) + assertTrue(actual.any { it.label == ChargeDriverLabel.HEART_RATE_VARIABILITY }) + } + } + } + + @Test fun respirationTraceUsesExactlyTheScoredTerms() { + for (status in statuses) { + val base = optionalBaselineFixture(16.0, 2.0, status) + for (value in respiratoryValues) { + val actual = RecoveryScorerTrace.recoveryTrace(hrv = 55.0, rhr = 58.0, resp = value, + hrvBaseline = hrv, rhrBaseline = rhr, respBaseline = base, + sleepPerf = 0.85, skinTempDev = 0.2) + val expected = RecoveryScorerTrace.recoveryTrace(hrv = 55.0, rhr = 58.0, resp = value, + hrvBaseline = hrv, rhrBaseline = rhr, respBaseline = base.takeIf { it.usable }, + sleepPerf = 0.85, skinTempDev = 0.2) + assertEquals(expected.first?.toRawBits(), actual.first?.toRawBits()) + assertEquals(expected.second, actual.second) + assertEquals(base.usable && value != null, actual.second.any { it.startsWith("charge term resp ") }) + assertEquals(base.usable, actual.second.any { it.startsWith("charge baseline resp ") }) + assertEquals(!base.usable || value == null, + actual.second.any { it.startsWith("charge nilTerm dropped=") && it.contains("resp") }) + } + } + } + + @Test fun dominantHrvColdStartStillRefusesEveryOptionalState() { + for (status in listOf(BaselineStatus.CALIBRATING, BaselineStatus.STALE)) { + val coldHrv = optionalBaselineFixture(50.0, 8.0, status) + for (optionalStatus in statuses) { + val base = optionalBaselineFixture(16.0, 2.0, optionalStatus) + assertNull(RecoveryScorer.recovery(hrv = 55.0, rhr = 58.0, resp = 14.0, + hrvBaseline = coldHrv, rhrBaseline = rhr, respBaseline = base, sleepPerf = 0.85, + effortBaseline = optionalBaselineFixture(45.0, 10.0, optionalStatus), priorDayEffort = 60.0)) + assertTrue(RecoveryDrivers.chargeDrivers(hrv = 55.0, rhr = 58.0, resp = 14.0, + hrvBaseline = coldHrv, rhrBaseline = rhr, respBaseline = base, sleepPerf = 0.85).isEmpty()) + val trace = RecoveryScorerTrace.recoveryTrace(hrv = 55.0, rhr = 58.0, resp = 14.0, + hrvBaseline = coldHrv, rhrBaseline = rhr, respBaseline = base, sleepPerf = 0.85) + assertNull(trace.first) + assertEquals(1, trace.second.size) + assertTrue(trace.second.firstOrNull()?.contains("hrvBaselineNotUsable") == true) + } + } + } +} diff --git a/android/app/src/test/java/com/noop/protocol/CrcTest.kt b/android/app/src/test/java/com/noop/protocol/CrcTest.kt index 4f2f0e5190..6ae2bec878 100644 --- a/android/app/src/test/java/com/noop/protocol/CrcTest.kt +++ b/android/app/src/test/java/com/noop/protocol/CrcTest.kt @@ -2,6 +2,7 @@ package com.noop.protocol import org.junit.Assert.assertEquals import org.junit.Test +import java.security.MessageDigest /** * Concrete-vector tests for the three frame checksums. The "123456789" check values are the @@ -44,7 +45,63 @@ class CrcTest { fun crc32_isUnsignedThirtyTwoBit() { // Result must always be in 0..0xFFFFFFFF (never negative / sign-extended). val v = Crc.crc32(byteArrayOf(0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte())) - assert(v in 0L..0xFFFFFFFFL) { "crc32 out of range: $v" } + assertEquals(0xFFFFFFFFL, v) + } + + @Test + fun crc32_matchesSwiftForEveryOneAndTwoBytePayload() { + // SHA-256 of decimal CRCs plus LF, in the order below. Generated by compiling the + // unchanged crc32 from Packages/WhoopProtocol/Framing.swift standalone with swiftc -O. + val digest = MessageDigest.getInstance("SHA-256") + fun record(bytes: ByteArray) { + digest.update("${Crc.crc32(bytes)}\n".toByteArray(Charsets.US_ASCII)) + } + for (first in 0..255) record(byteArrayOf(first.toByte())) + for (first in 0..255) { + for (second in 0..255) record(byteArrayOf(first.toByte(), second.toByte())) + } + val actual = digest.digest().joinToString("") { "%02x".format(it.toInt() and 0xFF) } + assertEquals("39d506358a00a529ff4afa6b3e3cb644deab97efcbc6f7ea5c93b32a5629be3f", actual) + } + + @Test + fun crc32_matchesSwiftAcrossPayloadSizesAndFrameOffsets() { + val lengths = listOf(0, 1, 2, 7, 8, 9, 255, 256, 257, 2128, 65535) + // Verbatim stdout from the same Swift oracle for byte[i] = (i * 73 + 19) mod 256. + val expected = """ + 0 + 1455205971 + 1122717362 + 3329140230 + 3357431126 + 2893778277 + 3488025878 + 1571329663 + 2522573416 + 1884520994 + 932587291 + """.trimIndent().lines().map { it.toLong() } + for ((index, length) in lengths.withIndex()) { + val payload = ByteArray(length) { (it * 73 + 19).toByte() } + assertEquals("whole length=$length", expected[index], Crc.crc32(payload)) + // WHOOP 4 and 5 payload offsets, plus an arbitrary offset; sentinels must be excluded. + for (offset in listOf(1, 4, 8, 17)) { + val frame = ByteArray(offset + length + 4) { 0xA5.toByte() } + payload.copyInto(frame, offset) + assertEquals("offset=$offset length=$length", expected[index], + Crc.crc32(frame, offset, offset + length)) + } + } + } + + @Test + fun crc32_callsDoNotShareChecksumState() { + repeat(20) { + assertEquals(0xCBF43926L, Crc.crc32(check)) + assertEquals(0L, Crc.crc32(check, 4, 4)) + assertEquals(0L, Crc.crc32(check, check.size, check.size)) + assertEquals(0xFFFFFFFFL, Crc.crc32(ByteArray(4) { 0xFF.toByte() })) + } } @Test diff --git a/android/app/src/test/java/com/noop/ui/StrapAlarmReconcileTest.kt b/android/app/src/test/java/com/noop/ui/StrapAlarmReconcileTest.kt index 8bdb357c95..9937fbec56 100644 --- a/android/app/src/test/java/com/noop/ui/StrapAlarmReconcileTest.kt +++ b/android/app/src/test/java/com/noop/ui/StrapAlarmReconcileTest.kt @@ -144,13 +144,48 @@ class StrapAlarmReconcileTest { } @Test - fun nextDailyEpochSec_rollsToTomorrowWhenPassed() { + fun companionDailySchedule_rollsToTomorrowWhenPassed() { // Companion fires every day; a time already passed today rolls to tomorrow. val now = wedAt(9, 0) - val slot = nextDailyEpochSec(8 * 60, now, ::utcCalendar) // 08:00, already passed + val slot = buzzReq(true, 8 * 60, now) // 08:00, already passed assertEquals(ms(2026, 6, 18, 8, 0) / 1000, slot) // Thu 08:00 } + @Test + fun dailyScheduleCoversEveryWakeMinuteAndRequiresAStrictlyFutureFire() { + val start = 1_781_654_400L // 2026-06-17 00:00 UTC + val now = 1_781_699_696L // 2026-06-17 12:34:56 UTC + for (minute in 0 until 1440) { + val today = start + minute * 60L + val expected = if (today <= now) today + 86_400L else today + assertEquals("minute $minute", expected, buzzReq(true, minute, now * 1000)) + assertEquals( + "exact wake minute $minute must roll to tomorrow", + today + 86_400L, + buzzReq(true, minute, today * 1000), + ) + } + } + + // DST epochs are the standalone Swift production scheduler oracle output, pinned on both sides. + @Test + fun dailySpringRolloverKeepsLocalWakeTimeAcrossTheShortDay() { + val factory = { Calendar.getInstance(TimeZone.getTimeZone("America/Chicago")) } + val now = 1_772_892_000L // 2026-03-07 08:00 CST + val expected = 1772974800L // 2026-03-08 08:00 CDT, 23 hours later + assertEquals(expected, nextSmartAlarmEpochSec(8 * 60, emptySet(), now * 1000, factory)) + assertEquals(23 * 3600L, expected - now) + } + + @Test + fun dailyAutumnRolloverKeepsLocalWakeTimeAcrossTheLongDay() { + val factory = { Calendar.getInstance(TimeZone.getTimeZone("America/Chicago")) } + val now = 1_793_451_600L // 2026-10-31 08:00 CDT + val expected = 1793541600L // 2026-11-01 08:00 CST, 25 hours later + assertEquals(expected, nextSmartAlarmEpochSec(8 * 60, emptySet(), now * 1000, factory)) + assertEquals(25 * 3600L, expected - now) + } + /** * The phone alarm's weekday selection must reach the COMPANION too. "Buzz WHOOP 4" arms the band at * the phone alarm's earliest wake time, so a day switched off there must not leave the strap buzzing diff --git a/android/app/src/test/java/com/noop/ui/StressLoadCancellationTest.kt b/android/app/src/test/java/com/noop/ui/StressLoadCancellationTest.kt new file mode 100644 index 0000000000..3ea570e12a --- /dev/null +++ b/android/app/src/test/java/com/noop/ui/StressLoadCancellationTest.kt @@ -0,0 +1,121 @@ +package com.noop.ui + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class StressLoadCancellationTest { + @Test fun successfulReadAndTwoPhaseOrderStayIntact() = runTest { + val phases = mutableListOf() + StressLoadCancellation.read { 21 }?.let { phases += it } + StressLoadCancellation.read { 34 }?.let { phases += it } + assertEquals(listOf(21, 34), phases) + } + + @Test fun successfulAbsentValueIsNotCancellation() = runTest { + var reads = 0 + assertNull(StressLoadCancellation.read { reads += 1; null }) + assertEquals(1, reads) + } + + @Test fun alreadyCancelledLoadDoesNotStartARead() = runTest { + val entered = CompletableDeferred() + val released = CompletableDeferred() + var reads = 0 + val old = launch { + withContext(NonCancellable) { entered.complete(Unit); released.await() } + StressLoadCancellation.read { reads += 1; 7 } + } + entered.await() + old.cancel() + released.complete(Unit) + old.join() + assertEquals(0, reads) + } + + @Test fun lateCancelledReadCannotReplaceFreshState() = runTest { + val entered = CompletableDeferred() + val released = CompletableDeferred() + val published = mutableListOf() + val old = launch { + StressLoadCancellation.read { + withContext(NonCancellable) { entered.complete(Unit); released.await() } + "old" + }?.let { published += it } + } + entered.await() + old.cancel() + StressLoadCancellation.read { "fresh" }?.let { published += it } + released.complete(Unit) + old.join() + assertEquals(listOf("fresh"), published) + } + + @Test fun cancelledReadDoesNotStartTheNextPhase() = runTest { + val entered = CompletableDeferred() + val released = CompletableDeferred() + var advancedReads = 0 + val published = mutableListOf() + val old = launch { + val core = StressLoadCancellation.read { + withContext(NonCancellable) { entered.complete(Unit); released.await() } + 1 + } ?: return@launch + published += core + StressLoadCancellation.read { advancedReads += 1; 2 }?.let { published += it } + } + entered.await() + old.cancel() + released.complete(Unit) + old.join() + assertEquals(emptyList(), published) + assertEquals(0, advancedReads) + } + + @Test fun lateAdvancedReadCannotRestoreOldLenses() = runTest { + val entered = CompletableDeferred() + val released = CompletableDeferred() + val published = mutableListOf() + val old = launch { + val core = StressLoadCancellation.read { "old-core" } ?: return@launch + published += core + StressLoadCancellation.read { + withContext(NonCancellable) { entered.complete(Unit); released.await() } + "old-lenses" + }?.let { published += it } + } + entered.await() + old.cancel() + StressLoadCancellation.read { "fresh-core" }?.let { published += it } + released.complete(Unit) + old.join() + assertEquals(listOf("old-core", "fresh-core"), published) + } + + @Test fun ordinaryReadFailureStillUsesAbsentFallback() = runTest { + var reads = 0 + val value = StressLoadCancellation.read { reads += 1; throw IllegalStateException("fixture") } + assertNull(value) + assertEquals(1, reads) + } + + @Test fun cancellationIsNeverAFailedReadFallback() = runTest { + var fallbackPublished = false + var cancellationRethrown = false + try { + val value = StressLoadCancellation.read { throw CancellationException("fixture") } + fallbackPublished = value == null + } catch (_: CancellationException) { + cancellationRethrown = true + } + assertTrue(cancellationRethrown) + assertEquals(false, fallbackPublished) + } +} diff --git a/android/app/src/test/java/com/noop/ui/StressPersonalBaselineSurfaceTest.kt b/android/app/src/test/java/com/noop/ui/StressPersonalBaselineSurfaceTest.kt index 7c00254ddb..162216a895 100644 --- a/android/app/src/test/java/com/noop/ui/StressPersonalBaselineSurfaceTest.kt +++ b/android/app/src/test/java/com/noop/ui/StressPersonalBaselineSurfaceTest.kt @@ -89,7 +89,17 @@ class StressPersonalBaselineSurfaceTest { val liquidToday = source("Strand/Liquid/LiquidTodayView.swift") val widget = source("StrandiOS/Widgets/WidgetPublish.swift") - assertTrue(detail.contains("let mode = await DaytimeStressMode.selected(")) + assertTrue( + "Stress detail must pass the selected personal-baseline preference", + Regex("DaytimeStressMode\\.selected\\([\\s\\S]*?personalBaseline:\\s*" + + "PuffinExperiment\\.stressPersonalBaselineEnabled") + .containsMatchIn(detail), + ) + assertTrue( + "Stress detail must analyze with the selected mode", + Regex("DaytimeStress\\.analyze\\([\\s\\S]*?mode:\\s*mode,") + .containsMatchIn(detail), + ) assertTrue(producer.contains("let mode = await DaytimeStressMode.selected(")) assertTrue( "the shared Apple producer must analyze with the selected mode", diff --git a/android/app/src/test/java/com/noop/ui/VitalRangeGatingTest.kt b/android/app/src/test/java/com/noop/ui/VitalRangeGatingTest.kt index 11592ae339..e180d20b30 100644 --- a/android/app/src/test/java/com/noop/ui/VitalRangeGatingTest.kt +++ b/android/app/src/test/java/com/noop/ui/VitalRangeGatingTest.kt @@ -4,7 +4,7 @@ import org.junit.Assert.assertEquals import org.junit.Test /** - * Range-chip gating for the Vital Signs detail (#943, ryanbr). filterVitalPoints windows off the + * Range-chip gating for the Vital Signs detail (#943, ryanbr). filterVitalReadings windows off the * LATEST reading, so with short history every window returns the same full point set and all the * chips drew byte-identical charts. A range only shows something NEW once the data span EXCEEDS the * previous range's window, so the unlocked chips form a contiguous prefix with W always available @@ -15,17 +15,20 @@ import org.junit.Test */ class VitalRangeGatingTest { - private fun dailyPoints(count: Int, start: String = "2026-01-01"): List> { + private fun dailyReadings(count: Int, start: String = "2026-01-01"): List { val first = java.time.LocalDate.parse(start) - return (0 until count).map { first.plusDays(it.toLong()).toString() to 60.0 + it } + return (0 until count).map { VitalReading(first.plusDays(it.toLong()).toString(), 60.0 + it, "source-$it") } } + private fun historySpan(readings: List): Long = + vitalHistorySpanDays(readings.map { it.day to it.value }) + // ── span math ─────────────────────────────────────────────────────────────── @Test fun spanIsLastMinusFirstInEpochDays() { - assertEquals(9L, vitalHistorySpanDays(dailyPoints(10))) - assertEquals(0L, vitalHistorySpanDays(dailyPoints(1))) - assertEquals(0L, vitalHistorySpanDays(emptyList())) + assertEquals(9L, historySpan(dailyReadings(10))) + assertEquals(0L, historySpan(dailyReadings(1))) + assertEquals(0L, historySpan(emptyList())) } @Test fun unparseableBoundsFallBackToZeroSpan() { @@ -113,27 +116,67 @@ class VitalRangeGatingTest { @Test fun lockedRangeWouldHaveDrawnTheSamePointsAsItsPredecessor() { // 10 daily points, span 9: W (7 points) differs from 2W (all 10), so 2W is unlocked; // 3W returns the identical set as 2W, so 3W is locked. - val points = dailyPoints(10) - val unlocked = unlockedVitalRanges(vitalHistorySpanDays(points)) + val readings = dailyReadings(10) + val unlocked = unlockedVitalRanges(historySpan(readings)) assertEquals( listOf(VitalDetailRange.WEEK, VitalDetailRange.TWO_WEEK, VitalDetailRange.ALL), unlocked, ) - assertEquals(7, filterVitalPoints(points, VitalDetailRange.WEEK).size) - assertEquals(10, filterVitalPoints(points, VitalDetailRange.TWO_WEEK).size) + assertEquals(7, filterVitalReadings(readings, VitalDetailRange.WEEK).size) + assertEquals(10, filterVitalReadings(readings, VitalDetailRange.TWO_WEEK).size) assertEquals( - filterVitalPoints(points, VitalDetailRange.TWO_WEEK), - filterVitalPoints(points, VitalDetailRange.THREE_WEEK), + filterVitalReadings(readings, VitalDetailRange.TWO_WEEK), + filterVitalReadings(readings, VitalDetailRange.THREE_WEEK), ) } @Test fun filterWindowsOffTheLatestReadingInclusive() { // The WEEK window is latestDate-6..latestDate, so exactly the last 7 daily points survive. - val points = dailyPoints(30) - val week = filterVitalPoints(points, VitalDetailRange.WEEK) + val readings = dailyReadings(30) + val week = filterVitalReadings(readings, VitalDetailRange.WEEK) assertEquals(7, week.size) - assertEquals(points.takeLast(7), week) + assertEquals(readings.takeLast(7), week) // The new 3W window: the last 21 daily points. - assertEquals(21, filterVitalPoints(points, VitalDetailRange.THREE_WEEK).size) + assertEquals(readings.takeLast(21), filterVitalReadings(readings, VitalDetailRange.THREE_WEEK)) + } + + @Test fun everyRangeKeepsTheExpectedDailyReadings() { + val readings = dailyReadings(400) + VitalDetailRange.entries.forEach { range -> + val expected = range.days?.let { readings.takeLast(it.toInt()) } ?: readings + assertEquals(expected, filterVitalReadings(readings, range)) + } + } + + @Test fun sparseWindowUsesCalendarDaysAndRetainsSourceAndValue() { + val readings = listOf( + VitalReading("2026-01-01", 55.0, "old"), + VitalReading("2026-01-23", 56.0, "before-cutoff"), + VitalReading("2026-01-24", 57.0, "at-cutoff"), + VitalReading("2026-01-29", 58.0, "apple-health"), + VitalReading("2026-01-30", 59.0, "my-whoop-noop"), + ) + assertEquals(readings.takeLast(3), filterVitalReadings(readings, VitalDetailRange.WEEK)) + assertEquals(readings, filterVitalReadings(readings, VitalDetailRange.ALL)) + } + + @Test fun invalidInteriorDateIsExcludedWhenLatestDateParses() { + val readings = listOf( + VitalReading("bad-day", 55.0, "unknown"), + VitalReading("2026-01-30", 59.0, "my-whoop"), + ) + assertEquals(readings.takeLast(1), filterVitalReadings(readings, VitalDetailRange.WEEK)) + } + + @Test fun invalidLatestDateFallsBackToReadingCount() { + val readings = dailyReadings(10) + VitalReading("bad-day", 70.0, "unknown") + assertEquals(readings.takeLast(7), filterVitalReadings(readings, VitalDetailRange.WEEK)) + assertEquals(readings, filterVitalReadings(readings, VitalDetailRange.ALL)) + } + + @Test fun emptyHistoryStaysEmptyInEveryRange() { + VitalDetailRange.entries.forEach { range -> + assertEquals(emptyList(), filterVitalReadings(emptyList(), range)) + } } } diff --git a/android/app/src/test/java/com/noop/ui/VitalReadingsTableTest.kt b/android/app/src/test/java/com/noop/ui/VitalReadingsTableTest.kt index 9270e6ac25..9858c5051f 100644 --- a/android/app/src/test/java/com/noop/ui/VitalReadingsTableTest.kt +++ b/android/app/src/test/java/com/noop/ui/VitalReadingsTableTest.kt @@ -113,18 +113,17 @@ class VitalReadingsTableTest { assertEquals("72", rows.single().value) } - @Test fun readingFilterMatchesPointFilterWindowInEveryRange() { - // The header counts filterVitalReadings; the chart consumes filterVitalPoints. They must window - // the SAME days in every range, or the "N readings" header and the table could disagree. - val readings = (0 until 30).map { + @Test fun tableRowsUseTheProductionWindowInEveryRange() { + val readings = (0 until 400).map { VitalReading(LocalDate.parse("2026-01-01").plusDays(it.toLong()).toString(), 60.0 + it, strap) } - val points = readings.map { it.day to it.value } VitalDetailRange.entries.forEach { range -> - assertEquals( - filterVitalPoints(points, range).map { it.first }, - filterVitalReadings(readings, range).map { it.day }, - ) + val expected = range.days?.let { readings.takeLast(it.toInt()) } ?: readings + val window = filterVitalReadings(readings, range) + assertEquals(expected, window) + val rows = vitalReadingRows(window, "%", strap, spo2Format) + assertEquals(expected.size, rows.size) + assertEquals(expected.reversed().map { "${spo2Format(it.value)} %" }, rows.map { it.value }) } } } diff --git a/android/app/src/test/java/com/noop/widget/StressWidgetStoredGateTest.kt b/android/app/src/test/java/com/noop/widget/StressWidgetStoredGateTest.kt new file mode 100644 index 0000000000..798b0771ed --- /dev/null +++ b/android/app/src/test/java/com/noop/widget/StressWidgetStoredGateTest.kt @@ -0,0 +1,188 @@ +package com.noop.widget + +import com.noop.data.PairedDeviceRow +import com.noop.data.WhoopDao +import com.noop.data.WhoopRepository +import java.io.File +import java.lang.reflect.Proxy +import java.time.ZoneId +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * #2710 — every gate standing in front of a UNION stress read must itself be a union witness. + * + * The producer's memo was only the first of three. The worker's gate is the one that bites hardest: + * it compares a PERSISTED value and returns before [StressWidgetProducer.todayCurve] is reached at + * all, so keyed on the active strap alone it skipped the scoring pass outright after a backfill landed + * today's rows under an alias id (#908, a strap re-added through the device manager). The widget then + * held a curve that nothing would correct until the active strap's own count moved. + * + * Stubbed through a Proxy [WhoopDao] (no Room), answering per-id so a change under ONE id is visible, + * and refusing every row fetch: this gate exists to AVOID one. + */ +class StressWidgetStoredGateTest { + + // 2023-11-14T22:13:20Z. In UTC the local stress day is 19675 and its window starts at 1699920000, + // so the gate's expected arguments are arithmetic rather than a second call to the window helper. + private val nowMs = 1_700_000_000_000L + private val utc = ZoneId.of("UTC") + + private fun row(id: String) = PairedDeviceRow( + id = id, brand = "WHOOP", model = "4.0", nickname = null, + sourceKind = "strap", capabilities = "hr", status = "active", + addedAt = 0L, lastSeenAt = 0L, + ) + + private fun repo( + registered: List, + counts: Map, + maxTs: Map, + seen: MutableList? = null, + windows: MutableList>? = null, + ): WhoopRepository { + val dao = Proxy.newProxyInstance( + WhoopDao::class.java.classLoader, + arrayOf(WhoopDao::class.java), + ) { _, method, args -> + when (method.name) { + "pairedDevices" -> registered.map(::row) + "countHrInWindow" -> { + val id = args[0] as String + seen?.add("count:$id") + windows?.add((args[1] as Long) to (args[2] as Long)) + counts[id] ?: 0 + } + "maxHrTsInWindow" -> { + val id = args[0] as String + seen?.add("maxTs:$id") + maxTs[id] ?: 0L + } + else -> throw UnsupportedOperationException("the stored gate must not call ${method.name}") + } + } as WhoopDao + return WhoopRepository(dao) + } + + @Test fun walksEveryUnionIdOverTodaySoFarWithIndexAggregatesOnly() = runBlocking { + val seen = mutableListOf() + val windows = mutableListOf>() + val fp = StressWidgetRefresh.storedFingerprint( + repo( + registered = listOf("whoop-alias"), + counts = mapOf("whoop-active" to 3, "whoop-alias" to 7, "my-whoop" to 11), + maxTs = mapOf("whoop-active" to 300L, "whoop-alias" to 700L, "my-whoop" to 1100L), + seen = seen, + windows = windows, + ), + deviceId = "whoop-active", nowMs = nowMs, zone = utc, + ) + + assertEquals( + "whoop-active=3:300,whoop-alias=7:700,my-whoop=11:1100|day=19675", + fp, + ) + assertEquals( + listOf( + "count:whoop-active", "maxTs:whoop-active", + "count:whoop-alias", "maxTs:whoop-alias", + "count:my-whoop", "maxTs:my-whoop", + ), + seen, + ) + // Day start to NOW, not to the day's end: the gate asks what has landed so far, which is the + // same span StressWidgetProducer scores. + assertEquals(List(3) { 1_699_920_000L to 1_700_000_000L }, windows) + } + + // THE #2710 CASE. The active strap is idle and unchanged; the day's rows arrive under the alias. + @Test fun aliasBackfillMovesTheStoredFingerprint() = runBlocking { + val before = StressWidgetRefresh.storedFingerprint( + repo( + registered = listOf("whoop-alias"), + counts = mapOf("whoop-active" to 3, "whoop-alias" to 0, "my-whoop" to 0), + maxTs = mapOf("whoop-active" to 300L), + ), + deviceId = "whoop-active", nowMs = nowMs, zone = utc, + ) + val after = StressWidgetRefresh.storedFingerprint( + repo( + registered = listOf("whoop-alias"), + counts = mapOf("whoop-active" to 3, "whoop-alias" to 412, "my-whoop" to 0), + maxTs = mapOf("whoop-active" to 300L, "whoop-alias" to 1_699_999_000L), + ), + deviceId = "whoop-active", nowMs = nowMs, zone = utc, + ) + + assertNotEquals(before, after) + } + + // A witness that happened to match across midnight would otherwise hold yesterday's line as today's. + @Test fun theLocalDayIsPartOfTheStoredIdentity() { + fun at(ms: Long) = runBlocking { + StressWidgetRefresh.storedFingerprint( + repo( + registered = emptyList(), + counts = mapOf("whoop-active" to 5, "my-whoop" to 0), + maxTs = mapOf("whoop-active" to 500L), + ), + deviceId = "whoop-active", nowMs = ms, zone = utc, + ) + } + + assertNotEquals(at(nowMs), at(nowMs + 86_400_000L)) + assertEquals(at(nowMs), at(nowMs)) + } + + /** + * Source tripwires for the two gates that cannot be reached from a JVM test: the worker's is behind + * a Context and a NoopApplication, the screen's is inside a Compose LaunchedEffect. Both compile + * perfectly well with the narrow witness, which is how they came to disagree with the read. + */ + @Test fun everyStressGateUsesTheUnionWitness() { + val userDir = File(System.getProperty("user.dir") ?: ".") + val root = listOf(userDir, File(userDir, ".."), File(userDir, "../..")) + .firstOrNull { File(it, "Strand/Data/StressDayCurve.swift").isFile } + ?: error("could not locate the repo root from ${userDir.absolutePath}") + fun source(path: String) = File(root, path).readText() + + val screen = source("android/app/src/main/java/com/noop/ui/StressScreen.kt") + val worker = source("android/app/src/main/java/com/noop/widget/StressWidgetRefresh.kt") + + assertTrue( + "the screen's periodic re-read gate must use the union witness", + screen.contains("vm.repo.hrUnionFingerprint(vm.activeStrapId, window.fromEpochSecond, nowSeconds)"), + ) + assertTrue( + "the worker's stored gate must route through storedFingerprint", + worker.contains("StressWidgetRefresh.storedFingerprint("), + ) + for (path in listOf( + "android/app/src/main/java/com/noop/ui/StressScreen.kt", + "android/app/src/main/java/com/noop/widget/StressWidgetRefresh.kt", + )) { + assertFalse( + "$path guards a union read, so it must not gate on hrFingerprintWindow", + source(path).contains("hrFingerprintWindow("), + ) + } + + // The Apple producer is the documented twin of StressWidgetProducer and guards the same union + // reads. Its witness used to be a union SUM, one count and one max across the ids, which holds + // steady when the window's composition changes without its totals changing. Asserted from here + // because this is the suite that runs on every push; the Swift side has no JVM reach of its own. + val appleProducer = source("Strand/Data/StressDayCurve.swift") + assertTrue( + "the Apple producer must gate on the per-id union witness", + appleProducer.contains("await repo.hrFingerprintUnion(from: from, to: to)"), + ) + assertFalse( + "the superseded summed witness must not come back", + source("Strand/Data/Repository.swift").contains("func hrFingerprint(from:"), + ) + } +} diff --git a/docs/ANDROID.md b/docs/ANDROID.md index 759858bf81..755940419b 100644 --- a/docs/ANDROID.md +++ b/docs/ANDROID.md @@ -1,10 +1,11 @@ # NOOP — Android Port Guide -NOOP is a standalone, fully **offline** companion app for WHOOP straps (4.0 and 5.0). It pairs -directly with the strap over Bluetooth Low Energy, stores everything on-device in SQLite, imports +NOOP is a standalone companion app for WHOOP straps (4.0 and 5.0), with **offline collection and analytics**. +It pairs directly with the strap over Bluetooth Low Energy, stores everything on-device in SQLite, imports WHOOP CSV exports and Apple Health exports, and computes recovery / strain / HRV / sleep locally. -There is no cloud, no account — the app talks only to **your own device** and -works only with **your own data**. +There is no NOOP cloud or account; collection and analytics need no network connection. +Optional network features and the default-on update check are documented in the +[privacy inventory](PRIVACY_SECURITY.md#11-network-paths-canonical-inventory). This document covers the **Android client** under [`android/`](../android). The macOS app is the reference implementation; the Android app is a native re-implementation of the same wire protocol @@ -46,7 +47,7 @@ the same analytics so results match macOS.) - [Raw capture import (`capture.json`)](#raw-capture-import-capturejson) - [Analytics](#analytics) - [Compose UI](#compose-ui) -- [Permissions and the no-internet posture](#permissions-and-the-no-internet-posture) +- [Permissions and offline collection](#permissions-and-offline-collection) - [Verification checklist](#verification-checklist) - [Credits](#credits) @@ -122,8 +123,8 @@ bump forces matching KSP and Compose-compiler bumps: | `sourceCompatibility` / `jvmTarget` | `17` | JDK 17 | | `applicationId` | `com.noop.whoop` | `.debug` suffix on debug builds | -The app declares **no `INTERNET` permission** by design (see -[Permissions](#permissions-and-the-no-internet-posture)) and sets `android:allowBackup="false"`. +The app declares `INTERNET` for its documented network features (see +[Permissions](#permissions-and-offline-collection)) and sets `android:allowBackup="false"`. --- @@ -538,8 +539,8 @@ A few Swift tables are not yet mirrored as Room entities (`rawBatch`, `cursors`, `workout`, `appleDaily`) — add them as the corresponding collector / journal / workout features are ported. -The database is created **without** an `INTERNET` permission and lives entirely in the app's private -storage; nothing is uploaded. +The database lives in the app's private storage. Collection and scoring do not need internet; +optional exports are governed by the [network inventory](PRIVACY_SECURITY.md#11-network-paths-canonical-inventory). --- @@ -641,13 +642,21 @@ UI, keep that parity. --- -## Permissions and the no-internet posture + -The manifest is deliberately minimal and **declares no `INTERNET` permission** — nothing leaves the -device. Permissions, straight from `android/app/src/main/AndroidManifest.xml`: +## Permissions and offline collection + +`android/app/src/main/AndroidManifest.xml` **declares `INTERNET`**. It permits the app's +network clients; it does not make BLE collection or local scoring depend on a connection. +The [canonical privacy inventory](PRIVACY_SECURITY.md#11-network-paths-canonical-inventory) +describes each feature's default, trigger and payload. In particular, automatic release checks +are on by default; AI Coach and Experimental self-hosted push require configuration. + +Selected permissions from the manifest (the manifest is the complete list): | Permission | API range | Why | | --- | --- | --- | +| `INTERNET` | all | documented network features; a [normal install-time permission](https://developer.android.com/reference/android/Manifest.permission#INTERNET), not a runtime consent prompt | | `BLUETOOTH_SCAN` (`neverForLocation`) | 31+ | scan for the strap; opt out of location coupling | | `BLUETOOTH_CONNECT` | 31+ | connect / bond / GATT I/O | | `BLUETOOTH`, `BLUETOOTH_ADMIN` | ≤30 | legacy install-time BLE perms | @@ -678,7 +687,7 @@ should be re-verified against a real build, a real device, and a real strap befo - [x] `./gradlew :app:testDebugUnitTest` is green (analytics vectors). - [x] `./gradlew assembleDebug` produces `app-debug.apk`. - [x] `./gradlew assembleFullRelease` / `assembleDemoRelease` succeed with R8 full mode + resource shrinking. -- [x] APK declares **no `INTERNET` permission** (`aapt dump permissions app-debug.apk`). +- [x] Manifest declares `INTERNET`; feature gates and payloads are documented in [Privacy & Security §1.1](PRIVACY_SECURITY.md#11-network-paths-canonical-inventory). **Protocol parity (JVM, no device)** diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0a5fd98cf9..4bc387eeb2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -374,8 +374,9 @@ computed locally. ## 11. Design principles, restated -1. **Offline by construction.** There is no network client anywhere in the data path. The strap, the - SQLite file, and the UI are the whole system. +1. **Offline collection and analytics.** The strap → SQLite → scoring pipeline needs no network + connection. App HTTP clients and platform services are documented in the canonical + [privacy inventory](PRIVACY_SECURITY.md#11-network-paths-canonical-inventory). 2. **Decoded-first durability.** Metrics are committed before raw is queued; the raw outbox is a prunable convenience, never the source of truth. 3. **Resumable safe-trim.** The strap forgets historical data only after NOOP has it durably and has diff --git a/docs/BUILD.md b/docs/BUILD.md index 8d4cc2d07c..81608e9375 100644 --- a/docs/BUILD.md +++ b/docs/BUILD.md @@ -1,10 +1,10 @@ # Building NOOP -NOOP is a standalone, fully **offline** companion app for WHOOP straps (4.0 and 5.0). It pairs -directly with the strap over Bluetooth Low Energy, stores everything on-device in SQLite, imports +NOOP is a standalone companion app for WHOOP straps (4.0 and 5.0), with **offline collection and analytics**. +It pairs directly with the strap over Bluetooth Low Energy, stores everything on-device in SQLite, imports WHOOP CSV exports and Apple Health exports, and computes recovery / strain / HRV / sleep locally. -There is no cloud, no account — the app talks only to **your own device** and -works only with **your own data**. +There is no NOOP cloud or account. Network features and the default-on release check are +documented in the [privacy inventory](PRIVACY_SECURITY.md#11-network-paths-canonical-inventory). > **Not affiliated with WHOOP, and not a medical device.** "WHOOP" is used only to identify the > hardware this software interoperates with. NOOP contains no WHOOP code, firmware, or assets. All diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 99ccfcac8a..beef207046 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -46,13 +46,11 @@ A few principles run through the whole codebase. Internalize them before opening 1. **Offline by design.** There is no NOOP server, telemetry, or account, and **nothing about you leaves the device unless you explicitly switch on a feature that sends it.** Strap data, imports, and computed metrics live in a local SQLite database. - The app makes exactly four network requests, all documented in - [docs/PRIVACY_SECURITY.md §1.1](PRIVACY_SECURITY.md): the opt-in AI Coach, the - compile-time-optional Oura history import, the update check (a read of a public version number, - on by default, switchable off), and Android's default-off Experimental one-way export to a - user-owned endpoint. Adding a fifth needs a very good reason and the same treatment: named in the - privacy doc, and switchable off. New hosted services or undisclosed network calls do not belong - here; see [Scope](SCOPE.md). + [Privacy & Security §1.1](PRIVACY_SECURITY.md#11-network-paths-canonical-inventory) owns the + network inventory: defaults, triggers, destinations, payloads and platform-service boundaries. + The update check is on by default and sends no health data. Any new network path needs a clear + justification, disclosure in that inventory and a way to disable it. New hosted services or + undisclosed network calls do not belong here; see [Scope](SCOPE.md). 2. **Interoperability, not impersonation.** NOOP talks to a strap the user already owns. It does not log into a WHOOP account, bypass a paywall, or ship WHOOP's proprietary code/firmware/assets/logos. Keep contributions on the right side of that line, and keep all WHOOP references *nominative* diff --git a/docs/PRIVACY_SECURITY.md b/docs/PRIVACY_SECURITY.md index b1bfd91eaa..bd1da6f201 100644 --- a/docs/PRIVACY_SECURITY.md +++ b/docs/PRIVACY_SECURITY.md @@ -27,49 +27,52 @@ local SQLite — has no network layer at all: no phone-home, no analytics, no ac no login, no cloud sync, and no telemetry. Everything NOOP computes about you lives in a single SQLite file on your own device. -There are exactly **three** opt-in network exceptions: the **AI Coach** (§1.1a), the **Oura history -import** (§1.1b), and Android's Experimental **self-hosted push** (§1.1d). The AI Coach is off until -you turn it on with your own API key; when you -ask it a question it sends a short text summary of your recent metrics to the provider you -choose. The Oura history import is **not even compiled into a default build** — the code -only exists in your binary if you build from source with your own Oura developer app's -credentials (§1.1b); instead of sending data out, it pulls your own Oura data **in** over -OAuth, once, and never sends any of your existing NOOP data out. Self-hosted push is off until an -Android user configures their own endpoint and bearer token; it then exports registered streams one -way after offload and never reads records back. Nothing else in the app touches the network. - -Data enters or leaves NOOP only through these explicit paths: +**Offline does not mean the app has no internet permission or never makes a request.** +Strap collection and local analytics work without a network connection. The update check is +**on by default**; optional features can send data to destinations you configure. +[§1.1](#11-network-paths-canonical-inventory) is the canonical inventory of network paths, +their defaults, triggers and payloads. + +The main local data paths are: | Path | Transport | Direction | |------|-----------|-----------| | Live collection | Bluetooth LE, strap → device | Read-only from the strap | | File import (Apple Health, WHOOP CSV, nutrition CSV) | User-selected files on disk | Read-only from disk | -| Oura history import (opt-in build flag, §1.1b) | HTTPS OAuth + REST, `api.ouraring.com` → device | Read-only from your own Oura account | | Apple Health export, incl. iOS "Export for Shortcuts" | On-device, user-initiated | NOOP → your Apple Health, on your device only (§1.3) | -| Self-hosted push (Experimental, Android, §1.1d) | HTTP(S), configured endpoint | One-way NOOP → user-owned receiver | - -The **network** paths are the opt-in AI Coach, the compile-time-optional Oura history import, the -update check (§1.1c), and Android's default-off Experimental self-hosted push (§1.1d); the -biometric pipeline produces no network traffic of any kind. The Apple Health export above is -an **on-device** hand-off, not a network upload — see §1.3. - -### 1.1 Network code: the four exceptions - -The biometric pipeline and all five Swift packages -(`WhoopProtocol`, `WhoopStore`, `StrandAnalytics`, `StrandImport`, `StrandDesign`) -contain **no** use of `URLSession`, `URLRequest`, `NWConnection`, `dataTask`, or any -other networking API — still true after the Oura history import (§1.1b) landed: its OAuth -and REST calls live entirely in the app target, `Strand/Oura/`, and `StrandImport` -gained only pure, network-free parsers for Oura's payload shapes. These Swift packages -are **shared by the macOS and iOS apps** (iOS is build-from-source only — no App Store / -TestFlight — and was folded into the main tree in v1.94), so the Swift-side privacy -behaviour described here applies equally to both. Android is a separate codebase using -Room for storage and Kotlin for the BLE / import / Coach paths; its own Oura support is -the local BLE ring-pairing lane, not a network API, so it has no equivalent to §1.1b. The -networking anywhere in the app is the AI Coach (`Strand/AI/AICoach.swift` on the -Swift side — macOS and iOS — `com.noop.ai.AiCoach` on Android), described in §1.1a, -the Oura history import (`Strand/Oura/`, Swift-only — macOS and iOS), described in §1.1b, -the update check, described in §1.1c, and the Android-only self-hosted push, described in §1.1d. + +These local hand-offs do not upload data through NOOP's HTTP clients. What another app or +the operating system does with shared data is governed by that service and your settings (§1.3). + + + +### 1.1 Network paths (canonical inventory) + +The following table covers NOOP's direct HTTP clients. A path can make several requests; +these are features, not a count of individual requests or destinations. + +| Path / platforms | Default and trigger | What is sent | Destination | +| --- | --- | --- | --- | +| AI Coach / macOS, iOS, Android (§1.1a) | Unconfigured initially; requests after provider setup for chat, model refresh or enabled briefs | Provider authentication when required; conversation text; health summaries only with data consent; optional chart attachment on Apple/Gemini | Chosen provider or Custom server | +| Oura history import / opt-in macOS, iOS build (§1.1b) | Compiled out by default; user starts authorization/import | OAuth credentials, authorization/refresh tokens and history query parameters; no existing NOOP health data | `cloud.ouraring.com`, `api.ouraring.com` | +| Update check / macOS, iOS, Android (§1.1c) | Automatic check **on by default**, at most daily after onboarding/Terms; manual check on tap | Public release `GET`; no health data or app-added user/account identifier | `api.github.com` | +| Self-hosted push / Android (§1.1d) | Experimental, **off by default**; configured export after offload/launch or on tap; explicit connection test | Bearer authentication; capability `GET`; versioned health batches with local installation/device IDs on export | User-owned HTTP(S) endpoint | + +**Platform services are a separate boundary.** Apple's workout route view uses `MKMapView` +(`Strand/Screens/WorkoutDetailView.swift`), which can load map data for the displayed region. +The route overlay is built locally; that does not guarantee the map makes no network requests. +Opening a web link hands it to a browser; Apple Health/Health Connect and sideloaders have their +own network and sharing settings. This inventory does not promise those services are offline. + +For ordinary strap collection without NOOP's direct HTTP requests: turn off **Check automatically** +in Settings → About, leave the AI Coach and self-hosted push unconfigured, use a default build +without the Oura cloud import, and do not tap the manual update check. This does not disable +platform-service traffic such as the route map. + +Networking clients live in the app targets: `Strand/AI/`, `Strand/Oura/`, `Strand/System/UpdateChecker.swift`, +and Android's `ai/`, `update/` and `push/` packages. The biometric pipeline and Swift packages under +`Packages/` do not make runtime network requests; shared parsers such as `SseDeltas` only process +already-received text. Android's Oura support is local BLE, with no equivalent to the cloud import. The package manifests reference dependency *download* URLs that Swift Package Manager resolves at build time, never at runtime: @@ -77,35 +80,39 @@ resolves at build time, never at runtime: ``` Packages/WhoopStore/Package.swift → https://github.com/groue/GRDB.swift.git Packages/StrandImport/Package.swift → https://github.com/weichsel/ZIPFoundation.git +Packages/NoopLocalAccess/Package.swift → https://github.com/groue/GRDB.swift.git ``` GRDB.swift is the SQLite layer; ZIPFoundation is the archive reader used by the importers. Neither opens a socket. -### 1.1a The AI Coach (optional, off by default, bring your own key) - -The AI Coach lets you ask questions about your data in plain language. It is one of three optional -network paths (the others are the Oura history import, §1.1b, and self-hosted push, §1.1d), and only -on your terms: - -- **Off until you enable it.** You enter your own API key for the provider you choose - (Anthropic, OpenAI, or a local / self-hosted OpenAI-compatible LLM such as Ollama or - LM Studio). No key, no network calls, ever. -- **What is sent.** When you ask a question, NOOP builds a compact **text** summary of - your recent metrics (Charge, Effort, Rest, HRV, resting HR over ~14 days, plus - 30-day averages and recent workouts) and sends it, with your question, directly to - your chosen endpoint (e.g. `api.anthropic.com` / `api.openai.com` for the hosted - providers). If you point the Coach at a local / self-hosted LLM, that endpoint is on - your own machine and the request never leaves it. -- **What is NOT sent.** No raw biometric streams, no Bluetooth data, no account or - device identifiers — only the summary text and your question. -- **Your key, your relationship.** The request goes from your device straight to the - provider you picked, under your own account. NOOP runs no server in between and keeps - no copy. - -If you never enable the AI Coach or self-hosted push and never build the Oura import in (§1.1b), -NOOP makes zero application network connections — and in a default build, the Oura code isn't in -the binary to begin with. + + +### 1.1a The AI Coach (optional, provider setup required) + +The AI Coach sends requests directly to the provider you choose: + +- **Unconfigured until you set it up.** Hosted providers (Anthropic, OpenAI and Google Gemini) + require your API key. A Custom OpenAI-compatible server requires an explicit Connect action; + its key is optional. A server on the same machine receives requests locally; a LAN or remote + server receives them over that network. "Self-hosted" does not necessarily mean on-device. +- **What triggers a request.** Sending a question, refreshing the provider's model list, or + connecting a Custom server (which fetches its models). With data consent, opening an empty + Coach conversation can generate a brief. A separately enabled morning brief can also request + one without a chat question. Saving a hosted-provider key alone does not fetch models. +- **What is sent.** Chat requests include your question and conversation context. With the + separate, default-off data consent, they also include compact summaries of recent metrics + and workouts. Additional on-device-signal summaries require another opt-in. On Apple, + Gemini can also receive a rendered chart when its default-off multimodal option is enabled + and you attach one. Model-list requests send authentication where required, without health + summaries. Scheduled briefs require provider configuration and data consent. +- **What is not automatically attached.** Raw biometric streams, BLE frames and local device + identifiers. Text you type and charts you attach can contain personal information. +- **Your provider, your relationship.** NOOP operates no intermediary. Provider retention and + processing policies apply to what you send. The conversation is also stored locally in NOOP. + +Code: `Strand/AI/AICoach.swift`, `Strand/AI/AIProvider.swift`, `Strand/AI/Providers/`, +`Strand/System/CoachBriefScheduler.swift`; Android `com.noop.ai` and `com.noop.ui.CoachViewModel`. ### 1.1b The Oura history import (compiled out by default, bring your own OAuth app) @@ -156,12 +163,12 @@ both read the **same** public endpoint: `https://api.github.com/repos/ryanbr/noo - **"Check for updates"** in Settings → About. Runs only when tapped. It has always existed; it was previously undocumented here, which is why this section is new rather than merely amended. -- **"Check automatically"**, beside it. At most once a day, after onboarding (and, on iOS, after the - Terms gate), NOOP reads that endpoint and — if a newer release exists — puts a row in the Updates +- **"Check automatically"**, beside it. At most once a day, after onboarding and the + Terms gate, NOOP reads that endpoint and — if a newer release exists — puts a row in the Updates inbox. **On by default**, and switchable off, at which point it makes no request at all. -What is sent: nothing. It is an unauthenticated `GET` of a public URL, carrying no identifier, no -account, no device information and no biometric data. What comes back is a version number and the +What is sent: an unauthenticated `GET` of a public URL, with normal HTTP headers but no app-added +user/account identifier or biometric data. What comes back is a version number and the release notes. **It never installs anything** — on iOS no API permits that for a sideloaded app (see [docs/IOS.md](IOS.md)); the row tells you a release exists and where to get it. @@ -233,10 +240,9 @@ That is the entire entitlement file. Four keys: signed/sandboxed build, where the sandbox otherwise refuses any socket the app tries to open (#128); the Oura history import (§1.1b) now relies on the same entitlement. The ad-hoc distributed build applies **no** entitlements at all (unsigned build + ad-hoc - re-sign), so this key only matters for a signed/sandboxed build. The entitlement only - permits the socket the sandbox would otherwise refuse — it doesn't make either feature - call out on its own; both stay off until you deliberately turn the Coach on or tap - Connect Oura. + re-sign), so this key only matters for a signed/sandboxed build. The update check (§1.1c) + also uses outbound network access. The entitlement permits connections; feature code + determines when they happen and what they carry. Notably **absent**: @@ -245,10 +251,11 @@ Notably **absent**: the app cannot wander the disk; it sees only what the user hands it through the open panel, plus its own sandbox container. -This is the structural guarantee behind "offline by default" on macOS: the sandbox -permits exactly the two Swift-side, opt-in exceptions above and nothing else — no -undeclared entitlement could smuggle out a connection the user didn't ask for. The -property is enforced by the OS, not merely by convention. +The sandbox restricts filesystem and device access, but `network.client` is **not a +destination allow-list** or proof that only the documented features can connect. As +[Apple documents](https://developer.apple.com/documentation/bundleresources/entitlements/com.apple.security.network.client), +it permits outgoing connections. The network boundaries in §1.1 rely on the source's feature +gates and request construction, not on an OS restriction to particular servers. > **Note on Hardened Runtime.** `project.yml` currently sets > `ENABLE_HARDENED_RUNTIME: NO` for local development builds. Distributable / @@ -611,7 +618,7 @@ dedicated source id `nutrition-csv`, alongside your other metrics and entirely o | Surface | Risk | Mitigation | Where | |---------|------|------------|-------| -| Process | Data exfiltration / network egress | Three explicit paths: AI Coach (your key, chosen provider, summary only — §1.1a), Oura history import (your OAuth app, inbound-only — §1.1b), and Android self-hosted push (default-off, user-owned endpoint, one-way versioned batches — §1.1d). No NOOP server, account, or telemetry; ordinary BLE/offline use makes no application network request. | `Strand/AI/AICoach.swift`, `Strand/Oura/`, `android/.../ai/AiCoach.kt`, `docs/PUSH_PROTOCOL.md` | +| Process | Data exfiltration / network egress | Feature defaults, triggers, destinations and payloads are documented once in the [§1.1 inventory](#11-network-paths-canonical-inventory), including update checks and platform-service boundaries. No NOOP server, account or telemetry. Internet permission is not a destination allow-list. | App HTTP clients and source paths in §1.1 | | Oura history import | OAuth token / scope leakage, cross-account data mixing | Compiled out by default (`OURA_CLOUD_IMPORT`, §1.1b); tokens Keychain-only (`kSecAttrAccessibleAfterFirstUnlock`, never UserDefaults/plist); fixed OAuth scopes set at build time; raw + normalized rows partitioned under `deviceId = "oura-api"`; Oura's own scores kept reference-only (`ref_*`/`oura_*` metricSeries keys, never NOOP's Charge/Effort/Rest); `.cloudImport` is structurally priority-2 so it never seizes a WHOOP day; Forget Oura access purges tokens + every `oura-api` row incl. the raw archive | `Strand/Oura/OuraTokenStore.swift`, `Strand/Oura/OuraConnectModel.swift`, `Packages/WhoopStore/Sources/WhoopStore/OuraRawStore.swift` | | Filesystem | Broad disk access | Only `files.user-selected.read-write`; data stays in the sandbox container | `Strand.entitlements`, `Strand/Collect/StorePaths.swift` | | BLE frames | Malformed / adversarial packets | CRC8 + CRC32 (+ CRC16 for v5) gating; reject on failure | `WhoopProtocol/Framing.swift`, `Strand/BLE/FrameRouter.swift` | diff --git a/docs/SECURITY_PIPELINE.md b/docs/SECURITY_PIPELINE.md index 7e3449d5c6..997b2ebb2c 100644 --- a/docs/SECURITY_PIPELINE.md +++ b/docs/SECURITY_PIPELINE.md @@ -19,7 +19,7 @@ The previous `fork-testing-build.yml` and `fork-release.yml` release entry point | CodeQL Swift | Builds the iOS simulator target separately for extraction; serious findings fail. | SARIF and gate JSON | | Dependency Review | Checks pull request dependency changes for high-severity advisories. | workflow check | | mobsfscan | iOS/Swift SAST, pinned 1.0.0, error-severity findings fail. | SARIF and gate JSON | -| Unsigned device build | XcodeGen, `NOOPiOS` Release for generic iOS device, locked SwiftPM, no signing credentials. The actual `NOOP Staging.app` product is renamed to `NOOP.app` after compilation for the IPA handoff; the bundle contents are unchanged. Build failure fails. | unsigned `.app` tar | +| Unsigned device build | XcodeGen, `NOOPiOS` Release for generic iOS device, locked SwiftPM, no signing credentials. The target produces `NOOP.app` directly for the IPA handoff. Build failure fails. | unsigned `.app` tar | | Binary policy | Requires app, iOS widget, watch app, complication; rejects extra signable bundles, `.framework`, `.dylib`, and unrecognized Mach-O executables. Checks generated sensitive Info.plists against source baseline, rejects URL hosts absent from the reviewed runtime inventory, and verifies all four bundles are unsigned with no effective entitlements. Records linked libraries, SHA-256 and URL host strings. | binary JSON, handoff JSON | | MobSF IPA | On dispatched runs, scans the exact unsigned IPA in a pinned MobSF v4.5.2 container. Fails for any new high/critical finding, potential secret, tracker, or domain MobSF does not mark clean. The sole reviewed high finding is `NSAllowsLocalNetworking`, required by the user's local-network endpoint; the gate confirms that precise Info.plist setting. | full report and gate JSON | diff --git a/project.yml b/project.yml index 7d1458d5fa..f16f4d0e7c 100644 --- a/project.yml +++ b/project.yml @@ -343,7 +343,11 @@ targets: settings: base: PRODUCT_BUNDLE_IDENTIFIER: $(BUNDLE_ID_PREFIX).noop - PRODUCT_NAME: "NOOP Staging" + # NOT "NOOP Staging": 749c94118 dropped the .staging suffix from every iOS-family bundle id + # because iOS has no App Store app to install beside (NoopApp is closed), so the overlay served + # no purpose and only broke sideloading. The product name was missed by that same change. macOS + # keeps both halves deliberately (#39) and is unaffected. + PRODUCT_NAME: NOOP ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon # Ship every *.appiconset (not just the primary AppIcon) so the alternate "AppIcon-Navy" # set is compiled into the bundle and is available to UIApplication.setAlternateIconName. diff --git a/scripts/security/baselines/source.json b/scripts/security/baselines/source.json index 7e1db4163d..c3279a7f65 100644 --- a/scripts/security/baselines/source.json +++ b/scripts/security/baselines/source.json @@ -592,10 +592,10 @@ ".github/CODEOWNERS": "86488c46318e34e0aae62899f30889aae570745e1068ba69204cc52924c80f1d", ".github/workflows/android.yml": "43aaea2f0e02961122581a5e029a3d02fdc73f3ec3b4829f80409eebf0eb5797", ".github/workflows/app-build.yml": "b01ca5a7cefeb4d0ad6eb2b77e4b6e13c12c80f1316108a404ee806a6febd779", - ".github/workflows/fork-release.yml": "0116561cde728dea80cbf6a3cd04937459e168a26dd6b579b0b09f45e69f2c65", - ".github/workflows/fork-testing-build.yml": "3f2ae00ca629a2d20682784b645d3356d86d2042910b7a52ced45567de64da86", + ".github/workflows/fork-release.yml": "9b0018a14ca6b4b00b97c17bb3be27a04d45e3b96b9e00055ceb68a682a45d55", + ".github/workflows/fork-testing-build.yml": "6177f4bc67d7ef97daac2d25e8e573fb3c9a5c18f2bad76aae3f320a753f6a9d", ".github/workflows/i18n-coverage.yml": "99096a08d723c3882559a5b6ee3ea2cfd7a990b9a01f847b234589d58eda44dd", - ".github/workflows/noop-security.yml": "f98fea3108f51b48af7293fd0c447e2921197c26892e5006317fb5fd5f61a24d", + ".github/workflows/noop-security.yml": "89bb36c13e1e03720c830389e725d98305757a224003321b05778a1ced50b527", ".github/workflows/noop-upstream-release.yml": "7567b1c7acfa341184564cac27871224c79e9e139f30c3eadcfaa163e9299fae", ".github/workflows/parity-governance.yml": "43eba17282a2efb7976516115ba76cb278bcf8ec0b15ca96fbf37d8051d66393", ".github/workflows/prune-stale-branches.yml": "598bd662a0172730c17e33898ced4dce6ef5d38f4eadff6d147bb6363d36cfc2", @@ -679,7 +679,7 @@ "android/gradlew": "fc977a94723af68aaffa4e5d60496fb4aeed1884b6b19e5e2f2fd7612673313d", "docs/protocol-examples/check_source_references.py": "dce0d33becfc69a42a847ede982799f88f4f6c83ddc34a4a371589d82cc6fc03", "docs/protocol-examples/validate_examples.py": "c94878750e993523d23883934fe81b393f8de3b349f37ef4e5fc34107acbceb5", - "project.yml": "4491ffa4705b44c0c65bd344bb3d7f5f0977a89bff429a744f00baa88cf333a6", + "project.yml": "7ed9055e556f5f41eaf9f9ea88d7ece7d44d689408ba80bfd1041874fe901052", "scripts/security/auto_update.py": "514a480ad272c1159bca0bd2dee76fb15cc9ae57f6426169c9b0348afca48478", "scripts/security/binary.py": "d7f5a39aa0e9745c4ce31b7df103b0361461eb52a695a8031c69644ef2ec1e41", "scripts/security/enable-background-install.py": "1847a9a4bcca0b9e069b5196878aa0227ad4a3131db87958d53e68248b13d0e2",