Skip to content

Live activity#537

Open
bjorkert wants to merge 100 commits intodevfrom
live-activity
Open

Live activity#537
bjorkert wants to merge 100 commits intodevfrom
live-activity

Conversation

@bjorkert
Copy link
Copy Markdown
Contributor

@bjorkert bjorkert commented Mar 12, 2026

Summary

Adds Live Activity support to LoopFollow — a persistent lock screen and Dynamic Island widget that displays real-time glucose data, trend arrows, and key diabetes metrics without opening the app.

New Features

  • Lock screen Live Activity showing current glucose, trend arrow, delta, and a configurable grid of additional data (IOB, COB, loop status, pump/sensor age, etc.)
  • Dynamic Island with redesigned compact view (BG + delta) and expanded view (large BG, trend, delta, projected glucose)
  • Configurable grid slots — choose which InfoType values appear in each grid position via Live Activity settings
  • CarPlay Dashboard & Apple Watch Smart Stack support via .supplementalActivityFamilies (iOS 18+)
  • SmallFamilyView with a configurable right slot for the small activity family
  • APNs self-push updates — the app sends push notifications to itself to keep the Live Activity updated in the background
  • Auto-renewal — works around the iOS 8-hour Live Activity limit by automatically restarting the activity, with a user-facing renewal overlay when the app needs to come to the foreground
  • Audio session recovery — detects and recovers from AVAudioSession failures, alerting the user via the Live Activity overlay
  • Deep-link tap navigation — tapping the Live Activity (loopfollow://la-tap) navigates to the relevant tab in the app
  • Siri Intent (RestartLiveActivityIntent) for restarting the Live Activity from Shortcuts or the system
  • Separate settings screens — new Live Activity settings screen for layout/slot configuration, APN credentials remain in their own screen
  • Background refresh via BGAppRefreshTask for improved background update reliability

Infrastructure Changes

  • New widget extension target (LoopFollowLAExtension) with its own entitlements and Info.plist
  • App Group shared storage (GlucoseSnapshotStore) for passing glucose snapshots to the extension
  • SceneDelegate added for scene-based URL handling (scene(_:openURLContexts:))
  • Mac Catalyst build guards (#if !targetEnvironment(macCatalyst)) to prevent compilation errors on macOS
  • CryptoKit migration — replaced SwiftJWT/CryptoSwift with native CryptoKit for JWT signing in both JWTManager and SecureMessenger
  • JWTManager thread-safety — added NSLock to prevent concurrent cache corruption when Live Activity pushes and remote commands race on different threads
  • Live Activity termination handling (endOnTerminate()) that blocks up to 3s on app exit
  • Fastlane updated for the new extension target

Changed Files

Key new files: LiveActivityManager, APNSClient, GlucoseSnapshot, GlucoseSnapshotBuilder, GlucoseSnapshotStore, LAAppGroupSettings, RestartLiveActivityIntent, LiveActivitySettingsView, LoopFollowLiveActivity (widget views)

Modified: AppDelegate, SceneDelegate, BackgroundTaskAudio, BGData, DeviceStatus*, Storage, JWTManager, SecureMessenger, SettingsMenuView, APNSettingsView, RemoteSettingsView, Podfile, Xcode project

MtlPhil and others added 6 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
@bjorkert
Copy link
Copy Markdown
Contributor Author

Summary of changes in 524b3bb — Replace SwiftJWT with CryptoKit and separate APNs credentials

Dependency removal:

  • Removes the SwiftJWT and swift-crypto SPM packages entirely. JWT signing now uses Apple's built-in CryptoKit (P256/ES256), eliminating two third-party dependencies.

JWT consolidation:

  • Deletes APNSJWTGenerator.swift. All JWT generation logic is now in JWTManager.swift, which uses an in-memory cache (keyed by keyId:teamId, 55-min TTL) instead of persisting JWT tokens to UserDefaults.

APNs credential separation:

  • Previously there was one set of APNs credentials (apnsKey/keyId) shared between remote commands and Live Activity. Now there are two distinct sets:
    • lfApnsKey/lfKeyId — for LoopFollow's own Live Activity pushes
    • remoteApnsKey/remoteKeyId — for remote commands to Loop/Trio
  • Removes the old "Return Notification Settings" section (which appeared when team IDs differed). Instead, the remote credential fields only show when team IDs differ, and LoopFollow credentials are always entered in the new APNSettingsView.

New APNSettingsView:

  • Adds a dedicated settings screen under the Settings menu for entering LoopFollow's own APNs Key ID and Key.

Migration (migrateStep5):

  • Migrates old credential storage keys (apnsKey, keyId, returnApnsKey, returnKeyId) to the new separated keys, handling same-team vs different-team scenarios. Cleans up the legacy keys afterward.

Build/CI updates:

  • Removes the Inject APNs Key Content CI step — credentials are no longer baked into the build via xcconfig/Info.plist. They're entered by the user at runtime.
  • Removes APNSKeyContent, APNSKeyID, APNSTeamID from Info.plist.
  • Updates CI to macos-26 / Xcode_26.2.

APNSClient refactored:

  • Uses JWTManager.shared instead of APNSJWTGenerator. Reads credentials from Storage.shared. Selects sandbox vs production APNs host based on BuildDetails.isTestFlightBuild().

Code style:

  • Standardized file headers, alphabetized imports, added trailing commas, cleaned up whitespace throughout the LiveActivity and Remote modules.

@bjorkert
Copy link
Copy Markdown
Contributor Author

Consolidated duplicated code in the Live Activity branch:

  • All glucose values now stored in mg/dL — removed the round-trip conversion (mg/dL → mmol/L at snapshot creation, then mmol/L → mg/dL for threshold comparison). Conversion to mmol/L now happens only at display time in LAFormat.
  • Single conversion constant — deleted GlucoseUnitConversion.swift (used 18.0182) and unified on GlucoseConversion.swift (18.01559), with mgDlToMmolL as a computed reciprocal.
  • Deduplicated unit detectionPreferredGlucoseUnit.hkUnit() now delegates to Localizer.getPreferredUnit() instead of duplicating it.
  • Added FortyFiveUp/FortyFiveDown trends — new upSlight/downSlight cases rendering as ↗/↘︎ instead of collapsing to ↑/↓.
  • Fixed locale bugLAFormat now uses NumberFormatter with Locale.current so decimal separators match the main app (e.g. "5,6" for Swedish locale).
  • Deleted dead code — removed LAThresholdSync.swift (never called).
  • Fixed APNs payload — added missing isNotLooping field so push-based updates correctly show the "Not Looping" overlay.

MtlPhil and others added 8 commits March 12, 2026 21:40
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
@bjorkert
Copy link
Copy Markdown
Contributor Author

Mac Catalyst build fix

Problem

ActivityKit is iOS-only — its module exists in the Mac Catalyst SDK but every API is marked @available(macCatalyst, unavailable). #if canImport(ActivityKit) evaluates to true on Mac Catalyst and if #available(iOS 16.1, *) runtime checks also pass, so neither helps. Additionally, the widget extension (LoopFollowLAExtensionExtension.appex) was being embedded unconditionally, causing an "embedded content built for the iOS platform" error.

Changes

Compile-time guards (#if !targetEnvironment(macCatalyst)):

  • Wrapped entire file contents of GlucoseLiveActivityAttributes.swift, LiveActivityManager.swift, and APNSClient.swift
  • Wrapped call sites in MainViewController.swift, BGData.swift, and DeviceStatus.swift

Removed unnecessary @available(iOS 16.1, *) checks — deployment target is already 16.6, so these were dead code.

Excluded widget extension from Mac Catalyst builds — added platformFilter = ios to both the embed build phase and target dependency for LoopFollowLAExtensionExtension in the Xcode project.

No logic changes — only build configuration and compile-time guards.

MtlPhil and others added 4 commits March 13, 2026 09:54
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>
@MtlPhil
Copy link
Copy Markdown

MtlPhil commented Mar 13, 2026

Live Activity auto-renewal (8-hour limit workaround)

PR Link

Apple enforces an 8-hour maximum lifetime on Live Activities in the Dynamic Island.

Approach

On every glucose refresh (~5 min), LiveActivityManager checks whether the current LA has passed its renewal deadline. If so, it:

  1. Requests a new LA first (so the user keeps live data if the request fails)
  2. Ends the old LA with .immediate dismissal (removes the lock screen card instantly)
  3. Records a new 7.5-hour deadline in Storage.shared.laRenewBy

If the request fails, laRenewalFailed is set and the existing LA is kept. On next foreground entry, the stale LA is ended and a fresh one is started.

Renewal warning overlay

Starting 20 minutes before the renewal deadline, a gray "Tap to update" overlay is rendered over the lock screen view and all expanded Dynamic Island regions. This prompts the user to bring the app to the foreground, where the LA can be renewed.

The overlay flag (showRenewalOverlay) is computed in GlucoseSnapshotBuilder and included in every APNs push payload so it is delivered reliably in the background.

Robustness

  • New-first renewal order. The new LA is requested before the old one is ended. If Activity.request() throws, the old LA remains intact and laRenewalFailed is set.
  • staleDate tied to renewal deadline. ActivityContent.staleDate is set to laRenewBy so ActivityKit marks the content stale at exactly the renewal moment.
  • Foreground retry awaits end. On foreground retry after a failed renewal, laRenewBy and laRenewalFailed are cleared synchronously, then activity.end() is awaited before startFromCurrentState() is called. This ensures Activity.activities is empty when startIfNeeded() runs, forcing a fresh-request path that writes a new deadline, preventing a stale overlay from appearing on the restarted LA.
  • showRenewalOverlay in APNs payload. The field is included in every push so background deliveries correctly activate and clear the overlay.
  • Orphaned activity cleanup. On app launch, endOrphanedActivities() ends any untracked Live Activities left behind by a previous crash.

Files changed

File: Storage/Storage.swift
Change: Added laRenewBy: TimeInterval and laRenewalFailed: Bool
────────────────────────────────────────
File: LiveActivity/GlucoseSnapshot.swift
Change: Added showRenewalOverlay: Bool field
────────────────────────────────────────
File: LiveActivity/GlucoseSnapshotBuilder.swift
Change: Computes showRenewalOverlay from laRenewBy
────────────────────────────────────────
File: LiveActivity/APNSClient.swift
Change: Includes showRenewalOverlay in APNs push payload
────────────────────────────────────────
File: LiveActivity/LiveActivityManager.swift
Change: Renewal logic: renewIfNeeded(), endOrphanedActivities(), foreground retry, renewalThreshold/renewalWarning constants
────────────────────────────────────────
File: LoopFollowLAExtension/LoopFollowLiveActivity.swift
Change: "Tap to update" overlay on lock screen and Dynamic Island expanded regions

Testing checklist

  • "Tap to update" overlay appears 20 minutes before the renewal deadline
  • LA is replaced cleanly at the renewal deadline (no duplicate cards, no stale overlay on new LA)
  • Foreground retry after a failed renewal starts a fresh LA with showRenewalOverlay = false
  • Orphaned activities from a previous crash are cleaned up on launch

MtlPhil and others added 5 commits March 13, 2026 19:32
* 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>
MtlPhil and others added 15 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.
@bjorkert
Copy link
Copy Markdown
Contributor Author

Fix: TooManyProviderTokenUpdates when sending rapid remote commands

The JWTManager cache dictionary was not thread-safe. When Live Activity pushes (async) and remote commands (URLSession callbacks) ran concurrently, the unprotected dictionary could get corrupted — causing cache misses that generated a new JWT for every request instead of reusing the cached one. Apple rate-limits this as TooManyProviderTokenUpdates (429).

Fix: added NSLock around all cache access. Also added JWT cache invalidation on 403 in PushNotificationManager and LoopAPNSService (was already done in APNSClient), and added logging for JWT generation/invalidation to make future issues easier to diagnose.

- Make SmallFamilyView right slot configurable via Live Activity settings
- Add Unit.displayName to GlucoseSnapshot for consistent unit labelling
- Use ViewThatFits for adaptive CarPlay vs Watch Smart Stack layout
- Fix APNs push token lost after renewal-overlay foreground restart
- Fix Not Looping overlay not showing when app is backgrounded
- Rename Live Activity settings section headers
@bjorkert bjorkert marked this pull request as ready for review March 25, 2026 20:12
bjorkert and others added 4 commits March 25, 2026 22:08
startIfNeeded() unconditionally reused any existing activity, which
meant that on cold start (app killed while stale overlay was showing)
willEnterForeground is never sent, handleForeground never runs, and
viewDidAppear → startFromCurrentState → startIfNeeded just rebinds to
the stale activity — leaving the overlay visible.

Fix: before reusing an existing activity in startIfNeeded(), check
whether its staleDate has passed or the renewal window is open. If so,
end it (awaited) and call startIfNeeded() again so a fresh activity
with a new 7.5h deadline is started.

Also add cancelRenewalFailedNotification() to handleForeground() so
the "Live Activity Expiring" system notification is dismissed whenever
the foreground restart path fires, not only via forceRestart().

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix LA not refreshing on foreground after stale overlay

startIfNeeded() unconditionally reused any existing activity, which
meant that on cold start (app killed while stale overlay was showing)
willEnterForeground is never sent, handleForeground never runs, and
viewDidAppear → startFromCurrentState → startIfNeeded just rebinds to
the stale activity — leaving the overlay visible.

Fix: before reusing an existing activity in startIfNeeded(), check
whether its staleDate has passed or the renewal window is open. If so,
end it (awaited) and call startIfNeeded() again so a fresh activity
with a new 7.5h deadline is started.

Also add cancelRenewalFailedNotification() to handleForeground() so
the "Live Activity Expiring" system notification is dismissed whenever
the foreground restart path fires, not only via forceRestart().

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

* Fix stale LA dismissed by iOS incorrectly blocking auto-restart

When iOS dismisses a Live Activity because its staleDate passed (background
stale overlay case), laRenewalFailed is false, so the state observer's else
branch fired and set dismissedByUser=true — permanently blocking all
auto-restart paths (startFromCurrentState has guard !dismissedByUser).

Fix 1: attachStateObserver now checks staleDatePassed alongside laRenewalFailed;
both are iOS-initiated dismissals that should allow auto-restart.

Fix 2: handleForeground() Task resets dismissedByUser=false before calling
startFromCurrentState(), guarding against the race where the state observer
fires .dismissed during our own end() call before its Task cancellation takes
effect.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* Add separate Watch and CarPlay toggles to Live Activity settings

Use GeometryReader in LockScreenFamilyAdaptiveView to distinguish
Watch Smart Stack (height ≤ 75 pt) from CarPlay Dashboard (height > 75 pt)
at render time — both surfaces share ActivityFamily.small with no API
to tell them apart, so canvas height is the only runtime signal.

Adds la.watchEnabled and la.carPlayEnabled App Group keys (default true).
The Right Slot picker hides when both are disabled.

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

* Fix Watch/CarPlay toggle detection: use width, not height; use Color.black

Two bugs with the height-based threshold:
1. System padding pushes Watch Smart Stack canvas above 75 pt, causing both
   Watch and CarPlay to be classified as CarPlay (blank on both when
   CarPlay toggle is off).
2. Color.clear is transparent — cached Watch renders show through it,
   leaving stale data visible after the toggle is turned off.

Fix: switch to width-based detection. Watch Ultra 2 (widest model) is
~183 pt; CarPlay is always at least ~250 pt. A 210 pt threshold gives a
~14% buffer above the max Watch width. Replace Color.clear with Color.black
so old frames are fully covered when the widget is disabled.

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

* Fix stale overlay tap: endingForRestart flag prevents dismissedByUser race

Root cause: handleForeground() clears laRenewalFailed=false synchronously
before calling activity.end(). When the state observer fires .dismissed,
renewalFailed is already false and staleDatePassed may also be false, so
it falls into the user-swipe branch and sets dismissedByUser=true.

Fix 4 (dismissedByUser=false in the Task) was meant to override this, but
the state observer's MainActor write can be queued *after* the Task's reset
write, winning the race and leaving dismissedByUser=true. The result: LA
stops after tapping the overlay and never restarts.

Add endingForRestart flag set synchronously (on the MainActor) before end()
is called. The state observer checks it first — before renewalFailed or
staleDatePassed — so any .dismissed delivery triggered by our own end() call
is never misclassified as a user swipe, regardless of MainActor queue order.

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

* Use forceRestart on Watch/CarPlay toggle instead of refreshFromCurrentState

The LA must be ended and recreated for Watch/CarPlay content changes to
take effect immediately. refreshFromCurrentState only sends a content
update to the existing activity; forceRestart ends the activity and
starts a fresh one, so the widget extension re-evaluates and the
black/clear tile appears (or disappears) without APNs latency.

Note: true per-surface dismissal (tile fully gone from Watch OR CarPlay
while the other remains) requires splitting into two LAs and is a
future architectural change.

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

* Remove Watch/CarPlay toggles; redesign expanded Dynamic Island

Watch/CarPlay toggles removed: disfavoredLocations API requires iOS 26
and GeometryReader-based detection is unreliable. Reverts to always
showing SmallFamilyView on .small activity family.

Expanded Dynamic Island redesigned to match SmallFamilyView layout:
- Leading: glucose + trend arrow (colour-keyed, firstTextBaseline), delta below
- Trailing: configurable slot (same smallWidgetSlot setting as CarPlay/Watch)
  with label + value, replaces hardcoded IOB/COB
- Bottom: unchanged — "Updated at" or "Not Looping" banner

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
@bjorkert bjorkert self-assigned this Mar 29, 2026
bjorkert and others added 3 commits March 29, 2026 14:25
Three clearly separated .dismissed sources:
(a) endingForRestart — our own end() during planned restart, ignore
(b) iOS system force-dismiss — renewalFailed OR pastDeadline (now >= laRenewBy)
    → auto-restart on next foreground, laRenewBy preserved
(c) User decision — explicit swipe
    → dismissedByUser=true, laRenewBy=0 (renewal intent cancelled)

Remove staleDatePassed: staleDate expiry fires .ended not .dismissed.

Preserve laRenewBy on .ended and system .dismissed so handleForeground()
detects the renewal window and restarts on next foreground. Only the
user-swipe path clears laRenewBy, preventing handleForeground() from
re-entering the renewal path after the user explicitly killed the LA.

Fix handleForeground() nil-current path: reaching it means iOS ended the
LA while the renewal window was open (laRenewBy still set). A user-swipe
would have cleared laRenewBy to 0, so overlayIsShowing would be false
and this branch would never be reached — startFromCurrentState() is safe.

Set renewalWarning to 30 minutes (overlay appears 30 min before 7.5h deadline).

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
# Conflicts:
#	LoopFollow.xcodeproj/project.pbxproj
#	LoopFollow/ViewControllers/MainViewController.swift
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.

Live Activities/Dynamic Island for LF

2 participants