Skip to content

BGAppRefreshTask audio recovery; LA expiry notification; code quality#570

Merged
bjorkert merged 23 commits intoloopandlearn:live-activityfrom
achkars-org:live-activity
Mar 22, 2026
Merged

BGAppRefreshTask audio recovery; LA expiry notification; code quality#570
bjorkert merged 23 commits intoloopandlearn:live-activityfrom
achkars-org:live-activity

Conversation

@MtlPhil
Copy link

@MtlPhil MtlPhil commented Mar 21, 2026

Summary

BGAppRefreshTask — automatic silent audio recovery

  • BackgroundRefreshManager.swift (new): singleton that registers a BGAppRefreshTask handler (com.loopfollow.audiorefresh). When iOS wakes the app, it checks whether the silent audio player is still alive and restarts it if not, then reschedules itself for 15 minutes later.
  • Info.plist: added com.loopfollow.audiorefresh to BGTaskSchedulerPermittedIdentifiers; added fetch to UIBackgroundModes (required for BGTaskScheduler.submit to succeed).
  • AppDelegate: calls BackgroundRefreshManager.shared.register() before return true in didFinishLaunchingWithOptions.
  • MainViewController: calls BackgroundRefreshManager.shared.scheduleRefresh() after backgroundTask.startBackgroundTask().
  • BackgroundTaskAudio: fixed idempotency — startBackgroundTask() now removes the interruption observer before re-adding it, preventing duplicate callbacks when audio restarts.
  • BGAppRefreshTask double setTaskCompleted race fixed: the expiration handler and the DispatchQueue.main.async block are now guarded by a shared completed flag — whichever runs first wins; the second is a no-op. Apple documents calling setTaskCompleted more than once as a programming error.

This adds a third recovery layer alongside the existing AVAudioSession interruption handler and BackgroundAlertManager watchdog notifications.

Extended data slots — all configurable metrics now populated

StorageCurrentGlucoseStateProvider previously only exposed core glucose, IOB, and COB. It now implements the full CurrentGlucoseStateProviding protocol, sourcing all 20+ extended metrics from Storage.shared and Observable.shared:

Metric Source
Override name Observable.shared.override
Recommended bolus Observable.shared.deviceRecBolus
CGM/uploader battery Observable.shared.deviceBatteryLevel
Pump battery Observable.shared.pumpBatteryLevel
Basal rate Storage.shared.lastBasal
Pump reservoir Storage.shared.lastPumpReservoirU
Autosensitivity Storage.shared.lastAutosens
TDD Storage.shared.lastTdd
BG target low/high Storage.shared.lastTargetLowMgdl/High
ISF Storage.shared.lastIsfMgdlPerU
Carb ratio Storage.shared.lastCarbRatio
Carbs today Storage.shared.lastCarbsToday
Profile name Storage.shared.lastProfileName
SAGE / CAGE / IAGE Storage.shared.sageInsertTime / cageInsertTime / iageInsertTime
Min/max predicted BG Storage.shared.lastMinBgMgdl/MaxBgMgdl
isNotLooping Observable.shared.isNotLooping
showRenewalOverlay computed from laRenewBy vs renewalWarning

This means all 22 configurable Live Activity slots (IOB, COB, basal, pump, target, ISF, CR, SAGE/CAGE/IAGE, etc.) now display live data rather than "—".

Live Activity expiry notification + dismissed-by-user distinction

  • LiveActivityManager: on first renewal failure, schedules a local notification ("Live Activity Expiring — Open LoopFollow to restart"). Notification is cancelled on successful renewal or forceRestart().
  • attachStateObserver: on .dismissed, checks laRenewalFailed to distinguish iOS force-dismissing after the 8-hour limit (auto-restart allowed) from the user manually swiping away (auto-restart blocked until explicit restart via button or App Intent).
  • Renewal deadline write ordering fixed: laRenewBy is now written 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.

GlucoseSnapshot.withRenewalOverlay(_:) helper

Replaces the manual field-by-field copy in renewIfNeeded with a single method call.

startIfNeeded seed snapshot improvement

On LA start, now attempts GlucoseSnapshotBuilder.build() first (all extended fields populated), falls back to the persisted store, then to the zero seed.

Dead code removal

  • SceneDelegate: removed pendingLATapNavigation flag — it was never set to true.
  • LoopFollowLiveActivity: removed commented-out /* let activityID: String */ property.
  • LiveActivitySlotConfig.swift: deleted (already removed upstream; resolved here).

Log category fixes

  • BackgroundRefreshManager: all logs → .taskScheduler
  • AppDelegate + APNSClient: APNs-related logs → .apns

Bug fix

  • DeviceStatusLoop: Int(prediction.last!)Int(round(prediction.last!)) — prevents truncation toward zero on prediction display values.

SwiftFormat pass

Applied SwiftFormat (Swift 6.2) across all changed files.

Test plan

  • Build succeeds for both LoopFollow and LoopFollowLAExtension targets
  • Silent audio keep-alive still works in background
  • BGAppRefreshTask fires and restarts audio when dead (test on device: background app, wait 15+ min)
  • BGAppRefreshTask expiration path completes without crash
  • All configurable LA slots display live data (IOB, COB, basal, pump, SAGE, etc.)
  • LA renewal failure triggers local notification
  • Swiping away LA blocks auto-restart; Restart button re-enables it
  • iOS-expired LA (after 8 h) allows auto-restart on next foreground
  • LA renewal still works at the 7.5-hour mark; deadline is written correctly on success

🤖 Generated with Claude Code

MtlPhil and others added 22 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>
@bjorkert bjorkert self-assigned this Mar 22, 2026
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>
@bjorkert bjorkert merged commit a62595c into loopandlearn:live-activity Mar 22, 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.

2 participants