From 860eaf55075083b65a3657d1c6dd080234166cc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 25 Oct 2026 12:00:08 +0100 Subject: [PATCH 1/9] Add LF Hub: follow a family's push server for badge, Live Activity, alarms rung as system alarms by a notification service extension, and Speak BG, with invites, roles and a fallback to alarming in the app --- HubShared/BGAnnouncement.swift | 98 ++ HubShared/HubAlarmKit.swift | 169 ++ HubShared/HubAppGroup.swift | 28 + LoopFollow.xcodeproj/project.pbxproj | 183 +++ LoopFollow/Alarm/AlarmManager.swift | 44 +- LoopFollow/Alarm/AlarmSettingsView.swift | 8 +- LoopFollow/Application/AppDelegate.swift | 37 +- .../BackgroundRefresh/BT/BLEManager.swift | 2 +- .../BackgroundRefreshSettingsView.swift | 13 +- .../BackgroundRefreshSettingsViewModel.swift | 46 +- .../BackgroundRefreshType.swift | 27 +- LoopFollow/Controllers/AlarmSound.swift | 2 +- .../Controllers/BackgroundAlertManager.swift | 2 +- .../Controllers/Nightscout/BGData.swift | 6 +- LoopFollow/Controllers/Nightscout/CAge.swift | 2 +- .../Controllers/Nightscout/DBSize.swift | 2 +- .../Controllers/Nightscout/DeviceStatus.swift | 4 +- LoopFollow/Controllers/Nightscout/IAge.swift | 2 +- .../Nightscout/NightscoutSocketManager.swift | 3 +- .../Controllers/Nightscout/Profile.swift | 2 +- LoopFollow/Controllers/Nightscout/SAge.swift | 2 +- .../Controllers/Nightscout/Treatments.swift | 2 +- LoopFollow/Controllers/SpeakBG.swift | 83 +- .../Helpers/BackgroundRefreshManager.swift | 33 +- LoopFollow/Helpers/NightscoutUtils.swift | 70 +- LoopFollow/Helpers/Telemetry.swift | 17 +- LoopFollow/Hub/HubAlarmSounds.swift | 101 ++ LoopFollow/Hub/HubBundle.swift | 198 +++ LoopFollow/Hub/HubChecklist.swift | 306 ++++ LoopFollow/Hub/HubClient.swift | 1387 +++++++++++++++++ LoopFollow/Hub/HubDeviceKey.swift | 152 ++ LoopFollow/Hub/HubManagedSettings.swift | 101 ++ LoopFollow/Hub/HubModeLimits.swift | 40 + LoopFollow/Hub/HubSettingsView.swift | 681 ++++++++ LoopFollow/Hub/HubSourceSync.swift | 307 ++++ LoopFollow/Hub/HubWatchdog.swift | 134 ++ LoopFollow/Info.plist | 2 + .../LiveActivity/LiveActivityManager.swift | 35 +- LoopFollow/LiveActivitySettingsView.swift | 3 + LoopFollow/Log/LogManager.swift | 1 + .../Nightscout/NightscoutSettingsView.swift | 6 +- .../Onboarding/OnboardingContainerView.swift | 4 + LoopFollow/Onboarding/OnboardingStep.swift | 7 +- .../Onboarding/OnboardingViewModel.swift | 25 +- .../Steps/DataSourceChoiceStepView.swift | 8 + .../Onboarding/Steps/HubJoinStepView.swift | 298 ++++ .../Steps/HubPermissionsStepView.swift | 70 + LoopFollow/Settings/APNSettingsView.swift | 94 +- .../Settings/CalendarSettingsView.swift | 10 +- LoopFollow/Settings/ContactSettingsView.swift | 7 + LoopFollow/Settings/DexcomSettingsView.swift | 56 +- .../Settings/DexcomSettingsViewModel.swift | 71 +- LoopFollow/Settings/GeneralSettingsView.swift | 13 +- LoopFollow/Settings/SettingsMenuView.swift | 15 +- LoopFollow/Storage/Storage.swift | 4 + LoopFollow/Task/BGTask.swift | 18 + LoopFollow/Task/CalendarTask.swift | 4 +- LoopFollow/Task/TaskScheduler.swift | 2 +- .../ViewControllers/MainViewController.swift | 84 +- LoopFollow/ViewControllers/MoreMenuView.swift | 5 +- .../LoopFollowLiveActivity.swift | 22 +- LoopFollowNotificationService/Info.plist | 35 + ...LoopFollowNotificationService.entitlements | 10 + .../NotificationService.swift | 297 ++++ Tests/HubTests.swift | 1083 +++++++++++++ fastlane/Fastfile | 99 +- 66 files changed, 6375 insertions(+), 307 deletions(-) create mode 100644 HubShared/BGAnnouncement.swift create mode 100644 HubShared/HubAlarmKit.swift create mode 100644 HubShared/HubAppGroup.swift create mode 100644 LoopFollow/Hub/HubAlarmSounds.swift create mode 100644 LoopFollow/Hub/HubBundle.swift create mode 100644 LoopFollow/Hub/HubChecklist.swift create mode 100644 LoopFollow/Hub/HubClient.swift create mode 100644 LoopFollow/Hub/HubDeviceKey.swift create mode 100644 LoopFollow/Hub/HubManagedSettings.swift create mode 100644 LoopFollow/Hub/HubModeLimits.swift create mode 100644 LoopFollow/Hub/HubSettingsView.swift create mode 100644 LoopFollow/Hub/HubSourceSync.swift create mode 100644 LoopFollow/Hub/HubWatchdog.swift create mode 100644 LoopFollow/Onboarding/Steps/HubJoinStepView.swift create mode 100644 LoopFollow/Onboarding/Steps/HubPermissionsStepView.swift create mode 100644 LoopFollowNotificationService/Info.plist create mode 100644 LoopFollowNotificationService/LoopFollowNotificationService.entitlements create mode 100644 LoopFollowNotificationService/NotificationService.swift create mode 100644 Tests/HubTests.swift diff --git a/HubShared/BGAnnouncement.swift b/HubShared/BGAnnouncement.swift new file mode 100644 index 000000000..e2c298008 --- /dev/null +++ b/HubShared/BGAnnouncement.swift @@ -0,0 +1,98 @@ +// LoopFollow +// BGAnnouncement.swift + +// Compiled into the app and into LoopFollowNotificationService: the app speaks it +// for Speak BG, the extension renders it for the hub's speech push. + +import Foundation + +/// The sentence Speak BG says for a new reading, in the chosen language. +enum BGAnnouncement { + struct Texts { + var stable: String + var increase: String + var decrease: String + var currentBGIs: String + + static func forLanguage(_ language: String) -> Texts { + switch language { + case "fr": + return Texts( + stable: "et c'est stable", + increase: "et c'est monté de", + decrease: "et c'est descendu de", + currentBGIs: "La glycémie est" + ) + case "it": + return Texts( + stable: "ed è stabile", + increase: "ed è salita di", + decrease: "ed è scesa di", + currentBGIs: "Glicemia attuale è" + ) + case "sk": + return Texts( + stable: "a je stabilná", + increase: "a stúpla o", + decrease: "a klesla o", + currentBGIs: "Aktuálna glykémia je" + ) + case "sv": + return Texts( + stable: "och det är stabilt", + increase: "och det har ökat med", + decrease: "och det har minskat med", + currentBGIs: "Blodsockret är" + ) + case "en": fallthrough + default: + return Texts( + stable: "and it is stable", + increase: "and it is up", + decrease: "and it is down", + currentBGIs: "Glucose is" + ) + } + } + } + + static let voiceLanguageMap: [String: String] = [ + "en": "en-US", + "fr": "fr-FR", + "it": "it-IT", + "sk": "sk-SK", + "sv": "sv-SE", + ] + + static func voiceLanguageCode(forAppLanguage appLanguage: String) -> String { + voiceLanguageMap[appLanguage, default: "en-US"] + } + + /// A change of this many mg/dL or less is spoken as stable. + static let negligibleChange = 3 + + /// The announcement for `current` after `previous` (mg/dL), with values in the + /// display unit and a period as the decimal mark, which every voice reads as one. + static func text(current: Int, previous: Int, language: String, mmol: Bool, + locale: Locale = .current) -> String + { + let texts = Texts.forLanguage(language) + let difference = current - previous + let currentText = format(current, mmol: mmol, locale: locale) + if abs(difference) <= negligibleChange { + return "\(texts.currentBGIs) \(currentText) \(texts.stable)" + } + let direction = difference < 0 ? texts.decrease : texts.increase + return "\(texts.currentBGIs) \(currentText) \(direction) \(format(abs(difference), mmol: mmol, locale: locale))" + } + + static func format(_ mgdl: Int, mmol: Bool, locale: Locale) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.locale = locale + formatter.minimumFractionDigits = mmol ? 1 : 0 + formatter.maximumFractionDigits = mmol ? 1 : 0 + let value = mmol ? Double(mgdl) * GlucoseConversion.mgDlToMmolL : Double(mgdl) + return (formatter.string(from: NSNumber(value: value)) ?? String(value)).replacingOccurrences(of: ",", with: ".") + } +} diff --git a/HubShared/HubAlarmKit.swift b/HubShared/HubAlarmKit.swift new file mode 100644 index 000000000..f7425b8d3 --- /dev/null +++ b/HubShared/HubAlarmKit.swift @@ -0,0 +1,169 @@ +// LoopFollow +// HubAlarmKit.swift + +// Compiled into the app and into LoopFollowNotificationService only: the extension +// rings hub alarms through it; the app stops them, handles their snooze intent and +// rings its own notices. + +import Foundation + +#if canImport(AlarmKit) && !targetEnvironment(macCatalyst) + import ActivityKit + import AlarmKit + import AppIntents + import struct SwiftUI.Color + + /// Rings an alarm LF Hub delivered as an AlarmKit alarm, which breaks through + /// the silent switch and Focus. The notification service extension calls this + /// for every hub alarm push, so it rings even while LoopFollow is suspended + /// or not running. The AlarmKit alarm takes the LoopFollow alarm's id, so a + /// repeated push for an alarm that is still ringing does not ring twice. + @available(iOS 26, *) + enum HubAlarmKit { + /// A fixed alarm at or before now never fires, so it is set this far ahead. + private static let immediateFireDelay: TimeInterval = 2 + + static var isAuthorized: Bool { + AlarmKit.AlarmManager.shared.authorizationState == .authorized + } + + /// The user has not been asked yet; once asked, only the Settings app changes it. + static var canAsk: Bool { + AlarmKit.AlarmManager.shared.authorizationState == .notDetermined + } + + static func requestAuthorization() async -> Bool { + if AlarmKit.AlarmManager.shared.authorizationState == .notDetermined { + _ = try? await AlarmKit.AlarmManager.shared.requestAuthorization() + } + return isAuthorized + } + + /// Whether the alarm is ringing or about to, when it starts if this call scheduled it, + /// and why. Not ringing when AlarmKit is not authorized or scheduling failed, so the + /// notification keeps its sound. + /// `symbol` is the SF Symbol on the alarm's button: the alarm type's icon. + static func ring(alarmID: UUID, title: String, soundName: String?, + symbol: String = "moon.zzz") async -> (ringing: Bool, firesAt: Date?, detail: String) + { + // A denied state is final; notDetermined is what an extension reads even + // after the app was authorized, so scheduling is attempted then too. + let state = AlarmKit.AlarmManager.shared.authorizationState + guard state != .denied else { return (false, nil, "authorization denied") } + if ((try? AlarmKit.AlarmManager.shared.alarms) ?? []).contains(where: { $0.id == alarmID }) { + return (true, nil, "already scheduled") + } + + let presentation = AlarmPresentation( + alert: AlarmPresentation.Alert( + title: LocalizedStringResource(stringLiteral: title), + stopButton: AlarmButton( + text: LocalizedStringResource(stringLiteral: "Snooze"), + textColor: .white, + systemImageName: symbol + ) + ) + ) + let firesAt = Date().addingTimeInterval(immediateFireDelay) + let configuration = AlarmKit.AlarmManager.AlarmConfiguration.alarm( + schedule: .fixed(firesAt), + attributes: AlarmAttributes(presentation: presentation, tintColor: .red), + stopIntent: SnoozeHubAlarmIntent(alarmID: alarmID), + sound: soundName.map { .named($0) } ?? .default + ) + do { + let alarm = try await AlarmKit.AlarmManager.shared.schedule(id: alarmID, configuration: configuration) + return (true, firesAt, "scheduled, state \(alarm.state) (authorization read \(state))") + } catch { + return (false, nil, "schedule failed with authorization \(state): \(error)") + } + } + + /// Rings a notice from the app itself (not a LoopFollow alarm) as a system + /// alarm now, with an OK button and no snooze. Replaces a notice with the same id. + static func ringNotice(id: UUID, title: String, symbol: String) async -> Bool { + guard AlarmKit.AlarmManager.shared.authorizationState == .authorized else { return false } + stop(alarmID: id) + let presentation = AlarmPresentation( + alert: AlarmPresentation.Alert( + title: LocalizedStringResource(stringLiteral: title), + stopButton: AlarmButton( + text: LocalizedStringResource(stringLiteral: "OK"), + textColor: .white, + systemImageName: symbol + ) + ) + ) + let configuration = AlarmKit.AlarmManager.AlarmConfiguration.alarm( + schedule: .fixed(Date().addingTimeInterval(immediateFireDelay)), + attributes: AlarmAttributes(presentation: presentation, tintColor: .orange), + sound: .default + ) + return (try? await AlarmKit.AlarmManager.shared.schedule(id: id, configuration: configuration)) != nil + } + + /// Whether the alarm is sounding now. + static func isAlerting(alarmID: UUID) -> Bool { + ((try? AlarmKit.AlarmManager.shared.alarms) ?? []).contains { $0.id == alarmID && $0.state == .alerting } + } + + static func stop(alarmID: UUID) { + try? AlarmKit.AlarmManager.shared.stop(id: alarmID) + try? AlarmKit.AlarmManager.shared.cancel(id: alarmID) + } + } + + @available(iOS 26, *) + struct HubAlarmMetadata: AlarmMetadata {} + + /// Runs in the app when the AlarmKit alarm's button is tapped, and snoozes + /// the LoopFollow alarm the way the notification's snooze action does. + @available(iOS 26, *) + struct SnoozeHubAlarmIntent: LiveActivityIntent { + static let title: LocalizedStringResource = "Snooze LoopFollow Alarm" + static let isDiscoverable = false + + @Parameter(title: "Alarm ID") var alarmID: String + + init() {} + + init(alarmID: UUID) { + self.alarmID = alarmID.uuidString + } + + func perform() async throws -> some IntentResult { + if let id = UUID(uuidString: alarmID) { + await HubAlarmSnoozeBridge.shared.snooze(id) + } + return .result() + } + } +#endif + +/// Hands an AlarmKit snooze to the app's alarm handling. Taps that arrive +/// before the app registers its handler are queued until it does. +@MainActor +final class HubAlarmSnoozeBridge { + static let shared = HubAlarmSnoozeBridge() + private init() {} + + private var handler: (@MainActor (UUID) async -> Void)? + private var pending: [UUID] = [] + + func setHandler(_ handler: @escaping @MainActor (UUID) async -> Void) { + self.handler = handler + let queued = pending + pending = [] + Task { for id in queued { + await handler(id) + } } + } + + func snooze(_ id: UUID) async { + if let handler { + await handler(id) + } else { + pending.append(id) + } + } +} diff --git a/HubShared/HubAppGroup.swift b/HubShared/HubAppGroup.swift new file mode 100644 index 000000000..793812d26 --- /dev/null +++ b/HubShared/HubAppGroup.swift @@ -0,0 +1,28 @@ +// LoopFollow +// HubAppGroup.swift + +/// What the app shares with LoopFollowNotificationService through the App Group's +/// defaults. The device key is not shared: the extension's one hub call, the +/// delivery receipt, needs no key. +enum HubAppGroup { + /// The hub's address while the app is connected to a hub; absent otherwise. + static let hubURLKey = "lfhubURL" + + /// How the extension delivers a push from the hub. + enum Delivery: Equatable { + /// As a system alarm, when the hub asks for one. + case systemAlarm + /// As the notification the hub sent, with its sound. + case notification + /// Quietly to the notification list, with no sound: this phone has left the hub, + /// so nothing it cannot silence rings, and the push is still kept. + case quiet + } + + /// A push reaching a phone that is not connected to a hub (`hubURL` absent) is + /// delivered quietly. + static func delivery(hubURL: String?, systemAlarm: Bool) -> Delivery { + guard let hubURL, !hubURL.isEmpty else { return .quiet } + return systemAlarm ? .systemAlarm : .notification + } +} diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 560cd2f8e..73c50dccb 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -17,6 +17,7 @@ DDF2C0142BEFD468007A20E6 /* blacklisted-versions.json in Resources */ = {isa = PBXBuildFile; fileRef = DDF2C0132BEFD468007A20E6 /* blacklisted-versions.json */; }; FC5A5C3D2497B229009C550E /* Config.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = FC5A5C3C2497B229009C550E /* Config.xcconfig */; }; FCFEEC9E2486E68E00402A7F /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FCFEEC9D2486E68E00402A7F /* WebKit.framework */; }; + 5D69EE5A57B7707A4CC26B71 /* LoopFollowNotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 596074433E79A175BE10D73D /* LoopFollowNotificationService.appex */; platformFilter = ios; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -34,6 +35,13 @@ remoteGlobalIDString = FC9788132485969B00A7906C; remoteInfo = LoopFollow; }; + DAB89B42788ADE45065F521D /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = FC97880C2485969B00A7906C /* Project object */; + proxyType = 1; + remoteGlobalIDString = EEEA50EDC79B8FCDBBED8FB6; + remoteInfo = LoopFollowNotificationService; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -44,6 +52,7 @@ dstSubfolderSpec = 13; files = ( 37A4BDE82F5B6B4C00EEB289 /* LoopFollowLAExtensionExtension.appex in Embed Foundation Extensions */, + 5D69EE5A57B7707A4CC26B71 /* LoopFollowNotificationService.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; @@ -68,6 +77,7 @@ FC9788142485969B00A7906C /* Loop Follow.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Loop Follow.app"; sourceTree = BUILT_PRODUCTS_DIR; }; FCE537C2249AAB2600F80BF8 /* NotificationCenter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = NotificationCenter.framework; path = System/Library/Frameworks/NotificationCenter.framework; sourceTree = SDKROOT; }; FCFEEC9D2486E68E00402A7F /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; }; + 596074433E79A175BE10D73D /* LoopFollowNotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = LoopFollowNotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -78,6 +88,14 @@ ); target = FC9788132485969B00A7906C /* LoopFollow */; }; + 6A4F9913224EE1D80273A65D /* Exceptions for "LoopFollowNotificationService" folder in "LoopFollowNotificationService" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + LoopFollowNotificationService.entitlements, + ); + target = EEEA50EDC79B8FCDBBED8FB6 /* LoopFollowNotificationService */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -118,6 +136,29 @@ path = Tests; sourceTree = ""; }; + AA1DB0AA90993486408F9C96 /* LoopFollowNotificationService */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 6A4F9913224EE1D80273A65D /* Exceptions for "LoopFollowNotificationService" folder in "LoopFollowNotificationService" target */, + ); + explicitFileTypes = { + }; + explicitFolders = ( + ); + path = LoopFollowNotificationService; + sourceTree = ""; + }; + 63C6AEAB74B2AAFE8858AD73 /* HubShared */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); + explicitFileTypes = { + }; + explicitFolders = ( + ); + path = HubShared; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -147,6 +188,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 2C03EEFB64198A86B554FA76 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -191,11 +239,13 @@ FC5A5C3C2497B229009C550E /* Config.xcconfig */, DDCC3AD72DDE1790006F1C10 /* Tests */, 37A4BDDE2F5B6B4A00EEB289 /* LoopFollowLAExtension */, + AA1DB0AA90993486408F9C96 /* LoopFollowNotificationService */, FC9788152485969B00A7906C /* Products */, 8E32230C453C93FDCE59C2B9 /* Pods */, 6A5880E0B811AF443B05AB02 /* Frameworks */, 9555FE45F7FAEFA6142D04CA /* LoopFollow */, BC0FBFD3754955D028EF6631 /* Shared */, + 63C6AEAB74B2AAFE8858AD73 /* HubShared */, ); sourceTree = ""; }; @@ -205,6 +255,7 @@ FC9788142485969B00A7906C /* Loop Follow.app */, DDCC3AD62DDE1790006F1C10 /* Tests.xctest */, 37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */, + 596074433E79A175BE10D73D /* LoopFollowNotificationService.appex */, ); name = Products; sourceTree = ""; @@ -271,10 +322,12 @@ ); dependencies = ( 37A4BDE72F5B6B4C00EEB289 /* PBXTargetDependency */, + 543F5550B040328239BAB8F8 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 9555FE45F7FAEFA6142D04CA /* LoopFollow */, BC0FBFD3754955D028EF6631 /* Shared */, + 63C6AEAB74B2AAFE8858AD73 /* HubShared */, ); name = LoopFollow; packageProductDependencies = ( @@ -284,6 +337,28 @@ productReference = FC9788142485969B00A7906C /* Loop Follow.app */; productType = "com.apple.product-type.application"; }; + EEEA50EDC79B8FCDBBED8FB6 /* LoopFollowNotificationService */ = { + isa = PBXNativeTarget; + buildConfigurationList = 42916AF8702E56F154251F93 /* Build configuration list for PBXNativeTarget "LoopFollowNotificationService" */; + buildPhases = ( + 82884377624835ABEDB5489D /* Sources */, + 2C03EEFB64198A86B554FA76 /* Frameworks */, + EC4E6C033A4E09C500F4D170 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + AA1DB0AA90993486408F9C96 /* LoopFollowNotificationService */, + 63C6AEAB74B2AAFE8858AD73 /* HubShared */, + BC0FBFD3754955D028EF6631 /* Shared */, + ); + name = LoopFollowNotificationService; + productName = LoopFollowNotificationService; + productReference = 596074433E79A175BE10D73D /* LoopFollowNotificationService.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -297,6 +372,9 @@ 37A4BDD82F5B6B4A00EEB289 = { CreatedOnToolsVersion = 26.2; }; + EEEA50EDC79B8FCDBBED8FB6 = { + CreatedOnToolsVersion = 27.0; + }; DDCC3AD52DDE1790006F1C10 = { CreatedOnToolsVersion = 16.3; TestTargetID = FC9788132485969B00A7906C; @@ -325,6 +403,7 @@ FC9788132485969B00A7906C /* LoopFollow */, DDCC3AD52DDE1790006F1C10 /* Tests */, 37A4BDD82F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension */, + EEEA50EDC79B8FCDBBED8FB6 /* LoopFollowNotificationService */, ); }; /* End PBXProject section */ @@ -355,6 +434,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + EC4E6C033A4E09C500F4D170 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -462,6 +548,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 82884377624835ABEDB5489D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -476,6 +569,12 @@ target = FC9788132485969B00A7906C /* LoopFollow */; targetProxy = DDCC3ADA2DDE1790006F1C10 /* PBXContainerItemProxy */; }; + 543F5550B040328239BAB8F8 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + platformFilter = ios; + target = EEEA50EDC79B8FCDBBED8FB6 /* LoopFollowNotificationService */; + targetProxy = DAB89B42788ADE45065F521D /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -813,6 +912,81 @@ }; name = Release; }; + A3D75A5E85326D3CE53B355B /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CODE_SIGN_ENTITLEMENTS = LoopFollowNotificationService/LoopFollowNotificationService.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = LoopFollowNotificationService/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = "$(MARKETING_VERSION)"; + PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix).LoopFollowNotificationService"; + PRODUCT_MODULE_NAME = LoopFollowNotificationService; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 7CD558935EC15EB016317671 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CODE_SIGN_ENTITLEMENTS = LoopFollowNotificationService/LoopFollowNotificationService.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = LoopFollowNotificationService/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = "$(MARKETING_VERSION)"; + PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix).LoopFollowNotificationService"; + PRODUCT_MODULE_NAME = LoopFollowNotificationService; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -852,6 +1026,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 42916AF8702E56F154251F93 /* Build configuration list for PBXNativeTarget "LoopFollowNotificationService" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A3D75A5E85326D3CE53B355B /* Debug */, + 7CD558935EC15EB016317671 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ diff --git a/LoopFollow/Alarm/AlarmManager.swift b/LoopFollow/Alarm/AlarmManager.swift index b0ee65912..45c680d7c 100644 --- a/LoopFollow/Alarm/AlarmManager.swift +++ b/LoopFollow/Alarm/AlarmManager.swift @@ -56,6 +56,20 @@ class AlarmManager { return } + // LF Hub evaluates and delivers the alarms of the types it checks while it drives + // them; this app checks the rest. A sound this app started for an alarm the hub + // checks stops; the hub's alarm rings instead. + var alarms = Storage.shared.alarms.value + let hubDrives = HubClient.shared.drivesAlarms + if hubDrives { + alarms = HubClient.shared.alarmsCheckedHere(alarms) + let soundsHere = Observable.shared.currentAlarm.value.map { id in alarms.contains { $0.id == id } } ?? false + if Observable.shared.alarmSoundPlaying.value, !soundsHere { + AlarmSound.stop() + } + if alarms.isEmpty { return } + } + let config = Storage.shared.alarmConfiguration.value // Honor the "Snooze All" setting. If active, stop any current alarm and exit. @@ -66,8 +80,6 @@ class AlarmManager { return } - let alarms = Storage.shared.alarms.value - let sorted = alarms.sorted(by: Alarm.byPriorityThenSpec) var skipType: AlarmType? @@ -159,7 +171,7 @@ class AlarmManager { break } - if isLatestReadingRecent, Storage.shared.persistentNotification.value, !alarmTriggered, let latestDate = data.bgReadings.last?.date, latestDate > Storage.shared.persistentNotificationLastBGTime.value { + if !hubDrives, isLatestReadingRecent, Storage.shared.persistentNotification.value, !alarmTriggered, let latestDate = data.bgReadings.last?.date, latestDate > Storage.shared.persistentNotificationLastBGTime.value { sendNotification(title: "Latest BG") Storage.shared.persistentNotificationLastBGTime.value = now } @@ -182,6 +194,13 @@ class AlarmManager { } func stopAlarm() { + // A hub-delivered alarm also rings as a system alarm and repeats at the hub. + if let alarmID = Observable.shared.currentAlarm.value, HubClient.shared.isHubMode { + #if !targetEnvironment(macCatalyst) + if #available(iOS 26, *) { HubAlarmKit.stop(alarmID: alarmID) } + #endif + HubClient.shared.alarmHandled(alarmID) + } AlarmSound.stop() Observable.shared.currentAlarm.value = nil UNUserNotificationCenter.current().removeAllPendingNotificationRequests() @@ -206,9 +225,22 @@ class AlarmManager { UNUserNotificationCenter.current().add(request, withCompletionHandler: nil) if let actionTitle = actionTitle { - let action = UNNotificationAction(identifier: "snooze", title: actionTitle, options: []) - let category = UNNotificationCategory(identifier: "category", actions: [action], intentIdentifiers: [], options: []) - UNUserNotificationCenter.current().setNotificationCategories([category]) + Self.registerNotificationCategories(snoozeTitle: actionTitle) } } + + /// Registers every notification category the app's notifications use; each call + /// replaces the whole set. "category" carries the snooze action of the app's and + /// LF Hub's alarms, titled for the latest alarm; "lfhub-ack" carries the Acknowledge + /// action of LF Hub's alarms whose snooze duration is 0. + static func registerNotificationCategories(snoozeTitle: String = "Snooze") { + let openApp = UNNotificationAction(identifier: "OPEN_APP_ACTION", title: "Open App", options: .foreground) + let snooze = UNNotificationAction(identifier: "snooze", title: snoozeTitle, options: []) + let acknowledge = UNNotificationAction(identifier: "snooze", title: "Acknowledge", options: []) + UNUserNotificationCenter.current().setNotificationCategories([ + UNNotificationCategory(identifier: BackgroundAlertIdentifier.categoryIdentifier, actions: [openApp], intentIdentifiers: [], options: []), + UNNotificationCategory(identifier: "category", actions: [snooze], intentIdentifiers: [], options: []), + UNNotificationCategory(identifier: "lfhub-ack", actions: [acknowledge], intentIdentifiers: [], options: []), + ]) + } } diff --git a/LoopFollow/Alarm/AlarmSettingsView.swift b/LoopFollow/Alarm/AlarmSettingsView.swift index 3f328bfa4..c45e9536f 100644 --- a/LoopFollow/Alarm/AlarmSettingsView.swift +++ b/LoopFollow/Alarm/AlarmSettingsView.swift @@ -139,7 +139,7 @@ struct AlarmSettingsView: View { .datePickerStyle(.compact) } - Section(header: Text("Alarm Settings")) { + Section(header: Text("Alarm Settings"), footer: hubFooter) { Toggle( "Override System Volume", isOn: Binding( @@ -197,4 +197,10 @@ struct AlarmSettingsView: View { .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) .navigationBarTitle("Alarm Settings", displayMode: .inline) } + + @ViewBuilder private var hubFooter: some View { + if HubClient.shared.isHubMode { + Text("In LoopFollow Hub mode, stop or snooze alarms with the alarm's own buttons; the volume buttons may not snooze them.") + } + } } diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index dd67f8801..0a710b27a 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -12,6 +12,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate { LogManager.shared.log(category: .general, message: "App started") LogManager.shared.cleanupOldLogs() ExitDiagnostics.shared.start() + HubClient.shared.observeChanges() + HubAlarmSnoozeBridge.shared.setHandler { @MainActor alarmID in + guard Storage.shared.alarms.value.contains(where: { $0.id == alarmID }) else { return } + Observable.shared.currentAlarm.value = alarmID + AlarmManager.shared.performSnooze() + } // Notification and calendar permissions are no longer requested here. // They're deferred to the moment the user opts into the feature that @@ -50,9 +56,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } } - let action = UNNotificationAction(identifier: "OPEN_APP_ACTION", title: "Open App", options: .foreground) - let category = UNNotificationCategory(identifier: BackgroundAlertIdentifier.categoryIdentifier, actions: [action], intentIdentifiers: [], options: []) - UNUserNotificationCenter.current().setNotificationCategories([category]) + AlarmManager.registerNotificationCategories() UNUserNotificationCenter.current().delegate = self @@ -123,6 +127,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { let tokenString = deviceToken.map { String(format: "%02.2hhx", $0) }.joined() Observable.shared.loopFollowDeviceToken.value = tokenString + Task { await HubClient.shared.sync() } LogManager.shared.log(category: .apns, message: "Successfully registered for remote notifications with token: \(LogRedactor.tail(tokenString))") } @@ -138,6 +143,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate { LogManager.shared.log(category: .apns, message: "Received remote notification: keys=\(userInfoKeys)") RemoteCommandTracker.shared.handleNotification(userInfo: userInfo) + if let sent = (userInfo["lfhubHeartbeat"] as? NSNumber)?.doubleValue { + let delay = Int(Date().timeIntervalSince1970 - sent) + LogManager.shared.log(category: .hub, message: "Hub heartbeat arrived \(delay) s after it was sent (app state \(UIApplication.shared.applicationState.rawValue))") + } + // 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) @@ -194,6 +204,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate { Observable.shared.selectedTabIndex.value = 0 } + if let hubAlarm = response.notification.request.content.userInfo["lfhubAlarmId"] as? String, + let alarmID = UUID(uuidString: hubAlarm) + { + HubClient.shared.adoptHubAlarm(alarmID) + } + if response.actionIdentifier == "snooze" { AlarmManager.shared.performSnooze() } @@ -279,6 +295,11 @@ extension AppDelegate: UNUserNotificationCenterDelegate { message: "Will present notification: keys=\(userInfoKeys), interruption=\(content.interruptionLevel.rawValue), title=\(content.title.isEmpty ? "empty" : "set"), body=\(content.body.isEmpty ? "empty" : "set")" ) + // The hub stopped an alarm: whether it still counts as sounding is the hub's to say. + if content.userInfo["lfhubStopAlarmId"] != nil { + Task { await HubClient.shared.refreshStatus() } + } + // Suppress notifications iOS routes here that we never intended to surface: // the Live Activity push-to-start uses interruption-level: passive with empty // title/body and must not produce a banner or sound when LF is foregrounded. @@ -287,6 +308,16 @@ extension AppDelegate: UNUserNotificationCenterDelegate { return } + if let hubAlarm = content.userInfo["lfhubAlarmId"] as? String, let alarmID = UUID(uuidString: hubAlarm) { + HubClient.shared.adoptHubAlarm(alarmID) + } + + // The hub's Speak BG: the app on screen shows the reading, so only the speech plays. + if content.userInfo["lfhubSpeak"] != nil { + completionHandler([.sound, .list]) + return + } + completionHandler([.banner, .sound, .badge]) } } diff --git a/LoopFollow/BackgroundRefresh/BT/BLEManager.swift b/LoopFollow/BackgroundRefresh/BT/BLEManager.swift index e16b344de..b6087b30d 100644 --- a/LoopFollow/BackgroundRefresh/BT/BLEManager.swift +++ b/LoopFollow/BackgroundRefresh/BT/BLEManager.swift @@ -114,7 +114,7 @@ class BLEManager: NSObject, ObservableObject { case .omnipodDash: activeDevice = OmnipodDashHeartbeatBluetoothTransmitter(address: device.id.uuidString, name: device.name, bluetoothDeviceDelegate: self) activeDevice?.connect() - case .silentTune, .none: + case .silentTune, .none, .hub: return } } else { diff --git a/LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsView.swift b/LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsView.swift index b03a67e68..e8edd0a9a 100644 --- a/LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsView.swift +++ b/LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsView.swift @@ -34,12 +34,18 @@ struct BackgroundRefreshSettingsView: View { private var refreshTypeSection: some View { Section { Picker("Background Refresh Type", selection: $viewModel.backgroundRefreshType) { - ForEach(BackgroundRefreshType.allCases, id: \.self) { type in + // LoopFollow Hub is offered on iOS 26 or later once the app is connected to a hub. + ForEach(BackgroundRefreshType.allCases.filter { + $0 != .hub || HubClient.isSupported + && (HubClient.shared.isConnected || viewModel.backgroundRefreshType == .hub) + }, id: \.self) { type in Text(type.rawValue).tag(type) } } .pickerStyle(MenuPickerStyle()) + HubModeWarning() + VStack(alignment: .leading, spacing: 4) { Text("Adjust the background refresh type.") .font(.footnote) @@ -51,6 +57,11 @@ struct BackgroundRefreshSettingsView: View { .font(.footnote) .foregroundColor(.secondary) + case .hub: + Text("LF Hub sends the alarms, badge and Live Activity, so LoopFollow can stay closed. Alarms depend on the network and Apple's push service. Until the hub confirms, LoopFollow runs as with Silent Tune. If the hub stops working, LoopFollow alarms by itself only when iOS lets it run, which can take a long time while the phone is locked. Requires a hub under Settings → LF Hub.") + .font(.footnote) + .foregroundColor(.secondary) + case .silentTune: Text("A silent tune will play in the background, keeping the app active. May be interrupted by other apps. Allows continuous updates but consumes more battery.") .font(.footnote) diff --git a/LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsViewModel.swift b/LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsViewModel.swift index 1bf23fe0c..ebd986f1e 100644 --- a/LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsViewModel.swift +++ b/LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsViewModel.swift @@ -21,16 +21,26 @@ class BackgroundRefreshSettingsViewModel: ObservableObject { $backgroundRefreshType .dropFirst() // Ignore the initial emission during setup .sink { [weak self] newValue in - guard let self = self else { return } - self.handleBackgroundRefreshTypeChange(newValue) + guard let self = self, newValue != self.storage.backgroundRefreshType.value else { return } + Self.change(to: newValue) + } + .store(in: &cancellables) - // Persist the change - self.storage.backgroundRefreshType.value = newValue + // A change made elsewhere (leaving Hub mode on disconnect) shows here too. + storage.backgroundRefreshType.$value + .dropFirst() + .receive(on: DispatchQueue.main) + .sink { [weak self] value in + guard let self = self, self.backgroundRefreshType != value else { return } + self.backgroundRefreshType = value } .store(in: &cancellables) } - private func handleBackgroundRefreshTypeChange(_ newValue: BackgroundRefreshType) { + /// Switches the Background Refresh mode: the one path for every change, from + /// this screen or from LF Hub. + static func change(to newValue: BackgroundRefreshType) { + let storage = Storage.shared LogManager.shared.log(category: .general, message: "Background refresh type changed to: \(newValue.rawValue)") // Touch BLEManager only when switching to a Bluetooth mode (the user is @@ -40,5 +50,31 @@ class BackgroundRefreshSettingsViewModel: ObservableObject { if newValue.isBluetooth || BLEManager.isInitialized { BLEManager.shared.disconnect() } + + // Entering or leaving Hub mode changes what the hub drives. Hub alarms + // ring as system alarms only with AlarmKit allowed. The app keeps alarming + // itself until the hub confirms. Storage still holds the mode being left. + let hubModeChanges = newValue == .hub || storage.backgroundRefreshType.value == .hub + if hubModeChanges, HubClient.shared.isConnected { + HubClient.shared.hubSettingsPending.value = true + } + if newValue == .hub { HubClient.shared.hubConfirmed.value = false } + + // Persist the change + storage.backgroundRefreshType.value = newValue + + MainViewController.shared?.backgroundModeChanged() + if storage.backgroundRefreshType.value != .hub { HubBundle.shared.stopListening() } + if hubModeChanges { + Task { + #if !targetEnvironment(macCatalyst) + if newValue == .hub, #available(iOS 26, *) { _ = await HubAlarmKit.requestAuthorization() } + #endif + // In the foreground the sync starts the hub's long poll and its watchdog + // checks, as coming to the foreground does. + await HubClient.shared.sync() + if newValue == .hub { await HubWatchdog.check() } + } + } } } diff --git a/LoopFollow/BackgroundRefresh/BackgroundRefreshType.swift b/LoopFollow/BackgroundRefresh/BackgroundRefreshType.swift index fd7c28e3e..01230ab5d 100644 --- a/LoopFollow/BackgroundRefresh/BackgroundRefreshType.swift +++ b/LoopFollow/BackgroundRefresh/BackgroundRefreshType.swift @@ -9,13 +9,32 @@ enum BackgroundRefreshType: String, Codable, CaseIterable { case rileyLink = "RileyLink" case dexcom = "Dexcom" case omnipodDash = "Omnipod Dash" + case hub = "LoopFollow Hub" + + /// The mode the app runs in: the selected one, except that LoopFollow Hub runs + /// as Silent Tune until the hub drives the alarms. + static var inEffect: BackgroundRefreshType { + let selected = Storage.shared.backgroundRefreshType.value + return selected == .hub && !HubClient.shared.drivesAlarms ? .silentTune : selected + } + + /// The app keeps itself running in the background (and so is watched for + /// stalls). In Hub mode the hub does the work and the app may be suspended. + var keepsAppRunning: Bool { + switch self { + case .silentTune, .rileyLink, .dexcom, .omnipodDash: + return true + case .none, .hub: + return false + } + } /// Indicates if the device type uses Bluetooth var isBluetooth: Bool { switch self { case .rileyLink, .dexcom, .omnipodDash: return true - case .silentTune, .none: + case .silentTune, .none, .hub: return false } } @@ -28,7 +47,7 @@ enum BackgroundRefreshType: String, Codable, CaseIterable { return 3 * 60 case .dexcom: return 5 * 60 - case .silentTune, .none: + case .silentTune, .none, .hub: return nil } } @@ -37,7 +56,7 @@ enum BackgroundRefreshType: String, Codable, CaseIterable { switch self { case .rileyLink: return true - case .dexcom, .omnipodDash, .silentTune, .none: + case .dexcom, .omnipodDash, .silentTune, .none, .hub: return false } } @@ -68,7 +87,7 @@ enum BackgroundRefreshType: String, Codable, CaseIterable { } return false - case .silentTune, .none: + case .silentTune, .none, .hub: return false } } diff --git a/LoopFollow/Controllers/AlarmSound.swift b/LoopFollow/Controllers/AlarmSound.swift index fafd2bf6f..fd575351b 100644 --- a/LoopFollow/Controllers/AlarmSound.swift +++ b/LoopFollow/Controllers/AlarmSound.swift @@ -286,7 +286,7 @@ class AlarmSound { // field which fallback (if any) the user landed on. fileprivate static func activateAudioSessionWithFallback() { let isBackgroundWithoutSilentTune = UIApplication.shared.applicationState == .background - && Storage.shared.backgroundRefreshType.value != .silentTune + && BackgroundRefreshType.inEffect != .silentTune let dominate: (label: String, options: AVAudioSession.CategoryOptions) = ("[]", []) let duck: (label: String, options: AVAudioSession.CategoryOptions) = (".duckOthers", .duckOthers) diff --git a/LoopFollow/Controllers/BackgroundAlertManager.swift b/LoopFollow/Controllers/BackgroundAlertManager.swift index b4cd22680..36d76341e 100644 --- a/LoopFollow/Controllers/BackgroundAlertManager.swift +++ b/LoopFollow/Controllers/BackgroundAlertManager.swift @@ -62,7 +62,7 @@ class BackgroundAlertManager { /// (Re)schedule all background alerts based on predefined durations. /// - Parameter force: When true, the scheduling is executed regardless of throttle constraints. func scheduleBackgroundAlert(force: Bool = false) { - guard isAlertScheduled, Storage.shared.backgroundRefreshType.value != .none else { return } + guard isAlertScheduled, BackgroundRefreshType.inEffect.keepsAppRunning else { return } // Throttle execution if not forced: only run once every 10 seconds. A forced // run stamps the date too, so the next tick doesn't immediately repeat the diff --git a/LoopFollow/Controllers/Nightscout/BGData.swift b/LoopFollow/Controllers/Nightscout/BGData.swift index 52e3555be..f6afc6379 100644 --- a/LoopFollow/Controllers/Nightscout/BGData.swift +++ b/LoopFollow/Controllers/Nightscout/BGData.swift @@ -61,7 +61,7 @@ extension MainViewController { // NS BG Data Web call func webLoadNSBGData(dexData: [ShareGlucoseData] = []) { // This kicks it out in the instance where dexcom fails but they aren't using NS && - if !IsNightscoutEnabled() { + if !IsNightscoutEnabled(), !HubBundle.shared.hasCurrent(.entries) { BannerManager.shared.clear(.nightscout) Storage.shared.lastBGChecked.value = Date() return @@ -75,7 +75,7 @@ extension MainViewController { // Exclude 'cal' entries parameters["find[type][$ne]"] = "cal" - NightscoutUtils.executeRequest(eventType: .sgv, parameters: parameters) { (result: Result<[ShareGlucoseData], Error>) in + NightscoutUtils.executeRequest(eventType: .sgv, parameters: parameters, hubPart: .entries) { (result: Result<[ShareGlucoseData], Error>) in switch result { case let .success(entriesResponse): BannerManager.shared.clear(.nightscout) @@ -329,7 +329,7 @@ extension MainViewController { #endif // Update contact - if Storage.shared.contactEnabled.value { + if Storage.shared.contactEnabled.value, !HubClient.shared.isHubMode { self.contactImageUpdater .updateContactImage( bgValue: Observable.shared.bgText.value, diff --git a/LoopFollow/Controllers/Nightscout/CAge.swift b/LoopFollow/Controllers/Nightscout/CAge.swift index ae02c46b4..e36e92ae8 100644 --- a/LoopFollow/Controllers/Nightscout/CAge.swift +++ b/LoopFollow/Controllers/Nightscout/CAge.swift @@ -14,7 +14,7 @@ extension MainViewController { "count": "1", ] - NightscoutUtils.executeRequest(eventType: .cage, parameters: parameters) { (result: Result<[cageData], Error>) in + NightscoutUtils.executeRequest(eventType: .cage, parameters: parameters, hubPart: .cage) { (result: Result<[cageData], Error>) in switch result { case let .success(data): self.updateCage(data: data) diff --git a/LoopFollow/Controllers/Nightscout/DBSize.swift b/LoopFollow/Controllers/Nightscout/DBSize.swift index 056971a4d..b827baa20 100644 --- a/LoopFollow/Controllers/Nightscout/DBSize.swift +++ b/LoopFollow/Controllers/Nightscout/DBSize.swift @@ -29,7 +29,7 @@ struct DBSizeData: Codable { extension MainViewController { // NS Database Size Web Call func webLoadNSDBSize() { - NightscoutUtils.executeRequest(eventType: .dbSize, parameters: [:]) { (result: Result) in + NightscoutUtils.executeRequest(eventType: .dbSize, parameters: [:], hubPart: .dbsize) { (result: Result) in switch result { case let .success(properties): self.updateDBSize(data: properties.dbsize) diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift index a4741037b..095a44556 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift @@ -8,7 +8,7 @@ import SwiftUI extension MainViewController { func webLoadNSDeviceStatus() { let parameters = ["count": "1"] - NightscoutUtils.executeDynamicRequest(eventType: .deviceStatus, parameters: parameters) { result in + NightscoutUtils.executeDynamicRequest(eventType: .deviceStatus, parameters: parameters, hubPart: .devicestatus) { result in switch result { case let .success(json): if let jsonDeviceStatus = json as? [[String: AnyObject]] { @@ -274,7 +274,7 @@ extension MainViewController { // Mark device status as loaded for initial loading state markDataLoaded("deviceStatus") - if Storage.shared.contactEnabled.value, Storage.shared.contactIOB.value != .off, + if Storage.shared.contactEnabled.value, !HubClient.shared.isHubMode, Storage.shared.contactIOB.value != .off, Observable.shared.iobText.value != previousIOBText { contactImageUpdater.updateContactImage( diff --git a/LoopFollow/Controllers/Nightscout/IAge.swift b/LoopFollow/Controllers/Nightscout/IAge.swift index d1f28dac7..bd4eb4297 100644 --- a/LoopFollow/Controllers/Nightscout/IAge.swift +++ b/LoopFollow/Controllers/Nightscout/IAge.swift @@ -16,7 +16,7 @@ extension MainViewController { "count": "1", ] - NightscoutUtils.executeRequest(eventType: .iage, parameters: parameters) { (result: Result<[iageData], Error>) in + NightscoutUtils.executeRequest(eventType: .iage, parameters: parameters, hubPart: .iage) { (result: Result<[iageData], Error>) in switch result { case let .success(data): DispatchQueue.main.async { diff --git a/LoopFollow/Controllers/Nightscout/NightscoutSocketManager.swift b/LoopFollow/Controllers/Nightscout/NightscoutSocketManager.swift index 6db744aa9..19baf181b 100644 --- a/LoopFollow/Controllers/Nightscout/NightscoutSocketManager.swift +++ b/LoopFollow/Controllers/Nightscout/NightscoutSocketManager.swift @@ -35,7 +35,8 @@ class NightscoutSocketManager { // MARK: - Public API func connectIfNeeded() { - guard Storage.shared.webSocketEnabled.value else { + // In Hub mode the hub follows Nightscout and the app long-polls the hub. + guard Storage.shared.webSocketEnabled.value, !HubClient.shared.isHubMode else { disconnect() return } diff --git a/LoopFollow/Controllers/Nightscout/Profile.swift b/LoopFollow/Controllers/Nightscout/Profile.swift index d88c453fa..158cc4bcc 100644 --- a/LoopFollow/Controllers/Nightscout/Profile.swift +++ b/LoopFollow/Controllers/Nightscout/Profile.swift @@ -12,7 +12,7 @@ extension MainViewController { "count": "1", "find[startDate][$lte]": formatter.string(from: Date().addingTimeInterval(60)), ] - NightscoutUtils.executeRequest(eventType: .profile, parameters: parameters) { (result: Result<[NSProfile], Error>) in + NightscoutUtils.executeRequest(eventType: .profile, parameters: parameters, hubPart: .profile) { (result: Result<[NSProfile], Error>) in switch result { case let .success(profiles): guard let profileData = profiles.first else { diff --git a/LoopFollow/Controllers/Nightscout/SAge.swift b/LoopFollow/Controllers/Nightscout/SAge.swift index 596974e12..e8a4814d9 100644 --- a/LoopFollow/Controllers/Nightscout/SAge.swift +++ b/LoopFollow/Controllers/Nightscout/SAge.swift @@ -16,7 +16,7 @@ extension MainViewController { "count": "1", ] - NightscoutUtils.executeRequest(eventType: .sage, parameters: parameters) { (result: Result<[sageData], Error>) in + NightscoutUtils.executeRequest(eventType: .sage, parameters: parameters, hubPart: .sage) { (result: Result<[sageData], Error>) in switch result { case let .success(data): DispatchQueue.main.async { diff --git a/LoopFollow/Controllers/Nightscout/Treatments.swift b/LoopFollow/Controllers/Nightscout/Treatments.swift index 430751bc4..6e8c37644 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments.swift @@ -50,7 +50,7 @@ extension MainViewController { "find[created_at][$lte]": endTimeString, "count": "\(estimatedCount)", ] - NightscoutUtils.executeDynamicRequest(eventType: .treatments, parameters: parameters) { (result: Result) in + NightscoutUtils.executeDynamicRequest(eventType: .treatments, parameters: parameters, hubPart: .treatments) { (result: Result) in switch result { case let .success(data): if let entries = data as? [[String: AnyObject]] { diff --git a/LoopFollow/Controllers/SpeakBG.swift b/LoopFollow/Controllers/SpeakBG.swift index ee003ce49..ac42acbe3 100644 --- a/LoopFollow/Controllers/SpeakBG.swift +++ b/LoopFollow/Controllers/SpeakBG.swift @@ -7,7 +7,8 @@ import Foundation extension MainViewController { func evaluateSpeakConditions(currentValue: Int, previousValue: Int) { - guard Storage.shared.speakBG.value else { + // In LoopFollow Hub mode the hub speaks each reading through a notification. + guard Storage.shared.speakBG.value, !HubClient.shared.drivesSpeech else { return } @@ -65,68 +66,6 @@ extension MainViewController { LogManager.shared.log(category: .general, message: "No condition met for speaking.", isDebug: true) } - struct AnnouncementTexts { - var stable: String - var increase: String - var decrease: String - var currentBGIs: String - - static func forLanguage(_ language: String) -> AnnouncementTexts { - switch language { - case "fr": - return AnnouncementTexts( - stable: "et c'est stable", - increase: "et c'est monté de", - decrease: "et c'est descendu de", - currentBGIs: "La glycémie est" - ) - case "it": - return AnnouncementTexts( - stable: "ed è stabile", - increase: "ed è salita di", - decrease: "ed è scesa di", - currentBGIs: "Glicemia attuale è" - ) - case "sk": - return AnnouncementTexts( - stable: "a je stabilná", - increase: "a stúpla o", - decrease: "a klesla o", - currentBGIs: "Aktuálna glykémia je" - ) - case "sv": - return AnnouncementTexts( - stable: "och det är stabilt", - increase: "och det har ökat med", - decrease: "och det har minskat med", - currentBGIs: "Blodsockret är" - ) - case "en": fallthrough - default: - return AnnouncementTexts( - stable: "and it is stable", - increase: "and it is up", - decrease: "and it is down", - currentBGIs: "Glucose is" - ) - } - } - } - - enum LanguageVoiceMapping { - static let voiceLanguageMap: [String: String] = [ - "en": "en-US", - "fr": "fr-FR", - "it": "it-IT", - "sk": "sk-SK", - "sv": "sv-SE", - ] - - static func voiceLanguageCode(forAppLanguage appLanguage: String) -> String { - return voiceLanguageMap[appLanguage, default: "en-US"] - } - } - // Speaks the current blood glucose value and the change from the previous value. func speakBG(currentValue: Int, previousValue: Int) { // 1. Check if there's a new, unspoken BG value @@ -156,21 +95,11 @@ extension MainViewController { lastSpokenBGDate = lastBG.date // 4. Generate announcement text. - let bloodGlucoseDifference = currentValue - previousValue let preferredLanguage = Storage.shared.speakLanguage.value - let voiceLanguageCode = LanguageVoiceMapping.voiceLanguageCode(forAppLanguage: preferredLanguage) - let texts = AnnouncementTexts.forLanguage(preferredLanguage) - let negligibleThreshold = 3 - let localizedCurrentValue = Localizer.toDisplayUnits(String(currentValue)).replacingOccurrences(of: ",", with: ".") - let announcementText: String - - if abs(bloodGlucoseDifference) <= negligibleThreshold { - announcementText = "\(texts.currentBGIs) \(localizedCurrentValue) \(texts.stable)" - } else { - let directionText = bloodGlucoseDifference < 0 ? texts.decrease : texts.increase - let absoluteDifference = Localizer.toDisplayUnits(String(abs(bloodGlucoseDifference))).replacingOccurrences(of: ",", with: ".") - announcementText = "\(texts.currentBGIs) \(localizedCurrentValue) \(directionText) \(absoluteDifference)" - } + let voiceLanguageCode = BGAnnouncement.voiceLanguageCode(forAppLanguage: preferredLanguage) + let announcementText = BGAnnouncement.text(current: currentValue, previous: previousValue, + language: preferredLanguage, + mmol: UnitSettingsStore.shared.glucoseUnit == .mmolL) // 5. Speak. let speechUtterance = AVSpeechUtterance(string: announcementText) diff --git a/LoopFollow/Helpers/BackgroundRefreshManager.swift b/LoopFollow/Helpers/BackgroundRefreshManager.swift index b1a6427b6..0f4cf74ac 100644 --- a/LoopFollow/Helpers/BackgroundRefreshManager.swift +++ b/LoopFollow/Helpers/BackgroundRefreshManager.swift @@ -55,9 +55,20 @@ class BackgroundRefreshManager { complete(false) } - // This task exists only to revive the Silent Tune keep-alive. Reading the mode - // is safe before storage is confirmed readable: the default is `.silentTune`, - // so an unhydrated read keeps the check armed rather than cancelling it. + // In LoopFollow Hub mode the task checks that the hub is alive. While the hub + // does not drive the alarms the app runs as in Silent Tune, so the check is + // followed by the Silent Tune's. + if StorageReadiness.ready.value, Storage.shared.backgroundRefreshType.value == .hub { + Task { @MainActor in + await HubWatchdog.check() + BackgroundRefreshManager.shared.continueAfterHubCheck(complete: complete) + } + return + } + + // Otherwise this task exists only to revive the Silent Tune keep-alive. Reading + // the mode is safe before storage is confirmed readable: the default is + // `.silentTune`, so an unhydrated read keeps the check armed rather than cancelling it. guard !StorageReadiness.ready.value || Storage.shared.backgroundRefreshType.value == .silentTune else { LogManager.shared.log(category: .taskScheduler, message: "Background refresh no longer needed for the current mode; cancelling") BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: taskIdentifier) @@ -66,6 +77,22 @@ class BackgroundRefreshManager { return } + reviveSilentTune(complete: complete) + } + + private func continueAfterHubCheck(complete: @escaping (Bool) -> Void) { + guard BackgroundRefreshType.inEffect == .silentTune else { + queue.async { + self.immediateRequested = false + self.submit(earliestBeginDate: Date(timeIntervalSinceNow: self.refreshInterval)) + } + complete(true) + return + } + reviveSilentTune(complete: complete) + } + + private func reviveSilentTune(complete: @escaping (Bool) -> Void) { // Queue the successor before doing any work, so an early expiration or a // crash still leaves a pending request behind. queue.async { diff --git a/LoopFollow/Helpers/NightscoutUtils.swift b/LoopFollow/Helpers/NightscoutUtils.swift index 1fa6c5bdb..b2b5fcb30 100644 --- a/LoopFollow/Helpers/NightscoutUtils.swift +++ b/LoopFollow/Helpers/NightscoutUtils.swift @@ -65,21 +65,12 @@ class NightscoutUtils { } } - static func executeRequest(eventType: EventType, parameters: [String: String], completion: @escaping (Result) -> Void) { - let baseURL = Storage.shared.url.value - let token = Storage.shared.token.value - - guard let url = NightscoutUtils.constructURL(baseURL: baseURL, token: token, endpoint: eventType.endpoint, parameters: parameters) else { - completion(.failure(NSError(domain: "NightscoutUtils", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to construct URL"]))) - return - } - - var request = URLRequest(url: url) - request.cachePolicy = URLRequest.CachePolicy.reloadIgnoringLocalCacheData - - let task = URLSession.shared.dataTask(with: request) { data, _, error in + /// `hubPart` names the LF Hub bundle part that answers this request in Hub mode; + /// without one, or without the part, the request goes to Nightscout. + static func executeRequest(eventType: EventType, parameters: [String: String], hubPart: HubBundle.Part? = nil, completion: @escaping (Result) -> Void) { + load(eventType: eventType, parameters: parameters, hubPart: hubPart) { data, error in guard let data = data, error == nil else { - completion(.failure(error!)) + completion(.failure(error ?? NSError(domain: "NightscoutUtils", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to construct URL"]))) return } @@ -109,24 +100,12 @@ class NightscoutUtils { completion(.failure(error)) } } - task.resume() } - static func executeDynamicRequest(eventType: EventType, parameters: [String: String], completion: @escaping (Result) -> Void) { - let baseURL = Storage.shared.url.value - let token = Storage.shared.token.value - - guard let url = NightscoutUtils.constructURL(baseURL: baseURL, token: token, endpoint: eventType.endpoint, parameters: parameters) else { - completion(.failure(NSError(domain: "NightscoutUtils", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to construct URL"]))) - return - } - - var request = URLRequest(url: url) - request.cachePolicy = URLRequest.CachePolicy.reloadIgnoringLocalCacheData - - let task = URLSession.shared.dataTask(with: request) { data, _, error in + static func executeDynamicRequest(eventType: EventType, parameters: [String: String], hubPart: HubBundle.Part? = nil, completion: @escaping (Result) -> Void) { + load(eventType: eventType, parameters: parameters, hubPart: hubPart) { data, error in guard let data = data, error == nil else { - completion(.failure(error!)) + completion(.failure(error ?? NSError(domain: "NightscoutUtils", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to construct URL"]))) return } @@ -146,7 +125,38 @@ class NightscoutUtils { completion(.failure(error)) } } - task.resume() + } + + /// The response bytes: the LF Hub bundle part in Hub mode when there is a current + /// one, otherwise a Nightscout request when Nightscout is set up. + private static func load(eventType: EventType, parameters: [String: String], hubPart: HubBundle.Part?, + completion: @escaping (Data?, Error?) -> Void) + { + let fromNightscout = { + let baseURL = Storage.shared.url.value + let token = Storage.shared.token.value + guard let url = NightscoutUtils.constructURL(baseURL: baseURL, token: token, endpoint: eventType.endpoint, parameters: parameters) else { + completion(nil, nil) + return + } + var request = URLRequest(url: url) + request.cachePolicy = URLRequest.CachePolicy.reloadIgnoringLocalCacheData + URLSession.shared.dataTask(with: request) { data, _, error in completion(data, error) }.resume() + } + + guard let hubPart, HubBundle.shared.isActive else { + fromNightscout() + return + } + Task { + if let data = await HubBundle.shared.data(for: hubPart) { + completion(data, nil) + } else if IsNightscoutEnabled() { + fromNightscout() + } else { + completion(nil, NSError(domain: "NightscoutUtils", code: -1, userInfo: [NSLocalizedDescriptionKey: "No current LF Hub data and no Nightscout URL"])) + } + } } static func createURLRequest(url: String, token: String?, path: String) -> URLRequest? { diff --git a/LoopFollow/Helpers/Telemetry.swift b/LoopFollow/Helpers/Telemetry.swift index afd90117f..818f5cb2c 100644 --- a/LoopFollow/Helpers/Telemetry.swift +++ b/LoopFollow/Helpers/Telemetry.swift @@ -145,6 +145,9 @@ final class TelemetryClient { } payload["backgroundRefreshMethod"] = storage.backgroundRefreshType.value.rawValue + let hub = HubClient.shared + payload.merge(Self.hubFields(connected: hub.isConnected, drivesAlarms: hub.drivesAlarms, + hub: hub.hubInfo.value)) { $1 } // Selected user-preference fields. Picked for product-decision value; // none reveal personal or health information. @@ -159,6 +162,18 @@ final class TelemetryClient { return payload } + /// While connected to LF Hub: whether the hub delivers the alarms, and the hub's + /// version and API version from its last answer. Never the hub's address. + static func hubFields(connected: Bool, drivesAlarms: Bool, hub: HubClient.Status.Hub?) -> [String: Any] { + guard connected else { return [:] } + var fields: [String: Any] = ["hubConfirmed": drivesAlarms] + if let hub { + fields["hubVersion"] = hub.version + fields["hubApi"] = hub.api + } + return fields + } + /// Build payload, POST it, update last-sent state on 2xx. Fire-and-forget; /// errors are logged at debug level only and never surfaced to the UI. func send() async { @@ -301,7 +316,7 @@ struct TelemetryPrivacyView: View { Group { Text("What is sent") .font(.headline) - Text("App version, build date, whether this is a TestFlight build, the install instance number, an Apple-supplied per-vendor identifier (IDFV) that resets when all this developer's apps are removed from the device, the hardware identifier (e.g. iPhone15,2), and iOS version. Whether Nightscout and Dexcom are configured (yes/no — no URLs or usernames). Which app you're following (Loop, Trio, etc), if known. A small set of preference flags (units, appearance mode, calendar/contact integration enabled, remote-command type, background refresh method). The full JSON is visible under Diagnostics → What's sent.") + Text("App version, build date, whether this is a TestFlight build, the install instance number, an Apple-supplied per-vendor identifier (IDFV) that resets when all this developer's apps are removed from the device, the hardware identifier (e.g. iPhone15,2), and iOS version. Whether Nightscout and Dexcom are configured (yes/no — no URLs or usernames). Which app you're following (Loop, Trio, etc), if known. A small set of preference flags (units, appearance mode, calendar/contact integration enabled, remote-command type, background refresh method). When connected to LF Hub, whether it delivers the alarms and its version (not its address). The full JSON is visible under Diagnostics → What's sent.") } Group { diff --git a/LoopFollow/Hub/HubAlarmSounds.swift b/LoopFollow/Hub/HubAlarmSounds.swift new file mode 100644 index 000000000..262a91344 --- /dev/null +++ b/LoopFollow/Hub/HubAlarmSounds.swift @@ -0,0 +1,101 @@ +// LoopFollow +// HubAlarmSounds.swift + +import AVFoundation +import Foundation + +/// Copies of the alarm sounds that a push notification can play. iOS plays a +/// notification sound only from Library/Sounds or the bundle, at most 30 +/// seconds long, in Linear PCM, IMA4, µLaw or aLaw — the bundled alarm sounds +/// are AAC, so each sound an alarm uses is converted once. The copy goes to the +/// app's Library/Sounds and to the App Group's, where the notification service +/// extension's AlarmKit alarm finds it. +enum HubAlarmSounds { + private static let maxSeconds: Double = 29.5 + + /// File name the hub puts in the push's `sound` for this alarm sound. + static func notificationName(for sound: SoundFile) -> String { + switch sound { + case let .builtin(name): return "lfhub-\(name).caf" + case let .custom(uuid): return "lfhub-\(uuid.uuidString).caf" + } + } + + static func prepare(for alarms: [Alarm]) async { + await Task.detached(priority: .utility) { + let directories = soundsDirectories() + guard let primary = directories.first else { return } + for sound in Set(alarms.map(\.soundFile)) { + let name = notificationName(for: sound) + let converted = primary.appendingPathComponent(name) + if !FileManager.default.fileExists(atPath: converted.path), let source = sourceURL(for: sound) { + do { + try convert(source, to: converted) + } catch { + try? FileManager.default.removeItem(at: converted) + LogManager.shared.log(category: .hub, message: "Notification sound conversion failed for \(sound.displayName): \(error.localizedDescription)") + continue + } + } + for directory in directories.dropFirst() { + let copy = directory.appendingPathComponent(name) + if !FileManager.default.fileExists(atPath: copy.path) { + try? FileManager.default.copyItem(at: converted, to: copy) + } + } + } + }.value + } + + /// The app's Library/Sounds first, then the App Group's. + private static func soundsDirectories() -> [URL] { + let libraries = [ + FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first, + FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: AppGroupID.current())? + .appendingPathComponent("Library", isDirectory: true), + ].compactMap { $0 } + return libraries.map { library in + let directory = library.appendingPathComponent("Sounds", isDirectory: true) + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } + } + + private static func sourceURL(for sound: SoundFile) -> URL? { + switch sound { + case let .builtin(name): return Bundle.main.url(forResource: name, withExtension: "caf") + case let .custom(uuid): return CustomSoundStore.shared.url(for: uuid) + } + } + + /// Writes 16-bit Linear PCM, mono, truncated to `maxSeconds`. + private static func convert(_ source: URL, to destination: URL) throws { + let input = try AVAudioFile(forReading: source) + let sampleRate = input.processingFormat.sampleRate + let settings: [String: Any] = [ + AVFormatIDKey: kAudioFormatLinearPCM, + AVSampleRateKey: sampleRate, + AVNumberOfChannelsKey: 1, + AVLinearPCMBitDepthKey: 16, + AVLinearPCMIsFloatKey: false, + AVLinearPCMIsBigEndianKey: false, + ] + let output = try AVAudioFile(forWriting: destination, settings: settings, + commonFormat: .pcmFormatFloat32, interleaved: false) + guard let monoFormat = AVAudioFormat(standardFormatWithSampleRate: sampleRate, channels: 1), + let converter = AVAudioConverter(from: input.processingFormat, to: monoFormat) + else { throw CocoaError(.fileReadCorruptFile) } + + let maxFrames = AVAudioFramePosition(maxSeconds * sampleRate) + let chunk: AVAudioFrameCount = 8192 + while input.framePosition < min(input.length, maxFrames) { + let frames = AVAudioFrameCount(min(AVAudioFramePosition(chunk), min(input.length, maxFrames) - input.framePosition)) + guard let inBuffer = AVAudioPCMBuffer(pcmFormat: input.processingFormat, frameCapacity: frames), + let outBuffer = AVAudioPCMBuffer(pcmFormat: monoFormat, frameCapacity: frames) + else { break } + try input.read(into: inBuffer, frameCount: frames) + try converter.convert(to: outBuffer, from: inBuffer) + try output.write(from: outBuffer) + } + } +} diff --git a/LoopFollow/Hub/HubBundle.swift b/LoopFollow/Hub/HubBundle.swift new file mode 100644 index 000000000..6ffc8345d --- /dev/null +++ b/LoopFollow/Hub/HubBundle.swift @@ -0,0 +1,198 @@ +// LoopFollow +// HubBundle.swift + +import Foundation + +/// In LoopFollow Hub mode the app reads its Nightscout and Dexcom data from the +/// hub's bundle: the latest response of every endpoint LoopFollow polls, kept +/// fresh by the hub. The app's own parsers take each part exactly as they take +/// a Nightscout response, so a cold start shows everything after one request. +/// While the app is open it long-polls the hub and reruns the tasks whose parts +/// changed, so new data shows at once. Without a current part (part not fetched +/// yet, or no answer from the hub for `staleAfter`) a request goes to Nightscout +/// or Dexcom as in every other mode. +final class HubBundle { + static let shared = HubBundle() + + private let activeCheck: () -> Bool + + /// Instances other than `shared` exist for tests, with their own idea of Hub mode. + init(isActive: @escaping () -> Bool = { HubClient.shared.isHubMode }) { + activeCheck = isActive + } + + enum Part: String, CaseIterable { + case entries, profile, devicestatus, treatments, sage, cage, iage, dbsize + + /// The task whose fetch reads this part. + var task: TaskID? { + switch self { + case .entries: return .fetchBG + case .profile: return .profile + case .devicestatus: return .deviceStatus + case .treatments, .sage, .cage, .iage: return .treatments + case .dbsize: return .dbSize + } + } + } + + private let lock = NSLock() + private var parts: [Part: Data] = [:] + private(set) var version: Int64 = 0 + /// When the hub last answered a bundle request. + private var lastContact: Date? + private var loading: Task? + private var loadingID: UUID? + private var listening: Task? + + /// How long a first fetch may wait for the bundle before going to Nightscout. + private static let firstLoadWait: UInt64 = 3_000_000_000 + private static let longPollSeconds = 25 + /// How often the open app runs the watchdog's check, which also refreshes the + /// hub's confirmation. + private static let watchdogInterval: TimeInterval = 60 + /// Parts older than this without an answer from the hub are not used. + static let staleAfter: TimeInterval = 3 * 60 + + var isActive: Bool { activeCheck() } + + /// Whether the bundle holds a current copy of the part in Hub mode. + func hasCurrent(_ part: Part, now: Date = Date()) -> Bool { + isActive && current(part, now: now) != nil + } + + /// The part for the app's own parser. A part that is not current is fetched first, + /// waiting up to `firstLoadWait`; nil sends the caller to Nightscout or Dexcom. + func data(for part: Part) async -> Data? { + guard isActive else { return nil } + if let cached = current(part) { return cached } + let load = loadOnce() + await withTaskGroup(of: Void.self) { group in + group.addTask { await load.value } + group.addTask { try? await Task.sleep(nanoseconds: Self.firstLoadWait) } + await group.next() + group.cancelAll() + } + return current(part) + } + + /// Long-polls the hub while the app is open; each new version reruns the tasks + /// that read a changed part. + func startListening() { + guard isActive, listening == nil else { return } + listening = Task { [weak self] in + // Coming to the foreground runs a check of its own. + var lastCheck = Date() + while !Task.isCancelled, let self, self.isActive { + let changed = await self.fetch(wait: Self.longPollSeconds) + if Task.isCancelled { break } + if Date().timeIntervalSince(lastCheck) >= Self.watchdogInterval { + lastCheck = Date() + await HubWatchdog.check() + } + // Without an answer, or without a version the hub can hold the request + // for, the next request waits. + if changed == nil || self.lock.withLock({ self.version }) == 0 { + try? await Task.sleep(nanoseconds: 10_000_000_000) + } + } + } + } + + func stopListening() { + listening?.cancel() + listening = nil + } + + // MARK: - Loading + + /// The stored part, whatever the mode. + func stored(_ part: Part) -> Data? { + lock.lock() + defer { lock.unlock() } + return parts[part] + } + + /// The stored part while the hub answered within `staleAfter` of `now`. + func current(_ part: Part, now: Date = Date()) -> Data? { + lock.lock() + defer { lock.unlock() } + guard let lastContact, now.timeIntervalSince(lastContact) < Self.staleAfter else { return nil } + return parts[part] + } + + private func markContact(_ at: Date) { + lock.withLock { lastContact = at } + } + + /// The load in progress, or a new one. The load clears itself when done. + private func loadOnce() -> Task { + lock.lock() + defer { lock.unlock() } + if let loading { return loading } + let id = UUID() + let task = Task { + _ = await self.fetch(wait: 0) + self.lock.withLock { if self.loadingID == id { self.loading = nil } } + } + loading = task + loadingID = id + return task + } + + /// Fetches the bundle (holding up to `wait` seconds for a newer one) and applies + /// it. The parts that changed, empty when nothing did, nil on failure. + @discardableResult + private func fetch(wait: Int) async -> Set? { + let known = lock.withLock { version } + do { + guard let data = try await HubClient.shared.bundle(since: known, wait: wait) else { + markContact(Date()) + return [] + } + let changed = apply(data) + if !changed.isEmpty { rerunTasks(for: changed) } + return changed + } catch { + LogManager.shared.log(category: .hub, message: "Hub bundle fetch failed: \(error.localizedDescription)", isDebug: true) + return nil + } + } + + /// Takes a bundle response received at `receivedAt`: stores its parts and version + /// and returns the parts whose content changed. A response that does not parse + /// changes nothing. + func apply(_ data: Data, receivedAt: Date = Date()) -> Set { + guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let newVersion = (root["version"] as? NSNumber)?.int64Value, + let incoming = root["parts"] as? [String: Any] + else { return [] } + + var decoded: [Part: Data] = [:] + for (name, value) in incoming { + guard let part = Part(rawValue: name), + let payload = (value as? [String: Any])?["data"], !(payload is NSNull), + let bytes = try? JSONSerialization.data(withJSONObject: payload, options: .fragmentsAllowed) + else { continue } + decoded[part] = bytes + } + + lock.lock() + defer { lock.unlock() } + let changed = Set(decoded.compactMap { part, bytes in parts[part] == bytes ? nil : part }) + parts.merge(decoded) { _, new in new } + version = newVersion + lastContact = receivedAt + return changed + } + + private func rerunTasks(for changed: Set) { + let tasks = Set(changed.compactMap(\.task)) + LogManager.shared.log(category: .hub, message: "Hub bundle: \(changed.map(\.rawValue).sorted().joined(separator: ", ")) changed", isDebug: true) + DispatchQueue.main.async { + for task in tasks { + TaskScheduler.shared.rescheduleTask(id: task, to: Date()) + } + } + } +} diff --git a/LoopFollow/Hub/HubChecklist.swift b/LoopFollow/Hub/HubChecklist.swift new file mode 100644 index 000000000..0b283bff0 --- /dev/null +++ b/LoopFollow/Hub/HubChecklist.swift @@ -0,0 +1,306 @@ +// LoopFollow +// HubChecklist.swift + +import SwiftUI +import UserNotifications + +/// What LF Hub needs on this phone and at the hub to send the alarms, each item with +/// what fixes it. Built from the hub's last status and what the phone knows itself. +enum HubChecklist { + enum Permission { + case allowed + case notAsked + case denied + } + + enum Fix: Equatable { + case retry + case useHubMode + case askNotifications + case askAlarmKit + case openNotificationSettings + case openSettings + case turnOnLiveActivity + case goToAPN + case registerAgain + case dataSource(dexcom: Bool) + case sendSources + case askAdmin + } + + struct Item: Identifiable, Equatable { + enum ID { + case hub, version, mode, notifications, alarmKit, liveActivity, apnKey, push, source, confirmed + } + + let id: ID + let title: String + /// What is missing; nil while the item is in place. + var problem: String? + var fix: Fix? + + var isReady: Bool { problem == nil } + } + + /// What the phone knows itself. + struct Facts { + var hubMode = true + var hubLooksHealthy = true + /// Nil until read. + var notifications: Permission? + var alarmKit = Permission.allowed + var liveActivityOn = true + var ownApnKey = true + /// This phone may send its own APN key; a phone that joined with an invite does not. + var ownKeyAllowed = true + var isAdmin = true + var hubSharesKeys = false + /// This app's data source settings are not the hub's sources. + var sourcesDiffer = false + var drivesAlarms = true + var hubProblems: [String] = [] + } + + /// The checklist for the hub's status; only the hub item when it did not answer, and + /// none before its first answer. + static func items(status: HubClient.Status?, error: String?, facts: Facts, now: Date) -> [Item] { + var hub = Item(id: .hub, title: "LF Hub answers") + if let error { + hub.problem = error + hub.fix = .retry + return [hub] + } + guard let status else { return [] } + if facts.hubMode, !facts.hubLooksHealthy { + hub.problem = "LF Hub has not answered properly for several minutes." + hub.fix = .retry + } + var items = [hub] + + if !HubClient.supports(status.hub) { + items.append(Item(id: .version, title: "LF Hub is up to date", problem: HubClient.Problem.hubOutdated)) + } + + items.append(Item(id: .mode, title: "Background Refresh uses LF Hub", + problem: facts.hubMode ? nil : "LoopFollow checks the alarms itself. Choose LF Hub to let the hub send them.", + fix: facts.hubMode ? nil : .useHubMode)) + + items += permissionItems(facts, liveActivity: facts.hubMode) + + let sharedKey = status.device.apnsKeyShared == true + if facts.ownApnKey || sharedKey { + items.append(Item(id: .apnKey, title: sharedKey && !facts.ownApnKey ? "APN key shared by an admin" : "APN key added")) + items.append(pushItem(status, now: now)) + } else { + if !facts.ownKeyAllowed { + items.append(Item(id: .apnKey, title: "APN key shared by an admin", + problem: "No admin phone shares an APN key yet. Ask an admin to add theirs.", + fix: .askAdmin)) + } else { + items.append(Item(id: .apnKey, title: "APN key added", + problem: facts.hubSharesKeys && !facts.isAdmin + ? "No admin phone shares an APN key yet. Ask an admin to add theirs, or add one here." + : "LF Hub needs an APN key to notify this phone.", + fix: .goToAPN)) + } + } + + items.append(sourceItem(status, facts: facts)) + + if facts.hubMode { + var confirmed = Item(id: .confirmed, title: "LF Hub sends this phone's alarms") + if !facts.drivesAlarms { + let covered = [HubClient.Problem.noPush, HubClient.Problem.notFollowed, HubClient.Problem.noPushYet, + HubClient.Problem.pushFailing, HubClient.Problem.alarmKitDenied, + HubClient.Problem.alarmKitUnavailable, HubClient.Problem.hubOutdated, + HubClient.Problem.notificationsOff] + let remaining = facts.hubProblems.filter { !covered.contains($0) } + if !remaining.isEmpty { + confirmed.problem = remaining.joined(separator: " ") + } else if items.contains(where: { !$0.isReady }) { + confirmed.problem = "LF Hub takes over once the items above are ready. Until then, LoopFollow checks the alarms itself." + } else { + confirmed.problem = "Waiting for LF Hub to confirm." + } + confirmed.fix = .retry + } + items.append(confirmed) + } + return items + } + + /// The permissions LF Hub's notifications, alarms and Live Activity need on this phone. + static func permissionItems(_ facts: Facts, liveActivity: Bool) -> [Item] { + var items: [Item] = [] + if let notifications = facts.notifications { + var item = Item(id: .notifications, title: "Notifications allowed") + switch notifications { + case .allowed: break + case .notAsked: (item.problem, item.fix) = ("LoopFollow has not asked to send notifications yet.", .askNotifications) + case .denied: (item.problem, item.fix) = (HubClient.Problem.notificationsOff, .openNotificationSettings) + } + items.append(item) + } + + var alarmKit = Item(id: .alarmKit, title: "Alarms ring through silent mode") + if HubClient.alarmKitProblem(authorized: facts.alarmKit == .allowed) != nil { + alarmKit.problem = "Alarms follow the ringer switch." + alarmKit.fix = facts.alarmKit == .notAsked ? .askAlarmKit : .openSettings + } + items.append(alarmKit) + + if liveActivity { + items.append(Item(id: .liveActivity, title: "Live Activity on", + problem: facts.liveActivityOn ? nil : "Live Activity is off.", + fix: facts.liveActivityOn ? nil : .turnOnLiveActivity)) + } + return items + } + + /// The notification permission as iOS reports it now. Notifications delivered + /// quietly or without alerts count as denied, as for the hub's confirmation. + static func notificationPermission() async -> Permission { + let settings = await UNUserNotificationCenter.current().notificationSettings() + if settings.authorizationStatus == .notDetermined { return .notAsked } + return HubClient.notificationsAllow(settings.authorizationStatus, alertSetting: settings.alertSetting) + ? .allowed : .denied + } + + /// AlarmKit's permission as it is now. + static var alarmKitPermission: Permission { + #if !targetEnvironment(macCatalyst) + if #available(iOS 26, *) { + if HubAlarmKit.isAuthorized { return .allowed } + return HubAlarmKit.canAsk ? .notAsked : .denied + } + #endif + return .denied + } + + /// Carries out a fix that needs no navigation: asks for a permission, opens the + /// Settings app, or redoes a step with the hub. + @MainActor static func perform(_ fix: Fix) async { + switch fix { + case .useHubMode: + BackgroundRefreshSettingsViewModel.change(to: .hub) + case .askNotifications: + await withCheckedContinuation { continuation in + NotificationAuthorization.requestIfNeeded { continuation.resume() } + } + case .askAlarmKit: + #if !targetEnvironment(macCatalyst) + if #available(iOS 26, *) { + _ = await HubAlarmKit.requestAuthorization() + await HubClient.shared.sync() + } + #endif + case .openNotificationSettings, .openSettings: + let link = fix == .openSettings ? UIApplication.openSettingsURLString : UIApplication.openNotificationSettingsURLString + if let url = URL(string: link) { await UIApplication.shared.open(url) } + case .turnOnLiveActivity: + #if !targetEnvironment(macCatalyst) + Storage.shared.laEnabled.value = true + LiveActivityManager.shared.forceRestart() + #endif + case .registerAgain: + await HubClient.shared.registerAgain() + case .sendSources: + await HubSourceSync.shared.retry() + case .retry, .goToAPN, .dataSource, .askAdmin: + break + } + } + + private static func pushItem(_ status: HubClient.Status, now: Date) -> Item { + var item = Item(id: .push, title: "Notifications from LF Hub arrive") + if !status.device.apnsConfigured { + item.problem = "LF Hub has not registered this phone for notifications yet." + } else if HubClient.health(of: status, now: now) != .healthy { + item.problem = status.device.lastPushOkAt == nil ? HubClient.Problem.noPushYet : HubClient.Problem.pushFailing + } + if item.problem != nil { item.fix = .registerAgain } + return item + } + + private static func sourceItem(_ status: HubClient.Status, facts: Facts) -> Item { + let patient = status.patient + var item = Item(id: .source, title: "LF Hub reads \(patient.name)'s data") + if patient.nightscoutHost == nil, patient.dexcom != true { + item.problem = "LF Hub has no data source for \(patient.name)." + } else if patient.followed != true { + item.problem = "LF Hub cannot read \(patient.name)'s data with this data source." + } else if patient.hasData != true { + item.problem = "LF Hub has not received a reading for \(patient.name) yet." + } + if item.problem != nil { + item.fix = facts.isAdmin ? .dataSource(dexcom: patient.dexcom == true) : .askAdmin + } else if facts.isAdmin, facts.sourcesDiffer { + item.problem = "This phone's data source has not reached LF Hub." + item.fix = .sendSources + } + return item + } +} + +/// The checklist's rows: a check for each item in place, otherwise what is missing and +/// a button that fixes it. +struct HubChecklistRows: View { + let items: [HubChecklist.Item] + let onFix: (HubChecklist.Fix) -> Void + + var body: some View { + ForEach(items) { item in + if item.isReady { + Label { + Text(item.title) + } icon: { + Image(systemName: "checkmark.circle.fill").foregroundColor(.green) + } + } else { + HStack(alignment: .firstTextBaseline, spacing: 12) { + Image(systemName: "exclamationmark.circle.fill").foregroundColor(.orange) + VStack(alignment: .leading, spacing: 2) { + Text(item.title) + if let problem = item.problem { + Text(problem).font(.caption).foregroundColor(.secondary) + } + } + Spacer(minLength: 8) + if let fix = item.fix { fixButton(fix) } + } + } + } + } + + @ViewBuilder + private func fixButton(_ fix: HubChecklist.Fix) -> some View { + switch fix { + case .goToAPN: + NavigationLink("Go to APN", value: SettingsRoute.apn) + .fixedSize() + case let .dataSource(dexcom): + NavigationLink("Data Source", value: dexcom ? SettingsRoute.dexcom : SettingsRoute.nightscout) + .fixedSize() + case .askAdmin: + Text("Ask an admin").font(.caption).foregroundColor(.secondary).fixedSize() + default: + Button(title(fix)) { onFix(fix) } + .buttonStyle(.bordered) + .controlSize(.small) + .fixedSize() + } + } + + private func title(_ fix: HubChecklist.Fix) -> String { + switch fix { + case .retry: return "Try Again" + case .useHubMode: return "Use LF Hub" + case .askNotifications, .askAlarmKit: return "Allow" + case .openNotificationSettings, .openSettings: return "Open Settings" + case .turnOnLiveActivity: return "Turn On" + case .registerAgain: return "Register Again" + case .sendSources: return "Send" + case .goToAPN, .dataSource, .askAdmin: return "" + } + } +} diff --git a/LoopFollow/Hub/HubClient.swift b/LoopFollow/Hub/HubClient.swift new file mode 100644 index 000000000..6c0c2ffd9 --- /dev/null +++ b/LoopFollow/Hub/HubClient.swift @@ -0,0 +1,1387 @@ +// LoopFollow +// HubClient.swift + +import Combine +import CryptoKit +import Foundation +import UIKit +import UserNotifications + +/// Client for an LF Hub: a server that follows the patient's Nightscout and +/// does work on this device's behalf over APNs: the badge, the Live Activity +/// and the alarms. While the hub drives them, the app does not do that work itself. +final class HubClient { + static let shared = HubClient() + private init() {} + + /// What a device may do at the hub: an admin manages the patient's data sources, + /// devices and invites; a member follows. A role this app does not know counts as member. + enum Role: String, Codable { + case admin + case member + + init(from decoder: Decoder) throws { + self = try Role(rawValue: decoder.singleValueContainer().decode(String.self)) ?? .member + } + } + + /// The answer to joining, with the setup token or with an invite. + struct ClaimResponse: Decodable { + let deviceId: String + let deviceKey: String + let patientId: String + /// Absent on a hub without roles, where every device manages. + let role: Role? + } + + /// An invite to join a hub as a member, in the form its QR code carries: + /// `/join#`. + struct Invite: Equatable { + let hubURL: String + let code: String + + var link: String { hubURL + "/join#" + code } + + init?(hubURL: String, code: String) { + guard let base = try? HubClient.hubBase(hubURL), Self.isCode(code) else { return nil } + self.hubURL = base + self.code = code + } + + /// Reads an invite link as scanned or pasted. Only an https hub, or a local test + /// hub over http, with no user name or password in the address. + init?(link: String) { + guard var components = URLComponents(string: link.trimmingCharacters(in: .whitespacesAndNewlines)), + ["https", "http"].contains(components.scheme?.lowercased()), + !(components.host ?? "").isEmpty, + components.user == nil, components.password == nil, + let code = components.fragment + else { return nil } + var path = components.path + if path.hasSuffix("/") { path.removeLast() } + guard path.hasSuffix("/join") else { return nil } + components.path = String(path.dropLast("/join".count)) + components.query = nil + components.fragment = nil + guard let base = components.string else { return nil } + self.init(hubURL: base, code: code) + } + + /// The hub's codes are random bytes in base64url. + private static func isCode(_ code: String) -> Bool { + (16 ... 128).contains(code.count) + && code.unicodeScalars.allSatisfy { $0.isASCII && (CharacterSet.alphanumerics.contains($0) || $0 == "-" || $0 == "_") } + } + } + + struct InviteResponse: Decodable { + let code: String + let expiresAt: Date + } + + /// A device of the hub's patient. The list offered when joining leaves out the + /// push fields and `current`. + struct Device: Decodable, Identifiable, Equatable { + let id: UUID + let name: String + /// Absent on a hub without roles. + let role: Role? + let createdAt: Date + let lastSeenAt: Date? + let appVersion: String? + let lastPushOkAt: Date? + let pushFailingSince: Date? + let current: Bool? + } + + struct DeviceList: Decodable { + let devices: [Device] + } + + /// How this phone joins a hub that already has devices. + enum Join: Hashable { + /// A new device with no alarms or settings at the hub. + case new + /// This phone takes over the device: its alarms and settings, under a new key. + case replace(UUID) + /// A new device starting with a copy of that device's alarms and settings. + case copy(UUID) + } + + /// The patient's data sources at the hub, as an app sent them. + struct Sources: Decodable, Equatable { + struct Dexcom: Decodable, Equatable { + let username: String + let password: String + let server: String? + } + + let nightscoutUrl: String? + let token: String? + let dexcom: Dexcom? + + var isEmpty: Bool { (nightscoutUrl ?? "").isEmpty && dexcom == nil } + } + + struct Status: Decodable { + struct Device: Decodable { + let name: String + /// Absent on a hub without roles, where every device manages. + let role: Role? + let apnsConfigured: Bool + /// The hub pushes to this device with an admin device's APN key. + let apnsKeyShared: Bool? + let apnsEnvironment: String? + let lastPushAt: Date? + let lastPushStatus: String? + let lastPushOkAt: Date? + let pushFailingSince: Date? + let alarmsReceived: Bool? + let alarmCount: Int? + let lastAlarmPushOk: Bool? + let disabledAlarmIds: [String]? + let activeAlarm: ActiveAlarm? + let liveActivity: LiveActivity + let settings: Settings? + } + + /// What the hub drives for the device. + struct Settings: Decodable { + let alarms: Bool? + } + + struct ActiveAlarm: Decodable { + let alarmId: String? + let title: String + let firedAt: Date + let lastPushAt: Date + let lastPushStatus: String? + } + + struct LiveActivity: Decodable { + let registered: Bool + let pushToStartRegistered: Bool + let renewBy: Date? + let renewalFailed: Bool + let lastPushAt: Date? + let lastPushStatus: String? + } + + struct Patient: Decodable { + let name: String + let nightscoutHost: String? + let dexcom: Bool? + let followed: Bool? + let hasData: Bool? + /// Counts the changes of the patient's data sources. + let sourceRevision: Int? + } + + struct Live: Decodable { + let connected: Bool + let lastReadingAt: Date? + let staleAfterSeconds: Int + } + + /// What the hub reports about itself; absent on a hub older than the version exchange. + struct Hub: Codable, Equatable { + let version: String + let api: Int + let features: [String] + } + + let hub: Hub? + let device: Device + let patient: Patient + let live: Live + } + + /// The API version this app speaks. + static let api = 1 + /// What this app supports, reported to the hub: delivery receipts for alarm pushes, and + /// speaking the hub's Speak BG pushes. + static let features = ["receipts", "speak"] + /// What the hub must provide before this app lets it deliver the alarms. + static let requiredHubFeatures = ["receipts"] + + enum HubError: LocalizedError { + case notConnected + case invalidURL + case keyNotSaved + case inviteNotValid + case tooManyTries + case invitedPhone + case http(Int, String) + + var errorDescription: String? { + switch self { + case .notConnected: return "Not connected to a hub." + case .invalidURL: return "The hub URL is not valid." + case .keyNotSaved: return "The hub's key for this phone could not be saved." + case .inviteNotValid: return "This invite has expired or was already used. Ask for a new one." + case .tooManyTries: return "Too many tries. Wait a minute and try again." + case .invitedPhone: return "A phone that joined with an invite does not send its own data sources to the hub." + case let .http(code, message): return "Hub responded \(code): \(message)" + } + } + } + + /// The key this device authenticates to the hub with. + let deviceKey = HubDeviceKey() + + var isConnected: Bool { + !Storage.shared.hubURL.value.isEmpty && !deviceKey.value.isEmpty + } + + /// This device's role: admin after joining with the setup token, member after joining + /// with an invite, and then as the hub last said. A hub without roles lets every device + /// it did not invite manage. + let role = StorageValue(key: "hubRole", defaultValue: .admin) + /// This phone joined with an invite. It never sends the hub its own data sources or + /// APN key, whatever role the hub gives it. + let joinedWithInvite = StorageValue(key: "hubJoinedWithInvite", defaultValue: false) + /// A device this phone left whose removal the hub has not confirmed yet. + let pendingUnregistration = HubPendingUnregistration() + /// The hub pushes to this device with an admin device's APN key, as it last said. + let apnsKeyShared = StorageValue(key: "hubApnsKeyShared", defaultValue: false) + /// The patient's name at the hub, for messages about changes that reach every phone. + let patientName = StorageValue(key: "hubPatientName", defaultValue: "") + + /// Connected, and allowed to manage the patient's data sources, devices and invites. + var isAdmin: Bool { isConnected && role.value == .admin } + + /// Connected as a member: the data sources and the devices are the admins' to manage. + var isMember: Bool { isConnected && role.value == .member } + + /// Connected as an admin that did not join with an invite: this app's data sources go + /// to the hub. + var sendsOwnSources: Bool { isAdmin && !joinedWithInvite.value } + + /// The role after joining: an invite makes a member, the setup token an admin. + static func joinRole(withInvite: Bool) -> Role { + withInvite ? .member : .admin + } + + /// The role the hub reports, or without one the role joining gave. + static func role(reported: Role?, joinedWithInvite: Bool) -> Role { + reported ?? joinRole(withInvite: joinedWithInvite) + } + + /// The connected hub reported the feature in its last status. + func offers(_ feature: String) -> Bool { + isConnected && hubInfo.value?.features.contains(feature) == true + } + + /// This app holds an APN key of its own under Settings → APN. + static var hasOwnApnKey: Bool { + !Storage.shared.lfKeyId.value.isEmpty && !Storage.shared.lfApnsKey.value.isEmpty + } + + /// The hub can push to this phone with a key: this app's own or one an admin shares. + var hasApnKey: Bool { Self.hasOwnApnKey || (isConnected && apnsKeyShared.value) } + + /// LF Hub needs iOS 26: its alarms ring through silent mode as AlarmKit alarms. + static var isSupported: Bool { + #if targetEnvironment(macCatalyst) + return false + #else + if #available(iOS 26, *) { return true } + return false + #endif + } + + /// The LoopFollow Hub background mode is selected and the app is connected to a + /// hub. The hub drives the features only once it confirmed it can (`drivesAlarms`). + var isHubMode: Bool { + Self.isSupported && isConnected && Storage.shared.backgroundRefreshType.value == .hub + } + + /// In Hub mode the hub checks the alarms and delivers them, and the app does not + /// keep itself running. Only once the hub has confirmed it is set up to (push + /// registered and reaching the phone, alarms received and on, patient followed), + /// while it looks healthy, and with AlarmKit allowed, so its alarms ring through + /// silent mode. Until then the app alarms itself and keeps itself running as in + /// Silent Tune. + var drivesAlarms: Bool { + isHubMode && hubConfirmed.value && HubWatchdog.hubLooksHealthy && Self.alarmKitAuthorized + && notificationsAllowed.value + } + + /// Notifications for LoopFollow are allowed and show alerts, as iOS last said. Read at + /// launch, on each watchdog check and on each return to the foreground. + let notificationsAllowed = StorageValue(key: "hubNotificationsAllowed", defaultValue: false) + + /// Notifications reach the user: allowed, not provisional, with alerts on. + static func notificationsAllow(_ status: UNAuthorizationStatus, alertSetting: UNNotificationSetting) -> Bool { + status == .authorized && alertSetting == .enabled + } + + /// Reads the notification permission. A change updates the hub's confirmation; losing + /// it makes the app alarm itself again. + func readNotificationPermission() async { + let settings = await UNUserNotificationCenter.current().notificationSettings() + let allowed = Self.notificationsAllow(settings.authorizationStatus, alertSetting: settings.alertSetting) + let changed = await MainActor.run { () -> Bool in + guard allowed != notificationsAllowed.value else { return false } + let wasDriving = drivesAlarms + notificationsAllowed.value = allowed + LogManager.shared.log(category: .hub, message: "Notifications for LoopFollow \(allowed ? "allowed" : "not allowed")") + if wasDriving, !drivesAlarms { MainViewController.shared?.backgroundModeChanged() } + return true + } + if changed, isHubMode { await refreshStatus() } + } + + /// The alarm types each hub API version checks, from the hub's Engine/Alarms/AlarmType.cs. + /// A hub skips an alarm of a type it does not know; this app checks those itself. + static let hubAlarmTypes: [Int: Set] = [ + 1: [.temporary, .iob, .cob, .low, .high, .fastDrop, .fastRise, .missedReading, .notLooping, .missedBolus, + .futureCarbs, .sensorChange, .pumpChange, .pump, .pumpBattery, .battery, .batteryDrop, .recBolus, + .overrideStart, .overrideEnd, .tempTargetStart, .tempTargetEnd, .buildExpire, .dbSize], + ] + + /// Whether a hub speaking `api` checks alarms of this type; the newest list at or below + /// that version counts. + static func hubChecks(_ type: AlarmType, api: Int?) -> Bool { + guard let api, let version = hubAlarmTypes.keys.filter({ $0 <= api }).max() else { return false } + return hubAlarmTypes[version]?.contains(type) == true + } + + /// The alarms the app checks itself while the hub drives the alarms: those of a type + /// the connected hub does not check. + func alarmsCheckedHere(_ alarms: [Alarm]) -> [Alarm] { + Self.alarmsCheckedHere(alarms, hubAPI: hubInfo.value?.api) + } + + static func alarmsCheckedHere(_ alarms: [Alarm], hubAPI: Int?) -> [Alarm] { + alarms.filter { !hubChecks($0.type, api: hubAPI) } + } + + /// The hub sets the app badge while it drives the alarms; the app leaves it alone. + var drivesBadge: Bool { drivesAlarms } + + /// The hub updates and renews the Live Activity while it drives the alarms. The + /// app still starts the activity and reports its tokens. + var drivesLiveActivity: Bool { drivesAlarms } + + /// In Hub mode the hub speaks each reading for Speak BG, under the same + /// conditions as it drives the alarms. + var drivesSpeech: Bool { drivesAlarms } + + /// The hub confirmed that it can deliver this device's alarms: alarms on, push + /// registered and reaching the phone, the latest alarm list received, the patient + /// followed, AlarmKit allowed. Cleared when Hub mode is entered or an alarm upload + /// fails, until the hub confirms again. + let hubConfirmed = StorageValue(key: "hubConfirmedAlarms", defaultValue: false) + /// Why the hub has not confirmed, in words for the user; empty while it has, and + /// before the first answer. + let hubProblems = StorageValue<[String]>(key: "hubConfirmationProblems", defaultValue: []) + /// What the hub reported about itself in its last status, while connected. + let hubInfo = StorageValue(key: "hubInfo", defaultValue: nil) + private let sentKeyFingerprint = StorageValue(key: "hubSentKeyFingerprint", defaultValue: "") + + /// The stored values that change what the hub builds for this device + /// (snapshot fields, alarm input). Sent as stored, with the phone's time + /// zone, decimal separator and time format. + static var clientSettings: [any ClientSetting] { + let storage = Storage.shared + return [ + storage.units, storage.predictionToLoad, storage.downloadPrediction, storage.downloadTreatments, + storage.downloadDays, storage.showYesterdayLine, storage.bgUpdateDelay, storage.infoDisplayItems, + storage.persistentNotification, storage.speakBG, storage.speakBGAlways, storage.speakLowBG, + storage.speakProactiveLowBG, storage.speakHighBG, storage.speakFastDropDelta, storage.speakLowBGLimit, + storage.speakHighBGLimit, storage.speakLanguage, + ] + } + + static var clientSettingKeys: [String] { clientSettings.map(\.key) } + + private var cancellables = Set() + private var uploadsObserved = false + + /// An alarm or client-settings change the hub has not received yet. Kept + /// across launches, so a change made offline reaches the hub on the next sync. + private let alarmsPending = StorageValue(key: "hubAlarmsUploadPending", defaultValue: false) + private let clientSettingsPending = StorageValue(key: "hubClientSettingsUploadPending", defaultValue: false) + /// What the hub drives changed (Hub mode entered or left) and the hub has not + /// taken it yet. + let hubSettingsPending = StorageValue(key: "hubSettingsUploadPending", defaultValue: false) + + /// What this phone takes from the hub after joining as an existing device or a copy of one. + enum Adoption: String, Codable { + case none + /// The device's alarms and client settings, as they are. + case replaced + /// The device's alarms and client settings, without its snoozes. + case copied + } + + /// The hub holds alarms and client settings this phone has not taken yet; until + /// it has, neither is uploaded. + private let adoptionPending = StorageValue(key: "hubAdoptionPending", defaultValue: .none) + + /// Uploads the alarms whenever they change (a snooze edits them too), the + /// client settings whenever one of them or the time zone or locale changes, what + /// the hub drives whenever the badge or Live Activity setting changes, and a change + /// of Hub mode the hub has not taken yet. The data sources follow `HubSourceSync`. + func observeChanges() { + guard !uploadsObserved else { return } + uploadsObserved = true + shareHubURL() + Task { await readNotificationPermission() } + Task { @MainActor in HubSourceSync.shared.observe() } + + Storage.shared.alarms.$value.map { _ in () } + .merge(with: Storage.shared.alarmConfiguration.$value.map { _ in () }) + .dropFirst(2) + .debounce(for: .seconds(1), scheduler: DispatchQueue.main) + .sink { [weak self] in + guard let self else { return } + alarmsPending.value = true + uploadSoon { client in + try await client.uploadAlarms() + if client.isHubMode { try await client.confirm() } + } + } + .store(in: &cancellables) + + NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification) + .merge(with: NotificationCenter.default.publisher(for: .NSSystemTimeZoneDidChange), + NotificationCenter.default.publisher(for: NSLocale.currentLocaleDidChangeNotification)) + .debounce(for: .seconds(2), scheduler: DispatchQueue.main) + .map { _ in Self.currentClientSettings() as NSDictionary } + .prepend(Self.currentClientSettings() as NSDictionary) + .removeDuplicates() + .dropFirst() + .sink { [weak self] _ in + guard let self else { return } + clientSettingsPending.value = true + uploadSoon { try await $0.uploadClientSettings() } + } + .store(in: &cancellables) + + Storage.shared.appBadge.$value.map { _ in () } + .merge(with: Storage.shared.laEnabled.$value.map { _ in () }) + .dropFirst(2) + .debounce(for: .seconds(1), scheduler: DispatchQueue.main) + .sink { [weak self] in + guard let self, isHubMode else { return } + hubSettingsPending.value = true + uploadSoon { try await $0.pushHubSettings() } + } + .store(in: &cancellables) + + if hubSettingsPending.value { uploadSoon { try await $0.pushHubSettings() } } + } + + /// Waits between upload attempts; the last one is followed by the next sync. + private static let uploadRetryDelays: [UInt64] = [5, 15, 30, 60, 120] + + /// Runs an upload with background time, so a snooze tapped on the lock + /// screen reaches the hub before iOS suspends the app again, and retries it + /// while the app runs (the hub is briefly away while it restarts). + private func uploadSoon(_ upload: @escaping (HubClient) async throws -> Void) { + guard isConnected else { return } + Task { @MainActor in + let backgroundTime = BackgroundTime(name: "LF Hub upload") + for (attempt, delay) in ([0] + Self.uploadRetryDelays).enumerated() { + if delay > 0 { try? await Task.sleep(nanoseconds: delay * 1_000_000_000) } + guard isConnected else { break } + do { + try await upload(self) + if attempt > 0 { + LogManager.shared.log(category: .hub, message: "Hub upload succeeded on attempt \(attempt + 1)") + } + break + } catch { + LogManager.shared.log(category: .hub, message: "Hub upload attempt \(attempt + 1) failed: \(error.localizedDescription)") + if !Self.isRetryable(error) { break } + } + } + backgroundTime.end() + } + } + + /// A failure that may pass on its own: no answer, the hub's own error, or its + /// rate limit. A rejected request fails again the same way. + static func isRetryable(_ error: Error) -> Bool { + switch error { + case let HubError.http(code, _): return code >= 500 || code == 429 + case HubError.notConnected, HubError.invalidURL: return false + default: return true + } + } + + /// Background time for an upload. When iOS takes it back, the upload's + /// retries go on the next time the app runs. + @MainActor private final class BackgroundTime { + private var id = UIBackgroundTaskIdentifier.invalid + + init(name: String) { + id = UIApplication.shared.beginBackgroundTask(withName: name) { [weak self] in self?.end() } + } + + func end() { + guard id != .invalid else { return } + UIApplication.shared.endBackgroundTask(id) + id = .invalid + } + } + + // MARK: - Calls + + /// The hub's address as entered, without surrounding space and slashes. Keys and + /// credentials travel to the hub, so only a local test hub may use plain http. + static func hubBase(_ url: String) throws -> String { + let base = url.trimmingCharacters(in: .whitespacesAndNewlines).trimmingCharacters(in: CharacterSet(charactersIn: "/")) + guard let parsed = URL(string: base), + parsed.scheme == "https" || ["localhost", "127.0.0.1"].contains(parsed.host ?? "") + else { throw HubError.invalidURL } + return base + } + + /// The patient's devices at the hub, offered when joining; empty for a fresh hub. + /// A hub without the device list answers 404 and takes new devices only. + func joinableDevices(url: String, setupToken: String) async throws -> [Device] { + let base = try Self.hubBase(url) + let (data, code) = try await request("POST", "/v1/claim/devices", body: ["setupToken": setupToken], + baseURL: base, key: nil) + switch code { + case 200: return try Self.decoder.decode(DeviceList.self, from: data).devices + case 404: return [] + default: throw Self.httpError(code, data) + } + } + + /// The patient's devices offered when joining with the invite: any to copy, a member's + /// to take over. + func joinableDevices(invite: Invite) async throws -> [Device] { + let (data, code) = try await request("POST", "/v1/join/devices", body: ["code": invite.code], + baseURL: invite.hubURL, key: nil) + guard code == 200 else { throw Self.joinError(code, data) } + return try Self.decoder.decode(DeviceList.self, from: data).devices + } + + /// Joins the hub as an admin device with the setup token, as `join` says, creating the + /// patient on a fresh hub. The patient's data sources at the hub become this app's; a + /// hub without sources gets this app's. + func claim(url: String, setupToken: String, patientName: String, deviceName: String, + join: Join = .new) async throws + { + let base = try Self.hubBase(url) + var body = Self.joinBody(join, deviceName: deviceName) + body["setupToken"] = setupToken + body["patientName"] = patientName + let data = try await send("POST", "/v1/claim", body: body, baseURL: base, key: nil) + try await joined(base: base, JSONDecoder().decode(ClaimResponse.self, from: data), join: join, withInvite: false) + } + + /// Joins the hub with an invite as a member device, as `join` says; the invite is used up. + /// The patient's data sources at the hub become this app's. + func join(invite: Invite, deviceName: String, join: Join = .new) async throws { + let (data, code) = try await request("POST", "/v1/join", body: Self.joinBody(join, deviceName: deviceName) + .merging(["code": invite.code]) { $1 }, baseURL: invite.hubURL, key: nil) + guard (200 ..< 300).contains(code) else { throw Self.joinError(code, data) } + try await joined(base: invite.hubURL, JSONDecoder().decode(ClaimResponse.self, from: data), join: join, + withInvite: true) + } + + private static func joinBody(_ join: Join, deviceName: String) -> [String: String] { + var body = ["deviceName": deviceName] + switch join { + case .new: break + case let .replace(id): body["replace"] = id.uuidString.lowercased() + case let .copy(id): body["copyFrom"] = id.uuidString.lowercased() + } + return body + } + + /// An invite the hub does not take, and its rate limit, in words for the user. + private static func joinError(_ code: Int, _ data: Data) -> HubError { + switch code { + case 403 where (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["detail"] as? String + == "unknown, used or expired invite": return .inviteNotValid + case 429: return .tooManyTries + default: return httpError(code, data) + } + } + + /// Stores the new device's key and the role joining gives. A replaced or copied + /// device's alarms and client settings become this app's, and the hub's data sources + /// this app's; a phone that joined with the setup token gives a hub without sources + /// this app's. Once the key is stored the phone is connected: a step after it that + /// fails is done by a later sync. + private func joined(base: String, _ claim: ClaimResponse, join: Join, withInvite: Bool) async throws { + let joinRole = Self.joinRole(withInvite: withInvite) + try await MainActor.run { + switch join { + case .new: adoptionPending.value = .none + case .replace: adoptionPending.value = .replaced + case .copy: adoptionPending.value = .copied + } + try deviceKey.set(claim.deviceKey) + role.value = joinRole + joinedWithInvite.value = withInvite + // The new device has no APN key at the hub yet. + sentKeyFingerprint.value = "" + Storage.shared.hubURL.value = base + } + shareHubURL() + LogManager.shared.log(category: .hub, message: "Joined hub as \(joinRole.rawValue)" + + (claim.role.map { $0 == joinRole ? "" : " (hub said \($0.rawValue))" } ?? "") + ": \(join.logDescription)") + + do { + try await adoptIfPending() + let status = try await status() + await record(status) + try await HubSourceSync.shared.takeHubSources(revision: status.patient.sourceRevision, + sendOwnIfNone: !withInvite) + } catch { + LogManager.shared.log(category: .hub, message: "Joined, but setting up with the hub failed; a later sync finishes it: \(error.localizedDescription)") + } + } + + /// Makes the hub's alarms and client settings for this device this app's own, once + /// after joining as an existing device or a copy of one. A part the hub does not + /// hold stays as it is here. + private func adoptIfPending() async throws { + let adoption = adoptionPending.value + guard adoption != .none else { return } + let alarms = try await optionalData("/v1/device/alarms") + let settings = try await optionalData("/v1/device/clientsettings") + let export = try alarms.map { try JSONDecoder().decode(AlarmSettingsExport.self, from: $0) } + let values = try settings.map { data in + guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Client settings are not an object")) + } + return object + } + await MainActor.run { + if let export { Self.apply(export, keepSnoozes: adoption == .replaced) } + if let values { Self.applyClientSettings(values) } + adoptionPending.value = .none + NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil) + } + LogManager.shared.log(category: .hub, message: "Took \(export?.alarms.count ?? 0) alarms" + + (values == nil ? "" : " and the client settings") + " from the hub") + } + + /// Replaces this app's alarms and alarm configuration with the export's. A copy + /// starts unsnoozed and unmuted. + @MainActor static func apply(_ export: AlarmSettingsExport, keepSnoozes: Bool) { + var alarms = export.alarms + var configuration = export.alarmConfiguration + if !keepSnoozes { + for index in alarms.indices { + alarms[index].snoozedUntil = nil + } + configuration.snoozeUntil = nil + configuration.muteUntil = nil + } + Storage.shared.alarms.value = alarms + Storage.shared.alarmConfiguration.value = configuration + } + + /// Sets each client setting the hub holds in a form its stored value takes; the + /// rest stay as they are. + @MainActor static func applyClientSettings(_ values: [String: Any]) { + for setting in clientSettings { + guard let value = values[setting.key], + let json = try? JSONSerialization.data(withJSONObject: value, options: .fragmentsAllowed) + else { continue } + if !setting.assign(json: json) { + LogManager.shared.log(category: .hub, message: "Hub client setting \(setting.key) skipped: unexpected form") + } + } + } + + func status() async throws -> Status { + let data = try await send("GET", "/v1/me") + return try Self.decoder.decode(Status.self, from: data) + } + + /// The patient's devices at the hub, this one marked `current`. + func devices() async throws -> [Device] { + try Self.decoder.decode(DeviceList.self, from: await send("GET", "/v1/devices")).devices + } + + /// Removes another device of the patient from the hub; its key stops working. Admins only. + func deleteDevice(_ id: UUID) async throws { + _ = try await send("DELETE", "/v1/devices/\(id.uuidString.lowercased())") + } + + /// Makes a device of the patient an admin or a member. Admins only; the hub keeps one admin. + func setRole(_ role: Role, of id: UUID) async throws { + _ = try await send("PUT", "/v1/devices/\(id.uuidString.lowercased())/role", body: ["role": role.rawValue]) + } + + /// A new invite for one phone to join as a member. Admins only. + func createInvite() async throws -> (invite: Invite, expiresAt: Date) { + let response = try Self.decoder.decode(InviteResponse.self, from: await send("POST", "/v1/invites")) + guard let invite = Invite(hubURL: Storage.shared.hubURL.value, code: response.code) else { + throw HubError.invalidURL + } + return (invite, response.expiresAt) + } + + /// The patient's data sources at the hub. + func sources() async throws -> Sources { + try JSONDecoder().decode(Sources.self, from: await send("GET", "/v1/patient/source")) + } + + /// Sets the patient's sources to these settings; one that is not set is removed at + /// the hub. Admins only. + func sendSources(_ sources: LocalSources) async throws { + guard !joinedWithInvite.value else { throw HubError.invitedPhone } + var body: [String: Any] = [:] + if sources.hasNightscout { + body["nightscoutUrl"] = sources.nightscoutURL + body["token"] = sources.token + } + if sources.hasDexcom { + body["dexcom"] = [ + "username": sources.dexcomUser, + "password": sources.dexcomPassword, + "server": sources.dexcomServer, + ] + } + _ = try await send("PUT", "/v1/patient/source", body: body) + } + + /// Asks the hub to deliver a test alarm to this device with the first enabled alarm's sound. + func sendTestAlarm(delaySeconds: Int = 0) async throws { + let alarm = Storage.shared.alarms.value.first(where: \.isEnabled) ?? Storage.shared.alarms.value.first + if let alarm { await HubAlarmSounds.prepare(for: [alarm]) } + var body: [String: Any] = ["title": "LF Hub test alarm", "delaySeconds": delaySeconds] + if let alarm { body["sound"] = HubAlarmSounds.notificationName(for: alarm.soundFile) } + _ = try await send("POST", "/v1/device/testalarm", body: body) + } + + /// Turns off the alarms the hub turned off (a Temporary alarm after it + /// fired), as LoopFollow does locally. + private func applyHubDisabledAlarms() async throws { + let ids = try Set((await status().device.disabledAlarmIds ?? []).compactMap { UUID(uuidString: $0) }) + guard !ids.isEmpty else { return } + await MainActor.run { + var alarms = Storage.shared.alarms.value + for index in alarms.indices where ids.contains(alarms[index].id) { + alarms[index].isEnabled = false + } + Storage.shared.alarms.value = alarms + } + } + + /// The alarm list and configuration in the app's own export format. + func uploadAlarms() async throws { + guard isConnected else { return } + try await adoptIfPending() + await HubAlarmSounds.prepare(for: Storage.shared.alarms.value) + guard let json = AlarmSettingsExport.fromCurrentStorage().encodeToJSON() else { return } + do { + _ = try await send("PUT", "/v1/device/alarms", rawBody: Data(json.utf8)) + } catch { + // The hub holds an older alarm list: the app alarms itself until it takes this one. + await setConfirmed(false, problems: [Problem.alarmsNotReceived]) + throw error + } + await MainActor.run { alarmsPending.value = false } + } + + func uploadClientSettings() async throws { + guard isConnected else { return } + try await adoptIfPending() + _ = try await send("PUT", "/v1/device/clientsettings", body: Self.currentClientSettings()) + await MainActor.run { clientSettingsPending.value = false } + } + + /// The stored JSON of each client setting, plus what the phone's locale + /// decides: the time zone, the decimal separator and the time format. + static func currentClientSettings() -> [String: Any] { + var settings: [String: Any] = [ + "timeZone": TimeZone.current.identifier, + "decimalSeparator": Locale.current.decimalSeparator ?? ".", + "timeFormat": DateFormatter.dateFormat(fromTemplate: "jmm", options: 0, locale: .current) ?? "HH:mm", + ] + for key in clientSettingKeys { + guard let data = UserDefaults.standard.data(forKey: key), + let value = try? JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed) + else { continue } + settings[key] = value + } + return settings + } + + /// Sends the current activity's push token (empty when there is none), the + /// push-to-start token and the renewal deadline. + func reportLiveActivity() async throws { + #if !targetEnvironment(macCatalyst) + guard isConnected else { return } + let (activityToken, renewBy) = await MainActor.run { + (LiveActivityManager.shared.currentPushToken ?? "", Storage.shared.laRenewBy.value) + } + var body: [String: Any] = ["activityToken": activityToken] + let pushToStart = Storage.shared.laPushToStartToken.value + if !pushToStart.isEmpty { body["pushToStartToken"] = pushToStart } + if renewBy > 0 { body["renewBy"] = renewBy } + _ = try await send("PUT", "/v1/device/liveactivity", body: body) + #endif + } + + /// Fire-and-forget form of `reportLiveActivity` for lifecycle callbacks. + func reportLiveActivitySoon() { + guard isConnected else { return } + Task { + do { try await reportLiveActivity() } catch { + LogManager.shared.log(category: .hub, message: "Hub Live Activity report failed: \(error.localizedDescription)") + } + } + } + + enum Health: Equatable { + case healthy + case unhealthy + /// The check did not run to an answer (the app was suspended mid-request). + case unknown + } + + /// Pushes to this device may fail this long before the hub counts as unable + /// to reach it. + static let pushFailureTolerance: TimeInterval = 10 * 60 + + /// The hub answers for this device (so its database and this device's key + /// work), its engine is alive, and its pushes reach this device. Records the + /// hub's confirmation from the same answer. + func health() async -> Health { + guard isConnected else { return .unhealthy } + do { + let status = try await status() + await absorb(status) + let (_, code) = try await request("GET", "/livez", key: nil) + guard code == 200 else { return .unhealthy } + return Self.health(of: status, now: Date()) + } catch { + return Self.isCancellation(error) ? .unknown : .unhealthy + } + } + + /// Healthy once a push has reached this device and while pushes have not been + /// failing for `pushFailureTolerance`. + static func health(of status: Status, now: Date) -> Health { + guard status.device.lastPushOkAt != nil else { return .unhealthy } + if let since = status.device.pushFailingSince, now.timeIntervalSince(since) >= pushFailureTolerance { + return .unhealthy + } + return .healthy + } + + static func isCancellation(_ error: Error) -> Bool { + error is CancellationError || (error as? URLError)?.code == .cancelled + } + + /// The patient's bundle, or nil when it is still `since` after waiting up to + /// `wait` seconds for a newer one (the hub answers 304). + func bundle(since: Int64, wait: Int) async throws -> Data? { + guard isConnected else { throw HubError.notConnected } + var path = "/v1/patient/bundle" + if since > 0 { path += "?since=\(since)" + (wait > 0 ? "&wait=\(wait)" : "") } + let (data, code) = try await request("GET", path, timeout: TimeInterval(wait + 15)) + switch code { + case 200: return data + case 304: return nil + default: throw Self.httpError(code, data) + } + } + + /// Log files to attach to Share Logs: the hub's lines about this device and + /// the notification service extension's log from the App Group. + func diagnosticLogFiles() async -> [URL] { + var files: [URL] = [] + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("HubLogs", isDirectory: true) + try? FileManager.default.removeItem(at: directory) + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + + if isConnected { + let hubLog = directory.appendingPathComponent("LF Hub.log") + do { + let data = try await send("GET", "/v1/device/log?hours=48") + try data.write(to: hubLog) + files.append(hubLog) + } catch { + let note = Data("Could not fetch the hub log: \(error.localizedDescription)\n".utf8) + if (try? note.write(to: hubLog)) != nil { files.append(hubLog) } + } + } + + if let group = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: AppGroupID.current()) { + let extensionLog = group.appendingPathComponent("Library/Logs/lfhub-nse.log") + let copy = directory.appendingPathComponent("LF Hub notification extension.log") + if (try? FileManager.default.copyItem(at: extensionLog, to: copy)) != nil { files.append(copy) } + } + return files + } + + /// Tells the hub the alarm was snoozed or acknowledged here, so it stops + /// repeating it at once. + func alarmHandled(_ alarmID: UUID) { + guard isHubMode else { return } + uploadSoon { _ = try await $0.send("POST", "/v1/device/alarm/\(alarmID.uuidString)/handled") } + } + + /// The alarm the hub is sounding that is the current alarm here, if any. + @MainActor private var adoptedAlarm: UUID? + + /// Makes an alarm the hub delivered the current alarm, so the snooze action and + /// the Snoozer act on it like on a locally fired one. + @MainActor func adoptHubAlarm(_ alarmID: UUID) { + adoptedAlarm = alarmID + if Observable.shared.currentAlarm.value != alarmID { + Observable.shared.currentAlarm.value = alarmID + } + } + + /// The hub no longer sounds the adopted alarm. While the hub drives the alarms it + /// stops being the current alarm, without telling the hub it was handled; while + /// the app alarms itself, its own check decides. + @MainActor private func hubStoppedAlarm() { + guard let alarmID = adoptedAlarm else { return } + adoptedAlarm = nil + guard drivesAlarms, Observable.shared.currentAlarm.value == alarmID else { return } + LogManager.shared.log(category: .hub, message: "Hub alarm \(alarmID.uuidString.prefix(8)) stopped at the hub") + Observable.shared.currentAlarm.value = nil + } + + /// Reads the hub's view of this device: whether it confirms the alarms, and + /// whether the adopted alarm still sounds there. + func refreshStatus() async { + guard isConnected else { return } + do { + try await confirm() + } catch HubError.http(401, _) { + await forgotten() + } catch { + LogManager.shared.log(category: .hub, message: "Hub status failed: \(error.localizedDescription)") + } + } + + /// Leaves the hub here at once. The device stays pending removal until the hub + /// confirms it, so the hub stops sending to this phone. + func disconnect() async { + let key = deviceKey.value + let base = Storage.shared.hubURL.value + if !key.isEmpty, !base.isEmpty, !pendingUnregistration.keep(key: key, hubURL: base) { + LogManager.shared.log(category: .hub, message: "The hub device key could not be kept for removal") + } + await localDisconnect() + await unregisterPending() + } + + /// Asks the hub to remove the device this phone left, if one is pending. Done once the + /// hub removes it or no longer knows its key; otherwise tried again on a later sync. + func unregisterPending() async { + guard let pending = pendingUnregistration.current else { return } + do { + let (_, code) = try await request("DELETE", "/v1/device", baseURL: pending.hubURL, key: pending.key) + if HubPendingUnregistration.isDone(code) { + pendingUnregistration.clear() + LogManager.shared.log(category: .hub, message: "Hub removed the device this phone left (\(code))") + } else { + LogManager.shared.log(category: .hub, message: "Hub did not remove the device this phone left: \(code)") + } + } catch { + LogManager.shared.log(category: .hub, message: "Hub removal of the device this phone left failed: \(error.localizedDescription)") + } + } + + /// Forgets the hub; Hub mode falls back to Silent Tune so the app alarms again. + @MainActor private func localDisconnect() { + deviceKey.clear() + shareHubURL() + setConfirmed(false, problems: []) + hubInfo.value = nil + role.value = .admin + joinedWithInvite.value = false + apnsKeyShared.value = false + patientName.value = "" + sentKeyFingerprint.value = "" + HubSourceSync.shared.forget() + adoptionPending.value = .none + if Storage.shared.backgroundRefreshType.value == .hub { + BackgroundRefreshSettingsViewModel.change(to: .silentTune) + LogManager.shared.log(category: .hub, message: "Hub disconnected; Background Refresh back to Silent Tune") + } + hubSettingsPending.value = false + LogManager.shared.log(category: .hub, message: "Disconnected from hub") + } + + /// The hub's address in the App Group while connected, for the notification + /// service extension's delivery receipts; removed when not connected. + private func shareHubURL() { + let defaults = UserDefaults(suiteName: AppGroupID.current()) + let url = isConnected ? Storage.shared.hubURL.value : nil + guard defaults?.string(forKey: HubAppGroup.hubURLKey) != url else { return } + if let url { + defaults?.set(url, forKey: HubAppGroup.hubURLKey) + } else { + defaults?.removeObject(forKey: HubAppGroup.hubURLKey) + } + } + + private let syncLock = NSLock() + private var lastSync: Task? + + /// Brings the hub up to date with this device: APNs registration and the + /// settings the hub acts on. Safe to call often; calls run one after another. + func sync() async { + let task = syncLock.withLock { + let previous = lastSync + let next = Task { [self] in + await previous?.value + await performSync() + } + lastSync = next + return next + } + await task.value + } + + private func performSync() async { + shareHubURL() + await unregisterPending() + guard isConnected else { return } + // In Hub mode the open app long-polls the hub and runs the watchdog's check. + await MainActor.run { + if UIApplication.shared.applicationState == .active { HubBundle.shared.startListening() } + } + do { + try await registerApns() + try await reportLiveActivity() + try await uploadClientSettings() + if isHubMode || alarmsPending.value { + try await applyHubDisabledAlarms() + try await uploadAlarms() + } + } catch HubError.http(401, _) { + await forgotten() + return + } catch { + LogManager.shared.log(category: .hub, message: "Hub sync failed: \(error.localizedDescription)") + } + + // What the hub drives goes even after a failed step: leaving Hub mode must + // reach the hub, or both would alarm. + do { + try await pushHubSettings() + } catch HubError.http(401, _) { + await forgotten() + } catch { + LogManager.shared.log(category: .hub, message: "Hub settings push failed: \(error.localizedDescription)") + uploadSoon { try await $0.pushHubSettings() } + } + } + + private func forgotten() async { + LogManager.shared.log(category: .hub, message: "Hub no longer knows this device") + await localDisconnect() + } + + /// Tells the hub what it drives for this device and records its confirmation. + /// The badge and the Live Activity follow their settings in this app. + private func pushHubSettings() async throws { + _ = try await send("PUT", "/v1/device/settings", + body: ["badge": isHubMode && Storage.shared.appBadge.value, + "liveActivity": isHubMode && Storage.shared.laEnabled.value, + "alarms": isHubMode, + "unit": unit, + "appVersion": Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0", + "features": Self.features] as [String: Any]) + await MainActor.run { hubSettingsPending.value = false } + try await confirm() + } + + /// Reads the hub's view of this device and records whether it delivers the alarms. + private func confirm() async throws { + try await absorb(await status()) + } + + /// Records the hub's answer and takes a newer revision of the patient's data sources. + private func absorb(_ status: Status) async { + await record(status) + await HubSourceSync.shared.follow(status) + } + + /// Records whether the hub's status confirms this device's alarms, and why not. + /// While it confirms, the alarm the hub is sounding becomes the current alarm, so + /// the in-app snooze acts on it; an adopted alarm the hub no longer sounds is let go. + @MainActor private func record(_ status: Status) { + if hubInfo.value != status.hub { hubInfo.value = status.hub } + role.value = Self.role(reported: status.device.role, joinedWithInvite: joinedWithInvite.value) + apnsKeyShared.value = status.device.apnsKeyShared == true + patientName.value = status.patient.name + guard isHubMode else { + setConfirmed(false, problems: []) + return + } + let problems = Self.confirmationProblems(status, alarmsPending: alarmsPending.value || adoptionPending.value != .none, + alarmKitAuthorized: Self.alarmKitAuthorized, + notificationsAllowed: notificationsAllowed.value, now: Date()) + setConfirmed(problems.isEmpty, problems: problems) + if let alarmID = status.device.activeAlarm?.alarmId.flatMap(UUID.init(uuidString:)) { + if problems.isEmpty { adoptHubAlarm(alarmID) } + } else { + hubStoppedAlarm() + } + } + + /// Whether the hub's status shows it delivering this device's alarms. + static func confirms(_ status: Status, hubMode: Bool, alarmsPending: Bool, alarmKitAuthorized: Bool, + notificationsAllowed: Bool, now: Date) -> Bool + { + hubMode && confirmationProblems(status, alarmsPending: alarmsPending, alarmKitAuthorized: alarmKitAuthorized, + notificationsAllowed: notificationsAllowed, now: now).isEmpty + } + + /// What keeps the hub from delivering this device's alarms, in words for the user. + enum Problem { + static let noPush = "The hub cannot notify this phone yet. Add the APN key under Settings → APN and allow notifications." + static let alarmsOff = "Alarms are not on at the hub yet." + static let alarmsNotReceived = "The hub does not have this phone's latest alarms yet." + static let notFollowed = "The hub is not reading the patient's data yet. Check the data sources under Settings → LF Hub." + static let noPushYet = "No notification from the hub has arrived yet." + static let pushFailing = "Notifications from the hub are failing." + static let alarmKitDenied = "Alarms may not ring through silent mode. Allow it under Settings → LF Hub." + static let alarmKitUnavailable = "Ringing through silent mode requires iOS 26." + static let notificationsOff = "Notifications for LoopFollow are off or delivered quietly, so LF Hub's alarms would not show." + static let hubOutdated = "The hub needs an update to work with this LoopFollow." + } + + /// Whether the hub speaks this app's API version and provides what this app requires. + static func supports(_ hub: Status.Hub?) -> Bool { + guard let hub, hub.api >= api else { return false } + return requiredHubFeatures.allSatisfy(hub.features.contains) + } + + /// Each reason the status gives for the hub not delivering this device's alarms: + /// a hub version this app does not work with, push not registered or not reaching the + /// phone, alarms off at the hub, the latest alarm list not received, the patient not + /// followed, AlarmKit not allowed, or notifications not allowed. + static func confirmationProblems(_ status: Status, alarmsPending: Bool, alarmKitAuthorized: Bool, + notificationsAllowed: Bool, now: Date) -> [String] + { + var problems: [String] = [] + if !supports(status.hub) { problems.append(Problem.hubOutdated) } + if !status.device.apnsConfigured { problems.append(Problem.noPush) } + if status.device.settings?.alarms != true { problems.append(Problem.alarmsOff) } + if alarmsPending || status.device.alarmsReceived != true { problems.append(Problem.alarmsNotReceived) } + if status.patient.followed != true { problems.append(Problem.notFollowed) } + if status.device.apnsConfigured, health(of: status, now: now) != .healthy { + problems.append(status.device.lastPushOkAt == nil ? Problem.noPushYet : Problem.pushFailing) + } + if let problem = alarmKitProblem(authorized: alarmKitAuthorized) { problems.append(problem) } + if !notificationsAllowed { problems.append(Problem.notificationsOff) } + return problems + } + + /// Why hub alarms cannot ring as system alarms, if they cannot. + static func alarmKitProblem(authorized: Bool) -> String? { + guard !authorized else { return nil } + if #available(iOS 26, *) { return Problem.alarmKitDenied } + return Problem.alarmKitUnavailable + } + + /// AlarmKit lets the hub's alarms ring as system alarms, through silent mode. + static var alarmKitAuthorized: Bool { + #if !targetEnvironment(macCatalyst) + if #available(iOS 26, *) { return HubAlarmKit.isAuthorized } + #endif + return false + } + + @MainActor private func setConfirmed(_ confirmed: Bool, problems: [String]) { + let wasDriving = drivesAlarms + if confirmed != hubConfirmed.value || problems != hubProblems.value { + LogManager.shared.log(category: .hub, message: confirmed + ? "Hub confirmed it delivers this device's alarms" + : "Hub does not deliver this device's alarms; the app keeps alarming. \(problems.joined(separator: " "))") + } + hubConfirmed.value = confirmed + hubProblems.value = problems + // In the background the app now keeps itself running, as in Silent Tune. + if wasDriving, !drivesAlarms { MainViewController.shared?.backgroundModeChanged() } + } + + /// The key the hub pushes to this device with. + enum ApnsKeyUse: Equatable { + /// This app's own key; the .p8 goes along only when `sendKey`. + case own(keyId: String, sendKey: Bool) + /// An admin device's key, which the hub shares with this device. + case shared + /// No key yet. + case none + } + + /// This app's own key when it has one and may send it, otherwise an admin's on a hub + /// that shares keys. The .p8 goes only when it changed since the hub last took it. + static func apnsKeyUse(keyId: String, key: String, sentFingerprint: String, hubSharesKeys: Bool, + ownKeyAllowed: Bool = true) -> ApnsKeyUse + { + if ownKeyAllowed, !keyId.isEmpty, !key.isEmpty { + return .own(keyId: keyId, sendKey: sentFingerprint != keyFingerprint(keyId: keyId, key: key)) + } + return hubSharesKeys ? .shared : .none + } + + static func keyFingerprint(keyId: String, key: String) -> String { + SHA256.hash(data: Data((keyId + key).utf8)).map { String(format: "%02x", $0) }.joined() + } + + private func registerApns() async throws { + let token = Observable.shared.loopFollowDeviceToken.value + let keyId = Storage.shared.lfKeyId.value + let key = Storage.shared.lfApnsKey.value + let use = Self.apnsKeyUse(keyId: keyId, key: key, sentFingerprint: sentKeyFingerprint.value, + hubSharesKeys: offers("sharedApnsKey"), ownKeyAllowed: !joinedWithInvite.value) + guard !token.isEmpty, use != .none, let teamId = BuildDetails.default.teamID, + let bundleId = Bundle.main.bundleIdentifier + else { + LogManager.shared.log(category: .hub, message: "Hub APNs registration waits for the device token and APN key", isDebug: true) + return + } + var body = [ + "token": token, + "environment": BuildDetails.default.isTestFlightBuild() ? "production" : "sandbox", + "bundleId": bundleId, + "teamId": teamId, + ] + switch use { + case .none: + return + case .shared: + do { + _ = try await send("PUT", "/v1/device/apns", body: body) + } catch let HubError.http(400, message) { + // No admin device has a key to share; the rest of the sync goes on. + LogManager.shared.log(category: .hub, message: "Hub APNs registration without a key refused: \(message)") + await MainActor.run { apnsKeyShared.value = false } + return + } + await MainActor.run { sentKeyFingerprint.value = "" } + case let .own(keyId, sendKey): + body["keyId"] = keyId + if sendKey { body["key"] = key } + do { + _ = try await send("PUT", "/v1/device/apns", body: body) + } catch HubError.http(400, _) where body["key"] == nil { + body["key"] = key + _ = try await send("PUT", "/v1/device/apns", body: body) + } + await MainActor.run { sentKeyFingerprint.value = Self.keyFingerprint(keyId: keyId, key: key) } + } + } + + /// Registers this phone for notifications again and sends the hub the registration + /// with the APN key. + func registerAgain() async { + await MainActor.run { + sentKeyFingerprint.value = "" + UIApplication.shared.registerForRemoteNotifications() + } + LogManager.shared.log(category: .hub, message: "Registering for the hub's notifications again") + await sync() + } + + // MARK: - Transport + + private var unit: String { + UnitSettingsStore.shared.glucoseUnit == .mmolL ? "mmol" : "mgdl" + } + + static let decoder: JSONDecoder = { + let decoder = JSONDecoder() + let fractional = ISO8601DateFormatter() + fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let plain = ISO8601DateFormatter() + decoder.dateDecodingStrategy = .custom { decoder in + let string = try decoder.singleValueContainer().decode(String.self) + if let date = fractional.date(from: string) ?? plain.date(from: string) { return date } + throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "Bad date \(string)")) + } + return decoder + }() + + /// The response body of a call answered with 2xx; any other answer throws. + private func send(_ method: String, _ path: String, body: Any? = nil, rawBody: Data? = nil, + baseURL: String = Storage.shared.hubURL.value, + key: String? = HubClient.shared.deviceKey.value) async throws -> Data + { + let (data, code) = try await request(method, path, body: body, rawBody: rawBody, baseURL: baseURL, key: key) + guard (200 ..< 300).contains(code) else { throw Self.httpError(code, data) } + return data + } + + /// The response body of a GET answered with 200, or nil when the hub answered 404. + private func optionalData(_ path: String) async throws -> Data? { + let (data, code) = try await request("GET", path) + switch code { + case 200: return data + case 404: return nil + default: throw Self.httpError(code, data) + } + } + + private static func httpError(_ code: Int, _ data: Data) -> HubError { + let detail = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["detail"] as? String + return HubError.http(code, detail ?? HTTPURLResponse.localizedString(forStatusCode: code)) + } + + /// The response body and status code of a call; `key` nil sends no key. + private func request(_ method: String, _ path: String, body: Any? = nil, rawBody: Data? = nil, + baseURL: String = Storage.shared.hubURL.value, + key: String? = HubClient.shared.deviceKey.value, + timeout: TimeInterval = 15) async throws -> (Data, Int) + { + if let key, key.isEmpty { throw HubError.notConnected } + guard let url = URL(string: baseURL + path), url.scheme == "https" || url.scheme == "http" else { + throw HubError.invalidURL + } + + var request = URLRequest(url: url, timeoutInterval: timeout) + request.httpMethod = method + request.cachePolicy = .reloadIgnoringLocalCacheData + if let key { request.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization") } + if let body { + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: body) + } else if let rawBody { + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = rawBody + } + + let (data, response) = try await URLSession.shared.data(for: request) + return (data, (response as? HTTPURLResponse)?.statusCode ?? 0) + } +} + +extension HubClient.Join { + var logDescription: String { + switch self { + case .new: return "new device" + case let .replace(id): return "replacing device \(id.uuidString.prefix(8))" + case let .copy(id): return "copying device \(id.uuidString.prefix(8))" + } + } +} + +/// A stored value sent to the hub as a client setting, and taken back from it. +protocol ClientSetting: AnyObject { + var key: String { get } + /// Sets the value from its JSON; false when the JSON is not of the value's type. + func assign(json: Data) -> Bool +} + +extension StorageValue: ClientSetting { + func assign(json: Data) -> Bool { + guard let decoded = try? JSONDecoder().decode(T.self, from: json) else { return false } + value = decoded + return true + } +} diff --git a/LoopFollow/Hub/HubDeviceKey.swift b/LoopFollow/Hub/HubDeviceKey.swift new file mode 100644 index 000000000..5ae1a5885 --- /dev/null +++ b/LoopFollow/Hub/HubDeviceKey.swift @@ -0,0 +1,152 @@ +// LoopFollow +// HubDeviceKey.swift + +import Combine +import Foundation +import Security + +/// Where the hub device key is kept. +protocol HubKeyStore { + func load() -> String? + /// False when the key was not stored. + func save(_ key: String) -> Bool + func delete() +} + +/// The device key in the Keychain. It is readable after the first unlock, so pushes +/// and background tasks can use it, and never leaves this phone, not even in a backup, +/// so a phone restored from a backup does not share this device's identity at the hub. +struct HubKeychain: HubKeyStore { + private static let service = "LoopFollowHub" + /// The item: the device key, or `pendingUnregistration` for the key of a device this phone left. + private let account: String + + init(account: String = "deviceKey") { + self.account = account + } + + static let pendingUnregistration = "pendingUnregistrationKey" + + private var query: [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Self.service, + kSecAttrAccount as String: account, + ] + } + + func load() -> String? { + var search = query + search[kSecReturnData as String] = true + search[kSecMatchLimit as String] = kSecMatchLimitOne + var result: AnyObject? + guard SecItemCopyMatching(search as CFDictionary, &result) == errSecSuccess, + let data = result as? Data + else { return nil } + return String(data: data, encoding: .utf8) + } + + func save(_ key: String) -> Bool { + let attributes: [String: Any] = [ + kSecValueData as String: Data(key.utf8), + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ] + var status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + if status == errSecItemNotFound { + status = SecItemAdd(query.merging(attributes) { $1 } as CFDictionary, nil) + } + if status != errSecSuccess { + LogManager.shared.log(category: .hub, message: "Keychain save of the hub \(account) failed: \(status)") + } + return status == errSecSuccess + } + + func delete() { + let status = SecItemDelete(query as CFDictionary) + if status != errSecSuccess, status != errSecItemNotFound { + LogManager.shared.log(category: .hub, message: "Keychain delete of the hub \(account) failed: \(status)") + } + } +} + +/// The key this device authenticates to the hub with; empty while not connected. +/// A key kept in the app's defaults moves to the store once the store takes it. +/// Re-read after a launch before the first unlock, like the storage values. +final class HubDeviceKey: ObservableObject, BFUReloadable { + @Published private(set) var value = "" + + /// The Keychain; a stand-in in tests, which run without a Keychain. + var store: HubKeyStore + private let legacy: StorageValue + + init(store: HubKeyStore = HubKeychain(), + legacy: StorageValue = StorageValue(key: "hubDeviceKey", defaultValue: "")) + { + self.store = store + self.legacy = legacy + reload() + StorageReadiness.register(self) + } + + func reload() { + let old = legacy.value + if !old.isEmpty, store.save(old) { + legacy.value = "" + legacy.remove() + LogManager.shared.log(category: .hub, message: "Hub device key moved to the Keychain") + } + let key = store.load() ?? legacy.value + if key != value { value = key } + } + + /// Stores a new key; throws when the store does not take it. + func set(_ key: String) throws { + guard store.save(key) else { throw HubClient.HubError.keyNotSaved } + value = key + } + + func clear() { + store.delete() + value = "" + } +} + +/// A device this phone left whose removal the hub has not confirmed: its key, kept like +/// the device key, and the hub's address. Kept until the hub removes the device or +/// answers that it no longer knows the key. +final class HubPendingUnregistration { + /// The Keychain; a stand-in in tests. + var store: HubKeyStore + private let hubURL: StorageValue + + init(store: HubKeyStore = HubKeychain(account: HubKeychain.pendingUnregistration), + hubURL: StorageValue = StorageValue(key: "hubPendingUnregistrationURL", defaultValue: "")) + { + self.store = store + self.hubURL = hubURL + } + + /// The key and hub address of the device to remove; nil when none is pending. + var current: (key: String, hubURL: String)? { + guard !hubURL.value.isEmpty, let key = store.load(), !key.isEmpty else { return nil } + return (key, hubURL.value) + } + + /// Keeps the device for removal; false when the key could not be stored. + @discardableResult + func keep(key: String, hubURL url: String) -> Bool { + guard store.save(key) else { return false } + hubURL.value = url + return true + } + + func clear() { + store.delete() + hubURL.value = "" + } + + /// The hub removed the device (2xx), or no longer knows its key (401). + static func isDone(_ code: Int) -> Bool { + (200 ..< 300).contains(code) || code == 401 + } +} diff --git a/LoopFollow/Hub/HubManagedSettings.swift b/LoopFollow/Hub/HubManagedSettings.swift new file mode 100644 index 000000000..8953eaf95 --- /dev/null +++ b/LoopFollow/Hub/HubManagedSettings.swift @@ -0,0 +1,101 @@ +// LoopFollow +// HubManagedSettings.swift + +import SwiftUI + +/// Data-source settings while LF Hub keeps the patient's data source the same on every +/// phone. A member, and any phone that joined with an invite, sees them read only. An admin confirms once that a change reaches every +/// phone; the change then goes to the hub after the app's own check of it. +struct HubManagedSources: View { + @ViewBuilder let content: Content + + @ObservedObject private var deviceKey = HubClient.shared.deviceKey + @ObservedObject private var role = HubClient.shared.role + @ObservedObject private var hubInfo = HubClient.shared.hubInfo + @ObservedObject private var patientName = HubClient.shared.patientName + @ObservedObject private var sync = HubSourceSync.shared + @State private var unlocked = false + @State private var confirming = false + + private var patient: String { + patientName.value.isEmpty ? "the patient" : patientName.value + } + + var body: some View { + if sync.isActive { + Section { + if HubClient.shared.sendsOwnSources { + if unlocked { + changeStatus + } else { + Text("LF Hub shares this data source with every phone following \(patient).") + .foregroundColor(.secondary) + Button("Change Data Source") { confirming = true } + } + } else { + Label("Managed by LF Hub", systemImage: "lock.fill") + Text("An admin sets \(patient)'s data source for every phone.") + .font(.footnote) + .foregroundColor(.secondary) + } + } + .alert("Change the data source?", isPresented: $confirming) { + Button("Cancel", role: .cancel) {} + Button("Continue") { + sync.beginEditing() + unlocked = true + } + } message: { + Text("This changes the data source for everyone following \(patient).") + } + } + Group { content } + .disabled(sync.isActive && !(HubClient.shared.sendsOwnSources && unlocked)) + } + + @ViewBuilder + private var changeStatus: some View { + switch sync.state { + case .idle: + Text("Changes go to LF Hub once LoopFollow has checked them.") + .font(.footnote) + .foregroundColor(.secondary) + case .checking: + HStack { + ProgressView() + Text("Checking the data source…").foregroundColor(.secondary) + } + case .sent: + Label("Sent to LF Hub", systemImage: "checkmark.circle.fill") + .foregroundColor(.green) + case let .failed(reason): + Text(reason).font(.footnote).foregroundColor(.red) + Button("Try Again") { Task { await sync.retry() } } + } + } +} + +/// The APN settings while LF Hub pushes to this phone with the APN key an admin added. +struct HubManagedApnKey: View { + @ViewBuilder let content: Content + + @ObservedObject private var deviceKey = HubClient.shared.deviceKey + @ObservedObject private var apnsKeyShared = HubClient.shared.apnsKeyShared + + private var shared: Bool { + HubClient.shared.isConnected && apnsKeyShared.value && !HubClient.hasOwnApnKey + } + + var body: some View { + if shared { + Section { + Label("Managed by LF Hub", systemImage: "lock.fill") + Text("LF Hub notifies this phone with the APN key an admin added.") + .font(.footnote) + .foregroundColor(.secondary) + } + } + Group { content } + .disabled(shared) + } +} diff --git a/LoopFollow/Hub/HubModeLimits.swift b/LoopFollow/Hub/HubModeLimits.swift new file mode 100644 index 000000000..20c030c15 --- /dev/null +++ b/LoopFollow/Hub/HubModeLimits.swift @@ -0,0 +1,40 @@ +// LoopFollow +// HubModeLimits.swift + +import Foundation + +/// In LoopFollow Hub mode the app sleeps, so a calendar event or a contact image +/// written by the app would go on showing an old reading. Neither is written in +/// Hub mode; the settings are kept for when the app leaves it. +enum HubModeLimits { + static let calendarNote = "Not available in LoopFollow Hub mode, where the app sleeps and would show old readings." + static let contactNote = "Not available in LoopFollow Hub mode, where the app sleeps and would show old readings." + + /// Hub mode was entered and its limits have been applied. + static let applied = StorageValue(key: "hubModeLimitsApplied", defaultValue: false) +} + +extension MainViewController { + /// Once per stay in Hub mode: removes the calendar's events and sets the contact + /// images to show no value. + func applyHubModeLimits() { + guard HubClient.shared.isHubMode else { + HubModeLimits.applied.value = false + return + } + guard !HubModeLimits.applied.value else { return } + HubModeLimits.applied.value = true + + if Storage.shared.writeCalendarEvent.value, !Storage.shared.calendarIdentifier.value.isEmpty { + store.requestCalendarAccess { granted, _ in + guard granted else { return } + self.removeCalendarEvents() + LogManager.shared.log(category: .calendar, message: "Hub mode: calendar events removed") + } + } + if Storage.shared.contactEnabled.value { + contactImageUpdater.updateContactImage(bgValue: "--", trend: "--", delta: "--", iob: "--", stale: true) + LogManager.shared.log(category: .contact, message: "Hub mode: contact images show no value") + } + } +} diff --git a/LoopFollow/Hub/HubSettingsView.swift b/LoopFollow/Hub/HubSettingsView.swift new file mode 100644 index 000000000..d83ab7de5 --- /dev/null +++ b/LoopFollow/Hub/HubSettingsView.swift @@ -0,0 +1,681 @@ +// LoopFollow +// HubSettingsView.swift + +import SwiftUI + +struct HubSettingsView: View { + @ObservedObject private var hubURL = Storage.shared.hubURL + @ObservedObject private var deviceKey = HubClient.shared.deviceKey + @ObservedObject private var backgroundRefreshType = Storage.shared.backgroundRefreshType + @ObservedObject private var laEnabled = Storage.shared.laEnabled + @ObservedObject private var nightscoutURL = Storage.shared.url + @ObservedObject private var dexcomUser = Storage.shared.shareUserName + @ObservedObject private var role = HubClient.shared.role + @ObservedObject private var joinedWithInvite = HubClient.shared.joinedWithInvite + @ObservedObject private var hubInfo = HubClient.shared.hubInfo + @ObservedObject private var hubConfirmed = HubClient.shared.hubConfirmed + @ObservedObject private var hubProblems = HubClient.shared.hubProblems + @ObservedObject private var lfKeyId = Storage.shared.lfKeyId + @ObservedObject private var lfApnsKey = Storage.shared.lfApnsKey + + @State private var urlInput = Storage.shared.hubURL.value + @State private var setupToken = "" + @State private var patientName = "" + @State private var deviceName = UIDevice.current.model + /// The hub's devices offered when joining; nil until the address and token, or the + /// invite, are checked. + @State private var joinableDevices: [HubClient.Device]? + @State private var join = HubClient.Join.new + /// The invite being joined with; nil while joining with the setup token. + @State private var joinInvite: HubClient.Invite? + @State private var inviteLink = "" + @State private var scanningInvite = false + + @State private var status: HubClient.Status? + @State private var statusError: String? + @State private var devices: [HubClient.Device] = [] + @State private var deviceToRemove: HubClient.Device? + /// This phone, about to stop being an admin. + @State private var deviceToDemote: HubClient.Device? + @State private var sourceAction: SourceAction? + @State private var shownInvite: ShownInvite? + @State private var notifications: HubChecklist.Permission? + @State private var confirmingDisconnect = false + @State private var busy = false + @State private var errorText: String? + @State private var infoText: String? + + private enum SourceAction: Identifiable { + case send, take + var id: Self { self } + } + + private struct ShownInvite: Identifiable { + let invite: HubClient.Invite + let expiresAt: Date + var id: String { invite.code } + } + + private var connected: Bool { + !hubURL.value.isEmpty && !deviceKey.value.isEmpty + } + + private var hubMode: Bool { + backgroundRefreshType.value == .hub + } + + private var isAdmin: Bool { + connected && role.value == .admin + } + + /// This app's data sources go to the hub: an admin that did not join with an invite. + private var sendsOwnSources: Bool { + isAdmin && !joinedWithInvite.value + } + + private func offers(_ feature: String) -> Bool { + connected && hubInfo.value?.features.contains(feature) == true + } + + var body: some View { + Form { + if !HubClient.isSupported { + Section { Text("LF Hub requires iOS 26 or later.").foregroundColor(.secondary) } + } else if connected { + connectedSections + } else { + connectSections + } + + if let errorText { + Section { Text(errorText).foregroundColor(.red) } + } + if let infoText { + Section { Text(infoText).foregroundColor(.secondary) } + } + } + .disabled(busy) + .task { + await readNotificationPermission() + await refresh() + } + .refreshable { await refresh() } + .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in + Task { await readNotificationPermission() } + } + .sheet(isPresented: $scanningInvite) { + SimpleQRCodeScannerView { result in + // The scanner can report a code more than once before it stops. + guard scanningInvite else { return } + scanningInvite = false + if case let .success(text) = result { readInvite(text) } + } + } + .sheet(item: $shownInvite) { shown in + HubInviteSheet(invite: shown.invite, expiresAt: shown.expiresAt, + patientName: status?.patient.name ?? HubClient.shared.patientName.value) + } + .confirmationDialog(deviceToRemove.map { "Remove \($0.name)?" } ?? "", + isPresented: Binding(get: { deviceToRemove != nil }, + set: { if !$0 { deviceToRemove = nil } }), + titleVisibility: .visible, + presenting: deviceToRemove) + { device in + Button("Remove", role: .destructive) { Task { await remove(device) } } + } message: { _ in + Text("The hub stops sending to it.") + } + .confirmationDialog("Make this phone a member?", + isPresented: Binding(get: { deviceToDemote != nil }, + set: { if !$0 { deviceToDemote = nil } }), + titleVisibility: .visible, + presenting: deviceToDemote) + { device in + Button("Make Member") { Task { await setRole(.member, of: device) } } + } message: { _ in + Text("This phone can no longer change the data source, invite or manage phones. Only another admin can make it an admin again.") + } + .confirmationDialog(sourceAction == .take ? "Use the hub's data sources in this app?" : "Send this app's data sources to the hub?", + isPresented: Binding(get: { sourceAction != nil }, + set: { if !$0 { sourceAction = nil } }), + titleVisibility: .visible, + presenting: sourceAction) + { action in + Button(action == .take ? "Use Hub's Sources" : "Send to Hub") { + Task { action == .take ? await takeSources() : await sendSources() } + } + } message: { action in + Text(action == .take ? "This app's Nightscout and Dexcom settings are replaced." + : "The hub's Nightscout and Dexcom settings are replaced for every phone.") + } + .confirmationDialog("Disconnect from LF Hub?", isPresented: $confirmingDisconnect, titleVisibility: .visible) { + Button("Disconnect", role: .destructive) { Task { await disconnect() } } + } message: { + Text("This phone stops getting LF Hub's alarms. LoopFollow goes back to alarming by itself.") + } + .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) + .navigationTitle("LF Hub") + .navigationBarTitleDisplayMode(.inline) + } + + // MARK: - Not connected + + @ViewBuilder + private var connectSections: some View { + if let joinInvite, let joinableDevices { + Section(header: Text("Join with Invite")) { + row("Hub", Self.host(joinInvite)) + Button("Use a Different Invite") { leaveInvite() } + } + joinChoiceSections(joinableDevices, invite: joinInvite) + } else { + Section( + header: Text("Join with Invite"), + footer: Text("Someone who manages LF Hub shows the invite under Settings → LF Hub → Invite Family Member.") + ) { + Button { + scanningInvite = true + } label: { + Label("Scan Invite Code", systemImage: "qrcode.viewfinder") + } + TextField("Or paste the invite link", text: $inviteLink) + .keyboardType(.URL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .onSubmit { readInvite(inviteLink) } + .onChange(of: inviteLink) { if HubClient.Invite(link: inviteLink) != nil { readInvite(inviteLink) } } + if !inviteLink.isEmpty, HubClient.Invite(link: inviteLink) == nil { + Text("This is not an LF Hub invite link.").font(.caption).foregroundColor(.orange) + } + } + + Section( + header: Text("Set Up as Owner"), + footer: Text("The owner connects with the hub's setup token. The hub stores this phone's APN key, encrypted, to send it notifications.") + ) { + TextField("https://hub.example.com", text: $urlInput) + .keyboardType(.URL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .onChange(of: urlInput) { joinableDevices = nil } + TogglableSecureInput(placeholder: "Setup token", text: $setupToken, style: .singleLine) + .onChange(of: setupToken) { joinableDevices = nil } + if joinableDevices == nil { + Button("Continue") { Task { await listDevices() } } + .disabled(urlInput.isEmpty || setupToken.isEmpty) + } + } + + if let joinableDevices { + joinChoiceSections(joinableDevices, invite: nil) + } + } + } + + /// How this phone joins: a new hub creates the patient; otherwise it replaces a phone, + /// copies one or comes as a new device. An invite cannot replace an admin's phone. + @ViewBuilder + private func joinChoiceSections(_ devices: [HubClient.Device], invite: HubClient.Invite?) -> some View { + if devices.isEmpty { + Section { + if invite == nil { TextField("Patient name", text: $patientName) } + TextField("This device's name", text: $deviceName) + Button(invite.map { "Join \(Self.host($0))" } ?? "Connect") { Task { await connect() } } + } footer: { + if invite == nil { Text("A new hub: connecting creates the patient.") } + } + } else { + let replaceable = invite == nil ? devices : devices.filter { $0.role != .admin } + Section( + header: Text("This Phone"), + footer: Text("Replace a phone you no longer use, or copy another phone's alarms to this one.") + ) { + ForEach(replaceable) { device in + joinRow("Replace \(device.name)", detail: deviceDetail(device), .replace(device.id), + name: device.name) + } + ForEach(devices) { device in + joinRow("Copy Settings from \(device.name)", detail: nil, .copy(device.id), + name: UIDevice.current.model) + } + joinRow("New Device", detail: nil, .new, name: UIDevice.current.model) + } + Section { + TextField("This device's name", text: $deviceName) + Button(invite.map { "Join \(Self.host($0))" } ?? "Connect") { Task { await connect() } } + } + } + } + + static func host(_ invite: HubClient.Invite) -> String { + URL(string: invite.hubURL)?.host ?? invite.hubURL + } + + private func joinRow(_ title: String, detail: String?, _ choice: HubClient.Join, name: String) -> some View { + Button { + join = choice + deviceName = name + } label: { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(title).foregroundColor(.primary) + if let detail { + Text(detail).font(.caption).foregroundColor(.secondary) + } + } + Spacer() + if join == choice { + Image(systemName: "checkmark").foregroundColor(.accentColor) + } + } + } + } + + // MARK: - Connected + + @ViewBuilder + private var connectedSections: some View { + Section(header: Text("Hub")) { + row("Address", hubURL.value) + if let status { + row("Patient", status.patient.name) + row("This device", status.device.name) + if offers("roles") { + row("Role", isAdmin ? "Admin" : "Member") + } + row("Live connection", status.live.connected ? "Connected" : "Disconnected") + if let at = status.live.lastReadingAt { + row("Last reading", relative(at)) + } + row("Push", status.device.apnsConfigured ? (status.device.apnsEnvironment ?? "") : "Not registered") + if let at = status.device.lastPushAt { + row("Last push", "\(relative(at)) · \(status.device.lastPushStatus ?? "")") + } + if let alarm = status.device.activeAlarm { + row("Sounding alarm", "\(alarm.title) · \(relative(alarm.firedAt))") + } + if hubMode { + let la = status.device.liveActivity + row("Live Activity", la.registered ? "Registered" : "No activity") + row("Push-to-start", la.pushToStartRegistered ? "Registered" : "Missing") + if let renewBy = la.renewBy { + row("Renews", la.renewalFailed ? "Failed, retrying" : relative(renewBy)) + } + if let at = la.lastPushAt { + row("Last LA push", "\(relative(at)) · \(la.lastPushStatus ?? "")") + } + } + } + } + + Section { + let items = checklist + if items.isEmpty { + ProgressView() + } else { + HubChecklistRows(items: items) { fix in Task { await apply(fix) } } + } + } header: { + Text("Ready") + } footer: { + if hubMode { + Text("The hub sends the alarms, badge and Live Activity, so LoopFollow can stay closed. Alarms depend on the network and Apple's push service. If the hub stops working, LoopFollow alarms by itself only when iOS lets it run, which can take a long time while the phone is locked.") + } + } + + Section( + header: Text("Patient Data Sources"), + footer: Text(sourcesFooter) + ) { + if let status { + if sendsOwnSources, offers("sourceRevision") { + NavigationLink(value: SettingsRoute.nightscout) { + row("Nightscout", status.patient.nightscoutHost ?? "Not set") + } + NavigationLink(value: SettingsRoute.dexcom) { + row("Dexcom", status.patient.dexcom == true ? "Set" : "Not set") + } + } else { + row("Nightscout", status.patient.nightscoutHost ?? "Not set") + row("Dexcom", status.patient.dexcom == true ? "Set" : "Not set") + } + } + if !offers("sourceRevision") { + if !joinedWithInvite.value { + Button("Send This App's Sources to the Hub") { sourceAction = .send } + .disabled(nightscoutURL.value.isEmpty && dexcomUser.value.isEmpty) + } + Button("Use the Hub's Sources in This App") { sourceAction = .take } + .disabled(status.map { $0.patient.nightscoutHost == nil && $0.patient.dexcom != true } ?? true) + } + } + + if isAdmin, offers("invites") { + Section(footer: Text("An invite lets one more phone follow \(patientDisplayName) as a member.")) { + Button { + Task { await createInvite() } + } label: { + Label("Invite Family Member", systemImage: "person.badge.plus") + } + } + } + + if !devices.isEmpty { + Section { + ForEach(devices) { device in + deviceRow(device) + .swipeActions { + if isAdmin, device.current != true { + Button("Remove", role: .destructive) { deviceToRemove = device } + } + if isAdmin, offers("roles"), let role = device.role { + Button(role == .admin ? "Make Member" : "Make Admin") { + if device.current == true, role == .admin { + deviceToDemote = device + } else { + Task { await setRole(role == .admin ? .member : .admin, of: device) } + } + } + .tint(.blue) + } + } + } + } header: { + Text("Devices") + } footer: { + if isAdmin { Text("Swipe a phone to remove it or change its role.") } + } + } + + Section(header: Text("Hub Mode")) { + row("Background Refresh", backgroundRefreshType.value.rawValue) + Button("Send Test Alarm") { Task { await testAlarm() } } + Button("Send Test Alarm in 30 s") { Task { await testAlarm(delaySeconds: 30) } } + } + + Section { + Button("Disconnect", role: .destructive) { confirmingDisconnect = true } + } + } + + private var patientDisplayName: String { + status?.patient.name ?? HubClient.shared.patientName.value + } + + private var sourcesFooter: String { + guard offers("sourceRevision") else { + return "The hub reads Dexcom if set, otherwise Nightscout. Every phone keeps a copy for when LoopFollow reads the data itself." + } + return (sendsOwnSources + ? "A change made here reaches every phone following \(patientDisplayName)." + : "An admin manages \(patientDisplayName)'s data sources.") + + " The hub reads Dexcom if set, otherwise Nightscout. Every phone keeps a copy for when LoopFollow reads the data itself." + } + + /// The checklist for the hub's last answer. + private var checklist: [HubChecklist.Item] { + var facts = HubChecklist.Facts() + facts.hubMode = hubMode + facts.hubLooksHealthy = HubWatchdog.hubLooksHealthy + facts.notifications = notifications + facts.alarmKit = HubChecklist.alarmKitPermission + facts.liveActivityOn = laEnabled.value + facts.ownApnKey = !lfKeyId.value.isEmpty && !lfApnsKey.value.isEmpty && !joinedWithInvite.value + facts.ownKeyAllowed = !joinedWithInvite.value + facts.isAdmin = sendsOwnSources + facts.hubSharesKeys = offers("sharedApnsKey") + facts.sourcesDiffer = HubSourceSync.shared.isActive && HubSourceSync.shared.differsFromHub + facts.drivesAlarms = HubClient.shared.drivesAlarms + facts.hubProblems = hubProblems.value + return HubChecklist.items(status: status, error: statusError, facts: facts, now: Date()) + } + + private func deviceRow(_ device: HubClient.Device) -> some View { + VStack(alignment: .leading, spacing: 2) { + HStack { + Text(device.name) + if device.current == true { + Text("This device").font(.caption).foregroundColor(.secondary) + } + Spacer() + if device.pushFailingSince != nil { + Label("Push failing", systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundColor(.orange) + } + } + Text(deviceDetail(device)).font(.caption).foregroundColor(.secondary) + } + } + + /// Whether the device is an admin, when it was last used, and its app version. + private func deviceDetail(_ device: HubClient.Device) -> String { + var parts = [device.lastSeenAt.map { "Last used \(relative($0))" } ?? "Never used"] + if device.role == .admin { parts.insert("Admin", at: 0) } + if let version = device.appVersion { parts.append(version) } + return parts.joined(separator: " · ") + } + + private func row(_ title: String, _ value: String) -> some View { + HStack { + Text(title) + Spacer() + Text(value).foregroundColor(.secondary).multilineTextAlignment(.trailing) + } + } + + private func relative(_ date: Date) -> String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .short + return formatter.localizedString(for: date, relativeTo: Date()) + } + + // MARK: - Actions + + private func run(_ action: () async throws -> Void) async { + busy = true + errorText = nil + infoText = nil + do { try await action() } catch { errorText = error.localizedDescription } + busy = false + } + + private func refresh() async { + guard connected else { return } + await run { try await load() } + } + + /// Brings the hub up to date and reads its view of this device and the patient's devices. + private func load() async throws { + await HubClient.shared.sync() + do { + let status = try await HubClient.shared.status() + self.status = status + statusError = nil + } catch { + statusError = error.localizedDescription + return + } + devices = offers("devices") ? try await HubClient.shared.devices() : [] + } + + private func readNotificationPermission() async { + notifications = await HubChecklist.notificationPermission() + await HubClient.shared.readNotificationPermission() + } + + private func listDevices() async { + await run { + let devices = try await HubClient.shared.joinableDevices(url: urlInput, setupToken: setupToken) + joinInvite = nil + join = .new + deviceName = UIDevice.current.model + joinableDevices = devices + } + } + + /// Reads a scanned or pasted invite and offers the hub's devices to join as. + private func readInvite(_ text: String) { + guard let invite = HubClient.Invite(link: text) else { + errorText = "This is not an LF Hub invite code." + return + } + guard invite != joinInvite else { return } + Task { + await run { + let devices = try await HubClient.shared.joinableDevices(invite: invite) + join = .new + deviceName = UIDevice.current.model + joinInvite = invite + joinableDevices = devices + } + } + } + + private func leaveInvite() { + joinInvite = nil + joinableDevices = nil + inviteLink = "" + errorText = nil + } + + private func connect() async { + await run { + defer { + if connected { + setupToken = "" + joinableDevices = nil + joinInvite = nil + inviteLink = "" + } + } + let choice = joinableDevices?.isEmpty == false ? join : .new + if let joinInvite { + try await HubClient.shared.join(invite: joinInvite, deviceName: deviceName, join: choice) + } else { + try await HubClient.shared.claim(url: urlInput, setupToken: setupToken, patientName: patientName, + deviceName: deviceName, join: choice) + } + try await load() + } + } + + private func sendSources() async { + await run { + try await HubSourceSync.shared.sendOwn() + infoText = "Data sources sent to the hub." + status = try await HubClient.shared.status() + } + } + + private func takeSources() async { + await run { + try await HubSourceSync.shared.takeHub() + infoText = "This app now uses the hub's data sources." + } + } + + private func createInvite() async { + await run { + let (invite, expiresAt) = try await HubClient.shared.createInvite() + shownInvite = ShownInvite(invite: invite, expiresAt: expiresAt) + } + } + + private func setRole(_ role: HubClient.Role, of device: HubClient.Device) async { + await run { + try await HubClient.shared.setRole(role, of: device.id) + LogManager.shared.log(category: .hub, message: "Device \(device.id.uuidString.prefix(8)) made \(role.rawValue)") + try await load() + } + } + + private func remove(_ device: HubClient.Device) async { + await run { + try await HubClient.shared.deleteDevice(device.id) + devices = try await HubClient.shared.devices() + } + } + + private func testAlarm(delaySeconds: Int = 0) async { + await run { + try await HubClient.shared.sendTestAlarm(delaySeconds: delaySeconds) + infoText = delaySeconds > 0 ? "Test alarm in \(delaySeconds) s. Lock the phone now." : "Test alarm sent." + } + } + + /// Carries out a checklist item's fix, then reads the hub's view again. + private func apply(_ fix: HubChecklist.Fix) async { + await HubChecklist.perform(fix) + await readNotificationPermission() + await refresh() + } + + private func disconnect() async { + busy = true + await HubClient.shared.disconnect() + status = nil + statusError = nil + devices = [] + busy = false + } +} + +/// An invite's QR code for another phone to scan in LoopFollow, and its link to share. +struct HubInviteSheet: View { + let invite: HubClient.Invite + let expiresAt: Date + let patientName: String + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: 20) { + QRCodeDisplayView(qrCodeString: invite.link, size: CGSize(width: 260, height: 260)) + .padding(.top, 8) + Text("On the other phone, open LoopFollow, go to Settings → LF Hub → Join with Invite, and scan this code.") + .multilineTextAlignment(.center) + Text("The invite works once, until \(expiresAt.formatted(date: .abbreviated, time: .shortened)). Share it only with someone who should follow \(patientName).") + .font(.footnote) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + ShareLink(item: invite.link) { + Label("Share Invite Link", systemImage: "square.and.arrow.up") + } + } + .padding() + } + .navigationTitle("Invite Family Member") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + } + } +} + +/// Shown while LoopFollow Hub mode is selected and the hub does not drive the alarms: +/// the app alarms itself, and the LF Hub page lists what is missing. +struct HubModeWarning: View { + @ObservedObject private var backgroundRefreshType = Storage.shared.backgroundRefreshType + @ObservedObject private var hubConfirmed = HubClient.shared.hubConfirmed + + var body: some View { + if backgroundRefreshType.value == .hub, !HubClient.shared.drivesAlarms { + NavigationLink(value: SettingsRoute.hub) { + VStack(alignment: .leading, spacing: 4) { + Label("LF Hub is not sending this phone's alarms", systemImage: "exclamationmark.triangle.fill") + .font(.subheadline.weight(.semibold)) + .foregroundColor(.red) + Text("LoopFollow checks the alarms itself until LF Hub is ready. See what is missing.") + .font(.footnote) + .foregroundColor(.secondary) + } + .padding(.vertical, 4) + } + } + } +} diff --git a/LoopFollow/Hub/HubSourceSync.swift b/LoopFollow/Hub/HubSourceSync.swift new file mode 100644 index 000000000..0ac2f1d30 --- /dev/null +++ b/LoopFollow/Hub/HubSourceSync.swift @@ -0,0 +1,307 @@ +// LoopFollow +// HubSourceSync.swift + +import Combine +import CryptoKit +import Foundation + +/// This app's Nightscout and Dexcom settings. +struct LocalSources: Equatable { + var nightscoutURL = "" + var token = "" + var dexcomUser = "" + var dexcomPassword = "" + var dexcomServer = "" + + var hasNightscout: Bool { !nightscoutURL.isEmpty } + var hasDexcom: Bool { !dexcomUser.isEmpty && !dexcomPassword.isEmpty } + var isEmpty: Bool { !hasNightscout && !hasDexcom } + + static func stored() -> LocalSources { + let storage = Storage.shared + return LocalSources(nightscoutURL: storage.url.value, token: storage.token.value, + dexcomUser: storage.shareUserName.value, dexcomPassword: storage.sharePassword.value, + dexcomServer: storage.shareServer.value) + } + + /// The settings for the hub's sources: one the hub does not have is empty, and a + /// Dexcom server the hub does not name stays `server`. + init(_ sources: HubClient.Sources, server: String) { + if let url = sources.nightscoutUrl, !url.isEmpty { + nightscoutURL = url + token = sources.token ?? "" + } + dexcomUser = sources.dexcom?.username ?? "" + dexcomPassword = sources.dexcom?.password ?? "" + dexcomServer = sources.dexcom?.server.flatMap { $0.isEmpty ? nil : $0 } ?? server + } + + init(nightscoutURL: String = "", token: String = "", dexcomUser: String = "", dexcomPassword: String = "", + dexcomServer: String = "") + { + self.nightscoutURL = nightscoutURL + self.token = token + self.dexcomUser = dexcomUser + self.dexcomPassword = dexcomPassword + self.dexcomServer = dexcomServer + } + + @MainActor func store() { + let storage = Storage.shared + storage.url.value = nightscoutURL + storage.token.value = token + storage.shareUserName.value = dexcomUser + storage.sharePassword.value = dexcomPassword + storage.shareServer.value = dexcomServer + } + + /// A digest of the settings the hub holds: the token only with a Nightscout address, + /// the server only with a Dexcom login. + var digest: String { + let parts = (hasNightscout ? [nightscoutURL, token] : ["", ""]) + + (hasDexcom ? [dexcomUser, dexcomPassword, dexcomServer] : ["", "", ""]) + return SHA256.hash(data: Data(parts.joined(separator: "\u{0}").utf8)).map { String(format: "%02x", $0) }.joined() + } +} + +/// Keeps this app's Nightscout and Dexcom settings and the patient's data sources at the +/// hub the same, so the app's own fallback never follows the patient with old credentials. +/// The hub counts each change of the sources in `sourceRevision`; a revision this app has +/// not applied brings the hub's sources here. A change made in this app goes to the hub +/// from an admin phone that did not join with an invite once the app's own check of the +/// new source succeeds; on any other phone the hub's sources are put back. +@MainActor +final class HubSourceSync: ObservableObject { + static let shared = HubSourceSync() + + enum State: Equatable { + case idle + case checking + case sent + case failed(String) + } + + /// The last change this app sent, or why it did not reach the hub. + @Published private(set) var state = State.idle + + /// The hub's source revision this app's settings follow; -1 before the first. + private let appliedRevision = StorageValue(key: "hubAppliedSourceRevision", defaultValue: -1) + /// `LocalSources.digest` of the settings as they last matched the hub's sources. + private let syncedDigest = StorageValue(key: "hubSyncedSourcesDigest", defaultValue: "") + private var busy = false + /// The settings changed while busy; looked at again when done. + private var changedWhileBusy = false + private var cancellables = Set() + + private var client: HubClient { HubClient.shared } + + /// The hub keeps the patient's data sources in step across the devices. + var isActive: Bool { client.offers("sourceRevision") } + + /// This app's settings are not the hub's sources as this app last saw them. + var differsFromHub: Bool { LocalSources.stored().digest != syncedDigest.value } + + /// Whether to take the hub's sources after a status: the hub counts revisions and + /// this app has not applied the current one. + nonisolated static func needsSources(hubRevision: Int?, applied: Int) -> Bool { + guard let hubRevision else { return false } + return hubRevision != applied + } + + /// Whether a revision read right after this app changed the sources is that change + /// alone, so the hub's sources are this app's already. + nonisolated static func isOwnChange(revisionBefore: Int, revisionAfter: Int?) -> Bool { + revisionBefore >= 0 && revisionAfter == revisionBefore + 1 + } + + /// Looks at the settings each time they settle after a change. + func observe() { + let storage = Storage.shared + Publishers.MergeMany(storage.url.$value.map { _ in () }.eraseToAnyPublisher(), + storage.token.$value.map { _ in () }.eraseToAnyPublisher(), + storage.shareUserName.$value.map { _ in () }.eraseToAnyPublisher(), + storage.sharePassword.$value.map { _ in () }.eraseToAnyPublisher(), + storage.shareServer.$value.map { _ in () }.eraseToAnyPublisher()) + .debounce(for: .seconds(2), scheduler: DispatchQueue.main) + .map { _ in LocalSources.stored() } + .removeDuplicates() + .dropFirst() + .sink { [weak self] _ in Task { await self?.settingsChanged() } } + .store(in: &cancellables) + } + + /// Forgets the hub's sources when this phone leaves the hub. + func forget() { + appliedRevision.value = -1 + syncedDigest.value = "" + state = .idle + } + + /// Takes the hub's sources when the status shows a revision this app has not applied, + /// and on a phone that does not send its own whose settings changed here while the hub + /// could not be asked. + func follow(_ status: HubClient.Status) async { + let hubHasSources = status.patient.nightscoutHost != nil || status.patient.dexcom == true + let memberDiffers = isActive && client.isConnected && !client.sendsOwnSources && hubHasSources && differsFromHub + guard Self.needsSources(hubRevision: status.patient.sourceRevision, applied: appliedRevision.value) + || memberDiffers, + !busy + else { return } + busy = true + defer { done() } + do { + try await takeHubSources(revision: status.patient.sourceRevision, sendOwnIfNone: false) + } catch { + LogManager.shared.log(category: .hub, message: "Hub data sources not taken: \(error.localizedDescription)") + } + } + + /// Makes the hub's sources this app's and records their revision. With none at the + /// hub, `sendOwnIfNone` gives the hub this app's; otherwise this app's stay as they are. + func takeHubSources(revision: Int?, sendOwnIfNone: Bool) async throws { + let sources = try await client.sources() + let own = LocalSources.stored() + if sources.isEmpty, sendOwnIfNone, !own.isEmpty { + try await client.sendSources(own) + syncedDigest.value = own.digest + LogManager.shared.log(category: .hub, message: "This app's data sources sent to the hub, which had none") + } else { + take(sources) + } + if let revision { appliedRevision.value = revision } + LogManager.shared.log(category: .hub, message: "Hub data source revision \(revision.map(String.init) ?? "none") applied") + } + + /// Sets this app's Nightscout and Dexcom settings to the hub's sources; with none at + /// the hub they stay as they are. + private func take(_ sources: HubClient.Sources) { + guard !sources.isEmpty else { return } + let hub = LocalSources(sources, server: Storage.shared.shareServer.value) + syncedDigest.value = hub.digest + guard hub != LocalSources.stored() else { return } + hub.store() + LogManager.shared.log(category: .hub, message: "Data sources taken from the hub: Nightscout " + + (hub.hasNightscout ? "set" : "none") + ", Dexcom " + (hub.hasDexcom ? "set" : "none")) + NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil) + } + + /// Sends this app's settings to the hub as they are, on a hub that does not count + /// source revisions. + func sendOwn() async throws { + let own = LocalSources.stored() + try await client.sendSources(own) + syncedDigest.value = own.digest + } + + /// Makes the hub's sources this app's, on a hub that does not count source revisions. + func takeHub() async throws { + try take(await client.sources()) + } + + /// Checks this app's settings again and sends them to the hub; for an admin after a + /// failed check or a refused change. + func retry() async { + await settingsChanged() + } + + /// An admin starts changing the settings here; `state` follows the coming change. + func beginEditing() { + state = .idle + } + + /// The settings changed in this app: an admin that did not join with an invite sends + /// them to the hub, any other phone gets the hub's back. + private func settingsChanged() async { + guard isActive, differsFromHub else { return } + guard !busy else { + changedWhileBusy = true + return + } + busy = true + defer { done() } + if client.sendsOwnSources { + await send(LocalSources.stored()) + } else { + LogManager.shared.log(category: .hub, message: "Data sources changed on a member phone; taking the hub's back") + do { + try take(await client.sources()) + } catch { + LogManager.shared.log(category: .hub, message: "Hub data sources not taken: \(error.localizedDescription)") + } + } + } + + private func done() { + busy = false + if changedWhileBusy { + changedWhileBusy = false + Task { await settingsChanged() } + } + } + + /// Sends the settings to the hub once the app's own check of them succeeds. + private func send(_ sources: LocalSources) async { + state = .checking + if let problem = await Self.check(sources) { + // A newer change is checked on its own. + guard LocalSources.stored() == sources else { + state = .idle + return + } + state = .failed(problem) + LogManager.shared.log(category: .hub, message: "Data source change not sent to the hub: \(problem)") + return + } + guard LocalSources.stored() == sources else { + state = .idle + return + } + do { + let before = appliedRevision.value + try await client.sendSources(sources) + syncedDigest.value = sources.digest + state = .sent + LogManager.shared.log(category: .hub, message: "Data source change sent to the hub") + // The new revision is this change; a later one is taken on the next status. + let after = try await client.status().patient.sourceRevision + if Self.isOwnChange(revisionBefore: before, revisionAfter: after), let after { + appliedRevision.value = after + } + } catch { + state = .failed("LF Hub did not take the change: \(Self.reason(error))") + LogManager.shared.log(category: .hub, message: "Data source change refused by the hub: \(error.localizedDescription)") + } + } + + /// The hub's own words for a refusal. + private static func reason(_ error: Error) -> String { + if case let HubClient.HubError.http(_, message) = error { return message } + return error.localizedDescription + } + + /// Why the settings cannot go to the hub, from the same checks the Nightscout and + /// Dexcom settings run; nil when every source set here works. + private static func check(_ sources: LocalSources) async -> String? { + if sources.isEmpty { return "Add a Nightscout site or a Dexcom login." } + if sources.hasNightscout { + let error = await withCheckedContinuation { continuation in + NightscoutUtils.verifyURLAndToken { error, _, _, _ in continuation.resume(returning: error) } + } + if error != nil { + return "LoopFollow could not connect to this Nightscout site, so LF Hub keeps the old data source." + } + } + if sources.hasDexcom { + let result = await withCheckedContinuation { continuation in + DexcomSettingsViewModel.checkLogin(userName: sources.dexcomUser, password: sources.dexcomPassword, + server: sources.dexcomServer) { continuation.resume(returning: $0) } + } + switch result { + case .accepted: break + case .rejected: return "Dexcom did not accept this login, so LF Hub keeps the old data source." + case .unreachable: return "LoopFollow could not reach Dexcom to check the login, so LF Hub keeps the old data source." + } + } + return nil + } +} diff --git a/LoopFollow/Hub/HubWatchdog.swift b/LoopFollow/Hub/HubWatchdog.swift new file mode 100644 index 000000000..45b15c467 --- /dev/null +++ b/LoopFollow/Hub/HubWatchdog.swift @@ -0,0 +1,134 @@ +// LoopFollow +// HubWatchdog.swift + +import Foundation +import UserNotifications + +/// In LoopFollow Hub mode the phone checks now and then that the hub is still +/// alive and reaching it: on a background refresh, whenever the app comes to the +/// foreground, and while it is open. After `unhealthyTolerance` the app's own alarm +/// check runs again (and the app keeps itself running, as in Silent Tune). After +/// `silentAfter` without a healthy answer it raises one alert per outage: a system +/// alarm through silent mode when AlarmKit is allowed, otherwise a time-sensitive +/// notification. In every other mode it does nothing. +enum HubWatchdog { + static let silentAfter: TimeInterval = 20 * 60 + /// Checks may fail this long before the app counts the hub as unhealthy. + static let unhealthyTolerance: TimeInterval = 5 * 60 + + private static let notificationID = "lfhub.watchdog" + private static let alertID = UUID(uuidString: "6C1F5A2E-0B7D-4E4F-9F3A-48B1A1E0D0A1")! + private static let alertTitle = "⚠️ LF Hub not responding" + private static let alertSymbol = "exclamationmark.icloud" + + private static let lastHealthy = StorageValue(key: "hubWatchdogLastHealthy", defaultValue: 0) + private static let alerted = StorageValue(key: "hubWatchdogAlerted", defaultValue: false) + private static let unhealthySince = StorageValue(key: "hubWatchdogUnhealthySince", defaultValue: 0) + + /// The watchdog's memory between checks, in Unix seconds; 0 means unset. + struct State: Equatable { + var lastHealthy: TimeInterval = 0 + var unhealthySince: TimeInterval = 0 + var alerted = false + } + + enum Action: Equatable { + case none + case raiseAlert(minutes: Int) + case clearAlert + } + + /// False once checks have failed for `unhealthyTolerance`: the app then runs its + /// own alarm check, alongside the hub. + static var hubLooksHealthy: Bool { + looksHealthy(currentState, now: Date().timeIntervalSince1970) + } + + static func looksHealthy(_ state: State, now: TimeInterval) -> Bool { + state.unhealthySince == 0 || now - state.unhealthySince < unhealthyTolerance + } + + /// The state after a check that gave `health` at `now`, and what to do about it. + /// The first unhealthy check without an earlier healthy one starts the clock. + static func next(_ state: State, hubMode: Bool, health: HubClient.Health, + now: TimeInterval) -> (state: State, action: Action) + { + guard hubMode else { return (State(), state.alerted ? .clearAlert : .none) } + var state = state + switch health { + case .unknown: + return (state, .none) + case .healthy: + let action: Action = state.alerted ? .clearAlert : .none + state = State(lastHealthy: now, unhealthySince: 0, alerted: false) + return (state, action) + case .unhealthy: + if state.unhealthySince == 0 { state.unhealthySince = now } + if state.lastHealthy == 0 { state.lastHealthy = now } + let silentFor = now - state.lastHealthy + guard silentFor >= silentAfter, !state.alerted else { return (state, .none) } + state.alerted = true + return (state, .raiseAlert(minutes: Int(silentFor / 60))) + } + } + + private static var currentState: State { + State(lastHealthy: lastHealthy.value, unhealthySince: unhealthySince.value, alerted: alerted.value) + } + + @MainActor + static func check() async { + await HubClient.shared.readNotificationPermission() + let hubMode = HubClient.shared.isHubMode + let health: HubClient.Health = hubMode ? await HubClient.shared.health() : .unknown + let wasHealthy = hubLooksHealthy + let (state, action) = next(currentState, hubMode: hubMode, health: health, + now: Date().timeIntervalSince1970) + lastHealthy.value = state.lastHealthy + unhealthySince.value = state.unhealthySince + alerted.value = state.alerted + + if health == .unhealthy { + let silentFor = Int((Date().timeIntervalSince1970 - state.lastHealthy) / 60) + LogManager.shared.log(category: .hub, message: "Hub watchdog: no healthy answer for \(silentFor) min") + } + if wasHealthy, !hubLooksHealthy { + LogManager.shared.log(category: .hub, message: "Hub watchdog: hub unhealthy; the app alarms itself") + } + switch action { + case .none: + break + case let .raiseAlert(minutes): + await raiseAlert(minutes: minutes) + case .clearAlert: + clearAlert() + LogManager.shared.log(category: .hub, message: hubMode ? "Hub watchdog: hub healthy again" : "Hub watchdog: alert cleared") + } + } + + private static func raiseAlert(minutes: Int) async { + LogManager.shared.log(category: .hub, message: "Hub watchdog: alerting after \(minutes) min") + #if !targetEnvironment(macCatalyst) + if #available(iOS 26, *), + await HubAlarmKit.ringNotice(id: alertID, title: alertTitle, symbol: alertSymbol) + { + return + } + #endif + let content = UNMutableNotificationContent() + content.title = alertTitle + content.body = "No working connection to LF Hub for \(minutes) minutes, so its alarms may not reach this phone. Open LoopFollow." + content.sound = .default + content.interruptionLevel = .timeSensitive + try? await UNUserNotificationCenter.current().add( + UNNotificationRequest(identifier: notificationID, content: content, trigger: nil) + ) + } + + private static func clearAlert() { + #if !targetEnvironment(macCatalyst) + if #available(iOS 26, *) { HubAlarmKit.stop(alarmID: alertID) } + #endif + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [notificationID]) + } +} diff --git a/LoopFollow/Info.plist b/LoopFollow/Info.plist index 898d09616..4ad0d406c 100644 --- a/LoopFollow/Info.plist +++ b/LoopFollow/Info.plist @@ -66,6 +66,8 @@ NSSupportsLiveActivities + NSAlarmKitUsageDescription + LoopFollow uses alarms so urgent glucose alerts from LF Hub reach you even when the phone is silenced or a Focus is on. UIBackgroundModes audio diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 82987e79b..d1e88957f 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -72,6 +72,7 @@ final class LiveActivityManager { category: .general, message: "[LA] push-to-start token received #\(deliveries) token=…\(tail) (prev=…\(previousTail))\(changed ? " CHANGED" : " same")" ) + HubClient.shared.reportLiveActivitySoon() } LogManager.shared.log( category: .general, @@ -152,10 +153,20 @@ final class LiveActivityManager { message: "[LA] activityUpdates: adopting new activity id=\(activity.id) (no prior current)" ) } - // Fresh deadline — push-to-start-initiated LAs reset the 8-hour clock. - Storage.shared.laRenewBy.value = Date().timeIntervalSince1970 + LiveActivityManager.renewalThreshold - Storage.shared.laRenewalFailed.value = false - cancelRenewalFailedNotification() + // An activity bound before this launch keeps its deadline, since iOS counts its + // 8 hours from when it started; one started through push-to-start gets a fresh one. + let boundBeforeLaunch = Storage.shared.laBoundActivityId.value == activity.id + && Storage.shared.laRenewBy.value > 0 + if boundBeforeLaunch { + LogManager.shared.log( + category: .general, + message: "[LA] adopt: id=\(activity.id) was bound before this launch — keeping renewBy=\(Storage.shared.laRenewBy.value)" + ) + } else { + Storage.shared.laRenewBy.value = Date().timeIntervalSince1970 + LiveActivityManager.renewalThreshold + Storage.shared.laRenewalFailed.value = false + cancelRenewalFailedNotification() + } dismissedByUser = false // A fresh LA invalidates any latched foreground-restart intent — the // condition that prompted the latch (overlay showing / renewal failed) @@ -176,7 +187,7 @@ final class LiveActivityManager { /// directly to the Live Activity while the app is still foreground-active, /// ensuring the LA is up to date the moment the lock screen appears. @objc private func handleWillResignActive() { - guard Storage.shared.laEnabled.value, let activity = current else { return } + guard Storage.shared.laEnabled.value, !HubClient.shared.drivesLiveActivity, let activity = current else { return } refreshCoalescer.cancel() @@ -434,6 +445,12 @@ final class LiveActivityManager { private static let pushToStartTokenRetryDelay: TimeInterval = 10 private(set) var current: Activity? + + /// Push token of the activity currently tracked, as reported to LF Hub. + var currentPushToken: String? { + current == nil ? nil : pushToken + } + private var stateObserverTask: Task? private var updateTask: Task? private var seq: Int = 0 @@ -668,7 +685,8 @@ final class LiveActivityManager { /// deadline has passed, working around Apple's 8-hour maximum LA lifetime. /// Returns true if renewal was performed (caller should return early). private func renewIfNeeded(snapshot: GlucoseSnapshot) -> Bool { - guard let oldActivity = current else { return false } + // LF Hub renews through push-to-start when it drives the activity. + guard !HubClient.shared.drivesLiveActivity, let oldActivity = current else { return false } let renewBy = Storage.shared.laRenewBy.value guard renewBy > 0, Date().timeIntervalSince1970 >= renewBy else { return false } @@ -1121,6 +1139,8 @@ final class LiveActivityManager { } guard let activity = current else { return } + // LF Hub sends every update while it drives the activity. + if HubClient.shared.drivesLiveActivity { return } updateTask?.cancel() @@ -1210,6 +1230,7 @@ final class LiveActivityManager { private func bind(to activity: Activity, logReason: String) { if current?.id == activity.id { return } current = activity + Storage.shared.laBoundActivityId.value = activity.id let wasEndingForRestart = endingForRestart dismissedByUser = false endingForRestart = false @@ -1235,6 +1256,7 @@ final class LiveActivityManager { category: .general, message: "[LA] push token received id=\(activityID) token=…\(tail) (prev=…\(previousTail))" ) + HubClient.shared.reportLiveActivitySoon() } } } @@ -1332,6 +1354,7 @@ final class LiveActivityManager { let wasCurrentActivity = current?.id == activity.id if wasCurrentActivity { current = nil + HubClient.shared.reportLiveActivitySoon() // Do NOT clear laRenewBy here. Preserving it means handleForeground() // can detect the renewal window on the next foreground event and restart // automatically — whether the LA ended normally (.ended) or was diff --git a/LoopFollow/LiveActivitySettingsView.swift b/LoopFollow/LiveActivitySettingsView.swift index 6d654f72d..a835dd613 100644 --- a/LoopFollow/LiveActivitySettingsView.swift +++ b/LoopFollow/LiveActivitySettingsView.swift @@ -15,8 +15,11 @@ private let slotLabels = ["Top left", "Top right", "Bottom left", "Bottom right"] + /// This app's own APN key, or in Hub mode the key an admin shares through LF Hub, + /// with which the hub updates the Live Activity. private var apnsConfigured: Bool { APNsCredentialValidator.isFullyConfigured(keyId: keyId, apnsKey: apnsKey) + || (HubClient.shared.isHubMode && HubClient.shared.apnsKeyShared.value) } var body: some View { diff --git a/LoopFollow/Log/LogManager.swift b/LoopFollow/Log/LogManager.swift index 5bb40eeb6..686bf9b4d 100644 --- a/LoopFollow/Log/LogManager.swift +++ b/LoopFollow/Log/LogManager.swift @@ -32,6 +32,7 @@ class LogManager { case remote = "Remote" case websocket = "WebSocket" case telemetry = "Telemetry" + case hub = "Hub" } init() { diff --git a/LoopFollow/Nightscout/NightscoutSettingsView.swift b/LoopFollow/Nightscout/NightscoutSettingsView.swift index 8d8573abf..dcca90574 100644 --- a/LoopFollow/Nightscout/NightscoutSettingsView.swift +++ b/LoopFollow/Nightscout/NightscoutSettingsView.swift @@ -13,8 +13,10 @@ struct NightscoutSettingsView: View { var body: some View { Form { - urlSection - tokenSection + HubManagedSources { + urlSection + tokenSection + } statusSection webSocketSection diff --git a/LoopFollow/Onboarding/OnboardingContainerView.swift b/LoopFollow/Onboarding/OnboardingContainerView.swift index 35ca8dc73..ec6e28783 100644 --- a/LoopFollow/Onboarding/OnboardingContainerView.swift +++ b/LoopFollow/Onboarding/OnboardingContainerView.swift @@ -69,6 +69,8 @@ struct OnboardingContainerView: View { DexcomConnectStepView(viewModel: viewModel.dexcomViewModel, onboarding: viewModel) case .copyFromPhone: ConnectImportStepView(viewModel: viewModel) + case .hub: + HubJoinStepView(viewModel: viewModel) default: NightscoutConnectStepView(viewModel: viewModel.nightscoutViewModel, onboarding: viewModel) } @@ -84,6 +86,8 @@ struct OnboardingContainerView: View { NotificationsStepView(viewModel: viewModel) case .telemetry: TelemetryStepView(viewModel: viewModel) + case .hubPermissions: + HubPermissionsStepView(viewModel: viewModel) case .completion: CompletionStepView(viewModel: viewModel) } diff --git a/LoopFollow/Onboarding/OnboardingStep.swift b/LoopFollow/Onboarding/OnboardingStep.swift index 0ae033ef4..6ccf9afac 100644 --- a/LoopFollow/Onboarding/OnboardingStep.swift +++ b/LoopFollow/Onboarding/OnboardingStep.swift @@ -12,8 +12,9 @@ import Foundation /// within-phase position through `OnboardingViewModel.phaseProgress`, so the /// overall progress bar stays smooth no matter how many pages a phase has. /// -/// `connect` renders the Nightscout, Dexcom, or import view depending on the data -/// source the user picks in `dataSource`. +/// `connect` renders the Nightscout, Dexcom, import, or LF Hub join view depending on +/// the data source the user picks in `dataSource`. `hubPermissions` follows joining a +/// family hub, in place of the notifications phase. enum OnboardingStep: CaseIterable, Hashable { case welcome case overview @@ -25,6 +26,7 @@ enum OnboardingStep: CaseIterable, Hashable { case tabOrder case notifications case telemetry + case hubPermissions case completion /// Steps that show the progress bar + phase label + Skip at the top. The @@ -64,6 +66,7 @@ enum OnboardingStep: CaseIterable, Hashable { case .tabOrder: return "Tabs" case .notifications: return "Notifications" case .telemetry: return "Privacy" + case .hubPermissions: return "Permissions" } } } diff --git a/LoopFollow/Onboarding/OnboardingViewModel.swift b/LoopFollow/Onboarding/OnboardingViewModel.swift index 5cd894857..1862724ea 100644 --- a/LoopFollow/Onboarding/OnboardingViewModel.swift +++ b/LoopFollow/Onboarding/OnboardingViewModel.swift @@ -17,6 +17,8 @@ final class OnboardingViewModel: ObservableObject { case nightscout case dexcom case copyFromPhone + /// Join a family's LF Hub with an invite; the hub brings the data source. + case hub } /// A single recommended alarm offered on the alarms phase. Display fields are @@ -56,6 +58,9 @@ final class OnboardingViewModel: ObservableObject { /// succeeds, so the connect phase can be considered complete. @Published var didImportSettings = false + /// How this phone joined a family's LF Hub, once it has. + @Published var hubJoin: HubClient.Join? + /// Whether the notification permission is still undecided. The notifications /// phase is only shown when it is — there's no point prompting someone who has /// already granted or denied it (iOS won't show the prompt again anyway). @@ -122,7 +127,7 @@ final class OnboardingViewModel: ObservableObject { var canProceed: Bool { switch step { case .welcome, .overview, .units, .generalAlarms, .alarms, - .tabOrder, .notifications, .telemetry, .completion: + .tabOrder, .notifications, .telemetry, .hubPermissions, .completion: return true case .dataSource: return dataSource != nil @@ -131,6 +136,7 @@ final class OnboardingViewModel: ObservableObject { case .nightscout: return nightscoutViewModel.isConnected || nightscoutViewModel.provisionedTokenPending case .dexcom: return dexcomViewModel.canVerifyProceed case .copyFromPhone: return didImportSettings + case .hub: return hubJoin != nil case .none: return false } } @@ -182,6 +188,19 @@ final class OnboardingViewModel: ObservableObject { steps.append(contentsOf: [.dataSource, .connect]) } + // Joining a family hub brings the data source, and a replaced or copied phone's + // alarms and settings; one permissions phase covers what the hub needs. + if dataSource == .hub { + if hubJoin == nil || hubJoin == .new { + steps.append(.units) + if alarmsOffered { + steps.append(contentsOf: [.generalAlarms, .alarms]) + } + } + steps.append(contentsOf: [.hubPermissions, .completion]) + return steps + } + steps.append(.units) if alarmsOffered { steps.append(contentsOf: [.generalAlarms, .alarms]) @@ -276,7 +295,9 @@ final class OnboardingViewModel: ObservableObject { /// Finish setup: seed selected alarms, mark units/onboarding complete, and /// kick a refresh so the home screen loads data immediately. func finish() { - persistSeededAlarms() + if activeSteps.contains(.alarms) { + persistSeededAlarms() + } Storage.shared.hasConfiguredUnits.value = true Storage.shared.hasCompletedOnboarding.value = true onClose() diff --git a/LoopFollow/Onboarding/Steps/DataSourceChoiceStepView.swift b/LoopFollow/Onboarding/Steps/DataSourceChoiceStepView.swift index 7021ae46e..5f424dbdf 100644 --- a/LoopFollow/Onboarding/Steps/DataSourceChoiceStepView.swift +++ b/LoopFollow/Onboarding/Steps/DataSourceChoiceStepView.swift @@ -35,6 +35,14 @@ struct DataSourceChoiceStepView: View { title: "Copy from another phone", detail: "Already using LoopFollow on another phone? Scan its QR code to copy the connection here." ) + if HubClient.isSupported { + choiceCard( + source: .hub, + icon: "person.2.fill", + title: "Join a Family Hub", + detail: "Someone in your family uses LF Hub? Scan their invite code to follow with the same data source." + ) + } } .padding(.horizontal) } diff --git a/LoopFollow/Onboarding/Steps/HubJoinStepView.swift b/LoopFollow/Onboarding/Steps/HubJoinStepView.swift new file mode 100644 index 000000000..9fb3127d9 --- /dev/null +++ b/LoopFollow/Onboarding/Steps/HubJoinStepView.swift @@ -0,0 +1,298 @@ +// LoopFollow +// HubJoinStepView.swift + +import SwiftUI + +/// The "join a family hub" connect path: scan an invite, choose how this phone joins, +/// name it, and join. The hub brings the data source, and a replaced or copied phone's +/// alarms and settings. After a failure the user can try again or go back and choose +/// another data source; once the hub has taken the phone, it counts as joined, and what +/// is left is finished by a later sync. +struct HubJoinStepView: View { + @ObservedObject var viewModel: OnboardingViewModel + @ObservedObject private var patientName = HubClient.shared.patientName + + private enum Page: Int { + case invite, choice, name + } + + @State private var page = Page.invite + @State private var invite: HubClient.Invite? + @State private var devices: [HubClient.Device] = [] + @State private var join = HubClient.Join.new + @State private var deviceName = UIDevice.current.model + @State private var inviteLink = "" + @State private var scanning = false + @State private var busy = false + @State private var errorText: String? + /// The failed step may work when tried again. + @State private var canRetry = false + + private var joined: Bool { + viewModel.hubJoin != nil && HubClient.shared.isConnected + } + + var body: some View { + VStack(spacing: 0) { + Form { + if joined { + joinedContent + } else { + switch page { + case .invite: inviteContent + case .choice: choiceContent + case .name: nameContent + } + } + if let errorText { + Section { + Text(errorText).font(.caption).foregroundColor(.red) + if canRetry { + Button("Try Again") { next() } + } + Button("Choose Another Data Source") { chooseAnotherSource() } + } + } + } + .disabled(busy) + + OnboardingNavFooter( + continueTitle: !joined && page == .name ? "Join" : "Continue", + continueEnabled: continueEnabled, + showBack: true, + onBack: back, + onContinue: next + ) + } + .sheet(isPresented: $scanning) { + SimpleQRCodeScannerView { result in + // The scanner can report a code more than once before it stops. + guard scanning else { return } + scanning = false + if case let .success(text) = result { Task { await read(text) } } + } + } + .onAppear { reportProgress() } + .onChange(of: page) { reportProgress() } + } + + // MARK: - Pages + + private func header(_ systemImage: String, _ title: String, _ subtitle: String) -> some View { + Section { + EmptyView() + } header: { + OnboardingStepHeader(systemImage: systemImage, title: title, subtitle: subtitle) + .textCase(nil) + .padding(.bottom, 8) + } + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + } + + @ViewBuilder + private var inviteContent: some View { + header("person.2.fill", "Join a family hub", + "On a phone that already uses LF Hub, open Settings → LF Hub → Invite Family Member. Then scan the code it shows.") + Section { + Button { + errorText = nil + scanning = true + } label: { + HStack { + Image(systemName: "qrcode.viewfinder").foregroundColor(.accentColor) + Text("Scan Invite Code") + } + } + TextField("Or paste the invite link", text: $inviteLink) + .keyboardType(.URL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + if busy { + HStack { + ProgressView() + Text("Reading the invite…").foregroundColor(.secondary) + } + } + } + } + + /// The hub the invite is for, so the user sees where this phone is joining. + @ViewBuilder + private var hubSection: some View { + if let invite { + Section { + HStack { + Text("Hub") + Spacer() + Text(HubSettingsView.host(invite)).foregroundColor(.secondary) + } + } footer: { + Text("Join \(HubSettingsView.host(invite))? Only join a hub you know.") + } + } + } + + @ViewBuilder + private var choiceContent: some View { + header("iphone.and.arrow.forward", "How should this phone join?", + "Copy another phone's alarms and settings, or start fresh and choose alarms next.") + hubSection + Section { + ForEach(devices) { device in + choiceRow("Copy settings from \(device.name)", detail: "Starts with the same alarms and settings.", + .copy(device.id)) + } + ForEach(devices.filter { $0.role != .admin }) { device in + choiceRow("Replace \(device.name)", + detail: "This replaces my old phone. It takes over its alarms and settings.", .replace(device.id)) + } + choiceRow("Start fresh", detail: "A new phone with its own alarms.", .new) + } + } + + @ViewBuilder + private var nameContent: some View { + header("character.cursor.ibeam", "Name this phone", + "Everyone who manages LF Hub sees this name in the list of phones.") + hubSection + Section { + TextField("This phone's name", text: $deviceName) + if busy { + HStack { + ProgressView() + Text("Joining…").foregroundColor(.secondary) + } + } + } + } + + @ViewBuilder + private var joinedContent: some View { + header("checkmark.circle.fill", "You're in", + "This phone now follows \(patientName.value.isEmpty ? "the patient" : patientName.value) with the family's data source." + + (viewModel.hubJoin == .new ? "" : " Its alarms and settings came along.")) + } + + private func choiceRow(_ title: String, detail: String, _ choice: HubClient.Join) -> some View { + Button { + join = choice + } label: { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(title).foregroundColor(.primary) + Text(detail).font(.caption).foregroundColor(.secondary) + } + Spacer() + Image(systemName: join == choice ? "checkmark.circle.fill" : "circle") + .foregroundStyle(join == choice ? Color.accentColor : Color(.systemGray3)) + } + } + } + + // MARK: - Navigation + + private var continueEnabled: Bool { + if busy { return false } + if joined { return true } + switch page { + case .invite: return HubClient.Invite(link: inviteLink) != nil + case .choice: return true + case .name: return !deviceName.trimmingCharacters(in: .whitespaces).isEmpty + } + } + + private func reportProgress() { + viewModel.phaseProgress = joined ? nil : .init(page: page.rawValue, count: 3) + } + + private func back() { + errorText = nil + canRetry = false + if joined { + viewModel.goBack() + return + } + switch page { + case .invite: viewModel.goBack() + case .choice: page = .invite + case .name: page = devices.isEmpty ? .invite : .choice + } + } + + private func next() { + errorText = nil + canRetry = false + if joined { + viewModel.advance() + return + } + switch page { + case .invite: + Task { await read(inviteLink) } + case .choice: + if case let .replace(id) = join, let device = devices.first(where: { $0.id == id }) { + deviceName = device.name + } else { + deviceName = UIDevice.current.model + } + page = .name + case .name: + Task { await joinHub() } + } + } + + /// Leaves the join path for the data source choice. + private func chooseAnotherSource() { + errorText = nil + canRetry = false + page = .invite + viewModel.goBack() + } + + /// Shows why a step failed; an invite the hub does not take sends the user back to scan another. + private func failed(_ error: Error) { + errorText = error.localizedDescription + switch error { + case HubClient.HubError.inviteNotValid: + canRetry = false + invite = nil + inviteLink = "" + page = .invite + default: + canRetry = true + } + } + + /// Reads the invite and offers the hub's phones to copy or replace. + private func read(_ link: String) async { + guard let invite = HubClient.Invite(link: link) else { + errorText = "This is not an LF Hub invite code." + return + } + busy = true + defer { busy = false } + do { + devices = try await HubClient.shared.joinableDevices(invite: invite) + self.invite = invite + inviteLink = invite.link + join = .new + page = devices.isEmpty ? .name : .choice + } catch { + failed(error) + } + } + + private func joinHub() async { + guard let invite else { return } + busy = true + defer { busy = false } + do { + try await HubClient.shared.join(invite: invite, deviceName: deviceName, join: join) + viewModel.hubJoin = join + viewModel.advance() + } catch { + failed(error) + } + } +} diff --git a/LoopFollow/Onboarding/Steps/HubPermissionsStepView.swift b/LoopFollow/Onboarding/Steps/HubPermissionsStepView.swift new file mode 100644 index 000000000..50a87c336 --- /dev/null +++ b/LoopFollow/Onboarding/Steps/HubPermissionsStepView.swift @@ -0,0 +1,70 @@ +// LoopFollow +// HubPermissionsStepView.swift + +import SwiftUI + +/// After joining a family hub: the permissions LF Hub needs on this phone, in one place. +/// Continuing sets Background Refresh to LF Hub. +struct HubPermissionsStepView: View { + @ObservedObject var viewModel: OnboardingViewModel + @ObservedObject private var laEnabled = Storage.shared.laEnabled + @State private var notifications: HubChecklist.Permission? + @State private var alarmKit = HubChecklist.alarmKitPermission + + private var items: [HubChecklist.Item] { + var facts = HubChecklist.Facts() + facts.notifications = notifications + facts.alarmKit = alarmKit + facts.liveActivityOn = laEnabled.value + return HubChecklist.permissionItems(facts, liveActivity: true) + } + + var body: some View { + VStack(spacing: 0) { + Form { + Section { + EmptyView() + } header: { + OnboardingStepHeader( + systemImage: "bell.badge.fill", + title: "Let LF Hub reach you", + subtitle: "LF Hub sends the alarms to this phone, rings them through silent mode, and keeps the Live Activity up to date." + ) + .textCase(nil) + .padding(.bottom, 8) + } + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + + Section { + HubChecklistRows(items: items) { fix in + Task { + await HubChecklist.perform(fix) + await read() + } + } + } footer: { + Text("Until LF Hub confirms it can send this phone's alarms, LoopFollow checks them itself. Settings → LF Hub shows what is still missing.") + } + } + + OnboardingNavFooter( + showBack: viewModel.canGoBack, + onBack: { viewModel.goBack() }, + onContinue: { + BackgroundRefreshSettingsViewModel.change(to: .hub) + viewModel.advance() + } + ) + } + .task { await read() } + .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in + Task { await read() } + } + } + + private func read() async { + notifications = await HubChecklist.notificationPermission() + alarmKit = HubChecklist.alarmKitPermission + } +} diff --git a/LoopFollow/Settings/APNSettingsView.swift b/LoopFollow/Settings/APNSettingsView.swift index 359664dbe..5626b2248 100644 --- a/LoopFollow/Settings/APNSettingsView.swift +++ b/LoopFollow/Settings/APNSettingsView.swift @@ -17,50 +17,8 @@ struct APNSettingsView: View { var body: some View { Form { - Section(header: Text("LoopFollow APNs Credentials")) { - VStack(alignment: .leading, spacing: 4) { - HStack { - Text("APNS Key ID") - TogglableSecureInput( - placeholder: "Enter APNS Key ID", - text: $keyId, - style: .singleLine - ) - if !keyId.isEmpty { - Image(systemName: keyIdValid ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") - .foregroundColor(keyIdValid ? .green : .orange) - .accessibilityLabel(keyIdValid ? "Valid Key ID" : "Invalid Key ID") - } - } - if !keyId.isEmpty, !keyIdValid { - Text("Key ID must be exactly 10 uppercase letters or digits.") - .font(.caption) - .foregroundColor(.orange) - } - } - - VStack(alignment: .leading, spacing: 4) { - HStack { - Text("APNS Key") - Spacer() - if !apnsKey.isEmpty { - Image(systemName: apnsKeyValid ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") - .foregroundColor(apnsKeyValid ? .green : .orange) - .accessibilityLabel(apnsKeyValid ? "Valid APNs key" : "Invalid APNs key") - } - } - TogglableSecureInput( - placeholder: "Paste APNS Key", - text: $apnsKey, - style: .multiLine - ) - .frame(minHeight: 110) - if !apnsKey.isEmpty, !apnsKeyValid { - Text("Paste the full .p8 contents — must include the BEGIN PRIVATE KEY and END PRIVATE KEY lines.") - .font(.caption) - .foregroundColor(.orange) - } - } + HubManagedApnKey { + credentialsSection } } .onChange(of: keyId) { newValue in @@ -81,4 +39,52 @@ struct APNSettingsView: View { .navigationTitle("APN") .navigationBarTitleDisplayMode(.inline) } + + private var credentialsSection: some View { + Section(header: Text("LoopFollow APNs Credentials")) { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("APNS Key ID") + TogglableSecureInput( + placeholder: "Enter APNS Key ID", + text: $keyId, + style: .singleLine + ) + if !keyId.isEmpty { + Image(systemName: keyIdValid ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") + .foregroundColor(keyIdValid ? .green : .orange) + .accessibilityLabel(keyIdValid ? "Valid Key ID" : "Invalid Key ID") + } + } + if !keyId.isEmpty, !keyIdValid { + Text("Key ID must be exactly 10 uppercase letters or digits.") + .font(.caption) + .foregroundColor(.orange) + } + } + + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("APNS Key") + Spacer() + if !apnsKey.isEmpty { + Image(systemName: apnsKeyValid ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") + .foregroundColor(apnsKeyValid ? .green : .orange) + .accessibilityLabel(apnsKeyValid ? "Valid APNs key" : "Invalid APNs key") + } + } + TogglableSecureInput( + placeholder: "Paste APNS Key", + text: $apnsKey, + style: .multiLine + ) + .frame(minHeight: 110) + if !apnsKey.isEmpty, !apnsKeyValid { + Text("Paste the full .p8 contents — must include the BEGIN PRIVATE KEY and END PRIVATE KEY lines.") + .font(.caption) + .foregroundColor(.orange) + } + } + } + } } diff --git a/LoopFollow/Settings/CalendarSettingsView.swift b/LoopFollow/Settings/CalendarSettingsView.swift index 40f882582..f15c07078 100644 --- a/LoopFollow/Settings/CalendarSettingsView.swift +++ b/LoopFollow/Settings/CalendarSettingsView.swift @@ -22,10 +22,18 @@ struct CalendarSettingsView: View { var body: some View { Form { // ------------- Calendar write ------------- + if HubClient.shared.isHubMode { + Section { + Label(HubModeLimits.calendarNote, systemImage: "xmark.circle") + .font(.footnote) + .foregroundColor(.secondary) + } + } + Section { Toggle("Save BG to Calendar", isOn: $writeCalendarEvent.value) - .disabled(accessDenied) // prevent use when no access + .disabled(accessDenied || HubClient.shared.isHubMode) // prevent use when no access } footer: { Text(""" Add the Apple-Calendar complication to your watch or CarPlay \ diff --git a/LoopFollow/Settings/ContactSettingsView.swift b/LoopFollow/Settings/ContactSettingsView.swift index 5ace358db..58c54dca3 100644 --- a/LoopFollow/Settings/ContactSettingsView.swift +++ b/LoopFollow/Settings/ContactSettingsView.swift @@ -19,8 +19,15 @@ struct ContactSettingsView: View { .foregroundColor(.secondary) .padding(.vertical, 4) + if HubClient.shared.isHubMode { + Label(HubModeLimits.contactNote, systemImage: "xmark.circle") + .font(.footnote) + .foregroundColor(.secondary) + } + Toggle("Enable Contact BG Updates", isOn: $viewModel.contactEnabled) .toggleStyle(SwitchToggleStyle()) + .disabled(HubClient.shared.isHubMode) .onChange(of: viewModel.contactEnabled) { isEnabled in if isEnabled { requestContactAccess() diff --git a/LoopFollow/Settings/DexcomSettingsView.swift b/LoopFollow/Settings/DexcomSettingsView.swift index 2f43360fd..768e3572d 100644 --- a/LoopFollow/Settings/DexcomSettingsView.swift +++ b/LoopFollow/Settings/DexcomSettingsView.swift @@ -12,31 +12,8 @@ struct DexcomSettingsView: View { var body: some View { Form { - Section(header: Text("Dexcom Settings")) { - HStack { - Text("Username") - TextField("Enter Username", text: $viewModel.userName) - .textContentType(.username) - .autocapitalization(.none) - .disableAutocorrection(true) - .multilineTextAlignment(.trailing) - } - - HStack { - Text("Password") - TogglableSecureInput( - placeholder: "Enter Password", - text: $viewModel.password, - style: .singleLine, - textContentType: .password - ) - } - - Picker("Server", selection: $viewModel.server) { - Text("US").tag("US") - Text("Outside US").tag("NON-US") - } - .pickerStyle(SegmentedPickerStyle()) + HubManagedSources { + credentialsSection } if viewModel.isFreshSetup { @@ -73,6 +50,35 @@ struct DexcomSettingsView: View { .navigationBarBackButtonHidden(usesModalCloseButton) } + private var credentialsSection: some View { + Section(header: Text("Dexcom Settings")) { + HStack { + Text("Username") + TextField("Enter Username", text: $viewModel.userName) + .textContentType(.username) + .autocapitalization(.none) + .disableAutocorrection(true) + .multilineTextAlignment(.trailing) + } + + HStack { + Text("Password") + TogglableSecureInput( + placeholder: "Enter Password", + text: $viewModel.password, + style: .singleLine, + textContentType: .password + ) + } + + Picker("Server", selection: $viewModel.server) { + Text("US").tag("US") + Text("Outside US").tag("NON-US") + } + .pickerStyle(SegmentedPickerStyle()) + } + } + private var importSection: some View { Section(header: Text("Import Settings")) { NavigationLink(destination: ImportExportSettingsView()) { diff --git a/LoopFollow/Settings/DexcomSettingsViewModel.swift b/LoopFollow/Settings/DexcomSettingsViewModel.swift index 5a929cbe6..60b3a60cb 100644 --- a/LoopFollow/Settings/DexcomSettingsViewModel.swift +++ b/LoopFollow/Settings/DexcomSettingsViewModel.swift @@ -100,42 +100,53 @@ class DexcomSettingsViewModel: ObservableObject { guard hasCredentials else { return } let generation = verifyGeneration + Self.checkLogin(userName: userName, password: password, server: server) { [weak self] result in + guard let self, generation == self.verifyGeneration else { return } + switch result { + case .rejected: + self.statusKind = .error + self.statusMessage = "Username or password not accepted" + self.isVerified = false + self.loginRejected = true + case .unreachable: + self.statusKind = .error + self.statusMessage = "Network error — check your connection" + self.isVerified = false + self.loginRejected = false + case .accepted: + self.statusKind = .connected + self.statusMessage = "Connected" + self.isVerified = true + self.loginRejected = false + } + } + } + + enum LoginCheck { + case accepted + case rejected + case unreachable + } + + /// Logs in to Dexcom Share with the credentials; `completion` runs on the main queue. + static func checkLogin(userName: String, password: String, server: String, + completion: @escaping (LoginCheck) -> Void) + { let serverURL = server == "US" ? KnownShareServers.US.rawValue : KnownShareServers.NON_US.rawValue let client = ShareClient(username: userName, password: password, shareServer: serverURL) - client.fetchData(1) { [weak self] error, _ in - DispatchQueue.main.async { - guard let self, generation == self.verifyGeneration else { return } - - if let error = error { - switch error { - case .loginError: - self.statusKind = .error - self.statusMessage = "Username or password not accepted" - self.isVerified = false - self.loginRejected = true - case .httpError: - self.statusKind = .error - self.statusMessage = "Network error — check your connection" - self.isVerified = false - self.loginRejected = false - default: - // Login succeeded but there's no recent reading yet; the - // credentials are valid, which is all we're confirming. - self.statusKind = .connected - self.statusMessage = "Connected" - self.isVerified = true - self.loginRejected = false - } - } else { - self.statusKind = .connected - self.statusMessage = "Connected" - self.isVerified = true - self.loginRejected = false - } + client.fetchData(1) { error, _ in + let result: LoginCheck + switch error { + case .loginError: result = .rejected + case .httpError: result = .unreachable + // Login succeeded but there's no recent reading yet; the + // credentials are valid, which is all we're confirming. + default: result = .accepted } + DispatchQueue.main.async { completion(result) } } } } diff --git a/LoopFollow/Settings/GeneralSettingsView.swift b/LoopFollow/Settings/GeneralSettingsView.swift index 0ac3acc69..bb8ee25f8 100644 --- a/LoopFollow/Settings/GeneralSettingsView.swift +++ b/LoopFollow/Settings/GeneralSettingsView.swift @@ -6,6 +6,7 @@ import SwiftUI struct GeneralSettingsView: View { @ObservedObject var colorBGText = Storage.shared.colorBGText @ObservedObject var appBadge = Storage.shared.appBadge + @ObservedObject var backgroundRefreshType = Storage.shared.backgroundRefreshType @ObservedObject var appearanceMode = Storage.shared.appearanceMode @ObservedObject var showStats = Storage.shared.showStats @ObservedObject var showSmallGraph = Storage.shared.showSmallGraph @@ -31,11 +32,21 @@ struct GeneralSettingsView: View { // Telemetry — see LoopFollow/Helpers/Telemetry.swift @ObservedObject var telemetryEnabled = Storage.shared.telemetryEnabled + private var hubMode: Bool { + backgroundRefreshType.value == .hub && HubClient.shared.isConnected + } + var body: some View { Form { - Section("App Settings") { + Section { Toggle("Display App Badge", isOn: $appBadge.value) Toggle("Persistent Notification", isOn: $persistentNotification.value) + } header: { + Text("App Settings") + } footer: { + if hubMode { + Text("In LoopFollow Hub mode the hub sets the badge.") + } } Section("Display") { diff --git a/LoopFollow/Settings/SettingsMenuView.swift b/LoopFollow/Settings/SettingsMenuView.swift index 93fd0fd93..dbde446c9 100644 --- a/LoopFollow/Settings/SettingsMenuView.swift +++ b/LoopFollow/Settings/SettingsMenuView.swift @@ -27,6 +27,7 @@ struct SettingsMenuView: View { // MARK: – Sheet routing enum SettingsSection: String, CaseIterable, Hashable { + case hub = "LoopFollow Hub" case data = "Data Settings" case display = "Display Settings" case app = "App Settings" @@ -53,6 +54,7 @@ enum SettingsRoute: Hashable, Identifiable { case units case nightscout, dexcom case backgroundRefresh + case hub case general, graph case tabSettings case infoDisplay @@ -84,6 +86,7 @@ enum SettingsRoute: Hashable, Identifiable { case .units: return "Units and Metrics" case .tabSettings: return "Tabs" case .backgroundRefresh: return "Background Refresh" + case .hub: return "LF Hub" case .importExport: return "Import/Export" case .apn: return "APN" #if !targetEnvironment(macCatalyst) @@ -108,6 +111,7 @@ enum SettingsRoute: Hashable, Identifiable { case .units: return "scalemass" case .tabSettings: return "rectangle.3.group" case .backgroundRefresh: return "arrow.clockwise" + case .hub: return "server.rack" case .importExport: return "square.and.arrow.down" case .apn: return "bell.and.waves.left.and.right" #if !targetEnvironment(macCatalyst) @@ -129,6 +133,7 @@ enum SettingsRoute: Hashable, Identifiable { case .units: return ["mmol", "mgdl", "metrics"] case .infoDisplay: return ["info"] case .apn: return ["push", "notification"] + case .hub: return ["server", "hub", "alarm"] #if !targetEnvironment(macCatalyst) case .liveActivity: return ["dynamic island", "lock screen"] #endif @@ -199,6 +204,9 @@ enum SettingsRoute: Hashable, Identifiable { case .backgroundRefresh: return [ SettingsLeaf("Background Refresh Type", ["silent tune", "bluetooth", "rileylink", "omnipod", "heartbeat"]), ] + case .hub: return [ + SettingsLeaf("Send Test Alarm", ["alarm", "test"]), + ] case .importExport: return [ SettingsLeaf("Scan QR Code to Import Settings", ["qr"]), SettingsLeaf("Export Settings To QR Code", ["qr"]), @@ -280,7 +288,11 @@ enum SettingsRoute: Hashable, Identifiable { app.append(.remote) } - return [ + var sections: [(SettingsSection, [SettingsRoute])] = [] + if HubClient.isSupported { + sections.append((.hub, [.hub])) + } + return sections + [ (.data, [.nightscout, .dexcom]), (.display, display), (.app, app), @@ -298,6 +310,7 @@ enum SettingsRoute: Hashable, Identifiable { case .nightscout: NightscoutSettingsView(viewModel: .init()) case .dexcom: DexcomSettingsView(viewModel: .init()) case .backgroundRefresh: BackgroundRefreshSettingsView(viewModel: .init()) + case .hub: HubSettingsView() case .general: GeneralSettingsView() case .graph: GraphSettingsView() case .tabSettings: TabCustomizationModal() diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 1432352b7..67466ca3d 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -20,6 +20,8 @@ class Storage { var lfApnsKey = StorageValue(key: "lfApnsKey", defaultValue: "") var lfKeyId = StorageValue(key: "lfKeyId", defaultValue: "") + + var hubURL = StorageValue(key: "hubURL", defaultValue: "") var bundleId = StorageValue(key: "bundleId", defaultValue: "") var user = StorageValue(key: "user", defaultValue: "") /// Remote command types the AID app advertises in its Nightscout profile. @@ -119,6 +121,8 @@ class Storage { // Live Activity var laEnabled = StorageValue(key: "laEnabled", defaultValue: false) var laRenewBy = StorageValue(key: "laRenewBy", defaultValue: 0) + /// The activity `laRenewBy` belongs to. + var laBoundActivityId = StorageValue(key: "laBoundActivityId", defaultValue: "") var laRenewalFailed = StorageValue(key: "laRenewalFailed", defaultValue: false) var laPushToStartToken = StorageValue(key: "laPushToStartToken", defaultValue: "") var laLastPushToStartAt = StorageValue(key: "laLastPushToStartAt", defaultValue: 0) diff --git a/LoopFollow/Task/BGTask.swift b/LoopFollow/Task/BGTask.swift index b5728970c..896b5b055 100644 --- a/LoopFollow/Task/BGTask.swift +++ b/LoopFollow/Task/BGTask.swift @@ -27,6 +27,24 @@ extension MainViewController { BannerManager.shared.clear(.nightscout) } + // Hub mode: the bundle's entries are the BG the hub already merged from + // Dexcom and Nightscout. Without a current copy the BG comes from this + // app's own sources. + if HubBundle.shared.isActive { + Task { + if await HubBundle.shared.data(for: .entries) != nil { + self.webLoadNSBGData() + } else { + self.loadBGFromSources() + } + } + return + } + + loadBGFromSources() + } + + private func loadBGFromSources() { // If no Dexcom credentials and no Nightscout, schedule a retry in 60 seconds. if Storage.shared.shareUserName.value == "", Storage.shared.sharePassword.value == "", diff --git a/LoopFollow/Task/CalendarTask.swift b/LoopFollow/Task/CalendarTask.swift index 2bee4f4f5..c1aaf9485 100644 --- a/LoopFollow/Task/CalendarTask.swift +++ b/LoopFollow/Task/CalendarTask.swift @@ -13,7 +13,9 @@ extension MainViewController { } func calendarTaskAction() { - if Storage.shared.writeCalendarEvent.value, + applyHubModeLimits() + if !HubClient.shared.isHubMode, + Storage.shared.writeCalendarEvent.value, !Storage.shared.calendarIdentifier.value.isEmpty { writeCalendar() diff --git a/LoopFollow/Task/TaskScheduler.swift b/LoopFollow/Task/TaskScheduler.swift index 95a2d49ff..4d1eeecd0 100644 --- a/LoopFollow/Task/TaskScheduler.swift +++ b/LoopFollow/Task/TaskScheduler.swift @@ -176,7 +176,7 @@ class TaskScheduler { // Silent Tune is the only mode whose invariant is continuous runtime, which is // what this measures. `.none` is meant to be suspended and the Bluetooth modes // tick at heartbeat cadence, so for both a gap is normal. - guard Storage.shared.backgroundRefreshType.value == .silentTune else { return } + guard BackgroundRefreshType.inEffect == .silentTune else { return } let alerts = BackgroundAlertDuration.allCases .filter { gap >= $0.rawValue } .map { "\(Int($0.rawValue / 60))" } diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index d788abfeb..b25de4998 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -600,16 +600,40 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { // We want to always come back to the home screen Observable.shared.selectedTabIndex.value = 0 - if Storage.shared.backgroundRefreshType.value == .silentTune { + startBackgroundKeepAlive() + + NightscoutSocketManager.shared.disconnect() + HubBundle.shared.stopListening() + } + + /// Starts what the mode in effect uses in the background: the Silent Tune and the + /// background refresh that revives it, the background refresh that checks the hub + /// in Hub mode, and the background alerts. + func startBackgroundKeepAlive() { + let mode = BackgroundRefreshType.inEffect + if mode == .silentTune { backgroundTask.startBackgroundTask() + } + + if mode == .silentTune || Storage.shared.backgroundRefreshType.value == .hub { BackgroundRefreshManager.shared.scheduleRefresh() } - if Storage.shared.backgroundRefreshType.value != .none { + if mode.keepsAppRunning { BackgroundAlertManager.shared.startBackgroundAlert() } + } - NightscoutSocketManager.shared.disconnect() + /// The mode in effect changed while the app may be in the background (the hub + /// stopped driving the alarms, or Hub mode ended): in the background, starts the + /// Silent Tune unless it already plays. + func backgroundModeChanged() { + guard UIApplication.shared.applicationState == .background, + BackgroundRefreshType.inEffect.keepsAppRunning, + !backgroundTask.isPlaying + else { return } + LogManager.shared.log(category: .general, message: "Background refresh mode in effect is now \(BackgroundRefreshType.inEffect.rawValue); starting its keep-alive") + startBackgroundKeepAlive() } // Migrations must only run when UserDefaults is accessible (i.e. after first unlock). @@ -689,6 +713,12 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { // runMigrationsIfNeeded() will always pass. Catches the case where viewDidLoad // ran during a BGAppRefreshTask background launch and deferred migrations. runMigrationsIfNeeded() + HubBundle.shared.startListening() + Task { + await HubClient.shared.readNotificationPermission() + await HubClient.shared.sync() + await HubWatchdog.check() + } } @objc func handleRemoteMealCommandDidComplete() { @@ -726,11 +756,13 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { // reset screenlock state if needed UIApplication.shared.isIdleTimerDisabled = Storage.shared.screenlockSwitchState.value - if Storage.shared.backgroundRefreshType.value == .silentTune { + // Hub mode runs the Silent Tune while the hub does not drive the alarms. + let selected = Storage.shared.backgroundRefreshType.value + if selected == .silentTune || selected == .hub { backgroundTask.stopBackgroundTask() } - if Storage.shared.backgroundRefreshType.value != .none { + if selected.keepsAppRunning || selected == .hub { BackgroundAlertManager.shared.stopBackgroundAlert() } @@ -834,6 +866,9 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { } func updateBadge(val: Int) { + if HubClient.shared.drivesBadge, Storage.shared.appBadge.value { + return + } if Storage.shared.appBadge.value { let latestBG = String(val) UIApplication.shared.applicationIconBadgeNumber = Int(Localizer.removePeriodAndCommaForBadge(Localizer.toDisplayUnits(latestBG))) ?? val @@ -960,22 +995,8 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { eventTitle = eventTitle.replacingOccurrences(of: "%COB%", with: latestCOB?.formattedValue() ?? "0") eventTitle = eventTitle.replacingOccurrences(of: "%BASAL%", with: basal) - // Delete Events from last 2 hours and 2 hours in future - let deleteStartDate = Date().addingTimeInterval(-60 * 60 * 2) - let deleteEndDate = Date().addingTimeInterval(60 * 60 * 2) // guard solves for some ios upgrades removing the calendar - guard let deleteCalendar = store.calendar(withIdentifier: Storage.shared.calendarIdentifier.value) as? EKCalendar else { return } - let predicate2 = store.predicateForEvents(withStart: deleteStartDate, end: deleteEndDate, calendars: [deleteCalendar]) - let eVDelete = store.events(matching: predicate2) as [EKEvent]? - if eVDelete != nil { - for i in eVDelete! { - do { - try store.remove(i, span: EKSpan.thisEvent, commit: true) - } catch { - LogManager.shared.log(category: .calendar, message: "Failed to remove calendar event: \(error.localizedDescription)") - } - } - } + guard removeCalendarEvents() else { return } // Write New Event let event = EKEvent(eventStore: store) @@ -994,6 +1015,27 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { } } + /// Deletes the events from 2 hours ago to 2 hours ahead on the chosen calendar. + /// False when that calendar no longer exists. + @discardableResult + func removeCalendarEvents() -> Bool { + let deleteStartDate = Date().addingTimeInterval(-60 * 60 * 2) + let deleteEndDate = Date().addingTimeInterval(60 * 60 * 2) + guard let deleteCalendar = store.calendar(withIdentifier: Storage.shared.calendarIdentifier.value) else { return false } + let predicate2 = store.predicateForEvents(withStart: deleteStartDate, end: deleteEndDate, calendars: [deleteCalendar]) + let eVDelete = store.events(matching: predicate2) as [EKEvent]? + if eVDelete != nil { + for i in eVDelete! { + do { + try store.remove(i, span: EKSpan.thisEvent, commit: true) + } catch { + LogManager.shared.log(category: .calendar, message: "Failed to remove calendar event: \(error.localizedDescription)") + } + } + } + return true + } + func userNotificationCenter(_: UNUserNotificationCenter, didReceive _: UNNotificationResponse, withCompletionHandler _: @escaping () -> Void) {} func calculateMaxBgGraphValue() -> Float { @@ -1255,7 +1297,7 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { extension MainViewController: AVSpeechSynthesizerDelegate { func speechSynthesizer(_: AVSpeechSynthesizer, didFinish _: AVSpeechUtterance) { let appState = UIApplication.shared.applicationState - let isSilentTuneMode = Storage.shared.backgroundRefreshType.value == .silentTune + let isSilentTuneMode = BackgroundRefreshType.inEffect == .silentTune if isSilentTuneMode, appState == .background { LogManager.shared.log(category: .general, message: "Silent tune active in background; not deactivating session.", isDebug: true) diff --git a/LoopFollow/ViewControllers/MoreMenuView.swift b/LoopFollow/ViewControllers/MoreMenuView.swift index ed1c8f360..887121b58 100644 --- a/LoopFollow/ViewControllers/MoreMenuView.swift +++ b/LoopFollow/ViewControllers/MoreMenuView.swift @@ -276,7 +276,10 @@ struct MoreMenuView: View { onShare: { noticeText in let presenter = UIApplication.shared.topMost presenter?.dismiss(animated: true) { - presentLogShareSheet(noticeText: noticeText, logFiles: files) + Task { @MainActor in + let hubFiles = await HubClient.shared.diagnosticLogFiles() + presentLogShareSheet(noticeText: noticeText, logFiles: files + hubFiles) + } } } ) diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 9f6ca7c94..3d353a522 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -50,7 +50,8 @@ private func makeDynamicIsland(context: ActivityViewContext + + + + CFBundleDisplayName + LoopFollowNotificationService + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LFAppGroupBaseBundleID + com.$(unique_id).LoopFollow$(app_suffix) + NSAlarmKitUsageDescription + LoopFollow uses alarms so urgent glucose alerts from LF Hub reach you even when the phone is silenced or a Focus is on. + NSSupportsLiveActivities + + NSExtension + + NSExtensionPointIdentifier + com.apple.usernotifications.service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).NotificationService + + + diff --git a/LoopFollowNotificationService/LoopFollowNotificationService.entitlements b/LoopFollowNotificationService/LoopFollowNotificationService.entitlements new file mode 100644 index 000000000..5b963cc90 --- /dev/null +++ b/LoopFollowNotificationService/LoopFollowNotificationService.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.$(unique_id).LoopFollow$(app_suffix) + + + diff --git a/LoopFollowNotificationService/NotificationService.swift b/LoopFollowNotificationService/NotificationService.swift new file mode 100644 index 000000000..818fa7098 --- /dev/null +++ b/LoopFollowNotificationService/NotificationService.swift @@ -0,0 +1,297 @@ +// LoopFollow +// NotificationService.swift + +import AVFAudio +import UserNotifications + +/// Runs for every push that carries `mutable-content`. For an LF Hub alarm it +/// rings an AlarmKit alarm (iOS 26+, when allowed) and confirms the delivery to the +/// hub, then delivers the notification, without its own sound when the system alarm +/// rings. A hub alarm or Speak BG push reaching a phone that left the hub is delivered +/// quietly. Other notifications are left as they are. +final class NotificationService: UNNotificationServiceExtension { + private let lock = NSLock() + private var contentHandler: ((UNNotificationContent) -> Void)? + private var content: UNMutableNotificationContent? + private var speech: SpeechFile? + + /// Hands the notification to the system once; whichever of the work and the + /// expiry comes second does nothing. + private func deliver(_ content: UNNotificationContent) { + let handler = lock.withLock { + defer { contentHandler = nil } + return contentHandler + } + handler?(content) + } + + override func didReceive(_ request: UNNotificationRequest, + withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) + { + guard let content = request.content.mutableCopy() as? UNMutableNotificationContent else { + contentHandler(request.content) + return + } + lock.withLock { + self.contentHandler = contentHandler + self.content = content + } + + let userInfo = request.content.userInfo + Self.log("received keys=\(userInfo.keys.compactMap { $0 as? String }.sorted())") + + // The hub stops a system alarm whose condition cleared or that Snooze All silenced. + if let stop = userInfo["lfhubStopAlarmId"] as? String, let stopID = UUID(uuidString: stop) { + #if canImport(AlarmKit) + if #available(iOS 26, *) { HubAlarmKit.stop(alarmID: stopID) } + #endif + Self.log("alarm \(stopID.uuidString.prefix(8)) stopped by the hub") + deliver(content) + return + } + let isAlarm = userInfo["lfhubAlarmId"] != nil + let delivery = HubAppGroup.delivery(hubURL: Self.hubURL, systemAlarm: (userInfo["lfhubAlarmKit"] as? Int) == 1) + if delivery == .quiet, isAlarm || userInfo["lfhubSpeak"] != nil { + content.sound = nil + content.interruptionLevel = .passive + Self.log("\(isAlarm ? "alarm" : "speak") delivered quietly: not connected to a hub") + deliver(content) + return + } + if let speak = userInfo["lfhubSpeak"] as? [String: Any], let library = Self.groupLibrary { + self.speak(speak, content: content, sounds: library.appendingPathComponent("Sounds", isDirectory: true)) + return + } + guard let alarm = userInfo["lfhubAlarmId"] as? String, let alarmID = UUID(uuidString: alarm) else { + deliver(content) + return + } + + let receipt = userInfo["lfhubReceipt"] as? String + let ringsSystemAlarm = delivery == .systemAlarm + let sound = userInfo["lfhubSound"] as? String + let symbol = userInfo["lfhubIcon"] as? String ?? "moon.zzz" + let ringOnce = (userInfo["lfhubRingOnce"] as? Int) == 1 + let started = Date() + Task { + async let receiptSent: Void = Self.sendReceipt(receipt, alarmID: alarmID) + if ringsSystemAlarm { + let (ringing, firesAt) = await Self.ring(alarmID: alarmID, title: content.title, sound: sound, symbol: symbol) + if ringing { + // The system alarm is the alert; the notification goes quietly to the list. + content.sound = nil + content.interruptionLevel = .passive + } + if ringOnce, let firesAt { + await Self.stopAfterOnePlay(alarmID: alarmID, firesAt: firesAt, sound: sound, started: started) + } + } + await receiptSent + self.deliver(content) + } + } + + /// Rings the hub alarm as an AlarmKit alarm: whether it rings, and when it starts if + /// this push scheduled it. + private static func ring(alarmID: UUID, title: String, sound: String?, symbol: String) async -> (Bool, Date?) { + #if canImport(AlarmKit) + if #available(iOS 26, *) { + let result = await HubAlarmKit.ring(alarmID: alarmID, title: title, soundName: sound, symbol: symbol) + log("alarm \(alarmID.uuidString.prefix(8)) sound=\(sound ?? "default") soundFile=\(soundExists(sound)) -> \(result.detail)") + return (result.ringing, result.firesAt) + } + #endif + log("alarm \(alarmID.uuidString.prefix(8)): AlarmKit unavailable") + return (false, nil) + } + + /// The system alarm loops its sound until stopped; an alarm whose sound does not repeat + /// is stopped once it has sounded for its sound's length plus `ringMargin`, within the + /// time the extension is given. An alarm that has not begun to sound by then + /// is left ringing; the hub stops it later. + private static func stopAfterOnePlay(alarmID: UUID, firesAt: Date, sound: String?, started: Date) async { + #if canImport(AlarmKit) + if #available(iOS 26, *) { + let deadline = started.addingTimeInterval(ringOnceDeadline) + while !HubAlarmKit.isAlerting(alarmID: alarmID) { + guard Date() < deadline else { + log("alarm \(alarmID.uuidString.prefix(8)) not sounding yet, left for the hub to stop") + return + } + try? await Task.sleep(nanoseconds: 250_000_000) + } + let alerting = Date() + let ring = (soundDuration(sound) ?? defaultSoundDuration) + ringMargin + let wait = min(alerting.addingTimeInterval(ring), deadline).timeIntervalSinceNow + if wait > 0 { try? await Task.sleep(nanoseconds: UInt64(wait * 1_000_000_000)) } + HubAlarmKit.stop(alarmID: alarmID) + log("alarm \(alarmID.uuidString.prefix(8)) sounded \(Int(alerting.timeIntervalSince(firesAt) * 1000)) ms after its fire time, stopped after \(Int(Date().timeIntervalSince(alerting))) s") + } + #endif + } + + /// How long a sound in the App Group's Library/Sounds plays; nil when it cannot be read. + private static func soundDuration(_ name: String?) -> TimeInterval? { + guard let name, let library = groupLibrary, + let player = try? AVAudioPlayer(contentsOf: library.appendingPathComponent("Sounds/\(name)")) + else { return nil } + return player.duration + } + + /// The play time assumed for the system's default alarm sound. + private static let defaultSoundDuration: TimeInterval = 8 + + /// The time from the alarm's alerting state to its sound being heard, allowed on top of the sound. + private static let ringMargin: TimeInterval = 0.75 + + /// The extension gets about 30 seconds per push; the stop and the delivery fit within this. + private static let ringOnceDeadline: TimeInterval = 25 + + /// Tells the hub this alarm push reached the phone. The receipt token is the + /// credential; the request carries no key and no body. + private static func sendReceipt(_ token: String?, alarmID: UUID) async { + guard let token, token.count == 32, token.allSatisfy(\.isHexDigit) else { return } + guard let base = hubURL, let url = URL(string: "\(base)/v1/receipt/\(token)") + else { + log("alarm \(alarmID.uuidString.prefix(8)) receipt not sent: no hub URL") + return + } + var request = URLRequest(url: url, timeoutInterval: receiptTimeout) + request.httpMethod = "POST" + do { + let (_, response) = try await URLSession.shared.data(for: request) + log("alarm \(alarmID.uuidString.prefix(8)) receipt -> \((response as? HTTPURLResponse)?.statusCode ?? 0)") + } catch { + log("alarm \(alarmID.uuidString.prefix(8)) receipt failed: \(error.localizedDescription)") + } + } + + private static let receiptTimeout: TimeInterval = 10 + + /// Speak BG from the hub: the announcement for the reading becomes the + /// notification's sound. Without a rendered file the push's own sound plays. + private func speak(_ speak: [String: Any], content: UNMutableNotificationContent, sounds: URL) { + guard let current = (speak["current"] as? NSNumber)?.intValue, + let previous = (speak["previous"] as? NSNumber)?.intValue + else { + deliver(content) + return + } + let language = speak["language"] as? String ?? "en" + let text = BGAnnouncement.text(current: current, previous: previous, language: language, + mmol: speak["mmol"] as? Bool ?? false) + let started = Date() + let speech = SpeechFile(directory: sounds) + lock.withLock { self.speech = speech } + speech.render(text, language: BGAnnouncement.voiceLanguageCode(forAppLanguage: language)) { name in + Self.log("speak \(language) -> \(name ?? "failed") in \(Int(Date().timeIntervalSince(started) * 1000)) ms") + if let name { content.sound = UNNotificationSound(named: UNNotificationSoundName(name)) } + self.deliver(content) + } + } + + /// The hub's address while the app is connected to one. + private static var hubURL: String? { + groupID.flatMap { UserDefaults(suiteName: $0) }?.string(forKey: HubAppGroup.hubURLKey) + } + + private static var groupID: String? { + (Bundle.main.object(forInfoDictionaryKey: "LFAppGroupBaseBundleID") as? String).map { "group.\($0)" } + } + + private static var groupLibrary: URL? { + guard let groupID else { return nil } + return FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: groupID)? + .appendingPathComponent("Library", isDirectory: true) + } + + private static func soundExists(_ name: String?) -> Bool { + guard let name, let library = groupLibrary else { return false } + return FileManager.default.fileExists(atPath: library.appendingPathComponent("Sounds/\(name)").path) + } + + /// The log keeps its newest lines within this size. + private static let logLimit = 256 * 1024 + + /// Appends to Library/Logs/lfhub-nse.log in the App Group, newest last. Past + /// `logLimit` the oldest half is dropped. + private static func log(_ message: String) { + guard let library = groupLibrary else { return } + let directory = library.appendingPathComponent("Logs", isDirectory: true) + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let url = directory.appendingPathComponent("lfhub-nse.log") + if let size = (try? FileManager.default.attributesOfItem(atPath: url.path))?[.size] as? Int, size > logLimit, + let data = try? Data(contentsOf: url) + { + var kept = data.suffix(logLimit / 2) + if let newline = kept.firstIndex(of: UInt8(ascii: "\n")) { kept = kept[kept.index(after: newline)...] } + try? Data(kept).write(to: url, options: .atomic) + } + let line = Data("\(ISO8601DateFormatter().string(from: Date())) \(message)\n".utf8) + if let handle = try? FileHandle(forWritingTo: url) { + handle.seekToEndOfFile() + handle.write(line) + try? handle.close() + } else { + try? line.write(to: url) + } + } + + override func serviceExtensionTimeWillExpire() { + if let content = lock.withLock({ content }) { deliver(content) } + } +} + +/// Renders text to speech into a sound file the notification can play. Only +/// the latest rendering is kept. +final class SpeechFile: NSObject { + private let directory: URL + private let synthesizer = AVSpeechSynthesizer() + private var file: AVAudioFile? + + init(directory: URL) { + self.directory = directory + } + + func render(_ text: String, language: String, completion: @escaping (String?) -> Void) { + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let old = (try? FileManager.default.contentsOfDirectory(atPath: directory.path)) ?? [] + for name in old where name.hasPrefix("lfhub-speak-") { + try? FileManager.default.removeItem(at: directory.appendingPathComponent(name)) + } + let name = "lfhub-speak-\(Int(Date().timeIntervalSince1970)).caf" + let url = directory.appendingPathComponent(name) + + let utterance = AVSpeechUtterance(string: text) + utterance.voice = AVSpeechSynthesisVoice(language: language) + var finished = false + synthesizer.write(utterance) { [weak self] buffer in + guard let self, !finished else { return } + guard let pcm = buffer as? AVAudioPCMBuffer, pcm.frameLength > 0 else { + finished = true + let ok = file != nil + file = nil + completion(ok ? name : nil) + return + } + do { + if file == nil { + let settings: [String: Any] = [ + AVFormatIDKey: kAudioFormatLinearPCM, + AVSampleRateKey: pcm.format.sampleRate, + AVNumberOfChannelsKey: pcm.format.channelCount, + AVLinearPCMBitDepthKey: 16, + AVLinearPCMIsFloatKey: false, + ] + file = try AVAudioFile(forWriting: url, settings: settings, + commonFormat: pcm.format.commonFormat, interleaved: pcm.format.isInterleaved) + } + try file?.write(from: pcm) + } catch { + finished = true + file = nil + completion(nil) + } + } + } +} diff --git a/Tests/HubTests.swift b/Tests/HubTests.swift new file mode 100644 index 000000000..dbd9f0a43 --- /dev/null +++ b/Tests/HubTests.swift @@ -0,0 +1,1083 @@ +// LoopFollow +// HubTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct HubBundleTests { + private func bundle(version: Int64, parts: [String: Any?]) -> Data { + var encoded: [String: Any] = [:] + for (name, data) in parts { + encoded[name] = ["fetchedAt": "2026-01-01T00:00:00Z", "data": data ?? NSNull()] + } + return try! JSONSerialization.data(withJSONObject: ["version": version, "parts": encoded]) + } + + @Test("a first bundle reports every part it carries and stores the version") + func firstBundle() { + let hub = HubBundle() + let changed = hub.apply(bundle(version: 3, parts: [ + "entries": [["sgv": 100, "date": 1_700_000_000_000, "direction": "Flat"]], + "profile": [["defaultProfile": "Default"]], + ])) + + #expect(changed == [.entries, .profile]) + #expect(hub.version == 3) + #expect(hub.stored(.entries) != nil) + #expect(hub.stored(.treatments) == nil) + } + + @Test("parts are used only while the hub answered recently") + func staleParts() { + let hub = HubBundle() + let received = Date(timeIntervalSince1970: 1_700_000_000) + _ = hub.apply(bundle(version: 1, parts: ["profile": [["defaultProfile": "Default"]]]), receivedAt: received) + + #expect(hub.current(.profile, now: received.addingTimeInterval(HubBundle.staleAfter - 1)) != nil) + #expect(hub.current(.profile, now: received.addingTimeInterval(HubBundle.staleAfter)) == nil) + #expect(hub.stored(.profile) != nil) + } + + @Test("an unchanged part is not reported again") + func unchangedPart() { + let hub = HubBundle() + let profile: [[String: Any]] = [["defaultProfile": "Default"]] + _ = hub.apply(bundle(version: 1, parts: ["profile": profile, "dbsize": ["dataSize": 1]])) + + let changed = hub.apply(bundle(version: 2, parts: ["profile": profile, "dbsize": ["dataSize": 2]])) + + #expect(changed == [.dbsize]) + #expect(hub.version == 2) + } + + @Test("a null part and an unknown part are skipped") + func skippedParts() { + let hub = HubBundle() + let changed = hub.apply(bundle(version: 1, parts: ["sage": nil, "somethingNew": [1, 2]])) + + #expect(changed.isEmpty) + #expect(hub.stored(.sage) == nil) + } + + @Test("a response that does not parse changes nothing") + func unparsable() { + let hub = HubBundle() + #expect(hub.apply(Data("not json".utf8)).isEmpty) + #expect(hub.version == 0) + } + + @Test("a part is current only in Hub mode and while the hub answered recently") + func currentPart() { + let received = Date(timeIntervalSince1970: 1_700_000_000) + let active = HubBundle(isActive: { true }) + _ = active.apply(bundle(version: 1, parts: ["entries": [["sgv": 100]]]), receivedAt: received) + #expect(active.hasCurrent(.entries, now: received.addingTimeInterval(60))) + #expect(!active.hasCurrent(.entries, now: received.addingTimeInterval(HubBundle.staleAfter))) + #expect(!active.hasCurrent(.profile, now: received.addingTimeInterval(60))) + + let inactive = HubBundle(isActive: { false }) + _ = inactive.apply(bundle(version: 1, parts: ["entries": [["sgv": 100]]]), receivedAt: received) + #expect(!inactive.hasCurrent(.entries, now: received.addingTimeInterval(60))) + } + + @Test("a current part is handed out as is, and outside Hub mode none is") + func dataForPart() async { + let active = HubBundle(isActive: { true }) + _ = active.apply(bundle(version: 1, parts: ["profile": [["defaultProfile": "Default"]]])) + #expect(await active.data(for: .profile) == active.stored(.profile)) + + let inactive = HubBundle(isActive: { false }) + _ = inactive.apply(bundle(version: 1, parts: ["profile": [["defaultProfile": "Default"]]])) + #expect(await inactive.data(for: .profile) == nil) + } + + @Test("each part reruns the task that reads it") + func partTasks() { + #expect(HubBundle.Part.entries.task == .fetchBG) + #expect(HubBundle.Part.devicestatus.task == .deviceStatus) + #expect(HubBundle.Part.profile.task == .profile) + #expect(HubBundle.Part.dbsize.task == .dbSize) + for part in [HubBundle.Part.treatments, .sage, .cage, .iage] { + #expect(part.task == .treatments) + } + } +} + +struct HubModeTests { + @Test("only modes that keep the app running are watched for stalls") + func keepsAppRunning() { + #expect(BackgroundRefreshType.silentTune.keepsAppRunning) + #expect(BackgroundRefreshType.dexcom.keepsAppRunning) + #expect(!BackgroundRefreshType.hub.keepsAppRunning) + #expect(!BackgroundRefreshType.none.keepsAppRunning) + #expect(!BackgroundRefreshType.hub.isBluetooth) + } + + @Test("converted alarm sounds have stable names the hub can address") + func notificationSoundNames() { + let id = UUID(uuidString: "00000000-0000-0000-0000-0000000000AB")! + #expect(HubAlarmSounds.notificationName(for: .builtin("Indeed")) == "lfhub-Indeed.caf") + #expect(HubAlarmSounds.notificationName(for: .custom(id)) == "lfhub-\(id.uuidString).caf") + } + + @Test("client settings always carry the phone's locale facts") + func clientSettings() { + let settings = HubClient.currentClientSettings() + #expect(settings["timeZone"] as? String == TimeZone.current.identifier) + #expect(settings["decimalSeparator"] is String) + #expect(settings["timeFormat"] is String) + for key in settings.keys where !["timeZone", "decimalSeparator", "timeFormat"].contains(key) { + #expect(HubClient.clientSettingKeys.contains(key)) + } + } +} + +struct HubHealthTests { + private func status(pushFailingSince: String? = nil, lastPushOkAt: String? = "2026-01-01T11:59:30.123456Z", + apnsConfigured: Bool = true, alarmsOn: Bool = true, alarmsReceived: Bool = true, + followed: Bool = true, + hub: [String: Any]? = ["version": "1.0.0", "api": 1, "features": ["receipts", "speak"]]) throws + -> HubClient.Status + { + var device: [String: Any] = [ + "name": "Phone", "apnsConfigured": apnsConfigured, + "liveActivity": ["registered": true, "pushToStartRegistered": true, "renewalFailed": false], + "settings": ["alarms": alarmsOn], + "alarmsReceived": alarmsReceived, + ] + if let pushFailingSince { device["pushFailingSince"] = pushFailingSince } + if let lastPushOkAt { device["lastPushOkAt"] = lastPushOkAt } + var json: [String: Any] = [ + "device": device, + "patient": ["name": "Patient", "followed": followed], + "live": ["connected": true, "staleAfterSeconds": 600], + ] + if let hub { json["hub"] = hub } + return try HubClient.decoder.decode(HubClient.Status.self, from: JSONSerialization.data(withJSONObject: json)) + } + + private let now = ISO8601DateFormatter().date(from: "2026-01-01T12:00:00Z")! + + private func confirms(_ status: HubClient.Status, hubMode: Bool = true, alarmsPending: Bool = false, + alarmKitAuthorized: Bool = true, notificationsAllowed: Bool = true) -> Bool + { + HubClient.confirms(status, hubMode: hubMode, alarmsPending: alarmsPending, + alarmKitAuthorized: alarmKitAuthorized, notificationsAllowed: notificationsAllowed, now: now) + } + + @Test("the hub's dates decode with and without fractional seconds") + func dates() throws { + let decoded = try status(pushFailingSince: "2026-01-01T11:55:00Z") + #expect(decoded.device.pushFailingSince == ISO8601DateFormatter().date(from: "2026-01-01T11:55:00Z")) + #expect(decoded.device.lastPushOkAt != nil) + } + + @Test("a hub whose pushes reach the device is healthy") + func pushesWork() throws { + #expect(try HubClient.health(of: status(pushFailingSince: nil), now: now) == .healthy) + } + + @Test("a hub that never reached the device is not healthy") + func neverPushed() throws { + #expect(try HubClient.health(of: status(lastPushOkAt: nil), now: now) == .unhealthy) + } + + @Test("pushes failing for a short while are tolerated") + func briefPushFailure() throws { + #expect(try HubClient.health(of: status(pushFailingSince: "2026-01-01T11:55:00Z"), now: now) == .healthy) + } + + @Test("pushes failing for ten minutes make the hub unhealthy") + func lastingPushFailure() throws { + #expect(try HubClient.health(of: status(pushFailingSince: "2026-01-01T11:50:00Z"), now: now) == .unhealthy) + } + + @Test("the hub confirms a device's alarms when everything is in place") + func confirmed() throws { + #expect(try confirms(status())) + #expect(try HubClient.confirmationProblems(status(), alarmsPending: false, alarmKitAuthorized: true, + notificationsAllowed: true, now: now).isEmpty) + } + + @Test("a hub without the version exchange or without receipts is not confirmed") + func hubVersion() throws { + #expect(try !confirms(status(hub: nil))) + #expect(try !confirms(status(hub: ["version": "0.9", "api": 1, "features": ["speak"]]))) + #expect(try !confirms(status(hub: ["version": "0.9", "api": 0, "features": ["receipts"]]))) + #expect(try confirms(status(hub: ["version": "2.0", "api": 2, "features": ["receipts", "somethingNew"]]))) + #expect(try HubClient.confirmationProblems(status(hub: nil), alarmsPending: false, alarmKitAuthorized: true, + notificationsAllowed: true, now: now) + == [HubClient.Problem.hubOutdated]) + } + + @Test("any missing piece leaves the app alarming itself") + func notConfirmed() throws { + #expect(try !confirms(status(), hubMode: false)) + #expect(try !confirms(status(), alarmsPending: true)) + #expect(try !confirms(status(), alarmKitAuthorized: false)) + #expect(try !confirms(status(), notificationsAllowed: false)) + #expect(try !confirms(status(apnsConfigured: false))) + #expect(try !confirms(status(alarmsOn: false))) + #expect(try !confirms(status(alarmsReceived: false))) + #expect(try !confirms(status(followed: false))) + #expect(try !confirms(status(lastPushOkAt: nil))) + #expect(try !confirms(status(pushFailingSince: "2026-01-01T11:40:00Z"))) + } + + @Test("each missing piece is named for the user") + func problems() throws { + func problems(_ status: HubClient.Status, alarmsPending: Bool = false, alarmKitAuthorized: Bool = true, + notificationsAllowed: Bool = true) -> [String] + { + HubClient.confirmationProblems(status, alarmsPending: alarmsPending, alarmKitAuthorized: alarmKitAuthorized, + notificationsAllowed: notificationsAllowed, now: now) + } + #expect(try problems(status(lastPushOkAt: nil, apnsConfigured: false)) == [HubClient.Problem.noPush]) + #expect(try problems(status(alarmsOn: false)) == [HubClient.Problem.alarmsOff]) + #expect(try problems(status(alarmsReceived: false)) == [HubClient.Problem.alarmsNotReceived]) + #expect(try problems(status(), alarmsPending: true) == [HubClient.Problem.alarmsNotReceived]) + #expect(try problems(status(followed: false)) == [HubClient.Problem.notFollowed]) + #expect(try problems(status(lastPushOkAt: nil)) == [HubClient.Problem.noPushYet]) + #expect(try problems(status(pushFailingSince: "2026-01-01T11:40:00Z")) == [HubClient.Problem.pushFailing]) + #expect(try problems(status(), alarmKitAuthorized: false) == [HubClient.alarmKitProblem(authorized: false)!]) + #expect(try problems(status(), notificationsAllowed: false) == [HubClient.Problem.notificationsOff]) + } + + @Test("a cancelled request gives no verdict") + func cancellation() { + #expect(HubClient.isCancellation(CancellationError())) + #expect(HubClient.isCancellation(URLError(.cancelled))) + #expect(!HubClient.isCancellation(URLError(.timedOut))) + } + + @Test("uploads are retried only when the failure may pass") + func retries() { + #expect(HubClient.isRetryable(URLError(.timedOut))) + #expect(HubClient.isRetryable(HubClient.HubError.http(503, ""))) + #expect(HubClient.isRetryable(HubClient.HubError.http(429, ""))) + #expect(!HubClient.isRetryable(HubClient.HubError.http(400, ""))) + #expect(!HubClient.isRetryable(HubClient.HubError.http(409, ""))) + #expect(!HubClient.isRetryable(HubClient.HubError.notConnected)) + } +} + +struct HubWatchdogTests { + private typealias State = HubWatchdog.State + private let start: TimeInterval = 1_700_000_000 + + @Test("outside Hub mode the watchdog forgets everything and clears its alert") + func leavingHubMode() { + let alerted = State(lastHealthy: start, unhealthySince: start + 60, alerted: true) + #expect(HubWatchdog.next(alerted, hubMode: false, health: .unhealthy, now: start + 120) == (State(), .clearAlert)) + #expect(HubWatchdog.next(State(), hubMode: false, health: .healthy, now: start) == (State(), .none)) + } + + @Test("a check without an answer changes nothing") + func unknown() { + let state = State(lastHealthy: start, unhealthySince: start + 60, alerted: false) + #expect(HubWatchdog.next(state, hubMode: true, health: .unknown, now: start + 3600) == (state, .none)) + } + + @Test("the first unhealthy check starts the clock") + func firstUnhealthy() { + let (state, action) = HubWatchdog.next(State(), hubMode: true, health: .unhealthy, now: start) + #expect(state == State(lastHealthy: start, unhealthySince: start, alerted: false)) + #expect(action == .none) + } + + @Test("one alert after twenty minutes without a healthy answer, then quiet") + func alertOncePerOutage() { + var state = State(lastHealthy: start, unhealthySince: 0, alerted: false) + var action: HubWatchdog.Action + (state, action) = HubWatchdog.next(state, hubMode: true, health: .unhealthy, now: start + 60) + #expect(action == .none) + (state, action) = HubWatchdog.next(state, hubMode: true, health: .unhealthy, now: start + HubWatchdog.silentAfter - 1) + #expect(action == .none) + (state, action) = HubWatchdog.next(state, hubMode: true, health: .unhealthy, now: start + HubWatchdog.silentAfter) + #expect(action == .raiseAlert(minutes: 20)) + (state, action) = HubWatchdog.next(state, hubMode: true, health: .unhealthy, now: start + 2 * HubWatchdog.silentAfter) + #expect(action == .none) + #expect(state.alerted) + } + + @Test("a healthy answer clears the alert and the clock") + func recovery() { + let alerted = State(lastHealthy: start, unhealthySince: start + 60, alerted: true) + let (state, action) = HubWatchdog.next(alerted, hubMode: true, health: .healthy, now: start + 3600) + #expect(state == State(lastHealthy: start + 3600, unhealthySince: 0, alerted: false)) + #expect(action == .clearAlert) + } + + @Test("the app takes over its alarms after five minutes of failed checks") + func looksHealthy() { + #expect(HubWatchdog.looksHealthy(State(), now: start)) + let failing = State(lastHealthy: start, unhealthySince: start, alerted: false) + #expect(HubWatchdog.looksHealthy(failing, now: start + HubWatchdog.unhealthyTolerance - 1)) + #expect(!HubWatchdog.looksHealthy(failing, now: start + HubWatchdog.unhealthyTolerance)) + } +} + +/// Answers every request to `host` with 401. +final class HubUnauthorizedProtocol: URLProtocol { + static let host = "hub.lfhub-tests.invalid" + + override class func canInit(with request: URLRequest) -> Bool { + request.url?.host == host + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + let response = HTTPURLResponse(url: request.url!, statusCode: 401, httpVersion: nil, headerFields: nil)! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data()) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} +} + +@Suite(.serialized) +struct HubForgottenTests { + @Test("a hub that no longer knows the device disconnects it and Hub mode falls back to Silent Tune") + func forgotten() async throws { + let storage = Storage.shared + let deviceKey = HubClient.shared.deviceKey + let saved = (storage.hubURL.value, deviceKey.store, storage.backgroundRefreshType.value) + deviceKey.store = MemoryKeyStore() + URLProtocol.registerClass(HubUnauthorizedProtocol.self) + defer { + URLProtocol.unregisterClass(HubUnauthorizedProtocol.self) + storage.hubURL.value = saved.0 + deviceKey.store = saved.1 + deviceKey.reload() + storage.backgroundRefreshType.value = saved.2 + } + try await MainActor.run { + storage.hubURL.value = "https://\(HubUnauthorizedProtocol.host)" + try deviceKey.set("lfh_test") + storage.backgroundRefreshType.value = .hub + } + + await HubClient.shared.sync() + + #expect(deviceKey.value.isEmpty) + #expect(storage.backgroundRefreshType.value == .silentTune) + #expect(!HubClient.shared.hubConfirmed.value) + } +} + +/// Answers every request to `host` with 503, as a hub that is down. +final class HubUnavailableProtocol: URLProtocol { + static let host = "down.lfhub-tests.invalid" + + override class func canInit(with request: URLRequest) -> Bool { + request.url?.host == host + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + let response = HTTPURLResponse(url: request.url!, statusCode: 503, httpVersion: nil, headerFields: nil)! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data()) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} +} + +/// Leaving the hub changes HubClient.shared's connection, so these run in the same +/// serialized suite as the other tests that do. +extension HubForgottenTests { + @Test("a disconnect the hub does not confirm leaves at once and is retried until the hub removes the device") + func unconfirmedDisconnect() async throws { + let storage = Storage.shared + let client = HubClient.shared + let saved = (storage.hubURL.value, client.deviceKey.store, client.pendingUnregistration.store, + storage.backgroundRefreshType.value) + client.deviceKey.store = MemoryKeyStore() + client.pendingUnregistration.store = MemoryKeyStore() + URLProtocol.registerClass(HubUnavailableProtocol.self) + URLProtocol.registerClass(HubUnauthorizedProtocol.self) + defer { + URLProtocol.unregisterClass(HubUnavailableProtocol.self) + URLProtocol.unregisterClass(HubUnauthorizedProtocol.self) + client.pendingUnregistration.clear() + client.pendingUnregistration.store = saved.2 + storage.hubURL.value = saved.0 + client.deviceKey.store = saved.1 + client.deviceKey.reload() + storage.backgroundRefreshType.value = saved.3 + } + try await MainActor.run { + storage.hubURL.value = "https://\(HubUnavailableProtocol.host)" + try client.deviceKey.set("lfh_left") + storage.backgroundRefreshType.value = .silentTune + } + + await client.disconnect() + #expect(!client.isConnected) + #expect(client.deviceKey.value.isEmpty) + #expect(client.pendingUnregistration.current?.key == "lfh_left") + #expect(client.pendingUnregistration.current?.hubURL == "https://\(HubUnavailableProtocol.host)") + + await client.unregisterPending() + #expect(client.pendingUnregistration.current?.key == "lfh_left") + + // The hub no longer knows the key: the device is gone. + client.pendingUnregistration.keep(key: "lfh_left", hubURL: "https://\(HubUnauthorizedProtocol.host)") + await client.unregisterPending() + #expect(client.pendingUnregistration.current == nil) + } + + @Test("a phone that joined with an invite does not send its own data sources, whatever its role") + func invitedPhoneSendsNoSources() async { + let client = HubClient.shared + let saved = (client.joinedWithInvite.value, client.role.value) + defer { + client.joinedWithInvite.value = saved.0 + client.role.value = saved.1 + } + client.joinedWithInvite.value = true + client.role.value = .admin + await #expect(throws: HubClient.HubError.self) { + try await client.sendSources(LocalSources(nightscoutURL: "https://ns.example.com", token: "t")) + } + } +} + +struct HubPendingUnregistrationTests { + @Test("a pending removal is kept with the hub's address and cleared") + func keepAndClear() { + let url = StorageValue(key: "hubPendingUnregistrationURLTest", defaultValue: "") + defer { url.remove() } + let pending = HubPendingUnregistration(store: MemoryKeyStore(), hubURL: url) + #expect(pending.current == nil) + + #expect(pending.keep(key: "lfh_old", hubURL: "https://hub.example.com")) + #expect(pending.current?.key == "lfh_old") + #expect(pending.current?.hubURL == "https://hub.example.com") + + pending.clear() + #expect(pending.current == nil) + } + + @Test("a key the store does not take is not kept") + func refused() { + let url = StorageValue(key: "hubPendingUnregistrationURLTest", defaultValue: "") + defer { url.remove() } + let store = MemoryKeyStore() + store.accepts = false + let pending = HubPendingUnregistration(store: store, hubURL: url) + #expect(!pending.keep(key: "lfh_old", hubURL: "https://hub.example.com")) + #expect(pending.current == nil) + } + + @Test("the removal is done once the hub removes the device or no longer knows its key") + func done() { + #expect(HubPendingUnregistration.isDone(200)) + #expect(HubPendingUnregistration.isDone(204)) + #expect(HubPendingUnregistration.isDone(401)) + #expect(!HubPendingUnregistration.isDone(0)) + #expect(!HubPendingUnregistration.isDone(500)) + #expect(!HubPendingUnregistration.isDone(503)) + #expect(!HubPendingUnregistration.isDone(429)) + } +} + +struct HubNotificationServiceTests { + @Test("a hub push to a phone that is not connected is delivered quietly") + func notConnected() { + #expect(HubAppGroup.delivery(hubURL: nil, systemAlarm: true) == .quiet) + #expect(HubAppGroup.delivery(hubURL: "", systemAlarm: true) == .quiet) + #expect(HubAppGroup.delivery(hubURL: nil, systemAlarm: false) == .quiet) + } + + @Test("a connected phone rings the system alarm the hub asks for") + func connected() { + #expect(HubAppGroup.delivery(hubURL: "https://hub.example.com", systemAlarm: true) == .systemAlarm) + #expect(HubAppGroup.delivery(hubURL: "https://hub.example.com", systemAlarm: false) == .notification) + } +} + +struct HubNotificationPermissionTests { + @Test("only notifications that are allowed and show alerts let the hub deliver the alarms") + func allows() { + #expect(HubClient.notificationsAllow(.authorized, alertSetting: .enabled)) + #expect(!HubClient.notificationsAllow(.authorized, alertSetting: .disabled)) + #expect(!HubClient.notificationsAllow(.provisional, alertSetting: .enabled)) + #expect(!HubClient.notificationsAllow(.denied, alertSetting: .disabled)) + #expect(!HubClient.notificationsAllow(.notDetermined, alertSetting: .notSupported)) + } +} + +struct HubAlarmTypeTests { + @Test("the hub's API 1 checks every alarm type this app has") + func parity() { + #expect(HubClient.hubAlarmTypes[1] == Set(AlarmType.allCases)) + for type in AlarmType.allCases { + #expect(HubClient.hubChecks(type, api: 1)) + #expect(HubClient.hubChecks(type, api: 3)) + } + } + + @Test("without a known hub API the app checks every alarm itself") + func unknownHub() { + #expect(!HubClient.hubChecks(.low, api: nil)) + #expect(!HubClient.hubChecks(.low, api: 0)) + let alarms = [Alarm(type: .low), Alarm(type: .dbSize)] + #expect(HubClient.alarmsCheckedHere(alarms, hubAPI: nil).map(\.id) == alarms.map(\.id)) + #expect(HubClient.alarmsCheckedHere(alarms, hubAPI: 1).isEmpty) + } +} + +struct HubInvitedRoleTests { + @Test("joining with an invite makes a member, the setup token an admin") + func joinRole() { + #expect(HubClient.joinRole(withInvite: true) == .member) + #expect(HubClient.joinRole(withInvite: false) == .admin) + } + + @Test("a hub that reports no role leaves an invited phone a member; a reported role counts") + func reportedRole() { + #expect(HubClient.role(reported: nil, joinedWithInvite: true) == .member) + #expect(HubClient.role(reported: nil, joinedWithInvite: false) == .admin) + #expect(HubClient.role(reported: .admin, joinedWithInvite: true) == .admin) + #expect(HubClient.role(reported: .member, joinedWithInvite: false) == .member) + } + + @Test("a phone that joined with an invite never sends its own APN key") + func noOwnKey() { + #expect(HubClient.apnsKeyUse(keyId: "ABCDE12345", key: "p8", sentFingerprint: "", hubSharesKeys: true, + ownKeyAllowed: false) == .shared) + #expect(HubClient.apnsKeyUse(keyId: "ABCDE12345", key: "p8", sentFingerprint: "", hubSharesKeys: false, + ownKeyAllowed: false) == .none) + } +} + +/// A key store in memory; `accepts` false makes it refuse every save. +final class MemoryKeyStore: HubKeyStore { + var key: String? + var accepts = true + + func load() -> String? { key } + + func save(_ key: String) -> Bool { + guard accepts else { return false } + self.key = key + return true + } + + func delete() { key = nil } +} + +@Suite(.serialized) +struct HubDeviceKeyTests { + private func legacy(_ value: String) -> StorageValue { + let stored = StorageValue(key: "hubDeviceKeyTest", defaultValue: "") + stored.value = value + return stored + } + + @Test("a key in the app's defaults moves to the store once") + func migration() { + let store = MemoryKeyStore() + let old = legacy("lfh_old") + defer { old.remove() } + + let key = HubDeviceKey(store: store, legacy: old) + + #expect(key.value == "lfh_old") + #expect(store.key == "lfh_old") + #expect(old.value.isEmpty) + #expect(!old.exists) + } + + @Test("a key the store does not take stays in the app's defaults and in use") + func migrationRefused() { + let store = MemoryKeyStore() + store.accepts = false + let old = legacy("lfh_old") + defer { old.remove() } + + let key = HubDeviceKey(store: store, legacy: old) + + #expect(key.value == "lfh_old") + #expect(old.value == "lfh_old") + + store.accepts = true + key.reload() + #expect(store.key == "lfh_old") + #expect(!old.exists) + } + + @Test("a stored key is read, replaced and cleared") + func storedKey() throws { + let store = MemoryKeyStore() + store.key = "lfh_kept" + let old = legacy("") + defer { old.remove() } + + let key = HubDeviceKey(store: store, legacy: old) + #expect(key.value == "lfh_kept") + + try key.set("lfh_new") + #expect(key.value == "lfh_new") + #expect(store.key == "lfh_new") + + store.accepts = false + #expect(throws: HubClient.HubError.self) { try key.set("lfh_refused") } + #expect(key.value == "lfh_new") + + key.clear() + #expect(key.value.isEmpty) + #expect(store.key == nil) + } +} + +struct HubJoinTests { + private func decode(_: T.Type, _ json: String) throws -> T { + try HubClient.decoder.decode(T.self, from: Data(json.utf8)) + } + + @Test("the devices offered when joining decode without the push fields") + func joinableDevices() throws { + let list = try decode(HubClient.DeviceList.self, """ + {"devices":[{"id":"6f1c2a4e-0b7d-4c1e-9a55-2f3e4d5c6b7a","name":"Old iPhone", + "createdAt":"2026-01-01T10:00:00.123Z","lastSeenAt":null,"appVersion":null}]} + """) + #expect(list.devices.count == 1) + #expect(list.devices[0].id == UUID(uuidString: "6F1C2A4E-0B7D-4C1E-9A55-2F3E4D5C6B7A")) + #expect(list.devices[0].lastSeenAt == nil) + #expect(list.devices[0].current == nil) + #expect(try decode(HubClient.DeviceList.self, #"{"devices":[]}"#).devices.isEmpty) + } + + @Test("the patient's devices decode with push state and the current marker") + func devices() throws { + let list = try decode(HubClient.DeviceList.self, """ + {"devices":[{"id":"6f1c2a4e-0b7d-4c1e-9a55-2f3e4d5c6b7a","name":"iPhone","createdAt":"2026-01-01T10:00:00Z", + "lastSeenAt":"2026-01-02T10:00:00Z","appVersion":"4.1.0","lastPushOkAt":"2026-01-02T09:59:00Z", + "pushFailingSince":"2026-01-02T10:00:00Z","current":true}]} + """) + let device = try #require(list.devices.first) + #expect(device.current == true) + #expect(device.appVersion == "4.1.0") + #expect(device.pushFailingSince != nil) + #expect(device.lastSeenAt != nil) + } + + @Test("a claim answer carries the device key") + func claimResponse() throws { + let claim = try decode(HubClient.ClaimResponse.self, """ + {"deviceId":"6f1c2a4e-0b7d-4c1e-9a55-2f3e4d5c6b7a","deviceKey":"lfh_x","patientId":"7f1c2a4e-0b7d-4c1e-9a55-2f3e4d5c6b7a"} + """) + #expect(claim.deviceKey == "lfh_x") + } + + @Test("the hub's sources decode, and a hub without any has none") + func sources() throws { + let both = try decode(HubClient.Sources.self, """ + {"nightscoutUrl":"https://ns.example.com","token":"t","dexcom":{"username":"u","password":"p","server":"NON-US"}} + """) + #expect(!both.isEmpty) + #expect(both.dexcom?.server == "NON-US") + #expect(try decode(HubClient.Sources.self, #"{"nightscoutUrl":null,"token":null,"dexcom":null}"#).isEmpty) + #expect(try !decode(HubClient.Sources.self, #"{"nightscoutUrl":null,"token":null,"dexcom":{"username":"u","password":"p","server":"US"}}"#).isEmpty) + } + + @Test("every client setting is a stored value that takes its own JSON back") + func clientSettingsRoundTrip() throws { + let settings = HubClient.currentClientSettings() + for setting in HubClient.clientSettings { + guard let value = settings[setting.key] else { continue } + let json = try JSONSerialization.data(withJSONObject: value, options: .fragmentsAllowed) + #expect(setting.assign(json: json), "\(setting.key)") + } + #expect(!HubClient.clientSettings[0].assign(json: Data("{\"not\":\"a unit\"}".utf8))) + } + + @Test("only a local test hub may use plain http") + func hubBase() throws { + #expect(try HubClient.hubBase(" https://hub.example.com/ ") == "https://hub.example.com") + #expect(try HubClient.hubBase("http://localhost:8080") == "http://localhost:8080") + #expect(throws: HubClient.HubError.self) { try HubClient.hubBase("http://hub.example.com") } + } +} + +struct HubTelemetryTests { + @Test("hub fields are sent only while connected, and never the address") + func hubFields() { + #expect(TelemetryClient.hubFields(connected: false, drivesAlarms: false, hub: nil).isEmpty) + + let hub = HubClient.Status.Hub(version: "0.2.0", api: 1, features: ["receipts", "devices"]) + let fields = TelemetryClient.hubFields(connected: true, drivesAlarms: true, hub: hub) + #expect(fields["hubConfirmed"] as? Bool == true) + #expect(fields["hubVersion"] as? String == "0.2.0") + #expect(fields["hubApi"] as? Int == 1) + #expect(Set(fields.keys) == ["hubConfirmed", "hubVersion", "hubApi"]) + + let unanswered = TelemetryClient.hubFields(connected: true, drivesAlarms: false, hub: nil) + #expect(Set(unanswered.keys) == ["hubConfirmed"]) + } +} + +struct BGAnnouncementTests { + private let swedish = Locale(identifier: "sv_SE") + + @Test("a small change is spoken as stable") + func stable() { + #expect(BGAnnouncement.text(current: 153, previous: 151, language: "sv", mmol: true, locale: swedish) + == "Blodsockret är 8.5 och det är stabilt") + } + + @Test("a rise and a fall are spoken with their size in the display unit") + func change() { + #expect(BGAnnouncement.text(current: 160, previous: 150, language: "en", mmol: false, locale: swedish) + == "Glucose is 160 and it is up 10") + #expect(BGAnnouncement.text(current: 140, previous: 158, language: "sv", mmol: true, locale: swedish) + == "Blodsockret är 7.8 och det har minskat med 1.0") + } + + @Test("an unknown language speaks English with the US voice") + func fallback() { + #expect(BGAnnouncement.text(current: 100, previous: 100, language: "xx", mmol: false, locale: swedish) + == "Glucose is 100 and it is stable") + #expect(BGAnnouncement.voiceLanguageCode(forAppLanguage: "xx") == "en-US") + } +} + +struct HubRoleTests { + private func status(role: String?, apnsKeyShared: Bool? = nil, sourceRevision: Int? = nil) throws -> HubClient.Status { + var device: [String: Any] = [ + "name": "Phone", "apnsConfigured": true, + "liveActivity": ["registered": false, "pushToStartRegistered": false, "renewalFailed": false], + ] + if let role { device["role"] = role } + if let apnsKeyShared { device["apnsKeyShared"] = apnsKeyShared } + var patient: [String: Any] = ["name": "Patient"] + if let sourceRevision { patient["sourceRevision"] = sourceRevision } + let json: [String: Any] = ["device": device, "patient": patient, + "live": ["connected": true, "staleAfterSeconds": 600]] + return try HubClient.decoder.decode(HubClient.Status.self, from: JSONSerialization.data(withJSONObject: json)) + } + + @Test("roles decode, an unknown role counts as member, and a hub without roles sends none") + func roles() throws { + #expect(try status(role: "admin").device.role == .admin) + #expect(try status(role: "member").device.role == .member) + #expect(try status(role: "owner").device.role == .member) + #expect(try status(role: nil).device.role == nil) + } + + @Test("the device list, the join answer and the status carry the new fields") + func fields() throws { + let list = try HubClient.decoder.decode(HubClient.DeviceList.self, from: Data(""" + {"devices":[{"id":"6f1c2a4e-0b7d-4c1e-9a55-2f3e4d5c6b7a","name":"Mum","role":"admin", + "createdAt":"2026-01-01T10:00:00Z","lastSeenAt":null,"appVersion":null}]} + """.utf8)) + #expect(list.devices.first?.role == .admin) + + let join = try JSONDecoder().decode(HubClient.ClaimResponse.self, from: Data(""" + {"deviceId":"6f1c2a4e-0b7d-4c1e-9a55-2f3e4d5c6b7a","deviceKey":"lfh_x", + "patientId":"7f1c2a4e-0b7d-4c1e-9a55-2f3e4d5c6b7a","role":"member"} + """.utf8)) + #expect(join.role == .member) + + let shared = try status(role: "member", apnsKeyShared: true, sourceRevision: 4) + #expect(shared.device.apnsKeyShared == true) + #expect(shared.patient.sourceRevision == 4) + #expect(try status(role: nil).patient.sourceRevision == nil) + } +} + +struct HubInviteTests { + private let code = "AbCdEfGhIjKlMnOpQrStUvWxYz0" + + @Test("an invite link names the hub and the code") + func parse() throws { + let invite = try #require(HubClient.Invite(link: " https://hub.example.com/join#\(code)\n")) + #expect(invite.hubURL == "https://hub.example.com") + #expect(invite.code == code) + #expect(invite.link == "https://hub.example.com/join#\(code)") + #expect(HubClient.Invite(link: invite.link) == invite) + } + + @Test("a hub under a path, with a port or a trailing slash is read too") + func variants() { + #expect(HubClient.Invite(link: "https://example.com/lfhub/join#\(code)")?.hubURL == "https://example.com/lfhub") + #expect(HubClient.Invite(link: "https://hub.example.com:8443/join/#\(code)")?.hubURL == "https://hub.example.com:8443") + #expect(HubClient.Invite(link: "https://hub.example.com/join?utm=x#\(code)")?.hubURL == "https://hub.example.com") + #expect(HubClient.Invite(link: "http://localhost:8080/join#\(code)")?.hubURL == "http://localhost:8080") + } + + @Test("anything else is not an invite") + func rejected() { + for link in [ + "http://hub.example.com/join#\(code)", + "ftp://hub.example.com/join#\(code)", + "https://user:pass@hub.example.com/join#\(code)", + "https://hub.example.com/join", + "https://hub.example.com/join#", + "https://hub.example.com/joinx#\(code)", + "https://hub.example.com/#\(code)", + "https://hub.example.com/join#short", + "https://hub.example.com/join#\(code)%20", + "https://hub.example.com/join#\(code)!", + "hub.example.com/join#\(code)", + "not a link", + ] { + #expect(HubClient.Invite(link: link) == nil, "\(link)") + } + } +} + +struct HubSourceRevisionTests { + @Test("the hub's sources are taken when the hub counts revisions and this one is not applied") + func needsSources() { + #expect(!HubSourceSync.needsSources(hubRevision: nil, applied: -1)) + #expect(!HubSourceSync.needsSources(hubRevision: 3, applied: 3)) + #expect(HubSourceSync.needsSources(hubRevision: 4, applied: 3)) + #expect(HubSourceSync.needsSources(hubRevision: 0, applied: -1)) + #expect(HubSourceSync.needsSources(hubRevision: 2, applied: 3)) + } + + @Test("only the revision right after this app's own change counts as that change") + func ownChange() { + #expect(HubSourceSync.isOwnChange(revisionBefore: 3, revisionAfter: 4)) + #expect(!HubSourceSync.isOwnChange(revisionBefore: 3, revisionAfter: 5)) + #expect(!HubSourceSync.isOwnChange(revisionBefore: 3, revisionAfter: 3)) + #expect(!HubSourceSync.isOwnChange(revisionBefore: 3, revisionAfter: nil)) + #expect(!HubSourceSync.isOwnChange(revisionBefore: -1, revisionAfter: 0)) + } + + private func sources(_ json: String) throws -> HubClient.Sources { + try JSONDecoder().decode(HubClient.Sources.self, from: Data(json.utf8)) + } + + @Test("a source the hub does not have leaves no old credentials behind") + func noStaleCredentials() throws { + let dexcomOnly = try LocalSources(sources( + #"{"nightscoutUrl":null,"token":"old","dexcom":{"username":"u","password":"p","server":"NON-US"}}"# + ), server: "US") + #expect(dexcomOnly == LocalSources(dexcomUser: "u", dexcomPassword: "p", dexcomServer: "NON-US")) + + let nightscoutOnly = try LocalSources(sources( + #"{"nightscoutUrl":"https://ns.example.com","token":"t","dexcom":null}"# + ), server: "US") + #expect(nightscoutOnly == LocalSources(nightscoutURL: "https://ns.example.com", token: "t", dexcomServer: "US")) + #expect(!nightscoutOnly.hasDexcom) + } + + @Test("the digest covers what the hub holds") + func digest() { + let base = LocalSources(nightscoutURL: "https://ns.example.com", token: "t", + dexcomUser: "u", dexcomPassword: "p", dexcomServer: "US") + var changed = base + changed.dexcomPassword = "q" + #expect(base.digest != changed.digest) + changed = base + changed.token = "u" + #expect(base.digest != changed.digest) + + // A token without an address and a server without a login are not at the hub. + #expect(LocalSources(token: "t").digest == LocalSources().digest) + #expect(LocalSources(dexcomServer: "NON-US").digest == LocalSources(dexcomServer: "US").digest) + } +} + +struct HubSharedApnKeyTests { + @Test("an own key is sent only when it changed; without one a hub that shares keys is asked for an admin's") + func keyUse() { + let fingerprint = HubClient.keyFingerprint(keyId: "ABCDE12345", key: "p8") + #expect(HubClient.apnsKeyUse(keyId: "ABCDE12345", key: "p8", sentFingerprint: "", hubSharesKeys: true) + == .own(keyId: "ABCDE12345", sendKey: true)) + #expect(HubClient.apnsKeyUse(keyId: "ABCDE12345", key: "p8", sentFingerprint: fingerprint, hubSharesKeys: false) + == .own(keyId: "ABCDE12345", sendKey: false)) + #expect(HubClient.apnsKeyUse(keyId: "", key: "", sentFingerprint: "", hubSharesKeys: true) == .shared) + #expect(HubClient.apnsKeyUse(keyId: "ABCDE12345", key: "", sentFingerprint: "", hubSharesKeys: true) == .shared) + #expect(HubClient.apnsKeyUse(keyId: "", key: "", sentFingerprint: "", hubSharesKeys: false) == .none) + } + + private func status(apnsKeyShared: Bool, apnsConfigured: Bool = true) throws -> HubClient.Status { + let json: [String: Any] = [ + "hub": ["version": "1.0.0", "api": 1, "features": ["receipts", "sharedApnsKey"]], + "device": [ + "name": "Phone", "role": "member", "apnsConfigured": apnsConfigured, "apnsKeyShared": apnsKeyShared, + "lastPushOkAt": "2026-01-01T11:59:00Z", + "liveActivity": ["registered": true, "pushToStartRegistered": true, "renewalFailed": false], + ], + "patient": ["name": "Patient", "nightscoutHost": "ns.example.com", "followed": true, "hasData": true], + "live": ["connected": true, "staleAfterSeconds": 600], + ] + return try HubClient.decoder.decode(HubClient.Status.self, from: JSONSerialization.data(withJSONObject: json)) + } + + private let now = ISO8601DateFormatter().date(from: "2026-01-01T12:00:00Z")! + + private func item(_ id: HubChecklist.Item.ID, _ status: HubClient.Status, _ facts: HubChecklist.Facts) + -> HubChecklist.Item? + { + HubChecklist.items(status: status, error: nil, facts: facts, now: now).first { $0.id == id } + } + + @Test("a key an admin shares counts as this phone's APN key") + func sharedKeyIsReady() throws { + var facts = HubChecklist.Facts() + facts.ownApnKey = false + facts.isAdmin = false + facts.hubSharesKeys = true + let apnKey = try #require(try item(.apnKey, status(apnsKeyShared: true), facts)) + #expect(apnKey.isReady) + #expect(apnKey.title == "APN key shared by an admin") + #expect(try item(.push, status(apnsKeyShared: true), facts)?.isReady == true) + } + + @Test("without a key of its own or a shared one, the phone is sent to the APN settings") + func missingKey() throws { + var facts = HubChecklist.Facts() + facts.ownApnKey = false + facts.isAdmin = false + facts.hubSharesKeys = true + let apnKey = try #require(try item(.apnKey, status(apnsKeyShared: false, apnsConfigured: false), facts)) + #expect(!apnKey.isReady) + #expect(apnKey.fix == .goToAPN) + #expect(try item(.push, status(apnsKeyShared: false, apnsConfigured: false), facts) == nil) + } +} + +struct HubChecklistTests { + private func status(nightscoutHost: String? = "ns.example.com", followed: Bool = true, + hasData: Bool = true) throws -> HubClient.Status + { + var patient: [String: Any] = ["name": "Patient", "followed": followed, "hasData": hasData] + if let nightscoutHost { patient["nightscoutHost"] = nightscoutHost } + let json: [String: Any] = [ + "hub": ["version": "1.0.0", "api": 1, "features": ["receipts"]], + "device": [ + "name": "Phone", "apnsConfigured": true, "lastPushOkAt": "2026-01-01T11:59:00Z", + "liveActivity": ["registered": true, "pushToStartRegistered": true, "renewalFailed": false], + ], + "patient": patient, + "live": ["connected": true, "staleAfterSeconds": 600], + ] + return try HubClient.decoder.decode(HubClient.Status.self, from: JSONSerialization.data(withJSONObject: json)) + } + + private let now = ISO8601DateFormatter().date(from: "2026-01-01T12:00:00Z")! + + private func items(_ status: HubClient.Status?, error: String? = nil, + _ change: (inout HubChecklist.Facts) -> Void = { _ in }) -> [HubChecklist.Item] + { + var facts = HubChecklist.Facts() + facts.notifications = .allowed + change(&facts) + return HubChecklist.items(status: status, error: error, facts: facts, now: now) + } + + @Test("everything in place is all ready") + func ready() throws { + let all = try items(status()) + #expect(all.allSatisfy { $0.isReady }) + #expect(all.map(\.id) == [.hub, .mode, .notifications, .alarmKit, .liveActivity, .apnKey, .push, .source, .confirmed]) + } + + @Test("a hub that does not answer shows only that, with Try Again; none before the first answer") + func noAnswer() { + #expect(items(nil, error: "offline").map(\.fix) == [.retry]) + #expect(items(nil).isEmpty) + } + + @Test("each missing permission comes with its fix") + func permissions() throws { + let missing = try items(status()) { + $0.notifications = .denied + $0.alarmKit = .notAsked + $0.liveActivityOn = false + $0.hubMode = true + } + #expect(missing.first { $0.id == .notifications }?.fix == .openNotificationSettings) + #expect(missing.first { $0.id == .alarmKit }?.fix == .askAlarmKit) + #expect(missing.first { $0.id == .liveActivity }?.fix == .turnOnLiveActivity) + #expect(try items(status()) { $0.alarmKit = .denied }.first { $0.id == .alarmKit }?.fix == .openSettings) + } + + @Test("a data source problem is the admin's to fix; a member asks an admin") + func dataSource() throws { + #expect(try items(status(nightscoutHost: nil)).first { $0.id == .source }?.fix == .dataSource(dexcom: false)) + #expect(try items(status(followed: false)) { $0.isAdmin = false }.first { $0.id == .source }?.fix == .askAdmin) + #expect(try items(status()) { $0.sourcesDiffer = true }.first { $0.id == .source }?.fix == .sendSources) + #expect(try items(status()) { $0.sourcesDiffer = true; $0.isAdmin = false }.first { $0.id == .source }?.isReady == true) + } + + @Test("outside Hub mode the hub's own items are left out and Hub mode is offered") + func notHubMode() throws { + let list = try items(status()) { $0.hubMode = false } + #expect(list.first { $0.id == .mode }?.fix == .useHubMode) + #expect(!list.contains { $0.id == .liveActivity || $0.id == .confirmed }) + } + + @Test("notifications that are off show on their own item, not again on the confirmation") + func notificationsOff() throws { + let list = try items(status()) { + $0.notifications = .denied + $0.drivesAlarms = false + $0.hubProblems = [HubClient.Problem.notificationsOff] + } + #expect(list.first { $0.id == .notifications }?.problem == HubClient.Problem.notificationsOff) + #expect(list.first { $0.id == .confirmed }?.problem != HubClient.Problem.notificationsOff) + #expect(list.first { $0.id == .confirmed }?.isReady == false) + } + + @Test("a phone that joined with an invite asks an admin for an APN key") + func invitedPhoneApnKey() throws { + let item = try items(status()) { + $0.ownApnKey = false + $0.ownKeyAllowed = false + $0.isAdmin = false + $0.hubSharesKeys = true + }.first { $0.id == .apnKey } + #expect(item?.fix == .askAdmin) + } + + @Test("the hub's reasons not covered by another item show on the confirmation") + func confirmation() throws { + let list = try items(status()) { + $0.drivesAlarms = false + $0.hubProblems = [HubClient.Problem.alarmsOff, HubClient.Problem.noPushYet] + } + #expect(list.first { $0.id == .confirmed }?.problem == HubClient.Problem.alarmsOff) + } +} + +struct HubRemoteSecretTests { + /// Every stored value of Remote: the commands' secrets, the remote APN key and the + /// AID app's push identity. + private var remoteValues: [String] { + let storage = Storage.shared + return [storage.remoteType.key, storage.deviceToken.key, storage.expirationDate.key, storage.sharedSecret.key, + storage.productionEnvironment.key, storage.remoteApnsKey.key, storage.teamId.key, + storage.remoteKeyId.key, storage.bundleId.key, storage.user.key, storage.loopAPNSQrCodeURL.key, + storage.maxBolus.key, storage.maxCarbs.key, storage.maxProtein.key, storage.maxFat.key] + } + + @Test("no Remote setting is a client setting, so none is sent to the hub or taken from it") + func clientSettings() { + #expect(Set(HubClient.clientSettingKeys).isDisjoint(with: remoteValues)) + #expect(Set(HubClient.currentClientSettings().keys).isDisjoint(with: remoteValues)) + } + + @Test("client settings from the hub cannot set a Remote value") + @MainActor func adoption() { + let storage = Storage.shared + let saved = storage.sharedSecret.value + HubClient.applyClientSettings(Dictionary(uniqueKeysWithValues: remoteValues.map { ($0, "from the hub") })) + #expect(storage.sharedSecret.value == saved) + } +} diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 8f46645c5..15e890b09 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -43,6 +43,35 @@ end APP_SUFFIX = loopfollow_app_suffix APP_IDENTIFIER = "com.#{TEAMID}.LoopFollow#{APP_SUFFIX}" LA_IDENTIFIER = "#{APP_IDENTIFIER}.LoopFollowLAExtension" +NOTIFICATION_SERVICE_IDENTIFIER = "#{APP_IDENTIFIER}.LoopFollowNotificationService" + +# The notification service extension, which rings LF Hub alarms, needs its own +# identifier with the LoopFollow App Group, which existing browser builders add once +# by hand, and a profile from the Create Certificates action. +def notification_service_setup_error! + UI.user_error!( + "The LoopFollow Notification Service identifier is not set up yet. Run the Add Identifiers action, " \ + "add the LoopFollow App Group to the LoopFollow Notification Service identifier, " \ + "run the Create Certificates action, then build again." + ) +end + +# Stops before match, whose read-only failure does not name the missing profile. +def ensure_notification_service_identifier_exists + notification_service_setup_error! unless Spaceship::ConnectAPI::BundleId.find(NOTIFICATION_SERVICE_IDENTIFIER) +end + +# Turns a signing failure that names the notification service extension into the steps that fix it. +def explain_signing_failure(error, log_dir = nil) + text = error.message.to_s + if log_dir + roots = [log_dir, File.join("..", log_dir), File.join(GITHUB_WORKSPACE.to_s, log_dir)].uniq + roots.flat_map { |root| Dir.glob(File.join(root, "**", "*.log")) }.uniq.each do |log| + text += File.read(log) rescue nil + end + end + notification_service_setup_error! if [NOTIFICATION_SERVICE_IDENTIFIER, "LoopFollowNotificationService"].any? { |id| text.include?(id) } +end platform :ios do desc "Build Loop Follow" @@ -72,14 +101,22 @@ platform :ios do build_number: current_build_number ) - match( - type: "appstore", - git_basic_authorization: Base64.strict_encode64("#{GITHUB_REPOSITORY_OWNER}:#{GH_PAT}"), - app_identifier: [ - APP_IDENTIFIER, - LA_IDENTIFIER - ] - ) + ensure_notification_service_identifier_exists + + begin + match( + type: "appstore", + git_basic_authorization: Base64.strict_encode64("#{GITHUB_REPOSITORY_OWNER}:#{GH_PAT}"), + app_identifier: [ + APP_IDENTIFIER, + LA_IDENTIFIER, + NOTIFICATION_SERVICE_IDENTIFIER + ] + ) + rescue => e + explain_signing_failure(e) + raise + end mapping = Actions.lane_context[ SharedValues::MATCH_PROVISIONING_PROFILE_MAPPING @@ -99,21 +136,34 @@ platform :ios do targets: ["LoopFollowLAExtensionExtension"] ) - gym( - export_method: "app-store", - scheme: "LoopFollow", - output_name: "LoopFollow.ipa", - configuration: "Release", - destination: 'generic/platform=iOS', - buildlog_path: 'buildlog', - export_options: { - provisioningProfiles: { - APP_IDENTIFIER => mapping[APP_IDENTIFIER], - LA_IDENTIFIER => mapping[LA_IDENTIFIER] - } - } + update_code_signing_settings( + path: "#{GITHUB_WORKSPACE}/LoopFollow.xcodeproj", + profile_name: mapping[NOTIFICATION_SERVICE_IDENTIFIER], + code_sign_identity: "iPhone Distribution", + targets: ["LoopFollowNotificationService"] ) + begin + gym( + export_method: "app-store", + scheme: "LoopFollow", + output_name: "LoopFollow.ipa", + configuration: "Release", + destination: 'generic/platform=iOS', + buildlog_path: 'buildlog', + export_options: { + provisioningProfiles: { + APP_IDENTIFIER => mapping[APP_IDENTIFIER], + LA_IDENTIFIER => mapping[LA_IDENTIFIER], + NOTIFICATION_SERVICE_IDENTIFIER => mapping[NOTIFICATION_SERVICE_IDENTIFIER] + } + } + ) + rescue => e + explain_signing_failure(e, "buildlog") + raise + end + copy_artifacts( target_path: "artifacts", artifacts: ["*.mobileprovision", "*.ipa", "*.dSYM.zip"] @@ -168,6 +218,10 @@ platform :ios do Spaceship::ConnectAPI::BundleIdCapability::Type::APP_GROUPS ]) + configure_bundle_id("LoopFollow Notification Service", NOTIFICATION_SERVICE_IDENTIFIER, [ + Spaceship::ConnectAPI::BundleIdCapability::Type::APP_GROUPS + ]) + end desc "Provision Certificates" @@ -188,7 +242,8 @@ platform :ios do git_basic_authorization: Base64.strict_encode64("#{GITHUB_REPOSITORY_OWNER}:#{GH_PAT}"), app_identifier: [ APP_IDENTIFIER, - LA_IDENTIFIER + LA_IDENTIFIER, + NOTIFICATION_SERVICE_IDENTIFIER ] ) end From 9409a55384beb28e82da4b42aa1bf6af0ca4510a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 25 Oct 2026 13:02:32 +0100 Subject: [PATCH 2/9] Offer LF Hub as the Background Refresh mode right after connecting to a hub --- LoopFollow/Hub/HubSettingsView.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/LoopFollow/Hub/HubSettingsView.swift b/LoopFollow/Hub/HubSettingsView.swift index d83ab7de5..289186349 100644 --- a/LoopFollow/Hub/HubSettingsView.swift +++ b/LoopFollow/Hub/HubSettingsView.swift @@ -41,6 +41,8 @@ struct HubSettingsView: View { @State private var shownInvite: ShownInvite? @State private var notifications: HubChecklist.Permission? @State private var confirmingDisconnect = false + /// Just connected while another Background Refresh mode is chosen. + @State private var offeringHubMode = false @State private var busy = false @State private var errorText: String? @State private var infoText: String? @@ -148,6 +150,14 @@ struct HubSettingsView: View { Text(action == .take ? "This app's Nightscout and Dexcom settings are replaced." : "The hub's Nightscout and Dexcom settings are replaced for every phone.") } + .confirmationDialog("Use LF Hub for Background Refresh?", isPresented: $offeringHubMode, + titleVisibility: .visible) + { + Button("Use LF Hub") { BackgroundRefreshSettingsViewModel.change(to: .hub) } + Button("Not Now", role: .cancel) {} + } message: { + Text("The hub keeps the badge, Live Activity and alarms up to date while LoopFollow is closed. You can change this under Background Refresh.") + } .confirmationDialog("Disconnect from LF Hub?", isPresented: $confirmingDisconnect, titleVisibility: .visible) { Button("Disconnect", role: .destructive) { Task { await disconnect() } } } message: { @@ -558,6 +568,7 @@ struct HubSettingsView: View { } try await load() } + if connected, backgroundRefreshType.value != .hub { offeringHubMode = true } } private func sendSources() async { From 161ef4ac45779baa82a34e5903ed1e442dd3ee4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Mon, 26 Oct 2026 03:28:43 +0100 Subject: [PATCH 3/9] Make LF Hub failures visible and recoverable: a removed phone leaves at once, rejected pushes and outages show on Home and in the Ready list, failed source changes can be undone, snoozes reach the hub before handled, and errors read plainly --- HubShared/HubAlarmKit.swift | 15 +- HubShared/HubAppGroup.swift | 4 + .../Components/AlarmAudioSection.swift | 13 +- LoopFollow/Alarm/AlarmManager.swift | 2 +- LoopFollow/Alarm/AlarmSettingsView.swift | 2 +- .../BackgroundRefreshSettingsViewModel.swift | 25 +- LoopFollow/Hub/HubBundle.swift | 4 + LoopFollow/Hub/HubChecklist.swift | 40 +- LoopFollow/Hub/HubClient.swift | 338 +++++++++++++-- LoopFollow/Hub/HubManagedSettings.swift | 41 +- LoopFollow/Hub/HubSettingsView.swift | 393 +++++++++++++----- LoopFollow/Hub/HubSourceSync.swift | 73 +++- LoopFollow/Hub/HubWatchdog.swift | 35 +- .../Nightscout/NightscoutSettingsView.swift | 4 +- .../NightscoutSettingsViewModel.swift | 18 +- .../Onboarding/Steps/HubJoinStepView.swift | 45 +- LoopFollow/Settings/DexcomSettingsView.swift | 4 +- .../Settings/DexcomSettingsViewModel.swift | 15 + LoopFollow/Settings/SettingsMenuView.swift | 7 +- LoopFollow/ViewControllers/MainHomeView.swift | 9 + .../ViewControllers/MainViewController.swift | 15 +- .../NotificationService.swift | 20 +- Tests/HubTests.swift | 350 +++++++++++++++- 23 files changed, 1261 insertions(+), 211 deletions(-) diff --git a/HubShared/HubAlarmKit.swift b/HubShared/HubAlarmKit.swift index f7425b8d3..f3d8576d8 100644 --- a/HubShared/HubAlarmKit.swift +++ b/HubShared/HubAlarmKit.swift @@ -41,10 +41,11 @@ import Foundation /// Whether the alarm is ringing or about to, when it starts if this call scheduled it, /// and why. Not ringing when AlarmKit is not authorized or scheduling failed, so the - /// notification keeps its sound. - /// `symbol` is the SF Symbol on the alarm's button: the alarm type's icon. - static func ring(alarmID: UUID, title: String, soundName: String?, - symbol: String = "moon.zzz") async -> (ringing: Bool, firesAt: Date?, detail: String) + /// notification keeps its sound. Without `soundName` it rings with the default sound. + /// `symbol` is the SF Symbol on the alarm's button: the alarm type's icon. The button + /// reads Acknowledge for an alarm whose snooze is 0 (`acknowledge`), as in the app. + static func ring(alarmID: UUID, title: String, soundName: String?, symbol: String = "moon.zzz", + acknowledge: Bool = false) async -> (ringing: Bool, firesAt: Date?, detail: String) { // A denied state is final; notDetermined is what an extension reads even // after the app was authorized, so scheduling is attempted then too. @@ -58,7 +59,7 @@ import Foundation alert: AlarmPresentation.Alert( title: LocalizedStringResource(stringLiteral: title), stopButton: AlarmButton( - text: LocalizedStringResource(stringLiteral: "Snooze"), + text: LocalizedStringResource(stringLiteral: buttonTitle(acknowledge: acknowledge)), textColor: .white, systemImageName: symbol ) @@ -79,6 +80,10 @@ import Foundation } } + static func buttonTitle(acknowledge: Bool) -> String { + acknowledge ? "Acknowledge" : "Snooze" + } + /// Rings a notice from the app itself (not a LoopFollow alarm) as a system /// alarm now, with an OK button and no snooze. Replaces a notice with the same id. static func ringNotice(id: UUID, title: String, symbol: String) async -> Bool { diff --git a/HubShared/HubAppGroup.swift b/HubShared/HubAppGroup.swift index 793812d26..4ee3bac10 100644 --- a/HubShared/HubAppGroup.swift +++ b/HubShared/HubAppGroup.swift @@ -8,6 +8,10 @@ enum HubAppGroup { /// The hub's address while the app is connected to a hub; absent otherwise. static let hubURLKey = "lfhubURL" + /// The notification category of a hub alarm whose snooze is 0: its buttons read + /// Acknowledge, as the app's own action for such an alarm does. + static let acknowledgeCategory = "lfhub-ack" + /// How the extension delivers a push from the hub. enum Delivery: Equatable { /// As a system alarm, when the hub asks for one. diff --git a/LoopFollow/Alarm/AlarmEditing/Components/AlarmAudioSection.swift b/LoopFollow/Alarm/AlarmEditing/Components/AlarmAudioSection.swift index e78de27a2..a6a4420c1 100644 --- a/LoopFollow/Alarm/AlarmEditing/Components/AlarmAudioSection.swift +++ b/LoopFollow/Alarm/AlarmEditing/Components/AlarmAudioSection.swift @@ -9,8 +9,10 @@ struct AlarmAudioSection: View { var hideRepeat: Bool = false @State private var showingTonePicker = false + @ObservedObject private var backgroundRefreshType = Storage.shared.backgroundRefreshType + var body: some View { - Section(header: Text("Alert Sound")) { + Section { Button { showingTonePicker = true } label: { @@ -59,7 +61,14 @@ struct AlarmAudioSection: View { } } } - }.onChange(of: alarm.activeOption) { newActive in + } header: { + Text("Alert Sound") + } footer: { + if backgroundRefreshType.value == .hub { + Text("In LF Hub mode, the alarm repeats its sound without a delay between sounds.") + } + } + .onChange(of: alarm.activeOption) { newActive in let playAllowed = PlaySoundOption.allowed(for: newActive) if !playAllowed.contains(alarm.playSoundOption) { alarm.playSoundOption = playAllowed.last! diff --git a/LoopFollow/Alarm/AlarmManager.swift b/LoopFollow/Alarm/AlarmManager.swift index 45c680d7c..a9195a906 100644 --- a/LoopFollow/Alarm/AlarmManager.swift +++ b/LoopFollow/Alarm/AlarmManager.swift @@ -240,7 +240,7 @@ class AlarmManager { UNUserNotificationCenter.current().setNotificationCategories([ UNNotificationCategory(identifier: BackgroundAlertIdentifier.categoryIdentifier, actions: [openApp], intentIdentifiers: [], options: []), UNNotificationCategory(identifier: "category", actions: [snooze], intentIdentifiers: [], options: []), - UNNotificationCategory(identifier: "lfhub-ack", actions: [acknowledge], intentIdentifiers: [], options: []), + UNNotificationCategory(identifier: HubAppGroup.acknowledgeCategory, actions: [acknowledge], intentIdentifiers: [], options: []), ]) } } diff --git a/LoopFollow/Alarm/AlarmSettingsView.swift b/LoopFollow/Alarm/AlarmSettingsView.swift index c45e9536f..eb5e9a2ba 100644 --- a/LoopFollow/Alarm/AlarmSettingsView.swift +++ b/LoopFollow/Alarm/AlarmSettingsView.swift @@ -200,7 +200,7 @@ struct AlarmSettingsView: View { @ViewBuilder private var hubFooter: some View { if HubClient.shared.isHubMode { - Text("In LoopFollow Hub mode, stop or snooze alarms with the alarm's own buttons; the volume buttons may not snooze them.") + Text("In LF Hub mode, alarms ring as system alarms: at the phone's alarm volume and through silent mode. Override System Volume, Volume Level and Audio During Calls do not apply to them. Stop or snooze them with the alarm's own buttons; the volume buttons may not snooze them.") } } } diff --git a/LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsViewModel.swift b/LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsViewModel.swift index ebd986f1e..4103a7d48 100644 --- a/LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsViewModel.swift +++ b/LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsViewModel.swift @@ -38,8 +38,10 @@ class BackgroundRefreshSettingsViewModel: ObservableObject { } /// Switches the Background Refresh mode: the one path for every change, from - /// this screen or from LF Hub. - static func change(to newValue: BackgroundRefreshType) { + /// this screen or from LF Hub. Entering or leaving Hub mode answers the task that + /// brings the hub up to date. + @discardableResult + static func change(to newValue: BackgroundRefreshType) -> Task? { let storage = Storage.shared LogManager.shared.log(category: .general, message: "Background refresh type changed to: \(newValue.rawValue)") @@ -65,16 +67,15 @@ class BackgroundRefreshSettingsViewModel: ObservableObject { MainViewController.shared?.backgroundModeChanged() if storage.backgroundRefreshType.value != .hub { HubBundle.shared.stopListening() } - if hubModeChanges { - Task { - #if !targetEnvironment(macCatalyst) - if newValue == .hub, #available(iOS 26, *) { _ = await HubAlarmKit.requestAuthorization() } - #endif - // In the foreground the sync starts the hub's long poll and its watchdog - // checks, as coming to the foreground does. - await HubClient.shared.sync() - if newValue == .hub { await HubWatchdog.check() } - } + guard hubModeChanges else { return nil } + return Task { + #if !targetEnvironment(macCatalyst) + if newValue == .hub, #available(iOS 26, *) { _ = await HubAlarmKit.requestAuthorization() } + #endif + // In the foreground the sync starts the hub's long poll and its watchdog + // checks, as coming to the foreground does. + await HubClient.shared.sync() + if newValue == .hub { await HubWatchdog.check() } } } } diff --git a/LoopFollow/Hub/HubBundle.swift b/LoopFollow/Hub/HubBundle.swift index 6ffc8345d..adf3a1927 100644 --- a/LoopFollow/Hub/HubBundle.swift +++ b/LoopFollow/Hub/HubBundle.swift @@ -153,6 +153,10 @@ final class HubBundle { let changed = apply(data) if !changed.isEmpty { rerunTasks(for: changed) } return changed + } catch is CancellationError { + return nil + } catch let error as URLError where error.code == .cancelled { + return nil } catch { LogManager.shared.log(category: .hub, message: "Hub bundle fetch failed: \(error.localizedDescription)", isDebug: true) return nil diff --git a/LoopFollow/Hub/HubChecklist.swift b/LoopFollow/Hub/HubChecklist.swift index 0b283bff0..f74b9a7e2 100644 --- a/LoopFollow/Hub/HubChecklist.swift +++ b/LoopFollow/Hub/HubChecklist.swift @@ -71,7 +71,8 @@ enum HubChecklist { return [hub] } guard let status else { return [] } - if facts.hubMode, !facts.hubLooksHealthy { + // Pushes that do not arrive show on their own item. + if facts.hubMode, !facts.hubLooksHealthy, HubClient.unhealthiness(of: status, now: now) == nil { hub.problem = "LF Hub has not answered properly for several minutes." hub.fix = .retry } @@ -90,7 +91,7 @@ enum HubChecklist { let sharedKey = status.device.apnsKeyShared == true if facts.ownApnKey || sharedKey { items.append(Item(id: .apnKey, title: sharedKey && !facts.ownApnKey ? "APN key shared by an admin" : "APN key added")) - items.append(pushItem(status, now: now)) + items.append(pushItem(status, ownApnKey: facts.ownApnKey, now: now)) } else { if !facts.ownKeyAllowed { items.append(Item(id: .apnKey, title: "APN key shared by an admin", @@ -111,7 +112,8 @@ enum HubChecklist { var confirmed = Item(id: .confirmed, title: "LF Hub sends this phone's alarms") if !facts.drivesAlarms { let covered = [HubClient.Problem.noPush, HubClient.Problem.notFollowed, HubClient.Problem.noPushYet, - HubClient.Problem.pushFailing, HubClient.Problem.alarmKitDenied, + HubClient.Problem.pushFailing, HubClient.Problem.apnKeyRejected, + HubClient.Problem.alarmKitDenied, HubClient.Problem.alarmKitUnavailable, HubClient.Problem.hubOutdated, HubClient.Problem.notificationsOff] let remaining = facts.hubProblems.filter { !covered.contains($0) } @@ -182,7 +184,7 @@ enum HubChecklist { @MainActor static func perform(_ fix: Fix) async { switch fix { case .useHubMode: - BackgroundRefreshSettingsViewModel.change(to: .hub) + await BackgroundRefreshSettingsViewModel.change(to: .hub)?.value case .askNotifications: await withCheckedContinuation { continuation in NotificationAuthorization.requestIfNeeded { continuation.resume() } @@ -211,14 +213,36 @@ enum HubChecklist { } } - private static func pushItem(_ status: HubClient.Status, now: Date) -> Item { + /// Whether the hub's notifications arrive, and if not, what is wrong: Apple rejecting + /// the APN key is fixed where the key was added; anything else by registering again. + private static func pushItem(_ status: HubClient.Status, ownApnKey: Bool, now: Date) -> Item { var item = Item(id: .push, title: "Notifications from LF Hub arrive") if !status.device.apnsConfigured { item.problem = "LF Hub has not registered this phone for notifications yet." - } else if HubClient.health(of: status, now: now) != .healthy { - item.problem = status.device.lastPushOkAt == nil ? HubClient.Problem.noPushYet : HubClient.Problem.pushFailing + item.fix = .registerAgain + return item + } + switch HubClient.unhealthiness(of: status, now: now) { + case nil: + break + case .keyRejected: + if ownApnKey { + item.problem = HubClient.Problem.apnKeyRejected + " Check the Key ID and key under Settings → APN." + item.fix = .goToAPN + } else { + item.problem = HubClient.Problem.apnKeyRejected + " Ask the admin who added the key to check it under Settings → APN." + item.fix = .askAdmin + } + case .registrationRejected: + item.problem = HubClient.Problem.pushFailing + " Apple says this phone's registration is out of date." + item.fix = .registerAgain + case .pushFailing, .noAnswer: + item.problem = HubClient.Problem.pushFailing + item.fix = .registerAgain + case .noPushYet: + item.problem = HubClient.Problem.noPushYet + item.fix = .registerAgain } - if item.problem != nil { item.fix = .registerAgain } return item } diff --git a/LoopFollow/Hub/HubClient.swift b/LoopFollow/Hub/HubClient.swift index 6c0c2ffd9..312b15372 100644 --- a/LoopFollow/Hub/HubClient.swift +++ b/LoopFollow/Hub/HubClient.swift @@ -67,6 +67,13 @@ final class HubClient { self.init(hubURL: base, code: code) } + /// The invite a paste put in the field: the text grew by more than one character at + /// once into an invite link. Typed text is read only on Continue. + static func pasted(from old: String, to new: String) -> Invite? { + guard new.count - old.count > 1 else { return nil } + return Invite(link: new) + } + /// The hub's codes are random bytes in base64url. private static func isCode(_ code: String) -> Bool { (16 ... 128).contains(code.count) @@ -96,6 +103,21 @@ final class HubClient { struct DeviceList: Decodable { let devices: [Device] + /// The patient's name, in the lists offered when joining; absent on an older hub. + var patientName: String? + } + + /// Replacing a device used this recently asks first: it may still be in use. + static let recentUse: TimeInterval = 15 * 60 + + /// The question to ask before replacing a device used within `recentUse`, if it was. + static func recentUseWarning(_ device: Device, now: Date) -> String? { + guard let seen = device.lastSeenAt else { return nil } + let ago = max(0, now.timeIntervalSince(seen)) + guard ago < recentUse else { return nil } + let minutes = Int(ago / 60) + let when = minutes < 1 ? "just now" : minutes == 1 ? "1 minute ago" : "\(minutes) minutes ago" + return "\(device.name) was used \(when). Replace it anyway?" } /// How this phone joins a hub that already has devices. @@ -226,6 +248,43 @@ final class HubClient { } } + /// An error in plain words for the user. The hub's own detail stays in the log. + static func userMessage(for error: Error) -> String { + switch error { + case let HubError.http(code, detail): + let lower = detail.lowercased() + switch code { + case 401: return "This phone is no longer part of LF Hub." + case 403 where lower.contains("take over"): return "A phone that joins with an invite cannot replace an admin's phone." + case 403 where lower.contains("admin"): return "Only an admin can do this." + case 403 where lower.contains("invite"): return HubError.inviteNotValid.errorDescription ?? "" + case 403 where lower.contains("setup token"): return "The setup token is not right." + case 403: return "LF Hub did not allow this." + case 404: return "LF Hub does not know this phone or device. It may have been removed." + case 409 where lower.contains("admin"): + return "LF Hub needs at least one admin. Make another phone an admin first." + case 409 where lower.contains("apns"): + return "LF Hub cannot notify this phone yet. Add the APN key under Settings → APN." + case 409: return "LF Hub cannot do this yet. Check the Ready list." + case 429: return HubError.tooManyTries.errorDescription ?? "" + case 502 where lower.hasPrefix("apns:"): + let status = detail.dropFirst("apns:".count).trimmingCharacters(in: .whitespaces) + return "Apple rejected the push: \(pushFailureReason(status))" + case 500...: return "LF Hub had a problem. Try again in a moment." + default: return "LF Hub did not accept this: \(detail)" + } + case HubError.notConnected: return HubError.notConnected.errorDescription ?? "" + case let error as HubError: return error.errorDescription ?? "" + case let error as URLError: + switch error.code { + case .notConnectedToInternet, .networkConnectionLost: return "This phone is offline. Try again when it is online." + case .timedOut: return "LF Hub did not answer in time. Try again in a moment." + default: return "LF Hub could not be reached. Check the address, or try again in a moment." + } + default: return error.localizedDescription + } + } + /// The key this device authenticates to the hub with. let deviceKey = HubDeviceKey() @@ -246,6 +305,9 @@ final class HubClient { let apnsKeyShared = StorageValue(key: "hubApnsKeyShared", defaultValue: false) /// The patient's name at the hub, for messages about changes that reach every phone. let patientName = StorageValue(key: "hubPatientName", defaultValue: "") + /// The hub stopped knowing this phone's key (the phone was replaced or removed), until + /// the phone joins again or the user dismisses the message. + let removedFromHub = StorageValue(key: "hubRemovedFromHub", defaultValue: false) /// Connected, and allowed to manage the patient's data sources, devices and invites. var isAdmin: Bool { isConnected && role.value == .admin } @@ -254,7 +316,8 @@ final class HubClient { var isMember: Bool { isConnected && role.value == .member } /// Connected as an admin that did not join with an invite: this app's data sources go - /// to the hub. + /// to the hub. An admin that joined with an invite sends them only on a change the + /// user confirms. var sendsOwnSources: Bool { isAdmin && !joinedWithInvite.value } /// The role after joining: an invite makes a member, the setup token an admin. @@ -357,6 +420,12 @@ final class HubClient { alarms.filter { !hubChecks($0.type, api: hubAPI) } } + /// How many of the alarms the hub should hold after reading this app's export: those + /// of a type it checks. + static func alarmCountForHub(_ alarms: [Alarm], hubAPI: Int?) -> Int { + alarms.filter { hubChecks($0.type, api: hubAPI) }.count + } + /// The hub sets the app badge while it drives the alarms; the app leaves it alone. var drivesBadge: Bool { drivesAlarms } @@ -542,24 +611,24 @@ final class HubClient { /// The patient's devices at the hub, offered when joining; empty for a fresh hub. /// A hub without the device list answers 404 and takes new devices only. - func joinableDevices(url: String, setupToken: String) async throws -> [Device] { + func joinableDevices(url: String, setupToken: String) async throws -> DeviceList { let base = try Self.hubBase(url) let (data, code) = try await request("POST", "/v1/claim/devices", body: ["setupToken": setupToken], baseURL: base, key: nil) switch code { - case 200: return try Self.decoder.decode(DeviceList.self, from: data).devices - case 404: return [] + case 200: return try Self.decoder.decode(DeviceList.self, from: data) + case 404: return DeviceList(devices: []) default: throw Self.httpError(code, data) } } /// The patient's devices offered when joining with the invite: any to copy, a member's /// to take over. - func joinableDevices(invite: Invite) async throws -> [Device] { + func joinableDevices(invite: Invite) async throws -> DeviceList { let (data, code) = try await request("POST", "/v1/join/devices", body: ["code": invite.code], baseURL: invite.hubURL, key: nil) guard code == 200 else { throw Self.joinError(code, data) } - return try Self.decoder.decode(DeviceList.self, from: data).devices + return try Self.decoder.decode(DeviceList.self, from: data) } /// Joins the hub as an admin device with the setup token, as `join` says, creating the @@ -620,6 +689,8 @@ final class HubClient { case .copy: adoptionPending.value = .copied } try deviceKey.set(claim.deviceKey) + uploads.forget() + removedFromHub.value = false role.value = joinRole joinedWithInvite.value = withInvite // The new device has no APN key at the hub yet. @@ -730,9 +801,10 @@ final class HubClient { } /// Sets the patient's sources to these settings; one that is not set is removed at - /// the hub. Admins only. - func sendSources(_ sources: LocalSources) async throws { - guard !joinedWithInvite.value else { throw HubError.invitedPhone } + /// the hub. Admins only; from a phone that joined with an invite only when the user + /// confirmed the change (`confirmed`). + func sendSources(_ sources: LocalSources, confirmed: Bool = false) async throws { + guard !joinedWithInvite.value || confirmed else { throw HubError.invitedPhone } var body: [String: Any] = [:] if sources.hasNightscout { body["nightscoutUrl"] = sources.nightscoutURL @@ -778,7 +850,7 @@ final class HubClient { await HubAlarmSounds.prepare(for: Storage.shared.alarms.value) guard let json = AlarmSettingsExport.fromCurrentStorage().encodeToJSON() else { return } do { - _ = try await send("PUT", "/v1/device/alarms", rawBody: Data(json.utf8)) + _ = try await send("PUT", "/v1/device/alarms", skipRepeat: true, rawBody: Data(json.utf8)) } catch { // The hub holds an older alarm list: the app alarms itself until it takes this one. await setConfirmed(false, problems: [Problem.alarmsNotReceived]) @@ -790,7 +862,7 @@ final class HubClient { func uploadClientSettings() async throws { guard isConnected else { return } try await adoptIfPending() - _ = try await send("PUT", "/v1/device/clientsettings", body: Self.currentClientSettings()) + _ = try await send("PUT", "/v1/device/clientsettings", skipRepeat: true, body: Self.currentClientSettings()) await MainActor.run { clientSettingsPending.value = false } } @@ -844,6 +916,51 @@ final class HubClient { case unknown } + /// Why the hub cannot deliver this device's alarms. + enum Unhealthy: Equatable { + /// The hub did not answer properly. + case noAnswer + /// No push has reached this device yet. + case noPushYet + /// Apple rejects the APN key the hub signs this device's pushes with. + case keyRejected + /// Apple rejects this phone's push registration. + case registrationRejected + /// Pushes have been failing for `pushFailureTolerance`. + case pushFailing + } + + /// Why Apple rejects pushes, from APNs' answer as the hub reports it ("403 InvalidProviderToken"). + enum PushRejection: Equatable { + case key + case registration + /// A failure that may pass. + case other + } + + static func pushRejection(_ status: String?) -> PushRejection { + guard let status else { return .other } + let keyReasons = ["InvalidProviderToken", "MissingProviderToken", "TopicDisallowed", "BadCertificate"] + let registrationReasons = ["BadDeviceToken", "Unregistered", "DeviceTokenNotForTopic"] + if keyReasons.contains(where: status.contains) { return .key } + if registrationReasons.contains(where: status.contains) || status.hasPrefix("410") { return .registration } + return .other + } + + /// APNs' reason for a rejected push in plain words. + static func pushFailureReason(_ status: String) -> String { + switch pushRejection(status) { + case .key: return "it does not accept the APN key. Check the Key ID and key under Settings → APN." + case .registration: return "this phone's registration is out of date. Tap Register Again in the Ready list." + case .other: return status + } + } + + /// The failed push the status tells of: the last badge push's or the sounding alarm's answer. + private static func failedPushStatus(_ device: Status.Device) -> String? { + [device.lastPushStatus, device.activeAlarm?.lastPushStatus].compactMap { $0 }.first { !$0.hasPrefix("200") } + } + /// Pushes to this device may fail this long before the hub counts as unable /// to reach it. static let pushFailureTolerance: TimeInterval = 10 * 60 @@ -852,26 +969,44 @@ final class HubClient { /// work), its engine is alive, and its pushes reach this device. Records the /// hub's confirmation from the same answer. func health() async -> Health { - guard isConnected else { return .unhealthy } + await healthCheck().health + } + + /// The hub's health and, when it is unhealthy, why. A hub that does not know this + /// device's key gives no verdict: the phone has left it. + func healthCheck() async -> (health: Health, cause: Unhealthy?) { + guard isConnected else { return (.unhealthy, .noAnswer) } do { let status = try await status() await absorb(status) let (_, code) = try await request("GET", "/livez", key: nil) - guard code == 200 else { return .unhealthy } - return Self.health(of: status, now: Date()) + guard code == 200 else { return (.unhealthy, .noAnswer) } + let cause = Self.unhealthiness(of: status, now: Date()) + return (cause == nil ? .healthy : .unhealthy, cause) } catch { - return Self.isCancellation(error) ? .unknown : .unhealthy + if Self.isCancellation(error) || !isConnected { return (.unknown, nil) } + return (.unhealthy, .noAnswer) } } /// Healthy once a push has reached this device and while pushes have not been /// failing for `pushFailureTolerance`. static func health(of status: Status, now: Date) -> Health { - guard status.device.lastPushOkAt != nil else { return .unhealthy } - if let since = status.device.pushFailingSince, now.timeIntervalSince(since) >= pushFailureTolerance { - return .unhealthy + unhealthiness(of: status, now: now) == nil ? .healthy : .unhealthy + } + + /// Why the hub's pushes do not reach this device, or nil while they do. Apple + /// rejecting the key or the phone's registration counts at once; any other failure + /// after `pushFailureTolerance`. + static func unhealthiness(of status: Status, now: Date) -> Unhealthy? { + if let since = status.device.pushFailingSince { + switch pushRejection(failedPushStatus(status.device)) { + case .key: return .keyRejected + case .registration: return .registrationRejected + case .other: if now.timeIntervalSince(since) >= pushFailureTolerance { return .pushFailing } + } } - return .healthy + return status.device.lastPushOkAt == nil ? .noPushYet : nil } static func isCancellation(_ error: Error) -> Bool { @@ -921,10 +1056,25 @@ final class HubClient { } /// Tells the hub the alarm was snoozed or acknowledged here, so it stops - /// repeating it at once. + /// repeating it at once. The alarms with the new snooze go first, so the hub + /// never takes the alarm as handled without its snooze. func alarmHandled(_ alarmID: UUID) { guard isHubMode else { return } - uploadSoon { _ = try await $0.send("POST", "/v1/device/alarm/\(alarmID.uuidString)/handled") } + uploadSoon { client in + try await Self.reportHandled(upload: { try await client.uploadAlarms() }, + handled: { _ = try await client.send("POST", "/v1/device/alarm/\(alarmID.uuidString)/handled") }) + } + } + + /// Uploads, then reports the alarm handled. An upload the hub refuses for good does + /// not hold back the report; one that may pass fails the whole step, to be retried. + static func reportHandled(upload: () async throws -> Void, handled: () async throws -> Void) async throws { + do { + try await upload() + } catch let error where !isRetryable(error) { + LogManager.shared.log(category: .hub, message: "Alarm upload before handled failed: \(error.localizedDescription)") + } + try await handled() } /// The alarm the hub is sounding that is the current alarm here, if any. @@ -950,6 +1100,13 @@ final class HubClient { Observable.shared.currentAlarm.value = nil } + /// The hub's view of this device, recorded as a sync records it. + func currentStatus() async throws -> Status { + let status = try await status() + await absorb(status) + return status + } + /// Reads the hub's view of this device: whether it confirms the alarms, and /// whether the adopted alarm still sounds there. func refreshStatus() async { @@ -973,6 +1130,30 @@ final class HubClient { } await localDisconnect() await unregisterPending() + await retryUnregistrationWhileActive() + } + + @MainActor private(set) var unregistrationRetry: Task? + static let unregistrationRetryInterval: UInt64 = 60 + + /// Asks the hub every minute to remove the device this phone left, while one is + /// pending and the app is active. Coming to the foreground starts it again. + @MainActor func retryUnregistrationWhileActive() { + guard pendingUnregistration.current != nil, unregistrationRetry == nil else { return } + unregistrationRetry = Task { @MainActor [self] in + while pendingUnregistration.current != nil, UIApplication.shared.applicationState == .active { + try? await Task.sleep(nanoseconds: Self.unregistrationRetryInterval * 1_000_000_000) + if Task.isCancelled { break } + await unregisterPending() + } + if !Task.isCancelled { unregistrationRetry = nil } + } + } + + /// Stops asking the hub to remove the device this phone left until the next start. + @MainActor func stopUnregistrationRetries() { + unregistrationRetry?.cancel() + unregistrationRetry = nil } /// Asks the hub to remove the device this phone left, if one is pending. Done once the @@ -995,6 +1176,7 @@ final class HubClient { /// Forgets the hub; Hub mode falls back to Silent Tune so the app alarms again. @MainActor private func localDisconnect() { deviceKey.clear() + uploads.forget() shareHubURL() setConfirmed(false, problems: []) hubInfo.value = nil @@ -1079,15 +1261,27 @@ final class HubClient { } } - private func forgotten() async { + /// The hub answered 401 to this phone's key: the phone was replaced or removed. Leaves + /// the hub at once, so the app alarms itself again, and tells the user once. + @MainActor private func forgotten() { + guard isConnected else { return } LogManager.shared.log(category: .hub, message: "Hub no longer knows this device") - await localDisconnect() + localDisconnect() + removedFromHub.value = true + let content = UNMutableNotificationContent() + content.title = "This phone was removed from LF Hub" + content.body = "LoopFollow alarms by itself again. To use LF Hub, join again under Settings → LF Hub." + content.sound = .default + postNotification(UNNotificationRequest(identifier: "lfhub.removed", content: content, trigger: nil)) } + /// Delivers the app's own notices; tests replace it. + @MainActor var postNotification: (UNNotificationRequest) -> Void = { UNUserNotificationCenter.current().add($0) } + /// Tells the hub what it drives for this device and records its confirmation. /// The badge and the Live Activity follow their settings in this app. private func pushHubSettings() async throws { - _ = try await send("PUT", "/v1/device/settings", + _ = try await send("PUT", "/v1/device/settings", skipRepeat: true, body: ["badge": isHubMode && Storage.shared.appBadge.value, "liveActivity": isHubMode && Storage.shared.laEnabled.value, "alarms": isHubMode, @@ -1123,7 +1317,9 @@ final class HubClient { } let problems = Self.confirmationProblems(status, alarmsPending: alarmsPending.value || adoptionPending.value != .none, alarmKitAuthorized: Self.alarmKitAuthorized, - notificationsAllowed: notificationsAllowed.value, now: Date()) + notificationsAllowed: notificationsAllowed.value, now: Date(), + exportedAlarms: Self.alarmCountForHub(Storage.shared.alarms.value, + hubAPI: status.hub?.api)) setConfirmed(problems.isEmpty, problems: problems) if let alarmID = status.device.activeAlarm?.alarmId.flatMap(UUID.init(uuidString:)) { if problems.isEmpty { adoptHubAlarm(alarmID) } @@ -1134,10 +1330,11 @@ final class HubClient { /// Whether the hub's status shows it delivering this device's alarms. static func confirms(_ status: Status, hubMode: Bool, alarmsPending: Bool, alarmKitAuthorized: Bool, - notificationsAllowed: Bool, now: Date) -> Bool + notificationsAllowed: Bool, now: Date, exportedAlarms: Int? = nil) -> Bool { hubMode && confirmationProblems(status, alarmsPending: alarmsPending, alarmKitAuthorized: alarmKitAuthorized, - notificationsAllowed: notificationsAllowed, now: now).isEmpty + notificationsAllowed: notificationsAllowed, now: now, + exportedAlarms: exportedAlarms).isEmpty } /// What keeps the hub from delivering this device's alarms, in words for the user. @@ -1147,7 +1344,9 @@ final class HubClient { static let alarmsNotReceived = "The hub does not have this phone's latest alarms yet." static let notFollowed = "The hub is not reading the patient's data yet. Check the data sources under Settings → LF Hub." static let noPushYet = "No notification from the hub has arrived yet." - static let pushFailing = "Notifications from the hub are failing." + static let pushFailing = "Notifications from LF Hub are not reaching this phone." + static let apnKeyRejected = "Apple rejects the APN key, so LF Hub cannot notify this phone." + static let alarmsSkipped = "LF Hub could not read some of this phone's alarms, so LoopFollow checks the alarms itself. Updating LF Hub may help." static let alarmKitDenied = "Alarms may not ring through silent mode. Allow it under Settings → LF Hub." static let alarmKitUnavailable = "Ringing through silent mode requires iOS 26." static let notificationsOff = "Notifications for LoopFollow are off or delivered quietly, so LF Hub's alarms would not show." @@ -1162,25 +1361,39 @@ final class HubClient { /// Each reason the status gives for the hub not delivering this device's alarms: /// a hub version this app does not work with, push not registered or not reaching the - /// phone, alarms off at the hub, the latest alarm list not received, the patient not - /// followed, AlarmKit not allowed, or notifications not allowed. + /// phone, alarms off at the hub, the latest alarm list not received or fewer alarms in + /// it than the `exportedAlarms` the hub should hold, the patient not followed, AlarmKit + /// not allowed, or notifications not allowed. static func confirmationProblems(_ status: Status, alarmsPending: Bool, alarmKitAuthorized: Bool, - notificationsAllowed: Bool, now: Date) -> [String] + notificationsAllowed: Bool, now: Date, exportedAlarms: Int? = nil) -> [String] { var problems: [String] = [] if !supports(status.hub) { problems.append(Problem.hubOutdated) } if !status.device.apnsConfigured { problems.append(Problem.noPush) } if status.device.settings?.alarms != true { problems.append(Problem.alarmsOff) } - if alarmsPending || status.device.alarmsReceived != true { problems.append(Problem.alarmsNotReceived) } + if alarmsPending || status.device.alarmsReceived != true { + problems.append(Problem.alarmsNotReceived) + } else if let exportedAlarms, let held = status.device.alarmCount, held < exportedAlarms { + problems.append(Problem.alarmsSkipped) + } if status.patient.followed != true { problems.append(Problem.notFollowed) } - if status.device.apnsConfigured, health(of: status, now: now) != .healthy { - problems.append(status.device.lastPushOkAt == nil ? Problem.noPushYet : Problem.pushFailing) + if status.device.apnsConfigured, let cause = unhealthiness(of: status, now: now) { + problems.append(problem(for: cause)) } if let problem = alarmKitProblem(authorized: alarmKitAuthorized) { problems.append(problem) } if !notificationsAllowed { problems.append(Problem.notificationsOff) } return problems } + /// The words for the user for a push problem. + static func problem(for cause: Unhealthy) -> String { + switch cause { + case .noPushYet: return Problem.noPushYet + case .keyRejected: return Problem.apnKeyRejected + case .noAnswer, .registrationRejected, .pushFailing: return Problem.pushFailing + } + } + /// Why hub alarms cannot ring as system alarms, if they cannot. static func alarmKitProblem(authorized: Bool) -> String? { guard !authorized else { return nil } @@ -1308,13 +1521,25 @@ final class HubClient { return decoder }() - /// The response body of a call answered with 2xx; any other answer throws. - private func send(_ method: String, _ path: String, body: Any? = nil, rawBody: Data? = nil, - baseURL: String = Storage.shared.hubURL.value, + /// The uploads that reached the hub lately, so the same upload made again right after + /// (two syncs in a row, a snooze and its debounced upload) is not sent twice. + private let uploads = RecentUploads() + + /// The response body of a call answered with 2xx; any other answer throws. With + /// `skipRepeat` an upload the hub took within `RecentUploads.window` is not sent again, + /// and answers an empty body. + private func send(_ method: String, _ path: String, skipRepeat: Bool = false, body: Any? = nil, + rawBody: Data? = nil, baseURL: String = Storage.shared.hubURL.value, key: String? = HubClient.shared.deviceKey.value) async throws -> Data { + let upload = skipRepeat ? RecentUploads.digest(body: body, rawBody: rawBody) : nil + if let upload, uploads.isRepeat(path, upload, now: Date()) { + LogManager.shared.log(category: .hub, message: "Hub upload \(path) skipped: the hub took the same just now", isDebug: true) + return Data() + } let (data, code) = try await request(method, path, body: body, rawBody: rawBody, baseURL: baseURL, key: key) guard (200 ..< 300).contains(code) else { throw Self.httpError(code, data) } + if let upload { uploads.record(path, upload, now: Date()) } return data } @@ -1357,7 +1582,44 @@ final class HubClient { } let (data, response) = try await URLSession.shared.data(for: request) - return (data, (response as? HTTPURLResponse)?.statusCode ?? 0) + let code = (response as? HTTPURLResponse)?.statusCode ?? 0 + // Any call with this phone's key answered 401: the hub does not know the phone. + if code == 401, let key, key == deviceKey.value { await forgotten() } + return (data, code) + } +} + +/// The uploads the hub took lately, by path, as a digest of their body. +final class RecentUploads { + /// The same upload within this long after the last is skipped. + static let window: TimeInterval = 30 + + private let lock = NSLock() + private var taken: [String: (digest: String, at: Date)] = [:] + + /// A digest of the body that does not depend on the order of its keys; nil when it + /// is not JSON. + static func digest(body: Any?, rawBody: Data?) -> String? { + let object: Any? = body ?? rawBody.flatMap { try? JSONSerialization.jsonObject(with: $0) } + guard let object, + let canonical = try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) + else { return nil } + return SHA256.hash(data: canonical).map { String(format: "%02x", $0) }.joined() + } + + func isRepeat(_ path: String, _ digest: String, now: Date) -> Bool { + lock.withLock { + guard let last = taken[path] else { return false } + return last.digest == digest && now.timeIntervalSince(last.at) < Self.window + } + } + + func record(_ path: String, _ digest: String, now: Date) { + lock.withLock { taken[path] = (digest, now) } + } + + func forget() { + lock.withLock { taken = [:] } } } diff --git a/LoopFollow/Hub/HubManagedSettings.swift b/LoopFollow/Hub/HubManagedSettings.swift index 8953eaf95..cb252207b 100644 --- a/LoopFollow/Hub/HubManagedSettings.swift +++ b/LoopFollow/Hub/HubManagedSettings.swift @@ -4,8 +4,9 @@ import SwiftUI /// Data-source settings while LF Hub keeps the patient's data source the same on every -/// phone. A member, and any phone that joined with an invite, sees them read only. An admin confirms once that a change reaches every -/// phone; the change then goes to the hub after the app's own check of it. +/// phone. A member sees them read only. An admin confirms once that a change reaches every +/// phone; the change then goes to the hub after the app's own check of it. While this +/// phone's settings are not the hub's, an admin can put the hub's back or try again. struct HubManagedSources: View { @ViewBuilder let content: Content @@ -14,6 +15,11 @@ struct HubManagedSources: View { @ObservedObject private var hubInfo = HubClient.shared.hubInfo @ObservedObject private var patientName = HubClient.shared.patientName @ObservedObject private var sync = HubSourceSync.shared + @ObservedObject private var url = Storage.shared.url + @ObservedObject private var token = Storage.shared.token + @ObservedObject private var dexcomUser = Storage.shared.shareUserName + @ObservedObject private var dexcomPassword = Storage.shared.sharePassword + @ObservedObject private var dexcomServer = Storage.shared.shareServer @State private var unlocked = false @State private var confirming = false @@ -24,9 +30,11 @@ struct HubManagedSources: View { var body: some View { if sync.isActive { Section { - if HubClient.shared.sendsOwnSources { + if HubClient.shared.isAdmin { if unlocked { changeStatus + } else if sync.differsFromHub { + notShared(sync.state.failure) } else { Text("LF Hub shares this data source with every phone following \(patient).") .foregroundColor(.secondary) @@ -48,9 +56,10 @@ struct HubManagedSources: View { } message: { Text("This changes the data source for everyone following \(patient).") } + .onDisappear { if unlocked { sync.endEditing() } } } Group { content } - .disabled(sync.isActive && !(HubClient.shared.sendsOwnSources && unlocked)) + .disabled(sync.isActive && !(HubClient.shared.isAdmin && unlocked)) } @ViewBuilder @@ -69,10 +78,30 @@ struct HubManagedSources: View { Label("Sent to LF Hub", systemImage: "checkmark.circle.fill") .foregroundColor(.green) case let .failed(reason): - Text(reason).font(.footnote).foregroundColor(.red) - Button("Try Again") { Task { await sync.retry() } } + notShared(reason) } } + + /// This phone's data source is not the one LF Hub shares: why, and the way back. + @ViewBuilder + private func notShared(_ reason: String?) -> some View { + Label("Not shared with LF Hub", systemImage: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + Text((reason.map { $0 + " " } ?? "") + + "Until it is, this phone's data source is not the one other phones use.") + .font(.footnote) + .foregroundColor(.secondary) + Button("Undo") { Task { await sync.undo() } } + Button("Try Again") { Task { await sync.retry() } } + } +} + +extension HubSourceSync.State { + /// Why the last change did not reach the hub, if it did not. + var failure: String? { + if case let .failed(reason) = self { return reason } + return nil + } } /// The APN settings while LF Hub pushes to this phone with the APN key an admin added. diff --git a/LoopFollow/Hub/HubSettingsView.swift b/LoopFollow/Hub/HubSettingsView.swift index 289186349..20dd29789 100644 --- a/LoopFollow/Hub/HubSettingsView.swift +++ b/LoopFollow/Hub/HubSettingsView.swift @@ -15,6 +15,7 @@ struct HubSettingsView: View { @ObservedObject private var hubInfo = HubClient.shared.hubInfo @ObservedObject private var hubConfirmed = HubClient.shared.hubConfirmed @ObservedObject private var hubProblems = HubClient.shared.hubProblems + @ObservedObject private var removedFromHub = HubClient.shared.removedFromHub @ObservedObject private var lfKeyId = Storage.shared.lfKeyId @ObservedObject private var lfApnsKey = Storage.shared.lfApnsKey @@ -25,10 +26,15 @@ struct HubSettingsView: View { /// The hub's devices offered when joining; nil until the address and token, or the /// invite, are checked. @State private var joinableDevices: [HubClient.Device]? + /// The patient's name the hub gave with the devices offered when joining. + @State private var joinPatientName: String? @State private var join = HubClient.Join.new + /// A recently used device about to be replaced, with the question to ask first. + @State private var replaceCandidate: ReplaceCandidate? /// The invite being joined with; nil while joining with the setup token. @State private var joinInvite: HubClient.Invite? @State private var inviteLink = "" + @State private var inviteError: String? @State private var scanningInvite = false @State private var status: HubClient.Status? @@ -44,8 +50,12 @@ struct HubSettingsView: View { /// Just connected while another Background Refresh mode is chosen. @State private var offeringHubMode = false @State private var busy = false - @State private var errorText: String? - @State private var infoText: String? + @State private var actionError: ActionError? + @State private var testAlarmInfo: String? + @State private var sourcesInfo: String? + + /// How often the page reads the hub's view again while it is shown. + private static let reloadInterval: UInt64 = 30 private enum SourceAction: Identifiable { case send, take @@ -58,6 +68,18 @@ struct HubSettingsView: View { var id: String { invite.code } } + private struct ActionError: Identifiable { + let title: String + let message: String + var id: String { title + message } + } + + private struct ReplaceCandidate: Identifiable { + let device: HubClient.Device + let warning: String + var id: UUID { device.id } + } + private var connected: Bool { !hubURL.value.isEmpty && !deviceKey.value.isEmpty } @@ -70,11 +92,6 @@ struct HubSettingsView: View { connected && role.value == .admin } - /// This app's data sources go to the hub: an admin that did not join with an invite. - private var sendsOwnSources: Bool { - isAdmin && !joinedWithInvite.value - } - private func offers(_ feature: String) -> Bool { connected && hubInfo.value?.features.contains(feature) == true } @@ -88,51 +105,73 @@ struct HubSettingsView: View { } else { connectSections } - - if let errorText { - Section { Text(errorText).foregroundColor(.red) } - } - if let infoText { - Section { Text(infoText).foregroundColor(.secondary) } - } } .disabled(busy) .task { await readNotificationPermission() await refresh() + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: Self.reloadInterval * 1_000_000_000) + if Task.isCancelled { break } + await reload() + } } .refreshable { await refresh() } .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in - Task { await readNotificationPermission() } + Task { + await readNotificationPermission() + await reload() + } } .sheet(isPresented: $scanningInvite) { SimpleQRCodeScannerView { result in // The scanner can report a code more than once before it stops. guard scanningInvite else { return } scanningInvite = false - if case let .success(text) = result { readInvite(text) } + if case let .success(text) = result { + inviteLink = text + readInvite(text) + } } } .sheet(item: $shownInvite) { shown in HubInviteSheet(invite: shown.invite, expiresAt: shown.expiresAt, patientName: status?.patient.name ?? HubClient.shared.patientName.value) } - .confirmationDialog(deviceToRemove.map { "Remove \($0.name)?" } ?? "", - isPresented: Binding(get: { deviceToRemove != nil }, - set: { if !$0 { deviceToRemove = nil } }), - titleVisibility: .visible, - presenting: deviceToRemove) + .alert(actionError?.title ?? "", isPresented: Binding(get: { actionError != nil }, + set: { if !$0 { actionError = nil } }), + presenting: actionError) + { _ in + Button("OK", role: .cancel) {} + } message: { error in + Text(error.message) + } + .alert(replaceCandidate.map { "Replace \($0.device.name)?" } ?? "", + isPresented: Binding(get: { replaceCandidate != nil }, + set: { if !$0 { replaceCandidate = nil } }), + presenting: replaceCandidate) + { candidate in + Button("Cancel", role: .cancel) {} + Button("Replace", role: .destructive) { choose(.replace(candidate.device.id), name: candidate.device.name) } + } message: { candidate in + Text(candidate.warning) + } + .alert(deviceToRemove.map { "Remove \($0.name)?" } ?? "", + isPresented: Binding(get: { deviceToRemove != nil }, + set: { if !$0 { deviceToRemove = nil } }), + presenting: deviceToRemove) { device in + Button("Cancel", role: .cancel) {} Button("Remove", role: .destructive) { Task { await remove(device) } } } message: { _ in Text("The hub stops sending to it.") } - .confirmationDialog("Make this phone a member?", - isPresented: Binding(get: { deviceToDemote != nil }, - set: { if !$0 { deviceToDemote = nil } }), - titleVisibility: .visible, - presenting: deviceToDemote) + .alert("Make this phone a member?", + isPresented: Binding(get: { deviceToDemote != nil }, + set: { if !$0 { deviceToDemote = nil } }), + presenting: deviceToDemote) { device in + Button("Cancel", role: .cancel) {} Button("Make Member") { Task { await setRole(.member, of: device) } } } message: { _ in Text("This phone can no longer change the data source, invite or manage phones. Only another admin can make it an admin again.") @@ -150,11 +189,14 @@ struct HubSettingsView: View { Text(action == .take ? "This app's Nightscout and Dexcom settings are replaced." : "The hub's Nightscout and Dexcom settings are replaced for every phone.") } - .confirmationDialog("Use LF Hub for Background Refresh?", isPresented: $offeringHubMode, - titleVisibility: .visible) - { - Button("Use LF Hub") { BackgroundRefreshSettingsViewModel.change(to: .hub) } + .alert("Use LF Hub for Background Refresh?", isPresented: $offeringHubMode) { Button("Not Now", role: .cancel) {} + Button("Use LF Hub") { + Task { + await BackgroundRefreshSettingsViewModel.change(to: .hub)?.value + await reload() + } + } } message: { Text("The hub keeps the badge, Live Activity and alarms up to date while LoopFollow is closed. You can change this under Background Refresh.") } @@ -172,9 +214,21 @@ struct HubSettingsView: View { @ViewBuilder private var connectSections: some View { + if removedFromHub.value { + Section { + Label("This phone was removed from LF Hub", systemImage: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + Text("Another phone replaced it, or an admin removed it. LoopFollow alarms by itself now. To use LF Hub again, ask an admin for an invite and join below.") + .font(.footnote) + .foregroundColor(.secondary) + Button("Dismiss") { removedFromHub.value = false } + } + } + if let joinInvite, let joinableDevices { Section(header: Text("Join with Invite")) { row("Hub", Self.host(joinInvite)) + if let joinPatientName { row("Patient", joinPatientName) } Button("Use a Different Invite") { leaveInvite() } } joinChoiceSections(joinableDevices, invite: joinInvite) @@ -184,6 +238,7 @@ struct HubSettingsView: View { footer: Text("Someone who manages LF Hub shows the invite under Settings → LF Hub → Invite Family Member.") ) { Button { + inviteError = nil scanningInvite = true } label: { Label("Scan Invite Code", systemImage: "qrcode.viewfinder") @@ -193,9 +248,14 @@ struct HubSettingsView: View { .textInputAutocapitalization(.never) .autocorrectionDisabled() .onSubmit { readInvite(inviteLink) } - .onChange(of: inviteLink) { if HubClient.Invite(link: inviteLink) != nil { readInvite(inviteLink) } } - if !inviteLink.isEmpty, HubClient.Invite(link: inviteLink) == nil { - Text("This is not an LF Hub invite link.").font(.caption).foregroundColor(.orange) + .onChange(of: inviteLink) { old, new in + if new.isEmpty { inviteError = nil } + if let invite = HubClient.Invite.pasted(from: old, to: new) { readInvite(invite.link) } + } + Button("Continue") { readInvite(inviteLink) } + .disabled(inviteLink.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + if let inviteError { + Text(inviteError).font(.caption).foregroundColor(.red) } } @@ -213,6 +273,8 @@ struct HubSettingsView: View { if joinableDevices == nil { Button("Continue") { Task { await listDevices() } } .disabled(urlInput.isEmpty || setupToken.isEmpty) + } else if let joinPatientName { + row("Patient", joinPatientName) } } @@ -243,6 +305,13 @@ struct HubSettingsView: View { ForEach(replaceable) { device in joinRow("Replace \(device.name)", detail: deviceDetail(device), .replace(device.id), name: device.name) + { + if let warning = HubClient.recentUseWarning(device, now: Date()) { + replaceCandidate = ReplaceCandidate(device: device, warning: warning) + } else { + choose(.replace(device.id), name: device.name) + } + } } ForEach(devices) { device in joinRow("Copy Settings from \(device.name)", detail: nil, .copy(device.id), @@ -261,10 +330,16 @@ struct HubSettingsView: View { URL(string: invite.hubURL)?.host ?? invite.hubURL } - private func joinRow(_ title: String, detail: String?, _ choice: HubClient.Join, name: String) -> some View { + private func choose(_ choice: HubClient.Join, name: String) { + join = choice + deviceName = name + } + + private func joinRow(_ title: String, detail: String?, _ choice: HubClient.Join, name: String, + onSelect: (() -> Void)? = nil) -> some View + { Button { - join = choice - deviceName = name + if let onSelect { onSelect() } else { choose(choice, name: name) } } label: { HStack { VStack(alignment: .leading, spacing: 2) { @@ -287,6 +362,9 @@ struct HubSettingsView: View { private var connectedSections: some View { Section(header: Text("Hub")) { row("Address", hubURL.value) + if let version = hubInfo.value?.version { + row("LF Hub version", version) + } if let status { row("Patient", status.patient.name) row("This device", status.device.name) @@ -338,7 +416,7 @@ struct HubSettingsView: View { footer: Text(sourcesFooter) ) { if let status { - if sendsOwnSources, offers("sourceRevision") { + if isAdmin, offers("sourceRevision") { NavigationLink(value: SettingsRoute.nightscout) { row("Nightscout", status.patient.nightscoutHost ?? "Not set") } @@ -358,6 +436,9 @@ struct HubSettingsView: View { Button("Use the Hub's Sources in This App") { sourceAction = .take } .disabled(status.map { $0.patient.nightscoutHost == nil && $0.patient.dexcom != true } ?? true) } + if let sourcesInfo { + Text(sourcesInfo).font(.footnote).foregroundColor(.secondary) + } } if isAdmin, offers("invites") { @@ -373,27 +454,12 @@ struct HubSettingsView: View { if !devices.isEmpty { Section { ForEach(devices) { device in - deviceRow(device) - .swipeActions { - if isAdmin, device.current != true { - Button("Remove", role: .destructive) { deviceToRemove = device } - } - if isAdmin, offers("roles"), let role = device.role { - Button(role == .admin ? "Make Member" : "Make Admin") { - if device.current == true, role == .admin { - deviceToDemote = device - } else { - Task { await setRole(role == .admin ? .member : .admin, of: device) } - } - } - .tint(.blue) - } - } + deviceEntry(device) } } header: { Text("Devices") } footer: { - if isAdmin { Text("Swipe a phone to remove it or change its role.") } + if isAdmin { Text("Tap a phone to remove it or change its role.") } } } @@ -401,6 +467,9 @@ struct HubSettingsView: View { row("Background Refresh", backgroundRefreshType.value.rawValue) Button("Send Test Alarm") { Task { await testAlarm() } } Button("Send Test Alarm in 30 s") { Task { await testAlarm(delaySeconds: 30) } } + if let testAlarmInfo { + Text(testAlarmInfo).font(.footnote).foregroundColor(.secondary) + } } Section { @@ -416,7 +485,7 @@ struct HubSettingsView: View { guard offers("sourceRevision") else { return "The hub reads Dexcom if set, otherwise Nightscout. Every phone keeps a copy for when LoopFollow reads the data itself." } - return (sendsOwnSources + return (isAdmin ? "A change made here reaches every phone following \(patientDisplayName)." : "An admin manages \(patientDisplayName)'s data sources.") + " The hub reads Dexcom if set, otherwise Nightscout. Every phone keeps a copy for when LoopFollow reads the data itself." @@ -432,7 +501,7 @@ struct HubSettingsView: View { facts.liveActivityOn = laEnabled.value facts.ownApnKey = !lfKeyId.value.isEmpty && !lfApnsKey.value.isEmpty && !joinedWithInvite.value facts.ownKeyAllowed = !joinedWithInvite.value - facts.isAdmin = sendsOwnSources + facts.isAdmin = isAdmin facts.hubSharesKeys = offers("sharedApnsKey") facts.sourcesDiffer = HubSourceSync.shared.isActive && HubSourceSync.shared.differsFromHub facts.drivesAlarms = HubClient.shared.drivesAlarms @@ -440,10 +509,42 @@ struct HubSettingsView: View { return HubChecklist.items(status: status, error: statusError, facts: facts, now: Date()) } + /// A device of the patient; an admin taps it, or swipes it, for its role and removal. + @ViewBuilder + private func deviceEntry(_ device: HubClient.Device) -> some View { + if isAdmin, device.current != true || (offers("roles") && device.role != nil) { + Menu { + deviceActions(device) + } label: { + deviceRow(device).contentShape(Rectangle()) + } + .swipeActions { deviceActions(device) } + } else { + deviceRow(device) + } + } + + @ViewBuilder + private func deviceActions(_ device: HubClient.Device) -> some View { + if offers("roles"), let role = device.role { + Button(role == .admin ? "Make Member" : "Make Admin") { + if device.current == true, role == .admin { + deviceToDemote = device + } else { + Task { await setRole(role == .admin ? .member : .admin, of: device) } + } + } + .tint(.blue) + } + if device.current != true { + Button("Remove", role: .destructive) { deviceToRemove = device } + } + } + private func deviceRow(_ device: HubClient.Device) -> some View { VStack(alignment: .leading, spacing: 2) { HStack { - Text(device.name) + Text(device.name).foregroundColor(.primary) if device.current == true { Text("This device").font(.caption).foregroundColor(.secondary) } @@ -475,38 +576,68 @@ struct HubSettingsView: View { } private func relative(_ date: Date) -> String { + Self.relative(date, now: Date()) + } + + /// A time relative to now; within a minute of now in words. + static func relative(_ date: Date, now: Date) -> String { + let interval = date.timeIntervalSince(now) + if abs(interval) < 60 { return interval > 0 ? "in under a minute" : "just now" } let formatter = RelativeDateTimeFormatter() formatter.unitsStyle = .short - return formatter.localizedString(for: date, relativeTo: Date()) + return formatter.localizedString(for: date, relativeTo: now) } // MARK: - Actions - private func run(_ action: () async throws -> Void) async { + /// Runs an action the user started; a failure shows in an alert with `failureTitle`, + /// in plain words, and goes to the log as the hub said it. + private func run(_ failureTitle: String, _ action: () async throws -> Void) async { busy = true - errorText = nil - infoText = nil - do { try await action() } catch { errorText = error.localizedDescription } + do { + try await action() + } catch { + LogManager.shared.log(category: .hub, message: "\(failureTitle): \(error.localizedDescription)") + actionError = ActionError(title: failureTitle, message: HubClient.userMessage(for: error)) + } busy = false } + /// Brings the hub up to date, then reads its view of this device and the patient's devices. private func refresh() async { guard connected else { return } - await run { try await load() } + await HubClient.shared.sync() + await reload() } - /// Brings the hub up to date and reads its view of this device and the patient's devices. - private func load() async throws { - await HubClient.shared.sync() + /// Reads the hub's view of this device and the patient's devices again, without + /// sending anything. A hub that does not answer shows on the Ready list. + private func reload() async { + guard connected else { return } do { - let status = try await HubClient.shared.status() - self.status = status + status = try await HubClient.shared.currentStatus() statusError = nil } catch { - statusError = error.localizedDescription + if HubClient.isCancellation(error) { return } + guard connected else { + status = nil + statusError = nil + devices = [] + return + } + LogManager.shared.log(category: .hub, message: "Hub status failed: \(error.localizedDescription)") + statusError = HubClient.userMessage(for: error) return } - devices = offers("devices") ? try await HubClient.shared.devices() : [] + guard offers("devices") else { + devices = [] + return + } + do { + devices = try await HubClient.shared.devices() + } catch { + LogManager.shared.log(category: .hub, message: "Hub device list failed: \(error.localizedDescription)") + } } private func readNotificationPermission() async { @@ -515,29 +646,37 @@ struct HubSettingsView: View { } private func listDevices() async { - await run { - let devices = try await HubClient.shared.joinableDevices(url: urlInput, setupToken: setupToken) + await run("Could Not Reach LF Hub") { + let list = try await HubClient.shared.joinableDevices(url: urlInput, setupToken: setupToken) joinInvite = nil join = .new deviceName = UIDevice.current.model - joinableDevices = devices + joinPatientName = list.patientName + joinableDevices = list.devices } } - /// Reads a scanned or pasted invite and offers the hub's devices to join as. + /// Reads a scanned, pasted or submitted invite and offers the hub's devices to join as. private func readInvite(_ text: String) { guard let invite = HubClient.Invite(link: text) else { - errorText = "This is not an LF Hub invite code." + inviteError = "This is not an LF Hub invite link." return } - guard invite != joinInvite else { return } + guard invite != joinInvite, !busy else { return } + inviteError = nil Task { - await run { - let devices = try await HubClient.shared.joinableDevices(invite: invite) + busy = true + defer { busy = false } + do { + let list = try await HubClient.shared.joinableDevices(invite: invite) join = .new deviceName = UIDevice.current.model joinInvite = invite - joinableDevices = devices + joinPatientName = list.patientName + joinableDevices = list.devices + } catch { + LogManager.shared.log(category: .hub, message: "Invite not read: \(error.localizedDescription)") + inviteError = HubClient.userMessage(for: error) } } } @@ -545,16 +684,18 @@ struct HubSettingsView: View { private func leaveInvite() { joinInvite = nil joinableDevices = nil + joinPatientName = nil inviteLink = "" - errorText = nil + inviteError = nil } private func connect() async { - await run { + await run("Could Not Join LF Hub") { defer { if connected { setupToken = "" joinableDevices = nil + joinPatientName = nil joinInvite = nil inviteLink = "" } @@ -566,52 +707,55 @@ struct HubSettingsView: View { try await HubClient.shared.claim(url: urlInput, setupToken: setupToken, patientName: patientName, deviceName: deviceName, join: choice) } - try await load() } + await reload() if connected, backgroundRefreshType.value != .hub { offeringHubMode = true } } private func sendSources() async { - await run { + sourcesInfo = nil + await run("Could Not Send the Data Sources") { try await HubSourceSync.shared.sendOwn() - infoText = "Data sources sent to the hub." + sourcesInfo = "Data sources sent to the hub." status = try await HubClient.shared.status() } } private func takeSources() async { - await run { + sourcesInfo = nil + await run("Could Not Use the Hub's Data Sources") { try await HubSourceSync.shared.takeHub() - infoText = "This app now uses the hub's data sources." + sourcesInfo = "This app now uses the hub's data sources." } } private func createInvite() async { - await run { + await run("Could Not Create an Invite") { let (invite, expiresAt) = try await HubClient.shared.createInvite() shownInvite = ShownInvite(invite: invite, expiresAt: expiresAt) } } private func setRole(_ role: HubClient.Role, of device: HubClient.Device) async { - await run { + await run("Could Not Change the Role") { try await HubClient.shared.setRole(role, of: device.id) LogManager.shared.log(category: .hub, message: "Device \(device.id.uuidString.prefix(8)) made \(role.rawValue)") - try await load() } + await reload() } private func remove(_ device: HubClient.Device) async { - await run { + await run("Could Not Remove the Phone") { try await HubClient.shared.deleteDevice(device.id) - devices = try await HubClient.shared.devices() } + await reload() } private func testAlarm(delaySeconds: Int = 0) async { - await run { + testAlarmInfo = nil + await run("Could Not Send the Test Alarm") { try await HubClient.shared.sendTestAlarm(delaySeconds: delaySeconds) - infoText = delaySeconds > 0 ? "Test alarm in \(delaySeconds) s. Lock the phone now." : "Test alarm sent." + testAlarmInfo = delaySeconds > 0 ? "Test alarm in \(delaySeconds) s. Lock the phone now." : "Test alarm sent." } } @@ -668,14 +812,23 @@ struct HubInviteSheet: View { } } -/// Shown while LoopFollow Hub mode is selected and the hub does not drive the alarms: +/// Shown while LF Hub mode is selected and the hub does not drive the alarms: /// the app alarms itself, and the LF Hub page lists what is missing. struct HubModeWarning: View { @ObservedObject private var backgroundRefreshType = Storage.shared.backgroundRefreshType @ObservedObject private var hubConfirmed = HubClient.shared.hubConfirmed + @ObservedObject private var hubProblems = HubClient.shared.hubProblems + @ObservedObject private var notificationsAllowed = HubClient.shared.notificationsAllowed + @ObservedObject private var looksHealthy = HubWatchdog.looksHealthy + + /// Hub mode is selected and the hub does not drive the alarms. The hub's health turns + /// with time; views follow it through `HubWatchdog.looksHealthy`, set at each check. + static var isShown: Bool { + HubClient.isSupported && Storage.shared.backgroundRefreshType.value == .hub && !HubClient.shared.drivesAlarms + } var body: some View { - if backgroundRefreshType.value == .hub, !HubClient.shared.drivesAlarms { + if Self.isShown { NavigationLink(value: SettingsRoute.hub) { VStack(alignment: .leading, spacing: 4) { Label("LF Hub is not sending this phone's alarms", systemImage: "exclamationmark.triangle.fill") @@ -690,3 +843,53 @@ struct HubModeWarning: View { } } } + +/// A compact line on Home while LF Hub mode is selected and the hub does not drive the +/// alarms; tapping it opens the LF Hub page. +struct HubStatusBanner: View { + let onTap: () -> Void + + @ObservedObject private var backgroundRefreshType = Storage.shared.backgroundRefreshType + @ObservedObject private var hubConfirmed = HubClient.shared.hubConfirmed + @ObservedObject private var hubProblems = HubClient.shared.hubProblems + @ObservedObject private var notificationsAllowed = HubClient.shared.notificationsAllowed + @ObservedObject private var looksHealthy = HubWatchdog.looksHealthy + + var body: some View { + if HubModeWarning.isShown { + Button(action: onTap) { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + Text("LF Hub isn't sending alarms. LoopFollow is alarming by itself.") + .font(.footnote.weight(.semibold)) + .multilineTextAlignment(.leading) + Spacer(minLength: 0) + Image(systemName: "chevron.right").font(.footnote) + } + .foregroundColor(.white) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color.orange, in: RoundedRectangle(cornerRadius: 10)) + } + .buttonStyle(.plain) + .padding([.horizontal, .top], 8) + } + } +} + +/// The LF Hub page on its own, as Home opens it from the banner. +struct HubSettingsSheet: View { + let onDone: () -> Void + + var body: some View { + NavigationStack { + HubSettingsView() + .navigationDestination(for: SettingsRoute.self) { $0.destination } + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done", action: onDone) + } + } + } + } +} diff --git a/LoopFollow/Hub/HubSourceSync.swift b/LoopFollow/Hub/HubSourceSync.swift index 0ac2f1d30..aa69c92e7 100644 --- a/LoopFollow/Hub/HubSourceSync.swift +++ b/LoopFollow/Hub/HubSourceSync.swift @@ -68,8 +68,9 @@ struct LocalSources: Equatable { /// hub the same, so the app's own fallback never follows the patient with old credentials. /// The hub counts each change of the sources in `sourceRevision`; a revision this app has /// not applied brings the hub's sources here. A change made in this app goes to the hub -/// from an admin phone that did not join with an invite once the app's own check of the -/// new source succeeds; on any other phone the hub's sources are put back. +/// once the app's own check of the new source succeeds: from an admin phone that did not +/// join with an invite, and from any admin phone while the user is making a change they +/// confirmed. On a member phone the hub's sources are put back. @MainActor final class HubSourceSync: ObservableObject { static let shared = HubSourceSync() @@ -91,6 +92,12 @@ final class HubSourceSync: ObservableObject { private var busy = false /// The settings changed while busy; looked at again when done. private var changedWhileBusy = false + /// An admin confirmed changing the data source here, so the change goes to the hub even + /// from a phone that joined with an invite. Lasts while the settings are open and + /// `confirmedChangeGrace` after, so the last edit still settles. + private var changeConfirmed = false + private var editingSession = 0 + private static let confirmedChangeGrace: UInt64 = 5 private var cancellables = Set() private var client: HubClient { HubClient.shared } @@ -98,9 +105,19 @@ final class HubSourceSync: ObservableObject { /// The hub keeps the patient's data sources in step across the devices. var isActive: Bool { client.offers("sourceRevision") } + /// A member does not import settings on the data source screens: the hub's data source + /// would replace them. + var hidesImport: Bool { isActive && client.isMember } + /// This app's settings are not the hub's sources as this app last saw them. var differsFromHub: Bool { LocalSources.stored().digest != syncedDigest.value } + /// Whether a change of the settings goes to the hub: always from an admin that did not + /// join with an invite, and from any admin on a change the user confirmed. + nonisolated static func sendsChange(isAdmin: Bool, joinedWithInvite: Bool, confirmed: Bool) -> Bool { + isAdmin && (!joinedWithInvite || confirmed) + } + /// Whether to take the hub's sources after a status: the hub counts revisions and /// this app has not applied the current one. nonisolated static func needsSources(hubRevision: Int?, applied: Int) -> Bool { @@ -142,7 +159,7 @@ final class HubSourceSync: ObservableObject { /// could not be asked. func follow(_ status: HubClient.Status) async { let hubHasSources = status.patient.nightscoutHost != nil || status.patient.dexcom == true - let memberDiffers = isActive && client.isConnected && !client.sendsOwnSources && hubHasSources && differsFromHub + let memberDiffers = isActive && client.isConnected && !client.isAdmin && hubHasSources && differsFromHub guard Self.needsSources(hubRevision: status.patient.sourceRevision, applied: appliedRevision.value) || memberDiffers, !busy @@ -201,17 +218,41 @@ final class HubSourceSync: ObservableObject { /// Checks this app's settings again and sends them to the hub; for an admin after a /// failed check or a refused change. func retry() async { - await settingsChanged() + await settingsChanged(confirmed: true) + } + + /// Puts the hub's sources back in this app's settings; for an admin whose change did + /// not reach the hub. + func undo() async { + do { + try take(await client.sources()) + state = .idle + LogManager.shared.log(category: .hub, message: "Data source change undone; the hub's sources are back") + } catch { + state = .failed("LF Hub could not be reached to bring back its data source. \(HubClient.userMessage(for: error))") + LogManager.shared.log(category: .hub, message: "Hub data sources not taken for undo: \(error.localizedDescription)") + } } - /// An admin starts changing the settings here; `state` follows the coming change. + /// An admin confirmed changing the settings here; `state` follows the coming change. func beginEditing() { state = .idle + changeConfirmed = true + editingSession += 1 + } + + /// The admin left the settings: a change made from now on is not a confirmed one. + func endEditing() { + let session = editingSession + Task { + try? await Task.sleep(nanoseconds: Self.confirmedChangeGrace * 1_000_000_000) + if editingSession == session { changeConfirmed = false } + } } - /// The settings changed in this app: an admin that did not join with an invite sends - /// them to the hub, any other phone gets the hub's back. - private func settingsChanged() async { + /// The settings changed in this app: they go to the hub as `sendsChange` says; + /// otherwise the hub's come back. + private func settingsChanged(confirmed: Bool? = nil) async { guard isActive, differsFromHub else { return } guard !busy else { changedWhileBusy = true @@ -219,10 +260,11 @@ final class HubSourceSync: ObservableObject { } busy = true defer { done() } - if client.sendsOwnSources { - await send(LocalSources.stored()) + let confirmed = confirmed ?? changeConfirmed + if Self.sendsChange(isAdmin: client.isAdmin, joinedWithInvite: client.joinedWithInvite.value, confirmed: confirmed) { + await send(LocalSources.stored(), confirmed: confirmed) } else { - LogManager.shared.log(category: .hub, message: "Data sources changed on a member phone; taking the hub's back") + LogManager.shared.log(category: .hub, message: "Data sources changed without a change this phone sends; taking the hub's back") do { try take(await client.sources()) } catch { @@ -240,7 +282,7 @@ final class HubSourceSync: ObservableObject { } /// Sends the settings to the hub once the app's own check of them succeeds. - private func send(_ sources: LocalSources) async { + private func send(_ sources: LocalSources, confirmed: Bool) async { state = .checking if let problem = await Self.check(sources) { // A newer change is checked on its own. @@ -258,7 +300,7 @@ final class HubSourceSync: ObservableObject { } do { let before = appliedRevision.value - try await client.sendSources(sources) + try await client.sendSources(sources, confirmed: confirmed) syncedDigest.value = sources.digest state = .sent LogManager.shared.log(category: .hub, message: "Data source change sent to the hub") @@ -273,10 +315,9 @@ final class HubSourceSync: ObservableObject { } } - /// The hub's own words for a refusal. + /// A refusal in plain words. private static func reason(_ error: Error) -> String { - if case let HubClient.HubError.http(_, message) = error { return message } - return error.localizedDescription + HubClient.userMessage(for: error) } /// Why the settings cannot go to the hub, from the same checks the Nightscout and diff --git a/LoopFollow/Hub/HubWatchdog.swift b/LoopFollow/Hub/HubWatchdog.swift index 45b15c467..cf9e5fb36 100644 --- a/LoopFollow/Hub/HubWatchdog.swift +++ b/LoopFollow/Hub/HubWatchdog.swift @@ -18,7 +18,6 @@ enum HubWatchdog { private static let notificationID = "lfhub.watchdog" private static let alertID = UUID(uuidString: "6C1F5A2E-0B7D-4E4F-9F3A-48B1A1E0D0A1")! - private static let alertTitle = "⚠️ LF Hub not responding" private static let alertSymbol = "exclamationmark.icloud" private static let lastHealthy = StorageValue(key: "hubWatchdogLastHealthy", defaultValue: 0) @@ -38,6 +37,9 @@ enum HubWatchdog { case clearAlert } + /// `hubLooksHealthy` as of the last check, for views to observe. + static let looksHealthy = ObservableValue(default: true) + /// False once checks have failed for `unhealthyTolerance`: the app then runs its /// own alarm check, alongside the hub. static var hubLooksHealthy: Bool { @@ -76,12 +78,28 @@ enum HubWatchdog { State(lastHealthy: lastHealthy.value, unhealthySince: unhealthySince.value, alerted: alerted.value) } + /// The alert's title and text for why the hub is unhealthy. + static func alert(for cause: HubClient.Unhealthy?, minutes: Int) -> (title: String, body: String) { + switch cause { + case .keyRejected: + return ("⚠️ Apple rejects LF Hub's APN key", + "LF Hub's alarms cannot reach this phone, so LoopFollow alarms by itself while it runs. Open LoopFollow → Settings → LF Hub to fix it.") + case .noPushYet, .registrationRejected, .pushFailing: + return ("⚠️ LF Hub can't reach this phone", + "LF Hub answers, but its notifications have not reached this phone for \(minutes) minutes. LoopFollow alarms by itself while it runs. Open LoopFollow → Settings → LF Hub.") + case .noAnswer, nil: + return ("⚠️ LF Hub not responding", + "No working connection to LF Hub for \(minutes) minutes, so its alarms may not reach this phone. Open LoopFollow.") + } + } + @MainActor static func check() async { await HubClient.shared.readNotificationPermission() let hubMode = HubClient.shared.isHubMode - let health: HubClient.Health = hubMode ? await HubClient.shared.health() : .unknown let wasHealthy = hubLooksHealthy + let (health, cause): (HubClient.Health, HubClient.Unhealthy?) = + hubMode ? await HubClient.shared.healthCheck() : (.unknown, nil) let (state, action) = next(currentState, hubMode: hubMode, health: health, now: Date().timeIntervalSince1970) lastHealthy.value = state.lastHealthy @@ -95,29 +113,30 @@ enum HubWatchdog { if wasHealthy, !hubLooksHealthy { LogManager.shared.log(category: .hub, message: "Hub watchdog: hub unhealthy; the app alarms itself") } + if looksHealthy.value != hubLooksHealthy { looksHealthy.value = hubLooksHealthy } switch action { case .none: break case let .raiseAlert(minutes): - await raiseAlert(minutes: minutes) + await raiseAlert(alert(for: cause, minutes: minutes), minutes: minutes) case .clearAlert: clearAlert() LogManager.shared.log(category: .hub, message: hubMode ? "Hub watchdog: hub healthy again" : "Hub watchdog: alert cleared") } } - private static func raiseAlert(minutes: Int) async { - LogManager.shared.log(category: .hub, message: "Hub watchdog: alerting after \(minutes) min") + private static func raiseAlert(_ alert: (title: String, body: String), minutes: Int) async { + LogManager.shared.log(category: .hub, message: "Hub watchdog: alerting after \(minutes) min: \(alert.title)") #if !targetEnvironment(macCatalyst) if #available(iOS 26, *), - await HubAlarmKit.ringNotice(id: alertID, title: alertTitle, symbol: alertSymbol) + await HubAlarmKit.ringNotice(id: alertID, title: alert.title, symbol: alertSymbol) { return } #endif let content = UNMutableNotificationContent() - content.title = alertTitle - content.body = "No working connection to LF Hub for \(minutes) minutes, so its alarms may not reach this phone. Open LoopFollow." + content.title = alert.title + content.body = alert.body content.sound = .default content.interruptionLevel = .timeSensitive try? await UNUserNotificationCenter.current().add( diff --git a/LoopFollow/Nightscout/NightscoutSettingsView.swift b/LoopFollow/Nightscout/NightscoutSettingsView.swift index dcca90574..69fce9f8b 100644 --- a/LoopFollow/Nightscout/NightscoutSettingsView.swift +++ b/LoopFollow/Nightscout/NightscoutSettingsView.swift @@ -24,7 +24,9 @@ struct NightscoutSettingsView: View { continueSection } - importSection + if !HubSourceSync.shared.hidesImport { + importSection + } } .navigationDestination(isPresented: $showUnitsSetup) { UnitsOnboardingView { diff --git a/LoopFollow/Nightscout/NightscoutSettingsViewModel.swift b/LoopFollow/Nightscout/NightscoutSettingsViewModel.swift index 1065cc892..ef0785087 100644 --- a/LoopFollow/Nightscout/NightscoutSettingsViewModel.swift +++ b/LoopFollow/Nightscout/NightscoutSettingsViewModel.swift @@ -112,6 +112,21 @@ class NightscoutSettingsViewModel: ObservableObject { setupDebounce() checkNightscoutStatus() observeWebSocketState() + + // A change made elsewhere while the screen is open, such as LF Hub putting its + // data source back, shows in the fields. + Storage.shared.url.$value.merge(with: Storage.shared.token.$value) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in self?.followStorage() } + .store(in: &cancellables) + } + + private func followStorage() { + let storage = Storage.shared + if storage.url.value != nightscoutURL { nightscoutURL = storage.url.value } + if storage.token.value != NightscoutUtils.sanitizeConnectionInput(nightscoutToken) { + nightscoutToken = storage.token.value + } } private func setupDebounce() { @@ -356,7 +371,8 @@ class NightscoutSettingsViewModel: ObservableObject { authStatus = "Read" + (Storage.shared.nsWriteAuth.value ? " & Write" : "") } - nightscoutStatus = "OK (\(authStatus))" + // A member's token comes from LF Hub; its Nightscout permissions are not this phone's role. + nightscoutStatus = HubClient.shared.isMember ? "OK" : "OK (\(authStatus))" if nightscoutURL != initialURL || nightscoutToken != initialToken { NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil) diff --git a/LoopFollow/Onboarding/Steps/HubJoinStepView.swift b/LoopFollow/Onboarding/Steps/HubJoinStepView.swift index 9fb3127d9..03b4dc910 100644 --- a/LoopFollow/Onboarding/Steps/HubJoinStepView.swift +++ b/LoopFollow/Onboarding/Steps/HubJoinStepView.swift @@ -19,7 +19,11 @@ struct HubJoinStepView: View { @State private var page = Page.invite @State private var invite: HubClient.Invite? @State private var devices: [HubClient.Device] = [] + /// The patient's name the hub gave with the devices; absent on an older hub. + @State private var invitePatientName: String? @State private var join = HubClient.Join.new + /// A recently used device about to be replaced, with the question to ask first. + @State private var replaceWarning: (device: HubClient.Device, warning: String)? @State private var deviceName = UIDevice.current.model @State private var inviteLink = "" @State private var scanning = false @@ -74,6 +78,16 @@ struct HubJoinStepView: View { } .onAppear { reportProgress() } .onChange(of: page) { reportProgress() } + .alert(replaceWarning.map { "Replace \($0.device.name)?" } ?? "", + isPresented: Binding(get: { replaceWarning != nil }, set: { if !$0 { replaceWarning = nil } })) + { + Button("Cancel", role: .cancel) {} + Button("Replace", role: .destructive) { + if let device = replaceWarning?.device { join = .replace(device.id) } + } + } message: { + Text(replaceWarning?.warning ?? "") + } } // MARK: - Pages @@ -108,6 +122,7 @@ struct HubJoinStepView: View { .keyboardType(.URL) .textInputAutocapitalization(.never) .autocorrectionDisabled() + .onChange(of: inviteLink) { if inviteLink.isEmpty { errorText = nil } } if busy { HStack { ProgressView() @@ -127,8 +142,16 @@ struct HubJoinStepView: View { Spacer() Text(HubSettingsView.host(invite)).foregroundColor(.secondary) } + if let invitePatientName { + HStack { + Text("Patient") + Spacer() + Text(invitePatientName).foregroundColor(.secondary) + } + } } footer: { - Text("Join \(HubSettingsView.host(invite))? Only join a hub you know.") + Text(invitePatientName.map { "Join \(HubSettingsView.host(invite)) to follow \($0)? Only join a hub you know." } + ?? "Join \(HubSettingsView.host(invite))? Only join a hub you know.") } } } @@ -146,6 +169,13 @@ struct HubJoinStepView: View { ForEach(devices.filter { $0.role != .admin }) { device in choiceRow("Replace \(device.name)", detail: "This replaces my old phone. It takes over its alarms and settings.", .replace(device.id)) + { + if let warning = HubClient.recentUseWarning(device, now: Date()) { + replaceWarning = (device, warning) + } else { + join = .replace(device.id) + } + } } choiceRow("Start fresh", detail: "A new phone with its own alarms.", .new) } @@ -174,9 +204,11 @@ struct HubJoinStepView: View { + (viewModel.hubJoin == .new ? "" : " Its alarms and settings came along.")) } - private func choiceRow(_ title: String, detail: String, _ choice: HubClient.Join) -> some View { + private func choiceRow(_ title: String, detail: String, _ choice: HubClient.Join, + onSelect: (() -> Void)? = nil) -> some View + { Button { - join = choice + if let onSelect { onSelect() } else { join = choice } } label: { HStack { VStack(alignment: .leading, spacing: 2) { @@ -252,7 +284,8 @@ struct HubJoinStepView: View { /// Shows why a step failed; an invite the hub does not take sends the user back to scan another. private func failed(_ error: Error) { - errorText = error.localizedDescription + LogManager.shared.log(category: .hub, message: "Joining with an invite failed: \(error.localizedDescription)") + errorText = HubClient.userMessage(for: error) switch error { case HubClient.HubError.inviteNotValid: canRetry = false @@ -273,7 +306,9 @@ struct HubJoinStepView: View { busy = true defer { busy = false } do { - devices = try await HubClient.shared.joinableDevices(invite: invite) + let list = try await HubClient.shared.joinableDevices(invite: invite) + devices = list.devices + invitePatientName = list.patientName self.invite = invite inviteLink = invite.link join = .new diff --git a/LoopFollow/Settings/DexcomSettingsView.swift b/LoopFollow/Settings/DexcomSettingsView.swift index 768e3572d..298dd136e 100644 --- a/LoopFollow/Settings/DexcomSettingsView.swift +++ b/LoopFollow/Settings/DexcomSettingsView.swift @@ -38,7 +38,9 @@ struct DexcomSettingsView: View { } } - importSection + if !HubSourceSync.shared.hidesImport { + importSection + } } .navigationDestination(isPresented: $showUnitsSetup) { UnitsOnboardingView { diff --git a/LoopFollow/Settings/DexcomSettingsViewModel.swift b/LoopFollow/Settings/DexcomSettingsViewModel.swift index 60b3a60cb..eec7b915a 100644 --- a/LoopFollow/Settings/DexcomSettingsViewModel.swift +++ b/LoopFollow/Settings/DexcomSettingsViewModel.swift @@ -79,6 +79,21 @@ class DexcomSettingsViewModel: ObservableObject { .store(in: &cancellables) scheduleVerification() + + // A change made elsewhere while the screen is open, such as LF Hub putting its + // data source back, shows in the fields. + Storage.shared.shareUserName.$value + .merge(with: Storage.shared.sharePassword.$value, Storage.shared.shareServer.$value) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in self?.followStorage() } + .store(in: &cancellables) + } + + private func followStorage() { + let storage = Storage.shared + if storage.shareUserName.value != userName { userName = storage.shareUserName.value } + if storage.sharePassword.value != password { password = storage.sharePassword.value } + if storage.shareServer.value != server { server = storage.shareServer.value } } /// Resets status to "checking" and queues a debounced verification. diff --git a/LoopFollow/Settings/SettingsMenuView.swift b/LoopFollow/Settings/SettingsMenuView.swift index dbde446c9..58674b26f 100644 --- a/LoopFollow/Settings/SettingsMenuView.swift +++ b/LoopFollow/Settings/SettingsMenuView.swift @@ -6,9 +6,14 @@ import UIKit struct SettingsMenuView: View { @ObservedObject private var nightscoutURL = Storage.shared.url + @ObservedObject private var backgroundRefreshType = Storage.shared.backgroundRefreshType + @ObservedObject private var hubConfirmed = HubClient.shared.hubConfirmed var body: some View { List { + if HubModeWarning.isShown { + Section { HubModeWarning() } + } ForEach(SettingsRoute.menuSections(nightscoutConfigured: !nightscoutURL.value.isEmpty), id: \.0) { section, routes in Section(section.rawValue) { ForEach(routes) { route in @@ -27,7 +32,7 @@ struct SettingsMenuView: View { // MARK: – Sheet routing enum SettingsSection: String, CaseIterable, Hashable { - case hub = "LoopFollow Hub" + case hub = "LF Hub" case data = "Data Settings" case display = "Display Settings" case app = "App Settings" diff --git a/LoopFollow/ViewControllers/MainHomeView.swift b/LoopFollow/ViewControllers/MainHomeView.swift index 23ee1e191..7e5dbafd7 100644 --- a/LoopFollow/ViewControllers/MainHomeView.swift +++ b/LoopFollow/ViewControllers/MainHomeView.swift @@ -21,6 +21,7 @@ struct MainHomeView: View { var onRefresh: (() -> Void)? var onStatsTap: (() -> Void)? + var onHubBannerTap: (() -> Void)? private var timeZoneOverride: String? { guard graphTimeZoneEnabled.value, @@ -34,6 +35,14 @@ struct MainHomeView: View { } var body: some View { + VStack(spacing: 0) { + HubStatusBanner { onHubBannerTap?() } + + homeContent + } + } + + private var homeContent: some View { VStack(spacing: 8) { // Top section: BG display + info table HStack(spacing: 10) { diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index b25de4998..e14818fc2 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -159,7 +159,8 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { infoManager: infoManager, statsModel: statsDisplayModel, onRefresh: { [weak self] in self?.refresh() }, - onStatsTap: { [weak self] in self?.statsViewTapped() } + onStatsTap: { [weak self] in self?.statsViewTapped() }, + onHubBannerTap: { [weak self] in self?.showHubSettings() } ) let hosting = UIHostingController(rootView: mainView) // Exclude the keyboard from the hosting controller's safe area. Home has @@ -714,6 +715,7 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { // ran during a BGAppRefreshTask background launch and deferred migrations. runMigrationsIfNeeded() HubBundle.shared.startListening() + HubClient.shared.retryUnregistrationWhileActive() Task { await HubClient.shared.readNotificationPermission() await HubClient.shared.sync() @@ -1181,6 +1183,17 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { setupDexcomButton?.isHidden = true } + /// Opens the LF Hub page from Home's banner. + private func showHubSettings() { + guard presentedViewController == nil else { return } + let hostingController = UIHostingController(rootView: HubSettingsSheet { [weak self] in + self?.dismiss(animated: true) + }) + hostingController.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle + hostingController.modalPresentationStyle = .pageSheet + present(hostingController, animated: true) + } + @objc private func setupNightscoutTapped() { let navController = UINavigationController() let nightscoutSettingsView = NightscoutSettingsView(viewModel: .init(), usesModalCloseButton: true, onContinueToUnits: { [weak navController] in diff --git a/LoopFollowNotificationService/NotificationService.swift b/LoopFollowNotificationService/NotificationService.swift index 818fa7098..6ed13af4b 100644 --- a/LoopFollowNotificationService/NotificationService.swift +++ b/LoopFollowNotificationService/NotificationService.swift @@ -69,14 +69,21 @@ final class NotificationService: UNNotificationServiceExtension { let receipt = userInfo["lfhubReceipt"] as? String let ringsSystemAlarm = delivery == .systemAlarm - let sound = userInfo["lfhubSound"] as? String + let named = userInfo["lfhubSound"] as? String + // A sound the app has not put in the App Group rings as the default sound. + let sound = named.flatMap { Self.soundExists($0) ? $0 : nil } + if let named, sound == nil { + Self.log("alarm \(alarmID.uuidString.prefix(8)) sound \(named) missing, ringing the default sound") + } + let acknowledge = request.content.categoryIdentifier == HubAppGroup.acknowledgeCategory let symbol = userInfo["lfhubIcon"] as? String ?? "moon.zzz" let ringOnce = (userInfo["lfhubRingOnce"] as? Int) == 1 let started = Date() Task { async let receiptSent: Void = Self.sendReceipt(receipt, alarmID: alarmID) if ringsSystemAlarm { - let (ringing, firesAt) = await Self.ring(alarmID: alarmID, title: content.title, sound: sound, symbol: symbol) + let (ringing, firesAt) = await Self.ring(alarmID: alarmID, title: content.title, sound: sound, + symbol: symbol, acknowledge: acknowledge) if ringing { // The system alarm is the alert; the notification goes quietly to the list. content.sound = nil @@ -93,11 +100,14 @@ final class NotificationService: UNNotificationServiceExtension { /// Rings the hub alarm as an AlarmKit alarm: whether it rings, and when it starts if /// this push scheduled it. - private static func ring(alarmID: UUID, title: String, sound: String?, symbol: String) async -> (Bool, Date?) { + private static func ring(alarmID: UUID, title: String, sound: String?, symbol: String, + acknowledge: Bool) async -> (Bool, Date?) + { #if canImport(AlarmKit) if #available(iOS 26, *) { - let result = await HubAlarmKit.ring(alarmID: alarmID, title: title, soundName: sound, symbol: symbol) - log("alarm \(alarmID.uuidString.prefix(8)) sound=\(sound ?? "default") soundFile=\(soundExists(sound)) -> \(result.detail)") + let result = await HubAlarmKit.ring(alarmID: alarmID, title: title, soundName: sound, symbol: symbol, + acknowledge: acknowledge) + log("alarm \(alarmID.uuidString.prefix(8)) sound=\(sound ?? "default") button=\(HubAlarmKit.buttonTitle(acknowledge: acknowledge)) -> \(result.detail)") return (result.ringing, result.firesAt) } #endif diff --git a/Tests/HubTests.swift b/Tests/HubTests.swift index dbd9f0a43..8959c6364 100644 --- a/Tests/HubTests.swift +++ b/Tests/HubTests.swift @@ -340,8 +340,23 @@ final class HubUnauthorizedProtocol: URLProtocol { override func stopLoading() {} } -@Suite(.serialized) +/// These tests change HubClient.shared's connection. Notices go nowhere, and the work +/// leaving a hub starts in the background (the bundle's long poll, removal retries) +/// stops after each test, so none reaches into the next. +@Suite(.serialized, .timeLimit(.minutes(1))) struct HubForgottenTests { + init() async { + await MainActor.run { HubClient.shared.postNotification = { _ in } } + } + + /// Stops what leaving or joining a hub left running. + static func stopBackgroundWork() async { + await MainActor.run { + HubBundle.shared.stopListening() + HubClient.shared.stopUnregistrationRetries() + } + } + @Test("a hub that no longer knows the device disconnects it and Hub mode falls back to Silent Tune") func forgotten() async throws { let storage = Storage.shared @@ -364,9 +379,13 @@ struct HubForgottenTests { await HubClient.shared.sync() - #expect(deviceKey.value.isEmpty) - #expect(storage.backgroundRefreshType.value == .silentTune) - #expect(!HubClient.shared.hubConfirmed.value) + // Leaving the hub runs on the main actor, also when the app's own fetch met the 401 first. + await MainActor.run { + #expect(deviceKey.value.isEmpty) + #expect(storage.backgroundRefreshType.value == .silentTune) + #expect(!HubClient.shared.hubConfirmed.value) + } + await Self.stopBackgroundWork() } } @@ -434,6 +453,7 @@ extension HubForgottenTests { client.pendingUnregistration.keep(key: "lfh_left", hubURL: "https://\(HubUnauthorizedProtocol.host)") await client.unregisterPending() #expect(client.pendingUnregistration.current == nil) + await Self.stopBackgroundWork() } @Test("a phone that joined with an invite does not send its own data sources, whatever its role") @@ -1081,3 +1101,325 @@ struct HubRemoteSecretTests { #expect(storage.sharedSecret.value == saved) } } + +/// Answers GET /v1/patient/source on `host` with the hub's sources, and 404 otherwise. +final class HubSourcesProtocol: URLProtocol { + static let host = "sources.lfhub-tests.invalid" + static let sources = #"{"nightscoutUrl":"https://ns.lfhub-tests.invalid","token":"hub-token","dexcom":null}"# + + override class func canInit(with request: URLRequest) -> Bool { + request.url?.host == host + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + let found = request.url?.path == "/v1/patient/source" + let response = HTTPURLResponse(url: request.url!, statusCode: found ? 200 : 404, httpVersion: nil, + headerFields: nil)! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data((found ? Self.sources : "{}").utf8)) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} +} + +extension HubForgottenTests { + @Test("any call the hub answers 401 leaves the hub at once, falls back to the app's alarms and tells the user") + func unauthorizedCall() async throws { + let storage = Storage.shared + let client = HubClient.shared + let saved = (storage.hubURL.value, client.deviceKey.store, storage.backgroundRefreshType.value, + client.removedFromHub.value) + client.deviceKey.store = MemoryKeyStore() + URLProtocol.registerClass(HubUnauthorizedProtocol.self) + defer { + URLProtocol.unregisterClass(HubUnauthorizedProtocol.self) + storage.hubURL.value = saved.0 + client.deviceKey.store = saved.1 + client.deviceKey.reload() + storage.backgroundRefreshType.value = saved.2 + client.removedFromHub.value = saved.3 + } + for call in ["health check", "bundle"] { + try await MainActor.run { + storage.hubURL.value = "https://\(HubUnauthorizedProtocol.host)" + try client.deviceKey.set("lfh_replaced") + storage.backgroundRefreshType.value = .hub + client.removedFromHub.value = false + } + + if call == "bundle" { + _ = try? await client.bundle(since: 0, wait: 0) + } else { + let (health, cause) = await client.healthCheck() + // Not a hub outage: the watchdog does not count it. + #expect(health == .unknown) + #expect(cause == nil) + } + + await MainActor.run { + #expect(!client.isConnected, "\(call)") + #expect(client.removedFromHub.value, "\(call)") + #expect(storage.backgroundRefreshType.value == .silentTune, "\(call)") + } + } + await Self.stopBackgroundWork() + } + + @Test("Undo puts the hub's data source back in this app's settings") + @MainActor func undoSourceChange() async throws { + let storage = Storage.shared + let client = HubClient.shared + let saved = (storage.hubURL.value, client.deviceKey.store, storage.url.value, storage.token.value, + storage.shareUserName.value, storage.sharePassword.value) + client.deviceKey.store = MemoryKeyStore() + URLProtocol.registerClass(HubSourcesProtocol.self) + defer { + URLProtocol.unregisterClass(HubSourcesProtocol.self) + storage.hubURL.value = saved.0 + client.deviceKey.store = saved.1 + client.deviceKey.reload() + storage.url.value = saved.2 + storage.token.value = saved.3 + storage.shareUserName.value = saved.4 + storage.sharePassword.value = saved.5 + HubSourceSync.shared.forget() + } + storage.hubURL.value = "https://\(HubSourcesProtocol.host)" + try client.deviceKey.set("lfh_admin") + storage.url.value = "https://wrong.example.com" + storage.token.value = "wrong" + storage.shareUserName.value = "" + storage.sharePassword.value = "" + + await HubSourceSync.shared.undo() + + #expect(storage.url.value == "https://ns.lfhub-tests.invalid") + #expect(storage.token.value == "hub-token") + #expect(!HubSourceSync.shared.differsFromHub) + #expect(HubSourceSync.shared.state == .idle) + await Self.stopBackgroundWork() + } +} + +struct HubPushFailureTests { + private let now = ISO8601DateFormatter().date(from: "2026-01-01T12:00:00Z")! + + private func status(lastPushStatus: String?, pushFailingSince: String? = "2026-01-01T11:59:00Z", + lastPushOkAt: String? = "2026-01-01T11:58:00Z", alarmCount: Int? = nil) throws -> HubClient.Status + { + var device: [String: Any] = [ + "name": "Phone", "apnsConfigured": true, "apnsKeyShared": true, "alarmsReceived": true, "settings": ["alarms": true], + "liveActivity": ["registered": true, "pushToStartRegistered": true, "renewalFailed": false], + ] + if let lastPushStatus { device["lastPushStatus"] = lastPushStatus } + if let pushFailingSince { device["pushFailingSince"] = pushFailingSince } + if let lastPushOkAt { device["lastPushOkAt"] = lastPushOkAt } + if let alarmCount { device["alarmCount"] = alarmCount } + let json: [String: Any] = [ + "hub": ["version": "0.2.0", "api": 1, "features": ["receipts"]], + "device": device, + "patient": ["name": "Patient", "nightscoutHost": "ns.example.com", "followed": true, "hasData": true], + "live": ["connected": true, "staleAfterSeconds": 600], + ] + return try HubClient.decoder.decode(HubClient.Status.self, from: JSONSerialization.data(withJSONObject: json)) + } + + private func problems(_ status: HubClient.Status, exportedAlarms: Int? = nil, alarmsPending: Bool = false) -> [String] { + HubClient.confirmationProblems(status, alarmsPending: alarmsPending, alarmKitAuthorized: true, + notificationsAllowed: true, now: now, exportedAlarms: exportedAlarms) + } + + @Test("Apple rejecting the APN key or the phone's registration makes the app alarm itself at once") + func rejectedAtOnce() throws { + let key = try status(lastPushStatus: "403 InvalidProviderToken") + #expect(HubClient.unhealthiness(of: key, now: now) == .keyRejected) + #expect(HubClient.health(of: key, now: now) == .unhealthy) + #expect(problems(key) == [HubClient.Problem.apnKeyRejected]) + + let neverReached = try status(lastPushStatus: "403 InvalidProviderToken", lastPushOkAt: nil) + #expect(HubClient.unhealthiness(of: neverReached, now: now) == .keyRejected) + + let registration = try status(lastPushStatus: "400 BadDeviceToken") + #expect(HubClient.unhealthiness(of: registration, now: now) == .registrationRejected) + #expect(problems(registration) == [HubClient.Problem.pushFailing]) + #expect(try HubClient.unhealthiness(of: status(lastPushStatus: "410 Unregistered"), now: now) == .registrationRejected) + } + + @Test("a failure that may pass is tolerated for a while, and an accepted push clears it") + func passingFailure() throws { + #expect(try HubClient.unhealthiness(of: status(lastPushStatus: "503 ServiceUnavailable"), now: now) == nil) + #expect(try HubClient.unhealthiness(of: status(lastPushStatus: "503 ServiceUnavailable", + pushFailingSince: "2026-01-01T11:45:00Z"), now: now) == .pushFailing) + #expect(try HubClient.unhealthiness(of: status(lastPushStatus: "403 InvalidProviderToken", + pushFailingSince: nil), now: now) == nil) + } + + @Test("the Ready list names the rejected key and sends the fix where the key was added") + func checklist() throws { + func push(_ status: HubClient.Status, ownApnKey: Bool) -> HubChecklist.Item? { + var facts = HubChecklist.Facts() + facts.notifications = .allowed + facts.ownApnKey = ownApnKey + facts.hubSharesKeys = true + return HubChecklist.items(status: status, error: nil, facts: facts, now: now).first { $0.id == .push } + } + let key = try status(lastPushStatus: "403 InvalidProviderToken") + #expect(push(key, ownApnKey: true)?.fix == .goToAPN) + #expect(push(key, ownApnKey: true)?.problem?.hasPrefix(HubClient.Problem.apnKeyRejected) == true) + #expect(push(key, ownApnKey: false)?.fix == .askAdmin) + #expect(try push(status(lastPushStatus: "400 BadDeviceToken"), ownApnKey: true)?.fix == .registerAgain) + #expect(try push(status(lastPushStatus: nil, pushFailingSince: nil), ownApnKey: true)?.isReady == true) + } + + @Test("the watchdog's alert names the real cause") + func watchdogAlert() { + #expect(HubWatchdog.alert(for: .noAnswer, minutes: 20).title == "⚠️ LF Hub not responding") + #expect(HubWatchdog.alert(for: nil, minutes: 20).title == "⚠️ LF Hub not responding") + #expect(HubWatchdog.alert(for: .keyRejected, minutes: 20).title.contains("APN key")) + #expect(HubWatchdog.alert(for: .pushFailing, minutes: 20).title == "⚠️ LF Hub can't reach this phone") + #expect(HubWatchdog.alert(for: .registrationRejected, minutes: 20).body.contains("20 minutes")) + } + + @Test("fewer alarms at the hub than this app exported leaves the alarms unconfirmed") + func skippedAlarms() throws { + let fine = try status(lastPushStatus: nil, pushFailingSince: nil, alarmCount: 3) + #expect(problems(fine, exportedAlarms: 3).isEmpty) + #expect(problems(fine, exportedAlarms: 2).isEmpty) + #expect(problems(fine, exportedAlarms: 4) == [HubClient.Problem.alarmsSkipped]) + #expect(!HubClient.confirms(fine, hubMode: true, alarmsPending: false, alarmKitAuthorized: true, + notificationsAllowed: true, now: now, exportedAlarms: 4)) + // An upload on its way is reported as that, and a hub without the count is not judged. + #expect(problems(fine, exportedAlarms: 4, alarmsPending: true) == [HubClient.Problem.alarmsNotReceived]) + #expect(try problems(status(lastPushStatus: nil, pushFailingSince: nil), exportedAlarms: 4).isEmpty) + #expect(HubClient.alarmCountForHub([], hubAPI: 1) == 0) + } +} + +struct HubClientBehaviourTests { + private enum Step: Equatable { case upload, handled } + + private final class Recorder: @unchecked Sendable { + var steps: [Step] = [] + } + + @Test("a snooze reaches the hub before the alarm is reported handled") + func handledAfterUpload() async throws { + let recorder = Recorder() + try await HubClient.reportHandled(upload: { recorder.steps.append(.upload) }, + handled: { recorder.steps.append(.handled) }) + #expect(recorder.steps == [.upload, .handled]) + + // An upload the hub refuses for good does not hold back the report. + let refused = Recorder() + try await HubClient.reportHandled(upload: { throw HubClient.HubError.http(400, "bad") }, + handled: { refused.steps.append(.handled) }) + #expect(refused.steps == [.handled]) + + // One that may pass fails the step, so it is retried before the report. + let offline = Recorder() + await #expect(throws: URLError.self) { + try await HubClient.reportHandled(upload: { throw URLError(.timedOut) }, + handled: { offline.steps.append(.handled) }) + } + #expect(offline.steps.isEmpty) + } + + @Test("errors read in plain words, never as the hub's raw answer") + func errorMessages() { + func message(_ code: Int, _ detail: String) -> String { + HubClient.userMessage(for: HubClient.HubError.http(code, detail)) + } + #expect(message(403, "unknown, used or expired invite") == HubClient.HubError.inviteNotValid.errorDescription) + #expect(message(409, "the patient must keep an admin device").contains("at least one admin")) + #expect(message(502, "APNs: 403 InvalidProviderToken") + .hasPrefix("Apple rejected the push: it does not accept the APN key")) + #expect(message(502, "APNs: 500 InternalServerError") == "Apple rejected the push: 500 InternalServerError") + #expect(message(429, "too many claims") == HubClient.HubError.tooManyTries.errorDescription) + #expect(message(403, "only an admin device can invite") == "Only an admin can do this.") + #expect(message(403, "an invite cannot take over an admin device").contains("cannot replace an admin's phone")) + #expect(message(503, "the patient is not followed yet").hasPrefix("LF Hub had a problem")) + for code in [400, 401, 403, 404, 409, 429, 500, 502, 503] { + #expect(!message(code, "detail").contains("Hub responded")) + } + #expect(HubClient.userMessage(for: URLError(.notConnectedToInternet)).contains("offline")) + } + + @Test("an invite is looked up when pasted or submitted, never while it is typed") + func inviteLookup() { + let link = "https://hub.example.com/join#abcdefghijklmnopqrstuvwxyz012345" + #expect(HubClient.Invite.pasted(from: "", to: link) == HubClient.Invite(link: link)) + #expect(HubClient.Invite.pasted(from: String(link.dropLast()), to: link) == nil) + #expect(HubClient.Invite.pasted(from: "", to: "https://hub.example.com/join#abc") == nil) + #expect(HubClient.Invite.pasted(from: "https://hub", to: "https://hub.example.com") == nil) + } + + @Test("the same upload right after the last is skipped, whatever the order of its keys") + func repeatedUploads() throws { + let uploads = RecentUploads() + let now = Date(timeIntervalSince1970: 1_700_000_000) + let first = try #require(RecentUploads.digest(body: ["a": 1, "b": [true]], rawBody: nil)) + let reordered = try #require(RecentUploads.digest(body: nil, rawBody: Data(#"{"b":[true],"a":1}"#.utf8))) + let other = try #require(RecentUploads.digest(body: ["a": 2, "b": [true]], rawBody: nil)) + #expect(first == reordered) + + #expect(!uploads.isRepeat("/v1/device/alarms", first, now: now)) + uploads.record("/v1/device/alarms", first, now: now) + #expect(uploads.isRepeat("/v1/device/alarms", reordered, now: now.addingTimeInterval(1))) + #expect(!uploads.isRepeat("/v1/device/alarms", other, now: now.addingTimeInterval(1))) + #expect(!uploads.isRepeat("/v1/device/settings", first, now: now.addingTimeInterval(1))) + #expect(!uploads.isRepeat("/v1/device/alarms", first, now: now.addingTimeInterval(RecentUploads.window))) + uploads.forget() + #expect(!uploads.isRepeat("/v1/device/alarms", first, now: now.addingTimeInterval(1))) + } + + @Test("replacing a phone used in the last 15 minutes asks first") + func recentUse() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + func device(lastSeen: TimeInterval?) throws -> HubClient.Device { + var json: [String: Any] = ["id": UUID().uuidString, "name": "Old iPhone", "createdAt": "2026-01-01T10:00:00Z"] + if let lastSeen { + json["lastSeenAt"] = ISO8601DateFormatter().string(from: now.addingTimeInterval(-lastSeen)) + } + return try HubClient.decoder.decode(HubClient.Device.self, from: JSONSerialization.data(withJSONObject: json)) + } + #expect(try HubClient.recentUseWarning(device(lastSeen: 120), now: now) + == "Old iPhone was used 2 minutes ago. Replace it anyway?") + #expect(try HubClient.recentUseWarning(device(lastSeen: 10), now: now) + == "Old iPhone was used just now. Replace it anyway?") + #expect(try HubClient.recentUseWarning(device(lastSeen: HubClient.recentUse), now: now) == nil) + #expect(try HubClient.recentUseWarning(device(lastSeen: nil), now: now) == nil) + } + + @Test("the devices offered when joining carry the patient's name when the hub sends it") + func joinPatientName() throws { + let named = #"{"patientName":"Nils","devices":[]}"# + #expect(try HubClient.decoder.decode(HubClient.DeviceList.self, from: Data(named.utf8)).patientName == "Nils") + #expect(try HubClient.decoder.decode(HubClient.DeviceList.self, from: Data(#"{"devices":[]}"#.utf8)).patientName == nil) + } + + @Test("an admin that joined with an invite sends a data source change only when the user confirmed it") + func confirmedSourceChange() { + #expect(HubSourceSync.sendsChange(isAdmin: true, joinedWithInvite: false, confirmed: false)) + #expect(!HubSourceSync.sendsChange(isAdmin: true, joinedWithInvite: true, confirmed: false)) + #expect(HubSourceSync.sendsChange(isAdmin: true, joinedWithInvite: true, confirmed: true)) + #expect(!HubSourceSync.sendsChange(isAdmin: false, joinedWithInvite: true, confirmed: true)) + } + + @Test("times within a minute of now read as just now") + func relativeTimes() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + #expect(HubSettingsView.relative(now.addingTimeInterval(-5), now: now) == "just now") + #expect(HubSettingsView.relative(now, now: now) == "just now") + #expect(HubSettingsView.relative(now.addingTimeInterval(-600), now: now) != "just now") + } + + @Test("an alarm whose snooze is 0 is acknowledged, as the app's own action for it reads") + func acknowledgeCategory() { + #expect(HubAppGroup.acknowledgeCategory == "lfhub-ack") + } +} From f8ad6c4c0ff697575cefd83cbc0aca860318d69a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Wed, 28 Oct 2026 08:18:17 +0100 Subject: [PATCH 4/9] Adopt a push-to-start Live Activity that a refresh already bound, keep the tracked activity on start and end the others, and log system alarm snoozes and handled reports --- LoopFollow/Application/AppDelegate.swift | 4 +- LoopFollow/Hub/HubClient.swift | 2 + .../LiveActivity/LiveActivityManager.swift | 39 +++++++++++++++++-- 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index ab696ca26..b021797d4 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -14,7 +14,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { ExitDiagnostics.shared.start() HubClient.shared.observeChanges() HubAlarmSnoozeBridge.shared.setHandler { @MainActor alarmID in - guard Storage.shared.alarms.value.contains(where: { $0.id == alarmID }) else { return } + let known = Storage.shared.alarms.value.contains(where: { $0.id == alarmID }) + LogManager.shared.log(category: .alarm, message: "System alarm snooze tapped for \(alarmID) (known=\(known), current=\(Observable.shared.currentAlarm.value?.uuidString ?? "nil"))") + guard known else { return } Observable.shared.currentAlarm.value = alarmID AlarmManager.shared.performSnooze() } diff --git a/LoopFollow/Hub/HubClient.swift b/LoopFollow/Hub/HubClient.swift index 312b15372..a370257de 100644 --- a/LoopFollow/Hub/HubClient.swift +++ b/LoopFollow/Hub/HubClient.swift @@ -1060,9 +1060,11 @@ final class HubClient { /// never takes the alarm as handled without its snooze. func alarmHandled(_ alarmID: UUID) { guard isHubMode else { return } + LogManager.shared.log(category: .hub, message: "Alarm \(alarmID) handled here (connected=\(isConnected)), reporting to the hub") uploadSoon { client in try await Self.reportHandled(upload: { try await client.uploadAlarms() }, handled: { _ = try await client.send("POST", "/v1/device/alarm/\(alarmID.uuidString)/handled") }) + LogManager.shared.log(category: .hub, message: "Alarm \(alarmID) reported handled to the hub") } } diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index afd0b4f33..683fdb887 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -111,8 +111,18 @@ final class LiveActivityManager { @MainActor private func adoptPushToStartActivity(_ activity: Activity) { + if endedOrphanIDs.contains(activity.id) { + LogManager.shared.log( + category: .general, + message: "[LA] activityUpdates: ignoring ended orphan id=\(activity.id)" + ) + return + } // Skip if it's the activity we already track (app-initiated path binds it directly). - if current?.id == activity.id { + // One that a refresh or start bound as an existing activity still gets adopted, + // since it may be a push-to-start activity that needs its own deadline. + let boundAsExisting = currentBindReason == "bind-existing" || currentBindReason == "reuse" + if current?.id == activity.id, !boundAsExisting { LogManager.shared.log( category: .general, message: "[LA] activityUpdates: ignoring own activity id=\(activity.id) (already current)" @@ -147,6 +157,11 @@ final class LiveActivityManager { Task { await oldActivity.end(nil, dismissalPolicy: .immediate) } + } else if current?.id == activity.id { + LogManager.shared.log( + category: .general, + message: "[LA] activityUpdates: adopting current activity id=\(activity.id) (bound via \(currentBindReason))" + ) } else { LogManager.shared.log( category: .general, @@ -155,7 +170,7 @@ final class LiveActivityManager { } // An activity bound before this launch keeps its deadline, since iOS counts its // 8 hours from when it started; one started through push-to-start gets a fresh one. - let boundBeforeLaunch = Storage.shared.laBoundActivityId.value == activity.id + let boundBeforeLaunch = (launchBoundActivityId ?? Storage.shared.laBoundActivityId.value) == activity.id && Storage.shared.laRenewBy.value > 0 if boundBeforeLaunch { LogManager.shared.log( @@ -445,6 +460,12 @@ final class LiveActivityManager { private static let pushToStartTokenRetryDelay: TimeInterval = 10 private(set) var current: Activity? + /// The `logReason` of the bind that made `current` the tracked activity. + private var currentBindReason = "" + /// The activity bound when the previous launch ended, read before this launch's first bind. + private var launchBoundActivityId: String? + /// Activities ended as orphans; `activityUpdates` may still deliver them. + private var endedOrphanIDs: Set = [] /// Push token of the activity currently tracked, as reported to LF Hub. var currentPushToken: String? { @@ -522,7 +543,10 @@ final class LiveActivityManager { // If an activity is already running and not stale we adopt/reuse it // (covers warm starts where the LA survived a relaunch); only truly new // starts go through createActivity (local when foreground, APNs otherwise). - if let existing = Activity.activities.first { + // The tracked activity comes first, then any active one; the others are orphans. + let activities = Activity.activities + let tracked = activities.first { $0.id == current?.id && $0.activityState == .active } + if let existing = tracked ?? activities.first(where: { $0.activityState == .active }) ?? activities.first { let renewBy = Storage.shared.laRenewBy.value let now = Date().timeIntervalSince1970 let staleDatePassed = existing.content.staleDate.map { $0 <= Date() } ?? false @@ -543,6 +567,7 @@ final class LiveActivityManager { } bind(to: existing, logReason: "reuse") Storage.shared.laRenewalFailed.value = false + endOrphanedActivities() return } LogManager.shared.log( @@ -550,6 +575,9 @@ final class LiveActivityManager { message: "[LA] existing activity is stale on startIfNeeded — replacing (staleDatePassed=\(staleDatePassed), inRenewalWindow=\(inRenewalWindow))" ) createActivity(reason: startReason, oldActivity: existing) + if currentBindReason.hasPrefix("local-create") { + endOrphanedActivities() + } return } createActivity(reason: startReason, oldActivity: nil) @@ -1220,6 +1248,7 @@ final class LiveActivityManager { for activity in Activity.activities { guard activity.id != current?.id else { continue } let orphanID = activity.id + endedOrphanIDs.insert(orphanID) Task { await activity.end(nil, dismissalPolicy: .immediate) LogManager.shared.log(category: .general, message: "Ended orphaned Live Activity id=\(orphanID)") @@ -1229,7 +1258,11 @@ final class LiveActivityManager { private func bind(to activity: Activity, logReason: String) { if current?.id == activity.id { return } + if launchBoundActivityId == nil { + launchBoundActivityId = Storage.shared.laBoundActivityId.value + } current = activity + currentBindReason = logReason Storage.shared.laBoundActivityId.value = activity.id let wasEndingForRestart = endingForRestart dismissedByUser = false From 1345a6bd645f81ab2aa319c06da610e9e7195111 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Wed, 28 Oct 2026 20:01:37 +0100 Subject: [PATCH 5/9] Clear the alarm title override when the hub stops its alarm --- LoopFollow/Hub/HubClient.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/LoopFollow/Hub/HubClient.swift b/LoopFollow/Hub/HubClient.swift index a370257de..7a82aeb13 100644 --- a/LoopFollow/Hub/HubClient.swift +++ b/LoopFollow/Hub/HubClient.swift @@ -1100,6 +1100,7 @@ final class HubClient { guard drivesAlarms, Observable.shared.currentAlarm.value == alarmID else { return } LogManager.shared.log(category: .hub, message: "Hub alarm \(alarmID.uuidString.prefix(8)) stopped at the hub") Observable.shared.currentAlarm.value = nil + Observable.shared.currentAlarmTitleOverride.value = nil } /// The hub's view of this device, recorded as a sync records it. From 26cc6f399f72a3b6d9ce6daea3a686bc352929ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Wed, 28 Oct 2026 20:01:37 +0100 Subject: [PATCH 6/9] Let the watch fetch on its own while the hub drives the alarms --- LoopFollow/Watch/WatchCredentialsBuilder.swift | 3 ++- LoopFollow/Watch/WatchSessionManager.swift | 5 +++++ LoopFollowWatch/WatchSession.swift | 5 +++-- RemoteShared/WatchCredentials.swift | 4 ++++ 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/LoopFollow/Watch/WatchCredentialsBuilder.swift b/LoopFollow/Watch/WatchCredentialsBuilder.swift index 52ef3965d..dbb0f7654 100644 --- a/LoopFollow/Watch/WatchCredentialsBuilder.swift +++ b/LoopFollow/Watch/WatchCredentialsBuilder.swift @@ -12,7 +12,8 @@ enum WatchCredentialsBuilder { dexcomUsername: Storage.shared.shareUserName.value, dexcomPassword: Storage.shared.sharePassword.value, dexcomServer: Storage.shared.shareServer.value, - remote: remote() + remote: remote(), + phoneSleeps: BackgroundRefreshType.inEffect == .hub ) } diff --git a/LoopFollow/Watch/WatchSessionManager.swift b/LoopFollow/Watch/WatchSessionManager.swift index 08a9aacb1..8bf36e20d 100644 --- a/LoopFollow/Watch/WatchSessionManager.swift +++ b/LoopFollow/Watch/WatchSessionManager.swift @@ -81,6 +81,11 @@ final class WatchSessionManager: NSObject { Storage.shared.sharedSecret.$value.map { _ in () }.eraseToAnyPublisher(), Storage.shared.loopAPNSQrCodeURL.$value.map { _ in () }.eraseToAnyPublisher(), Observable.shared.loopFollowDeviceToken.$value.map { _ in () }.eraseToAnyPublisher(), + // What decides whether the hub drives the alarms (`BackgroundRefreshType.inEffect`). + Storage.shared.backgroundRefreshType.$value.map { _ in () }.eraseToAnyPublisher(), + HubClient.shared.hubConfirmed.$value.map { _ in () }.eraseToAnyPublisher(), + HubClient.shared.notificationsAllowed.$value.map { _ in () }.eraseToAnyPublisher(), + HubWatchdog.looksHealthy.$value.map { _ in () }.eraseToAnyPublisher(), ] Publishers.MergeMany(credentialTriggers) .debounce(for: .seconds(2), scheduler: DispatchQueue.main) diff --git a/LoopFollowWatch/WatchSession.swift b/LoopFollowWatch/WatchSession.swift index c6101921b..4d4a6db68 100644 --- a/LoopFollowWatch/WatchSession.swift +++ b/LoopFollowWatch/WatchSession.swift @@ -61,10 +61,11 @@ final class WatchSession: NSObject, ObservableObject { }, errorHandler: nil) } - /// Fetches from Nightscout or Dexcom when the phone's data is stale and the phone does not answer. + /// Fetches from Nightscout or Dexcom when the phone's data is stale and the phone does not answer + /// or sleeps in LoopFollow Hub mode. func refreshIfNeeded() async { guard !isPhoneDataFresh else { return } - if session.activationState == .activated, session.isReachable { + if credentials?.phoneSleeps != true, session.activationState == .activated, session.isReachable { requestState() try? await Task.sleep(nanoseconds: 8_000_000_000) guard !isPhoneDataFresh else { return } diff --git a/RemoteShared/WatchCredentials.swift b/RemoteShared/WatchCredentials.swift index 4d3a30d9d..2830e858a 100644 --- a/RemoteShared/WatchCredentials.swift +++ b/RemoteShared/WatchCredentials.swift @@ -20,6 +20,10 @@ struct WatchCredentials: Codable, Equatable { var dexcomServer: String /// Present only when remote commands from the watch are allowed. var remote: Remote? + /// The phone runs in LoopFollow Hub mode with the hub driving the alarms, so it + /// may be suspended and its data goes stale: the watch fetches on its own + /// without asking the phone first. + var phoneSleeps: Bool? = nil struct Remote: Codable, Equatable { var kind: WatchRemoteConfig.Kind From 5489ca79fc9bfe1e17c061bf98a7fc52828786f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 1 Nov 2026 10:52:45 +0100 Subject: [PATCH 7/9] Show the LF Hub version next to the newest one for the hub's channel, and remind an admin phone every two weeks while a newer hub is out --- LoopFollow/Helpers/AppVersionManager.swift | 6 +-- LoopFollow/Hub/HubClient.swift | 3 ++ LoopFollow/Hub/HubSettingsView.swift | 16 +++++-- LoopFollow/Hub/HubVersionManager.swift | 47 +++++++++++++++++++ LoopFollow/Storage/Storage.swift | 4 ++ .../ViewControllers/MainViewController.swift | 19 ++++++++ Tests/HubTests.swift | 43 +++++++++++++++++ 7 files changed, 131 insertions(+), 7 deletions(-) create mode 100644 LoopFollow/Hub/HubVersionManager.swift diff --git a/LoopFollow/Helpers/AppVersionManager.swift b/LoopFollow/Helpers/AppVersionManager.swift index 03df38d0e..2898a9ab5 100644 --- a/LoopFollow/Helpers/AppVersionManager.swift +++ b/LoopFollow/Helpers/AppVersionManager.swift @@ -37,7 +37,7 @@ class AppVersionManager { if let cachedVersion = cachedForVersion, cachedVersion == currentVersion, now.timeIntervalSince(latestVersionChecked) < 24 * 3600, let latestVersion = latestVersion { - let isNewer = isVersion(latestVersion, newerThan: currentVersion) + let isNewer = Self.isVersion(latestVersion, newerThan: currentVersion) completion(latestVersion, isNewer, currentVersionBlackListed) return } @@ -52,7 +52,7 @@ class AppVersionManager { DispatchQueue.main.async { let fetchedVersion = versionData.flatMap { String(data: $0, encoding: .utf8) } .flatMap { self.parseVersionFromConfig(contents: $0) } - let isNewer = fetchedVersion.map { self.isVersion($0, newerThan: currentVersion) } ?? false + let isNewer = fetchedVersion.map { Self.isVersion($0, newerThan: currentVersion) } ?? false let isBlacklisted = (try? blacklistData.flatMap { try JSONDecoder().decode(Blacklist.self, from: $0) }) .map { $0.blacklistedVersions.map { $0.version }.contains(currentVersion) } ?? false @@ -83,7 +83,7 @@ class AppVersionManager { return nil } - private func isVersion(_ fetchedVersion: String, newerThan currentVersion: String) -> Bool { + static func isVersion(_ fetchedVersion: String, newerThan currentVersion: String) -> Bool { let fetchedVersionComponents = fetchedVersion.split(separator: ".").map { Int($0) ?? 0 } let currentVersionComponents = currentVersion.split(separator: ".").map { Int($0) ?? 0 } diff --git a/LoopFollow/Hub/HubClient.swift b/LoopFollow/Hub/HubClient.swift index 7a82aeb13..a9dccdb49 100644 --- a/LoopFollow/Hub/HubClient.swift +++ b/LoopFollow/Hub/HubClient.swift @@ -210,6 +210,9 @@ final class HubClient { let version: String let api: Int let features: [String] + /// The image tag the hub was published under (`latest` or `dev`); absent on a + /// hub built by its owner and on a hub older than the channel. + var channel: String? = nil } let hub: Hub? diff --git a/LoopFollow/Hub/HubSettingsView.swift b/LoopFollow/Hub/HubSettingsView.swift index 20dd29789..72bbaf3e7 100644 --- a/LoopFollow/Hub/HubSettingsView.swift +++ b/LoopFollow/Hub/HubSettingsView.swift @@ -38,6 +38,8 @@ struct HubSettingsView: View { @State private var scanningInvite = false @State private var status: HubClient.Status? + /// The newest LF Hub for the hub's channel; nil until known. + @State private var latestHubVersion: String? @State private var statusError: String? @State private var devices: [HubClient.Device] = [] @State private var deviceToRemove: HubClient.Device? @@ -117,6 +119,10 @@ struct HubSettingsView: View { } } .refreshable { await refresh() } + .task(id: hubInfo.value) { + guard let info = hubInfo.value else { return } + latestHubVersion = await HubVersionManager.latestVersion(for: info) + } .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in Task { await readNotificationPermission() @@ -362,8 +368,10 @@ struct HubSettingsView: View { private var connectedSections: some View { Section(header: Text("Hub")) { row("Address", hubURL.value) - if let version = hubInfo.value?.version { - row("LF Hub version", version) + if let info = hubInfo.value { + row("LF Hub version", info.channel.map { "\(info.version) (\($0))" } ?? info.version, + color: latestHubVersion.map { HubVersionManager.isOutdated(info, latest: $0) ? .orange : .green } ?? .secondary) + row("Latest LF Hub version", latestHubVersion ?? "Fetching…") } if let status { row("Patient", status.patient.name) @@ -567,11 +575,11 @@ struct HubSettingsView: View { return parts.joined(separator: " · ") } - private func row(_ title: String, _ value: String) -> some View { + private func row(_ title: String, _ value: String, color: Color = .secondary) -> some View { HStack { Text(title) Spacer() - Text(value).foregroundColor(.secondary).multilineTextAlignment(.trailing) + Text(value).foregroundColor(color).multilineTextAlignment(.trailing) } } diff --git a/LoopFollow/Hub/HubVersionManager.swift b/LoopFollow/Hub/HubVersionManager.swift new file mode 100644 index 000000000..c55bbd3a2 --- /dev/null +++ b/LoopFollow/Hub/HubVersionManager.swift @@ -0,0 +1,47 @@ +// LoopFollow +// HubVersionManager.swift + +import Foundation + +/// The newest LF Hub version on the branch the connected hub's image is built from: `main` +/// for the `latest` image, `dev` for the `dev` image and for a hub its owner built. +enum HubVersionManager { + static func branch(for channel: String?) -> String { + channel == "latest" ? "main" : "dev" + } + + /// The newest version for the hub's channel, checked at most once a day; the last one + /// known for that branch when GitHub does not answer, and nil before the first answer. + @MainActor + static func latestVersion(for hub: HubClient.Status.Hub) async -> String? { + let storage = Storage.shared + let branch = branch(for: hub.channel) + let cached = storage.hubLatestVersionBranch.value == branch ? storage.hubLatestVersion.value : nil + let checked = storage.hubLatestVersionChecked.value ?? .distantPast + if let cached, Date().timeIntervalSince(checked) < 24 * 3600 { return cached } + + guard let url = URL(string: "https://raw.githubusercontent.com/loopandlearn/lfhub/\(branch)/lfhub.csproj"), + let (data, response) = try? await URLSession.shared.data(from: url), + (response as? HTTPURLResponse)?.statusCode == 200, + let version = parseVersion(fromProject: String(decoding: data, as: UTF8.self)) + else { return cached } + + storage.hubLatestVersion.value = version + storage.hubLatestVersionBranch.value = branch + storage.hubLatestVersionChecked.value = Date() + return version + } + + /// The `` of an `lfhub.csproj`. + static func parseVersion(fromProject contents: String) -> String? { + guard let start = contents.range(of: ""), + let end = contents.range(of: "", range: start.upperBound ..< contents.endIndex) + else { return nil } + let version = contents[start.upperBound ..< end.lowerBound].trimmingCharacters(in: .whitespacesAndNewlines) + return version.isEmpty ? nil : version + } + + static func isOutdated(_ hub: HubClient.Status.Hub, latest: String) -> Bool { + AppVersionManager.isVersion(latest, newerThan: hub.version) + } +} diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index ecf3b7831..3316a6182 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -208,6 +208,10 @@ class Storage { var lastBlacklistNotificationShown = StorageValue(key: "lastBlacklistNotificationShown", defaultValue: nil) var lastVersionUpdateNotificationShown = StorageValue(key: "lastVersionUpdateNotificationShown", defaultValue: nil) var lastExpirationNotificationShown = StorageValue(key: "lastExpirationNotificationShown", defaultValue: nil) + var hubLatestVersion = StorageValue(key: "hubLatestVersion", defaultValue: nil) + var hubLatestVersionBranch = StorageValue(key: "hubLatestVersionBranch", defaultValue: nil) + var hubLatestVersionChecked = StorageValue(key: "hubLatestVersionChecked", defaultValue: nil) + var lastHubVersionUpdateNotificationShown = StorageValue(key: "lastHubVersionUpdateNotificationShown", defaultValue: nil) // MARK: - Telemetry ----------------------------------------------------------- diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 21d73257f..58ec98e61 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -794,14 +794,33 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { self.versionAlert(message: "The current version has a critical issue and should be updated as soon as possible.") Storage.shared.lastBlacklistNotificationShown.value = now Storage.shared.lastVersionUpdateNotificationShown.value = now + return } } else if isNewer { let lastVersionUpdateShown = Storage.shared.lastVersionUpdateNotificationShown.value ?? Date.distantPast if now.timeIntervalSince(lastVersionUpdateShown) > 1_209_600 { // 2 weeks self.versionAlert(message: "A new version is available: \(latestVersion ?? "Unknown"). It is recommended to update.") Storage.shared.lastVersionUpdateNotificationShown.value = now + return } } + self.checkAndNotifyHubVersionStatus() + } + } + + /// Reminds an admin phone every two weeks while a newer LF Hub is out for the hub's channel. + func checkAndNotifyHubVersionStatus() { + let hub = HubClient.shared + guard hub.isAdmin, let info = hub.hubInfo.value else { return } + Task { @MainActor in + guard let latest = await HubVersionManager.latestVersion(for: info), + HubVersionManager.isOutdated(info, latest: latest) else { return } + let lastShown = Storage.shared.lastHubVersionUpdateNotificationShown.value ?? Date.distantPast + guard Date().timeIntervalSince(lastShown) > 1_209_600, // 2 weeks + self.presentedViewController == nil else { return } + self.versionAlert(title: "LF Hub Update Available", + message: "LF Hub \(latest) is available; the hub runs \(info.version). Pull the new image and restart the hub to update it.") + Storage.shared.lastHubVersionUpdateNotificationShown.value = Date() } } diff --git a/Tests/HubTests.swift b/Tests/HubTests.swift index 8959c6364..831786735 100644 --- a/Tests/HubTests.swift +++ b/Tests/HubTests.swift @@ -1423,3 +1423,46 @@ struct HubClientBehaviourTests { #expect(HubAppGroup.acknowledgeCategory == "lfhub-ack") } } + +struct HubVersionTests { + @Test("the latest image follows main, the dev image and a hub's own build follow dev") + func branch() { + #expect(HubVersionManager.branch(for: "latest") == "main") + #expect(HubVersionManager.branch(for: "dev") == "dev") + #expect(HubVersionManager.branch(for: nil) == "dev") + } + + @Test("the version is read from the project file") + func parse() { + let project = """ + + + 0.3.1 + + + + + + """ + #expect(HubVersionManager.parseVersion(fromProject: project) == "0.3.1") + #expect(HubVersionManager.parseVersion(fromProject: "") == nil) + } + + @Test("a hub is outdated only when the latest version is newer") + func outdated() { + let hub = HubClient.Status.Hub(version: "0.2.0", api: 1, features: [], channel: "dev") + #expect(HubVersionManager.isOutdated(hub, latest: "0.2.1")) + #expect(HubVersionManager.isOutdated(hub, latest: "0.10.0")) + #expect(!HubVersionManager.isOutdated(hub, latest: "0.2.0")) + #expect(!HubVersionManager.isOutdated(hub, latest: "0.1.9")) + } + + @Test("a status without a channel decodes") + func decodesWithoutChannel() throws { + let json = Data(#"{"version":"0.2.0","api":1,"features":["receipts"]}"#.utf8) + let hub = try JSONDecoder().decode(HubClient.Status.Hub.self, from: json) + #expect(hub.channel == nil) + let withChannel = Data(#"{"version":"0.2.0","api":1,"features":[],"channel":"latest"}"#.utf8) + #expect(try JSONDecoder().decode(HubClient.Status.Hub.self, from: withChannel).channel == "latest") + } +} From 799386a0405f332787ae4defe1d263990c232271 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 1 Nov 2026 10:52:45 +0100 Subject: [PATCH 8/9] Show a server icon before the source name in LF Hub mode, blue while the hub is reachable and red while it is not --- LoopFollow/Hub/HubIndicator.swift | 21 +++++++++++++++++++ .../ViewControllers/BGDisplayView.swift | 7 +++++-- 2 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 LoopFollow/Hub/HubIndicator.swift diff --git a/LoopFollow/Hub/HubIndicator.swift b/LoopFollow/Hub/HubIndicator.swift new file mode 100644 index 000000000..acacbcd45 --- /dev/null +++ b/LoopFollow/Hub/HubIndicator.swift @@ -0,0 +1,21 @@ +// LoopFollow +// HubIndicator.swift + +import SwiftUI + +/// A server icon before the source name on Home while LF Hub mode is on: blue while the +/// hub answers and reaches this phone, red while it does not (`HubWatchdog.looksHealthy`). +struct HubIndicator: View { + @ObservedObject private var backgroundRefreshType = Storage.shared.backgroundRefreshType + @ObservedObject private var hubURL = Storage.shared.hubURL + @ObservedObject private var deviceKey = HubClient.shared.deviceKey + @ObservedObject private var looksHealthy = HubWatchdog.looksHealthy + + var body: some View { + if HubClient.shared.isHubMode { + Image(systemName: "server.rack") + .foregroundColor(looksHealthy.value ? .blue : .red) + .accessibilityLabel(looksHealthy.value ? "LF Hub mode, hub connected" : "LF Hub mode, no contact with the hub") + } + } +} diff --git a/LoopFollow/ViewControllers/BGDisplayView.swift b/LoopFollow/ViewControllers/BGDisplayView.swift index 036a92e56..4562336a4 100644 --- a/LoopFollow/ViewControllers/BGDisplayView.swift +++ b/LoopFollow/ViewControllers/BGDisplayView.swift @@ -28,8 +28,11 @@ struct BGDisplayView: View { ScrollView { VStack(spacing: 0) { - Text(serverText.value) - .font(.system(size: 13)) + HStack(spacing: 4) { + HubIndicator() + Text(serverText.value) + } + .font(.system(size: 13)) Text(snapshot?.bgText ?? bgText.value) .font(.system(size: 85, weight: .black)) From 0187fba7299554a2557de0d21bd33f3861ef63d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 1 Nov 2026 18:26:46 +0100 Subject: [PATCH 9/9] Apply the hub's badge push while LoopFollow is in the foreground --- LoopFollow/Application/AppDelegate.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index b021797d4..6a0be4801 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -307,8 +307,9 @@ extension AppDelegate: UNUserNotificationCenterDelegate { // Suppress notifications iOS routes here that we never intended to surface: // the Live Activity push-to-start uses interruption-level: passive with empty // title/body and must not produce a banner or sound when LF is foregrounded. + // The hub's badge push is also silent, so only its badge is applied. if content.interruptionLevel == .passive || (content.title.isEmpty && content.body.isEmpty) { - completionHandler([]) + completionHandler(content.badge != nil ? [.badge] : []) return }