Skip to content

initial port to iOS#42

Open
eltomato89 wants to merge 139 commits into
NoopApp:mainfrom
eltomato89:iOS
Open

initial port to iOS#42
eltomato89 wants to merge 139 commits into
NoopApp:mainfrom
eltomato89:iOS

Conversation

@eltomato89

@eltomato89 eltomato89 commented Jun 8, 2026

Copy link
Copy Markdown

Port NOOP to iPhone (iOS app, HealthKit, WidgetKit, App Intents)

unsigned ipa builds are now available here: https://github.com/eltomato89/noop/releases
I will try to keep it up to date but no promises ..

Summary

Brings the standalone, offline WHOOP-strap companion app to iPhone. The existing
macOS app stays untouched in behavior; the shared Strand/ sources are made
cross-platform via #if os(...) guards and reused by a new iOS app target plus a
widget extension. The five SwiftPM packages already built for iOS and are reused
as-is.

Both targets build cleanly against the iOS 26.5 simulator:

  • NOOPiOS (app) → BUILD SUCCEEDED
  • NOOPiOSWidgets (widget + Live Activity) → BUILD SUCCEEDED

What's included

  • Functional iOS app target with RootTabView (TabView with Today / Trends /
    Live / Sleep / More) replacing the macOS NavigationSplitView sidebar. Shared
    screens are reused unchanged where possible.
  • Cross-platform BLE: iOS state restoration via
    CBCentralManagerOptionRestoreIdentifierKey, background mode bluetooth-central.
  • Two-way HealthKit (HealthKitBridge): reads HR (avg/max), resting HR,
    HRV (SDNN), SpO₂, respiratory rate, steps, active/basal energy, VO₂max and sleep;
    writes NOOP's resting HR / HRV / SpO₂ / respiratory rate back to Health.
  • WidgetKit widget (Lock Screen accessory + Home Screen small/medium) and a
    Live Activity (Lock Screen + Dynamic Island), sharing data through App Group
    group.com.noopapp.noop.
  • App Intents / Shortcuts: MarkMomentIntent, BuzzStrapIntent, surfaced via
    NOOPShortcuts.

Cross-platform refactor

Shared Strand/ code was abstracted behind small platform shims so the same
sources compile for macOS (AppKit) and iOS (UIKit):

  • Strand/System/Platform.swiftPlatformImage, Image(platformImage:),
    PlatformPasteboard, PlatformOpen.
  • Strand/System/FileExport.swiftNSSavePanel on macOS,
    UIActivityViewController on iOS.
  • Strand/System/DocumentPicker.swift — iOS document import/export wrappers.
  • QRCode, MacActions, AutomationsView, SupportView, LiveView,
    SettingsView, DataBackup made cross-platform; macOS-only behaviors guarded
    with #if os(macOS) (e.g. lock screen action, Reveal in Finder, onExitCommand).

macOS-only files (StrandApp, RootView, MenuBar/, notification settings,
macOS Info.plist/entitlements/assets) are excluded from the iOS target.

New files

iOS app (StrandiOS/)

  • App/StrandiOSApp.swift, App/RootTabView.swift, App/AppModel+iOS.swift
  • Health/HealthKitBridge.swift
  • System/NOOPAppIntents.swift
  • Widgets/WidgetPublish.swift, Widgets/LiveActivityController.swift
  • Resources/Info.plist, Resources/NOOP.entitlements, Resources/Assets.xcassets/

Shared with widget (StrandiOSShared/)

  • WidgetSnapshot.swift, LiveActivityAttributes.swift

Widget extension (StrandiOSWidgets/)

  • NOOPWidget.swift, NOOPLiveActivity.swift, NOOPWidgetBundle.swift
  • Info.plist, NOOPWidgets.entitlements

Project / build config

  • project.yml: added NOOPiOS (application, iOS 17.0 deployment target) and
    NOOPiOSWidgets (app-extension) targets, App Group + HealthKit entitlements,
    Info.plist keys (Live Activities, background BLE, HealthKit usage strings),
    shared StrandiOSShared sources compiled into both targets.

Testing

  • xcodegen generate
  • xcodebuild -scheme NOOPiOS -destination 'platform=iOS Simulator' buildSUCCEEDED
  • xcodebuild -scheme NOOPiOSWidgets -destination 'platform=iOS Simulator' buildSUCCEEDED
  • macOS target unchanged.

Notes

  • Builds were verified with CODE_SIGNING_ALLOWED=NO. Running on a physical
    device requires setting DEVELOPMENT_TEAM in project.yml.

NoopApp and others added 30 commits June 8, 2026 06:49
Live could show a bonded strap without actually re-arming the active BLE feed. The
UI treated bonded like active connection state, so reconnects could leave realtime
HR off even after BLE_REALTIME_HR_ON was acknowledged.

This firmware also needs the R10/R11 realtime stream enabled before it emits usable
live BPM. Re-arm notifications on reconnect, scope R10/R11 to the Live tab, and let
realtime raw frames update the visible HR.

The Data Sources page had two file importers competing on the same screen, so WHOOP
export selection could route through the wrong importer state. Use one target-aware
importer instead.

Co-authored-by: Kabir M Khalil <kabiru02@outlook.com>
- Add a "Strap support" section: WHOOP 4.0 is the tested/supported path; 5.0/MG
  live HR works (confirmed on hardware), deeper metrics experimental with an opt-in
  toggle. Frames the project honestly as experimental.
- Add the Readiness feature, the optional AI Coach (honestly: opt-in, BYO key, the
  one network feature, sandbox-blocked on macOS), and the experimental 5/MG toggle.
- Note the onboarding expectations flow + the in-app What's New changelog, and link
  CHANGELOG.md.
Closes the long-open licensing question. The project documents itself as
independent and non-commercial, so a permissive or copyleft OSI licence (which all
permit commercial use) would have contradicted that posture. PolyForm Noncommercial
1.0.0 is a proper, expert-drafted software licence (patent terms included) that
matches the project's intent: free for personal and other non-commercial use — read,
run, fork, and contribute — but no commercial grant.

- Add LICENSE (PolyForm Noncommercial 1.0.0) with a preamble scoping it to NOOP's
  own original code, noting protocol facts are uncopyrightable, and that bundled
  deps keep their own licences.
- Add NOTICE listing the MIT dependencies (GRDB.swift, ZIPFoundation) and the
  community reverse-engineering NOOP's protocol facts come from (my-whoop, goose).
- README: licence badge + a "License" section explaining it's source-available, not
  OSI "open source" (which would permit the commercial use the project rules out).
- CONTRIBUTING: inbound = outbound (PRs licensed under the same terms).
- DISCLAIMER §4: reference the licence and the non-commercial scope.
- Correct "open-source" → "community" wherever it described the unlicensed upstream
  work, across the docs and the two in-app credit lines, so nothing implies an OSI
  licence the project doesn't carry.
…oopApp#15)

