Skip to content

Import v8.2.2 snapshot (upstream catch-up + board wave; ports PRs #1035/#1036 + BLE/readiness fixes) - #1

Merged
ryanbr merged 163 commits into
mainfrom
snapshot-v8.2.2
Jul 6, 2026
Merged

ryanbr merged 163 commits into
mainfrom
snapshot-v8.2.2

Conversation

@ryanbr

@ryanbr ryanbr commented Jul 6, 2026

Copy link
Copy Markdown
Owner

What

Brings ryanbr/noop up to the v8.2.2 snapshot taken from silentestshadows/noop@f099af09 (current upstream main + the 2026-07-06 board fix wave).

Why

ryanbr/noop:main was 158 commits behind upstream. The v8.2.2 snapshot catches the fork up and lands, among other things, faithful Android parity ports of the 8 PRs from this cycle — each credited by PR number in-code:

  • #1035 HrvAnalyzer.rollingRmssd minBeatsPerWindow=8 gate (drop 2-beat noisy spikes)
  • #1036 HrvAnalyzer.rollingRmssd stepSec thinning stride (day-scale flood guard, window/8)
  • HrBroadcaster radio-restart, WhoopBleClient @Volatile + RR rounding + cancel-stale-reconnect, SourceCoordinator serialize (Android thread-safety / BLE reliability)
  • ReadinessEngine locale-safe ACWR format + evaluate() memo

Plus the wider v8.2.2 wave: i18n, UI/sleep-visual fixes, corrupt-DB quarantine handler, terms-gate clickwrap, AltStore build 172.

Notes

  • Head snapshot-v8.2.2 points at the exact v8.2.2 head (f099af09); no local edits on top — it's a clean import of the published snapshot.
  • Base main currently carries 10 fork-only commits (the unified fork CI workflows + a few earlier merged testing PRs). Review the merge result for those before merging — the CI workflow files may need re-applying on top after this lands.

NoopApp added 30 commits June 30, 2026 10:24
…scrub a name leak

PRs reimplemented under our identity (cross-platform parity, Hevy CLOUD sync declined, on-device only):
- #827 dismissible "building your baseline" calibrating note (+ iOS parity)
- #831 strap firmware version on Devices (+ iOS/mac parity)
- #846 Teams notifications moved to Messaging (+ iOS parity)
- #854 O(1) Android frame reassembler (offset, no per-frame copy)
- #855 ranged CRC validation, no per-frame sub-array (Swift + Kotlin)
- #835 Apple Health WORKOUT read only (the Hevy cloud sync is out of scope)
- #830 Spanish + #837 Italian string-catalog completion

Bug fixes:
- #845 active-workout stat bubbles no longer clipped (Android)
- #821 alarm screen consistency (strap label, explainer position)
- #849 Today no longer re-runs the full history reload on every re-mount (the Apple-Health-import lag)
- #857 Oura export import accepts the real export shape with an honest error
- #823 imported-only days now compute a Charge/Rest from their metrics (source-only scoring)

Hardening:
- anonymize-macos-app.sh now scrubs the WHOLE bundle (embedded extensions/frameworks) and re-signs inside-out
- removed the maintainer first name from 16 source/doc comments (anonymity)

macOS widget (PR #828) deferred: App Groups need real signing, which the unsigned macOS distribution lacks, so it would not populate. Parked pending a signing-free IPC.
New issues folded in:
- #859 iOS Backup folder picker: open-in-place (asCopy false) so a folder is selectable
- #860 UX: Today defaults to the current day after an update (not the first-ever day); the More tab remembers which groups are expanded; Insights Q&A rolls over by date instead of pinning stale answers under "Today"
- #861 sleep: merge a single overnight that was being split into a nap plus a sleep

Hardening:
- build-v7-artifacts.sh now does an anonymity source-scan and refuses to build if a maintainer name reaches tracked source
- scrubbed three stray em-dashes off comment lines edited during the name scrub
…em 1)

Retire the launch "auto-land on your most recent day with data" (#605/#739): a
fresh launch now lands Today on today (offset 0) unconditionally, even when today
has no data yet and the only banked data is days back (the calibrating-user-after-
update case the v7.6.0 snap-to-today did not cover, since the auto-land overrode it).
In-session day memory (#739/#614) is preserved.

Extracted a pure launchDayOffset(isFreshLaunch, savedOffset, hasTodayData,
latestDataDayBack) helper on both platforms and pinned it with a deterministic test
(testFreshLaunchLandsOnTodayEvenWithOnlyOldData: fresh + no-today + data 5 days back
-> 0, not 5) that runs and passes on Swift (StrandTests) and Kotlin (JVM).

Lockstep bump: iOS 163 / Mac CFBundle 194 / Android vc 251.
… + Android)

A user supplied the actual per-file schema of a real Oura account export. Correcting
the parser against it, evidence-based:

- HEADLINE: real Oura export CSVs are semicolon-delimited; the reader split only on
  commas, so every Oura row parsed as one unsplit field and extracted nothing. Added a
  quote-aware per-file delimiter sniff (; / , / tab), defaulting to comma so the
  WHOOP/Fitbit comma path is byte-for-byte unchanged.
- SpO2: read Oura's real nested spo2_percentage.average (was a flat key that never hit).
- VO2max: now ingested (vo2_max from the vo2max file) -> vo2max metric series (feeds
  Fitness Age, same as Apple Health).
- Daily activity distance (equivalent_walking_distance), calorie alias order.
- Per-category bare `score` columns disambiguated (readiness vs sleep) instead of
  mislabelled; activity-score rows left out.
- Robustness: per-category single files recognised on their own columns; `deleted`
  sleep periods skipped; unknown file types/columns skipped cleanly, never an error.

Tests (Swift 13 + Android 15, all green): real-schema semicolon per-category CSVs,
nested spo2/vo2 from JSON, semicolon delimiter detection.
…oid)

A 5/MG owner enabled the strap wake-alarm; iOS said "Armed on the strap itself" but the
strap never buzzed. Their export showed NOOP's own log: "5/MG firmware alarm needs the
Experimental toggle (unconfirmed), not armed" , armStrapAlarm only arms a 5/MG when
PuffinExperiment is on, otherwise it returns without arming, yet the UI still claimed it
was armed. Honest-data violation.

Mirror Android's honest surface on iOS: when a 5/MG is detected and Experimental is off,
the card now says the wake time is saved but the strap is NOT armed until Experimental
mode is on (Settings, Experimental), and that even then a 5/MG strap wake is unconfirmed
so keep a backup, instead of implying it is armed. WHOOP 4.0 wording unchanged.

Adds AppModel.whoop5Detected (forwards BLEManager.isWhoop5).
…both platforms)

