Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 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
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: 4 additions & 0 deletions LoopFollow.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@
DD608A082C1F584900F91132 /* DeviceStatusLoop.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD608A072C1F584900F91132 /* DeviceStatusLoop.swift */; };
DD608A0A2C23593900F91132 /* SMB.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD608A092C23593900F91132 /* SMB.swift */; };
DD608A0C2C27415C00F91132 /* BackgroundAlertManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD608A0B2C27415C00F91132 /* BackgroundAlertManager.swift */; };
66E3D12E66AA4534A144A54B /* BackgroundRefreshManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8CA8BE0B3D247408FE088B4 /* BackgroundRefreshManager.swift */; };
DD6A935E2BFA6FA2003FFB8E /* DeviceStatusOpenAPS.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD6A935D2BFA6FA2003FFB8E /* DeviceStatusOpenAPS.swift */; };
DD7B0D442D730A3B0063DCB6 /* CycleHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7B0D432D730A320063DCB6 /* CycleHelper.swift */; };
DD7E19842ACDA50C00DBD158 /* Overrides.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7E19832ACDA50C00DBD158 /* Overrides.swift */; };
Expand Down Expand Up @@ -581,6 +582,7 @@
DD608A072C1F584900F91132 /* DeviceStatusLoop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStatusLoop.swift; sourceTree = "<group>"; };
DD608A092C23593900F91132 /* SMB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMB.swift; sourceTree = "<group>"; };
DD608A0B2C27415C00F91132 /* BackgroundAlertManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundAlertManager.swift; sourceTree = "<group>"; };
A8CA8BE0B3D247408FE088B4 /* BackgroundRefreshManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundRefreshManager.swift; sourceTree = "<group>"; };
DD6A935D2BFA6FA2003FFB8E /* DeviceStatusOpenAPS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStatusOpenAPS.swift; sourceTree = "<group>"; };
DD7B0D432D730A320063DCB6 /* CycleHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CycleHelper.swift; sourceTree = "<group>"; };
DD7E19832ACDA50C00DBD158 /* Overrides.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Overrides.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1655,6 +1657,7 @@
FCC6886A24898FD800A0279D /* ObservationToken.swift */,
FCC6886C2489909D00A0279D /* AnyConvertible.swift */,
FCC688592489554800A0279D /* BackgroundTaskAudio.swift */,
A8CA8BE0B3D247408FE088B4 /* BackgroundRefreshManager.swift */,
FCFEEC9F2488157B00402A7F /* Chart.swift */,
FCC0FAC124922A22003E610E /* DictionaryKeyPath.swift */,
FC16A98024996C07003D6245 /* DateTime.swift */,
Expand Down Expand Up @@ -2256,6 +2259,7 @@
DDFF3D852D14279B00BF9D9E /* BackgroundRefreshSettingsView.swift in Sources */,
DDCF9A882D85FD33004DF4DD /* AlarmData.swift in Sources */,
DD608A0C2C27415C00F91132 /* BackgroundAlertManager.swift in Sources */,
66E3D12E66AA4534A144A54B /* BackgroundRefreshManager.swift in Sources */,
379BECB02F65DA4B0069DC62 /* LiveActivitySettingsView.swift in Sources */,
DD4878082C7B30BF0048F05C /* RemoteSettingsView.swift in Sources */,
DDE75D2B2DE5E613007C1FC1 /* NavigationRow.swift in Sources */,
Expand Down
26 changes: 14 additions & 12 deletions LoopFollow/Application/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import EventKit
import UIKit
import UserNotifications

@UIApplicationMain
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
let notificationCenter = UNUserNotificationCenter.current()
Expand Down Expand Up @@ -45,6 +45,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
DispatchQueue.main.async {
UIApplication.shared.registerForRemoteNotifications()
}

BackgroundRefreshManager.shared.register()
return true
}