The macOS app is ad-hoc signed but not notarized (notarization needs a paid Apple
Developer ID tied to a real identity, which doesn't fit an anonymous free project),
so Gatekeeper blocks the quarantined download and users saw "damaged"/"unverified".
Document the one-time fix: xattr -dr com.apple.quarantine, or System Settings →
Privacy & Security → Open Anyway (right-click → Open on macOS 14 and earlier).
Closing the app used to tear down the BLE connection: WhoopBleClient was owned by
the Activity-scoped AppViewModel, and onCleared() disconnected and shut it down.
People reported the strap dropping the moment they closed NOOP.

- Move the BLE client + repository to a process-wide singleton (NoopApplication) so
  the connection outlives any single Activity/ViewModel.
- Add WhoopConnectionService: a foreground service (connectedDevice type) that holds
  the process up with a quiet ongoing notification mirroring live state.
- New "Keep connected in the background" toggle in Settings -> Strap (default on);
  AppViewModel starts/stops the service on connect/disconnect and on toggle change.
- onCleared() no longer disconnects when background mode is on, and never calls
  shutdown() (the client is process-owned now).
- Declare the previously-missing POST_NOTIFICATIONS permission (the runtime request
  in MainActivity was a no-op without it) and the service in the manifest.
- Failure-isolate every foreground/notification call so a platform quirk can never
  crash the connect flow.

On macOS this already came for free: AppModel is an app-level @StateObject kept alive
by the menu-bar extra, so closing the window keeps streaming.

Bump to 1.3; add the release to the in-app What's New (both platforms) and CHANGELOG.
People on Reddit reported that after connecting, live heart rate gets stuck on a
stale value while the strap stays "connected" — the only way to un-stick it is a
manual disconnect/reconnect. Root cause: the WHOOP firmware lets its realtime
stream lapse unless it's periodically re-armed, and a CCCD can silently drop. The
macOS BLEManager already handles this with a 30s keep-alive; the Android client had
no equivalent (its only timers were for historical offload).

WhoopBleClient:
- Add a 30s keep-alive (port of BLEManager.keepAliveFire), started on the WHOOP4
  connect handshake and on the WHOOP5/MG bonded-on-HR transition. Each tick, on the
  live path (never during offload): re-subscribe a dropped notification if the stream
  has gone quiet, re-arm TOGGLE_REALTIME_HR (WHOOP4, while the Live screen wants it),
  poll battery (~60s, also keeps the link warm), and — if NOTHING has arrived for
  120s — bounce the link so the auto-rescan re-bonds and resumes streaming (the
  automatic version of the manual disconnect/reconnect).
- Track lastDataAtMs on every inbound notification to feed the liveness watchdog.
- Own wantsRealtime via start/stopRealtime() (AppViewModel routes the Live screen's
  start/stopRealtimeHr through them) so the keep-alive only re-arms when HR is wanted.
- The bounce is gated on !backfilling and always re-posts the cadence, so it can
  never abort a long historical offload or orphan the keep-alive after a reconnect.
- Mark intentionalDisconnect @volatile (read on the GATT binder thread).

Framing.Reassembler:
- Reject an impossible frame length (>8 KB; the largest real WHOOP frame is ~1920 B)
  and resync to the next SOF, instead of waiting forever for bytes that can't arrive —
  a corrupt packet used to wedge the live stream until a reconnect.
- Add reset(), called on each (re)connect (matches the macOS per-connect fresh
  Reassembler), so a partial frame from one session can't corrupt the next.
- Tests cover both: garbage-length resync and reset-drops-partial.

Bump to 1.4; What's New + CHANGELOG updated on both platforms.
Returning the full 8-element tuple of inline `try Int.fetchOne(...) ?? 0` expressions
could make Swift's type-checker time out on some toolchains/machines (reported by a
contributor building the macOS app locally). Binding each count to its own `let` first
is behaviour-identical and trivial to type-check. No schema or logic change; WhoopStore
tests (testStorageStats, testInsertReturnsRowCounts) green.

Reported by an external contributor building the macOS app locally.
…hang (NoopApp#17)

A 5/MG strap requires an encrypted (bonded) link before it will allow subscribing to its
characteristics — it rejected the puffin notify chars with "Authentication is insufficient",
so the CLIENT_HELLO handshake waited forever for notifications that never came and the UI hung
at "Finishing the secure pairing handshake…". Diagnosed from a shared strap log by @artmzrbn on
issue NoopApp#17.

- Write CLIENT_HELLO with .withResponse (was .withoutResponse) so CoreBluetooth runs just-works
  bonding and didWriteValueFor fires.
- Add a .whoop5 branch in didWriteValueFor: on the bond ack, mark the link established and
  (re)subscribe the puffin notify chars + standard HR/battery; do NOT run the WHOOP4 command
  handshake (a 5/MG strap rejects WHOOP4-framed commands).
- Store the puffin notify chars at discovery so they can be re-subscribed post-bond.
- Skip the keep-alive's WHOOP4 command pings on a 5/MG link (dropped anyway; keeps the log clean).

Strictly isolated to the experimental 5/MG path (gated on deviceFamily == .whoop5); WHOOP 4.0
connect/bond/offload is byte-for-byte unchanged (verified by review). Unverified on real 5/MG
hardware — shipping experimental for the reporters to confirm. Bump to 1.5.
…nfusion)

