Live Activity: CarPlay/Watch Smart Stack widget + BFU crash fix + BGAppRefreshTask#574
Merged
bjorkert merged 58 commits intoloopandlearn:live-activityfrom Mar 24, 2026
Merged
Conversation
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>
- 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>
- 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>
Forgotten stub.
- 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>
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>
- 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>
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>
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>
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.
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.
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>
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>
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>
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>
…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>
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>
This reverts commit 7e6b191.
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>
…U 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>
…ields 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>
…ageValues 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>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…reground 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>
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>
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>
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>
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>
- 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>
- Scope all notification/task identifiers to Bundle.main.bundleIdentifier so multiple LoopFollow instances don't collide (BackgroundAlertManager, BackgroundRefreshManager, LiveActivityManager renewal notification) - Derive URL scheme dynamically via AppGroupID.urlScheme (loopfollow → loopfollow2/3/etc. for additional instances) - Update Info.plist: BGTask identifier and URL scheme use app_suffix var - AppGroupID: extract baseBundleID computed var, add urlScheme computed var - LAAppGroupSettings: add displayName/showDisplayName support for multi-instance LA footer (off by default, no behaviour change for single) - Migration step 7: cancel legacy hardcoded notification identifiers - LoopFollowLiveActivity.swift: apply urlScheme to all Link/widgetURL targets; add optional display name prefix to lock screen footer Conflict on LoopFollowLAExtension/LoopFollowLiveActivity.swift resolved by keeping our single-widget structure (SmallFamilyView / supplemental family) and applying the URL scheme and display name changes from pr-branch. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The LiveActivity.md doc file should not be part of this PR. https://claude.ai/code/session_01WaUhT8PoPNKumX9ZK9jeBy
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
CarPlay Dashboard & Apple Watch Smart Stack widget
The Live Activity now renders a purpose-built compact layout on CarPlay and Apple Watch Smart Stack, replacing the lock-screen view that was previously squeezed into those surfaces.
Layout (matches Loop's CarPlay card style):
Implementation:
ActivityConfigurationwidget with.supplementalActivityFamilies([.small])on iOS 18+LockScreenFamilyAdaptiveViewroutes on@Environment(\.activityFamily)—.small→SmallFamilyView, otherwise →LockScreenLiveActivityViewBFU (Before-First-Unlock) crash fix
iOS can wake the app via
BGAppRefreshTaskimmediately after reboot, before the user has unlocked the device. At that pointUserDefaultsis still encrypted and allStorageValuereads return defaults. Running migrations in that state would silently wipe persisted settings.runMigrationsIfNeeded()now guards withUIApplication.shared.isProtectedDataAvailableand defers to the next foreground if the device is lockedStorage.needsBFUReloadflag triggers a fullreloadAll()pass on the first foreground after a BFU background launch, restoring all cached values from the now-unlockedUserDefaultsmigrateStep7()cancels legacy hardcoded notification identifiers left behind by prior installsBGAppRefreshTask for silent audio recovery
LoopFollow keeps itself alive with a silent
AVAudioSession. Phone calls, AirPods disconnects, and system pressure can kill it silently.BackgroundRefreshManagerregisters aBGAppRefreshTask(<bundleID>.audiorefresh) so iOS wakes the app periodically, checksplayer.isPlaying, and restarts audio automatically — no user action needed. Registration happens before the end ofapplication(_:didFinishLaunchingWithOptions:)to satisfy theBGTaskSchedulerrequirement.Existing recovery layers (unchanged):
BackgroundTaskAudio—AVAudioSessioninterruption observer restarts audio on.ended(fixed: no longer bails on missingshouldResumeflag)BackgroundAlertManager— watchdog notifications at 6/12/18 min if the app goes silentMerged PR #572
Merged bjorkert's PR #572 (multi-instance Live Activity support).
Other fixes
"loopfollow://"URL schemes replaced withAppGroupID.urlScheme(dynamic, multi-instance safe)BackgroundAlertManagerandLiveActivityManagerscoped to bundle IDBGAppRefreshTaskidentifier scoped to bundle IDAVAudioSessioninterruption observer registrationsetTaskCompletedrace and renewal deadline write orderingmigrateStep1core fields guarded against BFU empty reads