Expand All @@ -56,43 +58,43 @@ class AppDelegate: UIResponder, UIApplicationDelegate {

// MARK: - Remote Notifications

// Called when successfully registered for remote notifications
/// Called when successfully registered for remote notifications
func application(_: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
let tokenString = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()

Observable.shared.loopFollowDeviceToken.value = tokenString

LogManager.shared.log(category: .general, message: "Successfully registered for remote notifications with token: \(tokenString)")
LogManager.shared.log(category: .apns, message: "Successfully registered for remote notifications with token: \(tokenString)")
}

// Called when failed to register for remote notifications
/// Called when failed to register for remote notifications
func application(_: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
LogManager.shared.log(category: .general, message: "Failed to register for remote notifications: \(error.localizedDescription)")
LogManager.shared.log(category: .apns, message: "Failed to register for remote notifications: \(error.localizedDescription)")
}

// Called when a remote notification is received
/// Called when a remote notification is received
func application(_: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
LogManager.shared.log(category: .general, message: "Received remote notification: \(userInfo)")
LogManager.shared.log(category: .apns, message: "Received remote notification: \(userInfo)")

// Check if this is a response notification from Loop or Trio
if let aps = userInfo["aps"] as? [String: Any] {
// Handle visible notification (alert, sound, badge)
if let alert = aps["alert"] as? [String: Any] {
let title = alert["title"] as? String ?? ""
let body = alert["body"] as? String ?? ""
LogManager.shared.log(category: .general, message: "Notification - Title: \(title), Body: \(body)")
LogManager.shared.log(category: .apns, message: "Notification - Title: \(title), Body: \(body)")
}

// Handle silent notification (content-available)
if let contentAvailable = aps["content-available"] as? Int, contentAvailable == 1 {
// This is a silent push, nothing implemented but logging for now

if let commandStatus = userInfo["command_status"] as? String {
LogManager.shared.log(category: .general, message: "Command status: \(commandStatus)")
LogManager.shared.log(category: .apns, message: "Command status: \(commandStatus)")
}

if let commandType = userInfo["command_type"] as? String {
LogManager.shared.log(category: .general, message: "Command type: \(commandType)")
LogManager.shared.log(category: .apns, message: "Command type: \(commandType)")
}
}
}
Expand Down Expand Up @@ -120,7 +122,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options _: UIScene.ConnectionOptions) -> UISceneConfiguration {
// Called when a new scene session is being created.
// Use this method to select a configuration to create the new scene with.
return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
}

func application(_: UIApplication, didDiscardSceneSessions _: Set<UISceneSession>) {
Expand Down Expand Up @@ -176,7 +178,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {

func userNotificationCenter(_: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
if response.actionIdentifier == "OPEN_APP_ACTION" {
if let window = window {
if let window {
window.rootViewController?.dismiss(animated: true, completion: nil)
window.rootViewController?.present(MainViewController(), animated: true, completion: nil)
}
Expand Down
12 changes: 2 additions & 10 deletions LoopFollow/Application/SceneDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,16 +32,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
func sceneDidBecomeActive(_: UIScene) {
// Called when the scene has moved from an inactive state to an active state.
// Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive.
if pendingLATapNavigation {
pendingLATapNavigation = false
NotificationCenter.default.post(name: .liveActivityDidForeground, object: nil)
}
}

/// Set when loopfollow://la-tap arrives before the scene is fully active.
/// Consumed in sceneDidBecomeActive once the view hierarchy is restored.
private var pendingLATapNavigation = false

func scene(_: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
guard URLContexts.contains(where: { $0.url.scheme == "loopfollow" && $0.url.host == "la-tap" }) else { return }
// scene(_:openURLContexts:) fires after sceneDidBecomeActive when the app
Expand Down Expand Up @@ -71,7 +63,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
(UIApplication.shared.delegate as? AppDelegate)?.saveContext()
}

// Handle the UIApplicationShortcutItem when the user taps on the Home Screen Quick Action. This function toggles the "Speak BG" setting in UserDefaultsRepository, speaks the current state (on/off) using AVSpeechSynthesizer, and updates the Quick Action appearance.
/// Handle the UIApplicationShortcutItem when the user taps on the Home Screen Quick Action. This function toggles the "Speak BG" setting in UserDefaultsRepository, speaks the current state (on/off) using AVSpeechSynthesizer, and updates the Quick Action appearance.
func handleShortcutItem(_ shortcutItem: UIApplicationShortcutItem) {
if let bundleIdentifier = Bundle.main.bundleIdentifier {
let expectedType = bundleIdentifier + ".toggleSpeakBG"
Expand All @@ -84,7 +76,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
}
}

// The following method is called when the user taps on the Home Screen Quick Action
/// The following method is called when the user taps on the Home Screen Quick Action
func windowScene(_: UIWindowScene, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler _: @escaping (Bool) -> Void) {
handleShortcutItem(shortcutItem)
}
Expand Down
2 changes: 1 addition & 1 deletion LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ extension MainViewController {

if let predictdata = lastLoopRecord["predicted"] as? [String: AnyObject] {
let prediction = predictdata["values"] as! [Double]
PredictionLabel.text = Localizer.toDisplayUnits(String(Int(prediction.last!)))
PredictionLabel.text = Localizer.toDisplayUnits(String(Int(round(prediction.last!))))
PredictionLabel.textColor = UIColor.systemPurple
if Storage.shared.downloadPrediction.value, previousLastLoopTime < lastLoopTime {
predictionData.removeAll()
Expand Down
96 changes: 96 additions & 0 deletions LoopFollow/Helpers/BackgroundRefreshManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// LoopFollow
// BackgroundRefreshManager.swift

import BackgroundTasks
import UIKit

class BackgroundRefreshManager {
static let shared = BackgroundRefreshManager()
private init() {}

private let taskIdentifier = "com.loopfollow.audiorefresh"

func register() {
BGTaskScheduler.shared.register(forTaskWithIdentifier: taskIdentifier, using: nil) { task in
guard let refreshTask = task as? BGAppRefreshTask else { return }
self.handleRefreshTask(refreshTask)
}
}

private func handleRefreshTask(_ task: BGAppRefreshTask) {
LogManager.shared.log(category: .taskScheduler, message: "BGAppRefreshTask fired")

// Guard against double setTaskCompleted if expiration fires while the
// main-queue block is in-flight (Apple documents this as a programming error).
var completed = false

task.expirationHandler = {
guard !completed else { return }
completed = true
LogManager.shared.log(category: .taskScheduler, message: "BGAppRefreshTask expired")
task.setTaskCompleted(success: false)
self.scheduleRefresh()
}

DispatchQueue.main.async {
guard !completed else { return }
completed = true
if let mainVC = self.getMainViewController() {
if !mainVC.backgroundTask.player.isPlaying {
LogManager.shared.log(category: .taskScheduler, message: "audio dead, attempting restart")
mainVC.backgroundTask.stopBackgroundTask()
mainVC.backgroundTask.startBackgroundTask()
LogManager.shared.log(category: .taskScheduler, message: "audio restart initiated")
} else {
LogManager.shared.log(category: .taskScheduler, message: "audio alive, no action needed", isDebug: true)
}
}
self.scheduleRefresh()
task.setTaskCompleted(success: true)
}
}

func scheduleRefresh() {
let request = BGAppRefreshTaskRequest(identifier: taskIdentifier)
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)
do {
try BGTaskScheduler.shared.submit(request)
} catch {
LogManager.shared.log(category: .taskScheduler, message: "Failed to schedule BGAppRefreshTask: \(error)")
}
}

private func getMainViewController() -> MainViewController? {
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first,
let rootVC = window.rootViewController
else {
return nil
}

if let mainVC = rootVC as? MainViewController {
return mainVC
}

if let navVC = rootVC as? UINavigationController,
let mainVC = navVC.viewControllers.first as? MainViewController
{
return mainVC
}

if let tabVC = rootVC as? UITabBarController {
for vc in tabVC.viewControllers ?? [] {
if let mainVC = vc as? MainViewController {
return mainVC
}
if let navVC = vc as? UINavigationController,
let mainVC = navVC.viewControllers.first as? MainViewController
{
return mainVC
}
}
}

return nil
}
}
5 changes: 3 additions & 2 deletions LoopFollow/Helpers/BackgroundTaskAudio.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class BackgroundTask {
// MARK: - Methods

func startBackgroundTask() {
NotificationCenter.default.removeObserver(self, name: AVAudioSession.interruptionNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(interruptedAudio), name: AVAudioSession.interruptionNotification, object: AVAudioSession.sharedInstance())
retryCount = 0
playAudio()
Expand All @@ -25,7 +26,7 @@ class BackgroundTask {
LogManager.shared.log(category: .general, message: "Silent audio stopped", isDebug: true)
}

@objc fileprivate func interruptedAudio(_ notification: Notification) {
@objc private func interruptedAudio(_ notification: Notification) {
guard notification.name == AVAudioSession.interruptionNotification,
let userInfo = notification.userInfo,
let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
Expand Down Expand Up @@ -57,7 +58,7 @@ class BackgroundTask {
}
}

fileprivate func playAudio() {
private func playAudio() {
let attemptDesc = retryCount == 0 ? "initial attempt" : "retry \(retryCount)/\(maxRetries)"
do {
let bundle = Bundle.main.path(forResource: "blank", ofType: "wav")
Expand Down
2 changes: 2 additions & 0 deletions LoopFollow/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.$(unique_id).LoopFollow$(app_suffix)</string>
<string>com.loopfollow.audiorefresh</string>
</array>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
Expand Down Expand Up @@ -87,6 +88,7 @@
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
<string>fetch</string>
<string>processing</string>
<string>bluetooth-central</string>
<string>remote-notification</string>
Expand Down
Loading