A Reddit tester saw the alarm log print "…+0000" and reasonably concluded the alarm
was being set in UTC and would be wrong for non-UTC timezones. It isn't: the wake time
is built with Calendar.current (the user's LOCAL zone) and SET_ALARM_TIME carries the
absolute instant of that local time, so the strap fires at the correct local moment
regardless of how its UTC RTC is labelled. The only thing that was "+0000" was Date's
default UTC description in the debug log.

Log the wake time formatted in the user's local zone (with the zone abbreviation) and
note the epoch is UTC, so the log no longer reads like a timezone bug. No functional
change to the alarm — deliberately NOT applying a timezone offset, which would break
the currently-correct firing time.
… default

People on Android couldn't share what their strap was doing — log() only went to logcat
(adb-only), so connection issues (NoopApp#17 on Android, NoopApp#18) were undiagnosable. WhoopBleClient now
keeps an in-memory ring buffer of the log; Settings → Strap → "Share strap log" writes it to a
cache file and opens the share sheet (FileProvider, applicationId-scoped authority).

Also fix the LiveState `worn` default: it was `false`, so the UI showed "Worn: Off" forever when
no WRIST_ON event arrived (NoopApp#18). Now defaults `true`, matching the macOS LiveState.

Bump to 1.6 (also carries the macOS alarm-log-in-local-time clarity from the prior commit).
…, NoopApp#20)

Cherry-picked from @j0b-dev's PRs NoopApp#19 and NoopApp#20 — reviewed, build-verified, and reimplemented onto
current main as NoopApp. Both are safe-by-design (additive, opt-in/off-by-default, read-only on the
strap, WHOOP 4.0 untouched) and directly serve the 5/MG decode effort.

macOS frame capture (NoopApp#20):
- PuffinCapture (pure, in WhoopProtocol, unit-tested) + PuffinFrameRecorder (@mainactor glue).
- "Record puffin frames" toggle under Settings -> Experimental, with Export / Reveal. Records the
  5/MG puffin frames already received (stamped with wall-clock + live HR) to JSON in App Support.
- BLEManager wires capture into the whoop5NotifyChars path only (additive, gated). The author
  couldn't compile this on macOS; verified here -- the app build SUCCEEDED, @mainactor isolation
  checks out, the WHOOP 4.0 path is byte-for-byte unchanged.

Linux workbench + whoop-decode (NoopApp#19):
- whoop-decode executable on WhoopProtocol (Foundation-only; decodes captures with the SAME shipped
  decoder, no second decoder to drift). Package.swift adds the product/target; the library is untouched.
- tools/linux-capture: bleak capture + a stdlib framing module with tests + a bonding probe. Only the
  curated non-destructive command set is sent; no network/shell/exec; capture files git-ignored.
- docs: hardware-verified WHOOP 5.0 bonding/session notes that CONFIRM the v1.5 just-works-bond
  approach (CLIENT_HELLO with-response triggers the bond). Clarified the doc to tie that to the macOS
  v1.5 fix + issue NoopApp#17, and softened a stale "unverified command set" comment.

Verified: WhoopProtocol 86 tests pass, whoop-decode links, Python framing tests (14) pass, macOS app
+ Android release builds green. 3-lens adversarial review (safe-for-verified-path on all three).

Bump to 1.7. The on-strap 5/MG capture flow still wants a real hardware test (asked the reporters).
Cherry-picked from @j0b-dev's PR NoopApp#21 — reviewed, build-verified, reimplemented as NoopApp.
parseFrameWhoop5 previously left the inner record raw; it now decodes the live REALTIME_DATA (type 40)
record by reusing the existing 4.0 schema shifted by +4 (the 5.0 inner record starts at byte 8 vs byte
4 on 4.0), so WHOOP 5 reports live HR + R-R from the puffin channel too.

Hardware-verified by the contributor: HR at inner-offset 16 matched the standard 2A37 profile to
~0.4 bpm mean |delta| across 96 worn-strap frames; R-R 572-637 ms. Confined to parseFrameWhoop5 —
WHOOP 4.0 decode is byte-for-byte unchanged (the PR ships a testWhoop4RealtimeIsUnaffected test that
asserts exactly that). Other 5.0 packet types keep their inner record raw pending per-type
verification before the +4 rule is applied.

Verified here: WhoopProtocol full suite 89 tests pass (86 existing incl WHOOP4 + 3 new), build green.
…pp#17, NoopApp#18)

macOS (NoopApp#17): the Live screen's strap-log card now has Copy / Save... buttons (NSPasteboard +
NSSavePanel) so Mac users can attach the connection log to a bug report — Android had this since 1.6,
Mac didn't, and the stuck 5/MG reporters are on Mac.

Android (NoopApp#18): the Health Monitor HR chart derived its series from R-R intervals, which are sparse on
WHOOP 4.0, so it fell back to a flat 2-point line even while HR was changing. Now it plots a rolling
buffer of the live HR over time (additive UI state; falls back to R-R then the flat pair while the
buffer fills). No change to other screens.

Bump to 1.8. Both platforms build green.
…read (PR NoopApp#22)

Cherry-picked from @Brechard's PR NoopApp#22 — reviewed (2-lens adversarial: no regression to the verified
WHOOP 4.0 path; all v1.4-v1.8 additions verified intact), build + unit tests green, reimplemented as
NoopApp.

A bonded WHOOP 4.0 could show NO live data — HR/battery/worn/events all blank — even though it
connected, bonded, and accepted commands (buzz worked). Root cause: connectGatt was called without a
Handler, so Android delivered GATT callbacks on arbitrary binder threads. onServicesDiscovered then
raced a concurrent callback that drained the CCCD queue to empty and fired the bond's with-response
write first; the bond held the stack's single GATT slot, so every writeDescriptor (subscribe) returned
BUSY and — with no retry — was abandoned. Not one notification ever enabled. Hardware-verified on a
Pixel 8 / Android 16 with before/after logs.

- connectGatt now passes the main-looper Handler so all callbacks serialise on one thread.
- drainCccdQueue / drainWriteQueue re-post to the main looper if entered off-thread.
- a transiently-BUSY CCCD write retries (bounded, short backoff) instead of dropping the stream.

Refinements on review (no-regression): gated the handler overload to API 28+ (the stack only reliably
honours callback-thread affinity from Android 9, which is also where the race reproduces; 26/27 keep
unchanged behaviour — no regression, no main-thread decode on old devices); and replenish the shared
BUSY-retry budget on each successful subscribe so one stalled char can't starve the others.

Bump to 1.9.
… HR on across HR screens (NoopApp#18)

NoopApp#17 (WHOOP 5/MG, from a Pixel 8 Pro log: CLIENT_HELLO written, then nothing): the Android path wrote
CLIENT_HELLO with WRITE_TYPE_NO_RESPONSE, which never triggered the strap's just-works bond, so it sat
connected-but-unbonded and silent (a 5/MG strap won't even stream standard 0x2A37 HR on an
unauthenticated link). CLIENT_HELLO is now a confirmed (with-response) write that holds the slot and,
on the ACK in onCharacteristicWrite, marks the link bonded (+ fires the opt-in puffin probe post-bond).
Mirrors the macOS v1.5 fix; strictly gated to connectedFamily == WHOOP5 — WHOOP 4.0 untouched.

NoopApp#18 (Health Monitor froze when opened from Live; log showed TOGGLE_REALTIME_HR=00 on Live-leave):
leaving Live stopped the realtime HR stream outright, so Health Monitor got no updates. The realtime
stream is now REF-COUNTED in AppViewModel (requestRealtimeHr/releaseRealtimeHr) — it stays on while
ANY live-HR screen is visible, and runConnectHandshake arms it on bond if wanted, so handing off
Live <-> Health Monitor never drops it. Both screens use a DisposableEffect request/release.

Bump to 1.10. Build + unit tests green.
The dashboard anchored "today", the 14-day sparklines and the Trends
W/M/3M windows to the newest stored ROW (macOS: latestDay) rather than
the device's actual calendar date. After a historical WHOOP import that
meant a months-old day rendered as today's recovery/readiness, and the
trend windows showed the last N imported days instead of the last N
calendar days (issue NoopApp#23, reported by @Brechard; corroborated by
Vikedlol seeing April data as "today").

Fix — date-anchor to the real local day key (ISO yyyy-MM-dd, compares
chronologically), on BOTH platforms:

  Android
  - AppViewModel: _today = days.lastOrNull { it.day == LocalDate.now() }
  - TodayScreen.remember14: last 14 CALENDAR days (cutoff filter), drop
    the takeLast(14) + "else all" fallback that resurrected old imports
  - TrendsScreen.windowValues: last N calendar days, not takeLast(n) rows

  macOS (parity)
  - Repository.today: days.last(where day == localDayKey(now)); +week to
    last 7 calendar days; new Repository.localDayKey(_:) (cached POSIX fmt)
  - TodayView.trailingWindow: cutoff-key filter
  - TrendsView.days(for:): cutoff-key filter, no longer anchored to
    latestDay / repo.days.last

No-op for the common case (recent contiguous data: last-N-days ==
last-N-rows) — only stale-import dashboards change: Today/sparklines go
empty for the real today and Trends auto-widen to where the data is, so
older imports stay visible under the wider ranges / All history. No
regression for anyone on current data.
macOS WHOOP 5/MG (issue NoopApp#17) — reimplemented from a 5/MG owner's
hardware-verified flow (dmisgano99); the v1.5 attempt bonded but never
streamed on real hardware. All changes scoped to the .whoop5 path;
WHOOP 4.0 untouched.

  - BLEManager.didDiscoverCharacteristicsFor: retain the puffin notify
    chars (fd4b0003/4/5/7) but DO NOT subscribe pre-bond — on an
    unauthenticated link the strap rejects them ("Authentication is
    insufficient"), which wedged the bond. Removed the wrongly-timed
    pre-bond PuffinExperiment realtime probe.
  - didWriteValueFor: subscribe the puffin chars only after the
    CLIENT_HELLO .withResponse write confirms; then arm realtime HR with
    puffin framing (send(.toggleRealtimeHR) once, guarded by
    whoop5RealtimeArmed).
  - send(): route the realtime-HR toggle through puffinCommandFrame for
    .whoop5 (the guard previously dropped every 5/MG command, so a bonded
    strap never started streaming); all other commands still dropped (no
    verified puffin equivalent).
  - Surface pairing-mode guidance (new LiveState.pairingHint, shown in
    SettingsView) when the bond is refused with "Encryption/Authentication
    is insufficient" — CoreBluetooth won't bond against a strap still
    paired to the official WHOOP app, so it must be in pairing mode.

Readiness anchoring (issue NoopApp#23/NoopApp#24, caught via @Brechard's PR) — v1.11
anchored Today/sparklines/Trends to the real calendar day but left the
Readiness card reading sorted.last, so a stale import still drove the
"Should you push today?" read. Both platforms:

  - TodayView.swift / TodayScreen.kt: pass the local day key into
    ReadinessEngine.evaluate(...).
  - ReadinessEngine (Swift + Kotlin): when an explicit `today` is given
    but no row matches, return INSUFFICIENT instead of falling back to
    the newest stored row; today == nil still falls back (no regression
    for live-strap callers). Added a regression test on both platforms.

No-op for the common case (recent contiguous data). Both platforms build
green; StrandAnalytics + Android unit tests pass.
Decode the WHOOP 5.0 ("puffin") historical DSP biometric store. Decode
half only — no app-side offload wiring yet (that's a separate change,
gated behind an opt-in flag) — so this is a no-op for current users and
the WHOOP 4.0 path is untouched.

Reimplemented as NoopApp from @j0b-dev's PR NoopApp#25, which is hardware-
verified: the test vectors are real frames captured from a worn WHOOP 5
(2026-06-08) and the decoded values are cross-checked by physiological
invariants independent of the decoder (|gravity| ≈ 1 g, 60000/mean(R-R)
≈ HR, rr_count == #valid intervals) plus a 96/96 HR match against the
ground-truth 0x2A37 profile.

WhoopProtocol/Interpreter.swift:
  - decodeWhoop5Historical: type-47 v18 record — version@9 (gated; any
    other version falls back to a labelled raw region, nothing invented),
    unix@15, heart_rate@22, rr_count@23, rr@24+2i, gravity f32@45/49/53.
    Optical channels past +57 left as one honest raw region.
  - decodeWhoop5Metadata: type-49 chunk unix@11 / subsec@15 /
    trim_cursor@21, so classifyHistoricalMeta can drive the ack.
  - whoop5HistoricalAckFrame: HISTORICAL_DATA_RESULT(23) = [0x01]+end_data
    as a puffin command — the WHOOP 5 image of BLEManager.ackHistoricalChunk
    and the byte-for-byte twin of the Python build_history_ack.
  - readF32 helper; two new else-if branches in parseFrameWhoop5.

Offload unlock (docs §5): each HISTORY_END's end_data must be echoed via
HISTORICAL_DATA_RESULT(23) to advance the strap's trim cursor — without
it the cursor stays frozen and zero type-47 records are served.

tools/linux-capture: --history-ack / --history-only / --stop-on-idle
capture flow + ack queue + the HISTORY_END decode helpers. Added an
off-hot-path periodic flush (every 2s, between bursts) so a long capture
isn't all-or-nothing on a crash — the notify callback still never blocks.

Verified: 98 WhoopProtocol tests pass (9 new real-frame vectors incl.
ack-byte match + metadata classification; existing Whoop4/Whoop5 realtime
unaffected); 19 Python frame tests pass; macOS app builds.
Android brought a 5/MG strap to "Bonded — Streaming" (v1.10) but showed
no HR: it only listened on the standard 0x2A37 profile, which a 5/MG
strap doesn't stream. Realtime HR rides the puffin notify chars as
REALTIME_DATA, exactly as on macOS. Brings Android to parity; all changes
scoped to the .whoop5 path — WHOOP 4.0 byte-for-byte unaffected (NoopApp#17/NoopApp#26).

  protocol/Framing.kt
  - Reassembler is now family-aware: WHOOP5/MG framing is declLen @[2..4]
    / total +8 vs WHOOP4 length @[1..3] / +4. The WHOOP4 rule decoded a
    bogus ~6 KB length for a 5/MG frame and never emitted — the core
    reason no frames reached the decoder.
  - parseWhoop5 now decodes REALTIME_DATA at the +4 offsets (timestamp@10,
    heart_rate@16, rr_count@17, rr@18+), mirroring the hardware-verified
    macOS decode (PR NoopApp#21). Other 5/MG types stay envelope-only.

  ble/WhoopBleClient.kt
  - WHOOP5_NOTIFY_CHARS (fd4b0003/4/5/7) subscribed AFTER the CLIENT_HELLO
    bond (rejected on an unauthenticated link); routed through the
    family-aware reassembler in onInbound.
  - reassembler reassigned per connection with the detected family.
  - send(): routes TOGGLE_REALTIME_HR through puffinCommandFrame for
    .whoop5 (every 5/MG command was dropped before); others still dropped
    (no verified puffin equivalent). startRealtime/stopRealtime + the
    post-bond branch arm it.
  - Removed the now-dead opt-in puffin probe (realtime HR is armed by
    default post-bond, matching macOS v1.12).

Verified: FramingTest decodes a real worn-strap REALTIME_DATA frame
(HR=98, R-R=[603,587], ts=1780916382) and asserts the family-aware
reassembler emits it where the WHOOP4 one can't. Full Android release +
all unit tests pass; macOS universal builds.

Known gap: 5/MG battery poll + haptic buzz still need their own verified
puffin framing and remain dropped, so buzz won't fire on 5/MG yet (NoopApp#28).
After a historical import, Android Today rendered missing current-day
metrics as raw dashes and the recovery ring as a depleted 0%, which read
like broken/zero data rather than "no score for today yet."

  ui/TodayScreen.kt
  - Missing current-day metric tiles now show an explicit "No Data"
    (NO_DATA) instead of "—"; gated on the value being null, so a user
    WITH today's data is unchanged (values render normally).
  - TodayRecoveryRing: when today's recovery is null, show "No Data" +
    supporting text instead of a 0% / depleted ring.
  - Added a Mac-style Today footer (TodayWorkoutsSection /
    TodaySourcesSection): recent 14-day workouts when present + Data
    Sources counts, so imported history is clearly labelled as history.
  ui/TrendsScreen.kt
  - Comment-only: corrected stale calendar-day-anchoring notes; the
    accepted v1.12 Trends widening behaviour is unchanged.

No-op for the common case (today's data present). Android-only; brings
Today to parity with the Mac screen and completes the stale-import
cleanup from v1.11/v1.12. Full Android release + unit tests pass; macOS
universal builds.

Reimplemented as NOOP from @Brechard's PR NoopApp#31 (refs NoopApp#23) — reviewed and
authored here rather than merged.
@jamartif confirming live HR on v1.13 proved a 5/MG strap acts on our
puffin-framed commands (the realtime-HR toggle reached it and started
the stream). So the haptic buzz (RUN_HAPTICS_PATTERN) is now allowlisted
through the same puffinCommandFrame transport in send(), on both
platforms — powering Test buzz, the smart alarm, and any haptic feedback
on 5/MG.

  - Strand/BLE/BLEManager.swift: 5/MG send() guard now allows
    .toggleRealtimeHR OR .runHapticsPattern.
  - android/.../WhoopBleClient.kt: same — TOGGLE_REALTIME_HR OR
    RUN_HAPTICS_PATTERN.

Still experimental: whether the strap honours that specific command is
the unverified part, but the transport is proven and the worst case is a
no-op (no link teardown was seen with the HR toggle). Every other command
stays dropped for 5/MG (the offload set needs its own verified framing).
Battery already worked on 5/MG via the standard 0x2A19 profile, so it
needed nothing here. WHOOP 4.0 is unaffected (issue NoopApp#28).

Both platforms build; Android unit tests pass; macOS universal builds.
Decode the WHOOP 5.0 ("puffin") COMMAND_RESPONSE (type 36) replies,
reimplemented as NOOP from @j0b-dev's PR NoopApp#32. Decode-only — like the
historical decoder, it's a no-op until the app actually sends those
commands to a 5/MG strap (send() still drops GET_HELLO/GET_DATA_RANGE/
GET_BATTERY for whoop5), so no behaviour change ships here. WHOOP 4.0 is
untouched (gated on the whoop5 command_response post-hook).

WhoopProtocol/Interpreter.swift — decodeWhoop5CommandResponse:
  - resp_cmd at frame[10] (4.0 frame[6] + 4); payloads diverge from 4.0,
    so every field is mapped from a real capture (fw 50.38.1.0).
  - GET_BATTERY_LEVEL: battery_pct = pay[2] DIRECT percent (the 4.0
    deci-percent ÷10 is gone on 5.0).
  - GET_DATA_RANGE: history_oldest/newest from the unix-range u32s
    (bounded 2020..2027) → the offload window.
  - GET_HELLO (145): device_name (printable ASCII @pay[16]) + fw_version
    (@pay[93], guarded on the "5.0" generation byte). The same response
    carries a SESSION TOKEN which the decoder NEVER reads or exposes.

Privacy: the token-bearing GET_HELLO test fixture is SYNTHETIC (fake name
"WHOOP-FAKE01", version bytes at real offsets, token region zeroed) — no
real device name or token enters a committed fixture; the battery fixture
is a traced token-free frame. No capture .json is committed.

Verified: 102 WhoopProtocol tests pass (4 new Whoop5CommandResponseTests:
battery, data-range, hello name+fw, guard-fails-closed) + the parity test;
WHOOP 4.0 decode unaffected. Also ports the --commands capture flag + the
response analyzer to tools/linux-capture.

Next (separate, not here): wire the sends now that the puffin transport is
hardware-proven — GET_HELLO would surface the strap's firmware + name on
5/MG; GET_DATA_RANGE feeds the held offload work.
Tooling-only (the Linux capture workbench) — no app or decoder change.
Reimplemented as NOOP from @j0b-dev's PR NoopApp#33.

- --commands now works for WHOOP 4: a 4.0 (CRC8) probe of the read-only
  GETs (battery, clock, version, ext-battery, data-range, hello), so a
  WHOOP 4 is testable the same way the 5 is. Command numbers verified
  against the authoritative WhoopCommand enum (Commands.swift:
  reportVersionInfo=7, getExtendedBatteryInfo=98, etc.) and are the same
  GETs NOOP already sends each connect — genuinely read-only (confirmed
  on real hardware: cmd 7 returned the 41.17.6.0 version block).
- scan-first connect: resolve --address via BleakScanner before
  BleakClient, with a clear "wake the strap" hint on miss — BlueZ connects
  far more reliably to a freshly-discovered device than a bare address.
- family-aware per-type tally: read the inner-type byte at offset 4 for
  WHOOP 4 vs 8 for WHOOP 5 (the summary was reading the 5.0 offset).

Captures stay gitignored; no device data committed. Python tests green (19).
…NoopApp#34)

Android: HealthConnectImporter stored its daily aggregates (steps/HR/HRV/
sleep/weight) under the shared "apple-health" deviceId — the same bucket
the Apple Health export uses — so the Data Sources screen counted them
under the Apple Health card. Only the workouts were correctly tagged
source="health-connect".

  - HealthConnectImporter: writes ALL its data (AppleDaily + workouts)
    under its own "health-connect" deviceId, device named "Health Connect"
    (the recovery/sleep backfill still merges under "my-whoop"). Dropped
    the now-unused APPLE constant.
  - DataSourcesScreen: the Health Connect card now shows its own imported
    counts; the Apple Health card keeps "apple-health".
  - Today footer + WorkoutsScreen: union "apple-health" + "health-connect"
    for the unified external-health view, so nothing disappears.
    (CompareScreen unaffected — HC writes no metricSeries.)
  - One-time refile (WhoopDao + WhoopRepository.refileLegacyHealthConnect,
    called at HealthConnectImporter.import() start): moves legacy HC data
    out of "apple-health". HC workouts move by their source tag; the daily
    rows move only when there's no Apple Health EXPORT (no apple-health
    metricSeries, since only the export writes those). Safe + idempotent:
    runs before the import writes any HC data (no PK conflict), and post-fix
    nothing writes HC data to apple-health again. So re-importing refiles
    cleanly instead of duplicating.

No data was ever lost — labelling only. Android build + unit tests pass;
macOS universal builds (version bump only).
…firm (NoopApp#39)

Both reimplemented as NOOP from @j0b-dev's PRs; both verified against real
captured frames, additive, and WHOOP 4.0 decode unaffected.

NoopApp#38 — WHOOP 5 EVENT (type 48): simple events (wrist on/off, double-tap,
  boot, pairing, BLE up/down, bonded) already decode via the +4 static
  walk. decodeWhoop5Event adds the one payload with on-device ground
  truth — BATTERY_LEVEL (soc@21 / mv@25 / charge@30 = 4.0 +4, KEEPS the
  4.0 deci-percent ÷10, unlike the 5.0 COMMAND_RESPONSE direct percent;
  monotonic discharge 49.9→47.7% confirmed). Drift-safe: event names come
  ONLY from the shared EventNumber schema — an unnamed firmware number
  (e.g. 123) stays raw 0x7B(123), never borrowing CommandNumber's name
  (123 = SELECT_WRIST). Other event payloads left raw (no 5.0 ground
  truth). Whoop5EventTests: battery, double-tap, unknown-stays-raw.

NoopApp#39 — WHOOP 4 historical-offload mode for the Linux capture tool (was
  whoop5-only) + a regression fixture confirming the firmware-drift
  question the WHOOP 5 v18 record raised: a real WHOOP 4 STILL emits v24
  (Whoop4HistoricalV24HardwareTests: HR 109, R-R [555,564], |g|≈1, NOT
  drifted). tools/linux-capture gains family-aware capture + the 4.0
  offload helpers (meta_type@6, trim_cursor@17, end_data frame[17:25],
  CRC8 acks). Shared command numbers (22/23) reused; only framing differs.

Verified: WhoopProtocol tests pass (new Whoop5Event + Whoop4HistoricalV24
vectors + existing decode unaffected); 24 Python frame tests pass.
Reimplemented as NOOP from @eltomato89's PR NoopApp#37. Makes every macOS UI
string translatable via a SwiftUI String Catalog, with the exact current
wording preserved as the English (US) base — so adding a language is a
translation task, not a code change. English-base only: NO user-visible
change, no language shipped yet. macOS-only; Android + the shared Swift
packages' runtime behaviour are unaffected.

  - Strand/Resources/Localizable.xcstrings (new): the String Catalog,
    432 English (US) base entries (sourceLanguage: en).
  - project.yml: developmentLanguage: en + SWIFT_EMIT_LOC_STRINGS: YES
    (Xcode auto-extracts every LocalizedStringKey into the catalog on
    build). [Reconciled against the current 1.16 project.yml — the only
    hunk that didn't apply, since version bumps had moved its context.]
  - StrandDesign components (SectionHeader, the metric/stat rows,
    ScreenScaffold, InsightCard, the pill/overline, StatePill): the
    user-facing String params become LocalizedStringKey so SwiftUI
    localizes literals/interpolations automatically. The `.uppercased()`
    calls were dropped because `strandOverline()` already applies
    `.textCase(.uppercase)` — verified, so no casing regression.
  - 14 view files: 1-5 line opt-ins to pass LocalizedStringKey through.
  - Tools/seed-string-catalog.py + Tools/translate-de.py: dev utilities
    (seed the catalog; a static EN->DE translation map). No network/API,
    not part of the build — a German tool, not an enabled language.

Reviewed: macOS BUILD SUCCEEDED with it fully applied (so no String-var
call site broke against the new LocalizedStringKey params); uppercase
preserved; no contributor identity in any committed file (anonymity);
no network/build-time execution in the scripts.
Two macOS reporters wore a WHOOP 4 overnight (connected) and got "1 day,
0 sleeps." Root cause: sleep is staged from the strap's overnight GRAVITY/
motion stream (SleepStager.detectSleep returns [] on empty gravity), and
the WHOOP 4 historical (type-47) post-hook bailed out of decode ENTIRELY
for any version outside the schema's {12, 24} — no HR, no R-R, NO gravity.
So the offload "completed" (acks + HISTORY_COMPLETE) but stored no motion;
HR was backfilled from the realtime stream (no gravity); IntelligenceEngine
computed a day with HR but zero sleeps.

  PostHooks.swift (historical_data): for an unmapped version, fall back to
  the canonical v24 DSP layout (firmware overwhelmingly shares it — schema
  notes V12 == V24) and ACCEPT only if physically valid: |gravity| ≈ 1 g
  (the DSP gravity is a unit vector) AND a plausible HR. A wrong layout
  yields random f32 gravity nowhere near 1 g → rejected, record left raw.
  Mapped versions unchanged.

  Backfiller.swift + BLEManager.swift: diagnostic — surface each unmapped
  historical version once in the strap log (now only fires when the v24
  fallback was REJECTED, i.e. a genuinely-different layout we must map).

Tests: accept (v24 record relabeled to v25 still decodes HR/gravity) +
reject (relabeled with wiped gravity → no biometrics stored). 111
WhoopProtocol tests pass; macOS universal builds; Android version bump
only (its Kotlin decoder is unaffected — the reporters are on Mac).
) + HC resilience (NoopApp#34)

NoopApp#40 (macOS, @robin-liquidium): importing an Apple Health export overwrote
the WHOOP import's status message in Data Sources. AppModel had ONE shared
`importing`/`importSummary`, both importers wrote it, and only the WHOOP
card rendered the summary — so an Apple Health import flipped both buttons
to loading and replaced the WHOOP message in the WHOOP section, looking
like a data overwrite. The data was always separate (my-whoop vs
apple-health). Split the state per source (whoopImporting/appleImporting +
per-source summaries); the Apple Health card now shows its own status.

NoopApp#34 (Android, @spasypaddy): a single Health Connect record type failing
("count must not be less than 1, currently 0" on some devices/SDK builds)
aborted the whole import — every readAll ran inside one try/catch. Each
type's read is now self-contained: on failure it's logged + skipped, and
every other type still imports (reads accumulate into shared buckets, so a
partial type is simply absent, never corrupt).

Both platforms build; Android unit tests pass.
eltomato89 and others added 5 commits June 10, 2026 17:21
Brings in v1.65 (dropped-chunk surfacing), v1.66 (Android WHOOP 4 fallback),
v1.67 (manual workout tracking), v1.68 (sleep figures, HR zones, charging,
calibration, illness notifications), and v1.69 (Live status cleanup + frame
diagnostic). Resolves the Localizable.xcstrings conflict by merging the
two string catalogs key-by-key — keeps upstream's translated values for
shared keys, preserves the iOS-only-extraction additions from 748f393,
and applies Xcode's 1.1 "key" : value format throughout.

project.yml's macOS-only NOOPAppIntents exclusion (from 748f393) and the
CompareView ViewThatFits fallback (from 0522c21) are untouched by the
merge. Verified with an iOS Simulator build (iPhone 17, iOS 26.5).
iOS build extracted 7 new keys carried in by v1.67/v1.68 (manual workout
flow, WHOOP 5/MG alarm caveat, %% display). Same shape as the catalog
refresh that produced 748f393 — keeps the iOS-only extraction state
in sync with the merged sources.
- Android Live screen shows "Syncing your strap history…" plainly during the offload, so sync
  visibility isn't just the brief "· syncing" pill suffix (NoopApp#91 / NoopApp#93).
- macOS CompareView range controls use ViewThatFits to stack instead of overflowing on a narrow
  window (ported from the iOS port's fix).
Brings in v1.70 (clearer Android sync status + a smaller version of the
iOS port's responsive Compare fix). The CompareView conflict resolves to
HEAD's version: the iOS fork already had a stronger fix that wraps the
stacked range pills in a horizontal ScrollView, so the pills can't clip
on a narrow iPhone screen — upstream only ported the bare ViewThatFits
half of it. Comment kept the iOS-fork wording since this is the iOS port,
not a port of it.

All other v1.70 changes (CHANGELOG, AppChangelog 1.70 release entry,
MARKETING_VERSION 1.70 / build 56) merged cleanly. No new user-visible
strings, so Localizable.xcstrings is untouched.
@NoopApp

NoopApp commented Jun 10, 2026

Copy link
Copy Markdown
Owner

This is a real piece of work — a clean iOS port that shares the Swift core via #if os() guards rather than forking it, with HealthKit, WidgetKit, a Live Activity, and App Intents wired up. Thank you for it, and for keeping it on-device with no networking added.

I want to be honest rather than leave it hanging — two things:

  1. Strategically, an iOS line roughly doubles the per-release surface for a one-person project: a separate HealthKit privacy review, App Group plumbing, and widgets / Live Activity / intents to keep in lockstep on every version bump, plus an unsigned-distribution story. That's a standing maintenance commitment, not a one-time merge, so I need to decide deliberately whether to take on an iOS line before adopting it — I don't want to ship an iOS app I can't sustain.

  2. Mechanically, the project is maintained under a single anonymous identity (no contributor handles in history), so I can't merge the branch directly — any adoption would be a clean NoopApp reimplementation of the port itself (and I'd keep the bundled localisation as its own separate change).

So I'm going to hold this rather than close it, while I think through the iOS question. None of that reflects on the quality of the port — it's good. Genuinely appreciate the time you put in.

@NoopApp

NoopApp commented Jun 10, 2026

Copy link
Copy Markdown
Owner

Quick follow-up to keep this from going quiet: the position from my last note still stands — I'm holding PR #42 open, not closing it, while I think the iOS question through properly. It's a genuinely good port and I don't want it to disappear.

To make sure nobody's blocked in the meantime: the iOS build already has a working home. @eltomato89 is publishing unsigned IPAs from their fork — https://github.com/eltomato89/noop/releases — so if you want to try it on a device today, that's the place (and the right place to file iOS-specific issues like the .zip document-picker / UTI one, since that fix lives in the iOS target, not in core).

Two things I'm weighing, honestly, so the reasoning isn't a black box:

  • Maintenance, not merge. An iOS line roughly doubles the per-release surface for a one-person project — a separate HealthKit privacy review, App Group plumbing, and widgets / Live Activity / App Intents to keep in lockstep on every version bump, plus the unsigned-distribution story. That's an ongoing commitment I want to be sure I can sustain before I take it on.
  • History. Core NOOP is maintained under a single anonymous identity with no contributor handles in the git history, so I can't fast-forward this branch in as-is regardless of quality — any eventual adoption would be a clean reimplementation, fully credited to you in the notes.

None of that is a knock on the work — @tigercraft4's review caught a handful of fixable things (WAL checkpoint before the iOS DB copy, UTC vs .current day-keys, the CI Xcode/SDK pin), but the core architecture is the right shape and respects the offline-first rules. If the anonymous-distribution story for iOS shifts — or the demand here keeps growing — this is exactly the foundation I'd pick straight back up. Thank you again. 🙏

NoopApp added a commit that referenced this pull request Jun 10, 2026
…ap item

iOS has no anonymous distribution path — App Store and TestFlight both require a
real Apple Developer identity — which is fundamentally at odds with NOOP staying
anonymous. So the iOS port (#42) is build-from-source only, not officially
maintained or distributed. Reframe both README platform rows from "planned/on the
roadmap" to "experimental community port — build it yourself", linking PR #42 so
people can find it.
NoopApp added a commit that referenced this pull request Jun 10, 2026
Bring the remaining docs in line with reality and the README fix (4e2a3e9). BUILD.md, CONTRIBUTING.md, FEATURES.md and IOS.md no longer call iOS and Android "planned": Android ships as a full app (android/, APKs in Releases), and iOS is an experimental, build-from-source community port (#42).

The iOS framing notes it is not officially maintained or distributed — iOS has no anonymous distribution path (App Store and TestFlight both require a real Apple Developer identity), at odds with the project staying anonymous. IOS.md gains a top-of-file note saying the same; BUILD.md's "Android (planned)" section and CONTRIBUTING.md's roadmap are reframed to match. macOS remains the reference implementation throughout.
@eltomato89

Copy link
Copy Markdown
Author

Thanks for the heads up! I do not have a problem with integrating the pr under an anonymous identity!
Most of the code is even based on your work. All of the widgets are reused and most of the work (of keeping the ios port updated) is to fix some display issues and merge conflicts ..
(And I need to say, the merge conflicts will be getting more and more problematic as the two sources drift apart)

I (for myself) do consider an iOS app much more helpful then a macos app as it can connect to HealthKit wich is (more or less) the most useful thing to do when you have an iPhone and a Fitness Strap. So, if maintaining 3 platforms is to much, I would consider dropping the macos support in favor of the iOS app.

P.S.: Regarding the document picker: I did not have any problems loading the whoop data export zip-file..

Upstream rewrote history (each prior version has a new SHA), so the
merge base resets back to v1.2 and the visible diff is large; only the
v1.71–v1.80 content is logically new. Brings in:

  - v1.71/v1.72: Android GPS workouts + crash fix
  - v1.73/v1.74: reconnect guide for 5/MG firmware-reset bond resets
    (Mac then Android) + the Galaxy-S24 startup-crash fix
  - v1.75: personal VitalBands baselines + macOS analytics parity
    (steps, respiration, calories, skin-temp, schema v11)
  - v1.76: tolerant Apple-Health import, marginal-radio HR fallback,
    live HR graph time-series
  - v1.77: first-run terms acknowledgment gate (TERMS.md), Explore
    chart hover-flicker fix
  - v1.78: daytime false-sleep guard + Android Sync-now button
  - v1.79: manual workouts + edit/relabel/dismiss + CSV export
  - v1.80: native journal logging + Imperial/Metric units toggle

Conflict resolution:

  - Android files (kotlin + AndroidManifest + gradle + docs/ANDROID.md):
    iOS fork never carried Android divergence (those commits in our
    first-parent walk are pre-existing upstream content), so taken
    straight from upstream.
  - Pure upstream Swift additions (Database/WhoopStore schema v11,
    LiveState reconnect/standard-HR fields, IntelligenceEngine
    skin-temp + resp baselines, OnboardingWizard/TodayView unit
    formatters): taken from upstream.
  - iOS-port Swift conflicts resolved by hand:
      * AppleHealthView: kept HEAD's LocalizedStringKey wrap (1fbcada).
      * CompareView: kept HEAD's ScrollView fallback (0522c21 — same
        conflict as the v1.70 merge).
      * BLEManager: pure upstream additions (marginal-radio detector +
        reconnect-guide clear). #if os(iOS) blocks auto-merged.
      * LiveView: kept HEAD's PlatformPasteboard/FileExport abstractions
        and #if os(iOS) strap-log header.
      * SettingsView: kept HEAD's #if os(macOS) wraps around
        revealPuffinCaptures + AppKit; took upstream's UnitPrefs/CSV-
        export wiring; AppKit import now conditional on os(macOS).
      * project.yml: restored HEAD's NOOPiOS/NOOPiOSWidgets targets
        (upstream had dropped them), applied the 1.80/build 57 bumps
        and the SWIFT_EMIT_LOC_STRINGS dedup.

  - Localizable.xcstrings: HEAD's catalog is a strict superset of
    upstream's (444 shared keys, identical German values; 30 HEAD-only
    iOS-extraction keys; 0 upstream-only), so taken from HEAD. New
    English strings are auto-extracted at build time.

Three post-merge fixes for the iOS build:
  - CsvExport.swift wraps `import AppKit` in canImport and its
    NSSavePanel block in #if os(macOS), with the DataBackup-pattern
    DocumentPicker fallback on iOS.
  - LiveView.swift: AppKit import moved under #if os(macOS) (upstream
    had added it unconditionally for the now-replaced NSPasteboard
    path).
  - TermsGateView.swift: .toggleStyle(.checkbox) replaced with .switch
    on iOS (checkbox is macOS-only).

Verified with an iOS Simulator build (iPhone 17, iOS 26.5).
Xcode 16 on macos-15 only carries the iOS 18 SDK, so any iOS 26-only
symbol the app targets fails to compile in CI even though the local
iOS 26.5 simulator build is green. macos-26 ships Xcode 26+ with the
matching iOS 26 SDK; `latest-stable` lets the runner pick the newest
shipping Xcode without pinning to a version that may be removed later.

Addresses PR NoopApp#42 review (tigercraft4, .github/workflows/release.yaml:21).
Make explicit in the workflow that stripping entitlements removes the
App Group and HealthKit capabilities from the unsigned build, so CI
cannot validate WidgetSnapshot / PendingIntents / Live Activity
cross-process paths. A green CI run only means the code compiles —
those integration paths still need an Xcode build with the real
entitlements.

Addresses PR NoopApp#42 review (tigercraft4, .github/workflows/release.yaml:36).
Switch from -target NOOPiOS to -scheme NOOPiOS. Targeting bypasses
scheme pre-actions, the dependency graph, and extension embedding,
so the NOOPiOSWidgets / Live Activity extension would never compile
and a broken widget target wouldn't fail CI. The scheme builds the
whole app the way a real archive would.

Addresses PR NoopApp#42 review (tigercraft4, .github/workflows/release.yaml:30).
Replace the hardcoded \`<string>25</string>\` in StrandiOS/Resources/Info.plist
with \$(CURRENT_PROJECT_VERSION), and move the iOS build number onto
the NOOPiOS target's settings.base.CURRENT_PROJECT_VERSION in
project.yml. App Store Connect / TestFlight uploads now track one
project setting instead of two literals that could drift apart and
collide on upload.

Addresses PR NoopApp#42 review (tigercraft4, StrandiOS/Resources/Info.plist:23).
The iOS restoration option was duplicated verbatim across both BLEManager
init overloads — easy to drift if one is updated without the other.
Pull the CBCentralManager constructor into a single private static
makeCentral(delegate:) so the restoration policy lives in one place.

Adds a comment on the helper documenting CoreBluetooth's hard
requirement: CBCentralManager must be constructed synchronously during
app launch for state restoration to fire. AppModel constructs
BLEManager synchronously, and StrandiOSApp.init constructs AppModel
synchronously, so the eager-construction contract is already met.

Addresses PR NoopApp#42 review (tigercraft4, Strand/BLE/BLEManager.swift:243).
\`WidgetSnapshot.suiteName\` (\"group.com.noopapp.noop\") must exactly
match the \`com.apple.security.application-groups\` entitlement on
BOTH the app and the widget target. A mismatch silently fails:
\`UserDefaults(suiteName:)\` returns nil and every consumer
(PendingIntents.append/drain, WidgetSnapshot.publish, Live Activity)
becomes a no-op, with no log line or visible failure — only the
widget showing nothing.

Add \`assertGroupProvisioned()\` so the canary trips on the first
debug-build run after a misprovisioning. Release builds compile it
out — App Store apps can't crash on a missing entitlement. The call
site (StrandiOSApp.init) is added in a follow-up commit so this one
stays focused on the shared API.

Addresses PR NoopApp#42 review (tigercraft4, StrandiOSShared/WidgetSnapshot.swift:27).
WhoopStore runs in WAL mode (PRAGMA journal_mode = WAL), so while the
store is open recent writes live in <db>-wal, not the main .sqlite.
The macOS export path calls copySidecarsIfPresent when the checkpoint
closure couldn't run, so nothing on disk is lost even if the WAL
wasn't folded in. iOS can't follow suit: DocumentPicker.export only
hands the system a single file, so the -wal/-shm siblings can't ride
along, and the import side would never see the pending writes.

Guard on \`checkpointed\` and return a user-facing .failure when it's
false, so we never produce a silently-incomplete backup on iOS. The
common case (store open, checkpoint succeeds) is unchanged.

Addresses PR NoopApp#42 review (tigercraft4, Strand/Data/DataBackup.swift:93).
exportText's iOS path used \`try?\` on the temp write and then opened
UIActivityViewController whether the file existed or not. A failed
write — disk full, sandbox path miss — handed the share sheet a
missing/empty URL, so the user got a broken export with no error.

Check the write outcome and only present on success. Also pass a
\`cleanup\` list to the share-sheet completion handler so files we
staged ourselves in \`temporaryDirectory\` don't accumulate across
runs. exportFile is the caller-owns-the-file path (Puffin captures,
SQLite backups) — those files are NOT cleaned up.

Addresses PR NoopApp#42 review (tigercraft4, Strand/System/FileExport.swift:35).
\`update\` is driven by \`model.live.\$heartRate\` — roughly once per
heart-rate sample, so ~1 Hz while a session is live. Instantiating
\`ActivityAuthorizationInfo()\` per call is needless system-bridge
allocation. ActivityKit's auth status only changes via Settings, so
caching for the controller's lifetime is safe.

Addresses PR NoopApp#42 review (tigercraft4, StrandiOS/Widgets/LiveActivityController.swift:18).
If two HR samples arrived close together while \`activity == nil\`,
both entered the else branch. The first \`Activity.request\` hadn't
completed (and assigned \`self.activity\`) when the second check ran,
so both saw nil and both fired \`Activity.request\` — creating two
simultaneous Live Activities. The 2-second throttle only protected
the update path, not the start path.

Add a synchronous \`isStarting\` flag set BEFORE \`Activity.request\`
so the second update bails immediately. The flag clears once the
request returns (success or throw).

Addresses PR NoopApp#42 review (tigercraft4, StrandiOS/Widgets/LiveActivityController.swift:29).
DayAgg has no bodyMass field and the sync loop never aggregates
weight. Keeping .bodyMass in quantityReadIds added it to the
HealthKit permission dialog and surfaced a privacy ask we don't
honour — noise for the user with no consumed benefit.

Add a comment so the next addition to this list goes through DayAgg
first.

Addresses PR NoopApp#42 review (tigercraft4, StrandiOS/Health/HealthKitBridge.swift:50).
The rest of the project keys days by UTC (Repository.compareDayParser,
WhoopStore's dailyMetric primary key). dayFormatter used .current,
so a user crossing time zones mapped the same physical day to
different yyyy-MM-dd strings — duplicate / split daily rows on the
next upsert. The write-back path used the same formatter, so a key
computed in one zone could orphan the prior day's row in another.

Pin the formatter to TimeZone(identifier: \"UTC\") so reads and writes
agree with the store and with each other.

Addresses PR NoopApp#42 review (tigercraft4, StrandiOS/Health/HealthKitBridge.swift:247).
writeBack created fresh HKQuantitySample instances on every call and
HealthKit assigns a new UUID per save, so repeated sync() runs (one
per app-active) flooded Apple Health with duplicate resting HR, HRV,
SpO2, and respiratory rate samples — HealthKit doesn't dedupe by
value, only by UUID.

Switch to a delete-then-write upsert. Every emitted sample carries a
deterministic HKMetadataKeyExternalUUID of
\"noop:<noopDeviceId>:<metric>:<day>\". Before saving the fresh batch,
delete any of OUR prior samples (scoped to HKSource.default() so we
never touch another app's data) that carry the same keys. Now a
re-sync replaces values instead of stacking duplicates.

Save errors are still swallowed in this commit; the follow-up wraps
writeBack in proper do/catch and surfaces the failure so lastSync
doesn't advance over a permanently-skipped window.

Addresses PR NoopApp#42 review (tigercraft4, StrandiOS/Health/HealthKitBridge.swift:148).
The previous \`try? await store.save(samples)\` swallowed every
HealthKit save failure — auth revoked for a specific type, invalid
sample, quota hit — and \`lastSync = Date()\` ran anyway. The next
delta sync skipped the window so the data was permanently never
written back.

Make writeBack throw, wrap the call in sync() in real do/catch,
and only advance lastSync on success. New @published lastError gives
the UI a hook to surface the failure (Settings can show a sync-failed
state alongside the existing syncing / auth pills) instead of going
silent.

Addresses PR NoopApp#42 review (tigercraft4, StrandiOS/Health/HealthKitBridge.swift:150).
Cold launch fired \`health.sync()\` in two places: the .task block on
ContentView (line 33) and again from .onChange(of: scenePhase) when
the scene transitioned to .active (line 47). HealthKitBridge.sync
guards on \`auth == .authorized\` and \`!syncing\` so two calls were a
no-op once auth was settled, but on the very first launch — with the
auth dialog still in flight — they overlapped and amplified the
duplicate-sample problem the write-back fix targets.

Keep scenePhase.active as the single trigger: it covers cold launch
AND every foreground return. Leave requestAuthorization in .task for
now; the next commit moves that out too.

Addresses PR NoopApp#42 review (tigercraft4, StrandiOS/App/StrandiOSApp.swift:33 — double sync).
requestAuthorization() ran in .task on first launch, so the system
permission dialog appeared before the user had seen any in-app
rationale — against Apple HIG and App Review guidance for HealthKit.
The auth dialog also raced sync() in the same .task block; if the
user took a moment to answer, sync() started against an unsettled
auth state.

Drop the .task block entirely. HealthKitBridge.sync guards on
\`auth == .authorized\` so the scenePhase.active trigger is a safe
no-op until authorization is granted from an explicit user action
(e.g. an "Enable Apple Health" row in Settings or an onboarding
step). The comment in scenePhase points the next reader at where to
wire that in.

Also calls WidgetSnapshot.assertGroupProvisioned() in init so a
missing App Group entitlement trips on the first debug run instead
of silently no-op'ing the widget / Live Activity flow.

Addresses PR NoopApp#42 review (tigercraft4, StrandiOS/App/StrandiOSApp.swift:33 — auth at launch,
and StrandiOSShared/WidgetSnapshot.swift:27 — App Group canary call site).
@eltomato89 eltomato89 requested a review from tigercraft4 June 10, 2026 23:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

experimental Kept available to build, not merged into main

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants