Conversation
…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
…e APNs error codes; fix DST timezone
- 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
Summary of changes in 524b3bb — Replace SwiftJWT with CryptoKit and separate APNs credentialsDependency removal:
JWT consolidation:
APNs credential separation:
New APNSettingsView:
Migration (
Build/CI updates:
APNSClient refactored:
Code style:
|
|
Consolidated duplicated code in the Live Activity branch:
|
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
Mac Catalyst build fixProblemActivityKit is iOS-only — its module exists in the Mac Catalyst SDK but every API is marked ChangesCompile-time guards (
Removed unnecessary Excluded widget extension from Mac Catalyst builds — added No logic changes — only build configuration and compile-time guards. |
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>
|
Live Activity auto-renewal (8-hour limit workaround) 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:
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
Files changed File: Storage/Storage.swift Testing checklist
|
* 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>
# 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.
|
Fix: TooManyProviderTokenUpdates when sending rapid remote commands The Fix: added |
- 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
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>
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
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
.supplementalActivityFamilies(iOS 18+)AVAudioSessionfailures, alerting the user via the Live Activity overlayloopfollow://la-tap) navigates to the relevant tab in the appRestartLiveActivityIntent) for restarting the Live Activity from Shortcuts or the systemBGAppRefreshTaskfor improved background update reliabilityInfrastructure Changes
LoopFollowLAExtension) with its own entitlements and Info.plistGlucoseSnapshotStore) for passing glucose snapshots to the extensionSceneDelegateadded for scene-based URL handling (scene(_:openURLContexts:))#if !targetEnvironment(macCatalyst)) to prevent compilation errors on macOSJWTManagerandSecureMessengerNSLockto prevent concurrent cache corruption when Live Activity pushes and remote commands race on different threadsendOnTerminate()) that blocks up to 3s on app exitChanged 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