Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 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 Down Expand Up @@ -107,7 +107,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
}
}
10 changes: 8 additions & 2 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 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
16 changes: 16 additions & 0 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
5 changes: 5 additions & 0 deletions LoopFollow/ViewControllers/MainViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,11 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
Storage.shared.migrationStep.value = 6
}

if Storage.shared.migrationStep.value < 7 {
Storage.shared.migrateStep7()
Storage.shared.migrationStep.value = 7
}

// Synchronize info types to ensure arrays are the correct size
synchronizeInfoTypes()

Expand Down
16 changes: 9 additions & 7 deletions LoopFollowLAExtension/LoopFollowLiveActivity.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,21 @@ import WidgetKit
private func makeDynamicIsland(context: ActivityViewContext<GlucoseLiveActivityAttributes>) -> DynamicIsland {
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
Link(destination: URL(string: "loopfollow://la-tap")!) {
Link(destination: URL(string: "\(AppGroupID.urlScheme)://la-tap")!) {
DynamicIslandLeadingView(snapshot: context.state.snapshot)
.overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay))
}
.id(context.state.seq)
}
DynamicIslandExpandedRegion(.trailing) {
Link(destination: URL(string: "loopfollow://la-tap")!) {
Link(destination: URL(string: "\(AppGroupID.urlScheme)://la-tap")!) {
DynamicIslandTrailingView(snapshot: context.state.snapshot)
.overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay))
}
.id(context.state.seq)
}
DynamicIslandExpandedRegion(.bottom) {
Link(destination: URL(string: "loopfollow://la-tap")!) {
Link(destination: URL(string: "\(AppGroupID.urlScheme)://la-tap")!) {
DynamicIslandBottomView(snapshot: context.state.snapshot)
.overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay, showText: true))
}
Expand Down Expand Up @@ -51,7 +51,7 @@ struct LoopFollowLiveActivityWidget: Widget {
.activitySystemActionForegroundColor(.white)
.activityBackgroundTint(LAColors.backgroundTint(for: context.state.snapshot))
.applyActivityContentMarginsFixIfAvailable()
.widgetURL(URL(string: "loopfollow://la-tap")!)
.widgetURL(URL(string: "\(AppGroupID.urlScheme)://la-tap")!)
} dynamicIsland: { context in
makeDynamicIsland(context: context)
}
Expand Down Expand Up @@ -106,7 +106,7 @@ private struct LockScreenFamilyAdaptiveView: View {
.activitySystemActionForegroundColor(.white)
.activityBackgroundTint(LAColors.backgroundTint(for: state.snapshot))
.applyActivityContentMarginsFixIfAvailable()
.widgetURL(URL(string: "loopfollow://la-tap")!)
.widgetURL(URL(string: "\(AppGroupID.urlScheme)://la-tap")!)
}
}
}
Expand Down Expand Up @@ -206,8 +206,10 @@ private struct LockScreenLiveActivityView: View {
.frame(maxWidth: .infinity, alignment: .trailing)
}

// Footer: last update time
Text("Last Update: \(LAFormat.updated(s))")
// Footer: last update time, optionally prefixed with display name
Text(LAAppGroupSettings.showDisplayName()
? "\(LAAppGroupSettings.displayName()) — \(LAFormat.updated(s))"
: "Last Update: \(LAFormat.updated(s))")
.font(.system(size: 11, weight: .regular, design: .rounded))
.monospacedDigit()
.foregroundStyle(.white.opacity(0.65))
Expand Down