Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
9f98f81
Release 12.0.0: add to AltStore source
ryanbr Oct 4, 2026
8e94d55
fix(ios): name the app NOOP, and say per-platform whether a build ins…
ryanbr Oct 7, 2026
86e880d
docs: align network privacy guidance with current clients
UtkuDenizAltiok Oct 8, 2026
466b0ff
test(android): exercise the production vital range filter
kavemang Oct 8, 2026
9160761
refactor(android): use platform CRC-32 with Swift oracle tests
kavemang Oct 8, 2026
6916f1a
test(alarm): cover production daily scheduling and DST rollover
kavemang Oct 8, 2026
54196f0
perf(hrv): reuse local-neighbour buffers during interval cleaning
UtkuDenizAltiok Oct 8, 2026
88654a0
fix(stress): discard results from cancelled screen loads
UtkuDenizAltiok Oct 8, 2026
bba256f
test: pin optional Charge baseline eligibility
UtkuDenizAltiok Oct 8, 2026
0b72940
charge: omit unusable optional personal baselines
UtkuDenizAltiok Oct 8, 2026
351b57c
fix(battery): round the sidebar and onboarding battery percent like t…
matthiasthomas Oct 8, 2026
4d40dda
fix(stress): gate every stress read on the union HR witness
ryanbr Oct 9, 2026
13468a1
Merge upstream/main through 4d40dda7 into the Apple test branch
alexanderschmidt2 Oct 9, 2026
85d5cd6
Use the upstream NOOP app name in the security handoff
alexanderschmidt2 Oct 9, 2026
bdbbad0
Review upstream workflow and iOS product-name policy hashes
alexanderschmidt2 Oct 9, 2026
723adc9
Center the pending-sync hint below Effort without layout changes
alexanderschmidt2 Oct 9, 2026
41d07ac
Center training glyphs and inset Dynamic Island content
alexanderschmidt2 Oct 9, 2026
8d902b5
Merge pull request #23 from AlexSchmidt1999/codex/today-sync-placement
AlexSchmidt1999 Oct 9, 2026
bd07021
Merge pull request #22 from AlexSchmidt1999/codex/training-island-layout
AlexSchmidt1999 Oct 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions .github/workflows/fork-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/fork-testing-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 0 additions & 3 deletions .github/workflows/noop-security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 []
}
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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
Expand All @@ -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",
Expand All @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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..<nn.count {
let lo = max(0, i - ectopicWindowRadius)
let hi = min(nn.count - 1, i + ectopicWindowRadius)
var neighbours: [Double] = []
neighbours.reserveCapacity(hi - lo)
neighbours.removeAll(keepingCapacity: true)
for j in lo...hi where j != i { neighbours.append(nn[j]) }
guard neighbours.count >= 2 else { kept.append(nn[i]); continue }
let med = median(neighbours)
Expand Down Expand Up @@ -181,10 +182,12 @@ public enum HRVAnalyzer {
// rejectEctopic returns the input unchanged for a series this short.
for k in 0..<rangedVal.count { keptOrig.append(rangedIdx[k]); keptVal.append(rangedVal[k]) }
} else {
var neighbours: [Double] = []
neighbours.reserveCapacity(2 * ectopicWindowRadius)
for i in 0..<rangedVal.count {
let lo = max(0, i - ectopicWindowRadius)
let hi = min(rangedVal.count - 1, i + ectopicWindowRadius)
var neighbours: [Double] = []; neighbours.reserveCapacity(hi - lo)
neighbours.removeAll(keepingCapacity: true)
for j in lo...hi where j != i { neighbours.append(rangedVal[j]) }
let keep: Bool
if neighbours.count < 2 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,13 @@ extension RecoveryScorer {
// unusable one, so without the same gate here the trace would list an rhr term the score
// did not use, which is precisely the divergence the line below promises cannot happen.
let rhrB = rhrBaseline.flatMap { $0.usable ? $0 : nil }
// Only eligible personal baselines may appear in respiration rows or diagnostic terms.
let respB = respBaseline.flatMap { $0.usable ? $0 : nil }

// The score the dashboard reads, verbatim, so the trace cannot diverge from it.
let score = recovery(hrv: hrv, rhr: rhr, resp: resp,
hrvBaseline: hrvBaseline, rhrBaseline: rhrB,
respBaseline: respBaseline, sleepPerf: sleepPerf,
respBaseline: respB, sleepPerf: sleepPerf,
skinTempDev: skinTempDev)

// Cold-start gate: HRV baseline not usable -> recovery() returns nil before any term is built.
Expand All @@ -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)")
}
Expand Down Expand Up @@ -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)")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?,
Expand All @@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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..<count).map { i in
if mixed && i % 31 == 0 { return 50 }
if mixed && i % 47 == 0 { return 1500 }
return 800 + Double((i * 17) % 101) / 4
}
}

private var cleaningOracleFixtures: [[Double]] {
var fixtures = (0...8).map { cleaningOracleInput($0, mixed: true) }
fixtures += [[.nan, .infinity, -.infinity, 800, 801, 802],
[300, 299, 800.25, 1500, 801.5, 2000, 2001],
[800, 960, 800, 960, 800, 960, 800],
[800, 960.0000000000001, 800, 960.0000000000001, 800],
[2000, 2000, 1600, 1600, 300, 300, 300]]
for count in [300, 36000, 108000] {
fixtures.append(cleaningOracleInput(count, mixed: false))
fixtures.append(cleaningOracleInput(count, mixed: true))
}
return fixtures
}

func testCleanRRPreservesOriginalBits() {
let expected = originalOracle.split(separator: "\n").map { $0.split(separator: "|") }
let fixtures = cleaningOracleFixtures
guard expected.count == fixtures.count else { XCTFail("Oracle fixture count changed"); return }
for (index, input) in fixtures.enumerated() {
let before = input.map(\.bitPattern)
let clean = HRVAnalyzer.cleanRR(input)
XCTAssertEqual(String(clean.count), String(expected[index][1]), "case \(index)")
XCTAssertEqual(cleaningOracleHash(clean.map(\.bitPattern)), String(expected[index][2]), "case \(index)")
XCTAssertEqual(input.map(\.bitPattern), before)
// Stop on a mismatch so an uncleared-buffer mutation never reaches the large fixtures.
guard String(clean.count) == String(expected[index][1]),
cleaningOracleHash(clean.map(\.bitPattern)) == String(expected[index][2]) else { return }
}
}

func testGapAwareCleaningPreservesOriginalBitsAndAdjacency() {
let expected = originalOracle.split(separator: "\n").map { $0.split(separator: "|") }
let fixtures = cleaningOracleFixtures
guard expected.count == fixtures.count else { XCTFail("Oracle fixture count changed"); return }
for (index, input) in fixtures.enumerated() {
let before = input.map(\.bitPattern)
let clean = HRVAnalyzer.cleanRRGapAware(input)
XCTAssertEqual(String(clean.nn.count), String(expected[index][1]), "case \(index)")
XCTAssertEqual(cleaningOracleHash(clean.nn.map(\.bitPattern)), String(expected[index][3]), "case \(index)")
XCTAssertEqual(cleaningOracleHash(clean.contiguous.map { $0 ? 1 : 0 }), String(expected[index][4]), "case \(index)")
XCTAssertEqual(input.map(\.bitPattern), before)
guard String(clean.nn.count) == String(expected[index][1]),
cleaningOracleHash(clean.nn.map(\.bitPattern)) == String(expected[index][3]),
cleaningOracleHash(clean.contiguous.map { $0 ? 1 : 0 }) == String(expected[index][4]) else { return }
}
}
}
Loading