The day-scale read already uses off-main SQL buckets, but the RAW (zoomed-in) branch
of Repository.timelineSeries dedup'd + sorted + mapped up to 200k HR points
SYNCHRONOUSLY on the @mainactor, a real beach-ball for a large-history user inspecting
the Deep Timeline. Moved that post-processing into a Task.detached(.utility), mirroring
restageFromRaw, via two nonisolated static helpers (dedupSortRawHr / dedupSortDownsampleRaw);
the store reads stay on the WhoopStore actor. Output byte-identical (first-id-wins dedup,
ascending sort, same isRaw/bucketSeconds).

Android parity: FullDayChartScreen.readTimeline ran the same 200k-row map + downsample on
Dispatchers.Main (called from a LaunchedEffect with no withContext). Wrapped its body in
withContext(Dispatchers.Default); Room queries still run on Room's executor.

Tests: testRawHrDedupSortIsOffMainPure / testRawNonHrDedupSortIsOffMainPure assert
dedup (first wins on overlapping ts) + ascending order off the actor.

NOTE: this is the zoom-case freeze. phsycology's ON-OPEN macOS freeze (#833) is a
separate, not-yet-pinned main-thread stall; a hang watchdog in the diagnostic export is
next so the reporter's capture pinpoints it. Does not claim #833 closed.
…ce Keychain write failures (#872)

#873 (real, low): refreshModels() is @mainactor with one suspension point (the model
fetch). It re-read provider.modelOptions FRESH after the await while the fetched ids were
the OLD provider's, so switching provider mid-fetch wrote a stale/mixed list to the new
provider. Now it captures the provider before the await and `guard provider == captured
else return` on resume, building the merge from the captured snapshot. DEBUG-only fetch
seam + a test (switch mid-fetch, assert only the new provider's models survive).

#872 (defensive): AIKeyStore.save discarded the SecItemAdd OSStatus and wrote the owner
marker unconditionally. The described broken-state impact does NOT occur (isConfigured/
hasKey read the live Keychain, not the marker), but a discarded credential-write status is
worth handling: save now returns Bool, skips the marker on failure, and setKey surfaces it.

Android already had both guards (captured-provider check; atomic key+owner write), so this
brings iOS to parity. Reimplemented under our identity; the external PRs are not merged.
…auses

The Deep-Timeline raw fix (acf8f77) handled the zoom case; these are the on-OPEN and
Insights stalls the audit pinned:

1) On open: Repository.reconcileWorkoutHrWithTrace ran a sequential per-row loop (cap 300),
   each awaiting store.hrSamples(limit: 8000) one at a time, then reduce/max over up to 8000
   ints SYNCHRONOUSLY on the @mainactor. Rewritten in 3 phases: eligibility in original order,
   bounded-concurrency reads (withTaskGroup, chunks of 8) with the reduction OFF the main actor
   (nonisolated reduceWorkoutHr returning Sendable scalars), then in-order reassembly.
   Byte-identical output. Test: WorkoutHrReduceTests (arithmetic + asserts it runs off-main).
2) Insights: the macOS sidebar's .id(selection) cold-mounts each screen on every switch, and
   Insights (unlike Today) had no remount cache, so it re-read full history each time. Added a
   Repository-level insightsCache/insightsLoadedSeq mirroring Today's pattern; load(allowCache:)
   restores the snapshot when refreshSeq + dayKey are unchanged. Crossfade kept.

Android already safe on both (SQL-aggregate hrWindowStats off the IO executor; Compose nav has
no .id teardown). Does not over-claim , phsycology to confirm on the next build.
…t a partial top-up inflate days-left

#7: the Swift "~X days left" badge read an in-memory buffer fed only by live BLE events, so after
a reconnect it restarted empty (Android seeds from its persisted battery table). Now bootstrapStore
seeds LiveState.batterySamples from store.batterySamples over a 14-day window (new seedBatterySamples
setter, de-duped against live points), so the estimate is measured immediately after reconnect.

#8: BatteryEstimator reset the discharge fit on ANY SoC rise >1pp, so a 40->55% top-up discarded the
long clean discharge run and inflated days-left exactly like a full charge. Now a rise only anchors a
new run when it lands near full (>=90%); a partial top-up is stepped over and the fit uses the longer
pre-top-up segment. Swift + Kotlin identical. Tests on both platforms (incl. the [(0,100),(60h,40),
(61h,55),(64h,53)] scenario -> honest ~53h, not inflated). #713 near-full reset behaviour preserved.
…arity) + Swift steps-trace parity

#9: HealthConnectImporter flat-summed every StepsRecord/calorie record across ALL data sources, so a
user with two HC sources (phone + watch logging the same walk) got ~2x steps and calories. Ported the
#589 per-source-MAX de-overlap already on iOS/mac + the Android XML importer: DayAcc now keys steps/
totalKcal/activeKcal by dataOrigin.packageName (sum within a source, MAX across sources) in both the
import and the live refreshTodaySteps top-up. Test: two sources -> MAX, not sum.

