Skip to content

feat: Live Activity including Lock screen + Dynamic Island, APNs self-push#534

Open
MtlPhil wants to merge 5 commits intoloopandlearn:devfrom
achkars-org:live-activity-pr
Open

feat: Live Activity including Lock screen + Dynamic Island, APNs self-push#534
MtlPhil wants to merge 5 commits intoloopandlearn:devfrom
achkars-org:live-activity-pr

Conversation

@MtlPhil
Copy link

@MtlPhil MtlPhil commented Mar 7, 2026

Adds a lock screen and Dynamic Island Live Activity to LoopFollow displaying
real-time glucose data, IOB, COB and projected blood glucose. Updates are driven by LoopFollow's existing refresh engine via APNs self-push.

See docs/LiveActivity.md for full architecture, setup instructions, and the reasoning behind APNs self-push over direct activity.update().

LiveActivity.md contents:

LoopFollow Live Activity — Architecture & Design Decisions

Author: Philippe Achkar (supported by Claude)
Date: 2026-03-07


What Is the Live Activity?

The Live Activity displays real-time glucose data on the iPhone lock screen and in the Dynamic Island. It shows:

  • Current glucose value (mg/dL or mmol/L)
  • Trend arrow and delta
  • IOB, COB, and projected glucose (when available)
  • Threshold-driven background color (red (low) / green (in-range) / orange (high)) with user-set thresholds
  • A "Not Looping" overlay when Loop has not reported in 15+ minutes

It updates every 5 minutes, driven by LoopFollow's existing refresh engine. No separate data pipeline exists — the Live Activity is a rendering surface only.


Core Principles

1. Single Source of Truth

The Live Activity never fetches data directly from Nightscout or Dexcom. It reads exclusively from LoopFollow's internal storage layer (Storage.shared, Observable.shared). All glucose values, thresholds, IOB, COB, and loop status flow through the same path as the rest of the app.

This means:

  • No duplicated business logic
  • No risk of the Live Activity showing different data than the app
  • The architecture is reusable for Apple Watch and CarPlay in future phases

2. Source-Agnostic Design

LoopFollow supports both Nightscout and Dexcom. IOB, COB, or predicted glucose are modeled as optional (Double?) in GlucoseSnapshot and the UI renders a dash (—) when they are absent. The Live Activity never assumes these values exist.

3. No Hardcoded Identifiers

The App Group ID is derived dynamically at runtime: group.`. No team-specific bundle IDs or App Group IDs are hardcoded anywhere. This ensures the project is safe to fork, clone, and submit as a pull request by any contributor.


Update Architecture — Why APNs Self-Push?

This is the most important architectural decision in Phase 1. Understanding it will help you maintain and extend this feature correctly.

What We Tried First — Direct activity.update()

The obvious approach to updating a Live Activity is to call activity.update() directly from the app. This works reliably when the app is in the foreground.

The problem appears when the app is in the background. LoopFollow uses a background audio session (.playback category, silent WAV file) to stay alive in the background and continue fetching glucose data. We discovered that liveactivitiesd (the iOS system daemon responsible for rendering Live Activities) refuses to process activity.update() calls from processes that hold an active background audio session. The update call either hangs indefinitely or is silently dropped. The Live Activity freezes on the lock screen while the app continues running normally.

We attempted several workarounds; none of these approaches were reliable or production-safe:

  • Call activity.update() while audio is playing | Updates hang or are dropped
  • Pause the audio player before updating | Insufficient — iOS checks the process-level audio assertion, not just the player state
  • Call AVAudioSession.setActive(false) before updating | Intermittently worked, but introduced a race condition and broke the audio session unpredictably
  • Add a fixed 3-second wait after deactivation | Fragile, caused background task timeout warnings, and still failed intermittently

The Solution — APNs Self-Push

Our solution is for LoopFollow to send an APNs (Apple Push Notification service) push notification to itself.

Here is how it works:

  1. When a Live Activity is started, ActivityKit provides a push token — a unique identifier for that specific Live Activity instance.
  2. LoopFollow captures this token via activity.pushTokenUpdates.
  3. After each BG refresh, LoopFollow generates a signed JWT using its APNs authentication key and posts an HTTP/2 request directly to Apple's APNs servers.
  4. Apple's APNs infrastructure delivers the push to liveactivitiesd on the device.
  5. liveactivitiesd updates the Live Activity directly — the app process is never involved in the rendering path.

Because liveactivitiesd receives the update via APNs rather than via an inter-process call from LoopFollow, it does not care that LoopFollow holds a background audio session. The update is processed reliably every time.

Why This Is Safe and Appropriate

  • This is an officially supported ActivityKit feature. Apple documents push-token-based Live Activity updates as the recommended update mechanism.
  • The push is sent from the app itself, to itself. No external server or provider infrastructure is required.
  • The APNs authentication key is injected at build time via xcconfig and Info.plist. It is never stored in the repository.
  • The JWT is generated on-device using CryptoKit (P256.Signing) and cached for 55 minutes (APNs tokens are valid for 60 minutes).

File Map

Main App Target

File Responsibility
LiveActivityManager.swift Orchestration — start, update, end, bind, observe lifecycle
GlucoseSnapshotBuilder.swift Pure data transformation — builds GlucoseSnapshot from storage
StorageCurrentGlucoseStateProvider.swift Thin abstraction over Storage.shared and Observable.shared
GlucoseSnapshotStore.swift App Group persistence — saves/loads latest snapshot
LAThresholdSync.swift Reads threshold config from Storage for widget color
PreferredGlucoseUnit.swift Reads user unit preference, converts mg/dL ↔ mmol/L
APNSClient.swift Sends APNs self-push with Live Activity content state
APNSJWTGenerator.swift Generates ES256-signed JWT for APNs authentication

Shared (App + Extension)

File Responsibility
GlucoseLiveActivityAttributes.swift ActivityKit attributes and content state definition
GlucoseSnapshot.swift Canonical cross-platform glucose data struct
GlucoseUnitConversion.swift Unit conversion helpers
LAAppGroupSettings.swift App Group UserDefaults access
AppGroupID.swift Derives App Group ID dynamically from bundle identifier

Extension Target

File Responsibility
LoopFollowLiveActivity.swift SwiftUI rendering — lock screen card and Dynamic Island
LoopFollowLABundle.swift WidgetBundle entry point

Update Flow

LoopFollow BG refresh completes
    → Storage.shared updated (glucose, delta, trend, IOB, COB, projected)
    → Observable.shared updated (isNotLooping)
    → BGData calls LiveActivityManager.refreshFromCurrentState(reason: "bg")
        → GlucoseSnapshotBuilder.build() reads from StorageCurrentGlucoseStateProvider
        → GlucoseSnapshot constructed (unit-converted, threshold-classified)
        → GlucoseSnapshotStore persists snapshot to App Group
        → activity.update(content) called (direct update for foreground reliability)
        → APNSClient.sendLiveActivityUpdate() sends self-push via APNs
            → liveactivitiesd receives push
            → Lock screen re-renders

APNs Setup — Required for Contributors

To build and run the Live Activity locally or via CI, you need an APNs authentication key. The key content is injected at build time via LoopFollowConfigOverride.xcconfig and is never stored in the repository.

What you need

  • An Apple Developer account
  • An APNs Auth Key (.p8 file) with the Apple Push Notifications service (APNs) capability enabled
  • The 10-character Key ID associated with that key

Local Build Setup

  1. Generate or download your .p8 key from developer.apple.com → Certificates, Identifiers & Profiles → Keys.
  2. Open the key file in a text editor. Copy the base64 content between the header and footer lines — exclude -----BEGIN PRIVATE KEY----- and -----END PRIVATE KEY-----. Join all lines into a single unbroken string with no spaces or line breaks.
  3. Create or edit LoopFollowConfigOverride.xcconfig in the project root (this file is gitignored):
APNS_KEY_ID = <YOUR_10_CHARACTER_KEY_ID>
APNS_KEY_CONTENT = <YOUR_SINGLE_LINE_BASE64_KEY_CONTENT>
  1. Build and run. The key is read at runtime from Info.plist which resolves $(APNS_KEY_CONTENT) from the xcconfig.

CI / GitHub Actions Setup

Add two repository secrets under Settings → Secrets and variables → Actions:

Secret Name Value
APNS_KEY_ID Your 10-character key ID
APNS_KEY Full contents of your .p8 file including PEM headers

The build workflow strips the PEM headers automatically and injects the content into LoopFollowConfigOverride.xcconfig before building.

…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
@bjorkert
Copy link
Contributor

bjorkert commented Mar 8, 2026

Thanks for the updated PR! I'll have a look.

@bjorkert
Copy link
Contributor

bjorkert commented Mar 8, 2026

Code Review

Again, thanks for the updated PR and documentation. It seems the APNs self-push approach is the correct solution for the background audio session limitation so I think we should follow that path. A few things need to be addressed before we can proceed with testing further review.

Critical — Must fix

1. Hardcoded Team ID and Bundle Identifiers

The PR description states "No Hardcoded Identifiers", but the diff replaces dynamic variables with a personal team ID (2HEY366Q6J) in multiple places:

  • project.pbxproj: DEVELOPMENT_TEAM changed from "$(LF_DEVELOPMENT_TEAM)" to 2HEY366Q6J (both Debug and Release)
  • project.pbxproj: PRODUCT_BUNDLE_IDENTIFIER changed from "com.$(unique_id).LoopFollow$(app_suffix)" to com.2HEY366Q6J.LoopFollow (both Debug and Release)
  • Loop Follow.entitlements: App group hardcoded as group.com.2HEY366Q6J.LoopFollow
  • LoopFollowLAExtensionExtension.entitlements: Same hardcoded app group
  • Extension build settings: DEVELOPMENT_TEAM = 2HEY366Q6J and PRODUCT_BUNDLE_IDENTIFIER = com.2HEY366Q6J.LoopFollow.LoopFollowLAExtension

These must all use the existing dynamic variable pattern ($(LF_DEVELOPMENT_TEAM), $(unique_id), $(app_suffix)) that the rest of the project uses. As-is, this breaks the build for every other user.

2. Removed iPad and Mac Catalyst support

The PR silently changes platform support settings that are unrelated to Live Activity:

  • SUPPORTS_MACCATALYST changed from YES to NO
  • TARGETED_DEVICE_FAMILY changed from "1,2" to 1 (removes iPad)
  • Added SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO
  • Added SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO

These should be reverted to their original values.

3. Info.plist: CFBundleIdentifier changed from dynamic to static

CFBundleIdentifier was changed from com.$(unique_id).LoopFollow$(app_suffix) to $(PRODUCT_BUNDLE_IDENTIFIER).

LoopFollow has the app_suffix in order so support multiple apps running at the same time, they should each have their own LiveActivity.

Important — Should fix

4. Both direct activity.update() AND APNs push on every refresh

LiveActivityManager.update() calls both await activity.update(content) and APNSClient.shared.sendLiveActivityUpdate() on every cycle. Given the documented audio session limitation, the direct call will silently fail in the background anyway. Consider:

  • Making the direct call conditional on foreground state (or when kept alive using a BLE device?), or
  • Documenting the dual-path strategy explicitly in code comments

Minor — Nice to fix

5. BackgroundTaskAudio.swift unrelated changes

This file has cosmetic changes (reordered copyright header, fileprivateprivate, removed comments, added static let shared) that are unrelated to Live Activity. Recommend splitting these into a separate commit or reverting the non-functional changes.

@MtlPhil
Copy link
Author

MtlPhil commented Mar 9, 2026

Ready to review again. Two issues came up overnight (other than what came out of your PR review):

  1. The change to DST in North America triggered a mismatch in the Last Update time reflected on the Live Activity. The issue looks like it was tied to ActivityKit silently re-encoding ContentState using Apple's reference date (2001) instead of Unix epoch, so my custom decoder was correct but the round-trip was broken without a matching custom encoder.

  2. My phone's APNs token flipped overnight. I wasn't catching the various error codes APNs can return. Should be fixed now.

@bjorkert
Copy link
Contributor

bjorkert commented Mar 9, 2026

Thanks, I think we are getting close! There are still a few things I'd like to clean up — hardcoded team IDs, some Xcode artifacts, and minor diff noise. Would you be ok if I take it from here? I can do the final fixes a bit faster than going back and forth :) Does your PR allow edits from maintainers? Otherwise I can create a new branch at loopandlearn from where we are.

@MtlPhil
Copy link
Author

MtlPhil commented Mar 9, 2026 via email

@MtlPhil
Copy link
Author

MtlPhil commented Mar 10, 2026

Not sure how to fix the remaining hardcoded bundleIDs. I can remove them in project.pbxproj, but when I rebuild in Xcode and add a team, the values get hard-coded again.

@bjorkert
Copy link
Contributor

No worries, I'll se what I can do and I'll get back to you if there are any questions, thanks!

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