diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 560cd2f8e..e9db02c3f 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -17,6 +17,9 @@ 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 */; }; + DD57A7C02F70000000000001 /* LoopFollowWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = DD57A7C02F70000000000002 /* LoopFollowWatch.app */; platformFilter = ios; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + DD57A7C02F70000000000003 /* LoopFollowWatchWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = DD57A7C02F70000000000004 /* LoopFollowWatchWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + DD57A7C02F70000000000103 /* ShareClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD57A7C02F70000000000102 /* ShareClient.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -34,6 +37,20 @@ remoteGlobalIDString = FC9788132485969B00A7906C; remoteInfo = LoopFollow; }; + DD57A7C02F70000000000005 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = FC97880C2485969B00A7906C /* Project object */; + proxyType = 1; + remoteGlobalIDString = DD57A7C02F70000000000006; + remoteInfo = LoopFollowWatch; + }; + DD57A7C02F70000000000007 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = FC97880C2485969B00A7906C /* Project object */; + proxyType = 1; + remoteGlobalIDString = DD57A7C02F70000000000008; + remoteInfo = LoopFollowWatchWidgets; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -48,6 +65,28 @@ name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; }; + DD57A7C02F70000000000009 /* Embed Watch Content */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "$(CONTENTS_FOLDER_PATH)/Watch"; + dstSubfolderSpec = 16; + files = ( + DD57A7C02F70000000000001 /* LoopFollowWatch.app in Embed Watch Content */, + ); + name = "Embed Watch Content"; + runOnlyForDeploymentPostprocessing = 0; + }; + DD57A7C02F7000000000000A /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + DD57A7C02F70000000000003 /* LoopFollowWatchWidgets.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ @@ -68,6 +107,9 @@ 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; }; + DD57A7C02F70000000000002 /* LoopFollowWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LoopFollowWatch.app; sourceTree = BUILT_PRODUCTS_DIR; }; + DD57A7C02F70000000000004 /* LoopFollowWatchWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = LoopFollowWatchWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + DD57A7C02F70000000000102 /* ShareClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ShareClient.swift; path = Pods/ShareClient/ShareClient/ShareClient.swift; sourceTree = SOURCE_ROOT; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -78,6 +120,42 @@ ); target = FC9788132485969B00A7906C /* LoopFollow */; }; + DD57A7C02F7000000000000B /* Exceptions for "LoopFollowWatch" folder in "LoopFollowWatch" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + LoopFollowWatch.entitlements, + ); + target = DD57A7C02F70000000000006 /* LoopFollowWatch */; + }; + DD57A7C02F7000000000000C /* Exceptions for "LoopFollowWatchWidgets" folder in "LoopFollowWatchWidgets" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + LoopFollowWatchWidgets.entitlements, + ); + target = DD57A7C02F70000000000008 /* LoopFollowWatchWidgets */; + }; + DD57A7C02F7000000000000D /* Exceptions for "Shared" folder in "LoopFollowWatch" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + GlucoseLiveActivityAttributes.swift, + LAAppGroupSettings.swift, + LALivenessMarker.swift, + LALivenessStore.swift, + ); + target = DD57A7C02F70000000000006 /* LoopFollowWatch */; + }; + DD57A7C02F7000000000000E /* Exceptions for "Shared" folder in "LoopFollowWatchWidgets" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + GlucoseLiveActivityAttributes.swift, + LAAppGroupSettings.swift, + LALivenessMarker.swift, + LALivenessStore.swift, + ); + target = DD57A7C02F70000000000008 /* LoopFollowWatchWidgets */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -103,6 +181,8 @@ BC0FBFD3754955D028EF6631 /* Shared */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( + DD57A7C02F7000000000000D /* Exceptions for "Shared" folder in "LoopFollowWatch" target */, + DD57A7C02F7000000000000E /* Exceptions for "Shared" folder in "LoopFollowWatchWidgets" target */, ); path = Shared; sourceTree = ""; @@ -118,6 +198,36 @@ path = Tests; sourceTree = ""; }; + DD57A7C02F7000000000000F /* LoopFollowWatch */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + DD57A7C02F7000000000000B /* Exceptions */, + ); + path = LoopFollowWatch; + sourceTree = ""; + }; + DD57A7C02F70000000000010 /* LoopFollowWatchWidgets */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + DD57A7C02F7000000000000C /* Exceptions */, + ); + path = LoopFollowWatchWidgets; + sourceTree = ""; + }; + DD57A7C02F70000000000011 /* WatchShared */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); + path = WatchShared; + sourceTree = ""; + }; + DD57A7C02F70000000000101 /* RemoteShared */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); + path = RemoteShared; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -147,6 +257,20 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DD57A7C02F70000000000012 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DD57A7C02F70000000000013 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -168,6 +292,7 @@ children = ( ECA3EFB4037410B4973BB632 /* Pods-LoopFollow.debug.xcconfig */, 059B0FA59AABFE72FE13DDDA /* Pods-LoopFollow.release.xcconfig */, + DD57A7C02F70000000000102 /* ShareClient.swift */, ); path = Pods; sourceTree = ""; @@ -196,6 +321,10 @@ 6A5880E0B811AF443B05AB02 /* Frameworks */, 9555FE45F7FAEFA6142D04CA /* LoopFollow */, BC0FBFD3754955D028EF6631 /* Shared */, + DD57A7C02F7000000000000F /* LoopFollowWatch */, + DD57A7C02F70000000000010 /* LoopFollowWatchWidgets */, + DD57A7C02F70000000000011 /* WatchShared */, + DD57A7C02F70000000000101 /* RemoteShared */, ); sourceTree = ""; }; @@ -205,6 +334,8 @@ FC9788142485969B00A7906C /* Loop Follow.app */, DDCC3AD62DDE1790006F1C10 /* Tests.xctest */, 37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */, + DD57A7C02F70000000000002 /* LoopFollowWatch.app */, + DD57A7C02F70000000000004 /* LoopFollowWatchWidgets.appex */, ); name = Products; sourceTree = ""; @@ -266,15 +397,19 @@ 04DA71CCA0280FA5FA2DF7A6 /* [CP] Embed Pods Frameworks */, DDB0AF532BB1AA0900AFA48B /* Capture Build Details */, 37A4BDED2F5B6B4C00EEB289 /* Embed Foundation Extensions */, + DD57A7C02F70000000000009 /* Embed Watch Content */, ); buildRules = ( ); dependencies = ( 37A4BDE72F5B6B4C00EEB289 /* PBXTargetDependency */, + DD57A7C02F7000000000001B /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 9555FE45F7FAEFA6142D04CA /* LoopFollow */, BC0FBFD3754955D028EF6631 /* Shared */, + DD57A7C02F70000000000011 /* WatchShared */, + DD57A7C02F70000000000101 /* RemoteShared */, ); name = LoopFollow; packageProductDependencies = ( @@ -284,6 +419,53 @@ productReference = FC9788142485969B00A7906C /* Loop Follow.app */; productType = "com.apple.product-type.application"; }; + DD57A7C02F70000000000006 /* LoopFollowWatch */ = { + isa = PBXNativeTarget; + buildConfigurationList = DD57A7C02F70000000000014 /* Build configuration list for PBXNativeTarget "LoopFollowWatch" */; + buildPhases = ( + DD57A7C02F70000000000015 /* Sources */, + DD57A7C02F70000000000012 /* Frameworks */, + DD57A7C02F70000000000016 /* Resources */, + DD57A7C02F7000000000000A /* Embed Foundation Extensions */, + ); + buildRules = ( + ); + dependencies = ( + DD57A7C02F70000000000017 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + DD57A7C02F7000000000000F /* LoopFollowWatch */, + DD57A7C02F70000000000011 /* WatchShared */, + BC0FBFD3754955D028EF6631 /* Shared */, + DD57A7C02F70000000000101 /* RemoteShared */, + ); + name = LoopFollowWatch; + productName = LoopFollowWatch; + productReference = DD57A7C02F70000000000002 /* LoopFollowWatch.app */; + productType = "com.apple.product-type.application"; + }; + DD57A7C02F70000000000008 /* LoopFollowWatchWidgets */ = { + isa = PBXNativeTarget; + buildConfigurationList = DD57A7C02F70000000000018 /* Build configuration list for PBXNativeTarget "LoopFollowWatchWidgets" */; + buildPhases = ( + DD57A7C02F70000000000019 /* Sources */, + DD57A7C02F70000000000013 /* Frameworks */, + DD57A7C02F7000000000001A /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + DD57A7C02F70000000000010 /* LoopFollowWatchWidgets */, + DD57A7C02F70000000000011 /* WatchShared */, + BC0FBFD3754955D028EF6631 /* Shared */, + ); + name = LoopFollowWatchWidgets; + productName = LoopFollowWatchWidgets; + productReference = DD57A7C02F70000000000004 /* LoopFollowWatchWidgets.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -297,6 +479,12 @@ 37A4BDD82F5B6B4A00EEB289 = { CreatedOnToolsVersion = 26.2; }; + DD57A7C02F70000000000006 = { + CreatedOnToolsVersion = 27.0; + }; + DD57A7C02F70000000000008 = { + CreatedOnToolsVersion = 27.0; + }; DDCC3AD52DDE1790006F1C10 = { CreatedOnToolsVersion = 16.3; TestTargetID = FC9788132485969B00A7906C; @@ -325,6 +513,8 @@ FC9788132485969B00A7906C /* LoopFollow */, DDCC3AD52DDE1790006F1C10 /* Tests */, 37A4BDD82F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension */, + DD57A7C02F70000000000006 /* LoopFollowWatch */, + DD57A7C02F70000000000008 /* LoopFollowWatchWidgets */, ); }; /* End PBXProject section */ @@ -355,6 +545,20 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DD57A7C02F70000000000016 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DD57A7C02F7000000000001A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -462,6 +666,21 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DD57A7C02F70000000000015 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + DD57A7C02F70000000000103 /* ShareClient.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DD57A7C02F70000000000019 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -476,6 +695,17 @@ target = FC9788132485969B00A7906C /* LoopFollow */; targetProxy = DDCC3ADA2DDE1790006F1C10 /* PBXContainerItemProxy */; }; + DD57A7C02F7000000000001B /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + platformFilter = ios; + target = DD57A7C02F70000000000006 /* LoopFollowWatch */; + targetProxy = DD57A7C02F70000000000005 /* PBXContainerItemProxy */; + }; + DD57A7C02F70000000000017 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = DD57A7C02F70000000000008 /* LoopFollowWatchWidgets */; + targetProxy = DD57A7C02F70000000000007 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -813,6 +1043,114 @@ }; name = Release; }; + DD57A7C02F7000000000001C /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = LoopFollowWatch/LoopFollowWatch.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = LoopFollowWatch/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix).watchkitapp"; + PRODUCT_MODULE_NAME = LoopFollowWatch; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Debug; + }; + DD57A7C02F7000000000001D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = LoopFollowWatch/LoopFollowWatch.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = LoopFollowWatch/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix).watchkitapp"; + PRODUCT_MODULE_NAME = LoopFollowWatch; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Release; + }; + DD57A7C02F7000000000001E /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_ENTITLEMENTS = LoopFollowWatchWidgets/LoopFollowWatchWidgets.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = LoopFollowWatchWidgets/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix).watchkitapp.widgets"; + PRODUCT_MODULE_NAME = LoopFollowWatchWidgets; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Debug; + }; + DD57A7C02F7000000000001F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_ENTITLEMENTS = LoopFollowWatchWidgets/LoopFollowWatchWidgets.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = LoopFollowWatchWidgets/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix).watchkitapp.widgets"; + PRODUCT_MODULE_NAME = LoopFollowWatchWidgets; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -852,6 +1190,24 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + DD57A7C02F70000000000014 /* Build configuration list for PBXNativeTarget "LoopFollowWatch" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DD57A7C02F7000000000001C /* Debug */, + DD57A7C02F7000000000001D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + DD57A7C02F70000000000018 /* Build configuration list for PBXNativeTarget "LoopFollowWatchWidgets" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DD57A7C02F7000000000001E /* Debug */, + DD57A7C02F7000000000001F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index 3cc65b736..d7b7f9735 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -55,6 +55,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { UNUserNotificationCenter.current().delegate = self + WatchSessionManager.shared.activate() + // Only spin up Bluetooth if the user has chosen a BLE-based background // refresh. Initializing BLEManager creates a CBCentralManager, which // triggers the Bluetooth permission prompt — deferring it keeps that diff --git a/LoopFollow/Charts/BGChartModel.swift b/LoopFollow/Charts/BGChartModel.swift index ab6deeb67..d23d22e29 100644 --- a/LoopFollow/Charts/BGChartModel.swift +++ b/LoopFollow/Charts/BGChartModel.swift @@ -403,6 +403,7 @@ final class BGChartModel: ObservableObject { guard let self else { return } self.rebuildScheduled = false self.performRebuild() + WatchSessionManager.shared.requestUpdate(reason: "chart") } } diff --git a/LoopFollow/InfoTable/InfoDisplayItem.swift b/LoopFollow/InfoTable/InfoDisplayItem.swift index cf3a3c1b9..8e8da861a 100644 --- a/LoopFollow/InfoTable/InfoDisplayItem.swift +++ b/LoopFollow/InfoTable/InfoDisplayItem.swift @@ -59,15 +59,25 @@ struct InfoColoring: Codable, Equatable { /// the default text color); when enabled, an in-range value reads green, /// mirroring the app's BG text coloring. func color(for value: Double, direction: InfoColorDirection) -> Color? { + switch level(for: value, direction: direction) { + case .urgent: .red + case .warning: .yellow + case .normal: .green + case nil: nil + } + } + + /// The threshold band a value falls in, or `nil` when coloring is off. + func level(for value: Double, direction: InfoColorDirection) -> InfoColorLevel? { guard enabled else { return nil } switch direction { case .above: - if let urgent, value >= urgent { return .red } - if let warning, value >= warning { return .yellow } + if let urgent, value >= urgent { return .urgent } + if let warning, value >= warning { return .warning } case .below: - if let urgent, value <= urgent { return .red } - if let warning, value <= warning { return .yellow } + if let urgent, value <= urgent { return .urgent } + if let warning, value <= warning { return .warning } } - return .green + return .normal } } diff --git a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift index 3010a98c3..bb59255dd 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift @@ -132,7 +132,7 @@ enum GlucoseSnapshotBuilder { let preferredUnit = PreferredGlucoseUnit.snapshotUnit() let deltaMgdl = provider.deltaMgdl ?? 0.0 - let trend = mapTrend(provider.trendCode) + let trend = GlucoseSnapshot.Trend(code: provider.trendCode) if provider.showRenewalOverlay { LogManager.shared.log(category: .general, message: "[LA] renewal overlay ON") @@ -180,39 +180,4 @@ enum GlucoseSnapshotBuilder { showRenewalOverlay: provider.showRenewalOverlay, ) } - - // MARK: - Trend Mapping - - private static func mapTrend(_ code: String?) -> GlucoseSnapshot.Trend { - guard - let raw = code? - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased(), - !raw.isEmpty - else { return .unknown } - - if raw.contains("doubleup") || raw.contains("rapidrise") || raw == "up2" || raw == "upfast" { - return .upFast - } - if raw.contains("fortyfiveup") { - return .upSlight - } - if raw.contains("singleup") || raw == "up" || raw == "up1" || raw == "rising" { - return .up - } - if raw.contains("flat") || raw == "steady" || raw == "none" { - return .flat - } - if raw.contains("doubledown") || raw.contains("rapidfall") || raw == "down2" || raw == "downfast" { - return .downFast - } - if raw.contains("fortyfivedown") { - return .downSlight - } - if raw.contains("singledown") || raw == "down" || raw == "down1" || raw == "falling" { - return .down - } - - return .unknown - } } diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 82987e79b..0d3e61fe5 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -659,6 +659,7 @@ final class LiveActivityManager { DispatchQueue.main.async { self.refreshFromCurrentState(reason: reason) } return } + WatchSessionManager.shared.requestUpdate(reason: reason) refreshCoalescer.request(reason: reason) } @@ -1064,10 +1065,9 @@ final class LiveActivityManager { let snapshotUnchanged = GlucoseSnapshotStore.shared.load() == snapshot let thresholdsChanged = LAAppGroupSettings.thresholdsMgdl() != UnitSettingsStore.shared.effectiveThresholds() - // Store + Watch: always update, independent of LA state. + // Store: always update, independent of LA state. writeThresholds() GlucoseSnapshotStore.shared.save(snapshot) - // WatchConnectivityManager.shared.send(snapshot: snapshot) // LA update: gated on LA being active, snapshot having changed, and activities enabled. if !Storage.shared.laEnabled.value { diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift index 537fa29d2..1392e697e 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift @@ -623,29 +623,3 @@ struct LoopAPNSCarbsView: View { min(max(date, oldestAcceptedDate), latestAcceptedDate) } } - -// APNS Payload structure for carbs -struct LoopAPNSPayload { - enum PayloadType { - case carbs - case bolus - } - - let type: PayloadType - let carbsAmount: Double? - let absorptionTime: Double? - let foodType: String? - let bolusAmount: Double? - let consumedDate: Date? - let otp: String - - init(type: PayloadType, carbsAmount: Double? = nil, absorptionTime: Double? = nil, foodType: String? = nil, bolusAmount: Double? = nil, consumedDate: Date? = nil, otp: String) { - self.type = type - self.carbsAmount = carbsAmount - self.absorptionTime = absorptionTime - self.foodType = foodType - self.bolusAmount = bolusAmount - self.consumedDate = consumedDate - self.otp = otp - } -} diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift index 2ca317809..0bd67521a 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift @@ -10,15 +10,13 @@ class LoopAPNSService { /// Returns the effective APNs credentials for sending commands to the remote app. /// Same team → use LoopFollow's own key. Different team → use remote-specific key. private func effectiveCredentials() -> (apnsKey: String, keyId: String, teamId: String) { - let lfTeamId = BuildDetails.default.teamID ?? "" - let remoteTeamId = storage.teamId.value ?? "" - let sameTeam = !lfTeamId.isEmpty && !remoteTeamId.isEmpty && lfTeamId == remoteTeamId - - if sameTeam || remoteTeamId.isEmpty { - return (storage.lfApnsKey.value, storage.lfKeyId.value, lfTeamId) - } else { - return (storage.remoteApnsKey.value, storage.remoteKeyId.value, remoteTeamId) - } + let resolved = RemoteSigningKey.resolve( + lfTeamID: BuildDetails.default.teamID ?? "", + remoteTeamID: storage.teamId.value ?? "", + lfKey: (storage.lfApnsKey.value, storage.lfKeyId.value), + remoteKey: (storage.remoteApnsKey.value, storage.remoteKeyId.value) + ) + return (resolved.key, resolved.keyID, resolved.teamID) } enum LoopAPNSError: Error, LocalizedError { @@ -59,34 +57,6 @@ class LoopAPNSService { } } - private func createReturnNotificationInfo() -> ReturnNotificationInfo? { - let loopFollowDeviceToken = Observable.shared.loopFollowDeviceToken.value - guard !loopFollowDeviceToken.isEmpty else { return nil } - - // Get LoopFollow's own Team ID from BuildDetails. - guard let loopFollowTeamID = BuildDetails.default.teamID, !loopFollowTeamID.isEmpty else { - LogManager.shared.log(category: .apns, message: "LoopFollow Team ID not found in BuildDetails.plist. Cannot create return notification info.") - return nil - } - - let lfKeyId = storage.lfKeyId.value - let lfApnsKey = storage.lfApnsKey.value - - guard !lfKeyId.isEmpty, !lfApnsKey.isEmpty else { - LogManager.shared.log(category: .apns, message: "Missing LoopFollow APNS credentials. Configure them in App Settings → APN.") - return nil - } - - return ReturnNotificationInfo( - productionEnvironment: BuildDetails.default.isTestFlightBuild(), - deviceToken: loopFollowDeviceToken, - bundleId: Bundle.main.bundleIdentifier ?? "", - teamId: loopFollowTeamID, - keyId: lfKeyId, - apnsKey: lfApnsKey - ) - } - /// Encrypts return notification info using OTP code private func encryptReturnNotificationInfo(returnInfo: ReturnNotificationInfo, otpCode: String) -> String? { guard let messenger = OTPSecureMessenger(otpCode: otpCode) else { @@ -124,76 +94,9 @@ class LoopAPNSService { private static let returnNotificationRequiredMessage = "Editing or deleting a carb entry needs LoopFollow's own APNS credentials so Loop can confirm the result. Configure them in App Settings → APN." - // MARK: - Command payloads (matching Nightscout's loop.js format) - - /// Fields every Loop remote command carries. - static func commandHeader(otp: String, alert: String, now: Date) -> [String: Any] { - [ - "otp": otp, - "remote-address": "LoopFollow", - "notes": "Sent via LoopFollow APNS", - "entered-by": "LoopFollow", - "sent-at": formatDateForAPNS(now), - "expiration": formatDateForAPNS(now.addingTimeInterval(5 * 60)), - "alert": alert, - ] - } - - static func carbsCommandPayload(_ payload: LoopAPNSPayload, now: Date = Date()) -> [String: Any] { - let carbsAmount = payload.carbsAmount ?? 0.0 - let absorptionTime = payload.absorptionTime ?? 3.0 - var fields = commandHeader( - otp: payload.otp, - alert: "Remote Carbs Entry: \(String(format: "%.1f", carbsAmount)) grams\nAbsorption Time: \(String(format: "%.1f", absorptionTime)) hours", - now: now - ) - fields["carbs-entry"] = carbsAmount - fields["absorption-time"] = absorptionTime - fields["start-time"] = formatDateForAPNS(payload.consumedDate ?? now) - return fields - } - - static func bolusCommandPayload(_ payload: LoopAPNSPayload, now: Date = Date()) -> [String: Any] { - let bolusAmount = payload.bolusAmount ?? 0.0 - var fields = commandHeader(otp: payload.otp, alert: "Remote Bolus Entry: \(String(format: "%.2f", bolusAmount)) U", now: now) - fields["bolus-entry"] = bolusAmount - return fields - } - - static func carbsDeleteCommandPayload(syncIdentifier: String, otp: String, now: Date = Date()) -> [String: Any] { - var fields = commandHeader(otp: otp, alert: "Remote Carbs Delete", now: now) - fields["carbs-delete"] = syncIdentifier - return fields - } - - /// Keys are prefixed so an unpatched Loop rejects the command. Amount, absorption time and start time are always sent; food type only when set. - static func carbsEditCommandPayload( - syncIdentifier: String, - carbsAmount: Double, - absorptionTimeHours: Double, - foodType: String?, - consumedDate: Date, - otp: String, - now: Date = Date() - ) -> [String: Any] { - var fields = commandHeader( - otp: otp, - alert: "Remote Carbs Edit: \(String(format: "%.1f", carbsAmount)) grams\nAbsorption Time: \(String(format: "%.1f", absorptionTimeHours)) hours", - now: now - ) - fields["carbs-edit"] = syncIdentifier - fields["carbs-edit-entry"] = carbsAmount - fields["carbs-edit-absorption-time"] = absorptionTimeHours - fields["carbs-edit-start-time"] = formatDateForAPNS(consumedDate) - if let foodType, !foodType.isEmpty { - fields["carbs-edit-food-type"] = foodType - } - return fields - } - /// The return-notification block encrypted with the OTP, when LoopFollow's own credentials are configured. private func encryptedReturnNotification(otp: String) -> String? { - guard let returnInfo = createReturnNotificationInfo() else { + guard let returnInfo = ReturnNotificationInfo.loopFollow() else { LogManager.shared.log(category: .apns, message: "Return notification info unavailable; Loop cannot confirm this command") return nil } @@ -233,7 +136,7 @@ class LoopAPNSService { /// Deletes the Loop carb entry with this `syncIdentifier`. Requires the Loop remote carb edit patch. func sendCarbsDelete(syncIdentifier: String, otp: String, completion: @escaping (Bool, String?) -> Void) { LogManager.shared.log(category: .apns, message: "Sending carbs delete for syncIdentifier=\(LogRedactor.tail(syncIdentifier))") - sendConfirmedCommand(Self.carbsDeleteCommandPayload(syncIdentifier: syncIdentifier, otp: otp), otp: otp, completion: completion) + sendConfirmedCommand(LoopAPNSCommands.carbsDeleteCommandPayload(syncIdentifier: syncIdentifier, otp: otp), otp: otp, completion: completion) } /// Replaces the Loop carb entry with this `syncIdentifier`. Requires the Loop remote carb edit patch. @@ -247,7 +150,7 @@ class LoopAPNSService { completion: @escaping (Bool, String?) -> Void ) { LogManager.shared.log(category: .apns, message: "Sending carbs edit for syncIdentifier=\(LogRedactor.tail(syncIdentifier)): \(String(format: "%.1f", carbsAmount))g, absorption \(String(format: "%.1f", absorptionTimeHours))h") - let payload = Self.carbsEditCommandPayload( + let payload = LoopAPNSCommands.carbsEditCommandPayload( syncIdentifier: syncIdentifier, carbsAmount: carbsAmount, absorptionTimeHours: absorptionTimeHours, @@ -263,7 +166,7 @@ class LoopAPNSService { /// - payload: The carbs payload to send /// - completion: Completion handler with success status and error message func sendCarbsViaAPNS(payload: LoopAPNSPayload, completion: @escaping (Bool, String?) -> Void) { - var finalPayload = Self.carbsCommandPayload(payload) + var finalPayload = LoopAPNSCommands.carbsCommandPayload(payload) if let encryptedReturnInfo = encryptedReturnNotification(otp: payload.otp) { finalPayload["encrypted_return_notification"] = encryptedReturnInfo } @@ -276,7 +179,7 @@ class LoopAPNSService { /// - payload: The bolus payload to send /// - completion: Completion handler with success status and error message func sendBolusViaAPNS(payload: LoopAPNSPayload, completion: @escaping (Bool, String?) -> Void) { - var finalPayload = Self.bolusCommandPayload(payload) + var finalPayload = LoopAPNSCommands.bolusCommandPayload(payload) if let encryptedReturnInfo = encryptedReturnNotification(otp: payload.otp) { finalPayload["encrypted_return_notification"] = encryptedReturnInfo } @@ -359,145 +262,37 @@ class LoopAPNSService { return } - // Create JWT token for APNS authentication - guard let jwt = JWTManager.shared.getOrGenerateJWT(keyId: keyId, teamId: teamId, apnsKey: apnsKey) else { - let errorMessage = "Failed to generate JWT, please check that the APNS Key ID, APNS Key and Team ID are correct." - LogManager.shared.log(category: .apns, message: errorMessage) - completion(false, errorMessage) - return - } - - // Determine APNS environment - let isProduction = storage.productionEnvironment.value - let apnsURL = APNSEnvironment.baseURL(production: isProduction) - guard let requestURL = URL(string: "\(apnsURL)/3/device/\(deviceToken)") else { - let errorMessage = "Failed to construct APNs URL" - LogManager.shared.log(category: .apns, message: errorMessage) - completion(false, errorMessage) - return - } - - var request = URLRequest(url: requestURL) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "content-type") - request.setValue("bearer \(jwt)", forHTTPHeaderField: "authorization") - request.setValue(bundleIdentifier, forHTTPHeaderField: "apns-topic") - request.setValue("alert", forHTTPHeaderField: "apns-push-type") - request.setValue("10", forHTTPHeaderField: "apns-priority") // High priority - // Validate bundle identifier format if !bundleIdentifier.contains(".") { LogManager.shared.log(category: .apns, message: "Warning: Bundle identifier may be in wrong format: \(bundleIdentifier)") } - // Create the proper APNS payload structure (matching @parse/node-apn format) - var apnsPayload: [String: Any] = [ - "aps": [ - "alert": payload["alert"] as? String ?? "", - "content-available": 1, - "interruption-level": "time-sensitive", - ], - ] - - // Add all the custom payload fields (excluding APNS-specific fields) - for (key, value) in payload { - if key != "alert", key != "content-available", key != "interruption-level" { - apnsPayload[key] = value - } - } - - // Remove nil values to clean up the payload - let cleanPayload = apnsPayload.compactMapValues { $0 } - - // Log if encrypted_return_notification is in the payload - if cleanPayload["encrypted_return_notification"] != nil { + if payload["encrypted_return_notification"] != nil { LogManager.shared.log(category: .apns, message: "encrypted_return_notification is present in final APNS payload") } else { - LogManager.shared.log(category: .apns, message: "WARNING: encrypted_return_notification is NOT in final APNS payload. Available keys: \(Array(cleanPayload.keys).joined(separator: ", "))") + LogManager.shared.log(category: .apns, message: "WARNING: encrypted_return_notification is NOT in final APNS payload. Available keys: \(Array(payload.keys).joined(separator: ", "))") } + let body: Data do { - let jsonData = try JSONSerialization.data(withJSONObject: cleanPayload) - - request.httpBody = jsonData - - let task = URLSession.shared.dataTask(with: request) { data, response, error in - if let error = error { - let errorMessage = "Failed to send push notification: \(error.localizedDescription)" - LogManager.shared.log(category: .apns, message: errorMessage) - completion(false, errorMessage) - return - } - - if let httpResponse = response as? HTTPURLResponse { - var responseBodyMessage = "" - if let data = data, let responseBody = String(data: data, encoding: .utf8) { - if let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], - let reason = json["reason"] as? String - { - responseBodyMessage = reason - } - } - - switch httpResponse.statusCode { - case 200: - LogManager.shared.log(category: .apns, message: "APNS notification sent successfully") - completion(true, nil) - case 400: - let errorMessage = "Bad request. The request was invalid or malformed. \(responseBodyMessage)" - LogManager.shared.log(category: .apns, message: "APNS error 400: \(responseBodyMessage) - Check device token and environment settings") - completion(false, errorMessage) - case 403: - JWTManager.shared.invalidateCache() - let errorMessage = "Authentication error. Check your certificate or authentication token. \(responseBodyMessage)" - LogManager.shared.log(category: .apns, message: "APNS error 403: \(responseBodyMessage) - Check APNS key permissions for bundle ID") - completion(false, errorMessage) - case 404: - let errorMessage = "Invalid request: The :path value was incorrect. \(responseBodyMessage)" - LogManager.shared.log(category: .apns, message: "APNS error 404: \(responseBodyMessage)") - completion(false, errorMessage) - case 405: - let errorMessage = "Invalid request: Only POST requests are supported. \(responseBodyMessage)" - LogManager.shared.log(category: .apns, message: "APNS error 405: \(responseBodyMessage)") - completion(false, errorMessage) - case 410: - let errorMessage = "The device token is no longer active for the topic. \(responseBodyMessage)" - LogManager.shared.log(category: .apns, message: "APNS error 410: Device token is invalid or expired") - completion(false, errorMessage) - case 413: - let errorMessage = "Payload too large. The notification payload exceeded the size limit. \(responseBodyMessage)" - LogManager.shared.log(category: .apns, message: "APNS error 413: \(responseBodyMessage)") - completion(false, errorMessage) - case 429: - let errorMessage = "Too many requests. \(responseBodyMessage)" - LogManager.shared.log(category: .apns, message: "APNS error 429: Rate limited - wait before retrying") - completion(false, errorMessage) - case 500: - let errorMessage = "Internal server error at APNs. \(responseBodyMessage)" - LogManager.shared.log(category: .apns, message: "APNS error 500: \(responseBodyMessage)") - completion(false, errorMessage) - case 503: - let errorMessage = "Service unavailable. The server is temporarily unavailable. Try again later. \(responseBodyMessage)" - LogManager.shared.log(category: .apns, message: "APNS error 503: \(responseBodyMessage)") - completion(false, errorMessage) - default: - let errorMessage = "Unexpected status code: \(httpResponse.statusCode). \(responseBodyMessage)" - LogManager.shared.log(category: .apns, message: "APNS error \(httpResponse.statusCode): \(responseBodyMessage)") - completion(false, errorMessage) - } - } else { - let errorMessage = "Failed to get a valid HTTP response." - LogManager.shared.log(category: .apns, message: errorMessage) - completion(false, errorMessage) - } - } - task.resume() - + body = try LoopAPNSCommands.apnsBody(payload) } catch { let errorMessage = "Failed to serialize APNS payload: \(error.localizedDescription)" LogManager.shared.log(category: .apns, message: errorMessage) completion(false, errorMessage) + return } + + APNSDelivery.send( + body: body, + deviceToken: deviceToken, + topic: bundleIdentifier, + production: storage.productionEnvironment.value, + keyId: keyId, + teamId: teamId, + apnsKey: apnsKey, + completion: completion + ) } /// Validates and fixes APNS key format if needed @@ -648,15 +443,6 @@ class LoopAPNSService { // MARK: - Date Formatting Helper - /// Creates a properly formatted ISO8601 date string with milliseconds (matching Nightscout's format) - /// - Parameter date: The date to format - /// - Returns: Formatted date string like "2022-12-24T21:34:02.090Z" - private static func formatDateForAPNS(_ date: Date) -> String { - let dateFormatter = ISO8601DateFormatter() - dateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return dateFormatter.string(from: date) - } - // MARK: - Override Methods func sendOverrideNotification(presetName: String, duration: TimeInterval? = nil, completion: @escaping (Bool, String?) -> Void) { @@ -676,47 +462,7 @@ class LoopAPNSService { return } - // Create APNS notification payload (matching Loop's expected format) - let now = Date() - let expiration = Date(timeIntervalSinceNow: 5 * 60) // 5 minutes from now - - // Create alert text (matching Nightscout's format) - var alertText = "\(presetName) Temporary Override" - if let duration = duration, duration > 0 { - let hours = Int(duration / 3600) - let minutes = Int((duration.truncatingRemainder(dividingBy: 3600)) / 60) - if hours > 0 { - alertText += " (\(hours)h \(minutes)m)" - } else { - alertText += " (\(minutes)m)" - } - } - - var payload: [String: Any] = [ - "override-name": presetName, - "remote-address": "LoopFollow", - "entered-by": "LoopFollow", - "sent-at": Self.formatDateForAPNS(now), - "expiration": Self.formatDateForAPNS(expiration), - "alert": alertText, - ] - - if let duration = duration { - payload["override-duration-minutes"] = Int(duration / 60) - } - - // For override commands, we can include return notification info unencrypted - // since override commands don't require OTP validation in Loop - if let returnInfo = createReturnNotificationInfo() { - payload["return_notification"] = [ - "production_environment": returnInfo.productionEnvironment, - "device_token": returnInfo.deviceToken, - "bundle_id": returnInfo.bundleId, - "team_id": returnInfo.teamId, - "key_id": returnInfo.keyId, - "apns_key": returnInfo.apnsKey, - ] - } + let payload = LoopAPNSCommands.overridePayload(presetName: presetName, duration: duration, returnInfo: ReturnNotificationInfo.loopFollow()) // Send the notification using the existing APNS infrastructure let creds = effectiveCredentials() @@ -748,31 +494,7 @@ class LoopAPNSService { return } - // Create APNS notification payload (matching Loop's expected format) - let now = Date() - let expiration = Date(timeIntervalSinceNow: 5 * 60) // 5 minutes from now - - var payload: [String: Any] = [ - "cancel-temporary-override": "true", - "remote-address": "LoopFollow", - "entered-by": "LoopFollow", - "sent-at": Self.formatDateForAPNS(now), - "expiration": Self.formatDateForAPNS(expiration), - "alert": "Cancel Temporary Override", - ] - - // For override commands, we can include return notification info unencrypted - // since override commands don't require OTP validation in Loop - if let returnInfo = createReturnNotificationInfo() { - payload["return_notification"] = [ - "production_environment": returnInfo.productionEnvironment, - "device_token": returnInfo.deviceToken, - "bundle_id": returnInfo.bundleId, - "team_id": returnInfo.teamId, - "key_id": returnInfo.keyId, - "apns_key": returnInfo.apnsKey, - ] - } + let payload = LoopAPNSCommands.cancelOverridePayload(returnInfo: ReturnNotificationInfo.loopFollow()) // Send the notification using the existing APNS infrastructure let creds = effectiveCredentials() diff --git a/LoopFollow/Remote/ReturnNotificationInfo+LoopFollow.swift b/LoopFollow/Remote/ReturnNotificationInfo+LoopFollow.swift new file mode 100644 index 000000000..34c61d15a --- /dev/null +++ b/LoopFollow/Remote/ReturnNotificationInfo+LoopFollow.swift @@ -0,0 +1,35 @@ +// LoopFollow +// ReturnNotificationInfo+LoopFollow.swift + +import Foundation + +extension ReturnNotificationInfo { + /// How Loop or Trio reaches this LoopFollow app to confirm a command, or nil + /// when LoopFollow's own APNs credentials or device token are missing. + static func loopFollow() -> ReturnNotificationInfo? { + let loopFollowDeviceToken = Observable.shared.loopFollowDeviceToken.value + guard !loopFollowDeviceToken.isEmpty else { return nil } + + guard let loopFollowTeamID = BuildDetails.default.teamID, !loopFollowTeamID.isEmpty else { + LogManager.shared.log(category: .apns, message: "LoopFollow Team ID not found in BuildDetails.plist. Cannot create return notification info.") + return nil + } + + let lfKeyId = Storage.shared.lfKeyId.value + let lfApnsKey = Storage.shared.lfApnsKey.value + + guard !lfKeyId.isEmpty, !lfApnsKey.isEmpty else { + LogManager.shared.log(category: .apns, message: "Missing LoopFollow APNS credentials. Configure them in App Settings → APN.") + return nil + } + + return ReturnNotificationInfo( + productionEnvironment: BuildDetails.default.isTestFlightBuild(), + deviceToken: loopFollowDeviceToken, + bundleId: Bundle.main.bundleIdentifier ?? "", + teamId: loopFollowTeamID, + keyId: lfKeyId, + apnsKey: lfApnsKey + ) + } +} diff --git a/LoopFollow/Remote/Settings/RemoteSettingsView.swift b/LoopFollow/Remote/Settings/RemoteSettingsView.swift index c4f8a5c92..c3146108c 100644 --- a/LoopFollow/Remote/Settings/RemoteSettingsView.swift +++ b/LoopFollow/Remote/Settings/RemoteSettingsView.swift @@ -9,6 +9,7 @@ struct RemoteSettingsView: View { @ObservedObject var viewModel: RemoteSettingsViewModel @ObservedObject private var device = Storage.shared.device @ObservedObject var bolusIncrement = Storage.shared.bolusIncrement + @ObservedObject private var watchRemoteCommands = Storage.shared.watchRemoteCommands @State private var showAlert: Bool = false @State private var alertType: AlertType? = nil @@ -93,6 +94,13 @@ struct RemoteSettingsView: View { if viewModel.remoteType == .trc || viewModel.remoteType == .loopAPNS { guardrailsSection + + Section( + header: Text("Apple Watch"), + footer: Text("Commands from the LoopFollow watch app are sent by this iPhone, with the same guardrails as here. The iPhone must be within reach of the watch.") + ) { + Toggle("Allow Commands from Apple Watch", isOn: $watchRemoteCommands.value) + } } if !Storage.shared.bolusIncrementDetected.value { diff --git a/LoopFollow/Remote/TRC/PushNotificationManager.swift b/LoopFollow/Remote/TRC/PushNotificationManager.swift index 236161b06..7a6513a15 100644 --- a/LoopFollow/Remote/TRC/PushNotificationManager.swift +++ b/LoopFollow/Remote/TRC/PushNotificationManager.swift @@ -21,49 +21,15 @@ class PushNotificationManager { user = Storage.shared.user.value bundleId = Storage.shared.bundleId.value - let lfTeamId = BuildDetails.default.teamID ?? "" - let remoteTeamId = Storage.shared.teamId.value ?? "" - let sameTeam = !lfTeamId.isEmpty && !remoteTeamId.isEmpty && lfTeamId == remoteTeamId - - if sameTeam || remoteTeamId.isEmpty { - apnsKey = Storage.shared.lfApnsKey.value - keyId = Storage.shared.lfKeyId.value - teamId = lfTeamId - } else { - apnsKey = Storage.shared.remoteApnsKey.value - keyId = Storage.shared.remoteKeyId.value - teamId = remoteTeamId - } - } - - private func createReturnNotificationInfo() -> CommandPayload.ReturnNotificationInfo? { - let loopFollowDeviceToken = Observable.shared.loopFollowDeviceToken.value - - guard !loopFollowDeviceToken.isEmpty else { - return nil - } - - guard let loopFollowTeamID = BuildDetails.default.teamID, !loopFollowTeamID.isEmpty else { - LogManager.shared.log(category: .apns, message: "LoopFollow Team ID not found in BuildDetails.plist. Cannot create return notification info.") - return nil - } - - let lfKeyId = Storage.shared.lfKeyId.value - let lfApnsKey = Storage.shared.lfApnsKey.value - - guard !lfKeyId.isEmpty, !lfApnsKey.isEmpty else { - LogManager.shared.log(category: .apns, message: "Missing LoopFollow APNS credentials. Configure them in App Settings → APN.") - return nil - } - - return CommandPayload.ReturnNotificationInfo( - productionEnvironment: BuildDetails.default.isTestFlightBuild(), - deviceToken: loopFollowDeviceToken, - bundleId: Bundle.main.bundleIdentifier ?? "", - teamId: loopFollowTeamID, - keyId: lfKeyId, - apnsKey: lfApnsKey + let resolved = RemoteSigningKey.resolve( + lfTeamID: BuildDetails.default.teamID ?? "", + remoteTeamID: Storage.shared.teamId.value ?? "", + lfKey: (Storage.shared.lfApnsKey.value, Storage.shared.lfKeyId.value), + remoteKey: (Storage.shared.remoteApnsKey.value, Storage.shared.remoteKeyId.value) ) + apnsKey = resolved.key + keyId = resolved.keyID + teamId = resolved.teamID } func sendOverridePushNotification(override: ProfileManager.TrioOverride, completion: @escaping (Bool, String?) -> Void) { @@ -72,7 +38,7 @@ class PushNotificationManager { commandType: .startOverride, timestamp: Date().timeIntervalSince1970, overrideName: override.name, - returnNotification: createReturnNotificationInfo() + returnNotification: ReturnNotificationInfo.loopFollow() ) sendEncryptedCommand(payload: payload, completion: completion) } @@ -83,7 +49,7 @@ class PushNotificationManager { commandType: .cancelOverride, timestamp: Date().timeIntervalSince1970, overrideName: nil, - returnNotification: createReturnNotificationInfo() + returnNotification: ReturnNotificationInfo.loopFollow() ) sendEncryptedCommand(payload: payload, completion: completion) } @@ -95,7 +61,7 @@ class PushNotificationManager { commandType: .bolus, timestamp: Date().timeIntervalSince1970, bolusAmount: bolusAmountDecimal, - returnNotification: createReturnNotificationInfo() + returnNotification: ReturnNotificationInfo.loopFollow() ) sendEncryptedCommand(payload: payload, completion: completion) } @@ -109,7 +75,7 @@ class PushNotificationManager { timestamp: Date().timeIntervalSince1970, target: targetValue, duration: durationValue, - returnNotification: createReturnNotificationInfo() + returnNotification: ReturnNotificationInfo.loopFollow() ) sendEncryptedCommand(payload: payload, completion: completion) } @@ -119,7 +85,7 @@ class PushNotificationManager { user: user, commandType: .cancelTempTarget, timestamp: Date().timeIntervalSince1970, - returnNotification: createReturnNotificationInfo() + returnNotification: ReturnNotificationInfo.loopFollow() ) sendEncryptedCommand(payload: payload, completion: completion) } @@ -159,14 +125,14 @@ class PushNotificationManager { protein: proteinValue, fat: fatValue, scheduledTime: scheduledTimeInterval, - returnNotification: createReturnNotificationInfo() + returnNotification: ReturnNotificationInfo.loopFollow() ) sendEncryptedCommand(payload: payload, completion: completion) } /// Trio confirms edit/delete through a return notification, so LoopFollow's own APNs credentials are required. func sendDeleteMealPushNotification(mealID: String, commandID: String, completion: @escaping (Bool, String?) -> Void) { - guard let returnNotification = createReturnNotificationInfo() else { + guard let returnNotification = ReturnNotificationInfo.loopFollow() else { completion(false, Self.returnNotificationRequiredMessage) return } @@ -194,7 +160,7 @@ class PushNotificationManager { completion(false, "No nutrient data provided. At least one of carbs, fat, or protein must be greater than 0.") return } - guard let returnNotification = createReturnNotificationInfo() else { + guard let returnNotification = ReturnNotificationInfo.loopFollow() else { completion(false, Self.returnNotificationRequiredMessage) return } @@ -281,20 +247,6 @@ class PushNotificationManager { return } - guard let url = constructAPNsURL() else { - let errorMessage = "Failed to construct APNs URL" - LogManager.shared.log(category: .apns, message: errorMessage) - completion(false, errorMessage) - return - } - - guard let jwt = JWTManager.shared.getOrGenerateJWT(keyId: keyId, teamId: teamId, apnsKey: apnsKey) else { - let errorMessage = "Failed to generate JWT, please check that the token is correct." - LogManager.shared.log(category: .apns, message: errorMessage) - completion(false, errorMessage) - return - } - do { guard let messenger = SecureMessenger(sharedSecret: sharedSecret) else { let errorMessage = "Failed to initialize security module. Check shared secret." @@ -306,78 +258,22 @@ class PushNotificationManager { let encryptedDataString = try messenger.encrypt(payload) let finalMessage = EncryptedPushMessage(encryptedData: encryptedDataString, commandType: payload.commandType) - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("bearer \(jwt)", forHTTPHeaderField: "authorization") - request.setValue("application/json", forHTTPHeaderField: "content-type") - request.setValue("10", forHTTPHeaderField: "apns-priority") - request.setValue("600", forHTTPHeaderField: "apns-expiration") - request.setValue(bundleId, forHTTPHeaderField: "apns-topic") - request.setValue("alert", forHTTPHeaderField: "apns-push-type") - request.setValue(payload.apnsCollapseID, forHTTPHeaderField: "apns-collapse-id") - - request.httpBody = try JSONEncoder().encode(finalMessage) - - let task = URLSession.shared.dataTask(with: request) { data, response, error in - if let error = error { - let errorMessage = "Failed to send push notification: \(error.localizedDescription)" - LogManager.shared.log(category: .apns, message: errorMessage) - completion(false, errorMessage) - return - } - - if let httpResponse = response as? HTTPURLResponse { - LogManager.shared.log(category: .apns, message: "Push notification sent. Status code: \(httpResponse.statusCode)", isDebug: true) - - var responseBodyMessage = "" - if let data = data, let responseBody = String(data: data, encoding: .utf8) { - LogManager.shared.log(category: .apns, message: "Response body: \(responseBody)", isDebug: true) - if let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], - let reason = json["reason"] as? String - { - responseBodyMessage = reason - } - } - - switch httpResponse.statusCode { - case 200: - completion(true, nil) - case 400: - completion(false, "Bad request. The request was invalid or malformed. \(responseBodyMessage)") - case 403: - JWTManager.shared.invalidateCache() - completion(false, "Authentication error. Check your certificate or authentication token. \(responseBodyMessage)") - case 404: - completion(false, "Invalid request: The :path value was incorrect. \(responseBodyMessage)") - case 405: - completion(false, "Invalid request: Only POST requests are supported. \(responseBodyMessage)") - case 410: - completion(false, "The device token is no longer active for the topic. \(responseBodyMessage)") - case 413: - completion(false, "Payload too large. The notification payload exceeded the size limit. \(responseBodyMessage)") - case 429: - completion(false, "Too many requests. \(responseBodyMessage)") - case 500: - completion(false, "Internal server error at APNs. \(responseBodyMessage)") - case 503: - completion(false, "Service unavailable. The server is temporarily unavailable. Try again later. \(responseBodyMessage)") - default: - completion(false, "Unexpected status code: \(httpResponse.statusCode). \(responseBodyMessage)") - } - } else { - completion(false, "Failed to get a valid HTTP response.") - } - } - task.resume() - + try APNSDelivery.send( + body: JSONEncoder().encode(finalMessage), + deviceToken: deviceToken, + topic: bundleId, + production: productionEnvironment, + keyId: keyId, + teamId: teamId, + apnsKey: apnsKey, + expiration: "600", + collapseID: payload.apnsCollapseID, + completion: completion + ) } catch { let errorMessage = "Failed to encode or encrypt push message: \(error.localizedDescription)" LogManager.shared.log(category: .apns, message: errorMessage) completion(false, errorMessage) } } - - private func constructAPNsURL() -> URL? { - URL(string: "\(APNSEnvironment.baseURL(production: productionEnvironment))/3/device/\(deviceToken)") - } } diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 1432352b7..ddb47e7b5 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -34,6 +34,8 @@ class Storage { var mealWithBolus = StorageValue(key: "mealWithBolus", defaultValue: false) var mealWithFatProtein = StorageValue(key: "mealWithFatProtein", defaultValue: false) + /// Lets the Apple Watch app send remote commands through this phone. + var watchRemoteCommands = StorageValue(key: "watchRemoteCommands", defaultValue: true) // TODO: This flag can be deleted in March 2027. Check the commit for other places to cleanup. var hasSeenFatProteinOrderChange = StorageValue(key: "hasSeenFatProteinOrderChange", defaultValue: true) diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 5c6fb7e4f..0b9514bc0 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -152,6 +152,7 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { view.backgroundColor = .systemBackground infoManager = InfoManager() + WatchSessionManager.shared.observe(infoManager, stats: statsDisplayModel) let mainView = MainHomeView( chartModel: chartModel, diff --git a/LoopFollow/Watch/WatchCommandHandler.swift b/LoopFollow/Watch/WatchCommandHandler.swift new file mode 100644 index 000000000..238e1d964 --- /dev/null +++ b/LoopFollow/Watch/WatchCommandHandler.swift @@ -0,0 +1,213 @@ +// LoopFollow +// WatchCommandHandler.swift + +import Foundation +import HealthKit + +/// Executes commands from the watch with the same checks and send paths as the +/// phone's Remote screens. Runs on the main queue. +final class WatchCommandHandler { + static let shared = WatchCommandHandler() + + private var handledIDs: [UUID] = [] + + private init() {} + + /// The guardrails from the phone's Remote settings. + static func limits() -> WatchCommandValidator.Limits { + WatchCommandValidator.Limits( + maxBolus: Storage.shared.maxBolus.value.doubleValue(for: .internationalUnit()), + bolusIncrement: Storage.shared.bolusIncrement.value.doubleValue(for: .internationalUnit()), + maxCarbs: Storage.shared.maxCarbs.value.doubleValue(for: .gram()), + maxFat: Storage.shared.maxFat.value.doubleValue(for: .gram()), + maxProtein: Storage.shared.maxProtein.value.doubleValue(for: .gram()), + mealWithBolus: Storage.shared.mealWithBolus.value, + mealWithFatProtein: Storage.shared.mealWithFatProtein.value + ) + } + + func handle(_ command: WatchCommand, reply: @escaping (WatchCommandReply) -> Void) { + let finish: (Bool, String) -> Void = { success, message in + LogManager.shared.log(category: .apns, message: "Watch command \(command.action.name) finished: success=\(success) \(message)") + reply(WatchCommandReply(success: success, message: message)) + } + + guard !handledIDs.contains(command.id) else { + finish(false, "This command was already handled.") + return + } + handledIDs.append(command.id) + if handledIDs.count > 50 { handledIDs.removeFirst() } + + guard abs(Date().timeIntervalSince(command.createdAt)) <= WatchCommand.maxAge else { + finish(false, "The command arrived too late and was not sent. Try again.") + return + } + + LogManager.shared.log(category: .apns, message: "Watch command received: \(command.action.name)") + + if case .snoozeAlarm = command.action { + guard Observable.shared.currentAlarm.value != nil else { + finish(false, "No alarm is active.") + return + } + AlarmManager.shared.performSnooze() + finish(true, "Alarm snoozed.") + return + } + + guard Storage.shared.watchRemoteCommands.value else { + finish(false, "Commands from Apple Watch are turned off in LoopFollow's Remote settings.") + return + } + guard let kind = WatchStateBuilder.remoteKind() else { + finish(false, "Remote control is not set up in LoopFollow.") + return + } + + switch WatchCommandValidator.validate(command.action, kind: kind, limits: Self.limits()) { + case let .failure(error): + finish(false, error.message) + case let .success(action): + switch kind { + case .loop: sendLoop(action, finish: finish) + case .trio: sendTrio(action, finish: finish) + } + } + } + + // MARK: - Loop + + private func sendLoop(_ action: WatchCommand.Action, finish: @escaping (Bool, String) -> Void) { + let service = LoopAPNSService() + let sent: (Bool, String?) -> Void = { success, error in + DispatchQueue.main.async { + finish(success, success ? RemoteCommandMessage.sent : (error ?? "Failed to send the command.")) + } + } + + switch action { + case let .bolus(units): + guard service.validateSetup() else { return finish(false, loopSetupIncomplete) } + guard let otp = loopOTP(finish: finish) else { return } + service.sendBolusViaAPNS(payload: LoopAPNSPayload(type: .bolus, bolusAmount: units, otp: otp)) { success, error in + DispatchQueue.main.async { + if success { + TOTPService.shared.markTOTPAsUsed(qrCodeURL: Storage.shared.loopAPNSQrCodeURL.value) + QuickPickBolusesManager.shared.recordBolus(units: units) + } + } + sent(success, error) + } + + case let .meal(carbs, _, _, _, consumedAt): + guard service.validateSetup() else { return finish(false, loopSetupIncomplete) } + guard let otp = loopOTP(finish: finish) else { return } + let payload = LoopAPNSPayload( + type: .carbs, + carbsAmount: carbs, + absorptionTime: WatchCommandValidator.loopAbsorptionHours, + consumedDate: consumedAt.dateUsingCurrentSeconds(), + otp: otp + ) + service.sendCarbsViaAPNS(payload: payload) { success, error in + DispatchQueue.main.async { + if success { + TOTPService.shared.markTOTPAsUsed(qrCodeURL: Storage.shared.loopAPNSQrCodeURL.value) + QuickPickMealsManager.shared.recordMeal(carbs: carbs) + } + } + sent(success, error) + } + + case let .startOverride(presetID): + guard let preset = ProfileManager.shared.loopOverrides.first(where: { $0.name == presetID }) else { + return finish(false, "The override preset was not found.") + } + let duration = TimeInterval(preset.duration ?? 0) + service.sendOverrideNotification(presetName: preset.name, duration: duration > 0 ? duration : nil, completion: sent) + + case .cancelOverride: + service.sendCancelOverrideNotification(completion: sent) + + case .startTempTarget, .cancelTempTarget, .snoozeAlarm: + finish(false, "This command is not available for Loop.") + } + } + + private var loopSetupIncomplete: String { + "Loop APNS setup is incomplete. Configure it in LoopFollow's Remote settings." + } + + /// Mirrors the one-time code checks in the phone's Loop bolus and carbs screens. + private func loopOTP(finish: (Bool, String) -> Void) -> String? { + let url = Storage.shared.loopAPNSQrCodeURL.value + guard let otp = TOTPGenerator.extractOTPFromURL(url) else { + finish(false, "Invalid QR code URL. Re-scan the QR code in LoopFollow's Remote settings.") + return nil + } + guard !TOTPService.shared.isTOTPBlocked(qrCodeURL: url) else { + finish(false, "The current one-time code was already used. Wait for the next code (up to 30 seconds) and try again.") + return nil + } + return otp + } + + // MARK: - Trio + + private func sendTrio(_ action: WatchCommand.Action, finish: @escaping (Bool, String) -> Void) { + let manager = PushNotificationManager() + let sent: (Bool, String?) -> Void = { success, error in + DispatchQueue.main.async { + finish(success, success ? RemoteCommandMessage.sent : (error ?? "Failed to send the command.")) + } + } + + switch action { + case let .bolus(units): + manager.sendBolusPushNotification(bolusAmount: HKQuantity(unit: .internationalUnit(), doubleValue: units)) { success, error in + if success { + DispatchQueue.main.async { QuickPickBolusesManager.shared.recordBolus(units: units) } + } + sent(success, error) + } + + case let .meal(carbs, fat, protein, bolus, consumedAt): + manager.sendMealPushNotification( + carbs: HKQuantity(unit: .gram(), doubleValue: carbs), + protein: HKQuantity(unit: .gram(), doubleValue: protein), + fat: HKQuantity(unit: .gram(), doubleValue: fat), + bolusAmount: HKQuantity(unit: .internationalUnit(), doubleValue: bolus), + scheduledTime: WatchCommand.trioScheduledTime(consumedAt) + ) { success, error in + if success, carbs > 0 { + DispatchQueue.main.async { + QuickPickMealsManager.shared.recordMeal(carbs: carbs, fat: fat, protein: protein, bolus: bolus) + } + } + sent(success, error) + } + + case let .startOverride(presetID): + guard let override = ProfileManager.shared.trioOverrides.first(where: { $0.name == presetID }) else { + return finish(false, "The override preset was not found.") + } + manager.sendOverridePushNotification(override: override, completion: sent) + + case .cancelOverride: + manager.sendCancelOverridePushNotification(completion: sent) + + case let .startTempTarget(presetID): + guard let preset = TempTargetPresetManager.shared.presets.first(where: { $0.id.uuidString == presetID }) else { + return finish(false, "The temp target preset was not found.") + } + manager.sendTempTargetPushNotification(target: preset.target, duration: preset.duration, completion: sent) + + case .cancelTempTarget: + manager.sendCancelTempTargetPushNotification(completion: sent) + + case .snoozeAlarm: + finish(false, "Unknown request.") + } + } +} diff --git a/LoopFollow/Watch/WatchCredentialsBuilder.swift b/LoopFollow/Watch/WatchCredentialsBuilder.swift new file mode 100644 index 000000000..52ef3965d --- /dev/null +++ b/LoopFollow/Watch/WatchCredentialsBuilder.swift @@ -0,0 +1,41 @@ +// LoopFollow +// WatchCredentialsBuilder.swift + +import Foundation + +/// Collects what the watch needs to fetch data and send commands on its own. +enum WatchCredentialsBuilder { + static func build() -> WatchCredentials { + WatchCredentials( + nightscoutURL: Storage.shared.url.value, + nightscoutToken: Storage.shared.token.value, + dexcomUsername: Storage.shared.shareUserName.value, + dexcomPassword: Storage.shared.sharePassword.value, + dexcomServer: Storage.shared.shareServer.value, + remote: remote() + ) + } + + private static func remote() -> WatchCredentials.Remote? { + guard Storage.shared.watchRemoteCommands.value, let kind = WatchStateBuilder.remoteKind() else { return nil } + let signing = RemoteSigningKey.resolve( + lfTeamID: BuildDetails.default.teamID ?? "", + remoteTeamID: Storage.shared.teamId.value ?? "", + lfKey: (Storage.shared.lfApnsKey.value, Storage.shared.lfKeyId.value), + remoteKey: (Storage.shared.remoteApnsKey.value, Storage.shared.remoteKeyId.value) + ) + return WatchCredentials.Remote( + kind: kind, + deviceToken: Storage.shared.deviceToken.value, + bundleID: Storage.shared.bundleId.value, + production: Storage.shared.productionEnvironment.value, + apnsKey: signing.key, + keyID: signing.keyID, + teamID: signing.teamID, + user: Storage.shared.user.value, + sharedSecret: Storage.shared.sharedSecret.value, + loopQRCodeURL: Storage.shared.loopAPNSQrCodeURL.value, + returnNotification: ReturnNotificationInfo.loopFollow() + ) + } +} diff --git a/LoopFollow/Watch/WatchSessionManager.swift b/LoopFollow/Watch/WatchSessionManager.swift new file mode 100644 index 000000000..08a9aacb1 --- /dev/null +++ b/LoopFollow/Watch/WatchSessionManager.swift @@ -0,0 +1,210 @@ +// LoopFollow +// WatchSessionManager.swift + +import Combine +import Foundation +import WatchConnectivity + +/// Phone side of the Apple Watch link. Publishes `WatchState` as the +/// application context and relays commands from the watch to +/// `WatchCommandHandler`. +final class WatchSessionManager: NSObject { + static let shared = WatchSessionManager() + + /// Complication pushes are rationed by watchOS (about 50 per day), so they + /// are only used when a new reading arrives and this long has passed. + private let complicationTransferInterval: TimeInterval = 15 * 60 + + private var lastSentState: WatchState? + private var lastSentCredentials: WatchCredentials? + private var lastComplicationTransfer: Date = .distantPast + private var cancellables = Set() + private lazy var coalescer = RefreshCoalescer(delay: 2) { [weak self] reason in + self?.publish(reason: reason) + } + + private var session: WCSession? { + WCSession.isSupported() ? WCSession.default : nil + } + + func activate() { + guard let session else { return } + session.delegate = self + session.activate() + observeSettings() + } + + /// Schedules a state update. Safe to call often; requests are coalesced. + func requestUpdate(reason: String) { + guard Thread.isMainThread else { + DispatchQueue.main.async { self.requestUpdate(reason: reason) } + return + } + guard let session, session.activationState == .activated, session.isPaired, session.isWatchAppInstalled else { return } + coalescer.request(reason: reason) + } + + private func observeSettings() { + let triggers: [AnyPublisher] = [ + Observable.shared.currentAlarm.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.watchRemoteCommands.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.remoteType.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.colorBGText.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.units.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.mealWithBolus.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.mealWithFatProtein.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.infoDisplayItems.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.graphTimeZoneEnabled.$value.map { _ in () }.eraseToAnyPublisher(), + ] + Publishers.MergeMany(triggers) + .dropFirst(triggers.count) + .sink { [weak self] in self?.requestUpdate(reason: "settings") } + .store(in: &cancellables) + + let credentialTriggers: [AnyPublisher] = [ + Storage.shared.url.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.token.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.shareUserName.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.sharePassword.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.shareServer.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.remoteType.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.watchRemoteCommands.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.deviceToken.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.bundleId.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.productionEnvironment.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.teamId.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.lfApnsKey.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.lfKeyId.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.remoteApnsKey.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.remoteKeyId.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.user.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.sharedSecret.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.loopAPNSQrCodeURL.$value.map { _ in () }.eraseToAnyPublisher(), + Observable.shared.loopFollowDeviceToken.$value.map { _ in () }.eraseToAnyPublisher(), + ] + Publishers.MergeMany(credentialTriggers) + .debounce(for: .seconds(2), scheduler: DispatchQueue.main) + .sink { [weak self] in self?.sendCredentialsIfChanged(force: false) } + .store(in: &cancellables) + } + + /// Sends the credentials when they differ from what was last sent, or when + /// the watch asks for them. They never travel with the state updates. + private func sendCredentialsIfChanged(force: Bool) { + guard let session, session.activationState == .activated, session.isPaired, session.isWatchAppInstalled else { return } + let credentials = WatchCredentialsBuilder.build() + guard force || credentials != lastSentCredentials, + let data = try? JSONEncoder().encode(credentials) + else { return } + for transfer in session.outstandingUserInfoTransfers where transfer.userInfo[WatchCredentials.userInfoKey] != nil { + transfer.cancel() + } + // Immediate when the watch app is reachable; the queued transfer covers the rest. + if session.isReachable { + session.sendMessage([WatchCredentials.userInfoKey: data], replyHandler: nil, errorHandler: nil) + } + session.transferUserInfo([WatchCredentials.userInfoKey: data]) + lastSentCredentials = credentials + LogManager.shared.log(category: .general, message: "[Watch] credentials sent") + } + + /// Sends the info table and statistics to the watch whenever they change. + func observe(_ infoManager: InfoManager, stats: StatsDisplayModel) { + infoManager.objectWillChange + .sink { [weak self] in self?.requestUpdate(reason: "info") } + .store(in: &cancellables) + stats.objectWillChange + .sink { [weak self] in self?.requestUpdate(reason: "stats") } + .store(in: &cancellables) + } + + private func publish(reason: String) { + guard let session, session.activationState == .activated else { return } + let state = WatchStateBuilder.build() + guard let data = state.encoded() else { return } + + do { + try session.updateApplicationContext([WatchState.contextKey: data]) + } catch { + LogManager.shared.log(category: .general, message: "[Watch] context update failed: \(error.localizedDescription)") + } + + let newReading = state.snapshot?.updatedAt != lastSentState?.snapshot?.updatedAt + if newReading, + session.isComplicationEnabled, + session.remainingComplicationUserInfoTransfers > 0, + Date().timeIntervalSince(lastComplicationTransfer) >= complicationTransferInterval + { + session.transferCurrentComplicationUserInfo([WatchState.contextKey: data]) + lastComplicationTransfer = Date() + } + + lastSentState = state + LogManager.shared.log(category: .general, message: "[Watch] state sent (\(data.count) bytes) reason=\(reason)", isDebug: true) + } +} + +// MARK: - WCSessionDelegate + +extension WatchSessionManager: WCSessionDelegate { + func session(_: WCSession, activationDidCompleteWith state: WCSessionActivationState, error: Error?) { + if let error { + LogManager.shared.log(category: .general, message: "[Watch] activation failed: \(error.localizedDescription)") + return + } + if state == .activated { + requestUpdate(reason: "activated") + DispatchQueue.main.async { self.sendCredentialsIfChanged(force: false) } + } + } + + func sessionDidBecomeInactive(_: WCSession) {} + + func sessionDidDeactivate(_ session: WCSession) { + // Required when the user switches to another watch. + session.activate() + } + + func sessionWatchStateDidChange(_: WCSession) { + requestUpdate(reason: "watchStateChanged") + } + + func session(_: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) { + DispatchQueue.main.async { + if message[WatchCredentials.requestKey] != nil { + let data = (try? JSONEncoder().encode(WatchCredentialsBuilder.build())) ?? Data() + replyHandler([WatchCredentials.userInfoKey: data]) + return + } + + if message[WatchMessageKey.requestState] != nil { + let data = WatchStateBuilder.build().encoded() ?? Data() + replyHandler([WatchState.contextKey: data]) + return + } + + guard let data = message[WatchCommand.messageKey] as? Data, + let command = try? JSONDecoder().decode(WatchCommand.self, from: data) + else { + Self.reply(WatchCommandReply(success: false, message: "Unknown request."), to: replyHandler) + return + } + + WatchCommandHandler.shared.handle(command) { result in + Self.reply(result, to: replyHandler) + self.requestUpdate(reason: "command") + } + } + } + + func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) { + if userInfo[WatchCredentials.requestKey] != nil { + DispatchQueue.main.async { self.sendCredentialsIfChanged(force: true) } + } + } + + private static func reply(_ reply: WatchCommandReply, to handler: ([String: Any]) -> Void) { + let data = (try? JSONEncoder().encode(reply)) ?? Data() + handler([WatchCommandReply.messageKey: data]) + } +} diff --git a/LoopFollow/Watch/WatchStateBuilder.swift b/LoopFollow/Watch/WatchStateBuilder.swift new file mode 100644 index 000000000..eaf59df86 --- /dev/null +++ b/LoopFollow/Watch/WatchStateBuilder.swift @@ -0,0 +1,233 @@ +// LoopFollow +// WatchStateBuilder.swift + +import Foundation +import HealthKit + +/// Collects the phone's current data into the state the watch renders. +enum WatchStateBuilder { + /// History sent to the watch; the watch chart scrolls back this far. + static let historyWindow: TimeInterval = 24 * 3600 + + static func build(now: Date = Date()) -> WatchState { + let thresholds = UnitSettingsStore.shared.effectiveThresholds() + let vc = MainViewController.shared + let cutoff = now.timeIntervalSince1970 - historyWindow + + let readings = (vc?.bgData ?? []) + .filter { $0.date >= cutoff && $0.sgv > 0 } + .map { WatchReading(t: $0.date, v: Double($0.sgv)) } + + let boluses = ((vc?.bolusData ?? []) + (vc?.smbData ?? [])) + .filter { $0.date >= cutoff } + .map { WatchReading(t: $0.date, v: $0.value) } + .sorted { $0.t < $1.t } + + let carbs = (vc?.carbData ?? []) + .filter { $0.date >= cutoff } + .map { WatchReading(t: $0.date, v: $0.value) } + + var predictions: [WatchPredictionLine] = [] + if let vc { + let lines: [(WatchPredictionLine.Kind, [ShareGlucoseData])] = [ + (.loop, vc.predictionData), + (.zt, vc.ztPredictionData), + (.iob, vc.iobPredictionData), + (.cob, vc.cobPredictionData), + (.uam, vc.uamPredictionData), + ] + for (kind, data) in lines where !data.isEmpty { + predictions.append(WatchPredictionLine(kind: kind, points: data.map { WatchReading(t: $0.date, v: Double($0.sgv)) })) + } + } + + let cone = (vc?.chartModel.cone ?? []).map { + WatchConePoint(t: $0.date.timeIntervalSince1970, low: $0.yMin, high: $0.yMax) + } + + return WatchState( + builtAt: now, + source: .phone, + phoneBuiltAt: now, + snapshot: GlucoseSnapshotBuilder.build(from: StorageCurrentGlucoseStateProvider()), + readings: readings, + predictions: predictions, + cone: cone, + boluses: boluses, + carbs: carbs, + lowMgdl: thresholds.low, + highMgdl: thresholds.high, + colorBGText: Storage.shared.colorBGText.value, + infoRows: infoRows(vc), + stats: vc.map { stats($0.statsDisplayModel) }, + device: Storage.shared.device.value, + alarm: currentAlarm(), + remote: remoteConfig() + ) + } + + /// Same rows, order and coloring as the phone's info table (InfoTableView). + private static func infoRows(_ vc: MainViewController?) -> [WatchInfoRow] { + var rows: [WatchInfoRow] = [] + if Storage.shared.graphTimeZoneEnabled.value, + let tz = TimeZone(identifier: Storage.shared.graphTimeZoneIdentifier.value) + { + rows.append(WatchInfoRow(name: "Time Zone", value: tz.identifier, level: nil)) + } + guard let infoManager = vc?.infoManager else { return rows } + let items = Storage.shared.infoDisplayItems.value + for row in infoManager.visibleRows { + var level: InfoColorLevel? + if let numeric = row.numericValue, + let type = InfoType(rawValue: row.id), + let config = type.colorConfig + { + level = items.item(for: type)?.coloring.level(for: numeric, direction: config.direction) + } + rows.append(WatchInfoRow(name: row.name, value: row.value.isEmpty ? "—" : row.value, level: level)) + } + return rows + } + + private static func stats(_ model: StatsDisplayModel) -> WatchStats { + WatchStats( + lowPercent: model.lowPercent, + inRangePercent: model.inRangePercent, + highPercent: model.highPercent, + avgBG: model.avgBG, + estA1CTitle: model.estA1CTitle, + estA1C: model.estA1C, + stdDevTitle: model.stdDevTitle, + stdDev: model.stdDev, + pieLow: model.pieLow, + pieRange: model.pieRange, + pieHigh: model.pieHigh + ) + } + + private static func currentAlarm() -> WatchAlarm? { + guard let id = Observable.shared.currentAlarm.value, + let alarm = Storage.shared.alarms.value.first(where: { $0.id == id }) + else { return nil } + return WatchAlarm(id: id, name: alarm.name) + } + + // MARK: - Remote + + static func remoteKind() -> WatchRemoteConfig.Kind? { + switch Storage.shared.remoteType.value { + case .loopAPNS: + return Storage.shared.device.value == "Loop" ? .loop : nil + case .trc: + return Storage.shared.device.value == "Trio" ? .trio : nil + case .none: + return nil + } + } + + private static func remoteConfig() -> WatchRemoteConfig? { + guard Storage.shared.watchRemoteCommands.value, let kind = remoteKind() else { return nil } + + let maxBolus = Storage.shared.maxBolus.value.doubleValue(for: .internationalUnit()) + let bolusIncrement = max(0.001, Storage.shared.bolusIncrement.value.doubleValue(for: .internationalUnit())) + let maxCarbs = Storage.shared.maxCarbs.value.doubleValue(for: .gram()) + let mealWithFatProtein = kind == .trio && Storage.shared.mealWithFatProtein.value + let now = Date() + + let recommendedAt: TimeInterval? = kind == .trio + ? Observable.shared.enactedOrSuggested.value + : Observable.shared.alertLastLoopTime.value + + return WatchRemoteConfig( + kind: kind, + maxBolus: maxBolus, + bolusIncrement: bolusIncrement, + maxCarbs: maxCarbs, + maxFat: Storage.shared.maxFat.value.doubleValue(for: .gram()), + maxProtein: Storage.shared.maxProtein.value.doubleValue(for: .gram()), + mealWithBolus: kind == .trio && Storage.shared.mealWithBolus.value, + mealWithFatProtein: mealWithFatProtein, + recommendedBolus: Observable.shared.deviceRecBolus.value, + recommendedBolusAt: recommendedAt.map { Date(timeIntervalSince1970: $0) }, + overridePresets: kind == .trio ? trioOverridePresets() : loopOverridePresets(), + tempTargetPresets: kind == .trio ? tempTargetPresets() : [], + quickPickBoluses: QuickPickBolusesManager.computeQuickPickBoluses( + from: Storage.shared.remoteBolusHistory.value, + now: now, + stepIncrement: bolusIncrement, + maxBolus: maxBolus + ).map(\.units), + quickPickMeals: QuickPickMealsManager.computeQuickPickMeals( + from: Storage.shared.remoteMealHistory.value, + now: now, + carbStep: 1, + maxCarbs: maxCarbs, + includeFatProtein: mealWithFatProtein + ).map { WatchMealPick(carbs: $0.carbs, fat: $0.fat, protein: $0.protein, bolus: $0.bolus) } + ) + } + + private static func loopOverridePresets() -> [WatchPreset] { + ProfileManager.shared.loopOverrides.map { override in + var parts: [String] = [] + if override.insulinNeedsScaleFactor != 1 { + parts.append("\(Int(round(override.insulinNeedsScaleFactor * 100))) %") + } + if override.targetRange.count >= 2 { + let low = Localizer.formatQuantity(override.targetRange[0]) + let high = Localizer.formatQuantity(override.targetRange[1]) + parts.append(low == high ? low : "\(low)–\(high)") + } + parts.append(durationText(seconds: TimeInterval(override.duration ?? 0))) + let symbol = override.symbol.isEmpty ? "" : "\(override.symbol) " + return WatchPreset( + id: override.name, + name: symbol + override.name, + detail: parts.joined(separator: ", "), + durationSeconds: TimeInterval(override.duration ?? 0) + ) + } + } + + private static func trioOverridePresets() -> [WatchPreset] { + ProfileManager.shared.trioOverrides.map { override in + var parts: [String] = [] + if let percentage = override.percentage { + parts.append("\(Int(percentage)) %") + } + if let target = override.target { + parts.append(Localizer.formatQuantity(target)) + } + parts.append(durationText(seconds: (override.duration ?? 0) * 60)) + return WatchPreset( + id: override.name, + name: override.name, + detail: parts.joined(separator: ", "), + durationSeconds: (override.duration ?? 0) * 60 + ) + } + } + + private static func tempTargetPresets() -> [WatchPreset] { + TempTargetPresetManager.shared.presets.map { preset in + let target = Localizer.formatQuantity(preset.target) + let duration = durationText(seconds: preset.duration.doubleValue(for: .second())) + return WatchPreset( + id: preset.id.uuidString, + name: preset.name, + detail: "\(target), \(duration)", + durationSeconds: preset.duration.doubleValue(for: .second()), + targetMgdl: preset.target.doubleValue(for: .milligramsPerDeciliter) + ) + } + } + + private static func durationText(seconds: TimeInterval) -> String { + guard seconds > 0 else { return "Indefinite" } + let hours = Int(seconds) / 3600 + let minutes = (Int(seconds) % 3600) / 60 + if hours > 0, minutes > 0 { return "\(hours) h \(minutes) min" } + if hours > 0 { return "\(hours) h" } + return "\(minutes) min" + } +} diff --git a/LoopFollowWatch/Assets.xcassets/AppIcon.appiconset/1024.png b/LoopFollowWatch/Assets.xcassets/AppIcon.appiconset/1024.png new file mode 100644 index 000000000..a975ee063 Binary files /dev/null and b/LoopFollowWatch/Assets.xcassets/AppIcon.appiconset/1024.png differ diff --git a/LoopFollowWatch/Assets.xcassets/AppIcon.appiconset/Contents.json b/LoopFollowWatch/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..113d82165 --- /dev/null +++ b/LoopFollowWatch/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "1024.png", + "idiom" : "universal", + "platform" : "watchos", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/LoopFollowWatch/Assets.xcassets/Contents.json b/LoopFollowWatch/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/LoopFollowWatch/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/LoopFollowWatch/ChartPage.swift b/LoopFollowWatch/ChartPage.swift new file mode 100644 index 000000000..d354cdd3a --- /dev/null +++ b/LoopFollowWatch/ChartPage.swift @@ -0,0 +1,77 @@ +// LoopFollow +// ChartPage.swift + +import SwiftUI +import WatchKit + +/// The full chart on its own page. Tap the chart to scrub back in time with the +/// Digital Crown; tap again to return to now. The button in the corner changes +/// the time span. +struct ChartPage: View { + let state: WatchState + + @AppStorage("chartSpanHours") private var spanHours: Double = 3 + @State private var isScrubbing = false + @State private var scrubSteps = 0.0 + @FocusState private var crownFocused: Bool + + /// The crown moves in five-minute steps, back to the start of the 24 hours the phone sends. + private var maxSteps: Double { max(0, (24 - spanHours * 0.75) * 12) } + + var body: some View { + TimelineView(.periodic(from: .now, by: 60)) { context in + VStack(spacing: 2) { + if isScrubbing { + scrubHeader(now: context.date) + } + WatchChartView( + state: state, + now: context.date, + spanHours: spanHours, + scrubOffset: isScrubbing ? scrubSteps * 300 : nil + ) + // Keeps the axis labels clear of the page indicator. + .padding(.trailing, 10) + .contentShape(Rectangle()) + .onTapGesture { + isScrubbing.toggle() + scrubSteps = 0 + crownFocused = isScrubbing + } + } + .focusable(isScrubbing) + .focused($crownFocused) + .digitalCrownRotation($scrubSteps, from: 0, through: maxSteps, by: 1, sensitivity: .medium, isContinuous: false, isHapticFeedbackEnabled: true) + } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("\(Int(spanHours)) h") { + let index = WatchChartView.spans.firstIndex(of: spanHours) ?? 1 + spanHours = WatchChartView.spans[(index + 1) % WatchChartView.spans.count] + scrubSteps = min(scrubSteps, maxSteps) + } + .accessibilityLabel("Time span \(Int(spanHours)) hours") + } + } + } + + /// Time and BG at the marker. + private func scrubHeader(now: Date) -> some View { + let marker = WatchChartView.markerDate(state: state, now: now, spanHours: spanHours, scrubOffset: scrubSteps * 300) + let nearest = state.readings.min { abs($0.date.timeIntervalSince(marker)) < abs($1.date.timeIntervalSince(marker)) } + let reading = nearest.flatMap { abs($0.date.timeIntervalSince(marker)) <= 450 ? $0 : nil } + return HStack { + Text(marker.formatted(date: .omitted, time: .shortened)) + .foregroundStyle(.secondary) + Spacer() + if let reading { + Text(WatchFormat.glucose(reading.v, unit: state.snapshot?.unit ?? .mgdl)) + .foregroundStyle(WatchFormat.bgColor(reading.v, lowMgdl: state.lowMgdl, highMgdl: state.highMgdl)) + } else { + Text("—") + } + } + .font(.headline) + .monospacedDigit() + } +} diff --git a/LoopFollowWatch/ContentView.swift b/LoopFollowWatch/ContentView.swift new file mode 100644 index 000000000..24e470ca9 --- /dev/null +++ b/LoopFollowWatch/ContentView.swift @@ -0,0 +1,107 @@ +// LoopFollow +// ContentView.swift + +import SwiftUI + +/// Screens pushed on the main navigation stack. +enum WatchRoute: Hashable { + case treatments + case meal + case bolus + case bolusAmount + case overrides + case tempTargets + case confirm(CommandRequest) +} + +/// What a confirmation screen sends, and how it describes it. +struct CommandRequest: Hashable { + let title: String + let lines: [String] + let action: WatchCommand.Action + let deliversInsulin: Bool +} + +/// Owns the navigation path so a finished command can return to the glucose page. +final class WatchNavigator: ObservableObject { + @Published var path: [WatchRoute] = [] + + func push(_ route: WatchRoute) { + path.append(route) + } + + func popToRoot() { + path.removeAll() + } +} + +struct ContentView: View { + @EnvironmentObject private var session: WatchSession + @StateObject private var navigator = WatchNavigator() + + var body: some View { + NavigationStack(path: $navigator.path) { + if let state = session.state { + TabView { + GlucosePage(state: state) + ChartPage(state: state) + if let stats = state.stats { + StatsPage(state: state, stats: stats) + } + InfoPage(state: state) + } + .tabViewStyle(.verticalPage) + .navigationDestination(for: WatchRoute.self) { route in + destination(route, state: state) + } + } else { + WaitingForPhoneView() + } + } + .environmentObject(navigator) + } + + @ViewBuilder + private func destination(_ route: WatchRoute, state: WatchState) -> some View { + if let remote = state.remote { + switch route { + case .treatments: + TreatmentMenuView() + case .meal: + MealEntryView(remote: remote) + case .bolus: + BolusPickerView(remote: remote) + case .bolusAmount: + BolusAmountView(remote: remote) + case .overrides: + PresetListView(kind: .override, state: state, presets: remote.overridePresets) + case .tempTargets: + PresetListView(kind: .tempTarget, state: state, presets: remote.tempTargetPresets) + case let .confirm(request): + CommandConfirmView(request: request) + } + } else { + Text("Remote commands are turned off in LoopFollow on the iPhone.") + .multilineTextAlignment(.center) + } + } +} + +private struct WaitingForPhoneView: View { + var body: some View { + VStack(spacing: 8) { + Image(systemName: "iphone.radiowaves.left.and.right") + .font(.title2) + .foregroundStyle(.secondary) + Text("Waiting for LoopFollow on iPhone") + .font(.headline) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + Text("Open LoopFollow on the paired iPhone.") + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding() + } +} diff --git a/LoopFollowWatch/CrownSymbol.swift b/LoopFollowWatch/CrownSymbol.swift new file mode 100644 index 000000000..1b6d66f23 --- /dev/null +++ b/LoopFollowWatch/CrownSymbol.swift @@ -0,0 +1,11 @@ +// LoopFollow +// CrownSymbol.swift + +import WatchKit + +enum CrownSymbol { + /// The crown symbol turning the way that raises a value, for the wrist the watch is worn on. + static var increase: String { + WKInterfaceDevice.current().wristLocation == .left ? "digitalcrown.arrow.clockwise" : "digitalcrown.arrow.counterclockwise" + } +} diff --git a/LoopFollowWatch/DirectCommandSender.swift b/LoopFollowWatch/DirectCommandSender.swift new file mode 100644 index 000000000..b2320524f --- /dev/null +++ b/LoopFollowWatch/DirectCommandSender.swift @@ -0,0 +1,172 @@ +// LoopFollow +// DirectCommandSender.swift + +import Foundation + +/// Sends a command from the watch straight to Loop or Trio when the phone is +/// out of reach, with the credentials and guardrails the phone last sent. +enum DirectCommandSender { + /// At least this long between two insulin commands sent from the watch. + static let insulinInterval: TimeInterval = 60 + + private static let lastInsulinKey = "directLastInsulinAt" + private static let lastLoopOTPKey = "directLastLoopOTP" + + static func send(_ requested: WatchCommand.Action, credentials: WatchCredentials, config: WatchRemoteConfig) async -> WatchCommandReply { + guard let remote = credentials.remote, remote.kind == config.kind else { + return WatchCommandReply(success: false, message: "iPhone not reachable, and commands from the watch alone are not set up. Open LoopFollow on the iPhone once.") + } + + let action: WatchCommand.Action + switch WatchCommandValidator.validate(requested, kind: remote.kind, limits: config.limits) { + case let .failure(error): return WatchCommandReply(success: false, message: error.message) + case let .success(validated): action = validated + } + + if action.deliversInsulin, let last = UserDefaults.standard.object(forKey: lastInsulinKey) as? Date, + Date().timeIntervalSince(last) < insulinInterval + { + return WatchCommandReply(success: false, message: "Insulin was sent from the watch less than a minute ago. Check Loop or Trio before sending more.") + } + + let result: (Bool, String?) + switch remote.kind { + case .trio: result = await sendTrio(action, remote: remote, config: config) + case .loop: result = await sendLoop(action, remote: remote, config: config) + } + + LogManager.shared.log(category: .apns, message: "Direct command \(action.name) finished: success=\(result.0) \(result.1 ?? "")") + guard result.0 else { + return WatchCommandReply(success: false, message: result.1 ?? "Failed to send the command.") + } + if action.deliversInsulin { + UserDefaults.standard.set(Date(), forKey: lastInsulinKey) + } + return WatchCommandReply(success: true, message: "Sent from the watch without the iPhone. Check Loop or Trio for the result.") + } + + // MARK: - Trio + + private static func sendTrio(_ action: WatchCommand.Action, remote: WatchCredentials.Remote, config: WatchRemoteConfig) async -> (Bool, String?) { + var payload = CommandPayload(user: remote.user, commandType: .bolus, timestamp: Date().timeIntervalSince1970, returnNotification: remote.returnNotification) + switch action { + case let .bolus(units): + payload.bolusAmount = Decimal(units) + case let .meal(carbs, fat, protein, bolus, consumedAt): + payload.commandType = .meal + payload.carbs = carbs > 0 ? Int(carbs) : nil + payload.fat = fat > 0 ? Int(fat) : nil + payload.protein = protein > 0 ? Int(protein) : nil + payload.bolusAmount = bolus > 0 ? Decimal(bolus) : nil + payload.scheduledTime = WatchCommand.trioScheduledTime(consumedAt)?.timeIntervalSince1970 + case let .startOverride(presetID): + payload.commandType = .startOverride + payload.overrideName = presetID + case .cancelOverride: + payload.commandType = .cancelOverride + case let .startTempTarget(presetID): + guard let preset = config.tempTargetPresets.first(where: { $0.id == presetID }), let target = preset.targetMgdl else { + return (false, "The temp target preset was not found.") + } + payload.commandType = .tempTarget + payload.target = Int(target) + payload.duration = Int(preset.durationSeconds / 60) + case .cancelTempTarget: + payload.commandType = .cancelTempTarget + case .snoozeAlarm: + return (false, "Snoozing needs the iPhone.") + } + + guard let messenger = SecureMessenger(sharedSecret: remote.sharedSecret) else { + return (false, "Failed to initialize security module. Check shared secret.") + } + do { + let message = try EncryptedPushMessage(encryptedData: messenger.encrypt(payload), commandType: payload.commandType) + let body = try JSONEncoder().encode(message) + return await deliver(body: body, remote: remote, expiration: "600", collapseID: payload.apnsCollapseID) + } catch { + return (false, "Failed to encode or encrypt push message: \(error.localizedDescription)") + } + } + + // MARK: - Loop + + private static func sendLoop(_ action: WatchCommand.Action, remote: WatchCredentials.Remote, config: WatchRemoteConfig) async -> (Bool, String?) { + var payload: [String: Any] + var usedOTP: String? + + switch action { + case let .bolus(units): + guard let otp = loopOTP(remote) else { return (false, otpUnavailable) } + payload = LoopAPNSCommands.bolusCommandPayload(LoopAPNSPayload(type: .bolus, bolusAmount: units, otp: otp)) + usedOTP = otp + case let .meal(carbs, _, _, _, consumedAt): + guard let otp = loopOTP(remote) else { return (false, otpUnavailable) } + payload = LoopAPNSCommands.carbsCommandPayload(LoopAPNSPayload( + type: .carbs, + carbsAmount: carbs, + absorptionTime: WatchCommandValidator.loopAbsorptionHours, + consumedDate: consumedAt.dateUsingCurrentSeconds(), + otp: otp + )) + usedOTP = otp + case let .startOverride(presetID): + guard let preset = config.overridePresets.first(where: { $0.id == presetID }) else { + return (false, "The override preset was not found.") + } + payload = LoopAPNSCommands.overridePayload( + presetName: presetID, + duration: preset.durationSeconds > 0 ? preset.durationSeconds : nil, + returnInfo: remote.returnNotification + ) + case .cancelOverride: + payload = LoopAPNSCommands.cancelOverridePayload(returnInfo: remote.returnNotification) + case .startTempTarget, .cancelTempTarget, .snoozeAlarm: + return (false, "This command is not available for Loop.") + } + + if let usedOTP, let returnInfo = remote.returnNotification, + let messenger = OTPSecureMessenger(otpCode: usedOTP), + let encrypted = try? messenger.encrypt(returnInfo) + { + payload["encrypted_return_notification"] = encrypted + } + + do { + let body = try LoopAPNSCommands.apnsBody(payload) + let result = await deliver(body: body, remote: remote) + if result.0, let usedOTP { + UserDefaults.standard.set(usedOTP, forKey: lastLoopOTPKey) + } + return result + } catch { + return (false, "Failed to serialize APNS payload: \(error.localizedDescription)") + } + } + + private static let otpUnavailable = "No unused one-time code right now. Wait for the next code (up to 30 seconds) and try again." + + /// The current one-time code, unless the watch already used it: Loop rejects a code it has seen. + private static func loopOTP(_ remote: WatchCredentials.Remote) -> String? { + guard let otp = TOTPGenerator.extractOTPFromURL(remote.loopQRCodeURL) else { return nil } + return otp == UserDefaults.standard.string(forKey: lastLoopOTPKey) ? nil : otp + } + + private static func deliver(body: Data, remote: WatchCredentials.Remote, expiration: String? = nil, collapseID: String? = nil) async -> (Bool, String?) { + await withCheckedContinuation { continuation in + APNSDelivery.send( + body: body, + deviceToken: remote.deviceToken, + topic: remote.bundleID, + production: remote.production, + keyId: remote.keyID, + teamId: remote.teamID, + apnsKey: remote.apnsKey, + expiration: expiration, + collapseID: collapseID + ) { success, error in + continuation.resume(returning: (success, error)) + } + } + } +} diff --git a/LoopFollowWatch/GlucosePage.swift b/LoopFollowWatch/GlucosePage.swift new file mode 100644 index 000000000..ed5ba466a --- /dev/null +++ b/LoopFollowWatch/GlucosePage.swift @@ -0,0 +1,231 @@ +// LoopFollow +// GlucosePage.swift + +import Charts +import SwiftUI + +/// Main page: one large BG value with trend and delta, a two-hour sparkline, +/// and the remote commands in the bottom toolbar. +struct GlucosePage: View { + let state: WatchState + + @EnvironmentObject private var session: WatchSession + @Environment(\.isLuminanceReduced) private var isLuminanceReduced + + var body: some View { + TimelineView(.periodic(from: .now, by: 30)) { context in + VStack(spacing: 2) { + if let alarm = state.alarm { + AlarmBanner(alarm: alarm) + } + glucose(now: context.date) + if !isLuminanceReduced { + details + Sparkline(state: state, now: context.date) + .frame(minHeight: 36) + } + } + .toolbar { + ToolbarItem(placement: .topBarLeading) { + if !isLuminanceReduced, let iob = state.snapshot?.iob { + Text("IOB \(WatchFormat.iob(iob))") + .font(.footnote) + .lineLimit(1) + .minimumScaleFactor(0.6) + } + } + ToolbarItem(placement: .topBarTrailing) { + age(now: context.date) + } + if !isLuminanceReduced, let remote = state.remote { + ToolbarItemGroup(placement: .bottomBar) { + RemoteToolbar(state: state, remote: remote) + } + } + } + } + } + + @ViewBuilder + private func glucose(now: Date) -> some View { + if let snapshot = state.snapshot { + let isStale = now.timeIntervalSince(snapshot.updatedAt) >= WatchFormat.staleAfter + VStack(spacing: -4) { + HStack(alignment: .center, spacing: 4) { + Text(WatchFormat.glucose(snapshot.glucose, unit: snapshot.unit)) + .font(.system(size: 56, weight: .black)) + .foregroundStyle(WatchFormat.bgColor(snapshot.glucose, lowMgdl: state.lowMgdl, highMgdl: state.highMgdl, colored: state.colorBGText)) + .strikethrough(isStale, pattern: .solid, color: .red) + .lineLimit(1) + .minimumScaleFactor(0.5) + Text(WatchFormat.trendArrow(snapshot.trend)) + .font(.system(size: 28, weight: .black)) + } + Text(WatchFormat.delta(snapshot.delta, unit: snapshot.unit)) + .font(.title3) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .layoutPriority(1) + .accessibilityElement(children: .combine) + } else { + Text("No BG data") + .font(.headline) + .foregroundStyle(.secondary) + } + } + + /// Age of the reading, with a marker while the watch fetches on its own or cannot reach the phone. + @ViewBuilder + private func age(now: Date) -> some View { + HStack(spacing: 2) { + if state.source == .watch { + Image(systemName: "applewatch.radiowaves.left.and.right") + .foregroundStyle(.blue) + .accessibilityLabel("Fetched by the watch") + } else if !session.isReachable { + Image(systemName: "iphone.slash") + .foregroundStyle(.orange) + .accessibilityLabel("iPhone not reachable") + } + if let snapshot = state.snapshot { + Text(WatchFormat.minutesAgo(snapshot.updatedAt, now: now)) + .foregroundStyle(now.timeIntervalSince(snapshot.updatedAt) >= WatchFormat.staleAfter ? .red : .secondary) + } + } + .font(.footnote) + } + + @ViewBuilder + private var details: some View { + if let snapshot = state.snapshot { + if snapshot.isNotLooping { + Label("Not looping", systemImage: "exclamationmark.triangle.fill") + .font(.footnote) + .foregroundStyle(.red) + } else if let cob = snapshot.cob { + Text("COB \(WatchFormat.cob(cob))") + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + } +} + +/// Override, treatments and temp target, as in Apple's bottom-toolbar pattern. +private struct RemoteToolbar: View { + let state: WatchState + let remote: WatchRemoteConfig + @EnvironmentObject private var session: WatchSession + @EnvironmentObject private var navigator: WatchNavigator + + var body: some View { + Button { + navigator.push(.overrides) + } label: { + Image(systemName: "slider.horizontal.3") + } + .tint(state.snapshot?.override != nil ? .green : nil) + .accessibilityLabel("Overrides") + .disabled(!session.canSendCommands) + + Button { + navigator.push(.treatments) + } label: { + Image(systemName: "plus") + } + .tint(.blue) + .accessibilityLabel("Meal or bolus") + .disabled(!session.canSendCommands) + + if remote.kind == .trio { + Button { + navigator.push(.tempTargets) + } label: { + Image(systemName: "scope") + } + .tint(state.snapshot?.tempTargetMgdl != nil ? .green : nil) + .accessibilityLabel("Temp Target") + .disabled(!session.canSendCommands) + } else { + // Holds the trailing slot so + stays centered. + Button {} label: { + Image(systemName: "scope") + } + .hidden() + .accessibilityHidden(true) + } + } +} + +/// The last two hours in range colors, without axes, scaled to the readings and the range lines. +struct Sparkline: View { + let state: WatchState + let now: Date + + var body: some View { + let start = now.addingTimeInterval(-2 * 3600) + let readings = state.readings.filter { $0.date >= start } + let values = readings.map(\.v) + let bottom = min(values.min() ?? state.lowMgdl, state.lowMgdl) - 10 + let top = max(values.max() ?? state.highMgdl, state.highMgdl) + 10 + Chart { + RuleMark(y: .value("High", state.highMgdl)) + .foregroundStyle(Color.yellow.opacity(0.5)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3])) + RuleMark(y: .value("Low", state.lowMgdl)) + .foregroundStyle(Color.red.opacity(0.5)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3])) + ForEach(readings, id: \.t) { reading in + PointMark(x: .value("Time", reading.date), y: .value("BG", reading.v)) + .symbolSize(12) + .foregroundStyle(WatchFormat.bgColor(reading.v, lowMgdl: state.lowMgdl, highMgdl: state.highMgdl)) + } + } + .chartXScale(domain: start ... now) + .chartYScale(domain: bottom ... top) + .chartXAxis(.hidden) + .chartYAxis(.hidden) + .accessibilityHidden(true) + } +} + +/// Shown while an alarm is sounding on the phone. +struct AlarmBanner: View { + let alarm: WatchAlarm + @EnvironmentObject private var session: WatchSession + @State private var isSending = false + @State private var error: String? + + var body: some View { + Button { + Task { + isSending = true + let reply = await session.send(.snoozeAlarm) + isSending = false + error = reply.success ? nil : reply.message + } + } label: { + HStack { + Image(systemName: "bell.fill") + VStack(alignment: .leading) { + Text(alarm.name) + .lineLimit(1) + Text(isSending ? "Snoozing…" : "Tap to snooze") + .font(.caption2) + .opacity(0.8) + } + Spacer(minLength: 0) + } + } + .buttonStyle(.borderedProminent) + .tint(.red) + .disabled(isSending) + .alert("Snooze failed", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) { + Button("OK", role: .cancel) {} + } message: { + Text(error ?? "") + } + } +} diff --git a/LoopFollowWatch/Info.plist b/LoopFollowWatch/Info.plist new file mode 100644 index 000000000..cdb0808fa --- /dev/null +++ b/LoopFollowWatch/Info.plist @@ -0,0 +1,28 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + $(display_name) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(LOOP_FOLLOW_MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + WKApplication + + WKCompanionAppBundleIdentifier + com.$(unique_id).LoopFollow$(app_suffix) + + diff --git a/LoopFollowWatch/InfoPage.swift b/LoopFollowWatch/InfoPage.swift new file mode 100644 index 000000000..c8a6d5826 --- /dev/null +++ b/LoopFollowWatch/InfoPage.swift @@ -0,0 +1,55 @@ +// LoopFollow +// InfoPage.swift + +import SwiftUI + +/// The phone's info table: the same rows, order and threshold coloring. +struct InfoPage: View { + let state: WatchState + + var body: some View { + ScrollView { + VStack(spacing: 0) { + if state.source == .watch, !state.infoRows.isEmpty { + Text("From the iPhone at \(state.phoneBuiltAt.formatted(date: .omitted, time: .shortened))") + .font(.caption2) + .foregroundStyle(.orange) + .frame(maxWidth: .infinity, alignment: .leading) + } + if state.infoRows.isEmpty { + Text("No info rows are shown on the iPhone.") + .font(.footnote) + .foregroundStyle(.secondary) + } + ForEach(Array(state.infoRows.enumerated()), id: \.offset) { _, row in + HStack { + Text(row.name) + .foregroundStyle(.secondary) + Spacer() + Text(row.value) + .foregroundStyle(Self.color(row.level)) + .multilineTextAlignment(.trailing) + } + .font(.footnote) + .lineLimit(1) + .minimumScaleFactor(0.6) + .padding(.vertical, 3) + Divider() + } + } + // Keeps the values clear of the page indicator. + .padding(.trailing, 10) + } + .navigationTitle("Info") + } + + /// Same colors as the phone's InfoColoring. + static func color(_ level: InfoColorLevel?) -> Color { + switch level { + case .urgent: .red + case .warning: .yellow + case .normal: .green + case nil: .primary + } + } +} diff --git a/LoopFollowWatch/LogManager.swift b/LoopFollowWatch/LogManager.swift new file mode 100644 index 000000000..5494d37f3 --- /dev/null +++ b/LoopFollowWatch/LogManager.swift @@ -0,0 +1,32 @@ +// LoopFollow +// LogManager.swift + +import Foundation +import os + +/// The watch's logger. Same call shape as the phone's LogManager so the code in +/// RemoteShared compiles for both; lines go to the unified system log. +final class LogManager { + static let shared = LogManager() + + enum Category: String { + case apns = "APNS" + case nightscout = "Nightscout" + case dexcom = "Dexcom" + case general = "General" + case remote = "Remote" + } + + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "LoopFollowWatch", category: "watch") + + private init() {} + + func log(category: Category, message: String, isDebug: Bool = false) { + let line = "[\(category.rawValue)] \(LogRedactor.sweep(message))" + if isDebug { + logger.debug("\(line, privacy: .public)") + } else { + logger.log("\(line, privacy: .public)") + } + } +} diff --git a/LoopFollowWatch/LoopFollowWatch.entitlements b/LoopFollowWatch/LoopFollowWatch.entitlements new file mode 100644 index 000000000..5b963cc90 --- /dev/null +++ b/LoopFollowWatch/LoopFollowWatch.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.$(unique_id).LoopFollow$(app_suffix) + + + diff --git a/LoopFollowWatch/LoopFollowWatchApp.swift b/LoopFollowWatch/LoopFollowWatchApp.swift new file mode 100644 index 000000000..8db456fa3 --- /dev/null +++ b/LoopFollowWatch/LoopFollowWatchApp.swift @@ -0,0 +1,38 @@ +// LoopFollow +// LoopFollowWatchApp.swift + +import SwiftUI + +@main +struct LoopFollowWatchApp: App { + @StateObject private var session = WatchSession.shared + @Environment(\.scenePhase) private var scenePhase + + init() { + WatchSession.shared.activate() + } + + var body: some Scene { + WindowGroup { + ContentView() + .environmentObject(session) + .task(id: scenePhase) { + guard scenePhase == .active else { return } + session.requestState() + session.scheduleBackgroundRefresh() + // While open, check every minute whether the watch has to fetch on its own. + while !Task.isCancelled { + await session.refreshIfNeeded() + try? await Task.sleep(nanoseconds: 60_000_000_000) + } + } + } + .backgroundTask(.watchConnectivity) { + await WatchSession.shared.waitForPendingContent() + } + .backgroundTask(.appRefresh(WatchSession.backgroundRefreshID)) { + await WatchSession.shared.refreshIfNeeded() + await WatchSession.shared.scheduleBackgroundRefresh() + } + } +} diff --git a/LoopFollowWatch/Remote/BolusEntryView.swift b/LoopFollowWatch/Remote/BolusEntryView.swift new file mode 100644 index 000000000..5e4064340 --- /dev/null +++ b/LoopFollowWatch/Remote/BolusEntryView.swift @@ -0,0 +1,154 @@ +// LoopFollow +// BolusEntryView.swift + +import SwiftUI + +/// Pick an amount: the AID's recommendation, the phone's Quick-Pick boluses, or +/// any amount with the Digital Crown. Every choice still needs the crown confirmation. +struct BolusPickerView: View { + let remote: WatchRemoteConfig + + @EnvironmentObject private var navigator: WatchNavigator + @State private var staleRecommendation: Double? + + /// Same cut-offs as the phone's bolus screens. + private let recommendationMaxAge: TimeInterval = 12 * 60 + private let recommendationWarnAge: TimeInterval = 5 * 60 + + private var step: Double { BolusAmountView.step(remote) } + + var body: some View { + List { + recommendation + ForEach(remote.quickPickBoluses.filter { $0 > 0 && $0 <= remote.maxBolus }, id: \.self) { units in + Button { + confirm(units) + } label: { + Text("\(WatchFormat.insulin(units)) U") + } + } + Button { + navigator.push(.bolusAmount) + } label: { + Text("Other Amount") + } + } + .navigationTitle("Bolus") + .alert( + "Old Calculation Warning", + isPresented: Binding(get: { staleRecommendation != nil }, set: { if !$0 { staleRecommendation = nil } }) + ) { + Button("Use Anyway") { + if let staleRecommendation { confirm(staleRecommendation) } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This recommended bolus was calculated more than 5 minutes ago. New treatments may have occurred since then. Proceed with caution.") + } + } + + @ViewBuilder + private var recommendation: some View { + if let rec = remote.recommendedBolus, rec > 0, let at = remote.recommendedBolusAt { + let age = Date().timeIntervalSince(at) + if age < recommendationMaxAge { + let stepped = BolusAmountView.roundedDown(min(rec, remote.maxBolus), step: step) + Section { + Button { + if age >= recommendationWarnAge { + staleRecommendation = stepped + } else { + confirm(stepped) + } + } label: { + VStack(alignment: .leading, spacing: 2) { + Text("Recommended \(WatchFormat.insulin(stepped)) U") + Text("Calculated \(Int(age / 60)) min ago") + .font(.caption2) + .foregroundStyle(age >= recommendationWarnAge ? .red : .secondary) + } + } + .disabled(stepped <= 0) + } + } + } + } + + private func confirm(_ units: Double) { + navigator.push(.confirm(BolusAmountView.request(units))) + } +} + +/// Any amount, set with the Digital Crown. +struct BolusAmountView: View { + let remote: WatchRemoteConfig + + @EnvironmentObject private var navigator: WatchNavigator + @State private var units = 0.0 + @FocusState private var amountFocused: Bool + + var body: some View { + ScrollView { + VStack(spacing: 8) { + AmountField(value: $units, unit: "U", step: Self.step(remote), maximum: remote.maxBolus, isHighlighted: amountFocused, format: WatchFormat.insulin) + .focused($amountFocused) + Text("Max \(WatchFormat.insulin(remote.maxBolus)) U") + .font(.footnote) + .foregroundStyle(.secondary) + Button("Continue") { + navigator.push(.confirm(Self.request(units))) + } + .buttonStyle(.borderedProminent) + .tint(.blue) + .disabled(units <= 0) + } + } + .navigationTitle("Bolus") + .onAppear { amountFocused = true } + } + + static func step(_ remote: WatchRemoteConfig) -> Double { + max(remote.bolusIncrement, 0.025) + } + + static func request(_ units: Double) -> CommandRequest { + CommandRequest(title: "Bolus", lines: ["\(WatchFormat.insulin(units)) U"], action: .bolus(units: units), deliversInsulin: true) + } + + static func roundedDown(_ value: Double, step: Double) -> Double { + let stepped = ((value / step) + 1e-10).rounded(.down) * step + return (stepped * 1000).rounded() / 1000 + } +} + +/// A value edited with the Digital Crown, in fixed steps between a minimum and a maximum. +struct AmountField: View { + @Binding var value: Double + let unit: String + let step: Double + var minimum: Double = 0 + let maximum: Double + /// The caller owns focus; this only draws the highlight. + let isHighlighted: Bool + let format: (Double) -> String + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(format(value)) + .font(.system(size: 34, weight: .bold)) + .monospacedDigit() + Text(unit) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 4) + .background(RoundedRectangle(cornerRadius: 8).stroke(isHighlighted ? Color.green : Color.gray.opacity(0.4), lineWidth: 1.5)) + .focusable() + .digitalCrownRotation($value, from: minimum, through: max(maximum, minimum + step), by: step, sensitivity: .medium, isContinuous: false, isHapticFeedbackEnabled: true) + .onChange(of: value) { _, newValue in + let nearest = ((newValue / step).rounded() * step * 1000).rounded() / 1000 + let clamped = min(max(minimum, nearest), maximum) + if clamped != newValue { value = clamped } + } + } +} diff --git a/LoopFollowWatch/Remote/CommandConfirmView.swift b/LoopFollowWatch/Remote/CommandConfirmView.swift new file mode 100644 index 000000000..d7e8cbbb7 --- /dev/null +++ b/LoopFollowWatch/Remote/CommandConfirmView.swift @@ -0,0 +1,176 @@ +// LoopFollow +// CommandConfirmView.swift + +import LocalAuthentication +import SwiftUI +import WatchKit + +/// Final step for every command: shows what will be sent, asks for confirmation +/// and shows the phone's reply. Insulin needs a full turn of the Digital Crown +/// and an unlocked watch on the wrist. A sent command is not yet delivered: +/// Loop or Trio confirms it later with a notification. +struct CommandConfirmView: View { + let request: CommandRequest + + @EnvironmentObject private var session: WatchSession + @EnvironmentObject private var navigator: WatchNavigator + @Environment(\.dismiss) private var dismiss + + /// How long the "sent" screen stays before returning to the glucose page. + private let sentDisplayTime: TimeInterval = 3 + + @State private var phase: Phase = .confirm + @State private var crownProgress = 0.0 + @State private var lastCrownChange = Date() + @FocusState private var crownFocused: Bool + + enum Phase: Equatable { + case confirm + case sending + case done(success: Bool, message: String) + } + + var body: some View { + ScrollView { + VStack(spacing: 8) { + switch phase { + case .confirm: + summary + if request.deliversInsulin { + crownConfirm + } else { + Button("Send") { Task { await send() } } + .buttonStyle(.borderedProminent) + .tint(.blue) + } + case .sending: + ProgressView() + Text("Sending…") + .foregroundStyle(.secondary) + case let .done(success, message): + if success { + Image(systemName: "paperplane.fill") + .font(.largeTitle) + .foregroundStyle(.blue) + Text("Sent") + .font(.headline) + Text(message) + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } else { + Image(systemName: "xmark.octagon.fill") + .font(.largeTitle) + .foregroundStyle(.red) + Text(message) + .font(.footnote) + .multilineTextAlignment(.center) + Button("OK") { dismiss() } + } + } + } + .frame(maxWidth: .infinity) + } + .navigationTitle(request.title) + .navigationBarBackButtonHidden(phase != .confirm) + } + + private var summary: some View { + VStack(spacing: 2) { + ForEach(request.lines, id: \.self) { line in + Text(line) + .font(.headline) + .multilineTextAlignment(.center) + } + } + } + + private var crownConfirm: some View { + VStack(spacing: 4) { + ZStack { + Circle() + .stroke(Color.gray.opacity(0.3), lineWidth: 6) + Circle() + .trim(from: 0, to: crownProgress) + .stroke(Color.blue, style: StrokeStyle(lineWidth: 6, lineCap: .round)) + .rotationEffect(.degrees(-90)) + Image(systemName: CrownSymbol.increase) + .font(.title3) + } + .frame(width: 56, height: 56) + Text("Turn the Digital Crown to confirm") + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .focusable() + .focused($crownFocused) + .digitalCrownRotation($crownProgress, from: 0, through: 1, by: 0.02, sensitivity: .low, isContinuous: false, isHapticFeedbackEnabled: true) + .onAppear { crownFocused = true } + .onChange(of: crownProgress) { _, value in + lastCrownChange = Date() + if value >= 1 { + WKInterfaceDevice.current().play(.success) + Task { await authenticateAndSend() } + } else { + resetCrownIfIdle() + } + } + } + + /// Partial turns reset after a pause, so a bump of the crown never adds up to a confirmation. + private func resetCrownIfIdle() { + let changedAt = lastCrownChange + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + if phase == .confirm, lastCrownChange == changedAt, crownProgress < 1 { + withAnimation { crownProgress = 0 } + } + } + } + + private func authenticateAndSend() async { + switch await Self.authenticate() { + case .success: + await send() + case let .failure(message): + crownProgress = 0 + phase = .done(success: false, message: message) + WKInterfaceDevice.current().play(.failure) + } + } + + private func send() async { + phase = .sending + let reply = await session.send(request.action) + phase = .done(success: reply.success, message: reply.message) + WKInterfaceDevice.current().play(reply.success ? .success : .failure) + if reply.success { + try? await Task.sleep(nanoseconds: UInt64(sentDisplayTime * 1_000_000_000)) + navigator.popToRoot() + } + } + + private enum AuthOutcome { + case success + case failure(String) + } + + /// Requires a passcode-protected watch that is unlocked on the wrist. + private static func authenticate() async -> AuthOutcome { + #if targetEnvironment(simulator) + return .success + #else + let context = LAContext() + var error: NSError? + guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithWristDetection, error: &error) else { + return .failure("Insulin can only be sent from a watch with a passcode and Wrist Detection turned on.") + } + do { + let ok = try await context.evaluatePolicy(.deviceOwnerAuthenticationWithWristDetection, localizedReason: "Confirm to send insulin") + return ok ? .success : .failure("Authentication failed.") + } catch { + return .failure("Authentication failed.") + } + #endif + } +} diff --git a/LoopFollowWatch/Remote/MealEntryView.swift b/LoopFollowWatch/Remote/MealEntryView.swift new file mode 100644 index 000000000..2c809d606 --- /dev/null +++ b/LoopFollowWatch/Remote/MealEntryView.swift @@ -0,0 +1,151 @@ +// LoopFollow +// MealEntryView.swift + +import SwiftUI + +/// Carbs, and for Trio fat, protein and a bolus per the phone's Meal settings, +/// as on the phone; the time behind a button. Everything goes as one command. +/// The crown scrolls the page until a field is tapped. +struct MealEntryView: View { + let remote: WatchRemoteConfig + + @EnvironmentObject private var navigator: WatchNavigator + @State private var carbs = 0.0 + @State private var fat = 0.0 + @State private var protein = 0.0 + @State private var bolus = 0.0 + @State private var offsetMinutes = 0.0 + @State private var showTime = false + @FocusState private var focus: Field? + + private enum Field: Hashable { + case carbs, fat, protein, bolus, time + } + + private var bolusStep: Double { BolusAmountView.step(remote) } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 6) { + quickPicks + + field("Carbs", .carbs) { + AmountField(value: $carbs, unit: "g", step: 1, maximum: remote.maxCarbs, isHighlighted: focus == .carbs, format: grams) + } + + if remote.mealWithFatProtein { + field("Fat", .fat) { + AmountField(value: $fat, unit: "g", step: 1, maximum: remote.maxFat, isHighlighted: focus == .fat, format: grams) + } + field("Protein", .protein) { + AmountField(value: $protein, unit: "g", step: 1, maximum: remote.maxProtein, isHighlighted: focus == .protein, format: grams) + } + } + if remote.mealWithBolus { + field("Bolus", .bolus) { + AmountField(value: $bolus, unit: "U", step: bolusStep, maximum: remote.maxBolus, isHighlighted: focus == .bolus, format: WatchFormat.insulin) + } + } + if showTime { + field("Time", .time) { + AmountField(value: $offsetMinutes, unit: "min", step: 5, minimum: -120, maximum: 60, isHighlighted: focus == .time, format: timeText) + } + } else { + Button { + showTime = true + focus = .time + } label: { + Label("Time", systemImage: "clock") + .font(.footnote) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxWidth: .infinity) + } + + Button { + navigator.push(.confirm(request(carbs: carbs, fat: fat, protein: protein, bolus: bolus, offsetMinutes: offsetMinutes))) + } label: { + Text(logTitle) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(.blue) + .disabled(carbs <= 0 && fat <= 0 && protein <= 0) + } + } + .navigationTitle("Meal") + } + + /// The phone's Quick-Pick meals, limited to what the phone's Meal settings allow. + @ViewBuilder + private var quickPicks: some View { + let picks = remote.quickPickMeals.filter { $0.carbs > 0 && $0.carbs <= remote.maxCarbs } + if !picks.isEmpty { + ForEach(Array(picks.enumerated()), id: \.offset) { _, pick in + let fat = remote.mealWithFatProtein ? pick.fat : 0 + let protein = remote.mealWithFatProtein ? pick.protein : 0 + let bolus = remote.mealWithBolus && pick.bolus <= remote.maxBolus ? pick.bolus : 0 + Button { + navigator.push(.confirm(request(carbs: pick.carbs, fat: fat, protein: protein, bolus: bolus, offsetMinutes: 0))) + } label: { + Text(pickTitle(carbs: pick.carbs, fat: fat, protein: protein, bolus: bolus)) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxWidth: .infinity) + } + } + } + + private func field(_ label: String, _ id: Field, content: () -> AmountField) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(.footnote) + .foregroundStyle(.secondary) + content() + .focused($focus, equals: id) + .onTapGesture { focus = id } + } + } + + private var logTitle: String { + if bolus > 0 { return "Log \(Int(carbs)) g + \(WatchFormat.insulin(bolus)) U" } + return "Log \(Int(carbs)) g" + } + + private func pickTitle(carbs: Double, fat: Double, protein: Double, bolus: Double) -> String { + var parts = ["\(Int(carbs)) g"] + if fat > 0 || protein > 0 { parts.append("F \(Int(fat)) P \(Int(protein))") } + if bolus > 0 { parts.append("\(WatchFormat.insulin(bolus)) U") } + return parts.joined(separator: " · ") + } + + private func grams(_ value: Double) -> String { + "\(Int(value))" + } + + private func timeText(_ minutes: Double) -> String { + let m = Int(minutes) + if m == 0 { return "Now" } + return m > 0 ? "+\(m)" : "\(m)" + } + + private func request(carbs: Double, fat: Double, protein: Double, bolus: Double, offsetMinutes: Double) -> CommandRequest { + var lines: [String] = [] + if carbs > 0 { lines.append("Carbs \(Int(carbs)) g") } + if fat > 0 { lines.append("Fat \(Int(fat)) g") } + if protein > 0 { lines.append("Protein \(Int(protein)) g") } + if bolus > 0 { lines.append("Bolus \(WatchFormat.insulin(bolus)) U") } + let at = Date().addingTimeInterval(offsetMinutes * 60) + lines.append(offsetMinutes == 0 ? "Now" : at.formatted(date: .omitted, time: .shortened)) + if bolus > 0, offsetMinutes != 0 { + // Same note as the phone's meal screen. + lines.append("The meal will be scheduled, but the bolus is enacted immediately.") + } + return CommandRequest( + title: "Meal", + lines: lines, + action: .meal(carbs: carbs, fat: fat, protein: protein, bolus: bolus, consumedAt: at), + deliversInsulin: bolus > 0 + ) + } +} diff --git a/LoopFollowWatch/Remote/PresetListView.swift b/LoopFollowWatch/Remote/PresetListView.swift new file mode 100644 index 000000000..71846db82 --- /dev/null +++ b/LoopFollowWatch/Remote/PresetListView.swift @@ -0,0 +1,77 @@ +// LoopFollow +// PresetListView.swift + +import SwiftUI + +/// Override or temp target presets synced from the phone, with a cancel row +/// while one is active. +struct PresetListView: View { + enum Kind { + case override + case tempTarget + } + + let kind: Kind + let state: WatchState + let presets: [WatchPreset] + + @EnvironmentObject private var navigator: WatchNavigator + + private var activeText: String? { + guard let snapshot = state.snapshot else { return nil } + switch kind { + case .override: + return snapshot.override + case .tempTarget: + return snapshot.tempTargetMgdl.map { WatchFormat.glucose($0, unit: snapshot.unit) } + } + } + + private var title: String { kind == .override ? "Overrides" : "Temp Target" } + + var body: some View { + List { + if let activeText { + Section("Active") { + Button { + navigator.push(.confirm(CommandRequest( + title: kind == .override ? "Cancel Override" : "Cancel Temp Target", + lines: ["Cancel \(activeText)?"], + action: kind == .override ? .cancelOverride : .cancelTempTarget, + deliversInsulin: false + ))) + } label: { + Label(activeText, systemImage: "xmark.circle") + .foregroundStyle(.red) + } + } + } + + Section("Presets") { + if presets.isEmpty { + Text(kind == .override ? "No override presets found." : "No temp target presets. Add them in LoopFollow on the iPhone.") + .font(.footnote) + .foregroundStyle(.secondary) + } + ForEach(presets) { preset in + Button { + navigator.push(.confirm(CommandRequest( + title: title, + lines: [preset.name, preset.detail], + action: kind == .override ? .startOverride(presetID: preset.id) : .startTempTarget(presetID: preset.id), + deliversInsulin: false + ))) + } label: { + VStack(alignment: .leading) { + Text(preset.name) + Text(preset.detail) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + } + } + .navigationTitle(title) + } +} diff --git a/LoopFollowWatch/Remote/TreatmentMenuView.swift b/LoopFollowWatch/Remote/TreatmentMenuView.swift new file mode 100644 index 000000000..830648630 --- /dev/null +++ b/LoopFollowWatch/Remote/TreatmentMenuView.swift @@ -0,0 +1,24 @@ +// LoopFollow +// TreatmentMenuView.swift + +import SwiftUI + +struct TreatmentMenuView: View { + @EnvironmentObject private var navigator: WatchNavigator + + var body: some View { + List { + Button { + navigator.push(.meal) + } label: { + Label("Meal", systemImage: "fork.knife") + } + Button { + navigator.push(.bolus) + } label: { + Label("Bolus", systemImage: "syringe") + } + } + .navigationTitle("Treatment") + } +} diff --git a/LoopFollowWatch/StandaloneFetcher.swift b/LoopFollowWatch/StandaloneFetcher.swift new file mode 100644 index 000000000..ea13ad588 --- /dev/null +++ b/LoopFollowWatch/StandaloneFetcher.swift @@ -0,0 +1,183 @@ +// LoopFollow +// StandaloneFetcher.swift + +import Foundation + +/// Builds the watch state straight from Nightscout or Dexcom while the phone is +/// out of reach. What only the phone knows (info rows, presets, guardrails, +/// thresholds) is kept from the last state the phone sent. +enum StandaloneFetcher { + enum FetchError: LocalizedError { + case noDataSource + case badResponse + + var errorDescription: String? { + switch self { + case .noDataSource: "No Nightscout or Dexcom account on the watch yet." + case .badResponse: "Unexpected response from the data source." + } + } + } + + private static let history: TimeInterval = 24 * 3600 + + static func fetch(credentials: WatchCredentials, base: WatchState?, now: Date = Date()) async throws -> WatchState { + if !credentials.nightscoutURL.isEmpty { + return try await fetchNightscout(credentials: credentials, base: base, now: now) + } + if !credentials.dexcomUsername.isEmpty, !credentials.dexcomPassword.isEmpty { + return try await fetchDexcom(credentials: credentials, base: base, now: now) + } + throw FetchError.noDataSource + } + + // MARK: - Nightscout + + private static func fetchNightscout(credentials: WatchCredentials, base: WatchState?, now: Date) async throws -> WatchState { + let since = now.addingTimeInterval(-history) + async let entries = get(credentials, "/api/v1/entries.json", [ + "find[date][$gte]": String(Int(since.timeIntervalSince1970 * 1000)), + "find[type][$ne]": "cal", + "count": "400", + ]) + async let deviceStatus = get(credentials, "/api/v1/devicestatus.json", ["count": "1"]) + async let treatments = get(credentials, "/api/v1/treatments.json", [ + "find[created_at][$gte]": DeviceStatusSummary.iso.string(from: since), + "count": "500", + ]) + + // Nightscout dates are milliseconds; the rest of the app works in seconds, as on the phone. + let glucose = try JSONDecoder().decode([ShareGlucoseData].self, from: await entries) + .filter { $0.sgv > 0 } + .map { ShareGlucoseData(sgv: $0.sgv, date: $0.date / 1000, direction: $0.direction) } + .sorted { $0.date < $1.date } + let status = (try? JSONSerialization.jsonObject(with: await deviceStatus) as? [[String: Any]])?.first + let treatmentList = (try? JSONSerialization.jsonObject(with: await treatments) as? [[String: Any]]) ?? [] + + return build(glucose: glucose, status: status.map { DeviceStatusSummary.parse($0, showsCone: base?.cone.isEmpty == false) }, treatments: treatmentList, base: base, now: now) + } + + private static func get(_ credentials: WatchCredentials, _ path: String, _ parameters: [String: String]) async throws -> Data { + var components = URLComponents(string: credentials.nightscoutURL) + components?.path = path + var items = parameters.map { URLQueryItem(name: $0.key, value: $0.value) } + if !credentials.nightscoutToken.isEmpty { + items.append(URLQueryItem(name: "token", value: credentials.nightscoutToken)) + } + components?.queryItems = items + guard let url = components?.url else { throw FetchError.badResponse } + var request = URLRequest(url: url) + request.timeoutInterval = 20 + let (data, response) = try await URLSession.shared.data(for: request) + guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw FetchError.badResponse } + return data + } + + // MARK: - Dexcom + + private static func fetchDexcom(credentials: WatchCredentials, base: WatchState?, now: Date) async throws -> WatchState { + let server = credentials.dexcomServer == "US" ? KnownShareServers.US.rawValue : KnownShareServers.NON_US.rawValue + let client = ShareClient(username: credentials.dexcomUsername, password: credentials.dexcomPassword, shareServer: server) + let glucose: [ShareGlucoseData] = try await withCheckedThrowingContinuation { continuation in + client.fetchData(288) { error, data in + if let data { + continuation.resume(returning: data) + } else { + continuation.resume(throwing: error ?? FetchError.badResponse) + } + } + } + return build(glucose: glucose.sorted { $0.date < $1.date }, status: nil, treatments: [], base: base, now: now) + } + + // MARK: - Building + + private static func build(glucose: [ShareGlucoseData], status: DeviceStatusSummary?, treatments: [[String: Any]], base: WatchState?, now: Date) -> WatchState { + let cutoff = now.timeIntervalSince1970 - history + let readings = glucose.filter { $0.date >= cutoff }.map { WatchReading(t: $0.date, v: Double($0.sgv)) } + let latest = glucose.last + let previous = glucose.dropLast().last + + let active = activeAdjustments(treatments, now: now) + var snapshot: GlucoseSnapshot? + if let latest { + let loopAge = status?.loopAt.map { now.timeIntervalSince($0) } + snapshot = GlucoseSnapshot( + glucose: Double(latest.sgv), + delta: previous.map { Double(latest.sgv - $0.sgv) } ?? 0, + trend: GlucoseSnapshot.Trend(code: latest.direction), + updatedAt: Date(timeIntervalSince1970: latest.date), + iob: status?.iob, + cob: status?.cob, + projected: nil, + override: active.override, + tempTargetMgdl: active.tempTargetMgdl, + recBolus: status?.recommendedBolus, + unit: base?.snapshot?.unit ?? .mgdl, + isNotLooping: loopAge.map { $0 >= 15 * 60 } ?? false + ) + } + + var remote = base?.remote + if remote != nil { + remote?.recommendedBolus = status?.recommendedBolus + remote?.recommendedBolusAt = status?.loopAt + } + + return WatchState( + builtAt: now, + source: .watch, + phoneBuiltAt: base?.phoneBuiltAt ?? .distantPast, + snapshot: snapshot, + readings: readings, + predictions: status?.predictions ?? [], + cone: status?.cone ?? [], + boluses: treatments.compactMap { reading($0, key: "insulin", cutoff: cutoff) }.sorted { $0.t < $1.t }, + carbs: treatments.compactMap { reading($0, key: "carbs", cutoff: cutoff) }.sorted { $0.t < $1.t }, + lowMgdl: base?.lowMgdl ?? 70, + highMgdl: base?.highMgdl ?? 180, + colorBGText: base?.colorBGText ?? true, + infoRows: base?.infoRows ?? [], + stats: base?.stats, + device: base?.device ?? "", + alarm: nil, + remote: remote + ) + } + + private static func reading(_ treatment: [String: Any], key: String, cutoff: TimeInterval) -> WatchReading? { + guard let value = treatment[key] as? Double, value > 0, + let at = DeviceStatusSummary.date(treatment["timestamp"] ?? treatment["created_at"]), + at.timeIntervalSince1970 >= cutoff + else { return nil } + return WatchReading(t: at.timeIntervalSince1970, v: value) + } + + /// The override and temp target that are running now, from the latest of each in the treatments. + private static func activeAdjustments(_ treatments: [[String: Any]], now: Date) -> (override: String?, tempTargetMgdl: Double?) { + func latest(_ eventType: String) -> (start: Date, entry: [String: Any])? { + treatments + .filter { $0["eventType"] as? String == eventType } + .compactMap { entry in DeviceStatusSummary.date(entry["timestamp"] ?? entry["created_at"]).map { ($0, entry) } } + .max { $0.0 < $1.0 } + } + + var override: String? + if let (start, entry) = latest("Temporary Override") { + let minutes = entry["duration"] as? Double ?? 0 + let indefinite = minutes == 0 || (entry["durationType"] as? String) == "indefinite" + if start <= now, indefinite || now < start.addingTimeInterval(minutes * 60) { + override = entry["reason"] as? String ?? entry["notes"] as? String ?? "Override" + } + } + + var tempTarget: Double? + if let (start, entry) = latest("Temporary Target") { + let minutes = entry["duration"] as? Double ?? 0 + if minutes > 0, start <= now, now < start.addingTimeInterval(minutes * 60) { + tempTarget = entry["targetTop"] as? Double ?? entry["targetBottom"] as? Double + } + } + return (override, tempTarget) + } +} diff --git a/LoopFollowWatch/StatsPage.swift b/LoopFollowWatch/StatsPage.swift new file mode 100644 index 000000000..aa9057e2c --- /dev/null +++ b/LoopFollowWatch/StatsPage.swift @@ -0,0 +1,69 @@ +// LoopFollow +// StatsPage.swift + +import Charts +import SwiftUI + +/// The statistics panel from under the phone's chart: the same pie and values. +struct StatsPage: View { + let state: WatchState + let stats: WatchStats + + var body: some View { + VStack(spacing: 8) { + Chart(slices, id: \.0) { slice in + SectorMark(angle: .value("share", slice.1)) + .foregroundStyle(slice.2) + } + .chartLegend(.hidden) + // Shrinks so the page fits the smaller watch sizes without scrolling. + .frame(minHeight: 30, maxHeight: 70) + .accessibilityHidden(true) + + Grid(horizontalSpacing: 4, verticalSpacing: 6) { + GridRow { + value("Low:", stats.lowPercent) + value("In Range:", stats.inRangePercent) + value("High:", stats.highPercent) + } + GridRow { + value("Avg BG:", stats.avgBG) + value(stats.estA1CTitle, stats.estA1C) + value(stats.stdDevTitle, stats.stdDev) + } + } + .layoutPriority(1) + + if state.source == .watch { + Text("From the iPhone at \(state.phoneBuiltAt.formatted(date: .omitted, time: .shortened))") + .font(.caption2) + .foregroundStyle(.orange) + .layoutPriority(1) + } + } + // Keeps the values clear of the page indicator. + .padding(.trailing, 10) + .navigationTitle("Statistics") + } + + /// Same colors as the phone's pie: red low, green in range, yellow high. + private var slices: [(String, Double, Color)] { + [ + ("low", max(stats.pieLow, 0.1), .red), + ("range", max(stats.pieRange, 0.1), .green), + ("high", max(stats.pieHigh, 0.1), .yellow), + ] + } + + private func value(_ title: String, _ value: String) -> some View { + VStack(spacing: 0) { + Text(title) + .foregroundStyle(.secondary) + Text(value.isEmpty ? "—" : value) + } + .font(.caption2) + .lineLimit(1) + .minimumScaleFactor(0.6) + .frame(maxWidth: .infinity) + } +} diff --git a/LoopFollowWatch/WatchChartView.swift b/LoopFollowWatch/WatchChartView.swift new file mode 100644 index 000000000..b6be4c090 --- /dev/null +++ b/LoopFollowWatch/WatchChartView.swift @@ -0,0 +1,186 @@ +// LoopFollow +// WatchChartView.swift + +import Charts +import SwiftUI + +/// BG chart in the phone's colors: readings colored by range, dashed low/high +/// lines, prediction lines or cone in their phone colors, boluses and carbs as markers. +struct WatchChartView: View { + let state: WatchState + let now: Date + let spanHours: Double + /// How far back the chart is scrolled; nil when not scrubbing. + var scrubOffset: TimeInterval? + + static let spans: [Double] = [1, 3, 6, 12, 24] + + private var unit: GlucoseSnapshot.Unit { state.snapshot?.unit ?? .mgdl } + + var body: some View { + let window = visibleWindow + let readings = state.readings.filter { window.contains($0.date) } + let predictions = state.predictions.map { line in + (line.kind, line.points.filter { window.contains($0.date) }) + } + let cone = state.cone.filter { window.contains($0.date) } + let top = yTop(readings: readings, predictions: predictions.flatMap(\.1) + cone.map { WatchReading(t: $0.t, v: $0.high) }) + + Chart { + RuleMark(y: .value("High", state.highMgdl)) + .foregroundStyle(Color.yellow.opacity(0.5)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 3])) + RuleMark(y: .value("Low", state.lowMgdl)) + .foregroundStyle(Color.red.opacity(0.5)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 3])) + RuleMark(x: .value("Now", now)) + .foregroundStyle(Color.gray.opacity(0.5)) + .lineStyle(StrokeStyle(lineWidth: 1)) + + ForEach(cone, id: \.t) { point in + AreaMark( + x: .value("Time", point.date), + yStart: .value("Low", point.low), + yEnd: .value("High", min(point.high, top)) + ) + .foregroundStyle(Color.blue.opacity(0.4)) + } + + if let marker = markerDate { + RuleMark(x: .value("Marker", marker)) + .foregroundStyle(Color.white.opacity(0.8)) + .lineStyle(StrokeStyle(lineWidth: 1)) + } + + ForEach(predictions, id: \.0) { kind, points in + ForEach(points, id: \.t) { point in + LineMark( + x: .value("Time", point.date), + y: .value("BG", min(point.v, top)), + series: .value("Series", kind.rawValue) + ) + .foregroundStyle(Self.color(for: kind)) + .lineStyle(StrokeStyle(lineWidth: 1.5)) + } + } + + ForEach(readings, id: \.t) { reading in + PointMark( + x: .value("Time", reading.date), + y: .value("BG", min(reading.v, top)) + ) + .symbolSize(spanHours <= 3 ? 14 : 6) + .foregroundStyle(WatchFormat.bgColor(reading.v, lowMgdl: state.lowMgdl, highMgdl: state.highMgdl)) + } + + ForEach(state.carbs.filter { window.contains($0.date) }, id: \.t) { carb in + PointMark( + x: .value("Time", carb.date), + y: .value("BG", max(markerY(at: carb.date, top: top) - 30, 45)) + ) + .symbolSize(30) + .foregroundStyle(Color.orange.opacity(0.85)) + } + + ForEach(state.boluses.filter { window.contains($0.date) }, id: \.t) { bolus in + PointMark( + x: .value("Time", bolus.date), + y: .value("BG", markerY(at: bolus.date, top: top) + 30) + ) + .symbol(.triangle) + .symbolSize(24) + .foregroundStyle(Color.blue.opacity(0.85)) + } + } + .chartXScale(domain: window.lowerBound ... window.upperBound) + .chartYScale(domain: 39 ... top) + .chartXAxis { + AxisMarks(values: hourTicks(in: window)) { _ in + AxisGridLine().foregroundStyle(Color.gray.opacity(0.3)) + AxisValueLabel(format: .dateTime.hour()) + } + } + .chartYAxis { + AxisMarks(position: .trailing, values: yAxisValues(top: top)) { value in + AxisGridLine().foregroundStyle(Color.gray.opacity(0.3)) + AxisValueLabel { + if let mgdl = value.as(Double.self) { + Text(WatchFormat.glucose(mgdl, unit: unit)) + } + } + } + } + .accessibilityLabel("BG chart, last \(Int(spanHours)) hours.") + } + + private var visibleWindow: ClosedRange { + Self.window(state: state, now: now, spanHours: spanHours, scrubOffset: scrubOffset ?? 0) + } + + private var markerDate: Date? { + scrubOffset.map { Self.markerDate(state: state, now: now, spanHours: spanHours, scrubOffset: $0) } + } + + /// The visible span ends a little after now so the prediction stays visible; + /// while scrubbing it moves back by the scroll offset. + static func window(state: WatchState, now: Date, spanHours: Double, scrubOffset: TimeInterval) -> ClosedRange { + let span = spanHours * 3600 + let lookAhead = state.predictions.isEmpty && state.cone.isEmpty ? span * 0.05 : span * 0.25 + let end = now.addingTimeInterval(lookAhead - scrubOffset) + return end.addingTimeInterval(-span) ... end + } + + /// While scrubbing, the time the marker points at: three quarters across the chart. + static func markerDate(state: WatchState, now: Date, spanHours: Double, scrubOffset: TimeInterval) -> Date { + window(state: state, now: now, spanHours: spanHours, scrubOffset: scrubOffset).upperBound + .addingTimeInterval(-spanHours * 3600 * 0.25) + } + + /// Whole hours in the window, leaving out those too close to an edge for their label to fit. + private func hourTicks(in window: ClosedRange) -> [Date] { + let step = spanHours > 6 ? 6 : (spanHours > 3 ? 2 : 1) + let margin = (window.upperBound.timeIntervalSince(window.lowerBound)) * 0.06 + let calendar = Calendar.current + guard var tick = calendar.dateInterval(of: .hour, for: window.lowerBound)?.end else { return [] } + var ticks: [Date] = [] + while tick < window.upperBound { + let hour = calendar.component(.hour, from: tick) + if hour % step == 0, + tick.timeIntervalSince(window.lowerBound) > margin, + window.upperBound.timeIntervalSince(tick) > margin + { + ticks.append(tick) + } + tick = tick.addingTimeInterval(3600) + } + return ticks + } + + private func yTop(readings: [WatchReading], predictions: [WatchReading]) -> Double { + let highest = max(readings.map(\.v).max() ?? 0, predictions.map(\.v).max() ?? 0) + return min(max(250, highest + 20), 400) + } + + private func yAxisValues(top: Double) -> [Double] { + let steps: [Double] = unit == .mmol + ? [72, 144, 216, 288, 360].filter { $0 < top } + : [100, 200, 300].filter { $0 < top } + return steps + } + + /// Markers sit next to the BG reading closest in time: boluses above, carbs below, as on the phone. + private func markerY(at date: Date, top: Double) -> Double { + let nearest = state.readings.min { abs($0.date.timeIntervalSince(date)) < abs($1.date.timeIntervalSince(date)) } + return min(nearest?.v ?? 100, top - 30) + } + + static func color(for kind: WatchPredictionLine.Kind) -> Color { + switch kind { + case .loop: .purple + case .zt: Color(red: 0.443, green: 0.380, blue: 0.937) + case .iob: Color(red: 0.118, green: 0.588, blue: 0.988) + case .cob: .orange + case .uam: Color(red: 0.820, green: 0.169, blue: 0.969) + } + } +} diff --git a/LoopFollowWatch/WatchKeychain.swift b/LoopFollowWatch/WatchKeychain.swift new file mode 100644 index 000000000..3c53feb87 --- /dev/null +++ b/LoopFollowWatch/WatchKeychain.swift @@ -0,0 +1,46 @@ +// LoopFollow +// WatchKeychain.swift + +import Foundation +import Security + +/// Stores the credentials from the phone in the watch's Keychain. Items are +/// readable after the first unlock and never leave this watch, not even in a backup. +enum WatchKeychain { + private static let service = "LoopFollowWatch" + private static let account = "credentials" + + private static var query: [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + } + + static func save(_ credentials: WatchCredentials) { + guard let data = try? JSONEncoder().encode(credentials) else { return } + let attributes: [String: Any] = [ + kSecValueData as String: data, + 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: .general, message: "Keychain save failed: \(status)") + } + } + + static func load() -> WatchCredentials? { + 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 try? JSONDecoder().decode(WatchCredentials.self, from: data) + } +} diff --git a/LoopFollowWatch/WatchSession.swift b/LoopFollowWatch/WatchSession.swift new file mode 100644 index 000000000..c6101921b --- /dev/null +++ b/LoopFollowWatch/WatchSession.swift @@ -0,0 +1,269 @@ +// LoopFollow +// WatchSession.swift + +import Foundation +import WatchConnectivity +import WatchKit +import WidgetKit + +/// Watch side of the phone link. The phone is the main source: it sends +/// `WatchState` and relays commands. While the phone's data is stale and the +/// phone does not answer, the watch fetches from Nightscout or Dexcom itself +/// and sends commands directly. +@MainActor +final class WatchSession: NSObject, ObservableObject { + static let shared = WatchSession() + + /// Phone data older than this hands over to the watch's own fetching. + static let phoneStaleAfter: TimeInterval = 10 * 60 + /// Minimum time between two fetches made by the watch itself. + private static let standaloneInterval: TimeInterval = 60 + + @Published private(set) var state: WatchState? + @Published private(set) var isReachable = false + + private var session: WCSession { WCSession.default } + private var credentials: WatchCredentials? + private var lastPhoneBuiltAt: Date + private var lastStandaloneAttempt: Date = .distantPast + + override private init() { + let stored = WatchStateStore.load() + state = stored + lastPhoneBuiltAt = stored?.phoneBuiltAt ?? .distantPast + credentials = WatchKeychain.load() + super.init() + } + + func activate() { + guard WCSession.isSupported(), session.delegate == nil else { return } + session.delegate = self + session.activate() + } + + /// Commands go through the phone, or directly when the phone sent what that needs. + var canSendCommands: Bool { + isReachable || credentials?.remote != nil + } + + /// True while the phone's latest state is recent enough to rely on. + var isPhoneDataFresh: Bool { + Date().timeIntervalSince(lastPhoneBuiltAt) < Self.phoneStaleAfter + } + + /// Asks the phone for fresh state; the application context covers the case + /// where the phone is out of reach. + func requestState() { + guard session.activationState == .activated, session.isReachable else { return } + session.sendMessage([WatchMessageKey.requestState: true], replyHandler: { reply in + guard let data = reply[WatchState.contextKey] as? Data else { return } + Task { @MainActor in self.applyFromPhone(data) } + }, errorHandler: nil) + } + + /// Fetches from Nightscout or Dexcom when the phone's data is stale and the phone does not answer. + func refreshIfNeeded() async { + guard !isPhoneDataFresh else { return } + if session.activationState == .activated, session.isReachable { + requestState() + try? await Task.sleep(nanoseconds: 8_000_000_000) + guard !isPhoneDataFresh else { return } + } + guard let credentials, credentials.hasDataSource else { + LogManager.shared.log(category: .general, message: "Phone data is stale, but the watch has no data source credentials yet") + return + } + guard Date().timeIntervalSince(lastStandaloneAttempt) >= Self.standaloneInterval else { return } + lastStandaloneAttempt = Date() + do { + let fetched = try await StandaloneFetcher.fetch(credentials: credentials, base: state) + guard !isPhoneDataFresh else { return } + store(fetched) + LogManager.shared.log(category: .nightscout, message: "Standalone fetch: \(fetched.readings.count) readings") + } catch { + LogManager.shared.log(category: .nightscout, message: "Standalone fetch failed: \(error.localizedDescription)") + } + } + + /// Sends a command through the phone when it is reachable, otherwise directly. + /// Falls back to sending directly only when the phone turned out to be out of + /// reach before the command was delivered; a command that may have reached + /// the phone is never sent a second time. + func send(_ action: WatchCommand.Action) async -> WatchCommandReply { + if session.activationState == .activated, session.isReachable { + switch await sendThroughPhone(action) { + case let .replied(reply): return reply + case .notDelivered: break + } + } + if case .snoozeAlarm = action { + return WatchCommandReply(success: false, message: "Snoozing needs the iPhone, which is not reachable right now.") + } + guard let credentials, let config = state?.remote else { + return WatchCommandReply(success: false, message: "iPhone not reachable. Open LoopFollow on the iPhone and keep it near the watch.") + } + return await DirectCommandSender.send(action, credentials: credentials, config: config) + } + + private enum PhoneSendResult { + case replied(WatchCommandReply) + /// WatchConnectivity reported the phone unreachable, so the command never left the watch. + case notDelivered + } + + private func sendThroughPhone(_ action: WatchCommand.Action) async -> PhoneSendResult { + guard let data = try? JSONEncoder().encode(WatchCommand(action: action)) else { + return .replied(WatchCommandReply(success: false, message: "Could not encode the command.")) + } + let noReply = WatchCommandReply( + success: false, + message: "No reply from the iPhone. The command may still have been sent: check Loop or Trio before trying again." + ) + return await withCheckedContinuation { continuation in + let once = ResumeOnce(continuation) + session.sendMessage([WatchCommand.messageKey: data], replyHandler: { reply in + let result = (reply[WatchCommandReply.messageKey] as? Data) + .flatMap { try? JSONDecoder().decode(WatchCommandReply.self, from: $0) } + once.resume(.replied(result ?? WatchCommandReply(success: false, message: "Unexpected reply from the iPhone."))) + }, errorHandler: { error in + once.resume((error as? WCError)?.code == .notReachable ? .notDelivered : .replied(noReply)) + }) + DispatchQueue.main.asyncAfter(deadline: .now() + Self.phoneReplyTimeout) { + once.resume(.replied(noReply)) + } + } + } + + /// Longest wait for the phone's verdict before telling the user it is unknown. + private static let phoneReplyTimeout: TimeInterval = 30 + + /// Lets a WatchConnectivity background task finish once queued data has been delivered. + func waitForPendingContent() async { + activate() + for _ in 0 ..< 40 { + if session.activationState == .activated, !session.hasContentPending { return } + try? await Task.sleep(nanoseconds: 500_000_000) + } + } + + static let backgroundRefreshID = "standalone" + + /// Schedules the next background check for stale phone data. + func scheduleBackgroundRefresh() { + WKApplication.shared().scheduleBackgroundRefresh( + withPreferredDate: Date().addingTimeInterval(15 * 60), + userInfo: Self.backgroundRefreshID as NSString + ) { _ in } + } + + private func applyFromPhone(_ data: Data) { + guard let incoming = WatchState.decode(data), incoming.builtAt > lastPhoneBuiltAt else { return } + lastPhoneBuiltAt = incoming.builtAt + store(incoming) + } + + private func store(_ incoming: WatchState) { + guard let encoded = incoming.encoded() else { return } + let newAlarm = incoming.alarm != nil && incoming.alarm?.id != state?.alarm?.id + let complicationChanged = Self.complicationKey(incoming) != state.map(Self.complicationKey) + state = incoming + WatchStateStore.save(encoded) + // Widget reloads are budgeted, so only reload when something the complication shows has changed. + if complicationChanged { + WidgetCenter.shared.reloadAllTimelines() + } + + if newAlarm, WKApplication.shared().applicationState == .active { + WKInterfaceDevice.current().play(.notification) + } + } + + private static func complicationKey(_ state: WatchState) -> String { + let s = state.snapshot + return "\(s?.updatedAt.timeIntervalSince1970 ?? 0)|\(s?.unit.rawValue ?? "")|\(state.lowMgdl)|\(state.highMgdl)|\(state.colorBGText)" + } + + private func receiveCredentials(_ data: Data) { + guard let received = try? JSONDecoder().decode(WatchCredentials.self, from: data) else { + LogManager.shared.log(category: .general, message: "Could not decode credentials from the phone") + return + } + credentials = received + WatchKeychain.save(received) + LogManager.shared.log(category: .general, message: "Credentials received (remote commands: \(received.remote != nil))") + } + + /// Asks the phone for credentials when the watch has none, e.g. after a reinstall. + private func requestCredentialsIfMissing() { + guard credentials == nil, session.activationState == .activated else { return } + if session.isReachable { + session.sendMessage([WatchCredentials.requestKey: true], replyHandler: { reply in + guard let data = reply[WatchCredentials.userInfoKey] as? Data else { return } + Task { @MainActor in self.receiveCredentials(data) } + }, errorHandler: nil) + } else { + session.transferUserInfo([WatchCredentials.requestKey: true]) + } + } +} + +extension WatchSession: WCSessionDelegate { + nonisolated func session(_ session: WCSession, activationDidCompleteWith _: WCSessionActivationState, error _: Error?) { + let context = session.receivedApplicationContext[WatchState.contextKey] as? Data + let reachable = session.isReachable + Task { @MainActor in + if let context { self.applyFromPhone(context) } + self.isReachable = reachable + self.requestState() + self.requestCredentialsIfMissing() + } + } + + nonisolated func sessionReachabilityDidChange(_ session: WCSession) { + let reachable = session.isReachable + Task { @MainActor in + self.isReachable = reachable + if reachable { + self.requestState() + self.requestCredentialsIfMissing() + } + } + } + + nonisolated func session(_: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) { + guard let data = applicationContext[WatchState.contextKey] as? Data else { return } + Task { @MainActor in self.applyFromPhone(data) } + } + + nonisolated func session(_: WCSession, didReceiveMessage message: [String: Any]) { + guard let credentials = message[WatchCredentials.userInfoKey] as? Data else { return } + Task { @MainActor in self.receiveCredentials(credentials) } + } + + nonisolated func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) { + let state = userInfo[WatchState.contextKey] as? Data + let credentials = userInfo[WatchCredentials.userInfoKey] as? Data + Task { @MainActor in + if let state { self.applyFromPhone(state) } + if let credentials { self.receiveCredentials(credentials) } + } + } +} + +/// Resumes a continuation at most once, from whichever callback comes first. +private final class ResumeOnce: @unchecked Sendable { + private var continuation: CheckedContinuation? + private let lock = NSLock() + + init(_ continuation: CheckedContinuation) { + self.continuation = continuation + } + + func resume(_ value: T) { + lock.lock() + let pending = continuation + continuation = nil + lock.unlock() + pending?.resume(returning: value) + } +} diff --git a/LoopFollowWatchWidgets/GlucoseComplicationView.swift b/LoopFollowWatchWidgets/GlucoseComplicationView.swift new file mode 100644 index 000000000..7e42e5f29 --- /dev/null +++ b/LoopFollowWatchWidgets/GlucoseComplicationView.swift @@ -0,0 +1,132 @@ +// LoopFollow +// GlucoseComplicationView.swift + +import Charts +import SwiftUI +import WidgetKit + +struct GlucoseComplicationView: View { + let entry: GlucoseEntry + @Environment(\.widgetFamily) private var family + + private var snapshot: GlucoseSnapshot? { entry.state?.snapshot } + + private var isStale: Bool { + guard let snapshot else { return true } + return entry.date.timeIntervalSince(snapshot.updatedAt) >= WatchFormat.staleAfter + } + + private var bgText: String { + guard let snapshot else { return "--" } + return WatchFormat.glucose(snapshot.glucose, unit: snapshot.unit) + } + + private var bgColor: Color { + guard let state = entry.state, let snapshot else { return .secondary } + if isStale { return .secondary } + return WatchFormat.bgColor(snapshot.glucose, lowMgdl: state.lowMgdl, highMgdl: state.highMgdl, colored: state.colorBGText) + } + + private var trendText: String { + snapshot.map { WatchFormat.trendArrow($0.trend) } ?? "" + } + + private var deltaText: String { + snapshot.map { WatchFormat.delta($0.delta, unit: $0.unit) } ?? "" + } + + private var ageText: String { + snapshot.map { WatchFormat.minutesAgo($0.updatedAt, now: entry.date) } ?? "" + } + + var body: some View { + switch family { + case .accessoryCircular: + circular + case .accessoryRectangular: + rectangular + case .accessoryCorner: + corner + default: + inline + } + } + + private var circular: some View { + VStack(spacing: -2) { + Text(bgText) + .font(.system(size: 20, weight: .bold)) + .foregroundStyle(bgColor) + .strikethrough(isStale, color: .red) + .minimumScaleFactor(0.5) + Text(trendText) + .font(.system(size: 12, weight: .bold)) + Text(ageText) + .font(.system(size: 9)) + .foregroundStyle(.secondary) + } + .widgetAccentable() + } + + private var rectangular: some View { + HStack(spacing: 6) { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .firstTextBaseline, spacing: 2) { + Text(bgText) + .font(.system(size: 26, weight: .bold)) + .foregroundStyle(bgColor) + .strikethrough(isStale, color: .red) + Text(trendText) + .font(.system(size: 14, weight: .bold)) + } + Text("\(deltaText) \(ageText)") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + .lineLimit(1) + .minimumScaleFactor(0.6) + .widgetAccentable() + sparkline + } + } + + /// Last two hours of readings in range colors. + @ViewBuilder + private var sparkline: some View { + if let state = entry.state { + let start = entry.date.addingTimeInterval(-2 * 3600) + let readings = state.readings.filter { $0.date >= start } + Chart { + RuleMark(y: .value("High", state.highMgdl)) + .foregroundStyle(Color.yellow.opacity(0.4)) + .lineStyle(StrokeStyle(lineWidth: 0.5, dash: [2, 2])) + RuleMark(y: .value("Low", state.lowMgdl)) + .foregroundStyle(Color.red.opacity(0.4)) + .lineStyle(StrokeStyle(lineWidth: 0.5, dash: [2, 2])) + ForEach(readings, id: \.t) { reading in + PointMark(x: .value("Time", reading.date), y: .value("BG", min(max(reading.v, 40), 300))) + .symbolSize(6) + .foregroundStyle(WatchFormat.bgColor(reading.v, lowMgdl: state.lowMgdl, highMgdl: state.highMgdl)) + } + } + .chartXScale(domain: start ... entry.date) + .chartYScale(domain: 40 ... 300) + .chartXAxis(.hidden) + .chartYAxis(.hidden) + } + } + + private var corner: some View { + Text(bgText) + .font(.system(size: 18, weight: .bold)) + .foregroundStyle(bgColor) + .widgetCurvesContent() + .widgetLabel { + Text("\(trendText) \(deltaText) \(ageText)") + } + } + + private var inline: some View { + Text(snapshot == nil ? "LoopFollow --" : "\(bgText) \(trendText) \(deltaText) · \(ageText)") + } +} diff --git a/LoopFollowWatchWidgets/Info.plist b/LoopFollowWatchWidgets/Info.plist new file mode 100644 index 000000000..c8ec08987 --- /dev/null +++ b/LoopFollowWatchWidgets/Info.plist @@ -0,0 +1,29 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + $(display_name) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(LOOP_FOLLOW_MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/LoopFollowWatchWidgets/LoopFollowWatchWidgets.entitlements b/LoopFollowWatchWidgets/LoopFollowWatchWidgets.entitlements new file mode 100644 index 000000000..5b963cc90 --- /dev/null +++ b/LoopFollowWatchWidgets/LoopFollowWatchWidgets.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.$(unique_id).LoopFollow$(app_suffix) + + + diff --git a/LoopFollowWatchWidgets/LoopFollowWatchWidgets.swift b/LoopFollowWatchWidgets/LoopFollowWatchWidgets.swift new file mode 100644 index 000000000..456d3a9bb --- /dev/null +++ b/LoopFollowWatchWidgets/LoopFollowWatchWidgets.swift @@ -0,0 +1,50 @@ +// LoopFollow +// LoopFollowWatchWidgets.swift + +import SwiftUI +import WidgetKit + +@main +struct LoopFollowWatchWidgets: WidgetBundle { + var body: some Widget { + GlucoseComplication() + } +} + +struct GlucoseComplication: Widget { + var body: some WidgetConfiguration { + StaticConfiguration(kind: "GlucoseComplication", provider: GlucoseTimelineProvider()) { entry in + GlucoseComplicationView(entry: entry) + .containerBackground(.black, for: .widget) + } + .configurationDisplayName("BG") + .description("Latest BG from LoopFollow on your iPhone.") + .supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline, .accessoryCorner]) + } +} + +struct GlucoseEntry: TimelineEntry { + let date: Date + let state: WatchState? +} + +/// Reads the state the watch app stored. One entry per minute keeps the +/// "minutes ago" text and the stale marking current until the next reload. +struct GlucoseTimelineProvider: TimelineProvider { + func placeholder(in _: Context) -> GlucoseEntry { + GlucoseEntry(date: Date(), state: nil) + } + + func getSnapshot(in _: Context, completion: @escaping (GlucoseEntry) -> Void) { + completion(GlucoseEntry(date: Date(), state: WatchStateStore.load())) + } + + func getTimeline(in _: Context, completion: @escaping (Timeline) -> Void) { + let state = WatchStateStore.load() + let now = Date() + let entries = (0 ..< 30).map { minute in + GlucoseEntry(date: now.addingTimeInterval(Double(minute) * 60), state: state) + } + completion(Timeline(entries: entries, policy: .atEnd)) + } +} diff --git a/RemoteShared/APNSDelivery.swift b/RemoteShared/APNSDelivery.swift new file mode 100644 index 000000000..9ec7d44c9 --- /dev/null +++ b/RemoteShared/APNSDelivery.swift @@ -0,0 +1,100 @@ +// LoopFollow +// APNSDelivery.swift + +import Foundation + +/// Sends one remote command push to the Loop or Trio app. Used by the phone and +/// by the watch when it sends without the phone. +enum APNSDelivery { + /// Calls `completion` on an arbitrary queue with success, or the reason for failure. + static func send( + body: Data, + deviceToken: String, + topic: String, + production: Bool, + keyId: String, + teamId: String, + apnsKey: String, + expiration: String? = nil, + collapseID: String? = nil, + completion: @escaping (Bool, String?) -> Void + ) { + guard let jwt = JWTManager.shared.getOrGenerateJWT(keyId: keyId, teamId: teamId, apnsKey: apnsKey) else { + let errorMessage = "Failed to generate JWT, please check that the APNS Key ID, APNS Key and Team ID are correct." + LogManager.shared.log(category: .apns, message: errorMessage) + completion(false, errorMessage) + return + } + + guard let url = URL(string: "\(APNSEnvironment.baseURL(production: production))/3/device/\(deviceToken)") else { + let errorMessage = "Failed to construct APNs URL" + LogManager.shared.log(category: .apns, message: errorMessage) + completion(false, errorMessage) + return + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("bearer \(jwt)", forHTTPHeaderField: "authorization") + request.setValue("application/json", forHTTPHeaderField: "content-type") + request.setValue("10", forHTTPHeaderField: "apns-priority") + request.setValue(topic, forHTTPHeaderField: "apns-topic") + request.setValue("alert", forHTTPHeaderField: "apns-push-type") + if let expiration { + request.setValue(expiration, forHTTPHeaderField: "apns-expiration") + } + if let collapseID { + request.setValue(collapseID, forHTTPHeaderField: "apns-collapse-id") + } + request.httpBody = body + + URLSession.shared.dataTask(with: request) { data, response, error in + if let error { + let errorMessage = "Failed to send push notification: \(error.localizedDescription)" + LogManager.shared.log(category: .apns, message: errorMessage) + completion(false, errorMessage) + return + } + guard let httpResponse = response as? HTTPURLResponse else { + let errorMessage = "Failed to get a valid HTTP response." + LogManager.shared.log(category: .apns, message: errorMessage) + completion(false, errorMessage) + return + } + + var reason = "" + if let data, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let value = json["reason"] as? String + { + reason = value + } + + if httpResponse.statusCode == 200 { + LogManager.shared.log(category: .apns, message: "APNS notification sent successfully") + completion(true, nil) + return + } + if httpResponse.statusCode == 403 { + JWTManager.shared.invalidateCache() + } + LogManager.shared.log(category: .apns, message: "APNS error \(httpResponse.statusCode): \(reason)") + completion(false, "\(message(forStatus: httpResponse.statusCode)) \(reason)") + }.resume() + } + + static func message(forStatus status: Int) -> String { + switch status { + case 400: "Bad request. The request was invalid or malformed." + case 403: "Authentication error. Check your certificate or authentication token." + case 404: "Invalid request: The :path value was incorrect." + case 405: "Invalid request: Only POST requests are supported." + case 410: "The device token is no longer active for the topic." + case 413: "Payload too large. The notification payload exceeded the size limit." + case 429: "Too many requests." + case 500: "Internal server error at APNs." + case 503: "Service unavailable. The server is temporarily unavailable. Try again later." + default: "Unexpected status code: \(status)." + } + } +} diff --git a/LoopFollow/Remote/APNSEnvironment.swift b/RemoteShared/APNSEnvironment.swift similarity index 100% rename from LoopFollow/Remote/APNSEnvironment.swift rename to RemoteShared/APNSEnvironment.swift diff --git a/LoopFollow/Helpers/DateExtensions.swift b/RemoteShared/DateExtensions.swift similarity index 100% rename from LoopFollow/Helpers/DateExtensions.swift rename to RemoteShared/DateExtensions.swift diff --git a/RemoteShared/DeviceStatusSummary.swift b/RemoteShared/DeviceStatusSummary.swift new file mode 100644 index 000000000..99216bba3 --- /dev/null +++ b/RemoteShared/DeviceStatusSummary.swift @@ -0,0 +1,92 @@ +// LoopFollow +// DeviceStatusSummary.swift + +import Foundation + +/// The parts of the latest Nightscout devicestatus the watch shows when it +/// fetches on its own. +struct DeviceStatusSummary: Equatable { + var loopAt: Date? + var iob: Double? + var cob: Double? + var recommendedBolus: Double? + var predictions: [WatchPredictionLine] = [] + var cone: [WatchConePoint] = [] + + /// How far ahead predictions are drawn. + static let predictionPoints = 36 + + /// Reads a devicestatus record the way the phone does: `loop` for Loop, `openaps` for Trio. + static func parse(_ status: [String: Any], showsCone: Bool) -> DeviceStatusSummary { + var result = DeviceStatusSummary() + if let loop = status["loop"] as? [String: Any] { + result.loopAt = date(loop["timestamp"]) + result.iob = (loop["iob"] as? [String: Any])?["iob"] as? Double + result.cob = (loop["cob"] as? [String: Any])?["cob"] as? Double + result.recommendedBolus = loop["recommendedBolus"] as? Double + if let predicted = loop["predicted"] as? [String: Any], + let values = predicted["values"] as? [Double], + let start = date(predicted["startDate"]) + { + result.predictions = [WatchPredictionLine(kind: .loop, points: points(values, start: start))] + } + } else if let openaps = status["openaps"] as? [String: Any] { + let suggested = openaps["suggested"] as? [String: Any] ?? openaps["enacted"] as? [String: Any] ?? [:] + result.loopAt = date(suggested["deliverAt"] ?? suggested["timestamp"]) + let iob = openaps["iob"] as? [String: Any] ?? (openaps["iob"] as? [[String: Any]])?.first + result.iob = iob?["iob"] as? Double ?? suggested["IOB"] as? Double + result.cob = suggested["COB"] as? Double + result.recommendedBolus = openaps["recommendedBolus"] as? Double + if let predBGs = suggested["predBGs"] as? [String: [Double]], let start = result.loopAt { + let lines: [(WatchPredictionLine.Kind, String)] = [(.zt, "ZT"), (.iob, "IOB"), (.cob, "COB"), (.uam, "UAM")] + let series = lines.compactMap { kind, key in predBGs[key].map { (kind, points($0, start: start)) } } + // The phone shows Trio's forecast as a cone or as lines; keep what it showed last. + if showsCone { + result.cone = cone(series.map(\.1)) + } else { + result.predictions = series.map { WatchPredictionLine(kind: $0.0, points: $0.1) } + } + } + } + return result + } + + private static func points(_ values: [Double], start: Date) -> [WatchReading] { + values.prefix(predictionPoints + 1).enumerated().map { index, value in + WatchReading(t: start.timeIntervalSince1970 + Double(index) * 300, v: value) + } + } + + /// Same construction as the phone: the shortest series sets the length, min and max per step. + private static func cone(_ series: [[WatchReading]]) -> [WatchConePoint] { + guard let length = series.map(\.count).min(), length > 0 else { return [] } + return (0 ..< length).map { index in + let values = series.map { $0[index].v } + var low = values.min() ?? 0 + var high = values.max() ?? 0 + if low == high { + low -= 1 + high += 1 + } + return WatchConePoint(t: series[0][index].t, low: low, high: high) + } + } + + // MARK: - Dates + + static let iso: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + private static let isoNoFraction = ISO8601DateFormatter() + + static func date(_ value: Any?) -> Date? { + if let number = value as? Double { + return Date(timeIntervalSince1970: number > 1e11 ? number / 1000 : number) + } + guard let string = value as? String else { return nil } + return iso.date(from: string) ?? isoNoFraction.date(from: string) + } +} diff --git a/LoopFollow/Helpers/JWTManager.swift b/RemoteShared/JWTManager.swift similarity index 100% rename from LoopFollow/Helpers/JWTManager.swift rename to RemoteShared/JWTManager.swift diff --git a/LoopFollow/Helpers/LogRedactor.swift b/RemoteShared/LogRedactor.swift similarity index 100% rename from LoopFollow/Helpers/LogRedactor.swift rename to RemoteShared/LogRedactor.swift diff --git a/RemoteShared/LoopAPNSCommands.swift b/RemoteShared/LoopAPNSCommands.swift new file mode 100644 index 000000000..0473df59e --- /dev/null +++ b/RemoteShared/LoopAPNSCommands.swift @@ -0,0 +1,165 @@ +// LoopFollow +// LoopAPNSCommands.swift + +import Foundation + +/// Loop remote command payloads, matching Nightscout's loop.js format. +enum LoopAPNSCommands { + /// Fields every Loop remote command carries. + static func commandHeader(otp: String, alert: String, now: Date) -> [String: Any] { + [ + "otp": otp, + "remote-address": "LoopFollow", + "notes": "Sent via LoopFollow APNS", + "entered-by": "LoopFollow", + "sent-at": formatDateForAPNS(now), + "expiration": formatDateForAPNS(now.addingTimeInterval(5 * 60)), + "alert": alert, + ] + } + + static func carbsCommandPayload(_ payload: LoopAPNSPayload, now: Date = Date()) -> [String: Any] { + let carbsAmount = payload.carbsAmount ?? 0.0 + let absorptionTime = payload.absorptionTime ?? 3.0 + var fields = commandHeader( + otp: payload.otp, + alert: "Remote Carbs Entry: \(String(format: "%.1f", carbsAmount)) grams\nAbsorption Time: \(String(format: "%.1f", absorptionTime)) hours", + now: now + ) + fields["carbs-entry"] = carbsAmount + fields["absorption-time"] = absorptionTime + fields["start-time"] = formatDateForAPNS(payload.consumedDate ?? now) + return fields + } + + static func bolusCommandPayload(_ payload: LoopAPNSPayload, now: Date = Date()) -> [String: Any] { + let bolusAmount = payload.bolusAmount ?? 0.0 + var fields = commandHeader(otp: payload.otp, alert: "Remote Bolus Entry: \(String(format: "%.2f", bolusAmount)) U", now: now) + fields["bolus-entry"] = bolusAmount + return fields + } + + static func carbsDeleteCommandPayload(syncIdentifier: String, otp: String, now: Date = Date()) -> [String: Any] { + var fields = commandHeader(otp: otp, alert: "Remote Carbs Delete", now: now) + fields["carbs-delete"] = syncIdentifier + return fields + } + + /// Keys are prefixed so an unpatched Loop rejects the command. Amount, absorption time and start time are always sent; food type only when set. + static func carbsEditCommandPayload( + syncIdentifier: String, + carbsAmount: Double, + absorptionTimeHours: Double, + foodType: String?, + consumedDate: Date, + otp: String, + now: Date = Date() + ) -> [String: Any] { + var fields = commandHeader( + otp: otp, + alert: "Remote Carbs Edit: \(String(format: "%.1f", carbsAmount)) grams\nAbsorption Time: \(String(format: "%.1f", absorptionTimeHours)) hours", + now: now + ) + fields["carbs-edit"] = syncIdentifier + fields["carbs-edit-entry"] = carbsAmount + fields["carbs-edit-absorption-time"] = absorptionTimeHours + fields["carbs-edit-start-time"] = formatDateForAPNS(consumedDate) + if let foodType, !foodType.isEmpty { + fields["carbs-edit-food-type"] = foodType + } + return fields + } + + /// Creates a properly formatted ISO8601 date string with milliseconds (matching Nightscout's format) + /// - Parameter date: The date to format + /// - Returns: Formatted date string like "2022-12-24T21:34:02.090Z" + /// Override commands need no one-time code, so the return block travels unencrypted. + static func overridePayload(presetName: String, duration: TimeInterval?, returnInfo: ReturnNotificationInfo?, now: Date = Date()) -> [String: Any] { + // Alert text matches Nightscout's format. + var alertText = "\(presetName) Temporary Override" + if let duration, duration > 0 { + let hours = Int(duration / 3600) + let minutes = Int(duration.truncatingRemainder(dividingBy: 3600) / 60) + alertText += hours > 0 ? " (\(hours)h \(minutes)m)" : " (\(minutes)m)" + } + var payload = overrideHeader(alert: alertText, now: now, returnInfo: returnInfo) + payload["override-name"] = presetName + if let duration { + payload["override-duration-minutes"] = Int(duration / 60) + } + return payload + } + + static func cancelOverridePayload(returnInfo: ReturnNotificationInfo?, now: Date = Date()) -> [String: Any] { + var payload = overrideHeader(alert: "Cancel Temporary Override", now: now, returnInfo: returnInfo) + payload["cancel-temporary-override"] = "true" + return payload + } + + private static func overrideHeader(alert: String, now: Date, returnInfo: ReturnNotificationInfo?) -> [String: Any] { + var payload: [String: Any] = [ + "remote-address": "LoopFollow", + "entered-by": "LoopFollow", + "sent-at": formatDateForAPNS(now), + "expiration": formatDateForAPNS(now.addingTimeInterval(5 * 60)), + "alert": alert, + ] + if let returnInfo { + payload["return_notification"] = [ + "production_environment": returnInfo.productionEnvironment, + "device_token": returnInfo.deviceToken, + "bundle_id": returnInfo.bundleId, + "team_id": returnInfo.teamId, + "key_id": returnInfo.keyId, + "apns_key": returnInfo.apnsKey, + ] + } + return payload + } + + /// Wraps command fields in the push payload Loop expects (matching @parse/node-apn). + static func apnsBody(_ payload: [String: Any]) throws -> Data { + var body: [String: Any] = [ + "aps": [ + "alert": payload["alert"] as? String ?? "", + "content-available": 1, + "interruption-level": "time-sensitive", + ], + ] + for (key, value) in payload where key != "alert" && key != "content-available" && key != "interruption-level" { + body[key] = value + } + return try JSONSerialization.data(withJSONObject: body) + } + + static func formatDateForAPNS(_ date: Date) -> String { + let dateFormatter = ISO8601DateFormatter() + dateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return dateFormatter.string(from: date) + } +} + +struct LoopAPNSPayload { + enum PayloadType { + case carbs + case bolus + } + + let type: PayloadType + let carbsAmount: Double? + let absorptionTime: Double? + let foodType: String? + let bolusAmount: Double? + let consumedDate: Date? + let otp: String + + init(type: PayloadType, carbsAmount: Double? = nil, absorptionTime: Double? = nil, foodType: String? = nil, bolusAmount: Double? = nil, consumedDate: Date? = nil, otp: String) { + self.type = type + self.carbsAmount = carbsAmount + self.absorptionTime = absorptionTime + self.foodType = foodType + self.bolusAmount = bolusAmount + self.consumedDate = consumedDate + self.otp = otp + } +} diff --git a/LoopFollow/Remote/LoopAPNS/OTPSecureMessenger.swift b/RemoteShared/OTPSecureMessenger.swift similarity index 97% rename from LoopFollow/Remote/LoopAPNS/OTPSecureMessenger.swift rename to RemoteShared/OTPSecureMessenger.swift index 02762338b..4e201a965 100644 --- a/LoopFollow/Remote/LoopAPNS/OTPSecureMessenger.swift +++ b/RemoteShared/OTPSecureMessenger.swift @@ -38,7 +38,7 @@ struct OTPSecureMessenger { } /// Information needed to send a response notification back via APNS -struct ReturnNotificationInfo: Codable { +struct ReturnNotificationInfo: Codable, Equatable { let productionEnvironment: Bool let deviceToken: String let bundleId: String diff --git a/LoopFollow/Remote/TRC/PushMessage.swift b/RemoteShared/PushMessage.swift similarity index 77% rename from LoopFollow/Remote/TRC/PushMessage.swift rename to RemoteShared/PushMessage.swift index 319482c7c..1d6267ae6 100644 --- a/LoopFollow/Remote/TRC/PushMessage.swift +++ b/RemoteShared/PushMessage.swift @@ -54,24 +54,6 @@ struct CommandPayload: Encodable { return commandType.rawValue } - struct ReturnNotificationInfo: Encodable { - let productionEnvironment: Bool - let deviceToken: String - let bundleId: String - let teamId: String - let keyId: String - let apnsKey: String - - enum CodingKeys: String, CodingKey { - case productionEnvironment = "production_environment" - case deviceToken = "device_token" - case bundleId = "bundle_id" - case teamId = "team_id" - case keyId = "key_id" - case apnsKey = "apns_key" - } - } - enum CodingKeys: String, CodingKey { case user case commandType = "command_type" diff --git a/LoopFollow/Remote/TRC/SecureMessenger.swift b/RemoteShared/SecureMessenger.swift similarity index 100% rename from LoopFollow/Remote/TRC/SecureMessenger.swift rename to RemoteShared/SecureMessenger.swift diff --git a/LoopFollow/Extensions/ShareClientExtension.swift b/RemoteShared/ShareClientExtension.swift similarity index 96% rename from LoopFollow/Extensions/ShareClientExtension.swift rename to RemoteShared/ShareClientExtension.swift index 6201e9100..c8b492b78 100644 --- a/LoopFollow/Extensions/ShareClientExtension.swift +++ b/RemoteShared/ShareClientExtension.swift @@ -2,7 +2,9 @@ // ShareClientExtension.swift import Foundation -import ShareClient +#if canImport(ShareClient) + import ShareClient +#endif public struct ShareGlucoseData: Decodable { var sgv: Int @@ -56,7 +58,6 @@ private var TrendTable: [String] = [ "RATE OUT OF RANGE", // 9 ] -// TODO: probably better to make this an inherited class rather than an extension public extension ShareClient { func fetchData(_ entries: Int, callback: @escaping (ShareError?, [ShareGlucoseData]?) -> Void) { fetchLast(entries) { error, result in diff --git a/LoopFollow/Helpers/TOTPGenerator.swift b/RemoteShared/TOTPGenerator.swift similarity index 100% rename from LoopFollow/Helpers/TOTPGenerator.swift rename to RemoteShared/TOTPGenerator.swift diff --git a/LoopFollow/Remote/TRC/TRCCommandType.swift b/RemoteShared/TRCCommandType.swift similarity index 100% rename from LoopFollow/Remote/TRC/TRCCommandType.swift rename to RemoteShared/TRCCommandType.swift diff --git a/RemoteShared/WatchCommandValidator.swift b/RemoteShared/WatchCommandValidator.swift new file mode 100644 index 000000000..15769a0b2 --- /dev/null +++ b/RemoteShared/WatchCommandValidator.swift @@ -0,0 +1,134 @@ +// LoopFollow +// WatchCommandValidator.swift + +import Foundation + +/// The guardrails for a watch command, applied by the phone before it sends +/// one and by the watch before it sends one directly. Same limits and messages +/// as the phone's Remote screens. +enum WatchCommandValidator { + struct Limits: Equatable { + var maxBolus: Double + var bolusIncrement: Double + var maxCarbs: Double + var maxFat: Double + var maxProtein: Double + var mealWithBolus: Bool + var mealWithFatProtein: Bool + } + + /// Loop rejects carb entries outside this window; same limits as LoopAPNSCarbsView. + static let loopMaxPastHours: Double = 12 + static let loopMaxFutureHours: Double = 1 + /// Loop's default absorption time for a remote carb entry, in hours. + static let loopAbsorptionHours = 3.0 + + /// Returns the action to send (Trio boluses rounded down to the increment) or the reason it is refused. + static func validate( + _ action: WatchCommand.Action, + kind: WatchRemoteConfig.Kind, + limits: Limits, + now: Date = Date() + ) -> Result { + switch (kind, action) { + case let (.loop, .bolus(units)): + guard units > 0 else { return .failure(ValidationError("Enter an amount above zero.")) } + guard units <= limits.maxBolus else { return .failure(ValidationError(exceedsMaxBolus(units, limits))) } + return .success(action) + + case let (.trio, .bolus(units)): + let stepped = roundedToStep(units, step: limits.bolusIncrement) + guard stepped > 0 else { return .failure(ValidationError("Enter an amount above zero.")) } + guard stepped <= limits.maxBolus else { return .failure(ValidationError(exceedsMaxBolus(stepped, limits))) } + return .success(.bolus(units: stepped)) + + case let (.loop, .meal(carbs, fat, protein, bolus, consumedAt)): + guard fat == 0, protein == 0, bolus == 0 else { return .failure(ValidationError("Loop accepts carbs only from the watch.")) } + guard carbs > 0 else { return .failure(ValidationError("Enter an amount above zero.")) } + guard carbs <= limits.maxCarbs else { return .failure(ValidationError(exceeds("Carbs", carbs, limits.maxCarbs))) } + guard consumedAt >= now.addingTimeInterval(-loopMaxPastHours * 3600) else { + return .failure(ValidationError("Time must be within the prior \(Int(loopMaxPastHours)) hours.")) + } + guard consumedAt <= now.addingTimeInterval(loopMaxFutureHours * 3600) else { + return .failure(ValidationError("Time must be within the next \(Int(loopMaxFutureHours)) hour.")) + } + return .success(action) + + case let (.trio, .meal(carbs, fat, protein, bolus, consumedAt)): + guard limits.mealWithFatProtein || (fat == 0 && protein == 0) else { + return .failure(ValidationError("Meal with Fat/Protein is turned off in LoopFollow's Remote settings.")) + } + guard limits.mealWithBolus || bolus == 0 else { + return .failure(ValidationError("Meal with Bolus is turned off in LoopFollow's Remote settings.")) + } + guard carbs >= 0, fat >= 0, protein >= 0, carbs > 0 || fat > 0 || protein > 0 else { + return .failure(ValidationError("Enter carbs, fat or protein.")) + } + guard carbs <= limits.maxCarbs else { return .failure(ValidationError(exceeds("Carbs", carbs, limits.maxCarbs))) } + guard fat <= limits.maxFat else { return .failure(ValidationError(exceeds("Fat", fat, limits.maxFat))) } + guard protein <= limits.maxProtein else { return .failure(ValidationError(exceeds("Protein", protein, limits.maxProtein))) } + let steppedBolus = roundedToStep(bolus, step: limits.bolusIncrement) + guard steppedBolus <= limits.maxBolus else { return .failure(ValidationError(exceedsMaxBolus(steppedBolus, limits))) } + return .success(.meal(carbs: carbs, fat: fat, protein: protein, bolus: steppedBolus, consumedAt: consumedAt)) + + case (.loop, .startTempTarget), (.loop, .cancelTempTarget): + return .failure(ValidationError("Temp targets are not available for Loop.")) + + default: + return .success(action) + } + } + + /// Rounds down to the bolus increment, as the phone's Trio bolus screen does. + static func roundedToStep(_ value: Double, step: Double) -> Double { + let step = max(0.001, step) + let stepped = ((value / step) + 1e-10).rounded(.down) * step + return (stepped * 1000).rounded() / 1000 + } + + private static func exceedsMaxBolus(_ units: Double, _ limits: Limits) -> String { + "\(format(units)) U exceeds the maximum bolus of \(format(limits.maxBolus)) U set in LoopFollow." + } + + private static func exceeds(_ what: String, _ grams: Double, _ max: Double) -> String { + "\(what) (\(Int(grams)) g) exceeds the maximum of \(Int(max)) g set in LoopFollow." + } + + private static func format(_ units: Double) -> String { + let nf = NumberFormatter() + nf.numberStyle = .decimal + nf.minimumFractionDigits = 0 + nf.maximumFractionDigits = 3 + return nf.string(from: NSNumber(value: units)) ?? "\(units)" + } + + struct ValidationError: Error, Equatable { + let message: String + + init(_ message: String) { + self.message = message + } + } +} + +extension WatchCommand { + /// Trio treats a missing time as now; within a minute of now counts as now, as in the phone's meal screen. + static func trioScheduledTime(_ consumedAt: Date, now: Date = Date()) -> Date? { + abs(consumedAt.timeIntervalSince(now)) < 60 ? nil : consumedAt.dateUsingCurrentSeconds() + } +} + +extension WatchRemoteConfig { + /// The phone's guardrails as last sent to the watch. + var limits: WatchCommandValidator.Limits { + WatchCommandValidator.Limits( + maxBolus: maxBolus, + bolusIncrement: bolusIncrement, + maxCarbs: maxCarbs, + maxFat: maxFat, + maxProtein: maxProtein, + mealWithBolus: mealWithBolus, + mealWithFatProtein: mealWithFatProtein + ) + } +} diff --git a/RemoteShared/WatchCredentials.swift b/RemoteShared/WatchCredentials.swift new file mode 100644 index 000000000..4d3a30d9d --- /dev/null +++ b/RemoteShared/WatchCredentials.swift @@ -0,0 +1,64 @@ +// LoopFollow +// WatchCredentials.swift + +import Foundation + +/// What the watch needs to keep working without the phone: read access to the +/// data source and, when commands from the watch are allowed, what the phone +/// itself uses to send them. Sent with `transferUserInfo` only when it changes, +/// and kept in the watch's Keychain. +struct WatchCredentials: Codable, Equatable { + static let userInfoKey = "watchCredentials" + /// Watch → phone: asks the phone to send its credentials again. + static let requestKey = "requestCredentials" + + var nightscoutURL: String + var nightscoutToken: String + var dexcomUsername: String + var dexcomPassword: String + /// "US" or anything else for outside the US, as in the phone's Dexcom settings. + var dexcomServer: String + /// Present only when remote commands from the watch are allowed. + var remote: Remote? + + struct Remote: Codable, Equatable { + var kind: WatchRemoteConfig.Kind + /// Push target: the Loop or Trio app. + var deviceToken: String + var bundleID: String + var production: Bool + /// The key the phone signs commands with (LoopFollow's own key when both apps share a team). + var apnsKey: String + var keyID: String + var teamID: String + /// Trio Remote Control. + var user: String + var sharedSecret: String + /// Loop: the one-time-password QR code URL. + var loopQRCodeURL: String + /// Lets Loop or Trio confirm a command to the LoopFollow app on the phone. + var returnNotification: ReturnNotificationInfo? + } + + var hasDataSource: Bool { + !nightscoutURL.isEmpty || (!dexcomUsername.isEmpty && !dexcomPassword.isEmpty) + } +} + +/// Picks the key that signs commands to the Loop or Trio app: LoopFollow's own +/// key when both apps belong to the same team or no remote team is set, +/// otherwise the remote app's key. +enum RemoteSigningKey { + static func resolve( + lfTeamID: String, + remoteTeamID: String, + lfKey: (key: String, keyID: String), + remoteKey: (key: String, keyID: String) + ) -> (key: String, keyID: String, teamID: String) { + let sameTeam = !lfTeamID.isEmpty && !remoteTeamID.isEmpty && lfTeamID == remoteTeamID + if sameTeam || remoteTeamID.isEmpty { + return (lfKey.key, lfKey.keyID, lfTeamID) + } + return (remoteKey.key, remoteKey.keyID, remoteTeamID) + } +} diff --git a/Shared/AppGroupID.swift b/Shared/AppGroupID.swift index 5eb1187b8..507bfb7c2 100644 --- a/Shared/AppGroupID.swift +++ b/Shared/AppGroupID.swift @@ -58,6 +58,8 @@ enum AppGroupID { ".WidgetExtension", ".Widgets", ".WidgetsExtension", + ".watchkitapp.widgets", + ".watchkitapp", ".Watch", ".WatchExtension", ".CarPlay", diff --git a/Shared/GlucoseSnapshot.swift b/Shared/GlucoseSnapshot.swift index b2a015d17..a8c80038e 100644 --- a/Shared/GlucoseSnapshot.swift +++ b/Shared/GlucoseSnapshot.swift @@ -363,5 +363,27 @@ extension GlucoseSnapshot { let raw = try container.decode(String.self) self = Trend(rawValue: raw) ?? .unknown } + + /// Maps a Nightscout or Dexcom direction string, e.g. "FortyFiveUp". + init(code: String?) { + let raw = code?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" + if raw.contains("doubleup") || raw.contains("rapidrise") || raw == "up2" || raw == "upfast" { + self = .upFast + } else if raw.contains("fortyfiveup") { + self = .upSlight + } else if raw.contains("singleup") || raw == "up" || raw == "up1" || raw == "rising" { + self = .up + } else if raw.contains("flat") || raw == "steady" || raw == "none" { + self = .flat + } else if raw.contains("doubledown") || raw.contains("rapidfall") || raw == "down2" || raw == "downfast" { + self = .downFast + } else if raw.contains("fortyfivedown") { + self = .downSlight + } else if raw.contains("singledown") || raw == "down" || raw == "down1" || raw == "falling" { + self = .down + } else { + self = .unknown + } + } } } diff --git a/Tests/LoopAPNSCommandPayloadTests.swift b/Tests/LoopAPNSCommandPayloadTests.swift index aabd53185..374509bc6 100644 --- a/Tests/LoopAPNSCommandPayloadTests.swift +++ b/Tests/LoopAPNSCommandPayloadTests.swift @@ -12,7 +12,7 @@ struct LoopAPNSCommandPayloadTests { @Test("carbs payload carries the loop.js keys") func carbsKeys() { let payload = LoopAPNSPayload(type: .carbs, carbsAmount: 30, absorptionTime: 3, consumedDate: now.addingTimeInterval(-600), otp: "123456") - let json = LoopAPNSService.carbsCommandPayload(payload, now: now) + let json = LoopAPNSCommands.carbsCommandPayload(payload, now: now) #expect(Set(json.keys) == headerKeys.union(["carbs-entry", "absorption-time", "start-time"])) #expect(json["carbs-entry"] as? Double == 30) #expect(json["absorption-time"] as? Double == 3) @@ -28,7 +28,7 @@ struct LoopAPNSCommandPayloadTests { @Test("bolus payload carries the loop.js keys") func bolusKeys() { - let json = LoopAPNSService.bolusCommandPayload(LoopAPNSPayload(type: .bolus, bolusAmount: 1.25, otp: "123456"), now: now) + let json = LoopAPNSCommands.bolusCommandPayload(LoopAPNSPayload(type: .bolus, bolusAmount: 1.25, otp: "123456"), now: now) #expect(Set(json.keys) == headerKeys.union(["bolus-entry"])) #expect(json["bolus-entry"] as? Double == 1.25) #expect(json["alert"] as? String == "Remote Bolus Entry: 1.25 U") @@ -36,7 +36,7 @@ struct LoopAPNSCommandPayloadTests { @Test("carbs delete addresses the entry by sync identifier") func deleteKeys() { - let json = LoopAPNSService.carbsDeleteCommandPayload(syncIdentifier: "SYNC-1", otp: "123456", now: now) + let json = LoopAPNSCommands.carbsDeleteCommandPayload(syncIdentifier: "SYNC-1", otp: "123456", now: now) #expect(Set(json.keys) == headerKeys.union(["carbs-delete"])) #expect(json["carbs-delete"] as? String == "SYNC-1") #expect(json["carbs-entry"] == nil) @@ -44,7 +44,7 @@ struct LoopAPNSCommandPayloadTests { @Test("carbs edit always sends amount, absorption and start time; food type only when set") func editKeys() { - let json = LoopAPNSService.carbsEditCommandPayload( + let json = LoopAPNSCommands.carbsEditCommandPayload( syncIdentifier: "SYNC-1", carbsAmount: 45, absorptionTimeHours: 2.5, @@ -61,7 +61,7 @@ struct LoopAPNSCommandPayloadTests { #expect(json["carbs-entry"] == nil) #expect(json["start-time"] == nil) - let withFood = LoopAPNSService.carbsEditCommandPayload( + let withFood = LoopAPNSCommands.carbsEditCommandPayload( syncIdentifier: "SYNC-1", carbsAmount: 45, absorptionTimeHours: 2.5, foodType: "🍕", consumedDate: now, otp: "123456", now: now ) #expect(withFood["carbs-edit-food-type"] as? String == "🍕") diff --git a/Tests/WatchProtocolTests.swift b/Tests/WatchProtocolTests.swift new file mode 100644 index 000000000..1854e27b8 --- /dev/null +++ b/Tests/WatchProtocolTests.swift @@ -0,0 +1,91 @@ +// LoopFollow +// WatchProtocolTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct WatchProtocolTests { + private let t0 = Date(timeIntervalSince1970: 1_790_000_000) + + private func state(alarm: WatchAlarm? = nil) -> WatchState { + WatchState( + builtAt: t0, + source: .phone, + phoneBuiltAt: t0, + snapshot: GlucoseSnapshot(glucose: 142, delta: -3, trend: .downSlight, updatedAt: t0, iob: 1.2, cob: 15, projected: 120, unit: .mmol, isNotLooping: false), + readings: [WatchReading(t: t0.timeIntervalSince1970 - 300, v: 145), WatchReading(t: t0.timeIntervalSince1970, v: 142)], + predictions: [WatchPredictionLine(kind: .iob, points: [WatchReading(t: t0.timeIntervalSince1970 + 300, v: 138)])], + cone: [WatchConePoint(t: t0.timeIntervalSince1970 + 300, low: 120, high: 150)], + boluses: [WatchReading(t: t0.timeIntervalSince1970 - 600, v: 2.5)], + carbs: [WatchReading(t: t0.timeIntervalSince1970 - 600, v: 30)], + lowMgdl: 70, + highMgdl: 180, + colorBGText: true, + infoRows: [WatchInfoRow(name: "IOB", value: "1,2", level: nil), WatchInfoRow(name: "SAGE", value: "9d 2h", level: .warning)], + stats: WatchStats(lowPercent: "2%", inRangePercent: "80%", highPercent: "18%", avgBG: "8,1", estA1CTitle: "GMI:", estA1C: "6,6", stdDevTitle: "Std Dev:", stdDev: "2,3", pieLow: 2, pieRange: 80, pieHigh: 18), + device: "Trio", + alarm: alarm, + remote: WatchRemoteConfig( + kind: .trio, + maxBolus: 5, + bolusIncrement: 0.05, + maxCarbs: 80, + maxFat: 30, + maxProtein: 30, + mealWithBolus: true, + mealWithFatProtein: false, + recommendedBolus: 0.4, + recommendedBolusAt: t0, + overridePresets: [WatchPreset(id: "Exercise", name: "Exercise", detail: "80 %, 1 h")], + tempTargetPresets: [], + quickPickBoluses: [1, 2.5], + quickPickMeals: [WatchMealPick(carbs: 45, fat: 0, protein: 0, bolus: 0)] + ) + ) + } + + @Test("State survives the round trip through the application context") + func stateRoundTrip() throws { + let original = state(alarm: WatchAlarm(id: UUID(), name: "Low")) + let data = try #require(original.encoded()) + #expect(WatchState.decode(data) == original) + } + + @Test("Commands keep their action, id and creation time") + func commandRoundTrip() throws { + let actions: [WatchCommand.Action] = [ + .bolus(units: 1.25), + .meal(carbs: 30, fat: 10, protein: 5, bolus: 2, consumedAt: t0), + .startOverride(presetID: "Exercise"), + .cancelOverride, + .startTempTarget(presetID: UUID().uuidString), + .cancelTempTarget, + .snoozeAlarm, + ] + for action in actions { + let command = WatchCommand(action: action) + let decoded = try JSONDecoder().decode(WatchCommand.self, from: JSONEncoder().encode(command)) + #expect(decoded == command) + } + } + + @Test("Glucose and delta follow the phone's unit formatting") + func glucoseFormatting() { + #expect(WatchFormat.glucose(142, unit: .mgdl) == "142") + #expect(WatchFormat.delta(-3, unit: .mgdl) == "-3") + #expect(WatchFormat.delta(4, unit: .mgdl) == "+4") + #expect(WatchFormat.delta(0.4, unit: .mgdl) == "0") + let oneMmol = WatchFormat.delta(GlucoseConversion.mmolToMgDl, unit: .mmol) + #expect(oneMmol.hasPrefix("+1") && oneMmol.count == 4) + #expect(!WatchFormat.delta(0.5, unit: .mmol).hasPrefix("+")) + } + + @Test("Range colors match the phone: red at or below low, yellow at or above high") + func rangeColors() { + #expect(WatchFormat.bgColor(70, lowMgdl: 70, highMgdl: 180) == .red) + #expect(WatchFormat.bgColor(71, lowMgdl: 70, highMgdl: 180) == .green) + #expect(WatchFormat.bgColor(180, lowMgdl: 70, highMgdl: 180) == .yellow) + #expect(WatchFormat.bgColor(250, lowMgdl: 70, highMgdl: 180, colored: false) == .primary) + } +} diff --git a/Tests/WatchRemoteTests.swift b/Tests/WatchRemoteTests.swift new file mode 100644 index 000000000..55c0c5a22 --- /dev/null +++ b/Tests/WatchRemoteTests.swift @@ -0,0 +1,138 @@ +// LoopFollow +// WatchRemoteTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct WatchRemoteTests { + private let now = Date(timeIntervalSince1970: 1_790_000_000) + + private let limits = WatchCommandValidator.Limits( + maxBolus: 2, + bolusIncrement: 0.05, + maxCarbs: 60, + maxFat: 30, + maxProtein: 30, + mealWithBolus: true, + mealWithFatProtein: false + ) + + private func validate(_ action: WatchCommand.Action, _ kind: WatchRemoteConfig.Kind, limits: WatchCommandValidator.Limits? = nil) -> Result { + WatchCommandValidator.validate(action, kind: kind, limits: limits ?? self.limits, now: now) + } + + // MARK: - Validation + + @Test("Trio boluses round down to the increment and respect the maximum") + func trioBolus() { + #expect(validate(.bolus(units: 1.27), .trio) == .success(.bolus(units: 1.25))) + #expect((try? validate(.bolus(units: 2.5), .trio).get()) == nil) + #expect((try? validate(.bolus(units: 0.01), .trio).get()) == nil) + } + + @Test("Loop boluses are sent as entered, up to the maximum") + func loopBolus() { + #expect(validate(.bolus(units: 1.27), .loop) == .success(.bolus(units: 1.27))) + #expect((try? validate(.bolus(units: 2.01), .loop).get()) == nil) + } + + @Test("Loop meals are carbs only, within the time window Loop accepts") + func loopMeal() { + #expect((try? validate(.meal(carbs: 30, fat: 0, protein: 0, bolus: 0, consumedAt: now), .loop).get()) != nil) + #expect((try? validate(.meal(carbs: 30, fat: 0, protein: 0, bolus: 1, consumedAt: now), .loop).get()) == nil) + #expect((try? validate(.meal(carbs: 70, fat: 0, protein: 0, bolus: 0, consumedAt: now), .loop).get()) == nil) + #expect((try? validate(.meal(carbs: 30, fat: 0, protein: 0, bolus: 0, consumedAt: now.addingTimeInterval(-13 * 3600)), .loop).get()) == nil) + #expect((try? validate(.meal(carbs: 30, fat: 0, protein: 0, bolus: 0, consumedAt: now.addingTimeInterval(2 * 3600)), .loop).get()) == nil) + } + + @Test("Trio meals follow the phone's Meal settings") + func trioMeal() { + #expect((try? validate(.meal(carbs: 30, fat: 10, protein: 0, bolus: 0, consumedAt: now), .trio).get()) == nil) + #expect(validate(.meal(carbs: 30, fat: 0, protein: 0, bolus: 1.27, consumedAt: now), .trio) + == .success(.meal(carbs: 30, fat: 0, protein: 0, bolus: 1.25, consumedAt: now))) + var noBolus = limits + noBolus.mealWithBolus = false + #expect((try? validate(.meal(carbs: 30, fat: 0, protein: 0, bolus: 1, consumedAt: now), .trio, limits: noBolus).get()) == nil) + } + + @Test("Temp targets are refused for Loop") + func loopTempTarget() { + #expect((try? validate(.startTempTarget(presetID: "x"), .loop).get()) == nil) + #expect((try? validate(.startTempTarget(presetID: "x"), .trio).get()) != nil) + } + + // MARK: - Signing key + + @Test("LoopFollow's own key signs when both apps share a team or no remote team is set") + func signingKey() { + let lf = (key: "LF", keyID: "LFKEY") + let remote = (key: "R", keyID: "RKEY") + #expect(RemoteSigningKey.resolve(lfTeamID: "T1", remoteTeamID: "T1", lfKey: lf, remoteKey: remote).key == "LF") + #expect(RemoteSigningKey.resolve(lfTeamID: "T1", remoteTeamID: "", lfKey: lf, remoteKey: remote).key == "LF") + let other = RemoteSigningKey.resolve(lfTeamID: "T1", remoteTeamID: "T2", lfKey: lf, remoteKey: remote) + #expect(other.key == "R") + #expect(other.teamID == "T2") + } + + // MARK: - Device status + + @Test("Loop devicestatus gives IOB, COB, recommendation and the prediction") + func loopDeviceStatus() { + let status: [String: Any] = [ + "loop": [ + "timestamp": "2026-10-19T08:00:00Z", + "iob": ["iob": 1.2], + "cob": ["cob": 15.0], + "recommendedBolus": 0.3, + "predicted": ["startDate": "2026-10-19T08:00:00Z", "values": [120.0, 125.0, 130.0]], + ], + ] + let summary = DeviceStatusSummary.parse(status, showsCone: false) + #expect(summary.iob == 1.2) + #expect(summary.cob == 15) + #expect(summary.recommendedBolus == 0.3) + #expect(summary.predictions.first?.kind == .loop) + #expect(summary.predictions.first?.points.map(\.v) == [120, 125, 130]) + #expect(summary.loopAt == DeviceStatusSummary.date("2026-10-19T08:00:00.000Z")) + } + + @Test("Trio devicestatus gives lines or the cone, matching what the phone showed") + func trioDeviceStatus() { + let status: [String: Any] = [ + "openaps": [ + "iob": ["iob": 1.5], + "suggested": [ + "timestamp": "2026-10-19T08:00:00.000Z", + "COB": 20.0, + "predBGs": ["IOB": [140.0, 138.0], "ZT": [140.0, 130.0], "COB": [140.0, 150.0]], + ], + ], + ] + let lines = DeviceStatusSummary.parse(status, showsCone: false) + #expect(lines.iob == 1.5) + #expect(lines.cob == 20) + #expect(Set(lines.predictions.map(\.kind)) == [.iob, .zt, .cob]) + + let cone = DeviceStatusSummary.parse(status, showsCone: true) + #expect(cone.predictions.isEmpty) + #expect(cone.cone.map(\.low) == [139, 130]) + #expect(cone.cone.map(\.high) == [141, 150]) + } + + // MARK: - Loop override payload + + @Test("Loop override payload carries name, duration and return info") + func loopOverridePayload() { + let info = ReturnNotificationInfo(productionEnvironment: false, deviceToken: "tok", bundleId: "b", teamId: "t", keyId: "k", apnsKey: "a") + let payload = LoopAPNSCommands.overridePayload(presetName: "Running", duration: 3600, returnInfo: info, now: now) + #expect(payload["override-name"] as? String == "Running") + #expect(payload["override-duration-minutes"] as? Int == 60) + #expect(payload["alert"] as? String == "Running Temporary Override (1h 0m)") + #expect((payload["return_notification"] as? [String: Any])?["device_token"] as? String == "tok") + + let cancel = LoopAPNSCommands.cancelOverridePayload(returnInfo: nil, now: now) + #expect(cancel["cancel-temporary-override"] as? String == "true") + #expect(cancel["return_notification"] == nil) + } +} diff --git a/WatchShared/WatchCommand.swift b/WatchShared/WatchCommand.swift new file mode 100644 index 000000000..5668fb463 --- /dev/null +++ b/WatchShared/WatchCommand.swift @@ -0,0 +1,69 @@ +// LoopFollow +// WatchCommand.swift + +import Foundation + +/// A request from the watch. The phone validates it against its own settings and +/// guardrails and sends it with the same code path as its Remote screens. +struct WatchCommand: Codable, Equatable { + static let messageKey = "watchCommand" + /// The phone ignores commands older than this, so a delayed delivery is never acted on. + static let maxAge: TimeInterval = 60 + + enum Action: Codable, Hashable { + case bolus(units: Double) + /// Loop sends carbs only; Trio may add fat, protein and a bolus in the same command. + case meal(carbs: Double, fat: Double, protein: Double, bolus: Double, consumedAt: Date) + case startOverride(presetID: String) + case cancelOverride + case startTempTarget(presetID: String) + case cancelTempTarget + case snoozeAlarm + } + + let id: UUID + let createdAt: Date + let action: Action + + init(action: Action) { + id = UUID() + createdAt = Date() + self.action = action + } +} + +extension WatchCommand.Action { + /// Short name for logs. + var name: String { + switch self { + case .bolus: "bolus" + case .meal: "meal" + case .startOverride: "startOverride" + case .cancelOverride: "cancelOverride" + case .startTempTarget: "startTempTarget" + case .cancelTempTarget: "cancelTempTarget" + case .snoozeAlarm: "snoozeAlarm" + } + } + + /// Whether the command can deliver insulin. + var deliversInsulin: Bool { + switch self { + case .bolus: true + case let .meal(_, _, _, bolus, _): bolus > 0 + default: false + } + } +} + +struct WatchCommandReply: Codable, Equatable { + static let messageKey = "watchCommandReply" + + let success: Bool + let message: String +} + +enum WatchMessageKey { + /// Watch → phone: asks for the current state; the reply carries `WatchState.contextKey`. + static let requestState = "requestState" +} diff --git a/WatchShared/WatchFormat.swift b/WatchShared/WatchFormat.swift new file mode 100644 index 000000000..24502d318 --- /dev/null +++ b/WatchShared/WatchFormat.swift @@ -0,0 +1,104 @@ +// LoopFollow +// WatchFormat.swift + +import Foundation +import SwiftUI + +/// Display formatting shared by the watch app and its complications. Values +/// arrive in mg/dL and are rendered in the unit chosen on the phone. +enum WatchFormat { + /// A reading this old is shown struck through, as on the phone. + static let staleAfter: TimeInterval = 12 * 60 + + private static let mgdlFormatter: NumberFormatter = { + let nf = NumberFormatter() + nf.numberStyle = .decimal + nf.maximumFractionDigits = 0 + nf.locale = .current + return nf + }() + + private static let oneDecimalFormatter: NumberFormatter = { + let nf = NumberFormatter() + nf.numberStyle = .decimal + nf.minimumFractionDigits = 1 + nf.maximumFractionDigits = 1 + nf.locale = .current + return nf + }() + + private static let insulinFormatter: NumberFormatter = { + let nf = NumberFormatter() + nf.numberStyle = .decimal + nf.minimumFractionDigits = 0 + nf.maximumFractionDigits = 2 + nf.locale = .current + return nf + }() + + static func glucose(_ mgdl: Double, unit: GlucoseSnapshot.Unit) -> String { + switch unit { + case .mgdl: + return mgdlFormatter.string(from: NSNumber(value: round(mgdl))) ?? "\(Int(round(mgdl)))" + case .mmol: + let mmol = GlucoseConversion.toMmol(mgdl) + return oneDecimalFormatter.string(from: NSNumber(value: mmol)) ?? String(format: "%.1f", mmol) + } + } + + static func delta(_ mgdl: Double, unit: GlucoseSnapshot.Unit) -> String { + switch unit { + case .mgdl: + let v = Int(round(mgdl)) + if v == 0 { return "0" } + return v > 0 ? "+\(v)" : "\(v)" + case .mmol: + let mmol = GlucoseConversion.toMmol(mgdl) + let d = abs(mmol) < 0.05 ? 0.0 : mmol + let formatted = oneDecimalFormatter.string(from: NSNumber(value: abs(d))) ?? String(format: "%.1f", abs(d)) + if d == 0 { return formatted } + return d > 0 ? "+\(formatted)" : "-\(formatted)" + } + } + + static func trendArrow(_ trend: GlucoseSnapshot.Trend) -> String { + switch trend { + case .upFast: "↑↑" + case .up: "↑" + case .upSlight: "↗" + case .flat: "→" + case .downSlight: "↘︎" + case .down: "↓" + case .downFast: "↓↓" + case .unknown: "–" + } + } + + static func insulin(_ units: Double) -> String { + insulinFormatter.string(from: NSNumber(value: units)) ?? String(format: "%.2f", units) + } + + static func iob(_ units: Double?) -> String { + guard let units else { return "—" } + return (oneDecimalFormatter.string(from: NSNumber(value: units)) ?? String(format: "%.1f", units)) + " U" + } + + static func cob(_ grams: Double?) -> String { + guard let grams else { return "—" } + return "\(Int(round(grams))) g" + } + + static func minutesAgo(_ date: Date, now: Date = Date()) -> String { + let minutes = max(0, Int(now.timeIntervalSince(date) / 60)) + return "\(minutes) min" + } + + /// Phone semantics: yellow at or above high, red at or below low, green in + /// range. White when the phone's "Color BG Text" setting is off. + static func bgColor(_ mgdl: Double, lowMgdl: Double, highMgdl: Double, colored: Bool = true) -> Color { + guard colored else { return .primary } + if mgdl >= highMgdl { return .yellow } + if mgdl <= lowMgdl { return .red } + return .green + } +} diff --git a/WatchShared/WatchState.swift b/WatchShared/WatchState.swift new file mode 100644 index 000000000..095784b9f --- /dev/null +++ b/WatchShared/WatchState.swift @@ -0,0 +1,167 @@ +// LoopFollow +// WatchState.swift + +import Foundation + +/// Everything the watch shows, built by the phone and delivered as the +/// WatchConnectivity application context. The watch never talks to Nightscout, +/// Dexcom or APNs itself, so no credentials are part of this state. +struct WatchState: Codable, Equatable { + static let contextKey = "watchState" + + /// When this state was built, by the phone or by the watch itself. + var builtAt: Date + var source: WatchDataSource + /// When the phone last built a state; info rows, presets and guardrails date from then. + var phoneBuiltAt: Date + /// Latest glucose state, shared with the Live Activity. + var snapshot: GlucoseSnapshot? + /// BG readings for the last 24 hours, oldest first. + var readings: [WatchReading] + /// Prediction lines as drawn on the phone's main chart. + var predictions: [WatchPredictionLine] + /// Trio/OpenAPS forecast drawn as a band when the phone shows the cone. + var cone: [WatchConePoint] + var boluses: [WatchReading] + var carbs: [WatchReading] + /// Range thresholds used for coloring, in mg/dL (same as the phone). + var lowMgdl: Double + var highMgdl: Double + /// Mirrors the phone's "Color BG Text" setting. + var colorBGText: Bool + /// The phone's visible info table rows, in its order and with its coloring. + var infoRows: [WatchInfoRow] + /// The statistics shown under the phone's chart. + var stats: WatchStats? + /// "Loop", "Trio" or another uploader name; empty for CGM-only setups. + var device: String + /// The alarm currently sounding on the phone. + var alarm: WatchAlarm? + /// Present when remote commands from the watch are enabled on the phone. + var remote: WatchRemoteConfig? + + func encoded() -> Data? { + try? JSONEncoder().encode(self) + } + + static func decode(_ data: Data) -> WatchState? { + try? JSONDecoder().decode(WatchState.self, from: data) + } +} + +enum WatchDataSource: String, Codable { + /// Built and sent by the phone. + case phone + /// Fetched by the watch from Nightscout or Dexcom while the phone was out of reach. + case watch +} + +struct WatchReading: Codable, Equatable { + /// Unix epoch seconds. + let t: TimeInterval + /// mg/dL for glucose, units for boluses, grams for carbs. + let v: Double + + var date: Date { Date(timeIntervalSince1970: t) } +} + +/// The phone's statistics panel, formatted on the phone. +struct WatchStats: Codable, Equatable { + var lowPercent: String + var inRangePercent: String + var highPercent: String + var avgBG: String + var estA1CTitle: String + var estA1C: String + var stdDevTitle: String + var stdDev: String + var pieLow: Double + var pieRange: Double + var pieHigh: Double +} + +struct WatchInfoRow: Codable, Equatable { + let name: String + let value: String + /// Threshold coloring from the phone's Info Display settings; nil uses the default text color. + let level: InfoColorLevel? +} + +/// Threshold band of an info table value, shared by the phone and the watch. +enum InfoColorLevel: String, Codable { + case normal + case warning + case urgent +} + +struct WatchPredictionLine: Codable, Equatable { + /// Matches the phone's chart series and colors. + enum Kind: String, Codable { + case loop + case zt + case iob + case cob + case uam + } + + let kind: Kind + let points: [WatchReading] +} + +struct WatchConePoint: Codable, Equatable { + let t: TimeInterval + let low: Double + let high: Double + + var date: Date { Date(timeIntervalSince1970: t) } +} + +struct WatchAlarm: Codable, Equatable { + let id: UUID + let name: String +} + +struct WatchPreset: Codable, Equatable, Identifiable { + let id: String + let name: String + /// Preformatted on the phone in the user's units, e.g. "120 %, 1 h". + let detail: String + /// Raw values the watch needs to send the preset without the phone: the + /// duration (0 = indefinite) and, for temp targets, the target in mg/dL. + var durationSeconds: Double = 0 + var targetMgdl: Double? +} + +struct WatchRemoteConfig: Codable, Equatable { + enum Kind: String, Codable { + case loop + case trio + } + + let kind: Kind + let maxBolus: Double + let bolusIncrement: Double + let maxCarbs: Double + let maxFat: Double + let maxProtein: Double + /// Trio only: a meal may carry a bolus. + let mealWithBolus: Bool + /// Trio only: a meal may carry fat and protein. + let mealWithFatProtein: Bool + /// The AID's own recommendation, as shown in the phone's bolus screen. + var recommendedBolus: Double? + var recommendedBolusAt: Date? + let overridePresets: [WatchPreset] + /// Trio only. + let tempTargetPresets: [WatchPreset] + /// The phone's Quick-Pick suggestions for this time of day. + let quickPickBoluses: [Double] + let quickPickMeals: [WatchMealPick] +} + +struct WatchMealPick: Codable, Equatable { + let carbs: Double + let fat: Double + let protein: Double + let bolus: Double +} diff --git a/WatchShared/WatchStateStore.swift b/WatchShared/WatchStateStore.swift new file mode 100644 index 000000000..65c078618 --- /dev/null +++ b/WatchShared/WatchStateStore.swift @@ -0,0 +1,26 @@ +// LoopFollow +// WatchStateStore.swift + +import Foundation + +/// Keeps the last state received from the phone in the App Group container, so +/// the watch app shows data at launch and the complications can read it. +enum WatchStateStore { + private static let fileName = "watch-state.json" + + private static var fileURL: URL? { + FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: AppGroupID.current())? + .appendingPathComponent(fileName) + } + + static func save(_ data: Data) { + guard let url = fileURL else { return } + try? data.write(to: url, options: .atomic) + } + + static func load() -> WatchState? { + guard let url = fileURL, let data = try? Data(contentsOf: url) else { return nil } + return WatchState.decode(data) + } +} diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 8f46645c5..8bf9986ce 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -43,6 +43,40 @@ end APP_SUFFIX = loopfollow_app_suffix APP_IDENTIFIER = "com.#{TEAMID}.LoopFollow#{APP_SUFFIX}" LA_IDENTIFIER = "#{APP_IDENTIFIER}.LoopFollowLAExtension" +WATCH_IDENTIFIER = "#{APP_IDENTIFIER}.watchkitapp" +WATCH_WIDGETS_IDENTIFIER = "#{WATCH_IDENTIFIER}.widgets" +WATCH_SETUP_DOCS = "https://loopfollowdocs.org/build/lf-browser-build/#update-for-the-apple-watch-app" + +# The watch app needs two identifiers with the LoopFollow App Group, which +# existing browser builders have to add once by hand. +def watch_setup_error! + UI.user_error!( + "The Apple Watch app identifiers are not set up yet. Run the Add Identifiers action, " \ + "add the LoopFollow App Group to the LoopFollow Watch App and LoopFollow Watch Complications identifiers, " \ + "then build again. Instructions: #{WATCH_SETUP_DOCS}" + ) +end + +# Stops before match, whose read-only failure does not name the missing profile. +def ensure_watch_identifiers_exist + missing = [WATCH_IDENTIFIER, WATCH_WIDGETS_IDENTIFIER].reject { |id| Spaceship::ConnectAPI::BundleId.find(id) } + watch_setup_error! unless missing.empty? +end + +# Turns a signing failure caused by a watch identifier without the App Group into a pointer to the docs. +def explain_watch_setup_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 + + mentions_watch = [WATCH_IDENTIFIER, WATCH_WIDGETS_IDENTIFIER, "LoopFollowWatch"].any? { |id| text.include?(id) } + missing_app_group = text.match?(/App Groups capability|com\.apple\.security\.application-groups/i) + watch_setup_error! if mentions_watch && missing_app_group +end platform :ios do desc "Build Loop Follow" @@ -72,12 +106,16 @@ platform :ios do build_number: current_build_number ) + ensure_watch_identifiers_exist + match( type: "appstore", git_basic_authorization: Base64.strict_encode64("#{GITHUB_REPOSITORY_OWNER}:#{GH_PAT}"), app_identifier: [ APP_IDENTIFIER, - LA_IDENTIFIER + LA_IDENTIFIER, + WATCH_IDENTIFIER, + WATCH_WIDGETS_IDENTIFIER ] ) @@ -99,21 +137,42 @@ 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[WATCH_IDENTIFIER], + code_sign_identity: "iPhone Distribution", + targets: ["LoopFollowWatch"] ) + update_code_signing_settings( + path: "#{GITHUB_WORKSPACE}/LoopFollow.xcodeproj", + profile_name: mapping[WATCH_WIDGETS_IDENTIFIER], + code_sign_identity: "iPhone Distribution", + targets: ["LoopFollowWatchWidgets"] + ) + + 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], + WATCH_IDENTIFIER => mapping[WATCH_IDENTIFIER], + WATCH_WIDGETS_IDENTIFIER => mapping[WATCH_WIDGETS_IDENTIFIER] + } + } + ) + rescue => e + explain_watch_setup_failure(e, "buildlog") + raise + end + copy_artifacts( target_path: "artifacts", artifacts: ["*.mobileprovision", "*.ipa", "*.dSYM.zip"] @@ -168,6 +227,14 @@ platform :ios do Spaceship::ConnectAPI::BundleIdCapability::Type::APP_GROUPS ]) + configure_bundle_id("LoopFollow Watch App", WATCH_IDENTIFIER, [ + Spaceship::ConnectAPI::BundleIdCapability::Type::APP_GROUPS + ]) + + configure_bundle_id("LoopFollow Watch Complications", WATCH_WIDGETS_IDENTIFIER, [ + Spaceship::ConnectAPI::BundleIdCapability::Type::APP_GROUPS + ]) + end desc "Provision Certificates" @@ -188,7 +255,9 @@ platform :ios do git_basic_authorization: Base64.strict_encode64("#{GITHUB_REPOSITORY_OWNER}:#{GH_PAT}"), app_identifier: [ APP_IDENTIFIER, - LA_IDENTIFIER + LA_IDENTIFIER, + WATCH_IDENTIFIER, + WATCH_WIDGETS_IDENTIFIER ] ) end