Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
5b2922d
Update BackgroundTaskAudio.swift
MtlPhil Mar 19, 2026
c01cc23
Update GlucoseLiveActivityAttributes.swift
MtlPhil Mar 19, 2026
808087c
Update GlucoseLiveActivityAttributes.swift
MtlPhil Mar 19, 2026
c2fc15b
Restore explanatory comment for 0.5s audio restart delay
MtlPhil Mar 20, 2026
2af22f7
Add BGAppRefreshTask support for silent audio recovery
MtlPhil Mar 20, 2026
41f9150
Fix BGAppRefreshTask: add fetch background mode, fix duplicate observer
MtlPhil Mar 20, 2026
6a7d706
Fix duplicate audio observer; add restart confirmation log
MtlPhil Mar 20, 2026
a88dc07
Merge branch 'loopandlearn:live-activity' into live-activity
MtlPhil Mar 21, 2026
0c2993d
Delete LiveActivitySlotConfig.swift
MtlPhil Mar 21, 2026
839a806
Update GlucoseSnapshotBuilder.swift
MtlPhil Mar 21, 2026
b33f05b
Update StorageCurrentGlucoseStateProvider.swift
MtlPhil Mar 21, 2026
ba18510
Update LiveActivityManager.swift
MtlPhil Mar 21, 2026
0f7fa11
Update GlucoseSnapshot.swift
MtlPhil Mar 21, 2026
b454e46
Update GlucoseSnapshot.swift
MtlPhil Mar 21, 2026
bf1b42e
Update LiveActivityManager.swift
MtlPhil Mar 21, 2026
a47e1da
Update LiveActivityManager.swift
MtlPhil Mar 21, 2026
a19cbee
Update GlucoseLiveActivityAttributes.swift
MtlPhil Mar 21, 2026
b62e14f
Update LiveActivityManager.swift
MtlPhil Mar 21, 2026
1867c82
Add LA expiry notification; fix OS-dismissed vs user-dismissed
MtlPhil Mar 21, 2026
8446fe7
Remove dead pendingLATapNavigation code
MtlPhil Mar 21, 2026
a026e11
Code quality pass: log categories, SwiftFormat, dead code cleanup
MtlPhil Mar 21, 2026
9db626e
Round prediction value before Int conversion
MtlPhil Mar 21, 2026
f59bd2e
Fix double setTaskCompleted race; fix renewal deadline write ordering
MtlPhil Mar 22, 2026
b28f9c2
Scope all identifiers to bundle ID for multi-instance support
bjorkert Mar 22, 2026
8e2e31f
Linting
bjorkert Mar 22, 2026
f2f87b5
Add migration step 7: cancel legacy notification identifiers
bjorkert Mar 22, 2026
4cb7bb4
Increase LA refresh debounce from 5s to 20s to coalesce double push
MtlPhil Mar 22, 2026
35d7c36
Merge branch 'loopandlearn:live-activity' into live-activity
MtlPhil Mar 22, 2026
269f2bd
Guard migrations against background launch to prevent BFU settings wipe
MtlPhil Mar 22, 2026
e52fee8
Fix BFU migration guard: wrap only migrations, not all of viewDidLoad
MtlPhil Mar 22, 2026
b23f278
Defer migrations to first foreground after BFU background launch
MtlPhil Mar 22, 2026
4dce14c
Use didBecomeActive (not willEnterForeground) for deferred migration …
MtlPhil Mar 22, 2026
7e6b191
Remove BGAppRefreshTask completely
MtlPhil Mar 23, 2026
29fdfc4
Revert "Remove BGAppRefreshTask completely"
MtlPhil Mar 23, 2026
9f22f42
Guard migrateStep1 core fields against BFU empty reads
MtlPhil Mar 23, 2026
69d76f8
Fix reboot settings wipe: reload StorageValues on foreground after BF…
MtlPhil Mar 23, 2026
ca99a58
Reload all Nightscout credentials on foreground, not just url/share f…
MtlPhil Mar 23, 2026
348cfc9
Gate BFU reload behind isProtectedDataAvailable flag; reload all Stor…
MtlPhil Mar 23, 2026
a79dac6
Add BFU diagnostic logs to AppDelegate and appCameToForeground
MtlPhil Mar 23, 2026
7cf93fd
Reschedule all tasks after BFU reload to fix blank charts on first fo…
MtlPhil Mar 23, 2026
b662c69
Show loading overlay during BFU data reload instead of blank charts
MtlPhil Mar 23, 2026
3bdc8e6
Redesign CarPlay SmallFamilyView to match Loop's LA layout
MtlPhil Mar 23, 2026
0baa2d7
Fix CarPlay: bypass activityFamily detection in supplemental widget
MtlPhil Mar 23, 2026
2e7af36
Fix Watch/CarPlay: register only one widget per iOS version band
MtlPhil Mar 23, 2026
7c2d6d7
Fix build error and harden Watch/CarPlay routing
MtlPhil Mar 23, 2026
7c469b4
Watch & CarPlay widget
MtlPhil Mar 23, 2026
a66f1f7
Update LoopFollowLiveActivity.swift
MtlPhil Mar 23, 2026
bd3288d
Update LoopFollowLiveActivity.swift
MtlPhil Mar 23, 2026
f549772
Update LoopFollowLABundle.swift
MtlPhil Mar 23, 2026
fcb5453
Update LoopFollowLABundle.swift
MtlPhil Mar 23, 2026
10e2d84
Update LoopFollowLABundle.swift
MtlPhil Mar 23, 2026
44400a0
Update LoopFollowLiveActivity.swift
MtlPhil Mar 23, 2026
ec468be
Update LoopFollowLABundle.swift
MtlPhil Mar 23, 2026
d9eed5d
Update LoopFollowLiveActivity.swift
MtlPhil Mar 23, 2026
897a410
Update LoopFollowLiveActivity.swift
MtlPhil Mar 23, 2026
b90839b
Update LoopFollowLiveActivity.swift
MtlPhil Mar 23, 2026
0627c21
Merge pr-branch: multi-instance support (scope identifiers to bundle ID)
MtlPhil Mar 23, 2026
3b2f920
Remove docs/ directory from PR scope
claude Mar 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions LoopFollow/Application/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
}

let action = UNNotificationAction(identifier: "OPEN_APP_ACTION", title: "Open App", options: .foreground)
let category = UNNotificationCategory(identifier: "loopfollow.background.alert", actions: [action], intentIdentifiers: [], options: [])
let category = UNNotificationCategory(identifier: BackgroundAlertIdentifier.categoryIdentifier, actions: [action], intentIdentifiers: [], options: [])
UNUserNotificationCenter.current().setNotificationCategories([category])

UNUserNotificationCenter.current().delegate = self
Expand All @@ -47,6 +47,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
}

BackgroundRefreshManager.shared.register()

// Detect Before-First-Unlock launch. If protected data is unavailable here,
// StorageValues were cached from encrypted UserDefaults and need a reload
// on the first foreground after the user unlocks.
let bfu = !UIApplication.shared.isProtectedDataAvailable
Storage.shared.needsBFUReload = bfu
LogManager.shared.log(category: .general, message: "BFU check: isProtectedDataAvailable=\(!bfu), needsBFUReload=\(bfu)")

return true
}

Expand Down Expand Up @@ -107,7 +115,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {

// Note: with scene-based lifecycle (iOS 13+), URLs are delivered to
// SceneDelegate.scene(_:openURLContexts:) — not here. The scene delegate
// handles loopfollow://la-tap for Live Activity tap navigation.
// handles <urlScheme>://la-tap for Live Activity tap navigation.

// MARK: UISceneSession Lifecycle

Expand Down
2 changes: 1 addition & 1 deletion LoopFollow/Application/SceneDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
}

