Skip to content

Live Activity: CarPlay/Watch Smart Stack widget + BFU crash fix + BGAppRefreshTask#574

Merged
bjorkert merged 58 commits intoloopandlearn:live-activityfrom
achkars-org:live-activity
Mar 24, 2026
Merged

Live Activity: CarPlay/Watch Smart Stack widget + BFU crash fix + BGAppRefreshTask#574
bjorkert merged 58 commits intoloopandlearn:live-activityfrom
achkars-org:live-activity

Conversation

@MtlPhil
Copy link

@MtlPhil MtlPhil commented Mar 23, 2026

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

  • Left column: BG + trend arrow in threshold colour (green / orange / red), delta + unit below
  • Right column: projected BG in white, unit label below

Implementation:

  • Single ActivityConfiguration widget with .supplementalActivityFamilies([.small]) on iOS 18+
  • LockScreenFamilyAdaptiveView routes on @Environment(\.activityFamily).smallSmallFamilyView, otherwise → LockScreenLiveActivityView
  • Pre-iOS 18 falls back to the lock-screen view (no CarPlay/Watch routing available)

BFU (Before-First-Unlock) crash fix

iOS can wake the app via BGAppRefreshTask immediately after reboot, before the user has unlocked the device. At that point UserDefaults is still encrypted and all StorageValue reads return defaults. Running migrations in that state would silently wipe persisted settings.

  • runMigrationsIfNeeded() now guards with UIApplication.shared.isProtectedDataAvailable and defers to the next foreground if the device is locked
  • Storage.needsBFUReload flag triggers a full reloadAll() pass on the first foreground after a BFU background launch, restoring all cached values from the now-unlocked UserDefaults
  • migrateStep7() cancels legacy hardcoded notification identifiers left behind by prior installs

BGAppRefreshTask for silent audio recovery

LoopFollow keeps itself alive with a silent AVAudioSession. Phone calls, AirPods disconnects, and system pressure can kill it silently.

BackgroundRefreshManager registers a BGAppRefreshTask (<bundleID>.audiorefresh) so iOS wakes the app periodically, checks player.isPlaying, and restarts audio automatically — no user action needed. Registration happens before the end of application(_:didFinishLaunchingWithOptions:) to satisfy the BGTaskScheduler requirement.

Existing recovery layers (unchanged):

  • BackgroundTaskAudioAVAudioSession interruption observer restarts audio on .ended (fixed: no longer bails on missing shouldResume flag)
  • BackgroundAlertManager — watchdog notifications at 6/12/18 min if the app goes silent

Merged PR #572

Merged bjorkert's PR #572 (multi-instance Live Activity support).

Other fixes

  • LA refresh debounce increased 5 s → 20 s to coalesce rapid successive pushes
  • All hardcoded "loopfollow://" URL schemes replaced with AppGroupID.urlScheme (dynamic, multi-instance safe)
  • Notification identifiers in BackgroundAlertManager and LiveActivityManager scoped to bundle ID
  • BGAppRefreshTask identifier scoped to bundle ID
  • Fixed duplicate AVAudioSession interruption observer registration
  • Fixed double setTaskCompleted race and renewal deadline write ordering
  • migrateStep1 core fields guarded against BFU empty reads

MtlPhil and others added 30 commits March 19, 2026 20:39
  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>
- 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>
MtlPhil and others added 28 commits March 22, 2026 19:40
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>
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
@MtlPhil MtlPhil changed the base branch from dev to live-activity March 24, 2026 13:02
@bjorkert bjorkert merged commit 84e1736 into loopandlearn:live-activity Mar 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants