Skip to content

Apple Watch app: complications, data grid, background delivery - #594

Closed
bjorkert wants to merge 98 commits into
devfrom
apple-watch
Closed

bjorkert wants to merge 98 commits into
devfrom
apple-watch

Conversation

@bjorkert

@bjorkert bjorkert commented Apr 9, 2026 •

Copy link
Copy Markdown
Member

No description provided.

MtlPhil and others added 30 commits March 7, 2026 17:05
…f-push)

Implements a lock screen and Dynamic Island Live Activity for LoopFollow displaying real-time glucose data updated via APNs self-push.

## What's included

- Lock screen card: glucose + trend arrow, delta, IOB, COB, projected, last update time, threshold-driven background color (green/orange/red)
- Dynamic Island: compact, expanded, and minimal presentations
- Not Looping overlay: red banner when Loop hasn't reported in 15+ min
- APNs self-push: app sends push to itself for reliable background updates without interference from background audio session
- Single source of truth: all data flows from Storage/Observable
- Source-agnostic: IOB/COB/projected are optional, safe for Dexcom-only users
- Dynamic App Group ID: derived from bundle identifier, no hardcoded team IDs
- APNs key injected via xcconfig/Info.plist — never bundled, never committed

## Files added
- LoopFollow/LiveActivity/: APNSClient, APNSJWTGenerator, AppGroupID, GlucoseLiveActivityAttributes, GlucoseSnapshot, GlucoseSnapshotBuilder, GlucoseSnapshotStore, GlucoseUnitConversion, LAAppGroupSettings, LAThresholdSync, LiveActivityManager, PreferredGlucoseUnit, StorageCurrentGlucoseStateProvider
- LoopFollowLAExtension/: LoopFollowLiveActivity, LoopFollowLABundle
- docs/LiveActivity.md (architecture + APNs setup guide)

## Files modified
- Storage: added lastBgReadingTimeSeconds, lastDeltaMgdl, lastTrendCode, lastIOB, lastCOB, projectedBgMgdl
- Observable: added isNotLooping
- BGData, DeviceStatusLoop, DeviceStatusOpenAPS: write canonical values to Storage
- DeviceStatus: write isNotLooping to Observable
- BackgroundTaskAudio: cleanup
- MainViewController: wired LiveActivityManager.refreshFromCurrentState()
- Info.plist: added APNSKeyID, APNSTeamID, APNSKeyContent build settings
- fastlane/Fastfile: added extension App ID and provisioning profile
- build_LoopFollow.yml: inject APNs key from GitHub secret
- Consolidate JWT generation into JWTManager using CryptoKit with
  multi-slot in-memory cache, removing SwiftJWT and swift-crypto SPM
  dependencies
- Separate APNs keys for LoopFollow (lf) vs remote commands, with
  automatic team-ID routing and a migration step for legacy keys
- Add dedicated APN settings page for LoopFollow's own APNs keys
- Remove hardcoded APNs credentials from CI workflow and Info.plist
  in favor of user-configured keys
- Apply swiftformat to Live Activity files
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- staleDate on every ActivityContent now tracks the renewal deadline,
  so the system shows Apple's built-in stale indicator if renewal fails
- Add laRenewalFailed StorageValue; set on Activity.request() failure,
  cleared on any successful LA start
- Observe willEnterForegroundNotification: retry startIfNeeded() if a
  previous renewal attempt failed
- New-first renewal order: request the replacement LA before ending the
  old one — if the request throws the existing LA stays alive so the
  user keeps live data until the system kills it at the 8-hour mark

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Restore renewalThreshold to 7.5 * 3600 (testing complete)
- Add showRenewalOverlay: Bool to GlucoseSnapshot (Codable, default false)
- GlucoseSnapshotBuilder sets it true when now >= laRenewBy - 1800
  (30 minutes before the renewal deadline)
- Lock screen: 60% gray overlay with "Tap to update" centered in white,
  layered above the existing isNotLooping overlay
- DI expanded: RenewalOverlayView applied to leading/trailing/bottom
  regions; "Tap to update" text shown on the bottom region only
- showRenewalOverlay resets to false automatically on renewal since
  laRenewBy is updated and the next snapshot rebuild clears the flag

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Overlay rendering:
- Replace Group{if condition{ZStack{RoundedRectangle...}}} with a
  permanently-present ZStack toggled via .opacity(). The Group/if
  pattern causes SwiftUI sizing ambiguity when the condition transitions
  from false→true inside an .overlay(), producing a zero-size result.
  The .opacity() approach keeps a stable view hierarchy.
- Same fix applied to RenewalOverlayView used on DI expanded regions.

Foreground restart:
- handleForeground() was calling startIfNeeded(), which finds the
  still-alive (failed-to-renew) LA in Activity.activities and reuses
  it, doing nothing useful. Fixed to manually nil out current, cancel
  all tasks, await activity.end(.immediate), then startFromCurrentState().

Overlay timing:
- Changed warning window from 30 min (1800s) to 20 min (1200s) before
  the renewal deadline, matching the intended test cadence.

Logging:
- handleForeground: log on every foreground event with laRenewalFailed value
- renewIfNeeded: log how many seconds past the deadline when firing
- GlucoseSnapshotBuilder: log when overlay activates with seconds to deadline
- performRefresh: log when sending an update with the overlay visible

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Normal renewal path (renewIfNeeded success):
- Build a fresh snapshot with showRenewalOverlay: false for the new
  LA's initial content — it has a new deadline so the overlay should
  never be visible from the first frame.
- Save that fresh snapshot to GlucoseSnapshotStore so the next
  duplicate check has the correct baseline and doesn't suppress the
  first real BG update.

Foreground restart path (handleForeground):
- Zero laRenewBy before calling startFromCurrentState() so
  GlucoseSnapshotBuilder computes showRenewalOverlay = false for the
  seed snapshot. startIfNeeded() then sets the new deadline after the
  request succeeds.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ension

- Wrap ActivityKit-dependent files (GlucoseLiveActivityAttributes,
  LiveActivityManager, APNSClient) in #if !targetEnvironment(macCatalyst)
- Guard LiveActivityManager call sites in MainViewController, BGData,
  and DeviceStatus with the same compile-time check
- Remove unnecessary @available(iOS 16.1, *) checks (deployment target
  is already 16.6)
- Add platformFilter = ios to the widget extension embed phase and
  target dependency so it is excluded from Mac Catalyst builds
With renewalThreshold=20min and the hardcoded 1200s warning window,
renewBy-1200 = start, so showRenewalOverlay is always true from the
moment the LA begins.

Extract a named renewalWarning constant (5 min for testing) so the
warning window is always less than the threshold. The builder now reads
LiveActivityManager.renewalWarning instead of a hardcoded literal.

Production values to restore before merging:
  renewalThreshold = 7.5 * 3600
  renewalWarning   = 20 * 60

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ynchronously

APNSClient was missing showRenewalOverlay from the push payload, so background
APNs updates never delivered the overlay flag to the extension — only foreground
direct ActivityKit updates did.

In handleForeground, laRenewBy is now zeroed synchronously before spawning the
async end/restart Task. This means any snapshot built between the foreground
notification and the new LA start (e.g. from viewDidAppear's startFromCurrentState)
computes showRenewalOverlay = false rather than reading the stale expired deadline.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…e path

Reset laRenewBy and laRenewalFailed synchronously before tearing down the
failed LA, then await activity.end() before calling startFromCurrentState().

This guarantees Activity.activities is clear when startIfNeeded() runs, so
it takes the fresh-request path and writes a new laRenewBy. startFromCurrentState
rebuilds the snapshot with showRenewalOverlay=false (laRenewBy=0), saves it
to the store, then startIfNeeded uses that clean snapshot as the seed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat: Live Activity auto-renewal to work around 8-hour system limit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test: reduce LA renewal threshold to 20 min for testing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: improve LA renewal robustness and stale indicator

- staleDate on every ActivityContent now tracks the renewal deadline,
  so the system shows Apple's built-in stale indicator if renewal fails
- Add laRenewalFailed StorageValue; set on Activity.request() failure,
  cleared on any successful LA start
- Observe willEnterForegroundNotification: retry startIfNeeded() if a
  previous renewal attempt failed
- New-first renewal order: request the replacement LA before ending the
  old one — if the request throws the existing LA stays alive so the
  user keeps live data until the system kills it at the 8-hour mark

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: renewal warning overlay + restore 7.5h threshold

- Restore renewalThreshold to 7.5 * 3600 (testing complete)
- Add showRenewalOverlay: Bool to GlucoseSnapshot (Codable, default false)
- GlucoseSnapshotBuilder sets it true when now >= laRenewBy - 1800
  (30 minutes before the renewal deadline)
- Lock screen: 60% gray overlay with "Tap to update" centered in white,
  layered above the existing isNotLooping overlay
- DI expanded: RenewalOverlayView applied to leading/trailing/bottom
  regions; "Tap to update" text shown on the bottom region only
- showRenewalOverlay resets to false automatically on renewal since
  laRenewBy is updated and the next snapshot rebuild clears the flag

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: overlay not appearing + foreground restart not working

Overlay rendering:
- Replace Group{if condition{ZStack{RoundedRectangle...}}} with a
  permanently-present ZStack toggled via .opacity(). The Group/if
  pattern causes SwiftUI sizing ambiguity when the condition transitions
  from false→true inside an .overlay(), producing a zero-size result.
  The .opacity() approach keeps a stable view hierarchy.
- Same fix applied to RenewalOverlayView used on DI expanded regions.

Foreground restart:
- handleForeground() was calling startIfNeeded(), which finds the
  still-alive (failed-to-renew) LA in Activity.activities and reuses
  it, doing nothing useful. Fixed to manually nil out current, cancel
  all tasks, await activity.end(.immediate), then startFromCurrentState().

Overlay timing:
- Changed warning window from 30 min (1800s) to 20 min (1200s) before
  the renewal deadline, matching the intended test cadence.

Logging:
- handleForeground: log on every foreground event with laRenewalFailed value
- renewIfNeeded: log how many seconds past the deadline when firing
- GlucoseSnapshotBuilder: log when overlay activates with seconds to deadline
- performRefresh: log when sending an update with the overlay visible

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test: set renewalThreshold to 20 min for testing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: renewal overlay not clearing after LA is refreshed

Normal renewal path (renewIfNeeded success):
- Build a fresh snapshot with showRenewalOverlay: false for the new
  LA's initial content — it has a new deadline so the overlay should
  never be visible from the first frame.
- Save that fresh snapshot to GlucoseSnapshotStore so the next
  duplicate check has the correct baseline and doesn't suppress the
  first real BG update.

Foreground restart path (handleForeground):
- Zero laRenewBy before calling startFromCurrentState() so
  GlucoseSnapshotBuilder computes showRenewalOverlay = false for the
  seed snapshot. startIfNeeded() then sets the new deadline after the
  request succeeds.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: overlay permanently active when warning window equals threshold

With renewalThreshold=20min and the hardcoded 1200s warning window,
renewBy-1200 = start, so showRenewalOverlay is always true from the
moment the LA begins.

Extract a named renewalWarning constant (5 min for testing) so the
warning window is always less than the threshold. The builder now reads
LiveActivityManager.renewalWarning instead of a hardcoded literal.

Production values to restore before merging:
  renewalThreshold = 7.5 * 3600
  renewalWarning   = 20 * 60

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: include showRenewalOverlay in APNs payload and clear laRenewBy synchronously

APNSClient was missing showRenewalOverlay from the push payload, so background
APNs updates never delivered the overlay flag to the extension — only foreground
direct ActivityKit updates did.

In handleForeground, laRenewBy is now zeroed synchronously before spawning the
async end/restart Task. This means any snapshot built between the foreground
notification and the new LA start (e.g. from viewDidAppear's startFromCurrentState)
computes showRenewalOverlay = false rather than reading the stale expired deadline.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: await LA end before restarting on foreground retry to avoid reuse path

Reset laRenewBy and laRenewalFailed synchronously before tearing down the
failed LA, then await activity.end() before calling startFromCurrentState().

This guarantees Activity.activities is clear when startIfNeeded() runs, so
it takes the fresh-request path and writes a new laRenewBy. startFromCurrentState
rebuilds the snapshot with showRenewalOverlay=false (laRenewBy=0), saves it
to the store, then startIfNeeded uses that clean snapshot as the seed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: restore production renewal timing (7.5h threshold, 20min warning)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
- SettingsMenuView: rename "APN" menu entry to "Live Activity"
- Storage: add laEnabled: Bool StorageValue (default false)
- APNSettingsView: on/off toggle bound to laEnabled; APNs key fields and
  Restart button shown only when enabled; disabling immediately ends any
  running LA
- LiveActivityManager:
  - forceRestart() (@mainactor) ends all running activities, resets laRenewBy
    and laRenewalFailed, then calls startFromCurrentState()
  - laEnabled guard added to startFromCurrentState(), refreshFromCurrentState(),
    handleForeground(), and handleDidBecomeActive()
  - didBecomeActiveNotification observer calls forceRestart() on every
    foreground transition when laEnabled is true
- RestartLiveActivityIntent: AppIntent + ForegroundContinuableIntent; sets
  laEnabled, validates credentials, opens settings deep link if missing,
  otherwise calls forceRestart() in foreground; LoopFollowAppShortcuts
  exposes the intent with Siri phrase

Note: RestartLiveActivityIntent.swift must be added to the app target in Xcode.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ctivityIntent

- LiveActivityManager: handleDidBecomeActive() calls @mainactor forceRestart()
  via Task { @mainactor in ... } to satisfy Swift concurrency isolation
- RestartLiveActivityIntent: drop ForegroundContinuableIntent — continueInForeground()
  was renamed to continueInForeground(_:alwaysConfirm:) in the iOS 26 SDK and
  requires iOS 26+; the didBecomeActiveNotification observer handles restart
  when the app comes to foreground, making explicit continuation unnecessary

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
continueInForeground(_:alwaysConfirm:) is iOS 26+ only. On earlier
versions, forceRestart() runs directly via the existing background
audio session — no foreground continuation needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…rceRestart

forceRestart() was killing the LA that the RestartLiveActivityIntent had
just created, because continueInForeground() triggers didBecomeActive.
startFromCurrentState() reuses an existing active LA via startIfNeeded()
and only creates one if none is present — no destructive race.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
APNSettingsView:
- "Restart Live Activity" button shows "Live Activity Restarted" for 2 seconds
  and disables itself during that window to prevent double-taps
- Toggle observes Storage.shared.laEnabled via onReceive so it reflects changes
  made externally (e.g. by the App Intent / Shortcuts)

LiveActivityManager:
- handleDidBecomeActive posts .liveActivityDidForeground after startFromCurrentState
- Notification.Name.liveActivityDidForeground extension defined here

MainViewController:
- Observes .liveActivityDidForeground; navigates to Snoozer tab if an alarm
  is currently active, otherwise navigates to tab 0 (Home)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…latest data

The 5-second debounce on refreshFromCurrentState means a BG update arriving
while the app is foreground won't reach the LA before the user returns to the
lock screen. When the debounce fires in background, isForeground=false so the
direct ActivityKit update is skipped.

Fix: observe willResignActiveNotification, cancel the pending debounce, and
immediately push the latest snapshot via direct ActivityKit update while the
app is still foreground-active. The APNs push is also sent as a backup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Compact:
- Trailing now shows delta instead of trend arrow (leading already showed BG)

Expanded leading:
- Large BG value at top
- Second row: trend arrow, delta, and "Proj: X" in smaller text below

Expanded trailing:
- IOB and COB (moved from bottom)

Expanded bottom:
- "Updated at: HH:mm" (moved from trailing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Proj label now uses same size (13), weight (semibold), and opacity (0.9) as
  the delta text beside it
- Added 6pt trailing padding to the expanded DI trailing VStack so the IOB
  decimal is not clipped by the Dynamic Island edge

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Per maintainer preference, the APNs credentials (Key ID and Key) remain in
their own "APN" settings screen. A new separate "Live Activity" settings screen
contains the enable/disable toggle and the Restart Live Activity button.

- APNSettingsView: restored to Key ID + Key only (no toggle or restart button)
- LiveActivitySettingsView: new view with laEnabled toggle and restart button
  (with 2-second confirmation feedback); toggle stays in sync via onReceive
- SettingsMenuView: "APN" entry restored; new "Live Activity" entry added with
  dot.radiowaves icon; Sheet.liveActivity case wired to LiveActivitySettingsView

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
MtlPhil and others added 20 commits March 18, 2026 11:39
# Conflicts:
#	LoopFollow/Helpers/BackgroundTaskAudio.swift
#	LoopFollow/LiveActivity/LiveActivityManager.swift
# Conflicts:
#	LoopFollow/Storage/Storage+Migrate.swift
* Update BackgroundTaskAudio.swift

* Update GlucoseLiveActivityAttributes.swift

* Update GlucoseLiveActivityAttributes.swift

* Restore explanatory comment for 0.5s audio restart delay
Deployment target is iOS 16.6, so these annotations are redundant.
…#570)

* Update BackgroundTaskAudio.swift

* Update GlucoseLiveActivityAttributes.swift

* Update GlucoseLiveActivityAttributes.swift

* Restore explanatory comment for 0.5s audio restart delay

* Add BGAppRefreshTask support for silent audio recovery

  Registers com.loopfollow.audiorefresh with BGTaskScheduler so iOS
  can wake the app every ~15 min to check if the silent audio session
  is still alive and restart it if not.

  Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix BGAppRefreshTask: add fetch background mode, fix duplicate observer

  - Add 'fetch' to UIBackgroundModes so BGTaskScheduler.submit() doesn't
    throw notPermitted on every background transition
  - Call stopBackgroundTask() before startBackgroundTask() in the refresh
    handler to prevent accumulating duplicate AVAudioSession observers

  Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix duplicate audio observer; add restart confirmation log

- startBackgroundTask() now removes the old observer before adding,
  making it idempotent and preventing duplicate interrupt callbacks
- Add 'audio restart initiated' log after restart so success is
  visible without debug mode
- Temporarily make 'Silent audio playing' log always visible for testing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Delete LiveActivitySlotConfig.swift

Forgotten stub.

* Update GlucoseSnapshotBuilder.swift

* Update StorageCurrentGlucoseStateProvider.swift

* Update LiveActivityManager.swift

* Update GlucoseSnapshot.swift

* Update GlucoseSnapshot.swift

* Update LiveActivityManager.swift

* Update LiveActivityManager.swift

* Update GlucoseLiveActivityAttributes.swift

* Update LiveActivityManager.swift

* Add LA expiry notification; fix OS-dismissed vs user-dismissed

- When renewIfNeeded fails in the background (app can't start a new LA
  because it's not visible), schedule a local notification on the first
  failure: "Live Activity Expiring — Open LoopFollow to restart."
  Subsequent failures in the same cycle are suppressed. Notification is
  cancelled if renewal later succeeds or forceRestart is called.
- In attachStateObserver, distinguish iOS force-dismiss (laRenewalFailed
  == true) from user swipe (laRenewalFailed == false). OS-dismissed LAs
  no longer set dismissedByUser, so opening the app triggers auto-restart
  as expected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Remove dead pendingLATapNavigation code

Force-quitting an app kills its Live Activities, so cold-launch via
LA tap only occurs when iOS terminates the app — in which case
scene(_:openURLContexts:) already handles navigation correctly via
DispatchQueue.main.async. The flag was never set and never needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Code quality pass: log categories, SwiftFormat, dead code cleanup

- BackgroundRefreshManager: all logs → .taskScheduler
- AppDelegate: APNs registration/notification logs → .apns
- APNSClient: all logs → .apns
- BackgroundTaskAudio: restore isDebug:true on silent audio log; fix double blank line
- LiveActivityManager: fix trailing whitespace; remove double blank line; SwiftFormat
- GlucoseSnapshotBuilder: fix file header (date → standard LoopFollow header)
- LoopFollowLiveActivity: remove dead commented-out activityID property
- SwiftFormat applied across all reviewed LiveActivity/, Storage/, extension files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Round prediction value before Int conversion

Prevents truncation toward zero (e.g. 179.9 → 179); now correctly rounds to nearest integer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix double setTaskCompleted race; fix renewal deadline write ordering

BackgroundRefreshManager: guard against double setTaskCompleted if the
expiration handler fires while the main-queue block is in-flight. Apple
documents calling setTaskCompleted more than once as a programming error.

LiveActivityManager.renewIfNeeded: write laRenewBy to Storage only after
Activity.request succeeds, eliminating the narrow window where a crash
between the write and the request could leave the deadline permanently
stuck in the future. No rollback needed on failure. The fresh snapshot
is built via withRenewalOverlay(false) directly rather than re-running
the builder, since the caller already has a current snapshot.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…ppRefreshTask (#574)

* Update BackgroundTaskAudio.swift

* Update GlucoseLiveActivityAttributes.swift

* Update GlucoseLiveActivityAttributes.swift

* Restore explanatory comment for 0.5s audio restart delay

* Add BGAppRefreshTask support for silent audio recovery

  Registers com.loopfollow.audiorefresh with BGTaskScheduler so iOS
  can wake the app every ~15 min to check if the silent audio session
  is still alive and restart it if not.

  Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix BGAppRefreshTask: add fetch background mode, fix duplicate observer

  - Add 'fetch' to UIBackgroundModes so BGTaskScheduler.submit() doesn't
    throw notPermitted on every background transition
  - Call stopBackgroundTask() before startBackgroundTask() in the refresh
    handler to prevent accumulating duplicate AVAudioSession observers

  Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix duplicate audio observer; add restart confirmation log

- startBackgroundTask() now removes the old observer before adding,
  making it idempotent and preventing duplicate interrupt callbacks
- Add 'audio restart initiated' log after restart so success is
  visible without debug mode
- Temporarily make 'Silent audio playing' log always visible for testing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Delete LiveActivitySlotConfig.swift

Forgotten stub.

* Update GlucoseSnapshotBuilder.swift

* Update StorageCurrentGlucoseStateProvider.swift

* Update LiveActivityManager.swift

* Update GlucoseSnapshot.swift

* Update GlucoseSnapshot.swift

* Update LiveActivityManager.swift

* Update LiveActivityManager.swift

* Update GlucoseLiveActivityAttributes.swift

* Update LiveActivityManager.swift

* Add LA expiry notification; fix OS-dismissed vs user-dismissed

- When renewIfNeeded fails in the background (app can't start a new LA
  because it's not visible), schedule a local notification on the first
  failure: "Live Activity Expiring — Open LoopFollow to restart."
  Subsequent failures in the same cycle are suppressed. Notification is
  cancelled if renewal later succeeds or forceRestart is called.
- In attachStateObserver, distinguish iOS force-dismiss (laRenewalFailed
  == true) from user swipe (laRenewalFailed == false). OS-dismissed LAs
  no longer set dismissedByUser, so opening the app triggers auto-restart
  as expected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Remove dead pendingLATapNavigation code

Force-quitting an app kills its Live Activities, so cold-launch via
LA tap only occurs when iOS terminates the app — in which case
scene(_:openURLContexts:) already handles navigation correctly via
DispatchQueue.main.async. The flag was never set and never needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Code quality pass: log categories, SwiftFormat, dead code cleanup

- BackgroundRefreshManager: all logs → .taskScheduler
- AppDelegate: APNs registration/notification logs → .apns
- APNSClient: all logs → .apns
- BackgroundTaskAudio: restore isDebug:true on silent audio log; fix double blank line
- LiveActivityManager: fix trailing whitespace; remove double blank line; SwiftFormat
- GlucoseSnapshotBuilder: fix file header (date → standard LoopFollow header)
- LoopFollowLiveActivity: remove dead commented-out activityID property
- SwiftFormat applied across all reviewed LiveActivity/, Storage/, extension files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Round prediction value before Int conversion

Prevents truncation toward zero (e.g. 179.9 → 179); now correctly rounds to nearest integer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix double setTaskCompleted race; fix renewal deadline write ordering

BackgroundRefreshManager: guard against double setTaskCompleted if the
expiration handler fires while the main-queue block is in-flight. Apple
documents calling setTaskCompleted more than once as a programming error.

LiveActivityManager.renewIfNeeded: write laRenewBy to Storage only after
Activity.request succeeds, eliminating the narrow window where a crash
between the write and the request could leave the deadline permanently
stuck in the future. No rollback needed on failure. The fresh snapshot
is built via withRenewalOverlay(false) directly rather than re-running
the builder, since the caller already has a current snapshot.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Scope all identifiers to bundle ID for multi-instance support

Derive BGTask IDs, notification IDs, URL schemes, and notification
categories from Bundle.main.bundleIdentifier so that LoopFollow,
LoopFollow_Second, and LoopFollow_Third each get isolated identifiers
and don't interfere with each other's background tasks, notifications,
or Live Activities.

Also show the configured display name in the Live Activity footer
(next to the update time) when the existing "Show Display Name"
toggle is enabled, so users can identify which instance a LA belongs to.

* Linting

* Add migration step 7: cancel legacy notification identifiers

Users upgrading from the old hardcoded identifiers would have orphaned
pending notifications that the new bundle-ID-scoped code can't cancel.
This one-time migration cleans them up on first launch.

* Increase LA refresh debounce from 5s to 20s to coalesce double push

The `bg` and `loopingResumed` refresh triggers fire ~10s apart. With a 5s
debounce, `loopingResumed` arrives after the debounce has already executed,
causing two APNs pushes per BG cycle instead of one. Widening the window to
20s ensures both events are coalesced into a single push containing the most
up-to-date post-loop-cycle state (fresh IOB, predicted BG, etc.).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Guard migrations against background launch to prevent BFU settings wipe

When BGAppRefreshTask fires after a reboot (before the user has unlocked
the device), UserDefaults files are still encrypted (Before First Unlock
state). Reading migrationStep returns 0, causing all migrations to re-run.
migrateStep1 reads old_url from the also-locked App Group suite, gets "",
and writes "" to url — wiping Nightscout and other settings.

Fix: skip the entire migration block when the app is in background state.
Migrations will run correctly on the next foreground open. This is safe
since no migration is time-critical and all steps are guarded by version
checks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix BFU migration guard: wrap only migrations, not all of viewDidLoad

The previous fix used guard+return which skipped the entire viewDidLoad
when the app launched in background (BGAppRefreshTask). viewDidLoad only
runs once per VC lifecycle, so the UI was never initialized when the user
later foregrounded the app — causing a blank screen.

Fix: wrap only the migration block in an if-check, so UI setup always
runs. Migrations are still skipped in background to avoid BFU corruption.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Defer migrations to first foreground after BFU background launch

runMigrationsIfNeeded() extracts the migration block and is called from
both viewDidLoad (normal launch) and appCameToForeground() (deferred
case). The guard skips execution when applicationState == .background
to prevent BFU corruption, and appCameToForeground() picks up any
deferred migrations the first time the user unlocks after a reboot.

The previous fix (wrapping migrations in an if-block inside viewDidLoad)
correctly prevented BFU corruption but left migrations permanently
unrun after a background cold-start, causing the app to behave as a
fresh install and prompt for Nightscout/Dexcom setup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Use didBecomeActive (not willEnterForeground) for deferred migration recovery

willEnterForegroundNotification fires while applicationState may still
be .background, causing the BFU guard in runMigrationsIfNeeded() to
skip migrations a second time. didBecomeActiveNotification guarantees
applicationState == .active, so the guard always passes.

Adds a dedicated appDidBecomeActive() handler that only calls
runMigrationsIfNeeded(). Since that function is idempotent (each step
checks migrationStep.value < N), calling it on every activation after
migrations have already completed is a fast no-op.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Remove BGAppRefreshTask completely

BGAppRefreshTask caused iOS to cold-launch the app in the background
after a reboot. In Before-First-Unlock state, UserDefaults is encrypted
and all reads return defaults, causing migrations to re-run and wipe
settings (Nightscout URL, etc.). Multiple fix attempts could not
reliably guard against this without risking the UI never initialising.

Removed entirely:
- BackgroundRefreshManager.swift (deleted)
- AppDelegate: BackgroundRefreshManager.shared.register()
- MainViewController: BackgroundRefreshManager.shared.scheduleRefresh()
  and all migration-guard code added to work around the BFU issue
- Info.plist: com.loopfollow.audiorefresh BGTaskSchedulerPermittedIdentifier
- Info.plist: fetch UIBackgroundMode
- project.pbxproj: all four BackgroundRefreshManager.swift references

Migrations restored to their original unconditional form in viewDidLoad.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Revert "Remove BGAppRefreshTask completely"

This reverts commit 7e6b191.

* Guard migrateStep1 core fields against BFU empty reads

The four primary fields (url, device, nsWriteAuth, nsAdminAuth) were
unconditionally copied from the App Group suite to UserDefaults.standard
with no .exists check — unlike every other field in the same function.

When the app launches in the background (remote-notification mode) while
the device is in Before-First-Unlock state, the App Group UserDefaults
file is encrypted and unreadable. object(forKey:) returns nil, .exists
returns false, and .value returns the default ("" / false). Without the
guard, "" was written to url in Standard UserDefaults and flushed to disk
on first unlock, wiping the Nightscout URL.

Adding .exists checks matches the pattern used by all helper migrations
in the same function. A fresh install correctly skips (nothing to
migrate). An existing user correctly copies (old key still present in
App Group since migrateStep1 never removes it). BFU state correctly
skips (App Group unreadable, Standard value preserved).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix reboot settings wipe: reload StorageValues on foreground after BFU launch

BGAppRefreshTask cold-launches the app while the device is locked (BFU),
causing StorageValue to cache empty defaults from encrypted UserDefaults.
The scene connects during that background launch, so viewDidLoad does not
run again when the user foregrounds — leaving url="" in the @published cache
and the setup screen showing despite correct data on disk.

Fix: add StorageValue.reload() (re-reads disk, fires @published only if
changed) and call it for url/shareUserName/sharePassword at the top of
appCameToForeground(), correcting the stale cache the first time the user
opens the app after a reboot.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Reload all Nightscout credentials on foreground, not just url/share fields

token, sharedSecret, nsWriteAuth, nsAdminAuth would all be stale after a
BFU background launch — Nightscout API calls would fail or use wrong auth
even if the setup screen was correctly dismissed by the url reload.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Gate BFU reload behind isProtectedDataAvailable flag; reload all StorageValues

Instead of calling individual reloads on every foreground (noisy, unnecessary
disk reads, cascade of observers on normal launches), capture whether protected
data was unavailable at launch time. On the first foreground after a BFU launch,
call Storage.reloadAll() — which reloads every StorageValue, firing @published
only where the cached value actually changed. Normal foregrounds are unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Add BFU diagnostic logs to AppDelegate and appCameToForeground

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Reschedule all tasks after BFU reload to fix blank charts on first foreground

During BFU viewDidLoad, all tasks fire with url="" and reschedule 60s out.
checkTasksNow() on first foreground finds nothing overdue. Fix: call
scheduleAllTasks() after reloadAll() so tasks reset to their normal 2-5s
initial delay, displacing the stale 60s BFU schedule.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Show loading overlay during BFU data reload instead of blank charts

After BFU reloadAll(), viewDidLoad left isInitialLoad=false and no overlay.
Reset loading state and show the overlay so the user sees the same spinner
they see on a normal cold launch, rather than blank charts for 2-5 seconds.
The overlay auto-hides via the normal markLoaded() path when data arrives.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Redesign CarPlay SmallFamilyView to match Loop's LA layout

Two-column layout: BG + trend arrow + delta/unit on the left (colored
by glucose threshold), projected BG + unit label on the right in white.
Dynamic Island and lock screen views are unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix CarPlay: bypass activityFamily detection in supplemental widget

LoopFollowLiveActivityWidgetWithCarPlay is declared with
.supplementalActivityFamilies([.small]) so it is only ever rendered
in .small contexts (CarPlay, Watch Smart Stack). Use SmallFamilyView
directly instead of routing through LockScreenFamilyAdaptiveView,
which was falling through to LockScreenLiveActivityView when
activityFamily wasn't detected as .small.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix Watch/CarPlay: register only one widget per iOS version band

Two ActivityConfiguration widgets for the same attributes type were
registered simultaneously. The system used the primary widget for all
contexts, ignoring the supplemental one.

On iOS 18+: register only LoopFollowLiveActivityWidgetWithCarPlay
(with .supplementalActivityFamilies([.small]) and family-adaptive
routing via LockScreenFamilyAdaptiveView for all contexts).
On iOS <18: register only LoopFollowLiveActivityWidget (lock screen
and Dynamic Island only).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix build error and harden Watch/CarPlay routing

- Revert bundle to if #available without else (WidgetBundleBuilder
  does not support if/else with #available)
- Make primary widget also use LockScreenFamilyAdaptiveView on iOS 18+
  so SmallFamilyView renders correctly regardless of which widget the
  system selects for .small contexts (CarPlay / Watch Smart Stack)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Watch & CarPlay widget

* Update LoopFollowLiveActivity.swift

* Update LoopFollowLiveActivity.swift

* Update LoopFollowLABundle.swift

* Update LoopFollowLABundle.swift

* Update LoopFollowLABundle.swift

* Update LoopFollowLiveActivity.swift

* Update LoopFollowLABundle.swift

* Update LoopFollowLiveActivity.swift

* Update LoopFollowLiveActivity.swift

* Update LoopFollowLiveActivity.swift

* Remove docs/ directory from PR scope

The LiveActivity.md doc file should not be part of this PR.

https://claude.ai/code/session_01WaUhT8PoPNKumX9ZK9jeBy

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Jonas Björkert <jonas@bjorkert.se>
Add NSLock to JWTManager to prevent concurrent cache corruption when
Live Activity pushes and remote commands race on different threads.
Invalidate JWT cache on 403 in all APNs clients. Add logging for
JWT generation and cache invalidation.
Restore `let lastBGTime = entries[latestEntryIndex].date` in
viewUpdateNSBG. Dev currently fails to compile with
"Cannot find 'lastBGTime' in scope" because two PRs that merged
into dev on the same day textually did not conflict but semantically
interfered:

- #537 (Live Activity) added
  `Storage.shared.lastBgReadingTimeSeconds.value = lastBGTime`
  later in the function, relying on the existing local.
- #565 (Display HIGH/LOW) removed the local declaration as part
  of an unrelated refactor; that branch was cut before #537 landed,
  so from its perspective the local was unused.

Git's 3-way merge produced a syntactically clean file but the
surviving reference now dangles. Re-adding the local is the
minimal, intent-preserving fix.
bjorkert added a commit that referenced this pull request Apr 9, 2026
- Guard LoopFollowDebugCorner descriptor, template, and placeholder
  branches with #if DEBUG so the diagnostic complication does not ship
  to end users.
- Remove unused LAAppGroupSettings.setWatchSlots / watchSlots (the
  fixed-4-slot variant superseded by watchSelectedSlots).
- Move WatchConnectivityManager.swift from the repo root into
  LoopFollow/LiveActivity/ next to the rest of the snapshot pipeline
  and drop the duplicate file header.
- Serialize WatchConnectivityManager.lastWatchAckTimestamp access
  through a dedicated state queue. send(snapshot:) may run on any
  thread and WCSession delegate callbacks arrive on a background
  queue, so the bare TimeInterval was racy.
- Assert main-thread access for WatchSessionReceiver.cacheComplication
  and document the invariant on cachedComplications.
- Drop the no-op JSONEncoder/JSONDecoder iso8601 date strategies —
  GlucoseSnapshot has a custom encoder/decoder that writes/reads
  updatedAt as a Double, so the strategy was never consulted.
- Nit: replace `if let _ = snapshot.projected` with
  `if snapshot.projected != nil`.
- Nit: comment why SlotSelectionView excludes .delta / .projectedBG.
- Remove CLAUDE.md.
@bjorkert bjorkert mentioned this pull request Apr 9, 2026
* Watch PR #594 review fixes

- Guard LoopFollowDebugCorner descriptor, template, and placeholder
  branches with #if DEBUG so the diagnostic complication does not ship
  to end users.
- Remove unused LAAppGroupSettings.setWatchSlots / watchSlots (the
  fixed-4-slot variant superseded by watchSelectedSlots).
- Move WatchConnectivityManager.swift from the repo root into
  LoopFollow/LiveActivity/ next to the rest of the snapshot pipeline
  and drop the duplicate file header.
- Serialize WatchConnectivityManager.lastWatchAckTimestamp access
  through a dedicated state queue. send(snapshot:) may run on any
  thread and WCSession delegate callbacks arrive on a background
  queue, so the bare TimeInterval was racy.
- Assert main-thread access for WatchSessionReceiver.cacheComplication
  and document the invariant on cachedComplications.
- Drop the no-op JSONEncoder/JSONDecoder iso8601 date strategies —
  GlucoseSnapshot has a custom encoder/decoder that writes/reads
  updatedAt as a Double, so the strategy was never consulted.
- Nit: replace `if let _ = snapshot.projected` with
  `if snapshot.projected != nil`.
- Nit: comment why SlotSelectionView excludes .delta / .projectedBG.
- Remove CLAUDE.md.

* Remove main-thread precondition from cacheComplication

cacheComplication is called from 3 sites: getCurrentTimelineEntry,
getTimelineEndDate (ClockKit callbacks — not documented as strictly
main-thread), and reloadComplicationsOnMainThread. The precondition
could crash if ClockKit ever delivers on a background queue.
@bjorkert
bjorkert marked this pull request as draft April 26, 2026 08:15
bjorkert added a commit that referenced this pull request Apr 27, 2026
# Conflicts:
#	LoopFollow/Log/LogManager.swift
#	LoopFollow/ViewControllers/MainViewController.swift
@bjorkert bjorkert mentioned this pull request Oct 19, 2026
@bjorkert

Copy link
Copy Markdown
Member Author

Closing in favor of #785, which combines this work into one Apple Watch app and is now merged into dev.

@bjorkert bjorkert closed this Oct 26, 2026
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