func scene(_: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
guard URLContexts.contains(where: { $0.url.scheme == "loopfollow" && $0.url.host == "la-tap" }) else { return }
guard URLContexts.contains(where: { $0.url.scheme == AppGroupID.urlScheme && $0.url.host == "la-tap" }) else { return }
// scene(_:openURLContexts:) fires after sceneDidBecomeActive when the app
// foregrounds from background. Post on the next run loop so the view
// hierarchy (including any presented modals) is fully settled.
Expand Down
25 changes: 19 additions & 6 deletions LoopFollow/Controllers/BackgroundAlertManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,24 @@ enum BackgroundAlertDuration: TimeInterval, CaseIterable {
case eighteenMinutes = 1080 // 18 minutes in seconds
}

/// Enum representing unique identifiers for each background alert.
enum BackgroundAlertIdentifier: String, CaseIterable {
case sixMin = "loopfollow.background.alert.6min"
case twelveMin = "loopfollow.background.alert.12min"
case eighteenMin = "loopfollow.background.alert.18min"
/// Unique identifiers for each background alert, scoped to the current bundle
/// so multiple LoopFollow instances don't interfere with each other's notifications.
enum BackgroundAlertIdentifier: CaseIterable {
case sixMin
case twelveMin
case eighteenMin

private static let prefix = Bundle.main.bundleIdentifier ?? "loopfollow"

var rawValue: String {
switch self {
case .sixMin: "\(Self.prefix).background.alert.6min"
case .twelveMin: "\(Self.prefix).background.alert.12min"
case .eighteenMin: "\(Self.prefix).background.alert.18min"
}
}

static let categoryIdentifier = "\(prefix).background.alert"
}

class BackgroundAlertManager {
Expand Down Expand Up @@ -118,7 +131,7 @@ class BackgroundAlertManager {
content.title = title
content.body = body
content.sound = .defaultCritical
content.categoryIdentifier = "loopfollow.background.alert"
content.categoryIdentifier = BackgroundAlertIdentifier.categoryIdentifier
return content
}

Expand Down
2 changes: 1 addition & 1 deletion LoopFollow/Helpers/BackgroundRefreshManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ class BackgroundRefreshManager {
static let shared = BackgroundRefreshManager()
private init() {}

private let taskIdentifier = "com.loopfollow.audiorefresh"
private let taskIdentifier = "\(Bundle.main.bundleIdentifier ?? "com.loopfollow").audiorefresh"

func register() {
BGTaskScheduler.shared.register(forTaskWithIdentifier: taskIdentifier, using: nil) { task in
Expand Down
4 changes: 2 additions & 2 deletions LoopFollow/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.$(unique_id).LoopFollow$(app_suffix)</string>
<string>com.loopfollow.audiorefresh</string>
<string>com.$(unique_id).LoopFollow$(app_suffix).audiorefresh</string>
</array>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
Expand All @@ -34,7 +34,7 @@
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>loopfollow</string>
<string>loopfollow$(app_suffix)</string>
</array>
</dict>
</array>
Expand Down
26 changes: 20 additions & 6 deletions LoopFollow/LiveActivity/AppGroupID.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,33 @@ enum AppGroupID {
/// to force a shared base bundle id (recommended for reliability).
private static let baseBundleIDPlistKey = "LFAppGroupBaseBundleID"

static func current() -> String {
/// The base bundle identifier for the main app, with extension suffixes stripped.
/// Usable from both the main app and extensions.
static var baseBundleID: String {
if let base = Bundle.main.object(forInfoDictionaryKey: baseBundleIDPlistKey) as? String,
!base.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
return "group.\(base)"
return base
}

let bundleID = Bundle.main.bundleIdentifier ?? "unknown"
return stripLikelyExtensionSuffixes(from: bundleID)
}

// Heuristic: strip common extension suffixes so the extension can land on the main app’s group id.
let base = stripLikelyExtensionSuffixes(from: bundleID)
/// URL scheme derived from the bundle identifier. Works across app and extensions.
/// Default build: "loopfollow", second: "loopfollow2", third: "loopfollow3", etc.
static var urlScheme: String {
let base = baseBundleID
// Extract the suffix after "LoopFollow" in the bundle ID
// e.g. "com.TEAM.LoopFollow2" → "2", "com.TEAM.LoopFollow" → ""
if let range = base.range(of: "LoopFollow", options: .backwards) {
let suffix = base[range.upperBound...]
return "loopfollow\(suffix)"
}
return "loopfollow"
}

return "group.\(base)"
static func current() -> String {
"group.\(baseBundleID)"
}

private static func stripLikelyExtensionSuffixes(from bundleID: String) -> String {
Expand Down
17 changes: 17 additions & 0 deletions LoopFollow/LiveActivity/LAAppGroupSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ enum LAAppGroupSettings {
static let lowLineMgdl = "la.lowLine.mgdl"
static let highLineMgdl = "la.highLine.mgdl"
static let slots = "la.slots"
static let displayName = "la.displayName"
static let showDisplayName = "la.showDisplayName"
}

private static var defaults: UserDefaults? {
Expand Down Expand Up @@ -176,4 +178,19 @@ enum LAAppGroupSettings {
}
return raw.map { LiveActivitySlotOption(rawValue: $0) ?? .none }
}

// MARK: - Display Name

static func setDisplayName(_ name: String, show: Bool) {
defaults?.set(name, forKey: Keys.displayName)
defaults?.set(show, forKey: Keys.showDisplayName)
}

static func displayName() -> String {
defaults?.string(forKey: Keys.displayName) ?? "LoopFollow"
}

static func showDisplayName() -> Bool {
defaults?.bool(forKey: Keys.showDisplayName) ?? false
}
}
12 changes: 9 additions & 3 deletions LoopFollow/LiveActivity/LiveActivityManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,10 @@ final class LiveActivityManager {
lowMgdl: Storage.shared.lowLine.value,
highMgdl: Storage.shared.highLine.value,
)
LAAppGroupSettings.setDisplayName(
Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? "LoopFollow",
show: Storage.shared.showDisplayName.value
)
GlucoseSnapshotStore.shared.save(snapshot)
}
startIfNeeded()
Expand All @@ -322,7 +326,7 @@ final class LiveActivityManager {
self?.performRefresh(reason: reason)
}
refreshWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0, execute: workItem)
DispatchQueue.main.asyncAfter(deadline: .now() + 20.0, execute: workItem)
}

// MARK: - Renewal
Expand Down Expand Up @@ -557,14 +561,16 @@ final class LiveActivityManager {

// MARK: - Renewal Notifications

private static let renewalNotificationID = "\(Bundle.main.bundleIdentifier ?? "loopfollow").la.renewal.failed"

private func scheduleRenewalFailedNotification() {
let content = UNMutableNotificationContent()
content.title = "Live Activity Expiring"
content.body = "Live Activity will expire soon. Open LoopFollow to restart."
content.sound = .default
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
let request = UNNotificationRequest(
identifier: "loopfollow.la.renewal.failed",
identifier: LiveActivityManager.renewalNotificationID,
content: content,
trigger: trigger,
)
Expand All @@ -577,7 +583,7 @@ final class LiveActivityManager {
}

private func cancelRenewalFailedNotification() {
let id = "loopfollow.la.renewal.failed"
let id = LiveActivityManager.renewalNotificationID
UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [id])
UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [id])
}
Expand Down
2 changes: 1 addition & 1 deletion LoopFollow/LiveActivity/RestartLiveActivityIntent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ struct RestartLiveActivityIntent: AppIntent {
let apnsKey = Storage.shared.lfApnsKey.value

if keyId.isEmpty || apnsKey.isEmpty {
if let url = URL(string: "loopfollow://settings/live-activity") {
if let url = URL(string: "\(AppGroupID.urlScheme)://settings/live-activity") {
await MainActor.run { UIApplication.shared.open(url) }
}
return .result(dialog: "Please enter your APNs credentials in LoopFollow settings to use the Live Activity.")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
// LoopFollow
// StorageCurrentGlucoseStateProvider.swift
// 2026-03-21

import Foundation

/// Reads the latest glucose state from LoopFollow's Storage and Observable layers.
/// This is the only file in the pipeline that is allowed to touch Storage.shared
/// or Observable.shared — all other layers read exclusively from this provider.
struct StorageCurrentGlucoseStateProvider: CurrentGlucoseStateProviding {

// MARK: - Core Glucose

var glucoseMgdl: Double? {
Expand Down
12 changes: 12 additions & 0 deletions LoopFollow/Storage/Framework/StorageValue.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,16 @@ class StorageValue<T: Codable & Equatable>: ObservableObject {
func remove() {
StorageValue.defaults.removeObject(forKey: key)
}

/// Re-reads the value from UserDefaults, updating the @Published cache.
/// Call this when the app foregrounds after a Before-First-Unlock background launch,
/// where StorageValue was initialized while UserDefaults was locked (returning defaults).
func reload() {
if let data = StorageValue.defaults.data(forKey: key),
let decodedValue = try? JSONDecoder().decode(T.self, from: data),
decodedValue != value
{
value = decodedValue
}
}
}
35 changes: 31 additions & 4 deletions LoopFollow/Storage/Storage+Migrate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Storage+Migrate.swift

import Foundation
import UserNotifications

extension Storage {
func migrateStep5() {
Expand Down Expand Up @@ -32,6 +33,21 @@ extension Storage {
}
}

func migrateStep7() {
// Cancel notifications scheduled with old hardcoded identifiers.
// Replaced with bundle-ID-scoped identifiers for multi-instance support.
LogManager.shared.log(category: .general, message: "Running migrateStep7 — cancel legacy notification identifiers")

let legacyNotificationIDs = [
"loopfollow.background.alert.6min",
"loopfollow.background.alert.12min",
"loopfollow.background.alert.18min",
"loopfollow.la.renewal.failed",
]
UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: legacyNotificationIDs)
UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: legacyNotificationIDs)
}

func migrateStep6() {
// APNs credential separation
LogManager.shared.log(category: .general, message: "Running migrateStep6 — APNs credential separation")
Expand Down Expand Up @@ -125,10 +141,21 @@ extension Storage {
}

func migrateStep1() {
Storage.shared.url.value = ObservableUserDefaults.shared.old_url.value
Storage.shared.device.value = ObservableUserDefaults.shared.old_device.value
Storage.shared.nsWriteAuth.value = ObservableUserDefaults.shared.old_nsWriteAuth.value
Storage.shared.nsAdminAuth.value = ObservableUserDefaults.shared.old_nsAdminAuth.value
// Guard each field with .exists so that if the App Group suite is unreadable
// (e.g. Before-First-Unlock state after a reboot), we skip the write rather
// than overwriting the already-migrated Standard value with an empty default.
if ObservableUserDefaults.shared.old_url.exists {
Storage.shared.url.value = ObservableUserDefaults.shared.old_url.value
}
if ObservableUserDefaults.shared.old_device.exists {
Storage.shared.device.value = ObservableUserDefaults.shared.old_device.value
}
if ObservableUserDefaults.shared.old_nsWriteAuth.exists {
Storage.shared.nsWriteAuth.value = ObservableUserDefaults.shared.old_nsWriteAuth.value
}
if ObservableUserDefaults.shared.old_nsAdminAuth.exists {
Storage.shared.nsAdminAuth.value = ObservableUserDefaults.shared.old_nsAdminAuth.value
}

// Helper: 1-to-1 type -----------------------------------------------------------------
func move<T: AnyConvertible & Equatable>(
Expand Down
Loading