diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index a6fd9f2b9..a24679c30 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -11,6 +11,21 @@ class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? let notificationCenter = UNUserNotificationCenter.current() + private func logRemoteCommandNotificationDetails(userInfo: [AnyHashable: Any]) -> Bool { + let commandStatus = userInfo["command_status"] as? String + let commandType = userInfo["command_type"] as? String + + if let commandStatus { + LogManager.shared.log(category: .general, message: "Command status: \(commandStatus)") + } + + if let commandType { + LogManager.shared.log(category: .general, message: "Command type: \(commandType)") + } + + return commandStatus != nil || commandType != nil + } + func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { LogManager.shared.log(category: .general, message: "App started") LogManager.shared.cleanupOldLogs() @@ -96,14 +111,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // 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: .apns, message: "Command status: \(commandStatus)") - } - - if let commandType = userInfo["command_type"] as? String { - LogManager.shared.log(category: .apns, message: "Command type: \(commandType)") - } + _ = logRemoteCommandNotificationDetails(userInfo: userInfo) } } @@ -219,6 +227,11 @@ extension AppDelegate: UNUserNotificationCenterDelegate { let userInfo = notification.request.content.userInfo LogManager.shared.log(category: .general, message: "Will present notification: \(userInfo)") + if logRemoteCommandNotificationDetails(userInfo: userInfo) { + NotificationCenter.default.post(name: .remoteCommandSucceeded, object: nil) + LogManager.shared.log(category: .general, message: "Started remote command polling from foreground result notification") + } + // Show the notification even when app is in foreground completionHandler([.banner, .sound, .badge]) } diff --git a/LoopFollow/Controllers/Nightscout/Treatments.swift b/LoopFollow/Controllers/Nightscout/Treatments.swift index 307a37e79..ccc04eb46 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments.swift @@ -184,5 +184,6 @@ extension MainViewController { } } processCage(entries: pumpSiteChange) + evaluateRemoteCommandPollingCompletion() } } diff --git a/LoopFollow/Remote/RemoteType.swift b/LoopFollow/Remote/RemoteType.swift index 1e4b958dd..bbc7ad8db 100644 --- a/LoopFollow/Remote/RemoteType.swift +++ b/LoopFollow/Remote/RemoteType.swift @@ -9,3 +9,7 @@ enum RemoteType: String, Codable { case trc = "Trio Remote Control" case loopAPNS = "Loop APNS" } + +extension Notification.Name { + static let remoteCommandSucceeded = Notification.Name("remoteCommandSucceeded") +} diff --git a/LoopFollow/Task/TaskScheduler.swift b/LoopFollow/Task/TaskScheduler.swift index a18b92aa3..99573160c 100644 --- a/LoopFollow/Task/TaskScheduler.swift +++ b/LoopFollow/Task/TaskScheduler.swift @@ -12,6 +12,7 @@ enum TaskID: CaseIterable { case minAgoUpdate case calendarWrite case alarmCheck + case remoteCommandPoll } struct ScheduledTask { diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index ac1f19a24..a64ed76f2 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -24,6 +24,39 @@ private struct APNSCredentialSnapshot: Equatable { } class MainViewController: UIViewController, UITableViewDataSource, ChartViewDelegate, UNUserNotificationCenterDelegate, UIScrollViewDelegate { + private struct RemoteCommandDataSignature { + let carbTimestamp: TimeInterval? + let bolusTimestamp: TimeInterval? + let overrideTimestamp: TimeInterval? + let tempTargetTimestamp: TimeInterval? + let overrideStateKey: String + let tempTargetStateKey: String + + func detectsFreshData(comparedTo baseline: RemoteCommandDataSignature) -> Bool { + if let carbTimestamp, carbTimestamp > (baseline.carbTimestamp ?? 0) { + return true + } + + if let bolusTimestamp, bolusTimestamp > (baseline.bolusTimestamp ?? 0) { + return true + } + + if let overrideTimestamp, overrideTimestamp > (baseline.overrideTimestamp ?? 0) { + return true + } + + if let tempTargetTimestamp, tempTargetTimestamp > (baseline.tempTargetTimestamp ?? 0) { + return true + } + + if overrideStateKey != baseline.overrideStateKey { + return true + } + + return tempTargetStateKey != baseline.tempTargetStateKey + } + } + var isPresentedAsModal: Bool = false @IBOutlet var BGText: UILabel! @@ -144,6 +177,10 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele "deviceStatus": false, ] private var loadingTimeoutTimer: Timer? + private var remoteCommandPollingStartedAt: Date? + private var remoteCommandPollingBaseline: RemoteCommandDataSignature? + private let remoteCommandPollingInterval: TimeInterval = 3 + private let remoteCommandPollingDuration: TimeInterval = 30 override func viewDidLoad() { super.viewDidLoad() @@ -228,6 +265,13 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele refreshScrollView.delegate = self NotificationCenter.default.addObserver(self, selector: #selector(refresh), name: NSNotification.Name("refresh"), object: nil) + NotificationCenter.default.publisher(for: .remoteCommandSucceeded) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.startRemoteCommandPolling() + } + .store(in: &cancellables) + Observable.shared.bgText.$value .receive(on: DispatchQueue.main) .sink { [weak self] newValue in @@ -889,6 +933,77 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele refreshControl.endRefreshing() } + private func startRemoteCommandPolling() { + guard IsNightscoutEnabled() else { return } + + remoteCommandPollingBaseline = currentRemoteCommandDataSignature() + remoteCommandPollingStartedAt = Date() + + TaskScheduler.shared.scheduleTask(id: .remoteCommandPoll, nextRun: Date()) { [weak self] in + self?.performRemoteCommandPollingTick() + } + + LogManager.shared.log(category: .general, message: "Started aggressive polling after remote command result notification") + } + + private func stopRemoteCommandPolling(reason: String) { + guard remoteCommandPollingStartedAt != nil else { return } + + remoteCommandPollingStartedAt = nil + remoteCommandPollingBaseline = nil + TaskScheduler.shared.rescheduleTask(id: .remoteCommandPoll, to: .distantFuture) + + LogManager.shared.log(category: .general, message: "Stopped aggressive polling: \(reason)") + } + + private func performRemoteCommandPollingTick() { + guard let remoteCommandPollingStartedAt else { return } + + if Date().timeIntervalSince(remoteCommandPollingStartedAt) >= remoteCommandPollingDuration { + stopRemoteCommandPolling(reason: "timeout reached") + return + } + + let now = Date() + TaskScheduler.shared.rescheduleTask(id: .deviceStatus, to: now) + TaskScheduler.shared.rescheduleTask(id: .treatments, to: now) + + TaskScheduler.shared.rescheduleTask( + id: .remoteCommandPoll, + to: Date().addingTimeInterval(remoteCommandPollingInterval) + ) + } + + private func currentRemoteCommandDataSignature() -> RemoteCommandDataSignature { + let latestBolusTimestamp = max(bolusData.last?.date ?? 0, smbData.last?.date ?? 0) + let overrideNote = Observable.shared.override.value ?? "" + let overrideStateKey = overrideNote + let tempTargetStateKey: String + + if let tempTarget = Observable.shared.tempTarget.value { + tempTargetStateKey = Localizer.formatQuantity(tempTarget) + } else { + tempTargetStateKey = "" + } + + return RemoteCommandDataSignature( + carbTimestamp: carbData.last?.date, + bolusTimestamp: latestBolusTimestamp > 0 ? latestBolusTimestamp : nil, + overrideTimestamp: overrideGraphData.last?.date, + tempTargetTimestamp: tempTargetGraphData.last?.date, + overrideStateKey: overrideStateKey, + tempTargetStateKey: tempTargetStateKey + ) + } + + func evaluateRemoteCommandPollingCompletion() { + guard let remoteCommandPollingBaseline else { return } + + if currentRemoteCommandDataSignature().detectsFreshData(comparedTo: remoteCommandPollingBaseline) { + stopRemoteCommandPolling(reason: "fresh remote data received") + } + } + // Scroll down BGText when refreshing func scrollViewDidScroll(_ scrollView: UIScrollView) { if scrollView == refreshScrollView {