#12: the Swift steps-trace rawCounterTrace lacked the zero-counter noRawCounter branch its Kotlin twin
has, so the two platforms emitted different text on a counter-less day. Added the matching branch
(byte-identical wording) + parity tests.
…aches the daily rollup (#17)

#0: SleepWindowReclip was START-BLIND (reclip(stagesJSON, sessionStart, oldEnd, newEnd)), so a
bed/onset edit on an imported or not-yet-synced night left stages BEFORE the corrected onset and
the displayed "time in bed" / hypnogram no longer matched (wake - onset). Android was worse: it
always reclipped against the OLD effectiveStartTs. Made reclip START-AWARE on both platforms
(adds newStart): reclipSegments now drops segments wholly before newStart and clips the straddling
one; reclipMinutes derives the delta from BOTH boundaries ((newEnd-newStart) - (oldEnd-sessionStart))
so a later onset trims the front. Callers (Repository.editSleepTimes, WhoopRepository.updateSleep-
SessionTimes, SleepScreen optimistic copy) now pass the new onset. Tests on both platforms cover a
bed-only edit on a segment night and an imported minute-dict night.

#17: imported Oura/Garmin respiratory rate was parsed onto the sleep SESSION but the day-rollup type
had no resp field, so DailyMetric.respRateBpm stayed nil and the resp term dropped out of imported
Charge. Added WearableDailyRow.respRateBpm, folded the night session's resp into the day row in the
Oura JSON+CSV and Garmin parsers (and the Android twin), and wired the WearableImporter mappers so
it actually reaches DailyMetric. Tests on both platforms.
…arbiter (#5)

#4/#6 (iOS): the iOS smart alarm only armed the strap firmware buzz and posted a local notification
AFTER the strap reported it fired, so a failed strap buzz or a suspended phone past day one meant NO
wake at all. applySmartAlarm() now also schedules a REPEATING daily UNCalendarNotificationTrigger
(the WindDownNudge idiom: lives in the notification center, survives relaunch) at the wake time per
selected weekday, gated on the wrist-alerts master + notification auth, replaced on every re-arm and
cancelled on disarm. HONEST: it is a best-effort backup, not a guaranteed loud alarm (a sideloaded
build has no critical-alert entitlement, so Focus/silent can still mute it) , the honesty card now
says so and still tells users to keep their Clock alarm.

#5 (Android): "Buzz WHOOP 4/5" and the "Strap wake-alarm" both drove the single firmware-alarm slot
with no coordination, so toggling one off ran disableStrapAlarm() and killed the other. Introduced
one reconcileStrapAlarm() as the SOLE caller of arm/disable: it computes both requested times and
arms the slot to the EARLIEST when either is on, else disarms. All six call sites routed through it.
Test: both on, turn one off, slot stays armed to the other.
…gs silent write-stalls (#13)

#1 (both platforms): in finishChunk the trim==0xFFFFFFFF "no banked history, fully charge it" gate
read sessionRowsPersisted at the TOP of the function, but this chunk's rows are tallied LATER (after
store.insert). So a bad-clock/flash strap that emits records on its no-cursor END logged the alarming
false "no history" line even though the END just delivered rows. Relocated the gate to AFTER
`sessionRowsPersisted += tally.rows` (and before setCursor); the genuinely-empty session path is
preserved (empty frames skip the persist block, gate still sees 0 rows). End-to-end test on Swift
(real v25 record frames + a 0xFFFFFFFF END) + a Kotlin ordering-contract test.

#13 (Android only): two failure returns in Backfiller.kt (decoded-insert catch, cursor-write catch)
returned silently with no log(), while the Swift twin logs both. Added the matching log lines so a
#601-class write-stall (history stalls while live HR still works) is falsifiable in the Android strap
log. The third candidate point already logs and was left untouched.
Between 00:00 and 04:00 (the logical-day rollover window) three top-bar paths showed a date one day
ahead of the data:
#14 (macOS): DayNavBar dated its label from raw Date(); added a `today: Date` logical anchor to the
init and the macOS call site now passes Repository.logicalDay(Date()), so the navigator date and the
shown data share one source of truth (the picker setter + range cap moved to the same anchor).
#15 (iOS): the Today date label at offset 0 now anchors to the resolver's surfaced row (repo.today?.day),
matching Android, instead of selectedLogicalDay.
#16 (iOS): the day-picker getter/setter + the nav label used raw Date(), so the graphical picker
pre-highlighted the wrong day (and the a11y label mismatched at offset >= 2). All three now use the
logical anchor via a new pure pickedDayOffset(pickedDate:anchorLogicalDay:) helper, with rollover tests.
On Apple the route polyline lives only in RouteStore, a side-store keyed by "<startTs>|<sport>".
The edit save path (Repository.saveManualWorkout) deleted+upserted the DB row on a natural-key
change but left the route under the OLD key, so editing a GPS workout's sport or start time
orphaned its route + distance from the detail view. Now, in that branch, the route is re-keyed:
load under the old key, and only if present, store under the new key + remove the old. Guarded so
a route-less session is a clean no-op. Android needs no change , its route is a column on the row
(WorkoutRow.routePolyline) and preservingCaptured carries it through the edit. Tests cover re-key
and the no-route no-op.
…zones/Effort (both platforms)

Editing a workout's Avg HR applies the typed number but preservingCaptured keeps the original
strain/zones/HR-graph, so the detail view showed an edited average over an unchanged curve with no
indication. We do NOT fabricate a re-score from a single number, instead we disclose it honestly:
the edit sheet shows "Avg HR is shown as typed. The HR graph, zones and Effort stay from the recorded
session." when the edit changes avg on a captured-strain/zones row, and the detail view adds a caption
under the HR card when the shown average diverges from the trace mean by more than 3 bpm. Mirrored on
Android via a pure WorkoutEditing.avgHrEdited helper (5 tests).
…tops getting missed (#897)

Two users in a row (#897 + a Discord report) could not find the Oura ring in Add a device because it
sat LAST under Experimental, below Amazfit, Mi Band and Garmin, so it fell below the fold and they
never scrolled to it. Oura is a flagship beta we are actively promoting, so it should not be buried.
Pure reorder (no behaviour change) on both platforms: the Experimental order is now Oura ring, Amazfit
/ Zepp, Xiaomi Mi Band, Garmin. The four standard options above the heading are unchanged.
…plements #896, thanks ryanbr)

WhoopBleClient's _state is a MutableStateFlow over an immutable LiveState data class, mutated with
~52 non-atomic _state.value = _state.value.copy(...) writes from THREE threads (the GATT binder
thread, the main Looper, and the IO dispatcher). A concurrent write could clobber a sibling field;
the sharpest case was parseStandardHr re-asserting bonded=true on every HR tick while a main-thread
teardown set it false, leaving a phantom-connected strap with frozen HR (5/MG especially). Converted
every write to an atomic _state.update { it.copy(...) } CAS (the helpers are pure, so the retry loop
is safe), handling the charging-site `it` shadow with a named param. Pure reads untouched, output
byte-identical. iOS/macOS LiveState is @mainactor + per-field @published, so it is structurally immune.
Reimplemented under our identity (PR not merged).
) + honest Rest tile (#898)

#863: a calibrating 4.0 banks sparse history fine, but two surfaces showed empty. (a) The Deep
Timeline auto-landed only when a scored DailyMetric existed, stranding a calibrating user on an empty
today; Android now falls back to the latest day with raw HR (latestHrSampleTs) when no scored day
exists (pure landTargetDayStart helper + test). iOS already landed on the raw-HR day, no change. (b)
The Today heart-rate card did `if (buckets.size < 2) return` and rendered nothing on a sparse/empty
day (read as "the graph froze"); both platforms now show an explicit calibrating/empty state instead
of vanishing (the past-day-swap was declined in #605).

#898: an aggregate-import user (Health Connect HRV/RHR, no in-bed session) gets Charge from
WatchRecovery but no Rest, because that path writes recovery and never sleep_performance. We do not
fabricate a Rest number; instead the Rest tile now shows "needs a tracked night" when there is a
recovery but no scored sleep, on both platforms, instead of a bare "No data". (A real Rest for HC-only
users needs Health Connect sleep ingest, tracked separately.)
…ve (#839, reimplements #879)

When a workout is active, Today now shows an indicator card with the sport + a live elapsed clock;
tapping it opens the in-exercise Live screen. Detection reads the existing activeWorkout source of
truth (survives an app kill), so the card auto-appears and clears with no new lifecycle wiring. The
card is a perf-isolated leaf with its own clock (TimelineView on iOS, a delay-tick on Android) so the
per-second tick never re-renders Today's body. Elapsed shows M:SS, then H:MM:SS past an hour.

Reimplemented under our identity from mvanhorn's PR with three fixes (resolve against the new
DayNavBar(today:) signature, drop a redundant monospacedDigit, add the >1h format) AND the full
Android port the PR skipped (the contributor only did iOS). Tests on both platforms.
…#895, thanks dpguglielmi)

Folded the Italian gap-fill from dpguglielmi's PR (the contributor's helper script was NOT taken in,
only the strings). 1265 new it values, placeholder integrity validated (205 with format specifiers,
zero mismatches), de/es/fr/ru and our existing Italian untouched, catalog now 1730 keys. Em-dashes
stripped from the new Italian values per our no-em-dash house rule (clause-break dashes -> commas; the
legit no-data placeholder kept). The English SOURCE strings still carry their existing em-dashes
(they regenerate from Swift source), tracked separately for an all-languages source sweep. A native
Italian skim of the longest onboarding/disclaimer strings is a recommended follow-up quality step.
…#829)

The Deep Timeline already had a full zoom/pan engine (both platforms), so the only iOS gap was the
Today HR chart, which used the same OverviewHRChart but passed no zoom binding. Wired a hrZoomDomain
window into it (pinch zooms about centre, drag pans, both clamped to the loaded day with a 60s floor),
added a shared double-tap-to-reset + reduce-motion gating to the chart gesture modifier, and a
reclamp rule so a day step opens full-scale while a same-day live extension keeps your zoom. No
per-frame DB read (Today's buckets are already loaded). 6 tests on the reclamp math. Android's Today
HR chart is the remaining mirror (its chart has no reusable zoom binding), tracked as a follow-up.
…STREAMING pill (#903)

#900 (critical, both platforms): live Oura HR never streamed. The enable triplet (dhr_read, enable,
subscribe) stalled at step 0 because handleSecureFrame only recognized enable-ACK sub-ops 0x23/0x27,
but the first command's ACK is sub-op 0x21 (OURA_PROTOCOL s5.6 step 1). Unrecognized, it returned
.unhandled, the sequencer never advanced, and no live HR ever arrived. Added 0x21 to the enable-ACK
set on Swift and the identical gap on Android, with a decode test each. Also resolves discussion #858.
Reimplemented from pipiche38's report, thanks for the find.

#903: the menu-bar pill never showed STREAMING for a live Oura ring because OuraLiveSource set no
positive signal. Added a dedicated streamingLiveHR flag to LiveState (NOT bonded, which carries WHOOP
encrypted-bond + buzz semantics), set on the Oura streaming branch and cleared on teardown, and drove
the green pill off it (bonded kept as the WHOOP fallback). macOS-only by nature (no Android menu bar).
…e tree (#902, thanks pipiche38)

These v2.8.2..2.8.8 macOS zips were committed before dist/ was gitignored and lingered as tracked
blobs. dist/ is gitignored, nothing in the tree references them (the only NOOP-v2.8 hits are iOS .ipa
release URLs in altstore-source.json), and they are already published as GitHub release assets, so
removing them loses nothing. Leaner tree, fewer old binaries. History is unchanged (a filter-repo
purge is a separate, heavier call).
IntelligenceEngine.analyzeRecent opened with `guard !computing else { return }` and silently dropped a
force:true post-sync recompute that landed while a 15-min idle pass was mid-flight. On a WHOOP 5/MG
segmented offload that window is wide enough to intermittently strand a freshly-synced night un-rescored,
so Today falls back to the last scored day (frazzle28 #899). Now a forced call that hits the guard sets a
pendingForcedRescore flag; the defer clears the lock and, if set, re-invokes once (single re-arm, no loop).
5 tests on the (computing, force) -> drop|rearm|rerun state machine. Android needs no change: its
IntelligenceEngine is a stateless object with no shared computing lock, so a forced rescore is never
dropped there (verified, not assumed). The stale carry-over bounds (B/C) await frazzle28's export.
…@63 (clean-room from #901)

The @63 activity_class (0=still,1=walk,2=run, #316/j0b-dev) was decoded into the StepSample model on both
platforms but DROPPED at the storage boundary, so it could never be read or shown. Plumbed it end-to-end:
Apple GRDB migration v18->v19 adds a nullable stepSample.activityClass, the StreamStore INSERT now writes
it, and Reads.swift maps it back; Android Room migration 12->13 + the WhoopRepository insert that was
dropping it. Then a subtle still/walk/run glyph on a REAL (measured) Steps tile, both platforms, tinted as
part of the tile, self-hiding when the class is unknown (never a fabricated 0/still). Migrations are
additive/nullable so existing rows read absent. Round-trip + migration tests on both platforms.
(The tigercraft4 PR botched this on Apple, SELECTing a column it never created; this is the correct chain.)
…from #901, no duplicate sheet)

Lifted only the good parts of the #901 coach work, inline in the EXISTING CoachView (not a second
settings sheet that duplicated the provider/key/model UI). An editable system prompt (collapsed by
default, TextEditor + Reset, persisted in UserDefaults / NoopPrefs, read fresh per send, blank clears
it), and a single derived Baevsky stress-index line added to the coach context under the SAME dataConsent
gate as the HRV/RHR already sent, computed the same way StressView does, no raw R-R egress, absent when
there are too few clean beats. Both platforms, BYOK untouched, tests on both.
ryanbr added a commit that referenced this pull request Jul 16, 2026
…description typos (#492) (#496)

* Today (Android): unclip the day title + drop the bogus Strain % (#492)

Two Android-only visual bugs from #492 (iOS already handles both correctly):

- #1: the tappable day-title Column had .clip(RoundedCornerShape(cornerSm)) with
  indication = null — no ripple to shape, but the rounded corners clipped the
  title/date text's bottom-left (the 'W' of 'Wednesday'). Drop the clip; the
  clickable still defines the tap area. iOS uses contentShape(Rectangle()), no clip.

- #8: the Strain Key-Metrics tile hardcoded unit = "%". Strain/Effort is a load
  index (0–21 WHOOP / 0–100 NOOP), not a percentage, so the % was wrong — most
  visibly on the 0–21 scale ('5.7%'). Recovery/Rest are genuine 0–100 % and keep
  it. iOS shows the strain axis as an 'of 21'/'of 100' caption with no %.

compileFullDebugKotlin: BUILD SUCCESSFUL. Reported by bartmuskala.

* Trends (Android): fix two metric-description punctuation typos (#492 follow-up)

Spotted in the #492 screenshots (not in the reporter's list): the Charge/Sleep
metric-explorer descriptions had a space before the comma, and the Sleep one
also used a comma where iOS uses a colon. These are documented to 'match the
macOS MetricCatalog entries exactly' (TrendsExploreScreen :190), so this
restores that parity:
  - 'you are , led'      -> 'you are, led'
  - 'sleep was , dura...' -> 'sleep was: dura...'  (matches MetricCatalog.swift)

String-literal only; no logic.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…8.2.2 elsewhere)

Resolves PR ryanbr#1 (ryanbr/noop) so main can adopt the v8.2.2 snapshot while retaining
the fork-only pieces. The 9 conflicting files were all cases where the fork's perf
(ranged CRC, O(1) Reassembler drain) and firmware-version-display work had ALREADY
been reimplemented independently in v8.2.2 — same features, different variable names
and comments. Taking v8.2.2 for those keeps a single upstream implementation instead
of a divergent fork copy; the features are retained (TodayScreen v8.2.2 is even a
superset: ryanbr#827 calibrating-dismissal plus a new carried-sleep card).

Kept (non-conflicting, fork-unique): the unified fork testing-build CI workflow,
fork debug keystore, fork appId/identity (build.gradle.kts, project.yml), and the
fork update-source plumbing (UpdateCheck.kt / UpdateChecker.swift).
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
Import v8.2.2 snapshot (upstream catch-up + board wave; ports PRs ryanbr#1035/ryanbr#1036 + BLE/readiness fixes)
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…to the notes

Each release's GitHub body is now: the curated docs/releases/<TAG>.md notes + GitHub's
auto-generated "What's Changed" breakdown (every merged PR since the previous RELEASE, with
authors/links + New Contributors + full-changelog compare link) + the downloads footer.

Uses the generate-notes REST API (deterministic; not dependent on gh flag-combining) diffed from
the most recent NON-prerelease release (so testing-latest snapshots are ignored). Skipped on the
very first release (no prior tag would diff to repo start). Best-effort — a failure is ignored.
Verified: the API returns the PR list (ryanbr#1..ryanbr#9); first-release PREV detection returns empty as expected.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…ck skew

Rounds out the Alarm block to cover the full alarm-failure matrix, not just 'didn't stick':
- ryanbr#1 last fired: persist STRAP_DRIVEN_ALARM_EXECUTED → 'Last fired: Nh ago / never observed'
  (the 'did the strap actually buzz?' leg of the armed→reports→fired sequence).
- ryanbr#3 model + 5/MG experimental gate → 'Model: 5.0/MG · experimental: off → NOT armed', the ryanbr#1
  '5/MG alarm never fires' cause (silently gated off).
- ryanbr#4 strap clock skew: persist the newest banked record → 'Strap clock: 446d behind (reset)',
  the actual ryanbr#34 root cause, surfaced instead of buried in the CLOCK-WARNING log line.

Now the block triages: didn't arm (model/experimental/connected), didn't stick (sent-vs-
reports + clock), didn't fire (last-fired). Both platforms; persists at low-frequency points
(fired event, GET_DATA_RANGE). Android compiles clean; iOS gated by CI.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…description typos (ryanbr#492) (ryanbr#496)

* Today (Android): unclip the day title + drop the bogus Strain % (ryanbr#492)

Two Android-only visual bugs from ryanbr#492 (iOS already handles both correctly):

- ryanbr#1: the tappable day-title Column had .clip(RoundedCornerShape(cornerSm)) with
  indication = null — no ripple to shape, but the rounded corners clipped the
  title/date text's bottom-left (the 'W' of 'Wednesday'). Drop the clip; the
  clickable still defines the tap area. iOS uses contentShape(Rectangle()), no clip.

- ryanbr#8: the Strain Key-Metrics tile hardcoded unit = "%". Strain/Effort is a load
  index (0–21 WHOOP / 0–100 NOOP), not a percentage, so the % was wrong — most
  visibly on the 0–21 scale ('5.7%'). Recovery/Rest are genuine 0–100 % and keep
  it. iOS shows the strain axis as an 'of 21'/'of 100' caption with no %.

compileFullDebugKotlin: BUILD SUCCESSFUL. Reported by bartmuskala.

* Trends (Android): fix two metric-description punctuation typos (ryanbr#492 follow-up)

Spotted in the ryanbr#492 screenshots (not in the reporter's list): the Charge/Sleep
metric-explorer descriptions had a space before the comma, and the Sleep one
also used a comma where iOS uses a colon. These are documented to 'match the
macOS MetricCatalog entries exactly' (TrendsExploreScreen :190), so this
restores that parity:
  - 'you are , led'      -> 'you are, led'
  - 'sleep was , dura...' -> 'sleep was: dura...'  (matches MetricCatalog.swift)

String-literal only; no logic.
UtkuDenizAltiok added a commit to UtkuDenizAltiok/noop that referenced this pull request Sep 9, 2026
The store reads rows, LiftMetrics computes — now invariant ryanbr#1, because both of
this feature's metric bugs were second implementations of one figure.

Records three real upstream findings and why none is worth a local diff: every
line changed in upstream code is a line re-merged on every sync. They are PR
candidates for later. Includes one that was checked and is NOT a bug, so nobody
investigates it twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ryanbr added a commit that referenced this pull request Sep 13, 2026
Requested by @paulinyrobert-del, who runs with background connection off and
wanted a sync without leaving Today. There was no way to start a scan from
Today at all: the quick-actions menu is four navigations, the header's other
controls go to Settings, Devices and that sheet, and there is no pull to
refresh on Today or in its scaffold.

Two affordances, one action, and neither takes width from anything.

A disc on the leading edge of the wordmark row, which was a spacer. Not
decoration: #2110 put it there to mirror the trailing Customize control
exactly, keeping the wordmark optically centred after the two had previously
overlapped. The new disc is the same HeaderClusterControl size as the one it
mirrors, so that invariant holds and the wordmark does not move. Deliberately
not in the right-hand cluster, where #2110 measured that a fifth control
leaves the 28sp day title about 117dp against the 139dp "Yesterday" needs.

And the sync chip takes a tap, being the one header element that already
reports sync state and had none. It carries an onClickLabel rather than a
contentDescription, so the action is named alongside the state the icon
describes instead of replacing it.

Both route through rememberRequestScan, the wrapper Settings' Re-scan and
Live's Connect already use, which requests the runtime Bluetooth permission
first. Calling connect() directly silently does nothing on Android 12+ when
that permission was denied or revoked, which was #1, so a third entry point
had to go through the gate rather than around it.

The disc greys and refuses taps while a scan runs, matching the Settings and
Live buttons. The chip keeps its tap during a sync, a backfill in flight not
being a scan in flight.

Android only. The disc stays visible when a strap is already connected, which
matches Settings' Re-scan rather than inventing a rule for the same action.
The new label is in all eight values files. SyncChipStateTest and
BackupSyncTest, 26 tests, 0 failures; i18n gate, doc lint and release lint
clean.

Two things found while reviewing and filed rather than folded in. Today lost
its recording-status chip in the 7.0.0 redesign and the composable is still
present with no call site (#2179), which is the status dot the report
remembers. And a permanently denied permission still ends in a silent tap on
every entry point including these, which wants deciding alongside #2179, since
Today having no connection status is the same gap from the other side.
@ryanbr ryanbr mentioned this pull request Sep 18, 2026
5 tasks done
ryanbr added a commit that referenced this pull request Sep 22, 2026
Color.init(light:dark:) built a UIColor/NSColor dynamic provider whose closure
called sRGBComponents(hex:). That closure runs once per RESOLUTION, not once
per token, and the liquid layer resolves every frame: liquidComponents() asks
for NSColor(self).usingColorSpace(.sRGB), which re-invokes the provider. So a
trimmingCharacters plus a Scanner over a string ran at 60 fps, which is how a
reporter's macOS profile came to show Color.sRGBComponents(hex:) and
closure #1 in Color.init(light:dark:) among its hot leaves.

Parses both hexes once in the initialiser, into one always-read pair, and lets
the provider pick between the two ready tuples. One declaration rather than two
because watchOS resolves straight to the dark hex, so a separate light constant
would be unused there and warn. The tokens are static let, so this is now once per token for
the life of the process.

Deliberately not a cache. A cache needs a key, a lock (a dynamic provider can
resolve off the main thread) and an eviction story, all to re-derive per frame
a value that cannot change after the initialiser runs.

A dynamic Color cannot be read back, as BrandSleepRampTests already notes, so
the resolved value is not assertable. The pin is a source census in the same
style as QuietMotionCoverageTests: exactly two parses in the initialiser, both
appearances reading the hoisted tuples. The regression is silent otherwise,
since a parse put back inside a closure renders identically.

No behaviour change: the same components reach the same provider, only earlier.
ryanbr added a commit that referenced this pull request Sep 22, 2026
Color.init(light:dark:) built a UIColor/NSColor dynamic provider whose closure
called sRGBComponents(hex:). That closure runs once per RESOLUTION, not once
per token, and the liquid layer resolves every frame: liquidComponents() asks
for NSColor(self).usingColorSpace(.sRGB), which re-invokes the provider. So a
trimmingCharacters plus a Scanner over a string ran at 60 fps, which is how a
reporter's macOS profile came to show Color.sRGBComponents(hex:) and
closure #1 in Color.init(light:dark:) among its hot leaves.

Parses both hexes once in the initialiser, into one always-read pair, and lets
the provider pick between the two ready tuples. One declaration rather than two
because watchOS resolves straight to the dark hex, so a separate light constant
would be unused there and warn. The tokens are static let, so this is now once per token for
the life of the process.

Deliberately not a cache. A cache needs a key, a lock (a dynamic provider can
resolve off the main thread) and an eviction story, all to re-derive per frame
a value that cannot change after the initialiser runs.

A dynamic Color cannot be read back, as BrandSleepRampTests already notes, so
the resolved value is not assertable. The pin is a source census in the same
style as QuietMotionCoverageTests: exactly two parses in the initialiser, both
appearances reading the hoisted tuples. The regression is silent otherwise,
since a parse put back inside a closure renders identically.

No behaviour change: the same components reach the same provider, only earlier.
ryanbr added a commit that referenced this pull request Sep 22, 2026
…me (#2395)

Color.init(light:dark:) built a UIColor/NSColor dynamic provider whose closure
called sRGBComponents(hex:). That closure runs once per RESOLUTION, not once
per token, and the liquid layer resolves every frame: liquidComponents() asks
for NSColor(self).usingColorSpace(.sRGB), which re-invokes the provider. So a
trimmingCharacters plus a Scanner over a string ran at 60 fps, which is how a
reporter's macOS profile came to show Color.sRGBComponents(hex:) and
closure #1 in Color.init(light:dark:) among its hot leaves.

Parses both hexes once in the initialiser, into one always-read pair, and lets
the provider pick between the two ready tuples. One declaration rather than two
because watchOS resolves straight to the dark hex, so a separate light constant
would be unused there and warn. The tokens are static let, so this is now once per token for
the life of the process.

Deliberately not a cache. A cache needs a key, a lock (a dynamic provider can
resolve off the main thread) and an eviction story, all to re-derive per frame
a value that cannot change after the initialiser runs.

A dynamic Color cannot be read back, as BrandSleepRampTests already notes, so
the resolved value is not assertable. The pin is a source census in the same
style as QuietMotionCoverageTests: exactly two parses in the initialiser, both
appearances reading the hoisted tuples. The regression is silent otherwise,
since a parse put back inside a closure renders identically.

No behaviour change: the same components reach the same provider, only earlier.
nathantttttt added a commit to nathantttttt/noop that referenced this pull request Oct 14, 2026
Build ryanbr#1 shipped CFBundleVersion 1 over an installed NOOP at 435, which SideStore
treats as a downgrade and stalls on. Builds are now 10000 + run number (checked
after packaging), and each release carries nathan-source.json so SideStore offers
the build as an update.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rzp1nid1YA7KA8uvHqAibR
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants