Skip to content

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

Open
bjorkert wants to merge 96 commits intodevfrom
apple-watch
Open

Apple Watch app: complications, data grid, background delivery#594
bjorkert wants to merge 96 commits intodevfrom
apple-watch

Conversation

@bjorkert
Copy link
Copy Markdown
Contributor

@bjorkert bjorkert commented Apr 9, 2026

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 27 commits March 17, 2026 17:21
The app's minimum deployment target is iOS 16.6, making all iOS 16.1
availability checks redundant. Removed @available(iOS 16.1, *) annotations
from all types and the if #available(iOS 16.1, *) wrapper in the bundle.

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously handleForeground() only restarted the LA when laRenewalFailed=true,
but the renewal overlay also appears as a warning before renewal is attempted
(while laRenewalFailed is still false). Users who foregrounded during the
warning window saw the overlay persist with no restart occurring.

Now triggers a restart whenever the overlay is showing (within the warning
window before the deadline) OR renewal previously failed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When AVAudioSession.setActive() fails (e.g. another app holds the session
exclusively), the app loses its background keep-alive with no recovery path.
Two bugs fixed and recovery logic added:

1. interruptedAudio handler was calling playAudio() on interruption *began*
   (intValue == 1) instead of *ended* — corrected to restart on .ended only.

2. playAudio() catch block now retries up to 3 times (2s apart). After all
   retries are exhausted it posts a BackgroundAudioFailed notification.

3. LiveActivityManager observes BackgroundAudioFailed and immediately sets
   laRenewBy to now (making showRenewalOverlay = true) then pushes a refresh
   so the lock screen overlay tells the user to foreground the app.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… layout fixes, cleanup (#554)

* 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>

* feat: laEnabled toggle, forceRestart(), and RestartLiveActivityIntent

- 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>

* Added RestartLiveActivityIntent to project

* fix: resolve two build errors in LiveActivityManager and RestartLiveActivityIntent

- 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>

* fix: guard continueInForeground() behind iOS 26 availability check

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>

* fix: use startFromCurrentState in handleDidBecomeActive instead of forceRestart

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>

* feat: LA foreground tab navigation, button feedback, and toggle sync

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>

* fix: flush LA update on willResignActive to ensure lock screen shows 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>

* feat: redesign Dynamic Island compact and expanded views

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>

* fix: match Proj text style to delta; add trailing padding to IOB/COB

- 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>

* feat: separate Live Activity and APN settings into distinct menus

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>

* Added Live Activity menu

* chore: add LiveActivitySettingsView to Xcode project

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

* fix: LA tap navigation, manual dismissal prevention, and toggle start

- widgetURL on lock screen LA → opens app via loopfollow://la-tap
- Link views in expanded DI regions → same URL on tap
- AppDelegate.application(_:open:) handles loopfollow://la-tap and posts
  .liveActivityDidForeground so MainViewController navigates to Snoozer
  (if alarm active) or Home tab — fires only on LA/DI taps, not every foreground
- Notification.Name.liveActivityDidForeground moved outside #if macCatalyst
  so AppDelegate can reference it unconditionally
- handleDidBecomeActive no longer posts .liveActivityDidForeground (was
  firing on every app foreground, not just LA taps)
- attachStateObserver: .dismissed state (user swipe) sets laEnabled = false
  so the LA does not auto-restart when the app foregrounds
- LiveActivitySettingsView: toggling ON calls startFromCurrentState()

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

* fix: end Live Activity on app force-quit

Adds endOnTerminate() to LiveActivityManager — blocks up to 3 s using
Task.detached + DispatchSemaphore so the async activity.end() completes
before the process exits. Called from applicationWillTerminate so the
LA clears from the lock screen immediately on force-quit.

laEnabled is preserved so the LA restarts correctly on next launch.

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

* fix: use dismissedByUser flag instead of disabling laEnabled on manual dismiss

- Manual LA swipe-away sets dismissedByUser (in-memory) instead of
  laEnabled = false, preserving the user's preference
- startFromCurrentState() guards on !dismissedByUser so the LA does not
  auto-restart on foreground, BG refresh, or handleDidBecomeActive
- forceRestart() clears dismissedByUser before starting, so the Restart
  button and App Intent both work as the explicit re-enable mechanism
- Toggle ON in settings calls forceRestart() (clears flag + starts) instead
  of startFromCurrentState() which would have been blocked by the flag
- dismissedByUser resets to false on app relaunch (in-memory only), so
  a kill + relaunch starts the LA fresh as expected

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

* fix: dismiss modal (Settings sheet) before tab switch on LA tap

navigateOnLAForeground now checks tabBarController.presentedViewController
and dismisses it before switching to the target tab, matching the existing
pattern used elsewhere in MainViewController for the same scenario.

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

* fix: LA tap navigation timing and LA reappear-after-dismiss

Navigation fix:
- loopfollow://la-tap URL can arrive before applicationDidBecomeActive
  while UIKit is still restoring the view hierarchy from background.
  At that moment tabBarController.presentedViewController is nil so the
  Settings modal is never dismissed and the navigation notification fires
  too early.
- Now stores a pendingLATapNavigation flag if app is not yet .active,
  and fires the notification in applicationDidBecomeActive when the full
  view hierarchy (including presented modals) is restored.

LA reappear fix:
- performRefresh() calls startIfNeeded() directly, bypassing the
  dismissedByUser guard that only exists in startFromCurrentState().
  On every BG refresh with laEnabled=true and current==nil (post-dismiss),
  it would recreate the LA.
- Added !dismissedByUser guard to refreshFromCurrentState() so the entire
  refresh pipeline is skipped while dismissed, matching the existing guard
  in startFromCurrentState().

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

* fix: handle loopfollow://la-tap URL in SceneDelegate, not AppDelegate

With scene-based lifecycle (iOS 13+), widget/LA tap URLs are delivered to
scene(_:openURLContexts:) on UIWindowSceneDelegate — AppDelegate.application
(_:open:url:options:) is never called. Our previous handler was dead code.

SceneDelegate now implements scene(_:openURLContexts:) which posts
.liveActivityDidForeground on the next run loop (via DispatchQueue.main.async)
to let the view hierarchy fully settle before navigateOnLAForeground() runs.
Also handles the edge case where the URL arrives before sceneDidBecomeActive
by storing a pendingLATapNavigation flag.

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

* feat: configurable LA grid slots + full InfoType snapshot coverage

Lock screen layout:
- Left column: glucose + trend arrow, delta below (replaces "Last Update:")
- Right grid: 4 configurable slots read from LAAppGroupSettings (defaults: IOB/COB/Proj/empty)
- Footer: centered HH:MM update time at reduced opacity

Slot configuration:
- LiveActivitySlotOption enum with all 22 InfoType-aligned cases
- LAAppGroupSettings.setSlots() / slots() persisted to App Group UserDefaults
- Uniqueness enforced: selecting an option clears it from any other slot
- Settings UI: "Grid slots" section with 4 pickers in LiveActivitySettingsView
- Changes take effect immediately (refreshFromCurrentState called on save)

GlucoseSnapshot extended with 19 new fields covering all InfoType items:
override, recBolus, battery, pumpBattery, basalRate, pumpReservoirU,
autosens, tdd, targetLowMgdl/High, isfMgdlPerU, carbRatio, carbsToday,
profileName, sageInsertTime, cageInsertTime, iageInsertTime, minBgMgdl/Max

Storage.swift: 13 new UserDefaults-backed fields for the above metrics

Controllers updated to write new Storage fields on each data fetch:
Basals, DeviceStatus, DeviceStatusLoop, DeviceStatusOpenAPS,
Carbs, Profile, IAge

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

* fix: label delta and footer on lock screen LA card

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

* docs: add PR description for configurable LA grid slots

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

* Update PR_configurable_slots.md

* chore: remove PR notes from tracking, keep docs/LiveActivity.md only

- Untrack docs/PR_configurable_slots.md (local-only reference doc)
- Add docs/PR_configurable_slots.md and docs/LiveActivityTestPlan.md to .gitignore

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

* fix: include all extended InfoType fields in APNs push payload

buildPayload() was only serializing the base fields (glucose, delta, trend,
updatedAt, unit, iob, cob, projected). All extended fields added with the
configurable grid slots (battery, sageInsertTime, pumpBattery, basalRate,
autosens, tdd, targets, isf, carbRatio, carbsToday, profileName, CAGE, IAGE,
minBg, maxBg, override, recBolus) were missing from the APNs payload, causing
the extension to decode them as nil/0 and display '--' on every push-driven
refresh.

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

* feat: add small family view for CarPlay Dashboard and Watch Smart Stack

Registers the Live Activity for the small activity family via
.supplementalActivityFamilies([.small]), enabling automatic display on:
- CarPlay Dashboard (iOS 26+)
- Apple Watch Smart Stack (watchOS 11+)

The small view is hardcoded to the essentials appropriate for a driving
context: glucose value, trend arrow, delta, and time since last reading.
Background tint matches the existing threshold-based color logic.

The lock screen layout (full grid with configurable slots) is unchanged.

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

* fix: guard CarPlay/Watch small family behind iOS 18 availability; increase renewal overlay opacity

- supplementalActivityFamilies and activityFamily require iOS 18.0+;
  restructured into two Widget structs sharing a makeDynamicIsland() helper:
  - LoopFollowLiveActivityWidget (iOS 16.1+): lock screen + Dynamic Island
  - LoopFollowLiveActivityWidgetWithCarPlay (iOS 18.0+): adds .supplementalActivityFamilies([.small])
- Bundle uses if #available(iOS 18.0, *) to select the right variant
- Podfile: set minimum deployment target to 16.6 for all pods
- Increase 'Tap to update' renewal overlay opacity from 60% to 90%

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

* fix: move if #available into Widget.body to avoid WidgetBundleBuilder limitation

@WidgetBundleBuilder does not support if #available { } else { }, but
@WidgetConfigurationBuilder (used by Widget.body) does. Collapsed back to
a single LoopFollowLiveActivityWidget struct with the iOS 18 conditional
inside body — iOS 18+ branch adds .supplementalActivityFamilies([.small])
for CarPlay and Watch Smart Stack; else branch uses the plain lock screen view.

Bundle reverts to the original single if #available(iOS 16.1, *) pattern.

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

* fix: use two separate single-branch if #available in bundle for CarPlay support

@WidgetConfigurationBuilder's buildEither requires both branches to return the
same concrete type, making if/else with supplementalActivityFamilies impossible
(it wraps to a different opaque type). @WidgetBundleBuilder does not support
if #available { } else { } at all.

Solution: two separate single-branch if #available blocks in the bundle —
the pattern that @WidgetBundleBuilder already supported in the original code:
- LoopFollowLiveActivityWidget (iOS 16.1+): primary, lock screen + Dynamic Island
- LoopFollowLiveActivityWidgetWithCarPlay (iOS 18.0+): supplemental, adds
  .supplementalActivityFamilies([.small]) for CarPlay Dashboard + Watch Smart Stack

ActivityKit uses the supplemental widget for small-family surfaces and the
primary widget for lock screen / Dynamic Island, keeping iOS 16.6+ support intact.

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

* fix: restore two-widget bundle; guard supplementalActivityFamilies and activityFamily behind iOS 18

Upstream's single-widget approach placed iOS 18+ APIs (supplementalActivityFamilies,
activityFamily, ActivityFamily) behind @available(iOS 16.1, *), which fails to
compile at the 16.6 deployment target. Restoring the two-widget pattern:

- LoopFollowLiveActivityWidget (@available iOS 16.1): lock screen + DI, uses
  LockScreenLiveActivityView, no supplementalActivityFamilies
- LoopFollowLiveActivityWidgetWithCarPlay (@available iOS 18.0): adds CarPlay
  Dashboard + Watch Smart Stack via supplementalActivityFamilies([.small]),
  uses LockScreenFamilyAdaptiveView (also @available iOS 18.0)
- SmallFamilyView availability corrected to @available(iOS 18.0, *)
- Bundle registers both via separate if #available blocks

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

* fix: extension version inherits from parent; remove spurious await in slot config

- LoopFollowLAExtension MARKETING_VERSION now uses "$(MARKETING_VERSION)" to
  match the parent app version automatically, resolving CFBundleShortVersionString
  mismatch warning
- Remove unnecessary Task/await wrapping of refreshFromCurrentState in
  LiveActivitySettingsView — the method is not async

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

* fix: prevent glucose + trend arrow clipping on wide mmol/L values

At 46pt, a 4-character mmol/L value ("10.5") plus "↑↑" overflowed the 168pt
left column, truncating the glucose reading. Fix: reduce trend arrow to 32pt
and add minimumScaleFactor(0.7) + lineLimit(1) to the glucose text so values
above 10 mmol/L render correctly.

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

* chore: remove redundant @available(iOS 16.1) guards

The app's minimum deployment target is iOS 16.6, making all iOS 16.1
availability checks redundant. Removed @available(iOS 16.1, *) annotations
from all types and the if #available(iOS 16.1, *) wrapper in the bundle.

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

* Fix Live Activity glucose overflow with flexible layout and tighter grid spacing

* Fix Live Activity glucose overflow with flexible layout and tighter grid spacing

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

* fix: restart LA on foreground when renewal overlay is showing

Previously handleForeground() only restarted the LA when laRenewalFailed=true,
but the renewal overlay also appears as a warning before renewal is attempted
(while laRenewalFailed is still false). Users who foregrounded during the
warning window saw the overlay persist with no restart occurring.

Now triggers a restart whenever the overlay is showing (within the warning
window before the deadline) OR renewal previously failed.

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

* fix: recover from audio session failure and alert user via LA overlay

When AVAudioSession.setActive() fails (e.g. another app holds the session
exclusively), the app loses its background keep-alive with no recovery path.
Two bugs fixed and recovery logic added:

1. interruptedAudio handler was calling playAudio() on interruption *began*
   (intValue == 1) instead of *ended* — corrected to restart on .ended only.

2. playAudio() catch block now retries up to 3 times (2s apart). After all
   retries are exhausted it posts a BackgroundAudioFailed notification.

3. LiveActivityManager observes BackgroundAudioFailed and immediately sets
   laRenewBy to now (making showRenewalOverlay = true) then pushes a refresh
   so the lock screen overlay tells the user to foreground the app.

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

* Update BackgroundTaskAudio.swift

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
# 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.
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