diff --git a/.github/workflows/build_LoopFollow.yml b/.github/workflows/build_LoopFollow.yml index 0ad0e814a..2e8c0be54 100644 --- a/.github/workflows/build_LoopFollow.yml +++ b/.github/workflows/build_LoopFollow.yml @@ -165,7 +165,7 @@ jobs: build: name: Build needs: [check_certs, check_status] - runs-on: macos-15 + runs-on: macos-26 permissions: contents: write if: @@ -175,7 +175,7 @@ jobs: (vars.SCHEDULED_SYNC != 'false' && needs.check_status.outputs.NEW_COMMITS == 'true' ) steps: - name: Select Xcode version - run: "sudo xcode-select --switch /Applications/Xcode_16.4.app/Contents/Developer" + run: "sudo xcode-select --switch /Applications/Xcode_26.2.app/Contents/Developer" - name: Checkout Repo for building uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index f176e2f72..178842387 100644 --- a/.gitignore +++ b/.gitignore @@ -80,4 +80,4 @@ fastlane/test_output fastlane/FastlaneRunner LoopFollowConfigOverride.xcconfig -.history \ No newline at end of file +.history*.xcuserstate diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 5bd76cc2a..d7ed09428 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -7,6 +7,24 @@ objects = { /* Begin PBXBuildFile section */ + 374A77992F5BD8B200E96858 /* APNSClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77982F5BD8AB00E96858 /* APNSClient.swift */; }; + 374A77A52F5BE17000E96858 /* AppGroupID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A779F2F5BE17000E96858 /* AppGroupID.swift */; }; + 374A77A62F5BE17000E96858 /* GlucoseSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A12F5BE17000E96858 /* GlucoseSnapshot.swift */; }; + 374A77A72F5BE17000E96858 /* LAAppGroupSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A32F5BE17000E96858 /* LAAppGroupSettings.swift */; }; + 374A77A82F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A02F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift */; }; + DD91E4DE2BDEC3F8002D9E97 /* GlucoseConversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD91E4DC2BDEC3F8002D9E97 /* GlucoseConversion.swift */; }; + 374A77AA2F5BE17000E96858 /* AppGroupID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A779F2F5BE17000E96858 /* AppGroupID.swift */; }; + 374A77AB2F5BE17000E96858 /* GlucoseSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A12F5BE17000E96858 /* GlucoseSnapshot.swift */; }; + 374A77AC2F5BE17000E96858 /* LAAppGroupSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A32F5BE17000E96858 /* LAAppGroupSettings.swift */; }; + 374A77AD2F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A02F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift */; }; + 374A77B42F5BE1AC00E96858 /* StorageCurrentGlucoseStateProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77B32F5BE1AC00E96858 /* StorageCurrentGlucoseStateProvider.swift */; }; + 374A77B52F5BE1AC00E96858 /* GlucoseSnapshotBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77AE2F5BE1AC00E96858 /* GlucoseSnapshotBuilder.swift */; }; + 374A77B72F5BE1AC00E96858 /* LiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77B12F5BE1AC00E96858 /* LiveActivityManager.swift */; }; + 374A77B82F5BE1AC00E96858 /* GlucoseSnapshotStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77AF2F5BE1AC00E96858 /* GlucoseSnapshotStore.swift */; }; + 374A77B92F5BE1AC00E96858 /* PreferredGlucoseUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77B22F5BE1AC00E96858 /* PreferredGlucoseUnit.swift */; }; + 37A4BDDB2F5B6B4A00EEB289 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 37A4BDDA2F5B6B4A00EEB289 /* WidgetKit.framework */; }; + 37A4BDDD2F5B6B4A00EEB289 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 37A4BDDC2F5B6B4A00EEB289 /* SwiftUI.framework */; }; + 37A4BDE82F5B6B4C00EEB289 /* LoopFollowLAExtensionExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 3F1335F351590E573D8E6962 /* Pods_LoopFollow.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */; }; 654132E72E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654132E62E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift */; }; 654132EA2E19F24800BDBE08 /* TOTPGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654132E92E19F24800BDBE08 /* TOTPGenerator.swift */; }; @@ -21,6 +39,7 @@ 6584B1012E4A263900135D4D /* TOTPService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6584B1002E4A263900135D4D /* TOTPService.swift */; }; 6589CC622E9E7D1600BB18FE /* ImportExportSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC532E9E7D1600BB18FE /* ImportExportSettingsView.swift */; }; 6589CC632E9E7D1600BB18FE /* GeneralSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC5D2E9E7D1600BB18FE /* GeneralSettingsView.swift */; }; + 5544D8C363FB5D3B9BF8CE4A /* APNSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7C2676561D686C6459CAA2D /* APNSettingsView.swift */; }; 6589CC642E9E7D1600BB18FE /* ContactSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC592E9E7D1600BB18FE /* ContactSettingsView.swift */; }; 6589CC652E9E7D1600BB18FE /* DexcomSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC5C2E9E7D1600BB18FE /* DexcomSettingsViewModel.swift */; }; 6589CC662E9E7D1600BB18FE /* AdvancedSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC562E9E7D1600BB18FE /* AdvancedSettingsView.swift */; }; @@ -88,7 +107,6 @@ DD4878152C7B75230048F05C /* MealView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878142C7B75230048F05C /* MealView.swift */; }; DD4878172C7B75350048F05C /* BolusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878162C7B75350048F05C /* BolusView.swift */; }; DD4878192C7C56D60048F05C /* TrioNightscoutRemoteController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878182C7C56D60048F05C /* TrioNightscoutRemoteController.swift */; }; - DD48781C2C7DAF140048F05C /* SwiftJWT in Frameworks */ = {isa = PBXBuildFile; productRef = DD48781B2C7DAF140048F05C /* SwiftJWT */; }; DD48781E2C7DAF2F0048F05C /* PushNotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD48781D2C7DAF2F0048F05C /* PushNotificationManager.swift */; }; DD4878202C7DAF890048F05C /* PushMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD48781F2C7DAF890048F05C /* PushMessage.swift */; }; DD493AD52ACF2109009A6922 /* ResumePump.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD493AD42ACF2109009A6922 /* ResumePump.swift */; }; @@ -200,14 +218,14 @@ DDC7E5472DBD8A1600EB1127 /* AlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC7E5402DBD8A1600EB1127 /* AlarmEditor.swift */; }; DDC7E5CF2DC77C2000EB1127 /* SnoozerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC7E5CE2DC77C2000EB1127 /* SnoozerViewModel.swift */; }; DDCC3A4B2DDBB5E4006F1C10 /* BatteryCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A4A2DDBB5E4006F1C10 /* BatteryCondition.swift */; }; - DDCC3A502DDED000006F1C10 /* PumpBatteryCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A512DDED000006F1C10 /* PumpBatteryCondition.swift */; }; DDCC3A4D2DDBB77C006F1C10 /* PhoneBatteryAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A4C2DDBB77C006F1C10 /* PhoneBatteryAlarmEditor.swift */; }; - DDCC3A5B2DDE2000006F1C10 /* PumpBatteryAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A5C2DDE2000006F1C10 /* PumpBatteryAlarmEditor.swift */; }; DDCC3A4F2DDC5B54006F1C10 /* BatteryDropCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A4E2DDC5B54006F1C10 /* BatteryDropCondition.swift */; }; + DDCC3A502DDED000006F1C10 /* PumpBatteryCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A512DDED000006F1C10 /* PumpBatteryCondition.swift */; }; DDCC3A542DDC5D62006F1C10 /* BatteryDropAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A532DDC5D62006F1C10 /* BatteryDropAlarmEditor.swift */; }; DDCC3A562DDC9617006F1C10 /* MissedBolusCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A552DDC9617006F1C10 /* MissedBolusCondition.swift */; }; DDCC3A582DDC9655006F1C10 /* MissedBolusAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A572DDC9655006F1C10 /* MissedBolusAlarmEditor.swift */; }; DDCC3A5A2DDC988F006F1C10 /* CarbSample.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A592DDC988F006F1C10 /* CarbSample.swift */; }; + DDCC3A5B2DDE2000006F1C10 /* PumpBatteryAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A5C2DDE2000006F1C10 /* PumpBatteryAlarmEditor.swift */; }; DDCF979424C0D380002C9752 /* UIViewExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF979324C0D380002C9752 /* UIViewExtension.swift */; }; DDCF9A802D85FD0B004DF4DD /* Alarm.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF9A7F2D85FD09004DF4DD /* Alarm.swift */; }; DDCF9A822D85FD15004DF4DD /* AlarmType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF9A812D85FD14004DF4DD /* AlarmType.swift */; }; @@ -402,6 +420,13 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 37A4BDE62F5B6B4C00EEB289 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = FC97880C2485969B00A7906C /* Project object */; + proxyType = 1; + remoteGlobalIDString = 37A4BDD82F5B6B4A00EEB289; + remoteInfo = LoopFollowLAExtensionExtension; + }; DDCC3ADA2DDE1790006F1C10 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = FC97880C2485969B00A7906C /* Project object */; @@ -411,8 +436,36 @@ }; /* End PBXContainerItemProxy section */ +/* Begin PBXCopyFilesBuildPhase section */ + 37A4BDED2F5B6B4C00EEB289 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 37A4BDE82F5B6B4C00EEB289 /* LoopFollowLAExtensionExtension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 059B0FA59AABFE72FE13DDDA /* Pods-LoopFollow.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-LoopFollow.release.xcconfig"; path = "Target Support Files/Pods-LoopFollow/Pods-LoopFollow.release.xcconfig"; sourceTree = ""; }; + 374A77982F5BD8AB00E96858 /* APNSClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNSClient.swift; sourceTree = ""; }; + 374A779F2F5BE17000E96858 /* AppGroupID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppGroupID.swift; sourceTree = ""; }; + 374A77A02F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseLiveActivityAttributes.swift; sourceTree = ""; }; + 374A77A12F5BE17000E96858 /* GlucoseSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseSnapshot.swift; sourceTree = ""; }; + 374A77A32F5BE17000E96858 /* LAAppGroupSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LAAppGroupSettings.swift; sourceTree = ""; }; + 374A77AE2F5BE1AC00E96858 /* GlucoseSnapshotBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseSnapshotBuilder.swift; sourceTree = ""; }; + 374A77AF2F5BE1AC00E96858 /* GlucoseSnapshotStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseSnapshotStore.swift; sourceTree = ""; }; + 374A77B12F5BE1AC00E96858 /* LiveActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = ""; }; + 374A77B22F5BE1AC00E96858 /* PreferredGlucoseUnit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferredGlucoseUnit.swift; sourceTree = ""; }; + 374A77B32F5BE1AC00E96858 /* StorageCurrentGlucoseStateProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageCurrentGlucoseStateProvider.swift; sourceTree = ""; }; + 374DAACA2F5B924B00BB663B /* LoopFollowLAExtensionExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = LoopFollowLAExtensionExtension.entitlements; sourceTree = ""; }; + 37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = LoopFollowLAExtensionExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 37A4BDDA2F5B6B4A00EEB289 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = /System/Library/Frameworks/WidgetKit.framework; sourceTree = ""; }; + 37A4BDDC2F5B6B4A00EEB289 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = /System/Library/Frameworks/SwiftUI.framework; sourceTree = ""; }; 654132E62E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleQRCodeScannerView.swift; sourceTree = ""; }; 654132E92E19F24800BDBE08 /* TOTPGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOTPGenerator.swift; sourceTree = ""; }; 654134172E1DC09700BDBE08 /* OverridePresetsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverridePresetsView.swift; sourceTree = ""; }; @@ -435,6 +488,7 @@ 6589CC5B2E9E7D1600BB18FE /* DexcomSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DexcomSettingsView.swift; sourceTree = ""; }; 6589CC5C2E9E7D1600BB18FE /* DexcomSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DexcomSettingsViewModel.swift; sourceTree = ""; }; 6589CC5D2E9E7D1600BB18FE /* GeneralSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingsView.swift; sourceTree = ""; }; + E7C2676561D686C6459CAA2D /* APNSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNSettingsView.swift; sourceTree = ""; }; 6589CC5E2E9E7D1600BB18FE /* GraphSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraphSettingsView.swift; sourceTree = ""; }; 6589CC5F2E9E7D1600BB18FE /* SettingsMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsMenuView.swift; sourceTree = ""; }; 6589CC602E9E7D1600BB18FE /* TabCustomizationModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabCustomizationModal.swift; sourceTree = ""; }; @@ -605,14 +659,14 @@ DDC7E5402DBD8A1600EB1127 /* AlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmEditor.swift; sourceTree = ""; }; DDC7E5CE2DC77C2000EB1127 /* SnoozerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnoozerViewModel.swift; sourceTree = ""; }; DDCC3A4A2DDBB5E4006F1C10 /* BatteryCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryCondition.swift; sourceTree = ""; }; - DDCC3A512DDED000006F1C10 /* PumpBatteryCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpBatteryCondition.swift; sourceTree = ""; }; DDCC3A4C2DDBB77C006F1C10 /* PhoneBatteryAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneBatteryAlarmEditor.swift; sourceTree = ""; }; - DDCC3A5C2DDE2000006F1C10 /* PumpBatteryAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpBatteryAlarmEditor.swift; sourceTree = ""; }; DDCC3A4E2DDC5B54006F1C10 /* BatteryDropCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryDropCondition.swift; sourceTree = ""; }; + DDCC3A512DDED000006F1C10 /* PumpBatteryCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpBatteryCondition.swift; sourceTree = ""; }; DDCC3A532DDC5D62006F1C10 /* BatteryDropAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryDropAlarmEditor.swift; sourceTree = ""; }; DDCC3A552DDC9617006F1C10 /* MissedBolusCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MissedBolusCondition.swift; sourceTree = ""; }; DDCC3A572DDC9655006F1C10 /* MissedBolusAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MissedBolusAlarmEditor.swift; sourceTree = ""; }; DDCC3A592DDC988F006F1C10 /* CarbSample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbSample.swift; sourceTree = ""; }; + DDCC3A5C2DDE2000006F1C10 /* PumpBatteryAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpBatteryAlarmEditor.swift; sourceTree = ""; }; DDCC3ABF2DDE10B0006F1C10 /* Testing.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Testing.framework; path = Platforms/iPhoneOS.platform/Developer/Library/Frameworks/Testing.framework; sourceTree = DEVELOPER_DIR; }; DDCC3AD62DDE1790006F1C10 /* Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = Tests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; DDCF979324C0D380002C9752 /* UIViewExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIViewExtension.swift; sourceTree = ""; }; @@ -814,12 +868,22 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + 37A4BDDE2F5B6B4A00EEB289 /* LoopFollowLAExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = LoopFollowLAExtension; sourceTree = ""; }; 65AC25F52ECFD5E800421360 /* Stats */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Stats; sourceTree = ""; }; 65AC26702ED245DF00421360 /* Treatments */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Treatments; sourceTree = ""; }; DDCC3AD72DDE1790006F1C10 /* Tests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Tests; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ + 37A4BDD62F5B6B4A00EEB289 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 37A4BDDD2F5B6B4A00EEB289 /* SwiftUI.framework in Frameworks */, + 37A4BDDB2F5B6B4A00EEB289 /* WidgetKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; DDCC3AD32DDE1790006F1C10 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -834,13 +898,29 @@ FCFEEC9E2486E68E00402A7F /* WebKit.framework in Frameworks */, DD485F162E46631000CE8CBF /* CryptoSwift in Frameworks */, 3F1335F351590E573D8E6962 /* Pods_LoopFollow.framework in Frameworks */, - DD48781C2C7DAF140048F05C /* SwiftJWT in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 376310762F5CD65100656488 /* LiveActivity */ = { + isa = PBXGroup; + children = ( + 374A77AE2F5BE1AC00E96858 /* GlucoseSnapshotBuilder.swift */, + 374A77AF2F5BE1AC00E96858 /* GlucoseSnapshotStore.swift */, + 374A77B12F5BE1AC00E96858 /* LiveActivityManager.swift */, + 374A77B22F5BE1AC00E96858 /* PreferredGlucoseUnit.swift */, + 374A77B32F5BE1AC00E96858 /* StorageCurrentGlucoseStateProvider.swift */, + 374A779F2F5BE17000E96858 /* AppGroupID.swift */, + 374A77A02F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift */, + 374A77A12F5BE17000E96858 /* GlucoseSnapshot.swift */, + 374A77A32F5BE17000E96858 /* LAAppGroupSettings.swift */, + 374A77982F5BD8AB00E96858 /* APNSClient.swift */, + ); + path = LiveActivity; + sourceTree = ""; + }; 6589CC552E9E7D1600BB18FE /* ImportExport */ = { isa = PBXGroup; children = ( @@ -858,6 +938,7 @@ children = ( 6589CC552E9E7D1600BB18FE /* ImportExport */, 6589CC562E9E7D1600BB18FE /* AdvancedSettingsView.swift */, + E7C2676561D686C6459CAA2D /* APNSettingsView.swift */, 6589CC572E9E7D1600BB18FE /* AdvancedSettingsViewModel.swift */, 6589CC582E9E7D1600BB18FE /* CalendarSettingsView.swift */, 6589CC592E9E7D1600BB18FE /* ContactSettingsView.swift */, @@ -880,6 +961,8 @@ FCFEEC9D2486E68E00402A7F /* WebKit.framework */, A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */, FCE537C2249AAB2600F80BF8 /* NotificationCenter.framework */, + 37A4BDDA2F5B6B4A00EEB289 /* WidgetKit.framework */, + 37A4BDDC2F5B6B4A00EEB289 /* SwiftUI.framework */, ); name = Frameworks; sourceTree = ""; @@ -1483,6 +1566,7 @@ FC8DEEE32485D1680075863F /* LoopFollow */ = { isa = PBXGroup; children = ( + 376310762F5CD65100656488 /* LiveActivity */, 6589CC612E9E7D1600BB18FE /* Settings */, 65AC26702ED245DF00421360 /* Treatments */, 65AC25F52ECFD5E800421360 /* Stats */, @@ -1513,6 +1597,7 @@ FC97880B2485969B00A7906C = { isa = PBXGroup; children = ( + 374DAACA2F5B924B00BB663B /* LoopFollowLAExtensionExtension.entitlements */, DDF2C0132BEFD468007A20E6 /* blacklisted-versions.json */, DDB0AF542BB1B24A00AFA48B /* BuildDetails.plist */, DDB0AF4F2BB1A81F00AFA48B /* Scripts */, @@ -1521,6 +1606,7 @@ FC5A5C3C2497B229009C550E /* Config.xcconfig */, FC8DEEE32485D1680075863F /* LoopFollow */, DDCC3AD72DDE1790006F1C10 /* Tests */, + 37A4BDDE2F5B6B4A00EEB289 /* LoopFollowLAExtension */, FC9788152485969B00A7906C /* Products */, 8E32230C453C93FDCE59C2B9 /* Pods */, 6A5880E0B811AF443B05AB02 /* Frameworks */, @@ -1532,6 +1618,7 @@ children = ( FC9788142485969B00A7906C /* Loop Follow.app */, DDCC3AD62DDE1790006F1C10 /* Tests.xctest */, + 37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */, ); name = Products; sourceTree = ""; @@ -1603,6 +1690,28 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 37A4BDD82F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 37A4BDEA2F5B6B4C00EEB289 /* Build configuration list for PBXNativeTarget "LoopFollowLAExtensionExtension" */; + buildPhases = ( + 37A4BDD52F5B6B4A00EEB289 /* Sources */, + 37A4BDD62F5B6B4A00EEB289 /* Frameworks */, + 37A4BDD72F5B6B4A00EEB289 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 37A4BDDE2F5B6B4A00EEB289 /* LoopFollowLAExtension */, + ); + name = LoopFollowLAExtensionExtension; + packageProductDependencies = ( + ); + productName = LoopFollowLAExtensionExtension; + productReference = 37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; DDCC3AD52DDE1790006F1C10 /* Tests */ = { isa = PBXNativeTarget; buildConfigurationList = DDCC3ADC2DDE1790006F1C10 /* Build configuration list for PBXNativeTarget "Tests" */; @@ -1637,10 +1746,12 @@ FC9788122485969B00A7906C /* Resources */, 04DA71CCA0280FA5FA2DF7A6 /* [CP] Embed Pods Frameworks */, DDB0AF532BB1AA0900AFA48B /* Capture Build Details */, + 37A4BDED2F5B6B4C00EEB289 /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( + 37A4BDE72F5B6B4C00EEB289 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 65AC25F52ECFD5E800421360 /* Stats */, @@ -1648,7 +1759,6 @@ ); name = LoopFollow; packageProductDependencies = ( - DD48781B2C7DAF140048F05C /* SwiftJWT */, DD485F152E46631000CE8CBF /* CryptoSwift */, ); productName = LoopFollow; @@ -1661,10 +1771,13 @@ FC97880C2485969B00A7906C /* Project object */ = { isa = PBXProject; attributes = { - LastSwiftUpdateCheck = 1630; + LastSwiftUpdateCheck = 2620; LastUpgradeCheck = 1140; ORGANIZATIONNAME = "Jon Fawcett"; TargetAttributes = { + 37A4BDD82F5B6B4A00EEB289 = { + CreatedOnToolsVersion = 26.2; + }; DDCC3AD52DDE1790006F1C10 = { CreatedOnToolsVersion = 16.3; TestTargetID = FC9788132485969B00A7906C; @@ -1684,8 +1797,6 @@ ); mainGroup = FC97880B2485969B00A7906C; packageReferences = ( - DD48781A2C7DAF140048F05C /* XCRemoteSwiftPackageReference "Swift-JWT" */, - 654132E82E19F0B800BDBE08 /* XCRemoteSwiftPackageReference "swift-crypto" */, DD485F0B2E4547C800CE8CBF /* XCRemoteSwiftPackageReference "CryptoSwift" */, ); productRefGroup = FC9788152485969B00A7906C /* Products */; @@ -1694,11 +1805,19 @@ targets = ( FC9788132485969B00A7906C /* LoopFollow */, DDCC3AD52DDE1790006F1C10 /* Tests */, + 37A4BDD82F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 37A4BDD72F5B6B4A00EEB289 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; DDCC3AD42DDE1790006F1C10 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -1922,6 +2041,18 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 37A4BDD52F5B6B4A00EEB289 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + DD91E4DE2BDEC3F8002D9E97 /* GlucoseConversion.swift in Sources */, + 374A77AA2F5BE17000E96858 /* AppGroupID.swift in Sources */, + 374A77AB2F5BE17000E96858 /* GlucoseSnapshot.swift in Sources */, + 374A77AC2F5BE17000E96858 /* LAAppGroupSettings.swift in Sources */, + 374A77AD2F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; DDCC3AD22DDE1790006F1C10 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -1945,6 +2076,11 @@ DD4AFB492DB576C200BB593F /* AlarmSettingsView.swift in Sources */, DD7F4C032DD4B92E00D449E9 /* NotLoopingAlarmEditor.swift in Sources */, DD7F4C1B2DD6501D00D449E9 /* COBCondition.swift in Sources */, + 374A77B42F5BE1AC00E96858 /* StorageCurrentGlucoseStateProvider.swift in Sources */, + 374A77B52F5BE1AC00E96858 /* GlucoseSnapshotBuilder.swift in Sources */, + 374A77B72F5BE1AC00E96858 /* LiveActivityManager.swift in Sources */, + 374A77B82F5BE1AC00E96858 /* GlucoseSnapshotStore.swift in Sources */, + 374A77B92F5BE1AC00E96858 /* PreferredGlucoseUnit.swift in Sources */, DDE75D232DE5E505007C1FC1 /* Glyph.swift in Sources */, DD4878202C7DAF890048F05C /* PushMessage.swift in Sources */, DD0247712DB4337700FCADF6 /* BuildExpireCondition.swift in Sources */, @@ -2025,6 +2161,7 @@ DDEF503A2D31615000999A5D /* LogManager.swift in Sources */, DD4878172C7B75350048F05C /* BolusView.swift in Sources */, DD026E592EA2C8A200A39CB5 /* InsulinPrecisionManager.swift in Sources */, + 374A77992F5BD8B200E96858 /* APNSClient.swift in Sources */, DD493AE72ACF23CF009A6922 /* DeviceStatus.swift in Sources */, DDC7E5162DBCFA7F00EB1127 /* SnoozerView.swift in Sources */, FCFEECA2248857A600402A7F /* SettingsViewController.swift in Sources */, @@ -2066,6 +2203,7 @@ DD493AE12ACF22FE009A6922 /* Profile.swift in Sources */, 6589CC622E9E7D1600BB18FE /* ImportExportSettingsView.swift in Sources */, 6589CC632E9E7D1600BB18FE /* GeneralSettingsView.swift in Sources */, + 5544D8C363FB5D3B9BF8CE4A /* APNSettingsView.swift in Sources */, 6589CC642E9E7D1600BB18FE /* ContactSettingsView.swift in Sources */, 6589CC652E9E7D1600BB18FE /* DexcomSettingsViewModel.swift in Sources */, 6589CC662E9E7D1600BB18FE /* AdvancedSettingsView.swift in Sources */, @@ -2151,6 +2289,10 @@ DD7F4C212DD66BB200D449E9 /* FastRiseAlarmEditor.swift in Sources */, FC97881A2485969B00A7906C /* SceneDelegate.swift in Sources */, DD0C0C662C46E54C00DBADDF /* InfoDataSeparator.swift in Sources */, + 374A77A52F5BE17000E96858 /* AppGroupID.swift in Sources */, + 374A77A62F5BE17000E96858 /* GlucoseSnapshot.swift in Sources */, + 374A77A72F5BE17000E96858 /* LAAppGroupSettings.swift in Sources */, + 374A77A82F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift in Sources */, DD58171C2D299F940041FB98 /* BluetoothDevice.swift in Sources */, DD7E198A2ACDA62600DBD158 /* SensorStart.swift in Sources */, DD026E5B2EA2C9C300A39CB5 /* InsulinFormatter.swift in Sources */, @@ -2203,6 +2345,11 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 37A4BDE72F5B6B4C00EEB289 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 37A4BDD82F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension */; + targetProxy = 37A4BDE62F5B6B4C00EEB289 /* PBXContainerItemProxy */; + }; DDCC3ADB2DDE1790006F1C10 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = FC9788132485969B00A7906C /* LoopFollow */; @@ -2230,6 +2377,109 @@ /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ + 37A4BDEB2F5B6B4C00EEB289 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CODE_SIGN_ENTITLEMENTS = LoopFollowLAExtensionExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + ENABLE_APP_SANDBOX = NO; + ENABLE_HARDENED_RUNTIME = NO; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = LoopFollowLAExtension/ExtensionInfo.plist; + INFOPLIST_KEY_CFBundleDisplayName = LoopFollowLAExtension; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 Jon Fawcett. All rights reserved."; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 26.2; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix).LoopFollowLAExtension"; + PRODUCT_MODULE_NAME = LoopFollowLAExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + XROS_DEPLOYMENT_TARGET = 26.2; + }; + name = Debug; + }; + 37A4BDEC2F5B6B4C00EEB289 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CODE_SIGN_ENTITLEMENTS = LoopFollowLAExtensionExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + ENABLE_APP_SANDBOX = NO; + ENABLE_HARDENED_RUNTIME = NO; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = LoopFollowLAExtension/ExtensionInfo.plist; + INFOPLIST_KEY_CFBundleDisplayName = LoopFollowLAExtension; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 Jon Fawcett. All rights reserved."; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 26.2; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix).LoopFollowLAExtension"; + PRODUCT_MODULE_NAME = LoopFollowLAExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + XROS_DEPLOYMENT_TARGET = 26.2; + }; + name = Release; + }; DDCC3ADD2DDE1790006F1C10 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -2239,7 +2489,7 @@ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; @@ -2266,7 +2516,7 @@ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; @@ -2421,6 +2671,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix)"; PRODUCT_NAME = "Loop Follow"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -2445,6 +2696,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix)"; PRODUCT_NAME = "Loop Follow"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -2454,6 +2706,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 37A4BDEA2F5B6B4C00EEB289 /* Build configuration list for PBXNativeTarget "LoopFollowLAExtensionExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 37A4BDEB2F5B6B4C00EEB289 /* Debug */, + 37A4BDEC2F5B6B4C00EEB289 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; DDCC3ADC2DDE1790006F1C10 /* Build configuration list for PBXNativeTarget "Tests" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -2484,14 +2745,6 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ - 654132E82E19F0B800BDBE08 /* XCRemoteSwiftPackageReference "swift-crypto" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/apple/swift-crypto.git"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 3.12.3; - }; - }; DD485F0B2E4547C800CE8CBF /* XCRemoteSwiftPackageReference "CryptoSwift" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/krzyzanowskim/CryptoSwift.git"; @@ -2500,27 +2753,14 @@ minimumVersion = 1.9.0; }; }; - DD48781A2C7DAF140048F05C /* XCRemoteSwiftPackageReference "Swift-JWT" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/Kitura/Swift-JWT.git"; - requirement = { - kind = exactVersion; - version = 4.0.1; - }; - }; -/* End XCRemoteSwiftPackageReference section */ + /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ DD485F152E46631000CE8CBF /* CryptoSwift */ = { isa = XCSwiftPackageProductDependency; productName = CryptoSwift; }; - DD48781B2C7DAF140048F05C /* SwiftJWT */ = { - isa = XCSwiftPackageProductDependency; - package = DD48781A2C7DAF140048F05C /* XCRemoteSwiftPackageReference "Swift-JWT" */; - productName = SwiftJWT; - }; -/* End XCSwiftPackageProductDependency section */ + /* End XCSwiftPackageProductDependency section */ /* Begin XCVersionGroup section */ FC3AE7B3249E8E0E00AAE1E0 /* LoopFollow.xcdatamodeld */ = { diff --git a/LoopFollow.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/LoopFollow.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index f2d75025c..000000000 --- a/LoopFollow.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,16 +0,0 @@ -{ - "object": { - "pins": [ - { - "package": "Oxygen", - "repositoryURL": "https://github.com/mpangburn/Oxygen.git", - "state": { - "branch": "master", - "revision": "b3c7a6ead1400e4799b16755d23c9905040d4acc", - "version": null - } - } - ] - }, - "version": 1 -} diff --git a/LoopFollow/Controllers/Nightscout/BGData.swift b/LoopFollow/Controllers/Nightscout/BGData.swift index c07da66d5..c870abe57 100644 --- a/LoopFollow/Controllers/Nightscout/BGData.swift +++ b/LoopFollow/Controllers/Nightscout/BGData.swift @@ -260,9 +260,19 @@ extension MainViewController { Observable.shared.deltaText.value = "+" + Localizer.toDisplayUnits(String(deltaBG)) } + // Live Activity storage + Storage.shared.lastBgReadingTimeSeconds.value = lastBGTime + Storage.shared.lastDeltaMgdl.value = Double(deltaBG) + Storage.shared.lastTrendCode.value = entries[latestEntryIndex].direction + // Mark BG data as loaded for initial loading state self.markDataLoaded("bg") + // Live Activity update + if #available(iOS 16.1, *) { + LiveActivityManager.shared.refreshFromCurrentState(reason: "bg") + } + // Update contact if Storage.shared.contactEnabled.value { self.contactImageUpdater diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift index f8bc8f867..fc1739c4c 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift @@ -46,6 +46,7 @@ extension MainViewController { if IsNightscoutEnabled(), (now - lastLoopTime) >= nonLoopingTimeThreshold, lastLoopTime > 0 { IsNotLooping = true + Observable.shared.isNotLooping.value = true statusStackView.distribution = .fill PredictionLabel.isHidden = true @@ -55,9 +56,13 @@ extension MainViewController { LoopStatusLabel.text = "⚠️ Not Looping!" LoopStatusLabel.textColor = UIColor.systemYellow LoopStatusLabel.font = UIFont.boldSystemFont(ofSize: 18) + if #available(iOS 16.1, *) { + LiveActivityManager.shared.refreshFromCurrentState(reason: "notLooping") + } } else { IsNotLooping = false + Observable.shared.isNotLooping.value = false statusStackView.distribution = .fillEqually PredictionLabel.isHidden = false @@ -72,6 +77,9 @@ extension MainViewController { case .system: LoopStatusLabel.textColor = UIColor.label } + if #available(iOS 16.1, *) { + LiveActivityManager.shared.refreshFromCurrentState(reason: "loopingResumed") + } } } diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift index fe10b62b9..650092237 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift @@ -119,6 +119,17 @@ extension MainViewController { LoopStatusLabel.text = "↻" latestLoopStatusString = "↻" } + + // Live Activity storage + Storage.shared.lastIOB.value = latestIOB?.value + Storage.shared.lastCOB.value = latestCOB?.value + if let predictdata = lastLoopRecord["predicted"] as? [String: AnyObject], + let values = predictdata["values"] as? [Double] + { + Storage.shared.projectedBgMgdl.value = values.last + } else { + Storage.shared.projectedBgMgdl.value = nil + } } } } diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift index 57a940695..fc3b3c5b5 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift @@ -224,6 +224,11 @@ extension MainViewController { LoopStatusLabel.text = "↻" latestLoopStatusString = "↻" } + + // Live Activity storage + Storage.shared.lastIOB.value = latestIOB?.value + Storage.shared.lastCOB.value = latestCOB?.value + Storage.shared.projectedBgMgdl.value = nil } } } diff --git a/LoopFollow/Helpers/GlucoseConversion.swift b/LoopFollow/Helpers/GlucoseConversion.swift index bee265965..dab205bfa 100644 --- a/LoopFollow/Helpers/GlucoseConversion.swift +++ b/LoopFollow/Helpers/GlucoseConversion.swift @@ -4,6 +4,10 @@ import Foundation enum GlucoseConversion { - static let mgDlToMmolL: Double = 0.0555 static let mmolToMgDl: Double = 18.01559 + static let mgDlToMmolL: Double = 1.0 / mmolToMgDl + + static func toMmol(_ mgdl: Double) -> Double { + mgdl * mgDlToMmolL + } } diff --git a/LoopFollow/Helpers/JWTManager.swift b/LoopFollow/Helpers/JWTManager.swift index 621b2186d..06f4a5583 100644 --- a/LoopFollow/Helpers/JWTManager.swift +++ b/LoopFollow/Helpers/JWTManager.swift @@ -1,42 +1,46 @@ // LoopFollow // JWTManager.swift +import CryptoKit import Foundation -import SwiftJWT - -struct JWTClaims: Claims { - let iss: String - let iat: Date -} class JWTManager { static let shared = JWTManager() + private struct CachedToken { + let jwt: String + let expiresAt: Date + } + + /// Cache keyed by "keyId:teamId", 55 min TTL + private var cache: [String: CachedToken] = [:] + private let ttl: TimeInterval = 55 * 60 + private init() {} func getOrGenerateJWT(keyId: String, teamId: String, apnsKey: String) -> String? { - // 1. Check for a valid, non-expired JWT directly from Storage.shared - if let jwt = Storage.shared.cachedJWT.value, - let expiration = Storage.shared.jwtExpirationDate.value, - Date() < expiration - { - return jwt - } + let cacheKey = "\(keyId):\(teamId)" - // 2. If no valid JWT is found, generate a new one - let header = Header(kid: keyId) - let claims = JWTClaims(iss: teamId, iat: Date()) - var jwt = JWT(header: header, claims: claims) + if let cached = cache[cacheKey], Date() < cached.expiresAt { + return cached.jwt + } do { - let privateKey = Data(apnsKey.utf8) - let jwtSigner = JWTSigner.es256(privateKey: privateKey) - let signedJWT = try jwt.sign(using: jwtSigner) + let privateKey = try loadPrivateKey(from: apnsKey) + let header = try encodeHeader(keyId: keyId) + let payload = try encodePayload(teamId: teamId) + let signingInput = "\(header).\(payload)" + + guard let signingData = signingInput.data(using: .utf8) else { + LogManager.shared.log(category: .apns, message: "Failed to encode JWT signing input") + return nil + } - // 3. Save the new JWT and its expiration date directly to Storage.shared - Storage.shared.cachedJWT.value = signedJWT - Storage.shared.jwtExpirationDate.value = Date().addingTimeInterval(3600) // Expires in 1 hour + let signature = try privateKey.signature(for: signingData) + let signatureBase64 = base64URLEncode(signature.rawRepresentation) + let signedJWT = "\(signingInput).\(signatureBase64)" + cache[cacheKey] = CachedToken(jwt: signedJWT, expiresAt: Date().addingTimeInterval(ttl)) return signedJWT } catch { LogManager.shared.log(category: .apns, message: "Failed to sign JWT: \(error.localizedDescription)") @@ -44,9 +48,61 @@ class JWTManager { } } - // Invalidate the cache by clearing values in Storage.shared func invalidateCache() { - Storage.shared.cachedJWT.value = nil - Storage.shared.jwtExpirationDate.value = nil + cache.removeAll() + } + + // MARK: - Private Helpers + + private func loadPrivateKey(from apnsKey: String) throws -> P256.Signing.PrivateKey { + let cleaned = apnsKey + .replacingOccurrences(of: "-----BEGIN PRIVATE KEY-----", with: "") + .replacingOccurrences(of: "-----END PRIVATE KEY-----", with: "") + .replacingOccurrences(of: "\n", with: "") + .replacingOccurrences(of: "\r", with: "") + .trimmingCharacters(in: .whitespaces) + + guard let keyData = Data(base64Encoded: cleaned) else { + throw JWTError.keyDecodingFailed + } + + return try P256.Signing.PrivateKey(derRepresentation: keyData) + } + + private func encodeHeader(keyId: String) throws -> String { + let header: [String: String] = [ + "alg": "ES256", + "kid": keyId, + ] + let data = try JSONSerialization.data(withJSONObject: header) + return base64URLEncode(data) + } + + private func encodePayload(teamId: String) throws -> String { + let now = Int(Date().timeIntervalSince1970) + let payload: [String: Any] = [ + "iss": teamId, + "iat": now, + ] + let data = try JSONSerialization.data(withJSONObject: payload) + return base64URLEncode(data) + } + + private func base64URLEncode(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + private enum JWTError: Error, LocalizedError { + case keyDecodingFailed + + var errorDescription: String? { + switch self { + case .keyDecodingFailed: + return "Failed to decode APNs p8 key content. Ensure it is valid base64." + } + } } } diff --git a/LoopFollow/Info.plist b/LoopFollow/Info.plist index e76068f9a..28385ac6e 100644 --- a/LoopFollow/Info.plist +++ b/LoopFollow/Info.plist @@ -16,6 +16,8 @@ $(EXECUTABLE_NAME) CFBundleGetInfoString + CFBundleIconFile + Activities CFBundleIdentifier com.$(unique_id).LoopFollow$(app_suffix) CFBundleInfoDictionaryVersion @@ -61,6 +63,8 @@ This app requires Face ID for secure authentication. NSHumanReadableCopyright + NSSupportsLiveActivities + UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/LoopFollow/LiveActivity/APNSClient.swift b/LoopFollow/LiveActivity/APNSClient.swift new file mode 100644 index 000000000..ac2dfc782 --- /dev/null +++ b/LoopFollow/LiveActivity/APNSClient.swift @@ -0,0 +1,128 @@ +// LoopFollow +// APNSClient.swift + +import Foundation + +class APNSClient { + static let shared = APNSClient() + private init() {} + + // MARK: - Configuration + + private let bundleID = Bundle.main.bundleIdentifier ?? "com.apple.unknown" + + private var apnsHost: String { + let isProduction = BuildDetails.default.isTestFlightBuild() + return isProduction + ? "https://api.push.apple.com" + : "https://api.sandbox.push.apple.com" + } + + private var lfKeyId: String { Storage.shared.lfKeyId.value } + private var lfTeamId: String { BuildDetails.default.teamID ?? "" } + private var lfApnsKey: String { Storage.shared.lfApnsKey.value } + + // MARK: - Send Live Activity Update + + func sendLiveActivityUpdate( + pushToken: String, + state: GlucoseLiveActivityAttributes.ContentState + ) async { + guard let jwt = JWTManager.shared.getOrGenerateJWT(keyId: lfKeyId, teamId: lfTeamId, apnsKey: lfApnsKey) else { + LogManager.shared.log(category: .general, message: "APNs failed to generate JWT for Live Activity push") + return + } + + let payload = buildPayload(state: state) + + guard let url = URL(string: "\(apnsHost)/3/device/\(pushToken)") else { + LogManager.shared.log(category: .general, message: "APNs invalid URL", isDebug: true) + return + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("bearer \(jwt)", forHTTPHeaderField: "authorization") + request.setValue("application/json", forHTTPHeaderField: "content-type") + request.setValue("\(bundleID).push-type.liveactivity", forHTTPHeaderField: "apns-topic") + request.setValue("liveactivity", forHTTPHeaderField: "apns-push-type") + request.setValue("10", forHTTPHeaderField: "apns-priority") + request.httpBody = payload + + do { + let (data, response) = try await URLSession.shared.data(for: request) + + if let httpResponse = response as? HTTPURLResponse { + switch httpResponse.statusCode { + case 200: + LogManager.shared.log(category: .general, message: "APNs push sent successfully", isDebug: true) + + case 400: + let responseBody = String(data: data, encoding: .utf8) ?? "empty" + LogManager.shared.log(category: .general, message: "APNs bad request (400) — malformed payload: \(responseBody)") + + case 403: + // JWT rejected — force regenerate on next push + JWTManager.shared.invalidateCache() + LogManager.shared.log(category: .general, message: "APNs JWT rejected (403) — token cache cleared, will regenerate") + + case 404, 410: + // Activity token not found or expired — end and restart on next refresh + let reason = httpResponse.statusCode == 410 ? "expired (410)" : "not found (404)" + LogManager.shared.log(category: .general, message: "APNs token \(reason) — restarting Live Activity") + LiveActivityManager.shared.handleExpiredToken() + + case 429: + LogManager.shared.log(category: .general, message: "APNs rate limited (429) — will retry on next refresh") + + case 500 ... 599: + let responseBody = String(data: data, encoding: .utf8) ?? "empty" + LogManager.shared.log(category: .general, message: "APNs server error (\(httpResponse.statusCode)) — will retry on next refresh: \(responseBody)") + + default: + let responseBody = String(data: data, encoding: .utf8) ?? "empty" + LogManager.shared.log(category: .general, message: "APNs push failed status=\(httpResponse.statusCode) body=\(responseBody)") + } + } + + } catch { + LogManager.shared.log(category: .general, message: "APNs error: \(error.localizedDescription)") + } + } + + // MARK: - Payload Builder + + private func buildPayload(state: GlucoseLiveActivityAttributes.ContentState) -> Data? { + let snapshot = state.snapshot + + var snapshotDict: [String: Any] = [ + "glucose": snapshot.glucose, + "delta": snapshot.delta, + "trend": snapshot.trend.rawValue, + "updatedAt": snapshot.updatedAt.timeIntervalSince1970, + "unit": snapshot.unit.rawValue, + ] + + snapshotDict["isNotLooping"] = snapshot.isNotLooping + if let iob = snapshot.iob { snapshotDict["iob"] = iob } + if let cob = snapshot.cob { snapshotDict["cob"] = cob } + if let projected = snapshot.projected { snapshotDict["projected"] = projected } + + let contentState: [String: Any] = [ + "snapshot": snapshotDict, + "seq": state.seq, + "reason": state.reason, + "producedAt": state.producedAt.timeIntervalSince1970, + ] + + let payload: [String: Any] = [ + "aps": [ + "timestamp": Int(Date().timeIntervalSince1970), + "event": "update", + "content-state": contentState, + ], + ] + + return try? JSONSerialization.data(withJSONObject: payload) + } +} diff --git a/LoopFollow/LiveActivity/AppGroupID.swift b/LoopFollow/LiveActivity/AppGroupID.swift new file mode 100644 index 000000000..6fc2bb9a6 --- /dev/null +++ b/LoopFollow/LiveActivity/AppGroupID.swift @@ -0,0 +1,63 @@ +// LoopFollow +// AppGroupID.swift + +import Foundation + +/// Resolves the App Group identifier in a PR-safe way. +/// +/// Preferred contract: +/// - App Group = "group." +/// - No team-specific hardcoding +/// +/// Important nuance: +/// - Extensions often have a *different* bundle identifier than the main app. +/// - To keep app + extensions aligned, we: +/// 1) Prefer an explicit base bundle id if provided via Info.plist key. +/// 2) Otherwise, apply a conservative suffix-stripping heuristic. +/// 3) Fall back to the current bundle identifier. +enum AppGroupID { + /// Optional Info.plist key you can set in *both* app + extension targets + /// to force a shared base bundle id (recommended for reliability). + private static let baseBundleIDPlistKey = "LFAppGroupBaseBundleID" + + static func current() -> String { + if let base = Bundle.main.object(forInfoDictionaryKey: baseBundleIDPlistKey) as? String, + !base.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + return "group.\(base)" + } + + let bundleID = Bundle.main.bundleIdentifier ?? "unknown" + + // Heuristic: strip common extension suffixes so the extension can land on the main app’s group id. + let base = stripLikelyExtensionSuffixes(from: bundleID) + + return "group.\(base)" + } + + private static func stripLikelyExtensionSuffixes(from bundleID: String) -> String { + let knownSuffixes = [ + ".LiveActivity", + ".LiveActivityExtension", + ".LoopFollowLAExtension", + ".Widget", + ".WidgetExtension", + ".Widgets", + ".WidgetsExtension", + ".Watch", + ".WatchExtension", + ".CarPlay", + ".CarPlayExtension", + ".Intents", + ".IntentsExtension", + ] + + for suffix in knownSuffixes { + if bundleID.hasSuffix(suffix) { + return String(bundleID.dropLast(suffix.count)) + } + } + + return bundleID + } +} diff --git a/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift b/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift new file mode 100644 index 000000000..9d6811e56 --- /dev/null +++ b/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift @@ -0,0 +1,37 @@ +// LoopFollow +// GlucoseLiveActivityAttributes.swift + +import ActivityKit +import Foundation + +struct GlucoseLiveActivityAttributes: ActivityAttributes { + public struct ContentState: Codable, Hashable { + let snapshot: GlucoseSnapshot + let seq: Int + let reason: String + let producedAt: Date + + init(snapshot: GlucoseSnapshot, seq: Int, reason: String, producedAt: Date) { + self.snapshot = snapshot + self.seq = seq + self.reason = reason + self.producedAt = producedAt + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + snapshot = try container.decode(GlucoseSnapshot.self, forKey: .snapshot) + seq = try container.decode(Int.self, forKey: .seq) + reason = try container.decode(String.self, forKey: .reason) + let producedAtInterval = try container.decode(Double.self, forKey: .producedAt) + producedAt = Date(timeIntervalSince1970: producedAtInterval) + } + + private enum CodingKeys: String, CodingKey { + case snapshot, seq, reason, producedAt + } + } + + /// Reserved for future metadata. Keep minimal for stability. + let title: String +} diff --git a/LoopFollow/LiveActivity/GlucoseSnapshot.swift b/LoopFollow/LiveActivity/GlucoseSnapshot.swift new file mode 100644 index 000000000..934f44eac --- /dev/null +++ b/LoopFollow/LiveActivity/GlucoseSnapshot.swift @@ -0,0 +1,134 @@ +// LoopFollow +// GlucoseSnapshot.swift + +import Foundation + +/// Canonical, source-agnostic glucose state used by +/// Live Activity, future Watch complication, and CarPlay. +/// +struct GlucoseSnapshot: Codable, Equatable, Hashable { + // MARK: - Units + + enum Unit: String, Codable, Hashable { + case mgdl + case mmol + } + + // MARK: - Core Glucose + + /// Glucose value in mg/dL (canonical internal unit). + let glucose: Double + + /// Delta in mg/dL. May be 0.0 if unchanged. + let delta: Double + + /// Trend direction (mapped from LoopFollow state). + let trend: Trend + + /// Timestamp of reading. + let updatedAt: Date + + // MARK: - Secondary Metrics + + /// Insulin On Board + let iob: Double? + + /// Carbs On Board + let cob: Double? + + /// Projected glucose in mg/dL (if available) + let projected: Double? + + // MARK: - Unit Context + + /// User's preferred display unit. Values are always stored in mg/dL; + /// this tells the display layer which unit to render. + let unit: Unit + + // MARK: - Loop Status + + /// True when LoopFollow detects the loop has not reported in 15+ minutes (Nightscout only). + let isNotLooping: Bool + + init( + glucose: Double, + delta: Double, + trend: Trend, + updatedAt: Date, + iob: Double?, + cob: Double?, + projected: Double?, + unit: Unit, + isNotLooping: Bool + ) { + self.glucose = glucose + self.delta = delta + self.trend = trend + self.updatedAt = updatedAt + self.iob = iob + self.cob = cob + self.projected = projected + self.unit = unit + self.isNotLooping = isNotLooping + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(glucose, forKey: .glucose) + try container.encode(delta, forKey: .delta) + try container.encode(trend, forKey: .trend) + try container.encode(updatedAt.timeIntervalSince1970, forKey: .updatedAt) + try container.encodeIfPresent(iob, forKey: .iob) + try container.encodeIfPresent(cob, forKey: .cob) + try container.encodeIfPresent(projected, forKey: .projected) + try container.encode(unit, forKey: .unit) + try container.encode(isNotLooping, forKey: .isNotLooping) + } + + private enum CodingKeys: String, CodingKey { + case glucose, delta, trend, updatedAt, iob, cob, projected, unit, isNotLooping + } + + // MARK: - Codable + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + glucose = try container.decode(Double.self, forKey: .glucose) + delta = try container.decode(Double.self, forKey: .delta) + trend = try container.decode(Trend.self, forKey: .trend) + updatedAt = try Date(timeIntervalSince1970: container.decode(Double.self, forKey: .updatedAt)) + iob = try container.decodeIfPresent(Double.self, forKey: .iob) + cob = try container.decodeIfPresent(Double.self, forKey: .cob) + projected = try container.decodeIfPresent(Double.self, forKey: .projected) + unit = try container.decode(Unit.self, forKey: .unit) + isNotLooping = try container.decodeIfPresent(Bool.self, forKey: .isNotLooping) ?? false + } + + // MARK: - Derived Convenience + + /// Age of reading in seconds. + var age: TimeInterval { + Date().timeIntervalSince(updatedAt) + } +} + +// MARK: - Trend + +extension GlucoseSnapshot { + enum Trend: String, Codable, Hashable { + case up + case upSlight + case upFast + case flat + case down + case downSlight + case downFast + case unknown + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let raw = try container.decode(String.self) + self = Trend(rawValue: raw) ?? .unknown + } + } +} diff --git a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift new file mode 100644 index 000000000..ad5b93dae --- /dev/null +++ b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift @@ -0,0 +1,114 @@ +// LoopFollow +// GlucoseSnapshotBuilder.swift + +import Foundation + +/// Provides the *latest* glucose-relevant values from LoopFollow’s single source of truth. +/// This is intentionally provider-agnostic (Nightscout vs Dexcom doesn’t matter). +protocol CurrentGlucoseStateProviding { + /// Canonical glucose value in mg/dL (recommended internal canonical form). + var glucoseMgdl: Double? { get } + + /// Canonical delta in mg/dL. + var deltaMgdl: Double? { get } + + /// Canonical projected glucose in mg/dL. + var projectedMgdl: Double? { get } + + /// Timestamp of the last reading/update. + var updatedAt: Date? { get } + + /// Trend string / code from LoopFollow (we map to GlucoseSnapshot.Trend). + var trendCode: String? { get } + + /// Secondary metrics (typically already unitless) + var iob: Double? { get } + var cob: Double? { get } +} + +/// Builds a GlucoseSnapshot in the user’s preferred unit, without embedding provider logic. +enum GlucoseSnapshotBuilder { + static func build(from provider: CurrentGlucoseStateProviding) -> GlucoseSnapshot? { + guard + let glucoseMgdl = provider.glucoseMgdl, + glucoseMgdl > 0, + let updatedAt = provider.updatedAt + else { + // Debug-only signal: we’re missing core state. + // (If you prefer no logs here, remove this line.) + LogManager.shared.log( + category: .general, + message: "GlucoseSnapshotBuilder: missing/invalid core values glucoseMgdl=\(provider.glucoseMgdl?.description ?? "nil") updatedAt=\(provider.updatedAt?.description ?? "nil")", + isDebug: true + ) + return nil + } + + let preferredUnit = PreferredGlucoseUnit.snapshotUnit() + + let deltaMgdl = provider.deltaMgdl ?? 0.0 + + let trend = mapTrend(provider.trendCode) + + // Not Looping — read from Observable, set by evaluateNotLooping() in DeviceStatus.swift + let isNotLooping = Observable.shared.isNotLooping.value + + LogManager.shared.log( + category: .general, + message: "LA snapshot built: updatedAt=\(updatedAt) interval=\(updatedAt.timeIntervalSince1970)", + isDebug: true + ) + + return GlucoseSnapshot( + glucose: glucoseMgdl, + delta: deltaMgdl, + trend: trend, + updatedAt: updatedAt, + iob: provider.iob, + cob: provider.cob, + projected: provider.projectedMgdl, + unit: preferredUnit, + isNotLooping: isNotLooping + ) + } + + private static func mapTrend(_ code: String?) -> GlucoseSnapshot.Trend { + guard + let raw = code? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased(), + !raw.isEmpty + else { return .unknown } + + // Common Nightscout strings: + // "Flat", "FortyFiveUp", "SingleUp", "DoubleUp", "FortyFiveDown", "SingleDown", "DoubleDown" + // Common variants: + // "rising", "falling", "rapidRise", "rapidFall" + + 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/GlucoseSnapshotStore.swift b/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift new file mode 100644 index 000000000..b45a7a0b9 --- /dev/null +++ b/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift @@ -0,0 +1,75 @@ +// LoopFollow +// GlucoseSnapshotStore.swift + +import Foundation + +/// Persists the latest GlucoseSnapshot into the App Group container so that: +/// - the Live Activity extension can read it +/// - future Watch + CarPlay surfaces can reuse it +/// +/// Uses an atomic JSON file write to avoid partial/corrupt reads across processes. +final class GlucoseSnapshotStore { + static let shared = GlucoseSnapshotStore() + private init() {} + + private let fileName = "glucose_snapshot.json" + private let queue = DispatchQueue(label: "com.loopfollow.glucoseSnapshotStore", qos: .utility) + + // MARK: - Public API + + func save(_ snapshot: GlucoseSnapshot) { + queue.async { + do { + let url = try self.fileURL() + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode(snapshot) + try data.write(to: url, options: [.atomic]) + } catch { + // Intentionally silent (extension-safe, no dependencies). + } + } + } + + func load() -> GlucoseSnapshot? { + do { + let url = try fileURL() + guard FileManager.default.fileExists(atPath: url.path) else { return nil } + + let data = try Data(contentsOf: url) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode(GlucoseSnapshot.self, from: data) + } catch { + // Intentionally silent (extension-safe, no dependencies). + return nil + } + } + + func delete() { + queue.async { + do { + let url = try self.fileURL() + if FileManager.default.fileExists(atPath: url.path) { + try FileManager.default.removeItem(at: url) + } + } catch { + // Intentionally silent (extension-safe, no dependencies). + } + } + } + + // MARK: - Helpers + + private func fileURL() throws -> URL { + let groupID = AppGroupID.current() + guard let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: groupID) else { + throw NSError( + domain: "GlucoseSnapshotStore", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "App Group containerURL is nil for id=\(groupID)"] + ) + } + return containerURL.appendingPathComponent(fileName, isDirectory: false) + } +} diff --git a/LoopFollow/LiveActivity/LAAppGroupSettings.swift b/LoopFollow/LiveActivity/LAAppGroupSettings.swift new file mode 100644 index 000000000..7615b2cf7 --- /dev/null +++ b/LoopFollow/LiveActivity/LAAppGroupSettings.swift @@ -0,0 +1,34 @@ +// LoopFollow +// LAAppGroupSettings.swift + +import Foundation + +/// Minimal App Group settings needed by the Live Activity UI. +/// +/// We keep this separate from Storage.shared to avoid target-coupling and +/// ensure the widget extension reads the same values as the app. +enum LAAppGroupSettings { + private enum Keys { + static let lowLineMgdl = "la.lowLine.mgdl" + static let highLineMgdl = "la.highLine.mgdl" + } + + private static var defaults: UserDefaults? { + UserDefaults(suiteName: AppGroupID.current()) + } + + // MARK: - Write (App) + + static func setThresholds(lowMgdl: Double, highMgdl: Double) { + defaults?.set(lowMgdl, forKey: Keys.lowLineMgdl) + defaults?.set(highMgdl, forKey: Keys.highLineMgdl) + } + + // MARK: - Read (Extension) + + static func thresholdsMgdl(fallbackLow: Double = 70, fallbackHigh: Double = 180) -> (low: Double, high: Double) { + let low = defaults?.object(forKey: Keys.lowLineMgdl) as? Double ?? fallbackLow + let high = defaults?.object(forKey: Keys.highLineMgdl) as? Double ?? fallbackHigh + return (low, high) + } +} diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift new file mode 100644 index 000000000..b342711a7 --- /dev/null +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -0,0 +1,279 @@ +// LoopFollow +// LiveActivityManager.swift + +@preconcurrency import ActivityKit +import Foundation +import os +import UIKit + +/// Live Activity manager for LoopFollow. + +@available(iOS 16.1, *) +final class LiveActivityManager { + static let shared = LiveActivityManager() + private init() {} + + private(set) var current: Activity? + private var stateObserverTask: Task? + private var updateTask: Task? + private var seq: Int = 0 + private var lastUpdateTime: Date? + private var pushToken: String? + private var tokenObservationTask: Task? + private var refreshWorkItem: DispatchWorkItem? + + // MARK: - Public API + + func startIfNeeded() { + guard ActivityAuthorizationInfo().areActivitiesEnabled else { + LogManager.shared.log(category: .general, message: "Live Activity not authorized") + return + } + + if let existing = Activity.activities.first { + bind(to: existing, logReason: "reuse") + return + } + + do { + let attributes = GlucoseLiveActivityAttributes(title: "LoopFollow") + + let seedSnapshot = GlucoseSnapshotStore.shared.load() ?? GlucoseSnapshot( + glucose: 0, + delta: 0, + trend: .unknown, + updatedAt: Date(), + iob: nil, + cob: nil, + projected: nil, + unit: .mgdl, + isNotLooping: false + ) + + let initialState = GlucoseLiveActivityAttributes.ContentState( + snapshot: seedSnapshot, + seq: 0, + reason: "start", + producedAt: Date() + ) + + let content = ActivityContent(state: initialState, staleDate: Date().addingTimeInterval(15 * 60)) + let activity = try Activity.request(attributes: attributes, content: content, pushType: .token) + + bind(to: activity, logReason: "start-new") + LogManager.shared.log(category: .general, message: "Live Activity started id=\(activity.id)") + } catch { + LogManager.shared.log(category: .general, message: "Live Activity failed to start: \(error)") + } + } + + func end(dismissalPolicy: ActivityUIDismissalPolicy = .default) { + updateTask?.cancel() + updateTask = nil + + guard let activity = current else { return } + + Task { + let finalState = GlucoseLiveActivityAttributes.ContentState( + snapshot: GlucoseSnapshotStore.shared.load() ?? GlucoseSnapshot( + glucose: 0, + delta: 0, + trend: .unknown, + updatedAt: Date(), + iob: nil, + cob: nil, + projected: nil, + unit: .mgdl, + isNotLooping: false + ), + seq: seq, + reason: "end", + producedAt: Date() + ) + + let content = ActivityContent(state: finalState, staleDate: nil) + await activity.end(content, dismissalPolicy: dismissalPolicy) + + LogManager.shared.log(category: .general, message: "Live Activity ended id=\(activity.id)", isDebug: true) + + if current?.id == activity.id { + current = nil + } + } + } + + func startFromCurrentState() { + let provider = StorageCurrentGlucoseStateProvider() + if let snapshot = GlucoseSnapshotBuilder.build(from: provider) { + LAAppGroupSettings.setThresholds( + lowMgdl: Storage.shared.lowLine.value, + highMgdl: Storage.shared.highLine.value + ) + GlucoseSnapshotStore.shared.save(snapshot) + } + startIfNeeded() + } + + func refreshFromCurrentState(reason: String) { + refreshWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + self?.performRefresh(reason: reason) + } + refreshWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 5.0, execute: workItem) + } + + private func performRefresh(reason: String) { + let provider = StorageCurrentGlucoseStateProvider() + guard let snapshot = GlucoseSnapshotBuilder.build(from: provider) else { + return + } + LogManager.shared.log(category: .general, message: "[LA] refresh g=\(snapshot.glucose) reason=\(reason)", isDebug: true) + let fingerprint = + "g=\(snapshot.glucose) d=\(snapshot.delta) t=\(snapshot.trend.rawValue) " + + "at=\(snapshot.updatedAt.timeIntervalSince1970) iob=\(snapshot.iob?.description ?? "nil") " + + "cob=\(snapshot.cob?.description ?? "nil") proj=\(snapshot.projected?.description ?? "nil") u=\(snapshot.unit.rawValue)" + LogManager.shared.log(category: .general, message: "[LA] snapshot \(fingerprint) reason=\(reason)", isDebug: true) + let now = Date() + let timeSinceLastUpdate = now.timeIntervalSince(lastUpdateTime ?? .distantPast) + let forceRefreshNeeded = timeSinceLastUpdate >= 5 * 60 + if let previous = GlucoseSnapshotStore.shared.load(), previous == snapshot, !forceRefreshNeeded { + return + } + LAAppGroupSettings.setThresholds( + lowMgdl: Storage.shared.lowLine.value, + highMgdl: Storage.shared.highLine.value + ) + GlucoseSnapshotStore.shared.save(snapshot) + guard ActivityAuthorizationInfo().areActivitiesEnabled else { + return + } + if current == nil, let existing = Activity.activities.first { + bind(to: existing, logReason: "bind-existing") + } + if let _ = current { + update(snapshot: snapshot, reason: reason) + return + } + if isAppVisibleForLiveActivityStart() { + startIfNeeded() + if current != nil { + update(snapshot: snapshot, reason: reason) + } + } else { + LogManager.shared.log(category: .general, message: "LA start suppressed (not visible) reason=\(reason)", isDebug: true) + } + } + + private func isAppVisibleForLiveActivityStart() -> Bool { + let scenes = UIApplication.shared.connectedScenes + return scenes.contains { $0.activationState == .foregroundActive } + } + + func update(snapshot: GlucoseSnapshot, reason: String) { + if current == nil, let existing = Activity.activities.first { + bind(to: existing, logReason: "bind-existing") + } + + guard let activity = current else { return } + + updateTask?.cancel() + + seq += 1 + let nextSeq = seq + let activityID = activity.id + + let state = GlucoseLiveActivityAttributes.ContentState( + snapshot: snapshot, + seq: nextSeq, + reason: reason, + producedAt: Date() + ) + + updateTask = Task { [weak self] in + guard let self else { return } + + if activity.activityState == .ended || activity.activityState == .dismissed { + if self.current?.id == activityID { self.current = nil } + return + } + + let content = ActivityContent( + state: state, + staleDate: Date().addingTimeInterval(15 * 60), + relevanceScore: 100.0 + ) + + if Task.isCancelled { return } + + // Dual-path update strategy: + // - Foreground: direct ActivityKit update works reliably. + // - Background: direct update silently fails due to the audio session + // limitation. APNs self-push is the only reliable delivery path. + // Both paths are attempted when applicable; APNs is the authoritative + // background mechanism. + let isForeground = await MainActor.run { + UIApplication.shared.applicationState == .active + } + + if isForeground { + await activity.update(content) + } + + if Task.isCancelled { return } + + guard self.current?.id == activityID else { + LogManager.shared.log(category: .general, message: "Live Activity update — activity ID mismatch, discarding") + return + } + + self.lastUpdateTime = Date() + LogManager.shared.log(category: .general, message: "[LA] updated id=\(activityID) seq=\(nextSeq) reason=\(reason)", isDebug: true) + + if let token = self.pushToken { + await APNSClient.shared.sendLiveActivityUpdate(pushToken: token, state: state) + } + } + } + + // MARK: - Binding / Lifecycle + + private func bind(to activity: Activity, logReason: String) { + if current?.id == activity.id { return } + current = activity + attachStateObserver(to: activity) + LogManager.shared.log(category: .general, message: "Live Activity bound id=\(activity.id) (\(logReason))", isDebug: true) + observePushToken(for: activity) + } + + private func observePushToken(for activity: Activity) { + tokenObservationTask?.cancel() + tokenObservationTask = Task { + for await tokenData in activity.pushTokenUpdates { + let token = tokenData.map { String(format: "%02x", $0) }.joined() + self.pushToken = token + LogManager.shared.log(category: .general, message: "Live Activity push token received", isDebug: true) + } + } + } + + func handleExpiredToken() { + end() + // Activity will restart on next BG refresh via refreshFromCurrentState() + } + + private func attachStateObserver(to activity: Activity) { + stateObserverTask?.cancel() + stateObserverTask = Task { + for await state in activity.activityStateUpdates { + LogManager.shared.log(category: .general, message: "Live Activity state id=\(activity.id) -> \(state)", isDebug: true) + if state == .ended || state == .dismissed { + if current?.id == activity.id { + current = nil + LogManager.shared.log(category: .general, message: "Live Activity cleared id=\(activity.id)", isDebug: true) + } + } + } + } + } +} diff --git a/LoopFollow/LiveActivity/PreferredGlucoseUnit.swift b/LoopFollow/LiveActivity/PreferredGlucoseUnit.swift new file mode 100644 index 000000000..eb26b9b54 --- /dev/null +++ b/LoopFollow/LiveActivity/PreferredGlucoseUnit.swift @@ -0,0 +1,22 @@ +// LoopFollow +// PreferredGlucoseUnit.swift + +import Foundation +import HealthKit + +enum PreferredGlucoseUnit { + /// LoopFollow’s existing source of truth for unit selection. + static func hkUnit() -> HKUnit { + Localizer.getPreferredUnit() + } + + /// Maps HKUnit -> GlucoseSnapshot.Unit (our cross-platform enum). + static func snapshotUnit() -> GlucoseSnapshot.Unit { + switch hkUnit() { + case .millimolesPerLiter: + return .mmol + default: + return .mgdl + } + } +} diff --git a/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift b/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift new file mode 100644 index 000000000..b5a5cf7ea --- /dev/null +++ b/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift @@ -0,0 +1,44 @@ +// LoopFollow +// StorageCurrentGlucoseStateProvider.swift + +import Foundation + +/// Reads the latest glucose state from LoopFollow’s existing single source of truth. +/// Provider remains source-agnostic (Nightscout vs Dexcom). +struct StorageCurrentGlucoseStateProvider: CurrentGlucoseStateProviding { + var glucoseMgdl: Double? { + guard + let bg = Observable.shared.bg.value, + bg > 0 + else { + return nil + } + + return Double(bg) + } + + var deltaMgdl: Double? { + Storage.shared.lastDeltaMgdl.value + } + + var projectedMgdl: Double? { + Storage.shared.projectedBgMgdl.value + } + + var updatedAt: Date? { + guard let t = Storage.shared.lastBgReadingTimeSeconds.value else { return nil } + return Date(timeIntervalSince1970: t) + } + + var trendCode: String? { + Storage.shared.lastTrendCode.value + } + + var iob: Double? { + Storage.shared.lastIOB.value + } + + var cob: Double? { + Storage.shared.lastCOB.value + } +} diff --git a/LoopFollow/Loop Follow.entitlements b/LoopFollow/Loop Follow.entitlements index ec1156a01..69ade1013 100644 --- a/LoopFollow/Loop Follow.entitlements +++ b/LoopFollow/Loop Follow.entitlements @@ -8,6 +8,10 @@ development com.apple.security.app-sandbox + com.apple.security.application-groups + + group.com.$(unique_id).LoopFollow$(app_suffix) + com.apple.security.device.bluetooth com.apple.security.network.client diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift index efcb15862..61aaa6ef4 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift @@ -1,14 +1,26 @@ // LoopFollow // LoopAPNSService.swift -import CryptoKit import Foundation import HealthKit -import SwiftJWT class LoopAPNSService { private let storage = Storage.shared + /// 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) + } + } + enum LoopAPNSError: Error, LocalizedError { case invalidConfiguration case jwtError @@ -57,26 +69,11 @@ class LoopAPNSService { return nil } - // Get the target Loop app's Team ID from storage. - let targetTeamId = storage.teamId.value ?? "" - let teamIdsAreDifferent = loopFollowTeamID != targetTeamId - - let keyIdForReturn: String - let apnsKeyForReturn: String - - if teamIdsAreDifferent { - // Team IDs differ, use the separate return credentials. - keyIdForReturn = storage.returnKeyId.value - apnsKeyForReturn = storage.returnApnsKey.value - } else { - // Team IDs are the same, use the primary credentials. - keyIdForReturn = storage.keyId.value - apnsKeyForReturn = storage.apnsKey.value - } + let lfKeyId = storage.lfKeyId.value + let lfApnsKey = storage.lfApnsKey.value - // Ensure we have the necessary credentials. - guard !keyIdForReturn.isEmpty, !apnsKeyForReturn.isEmpty else { - LogManager.shared.log(category: .apns, message: "Missing required return APNS credentials. Check Remote Settings.") + guard !lfKeyId.isEmpty, !lfApnsKey.isEmpty else { + LogManager.shared.log(category: .apns, message: "Missing LoopFollow APNS credentials. Configure them in App Settings → APN.") return nil } @@ -85,8 +82,8 @@ class LoopAPNSService { deviceToken: loopFollowDeviceToken, bundleId: Bundle.main.bundleIdentifier ?? "", teamId: loopFollowTeamID, - keyId: keyIdForReturn, - apnsKey: apnsKeyForReturn + keyId: lfKeyId, + apnsKey: lfApnsKey ) } @@ -108,8 +105,9 @@ class LoopAPNSService { /// Validates the Loop APNS setup by checking all required fields /// - Returns: True if setup is valid, false otherwise func validateSetup() -> Bool { - let hasKeyId = !storage.keyId.value.isEmpty - let hasAPNSKey = !storage.apnsKey.value.isEmpty + let creds = effectiveCredentials() + let hasKeyId = !creds.keyId.isEmpty + let hasAPNSKey = !creds.apnsKey.isEmpty let hasQrCode = !storage.loopAPNSQrCodeURL.value.isEmpty let hasDeviceToken = !Storage.shared.deviceToken.value.isEmpty let hasBundleIdentifier = !Storage.shared.bundleId.value.isEmpty @@ -138,8 +136,7 @@ class LoopAPNSService { let deviceToken = Storage.shared.deviceToken.value let bundleIdentifier = Storage.shared.bundleId.value - let keyId = storage.keyId.value - let apnsKey = storage.apnsKey.value + let creds = effectiveCredentials() // Create APNS notification payload (matching Loop's expected format) let now = Date() @@ -186,8 +183,9 @@ class LoopAPNSService { sendAPNSNotification( deviceToken: deviceToken, bundleIdentifier: bundleIdentifier, - keyId: keyId, - apnsKey: apnsKey, + keyId: creds.keyId, + apnsKey: creds.apnsKey, + teamId: creds.teamId, payload: finalPayload, completion: completion ) @@ -207,8 +205,7 @@ class LoopAPNSService { let deviceToken = Storage.shared.deviceToken.value let bundleIdentifier = Storage.shared.bundleId.value - let keyId = storage.keyId.value - let apnsKey = storage.apnsKey.value + let creds = effectiveCredentials() // Create APNS notification payload (matching Loop's expected format) let now = Date() @@ -250,8 +247,9 @@ class LoopAPNSService { sendAPNSNotification( deviceToken: deviceToken, bundleIdentifier: bundleIdentifier, - keyId: keyId, - apnsKey: apnsKey, + keyId: creds.keyId, + apnsKey: creds.apnsKey, + teamId: creds.teamId, payload: finalPayload, completion: completion ) @@ -262,9 +260,10 @@ class LoopAPNSService { private func validateCredentials() -> [String]? { var errors = [String]() - let keyId = storage.keyId.value - let teamId = Storage.shared.teamId.value ?? "" - let apnsKey = storage.apnsKey.value + let creds = effectiveCredentials() + let keyId = creds.keyId + let teamId = creds.teamId + let apnsKey = creds.apnsKey // Validate keyId (should be 10 alphanumeric characters) let keyIdPattern = "^[A-Z0-9]{10}$" @@ -328,6 +327,7 @@ class LoopAPNSService { bundleIdentifier: String, keyId: String, apnsKey: String, + teamId: String, payload: [String: Any], completion: @escaping (Bool, String?) -> Void ) { @@ -340,7 +340,7 @@ class LoopAPNSService { } // Create JWT token for APNS authentication - guard let jwt = JWTManager.shared.getOrGenerateJWT(keyId: keyId, teamId: Storage.shared.teamId.value ?? "", apnsKey: apnsKey) else { + 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) @@ -699,11 +699,13 @@ class LoopAPNSService { } // Send the notification using the existing APNS infrastructure + let creds = effectiveCredentials() sendAPNSNotification( deviceToken: deviceToken, bundleIdentifier: bundleIdentifier, - keyId: storage.keyId.value, - apnsKey: storage.apnsKey.value, + keyId: creds.keyId, + apnsKey: creds.apnsKey, + teamId: creds.teamId, payload: payload, completion: completion ) @@ -753,11 +755,13 @@ class LoopAPNSService { } // Send the notification using the existing APNS infrastructure + let creds = effectiveCredentials() sendAPNSNotification( deviceToken: deviceToken, bundleIdentifier: bundleIdentifier, - keyId: storage.keyId.value, - apnsKey: storage.apnsKey.value, + keyId: creds.keyId, + apnsKey: creds.apnsKey, + teamId: creds.teamId, payload: payload, completion: completion ) diff --git a/LoopFollow/Remote/Settings/RemoteCommandSettings.swift b/LoopFollow/Remote/Settings/RemoteCommandSettings.swift index bdc270dc6..56c5686fb 100644 --- a/LoopFollow/Remote/Settings/RemoteCommandSettings.swift +++ b/LoopFollow/Remote/Settings/RemoteCommandSettings.swift @@ -8,8 +8,8 @@ struct RemoteCommandSettings: Codable { let remoteType: RemoteType let user: String let sharedSecret: String - let apnsKey: String - let keyId: String + let remoteApnsKey: String + let remoteKeyId: String let teamId: String? let maxBolus: Double let maxCarbs: Double @@ -27,8 +27,8 @@ struct RemoteCommandSettings: Codable { remoteType: RemoteType, user: String, sharedSecret: String, - apnsKey: String, - keyId: String, + remoteApnsKey: String, + remoteKeyId: String, teamId: String?, maxBolus: Double, maxCarbs: Double, @@ -44,8 +44,8 @@ struct RemoteCommandSettings: Codable { self.remoteType = remoteType self.user = user self.sharedSecret = sharedSecret - self.apnsKey = apnsKey - self.keyId = keyId + self.remoteApnsKey = remoteApnsKey + self.remoteKeyId = remoteKeyId self.teamId = teamId self.maxBolus = maxBolus self.maxCarbs = maxCarbs @@ -68,8 +68,8 @@ struct RemoteCommandSettings: Codable { remoteType: storage.remoteType.value, user: storage.user.value, sharedSecret: storage.sharedSecret.value, - apnsKey: storage.apnsKey.value, - keyId: storage.keyId.value, + remoteApnsKey: storage.remoteApnsKey.value, + remoteKeyId: storage.remoteKeyId.value, teamId: storage.teamId.value, maxBolus: storage.maxBolus.value.doubleValue(for: .internationalUnit()), maxCarbs: storage.maxCarbs.value.doubleValue(for: .gram()), @@ -91,8 +91,8 @@ struct RemoteCommandSettings: Codable { storage.remoteType.value = remoteType storage.user.value = user storage.sharedSecret.value = sharedSecret - storage.apnsKey.value = apnsKey - storage.keyId.value = keyId + storage.remoteApnsKey.value = remoteApnsKey + storage.remoteKeyId.value = remoteKeyId storage.teamId.value = teamId storage.maxBolus.value = HKQuantity(unit: .internationalUnit(), doubleValue: maxBolus) storage.maxCarbs.value = HKQuantity(unit: .gram(), doubleValue: maxCarbs) @@ -152,9 +152,9 @@ struct RemoteCommandSettings: Codable { case .nightscout: return !user.isEmpty case .trc: - return !user.isEmpty && !sharedSecret.isEmpty && !apnsKey.isEmpty && !keyId.isEmpty + return !user.isEmpty && !sharedSecret.isEmpty && !remoteApnsKey.isEmpty && !remoteKeyId.isEmpty case .loopAPNS: - return !keyId.isEmpty && !apnsKey.isEmpty && teamId != nil && !loopAPNSQrCodeURL.isEmpty + return !remoteKeyId.isEmpty && !remoteApnsKey.isEmpty && teamId != nil && !loopAPNSQrCodeURL.isEmpty } } diff --git a/LoopFollow/Remote/Settings/RemoteSettingsView.swift b/LoopFollow/Remote/Settings/RemoteSettingsView.swift index 3df7acf2d..c9d1878ba 100644 --- a/LoopFollow/Remote/Settings/RemoteSettingsView.swift +++ b/LoopFollow/Remote/Settings/RemoteSettingsView.swift @@ -148,23 +148,25 @@ struct RemoteSettingsView: View { ) } - HStack { - Text("APNS Key ID") - TogglableSecureInput( - placeholder: "Enter APNS Key ID", - text: $viewModel.keyId, - style: .singleLine - ) - } + if viewModel.areTeamIdsDifferent { + HStack { + Text("APNS Key ID") + TogglableSecureInput( + placeholder: "Enter APNS Key ID", + text: $viewModel.remoteKeyId, + style: .singleLine + ) + } - VStack(alignment: .leading) { - Text("APNS Key") - TogglableSecureInput( - placeholder: "Paste APNS Key", - text: $viewModel.apnsKey, - style: .multiLine - ) - .frame(minHeight: 110) + VStack(alignment: .leading) { + Text("APNS Key") + TogglableSecureInput( + placeholder: "Paste APNS Key", + text: $viewModel.remoteApnsKey, + style: .multiLine + ) + .frame(minHeight: 110) + } } } @@ -194,23 +196,25 @@ struct RemoteSettingsView: View { ) } - HStack { - Text("APNS Key ID") - TogglableSecureInput( - placeholder: "Enter APNS Key ID", - text: $viewModel.keyId, - style: .singleLine - ) - } + if viewModel.areTeamIdsDifferent { + HStack { + Text("APNS Key ID") + TogglableSecureInput( + placeholder: "Enter APNS Key ID", + text: $viewModel.remoteKeyId, + style: .singleLine + ) + } - VStack(alignment: .leading) { - Text("APNS Key") - TogglableSecureInput( - placeholder: "Paste APNS Key", - text: $viewModel.apnsKey, - style: .multiLine - ) - .frame(minHeight: 110) + VStack(alignment: .leading) { + Text("APNS Key") + TogglableSecureInput( + placeholder: "Paste APNS Key", + text: $viewModel.remoteApnsKey, + style: .multiLine + ) + .frame(minHeight: 110) + } } HStack { @@ -279,29 +283,6 @@ struct RemoteSettingsView: View { Text("Bolus Increment: \(Storage.shared.bolusIncrement.value.doubleValue(for: .internationalUnit()), specifier: "%.3f") U") } } - - if viewModel.areTeamIdsDifferent { - Section(header: Text("Return Notification Settings"), footer: Text("Because LoopFollow and the target app were built with different Team IDs, you must provide the APNS credentials for LoopFollow below.").font(.caption)) { - HStack { - Text("Return APNS Key ID") - TogglableSecureInput( - placeholder: "Enter Key ID for LoopFollow", - text: $viewModel.returnKeyId, - style: .singleLine - ) - } - - VStack(alignment: .leading) { - Text("Return APNS Key") - TogglableSecureInput( - placeholder: "Paste APNS Key for LoopFollow", - text: $viewModel.returnApnsKey, - style: .multiLine - ) - .frame(minHeight: 110) - } - } - } } } .alert(isPresented: $showAlert) { diff --git a/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift b/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift index bcf5a9952..d5ea2fc4e 100644 --- a/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift +++ b/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift @@ -9,8 +9,8 @@ class RemoteSettingsViewModel: ObservableObject { @Published var remoteType: RemoteType @Published var user: String @Published var sharedSecret: String - @Published var apnsKey: String - @Published var keyId: String + @Published var remoteApnsKey: String + @Published var remoteKeyId: String @Published var maxBolus: HKQuantity @Published var maxCarbs: HKQuantity @@ -21,11 +21,6 @@ class RemoteSettingsViewModel: ObservableObject { @Published var isTrioDevice: Bool = (Storage.shared.device.value == "Trio") @Published var isLoopDevice: Bool = (Storage.shared.device.value == "Loop") - // MARK: - Return Notification Properties - - @Published var returnApnsKey: String - @Published var returnKeyId: String - // MARK: - Loop APNS Setup Properties @Published var loopDeveloperTeamId: String @@ -56,16 +51,13 @@ class RemoteSettingsViewModel: ObservableObject { // Determine if a comparison is needed and perform it. switch remoteType { - case .trc: - // If the target ID is empty, there's nothing to compare. + case .trc, .loopAPNS: guard !targetTeamId.isEmpty else { return false } - // Return true if the IDs are different. return loopFollowTeamID != targetTeamId - case .loopAPNS, .none, .nightscout: - // For other remote types, this check is not applicable. + case .none, .nightscout: return false } } @@ -73,8 +65,13 @@ class RemoteSettingsViewModel: ObservableObject { // MARK: - Computed property for Loop APNS Setup validation var loopAPNSSetup: Bool { - !keyId.isEmpty && - !apnsKey.isEmpty && + let hasCredentials: Bool + if areTeamIdsDifferent { + hasCredentials = !remoteKeyId.isEmpty && !remoteApnsKey.isEmpty + } else { + hasCredentials = !Storage.shared.lfKeyId.value.isEmpty && !Storage.shared.lfApnsKey.value.isEmpty + } + return hasCredentials && !loopDeveloperTeamId.isEmpty && !loopAPNSQrCodeURL.isEmpty && !Storage.shared.deviceToken.value.isEmpty && @@ -89,8 +86,8 @@ class RemoteSettingsViewModel: ObservableObject { remoteType = storage.remoteType.value user = storage.user.value sharedSecret = storage.sharedSecret.value - apnsKey = storage.apnsKey.value - keyId = storage.keyId.value + remoteApnsKey = storage.remoteApnsKey.value + remoteKeyId = storage.remoteKeyId.value maxBolus = storage.maxBolus.value maxCarbs = storage.maxCarbs.value maxProtein = storage.maxProtein.value @@ -102,9 +99,6 @@ class RemoteSettingsViewModel: ObservableObject { loopAPNSQrCodeURL = storage.loopAPNSQrCodeURL.value productionEnvironment = storage.productionEnvironment.value - returnApnsKey = storage.returnApnsKey.value - returnKeyId = storage.returnKeyId.value - setupBindings() } @@ -125,19 +119,18 @@ class RemoteSettingsViewModel: ObservableObject { .sink { [weak self] in self?.storage.sharedSecret.value = $0 } .store(in: &cancellables) - $apnsKey + $remoteApnsKey .dropFirst() .sink { [weak self] newValue in - // Validate and fix the APNS key format using the service let apnsService = LoopAPNSService() let fixedKey = apnsService.validateAndFixAPNSKey(newValue) - self?.storage.apnsKey.value = fixedKey + self?.storage.remoteApnsKey.value = fixedKey } .store(in: &cancellables) - $keyId + $remoteKeyId .dropFirst() - .sink { [weak self] in self?.storage.keyId.value = $0 } + .sink { [weak self] in self?.storage.remoteKeyId.value = $0 } .store(in: &cancellables) $maxBolus @@ -194,17 +187,6 @@ class RemoteSettingsViewModel: ObservableObject { .dropFirst() .sink { [weak self] in self?.storage.productionEnvironment.value = $0 } .store(in: &cancellables) - - // Return notification bindings - $returnApnsKey - .dropFirst() - .sink { [weak self] in self?.storage.returnApnsKey.value = $0 } - .store(in: &cancellables) - - $returnKeyId - .dropFirst() - .sink { [weak self] in self?.storage.returnKeyId.value = $0 } - .store(in: &cancellables) } func handleLoopAPNSQRCodeScanResult(_ result: Result) { @@ -235,8 +217,8 @@ class RemoteSettingsViewModel: ObservableObject { remoteType = storage.remoteType.value user = storage.user.value sharedSecret = storage.sharedSecret.value - apnsKey = storage.apnsKey.value - keyId = storage.keyId.value + remoteApnsKey = storage.remoteApnsKey.value + remoteKeyId = storage.remoteKeyId.value maxBolus = storage.maxBolus.value maxCarbs = storage.maxCarbs.value maxProtein = storage.maxProtein.value diff --git a/LoopFollow/Remote/TRC/PushNotificationManager.swift b/LoopFollow/Remote/TRC/PushNotificationManager.swift index 1cef2ff1a..e0c70d746 100644 --- a/LoopFollow/Remote/TRC/PushNotificationManager.swift +++ b/LoopFollow/Remote/TRC/PushNotificationManager.swift @@ -3,7 +3,6 @@ import Foundation import HealthKit -import SwiftJWT class PushNotificationManager { private var deviceToken: String @@ -19,11 +18,22 @@ class PushNotificationManager { deviceToken = Storage.shared.deviceToken.value sharedSecret = Storage.shared.sharedSecret.value productionEnvironment = Storage.shared.productionEnvironment.value - apnsKey = Storage.shared.apnsKey.value - teamId = Storage.shared.teamId.value ?? "" - keyId = Storage.shared.keyId.value 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? { @@ -38,20 +48,11 @@ class PushNotificationManager { return nil } - let teamIdsAreDifferent = loopFollowTeamID != teamId - let keyIdForReturn: String - let apnsKeyForReturn: String - - if teamIdsAreDifferent { - keyIdForReturn = Storage.shared.returnKeyId.value - apnsKeyForReturn = Storage.shared.returnApnsKey.value - } else { - keyIdForReturn = keyId - apnsKeyForReturn = apnsKey - } + let lfKeyId = Storage.shared.lfKeyId.value + let lfApnsKey = Storage.shared.lfApnsKey.value - guard !keyIdForReturn.isEmpty, !apnsKeyForReturn.isEmpty else { - LogManager.shared.log(category: .apns, message: "Missing required return APNS credentials. Check Remote Settings.") + guard !lfKeyId.isEmpty, !lfApnsKey.isEmpty else { + LogManager.shared.log(category: .apns, message: "Missing LoopFollow APNS credentials. Configure them in App Settings → APN.") return nil } @@ -60,8 +61,8 @@ class PushNotificationManager { deviceToken: loopFollowDeviceToken, bundleId: Bundle.main.bundleIdentifier ?? "", teamId: loopFollowTeamID, - keyId: keyIdForReturn, - apnsKey: apnsKeyForReturn + keyId: lfKeyId, + apnsKey: lfApnsKey ) } diff --git a/LoopFollow/Settings/APNSettingsView.swift b/LoopFollow/Settings/APNSettingsView.swift new file mode 100644 index 000000000..79b07e7cd --- /dev/null +++ b/LoopFollow/Settings/APNSettingsView.swift @@ -0,0 +1,44 @@ +// LoopFollow +// APNSettingsView.swift + +import SwiftUI + +struct APNSettingsView: View { + @State private var keyId: String = Storage.shared.lfKeyId.value + @State private var apnsKey: String = Storage.shared.lfApnsKey.value + + var body: some View { + Form { + Section(header: Text("LoopFollow APNs Credentials")) { + HStack { + Text("APNS Key ID") + TogglableSecureInput( + placeholder: "Enter APNS Key ID", + text: $keyId, + style: .singleLine + ) + } + + VStack(alignment: .leading) { + Text("APNS Key") + TogglableSecureInput( + placeholder: "Paste APNS Key", + text: $apnsKey, + style: .multiLine + ) + .frame(minHeight: 110) + } + } + } + .onChange(of: keyId) { newValue in + Storage.shared.lfKeyId.value = newValue + } + .onChange(of: apnsKey) { newValue in + let apnsService = LoopAPNSService() + Storage.shared.lfApnsKey.value = apnsService.validateAndFixAPNSKey(newValue) + } + .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) + .navigationTitle("APN") + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/LoopFollow/Settings/ImportExport/ExportableSettings.swift b/LoopFollow/Settings/ImportExport/ExportableSettings.swift index 77c905806..0425528c9 100644 --- a/LoopFollow/Settings/ImportExport/ExportableSettings.swift +++ b/LoopFollow/Settings/ImportExport/ExportableSettings.swift @@ -148,8 +148,8 @@ struct RemoteSettingsExport: Codable { let remoteType: RemoteType let user: String let sharedSecret: String - let apnsKey: String - let keyId: String + let remoteApnsKey: String + let remoteKeyId: String let teamId: String? let maxBolus: Double let maxCarbs: Double @@ -168,8 +168,8 @@ struct RemoteSettingsExport: Codable { remoteType: storage.remoteType.value, user: storage.user.value, sharedSecret: storage.sharedSecret.value, - apnsKey: storage.apnsKey.value, - keyId: storage.keyId.value, + remoteApnsKey: storage.remoteApnsKey.value, + remoteKeyId: storage.remoteKeyId.value, teamId: storage.teamId.value, maxBolus: storage.maxBolus.value.doubleValue(for: .internationalUnit()), maxCarbs: storage.maxCarbs.value.doubleValue(for: .gram()), @@ -189,8 +189,8 @@ struct RemoteSettingsExport: Codable { storage.remoteType.value = remoteType storage.user.value = user storage.sharedSecret.value = sharedSecret - storage.apnsKey.value = apnsKey - storage.keyId.value = keyId + storage.remoteApnsKey.value = remoteApnsKey + storage.remoteKeyId.value = remoteKeyId storage.teamId.value = teamId storage.maxBolus.value = HKQuantity(unit: .internationalUnit(), doubleValue: maxBolus) storage.maxCarbs.value = HKQuantity(unit: .gram(), doubleValue: maxCarbs) @@ -237,9 +237,9 @@ struct RemoteSettingsExport: Codable { case .nightscout: return !user.isEmpty case .trc: - return !user.isEmpty && !sharedSecret.isEmpty && !apnsKey.isEmpty && !keyId.isEmpty + return !user.isEmpty && !sharedSecret.isEmpty && !remoteApnsKey.isEmpty && !remoteKeyId.isEmpty case .loopAPNS: - return !keyId.isEmpty && !apnsKey.isEmpty && teamId != nil && !loopAPNSQrCodeURL.isEmpty + return !remoteKeyId.isEmpty && !remoteApnsKey.isEmpty && teamId != nil && !loopAPNSQrCodeURL.isEmpty } } @@ -266,14 +266,14 @@ struct RemoteSettingsExport: Codable { // For TRC and LoopAPNS, check if key details are changing if remoteType == .trc || remoteType == .loopAPNS { - let currentKeyId = storage.keyId.value - let currentApnsKey = storage.apnsKey.value + let currentKeyId = storage.remoteKeyId.value + let currentApnsKey = storage.remoteApnsKey.value - if !currentKeyId.isEmpty, currentKeyId != keyId { + if !currentKeyId.isEmpty, currentKeyId != remoteKeyId { message += "APNS Key ID is changing. This may affect your remote commands.\n" } - if !currentApnsKey.isEmpty, currentApnsKey != apnsKey { + if !currentApnsKey.isEmpty, currentApnsKey != remoteApnsKey { message += "APNS Key is changing. This may affect your remote commands.\n" } } diff --git a/LoopFollow/Settings/SettingsMenuView.swift b/LoopFollow/Settings/SettingsMenuView.swift index 4ec770943..1ddcffc77 100644 --- a/LoopFollow/Settings/SettingsMenuView.swift +++ b/LoopFollow/Settings/SettingsMenuView.swift @@ -60,6 +60,12 @@ struct SettingsMenuView: View { settingsPath.value.append(Sheet.importExport) } + NavigationRow(title: "APN", + icon: "bell.and.waves.left.and.right") + { + settingsPath.value.append(Sheet.apn) + } + if !nightscoutURL.value.isEmpty { NavigationRow(title: "Information Display Settings", icon: "info.circle") @@ -238,6 +244,7 @@ private enum Sheet: Hashable, Identifiable { case general, graph case infoDisplay case alarmSettings + case apn case remote case importExport case calendar, contact @@ -257,6 +264,7 @@ private enum Sheet: Hashable, Identifiable { case .graph: GraphSettingsView() case .infoDisplay: InfoDisplaySettingsView(viewModel: .init()) case .alarmSettings: AlarmSettingsView() + case .apn: APNSettingsView() case .remote: RemoteSettingsView(viewModel: .init()) case .importExport: ImportExportSettingsView() case .calendar: CalendarSettingsView() diff --git a/LoopFollow/Storage/Observable.swift b/LoopFollow/Storage/Observable.swift index fd4494342..f5e9b1606 100644 --- a/LoopFollow/Storage/Observable.swift +++ b/LoopFollow/Storage/Observable.swift @@ -43,5 +43,7 @@ class Observable { var loopFollowDeviceToken = ObservableValue(default: "") + var isNotLooping = ObservableValue(default: false) + private init() {} } diff --git a/LoopFollow/Storage/Storage+Migrate.swift b/LoopFollow/Storage/Storage+Migrate.swift index b913d9b42..d3efbe16e 100644 --- a/LoopFollow/Storage/Storage+Migrate.swift +++ b/LoopFollow/Storage/Storage+Migrate.swift @@ -4,6 +4,45 @@ import Foundation extension Storage { + func migrateStep5() { + LogManager.shared.log(category: .general, message: "Running migrateStep5 — APNs credential separation") + + let legacyReturnApnsKey = StorageValue(key: "returnApnsKey", defaultValue: "") + let legacyReturnKeyId = StorageValue(key: "returnKeyId", defaultValue: "") + let legacyApnsKey = StorageValue(key: "apnsKey", defaultValue: "") + let legacyKeyId = StorageValue(key: "keyId", defaultValue: "") + + // 1. If returnApnsKey had a value, that was LoopFollow's own key (different team scenario) + if legacyReturnApnsKey.exists, !legacyReturnApnsKey.value.isEmpty { + lfApnsKey.value = legacyReturnApnsKey.value + lfKeyId.value = legacyReturnKeyId.value + } + + // 2. If lfApnsKey is still empty and the old primary key exists, + // check if same team — if so, the primary key was used for everything + if lfApnsKey.value.isEmpty, legacyApnsKey.exists, !legacyApnsKey.value.isEmpty { + let lfTeamId = BuildDetails.default.teamID ?? "" + let remoteTeamId = teamId.value ?? "" + let sameTeam = !lfTeamId.isEmpty && (remoteTeamId.isEmpty || lfTeamId == remoteTeamId) + if sameTeam { + lfApnsKey.value = legacyApnsKey.value + lfKeyId.value = legacyKeyId.value + } + } + + // 3. Move old primary credentials to remoteApnsKey/remoteKeyId + if legacyApnsKey.exists, !legacyApnsKey.value.isEmpty { + remoteApnsKey.value = legacyApnsKey.value + remoteKeyId.value = legacyKeyId.value + } + + // 4. Clean up old keys + legacyReturnApnsKey.remove() + legacyReturnKeyId.remove() + legacyApnsKey.remove() + legacyKeyId.remove() + } + func migrateStep3() { LogManager.shared.log(category: .general, message: "Running migrateStep3 - this should only happen once!") let legacyForceDarkMode = StorageValue(key: "forceDarkMode", defaultValue: true) diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 08781a6b1..dc0c8a282 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -15,9 +15,12 @@ class Storage { var expirationDate = StorageValue(key: "expirationDate", defaultValue: nil) var sharedSecret = StorageValue(key: "sharedSecret", defaultValue: "") var productionEnvironment = StorageValue(key: "productionEnvironment", defaultValue: false) - var apnsKey = StorageValue(key: "apnsKey", defaultValue: "") + var remoteApnsKey = StorageValue(key: "remoteApnsKey", defaultValue: "") var teamId = StorageValue(key: "teamId", defaultValue: nil) - var keyId = StorageValue(key: "keyId", defaultValue: "") + var remoteKeyId = StorageValue(key: "remoteKeyId", defaultValue: "") + + var lfApnsKey = StorageValue(key: "lfApnsKey", defaultValue: "") + var lfKeyId = StorageValue(key: "lfKeyId", defaultValue: "") var bundleId = StorageValue(key: "bundleId", defaultValue: "") var user = StorageValue(key: "user", defaultValue: "") @@ -32,9 +35,6 @@ class Storage { // TODO: This flag can be deleted in March 2027. Check the commit for other places to cleanup. var hasSeenFatProteinOrderChange = StorageValue(key: "hasSeenFatProteinOrderChange", defaultValue: false) - var cachedJWT = StorageValue(key: "cachedJWT", defaultValue: nil) - var jwtExpirationDate = StorageValue(key: "jwtExpirationDate", defaultValue: nil) - var backgroundRefreshType = StorageValue(key: "backgroundRefreshType", defaultValue: .silentTune) var selectedBLEDevice = StorageValue(key: "selectedBLEDevice", defaultValue: nil) @@ -83,6 +83,14 @@ class Storage { var speakLanguage = StorageValue(key: "speakLanguage", defaultValue: "en") // General Settings [END] + // Live Activity glucose state + var lastBgReadingTimeSeconds = StorageValue(key: "lastBgReadingTimeSeconds", defaultValue: nil) + var lastDeltaMgdl = StorageValue(key: "lastDeltaMgdl", defaultValue: nil) + var lastTrendCode = StorageValue(key: "lastTrendCode", defaultValue: nil) + var lastIOB = StorageValue(key: "lastIOB", defaultValue: nil) + var lastCOB = StorageValue(key: "lastCOB", defaultValue: nil) + var projectedBgMgdl = StorageValue(key: "projectedBgMgdl", defaultValue: nil) + // Graph Settings [BEGIN] var showDots = StorageValue(key: "showDots", defaultValue: true) var showLines = StorageValue(key: "showLines", defaultValue: true) @@ -178,9 +186,6 @@ class Storage { var loopAPNSQrCodeURL = StorageValue(key: "loopAPNSQrCodeURL", defaultValue: "") - var returnApnsKey = StorageValue(key: "returnApnsKey", defaultValue: "") - var returnKeyId = StorageValue(key: "returnKeyId", defaultValue: "") - var bolusIncrement = SecureStorageValue(key: "bolusIncrement", defaultValue: HKQuantity(unit: .internationalUnit(), doubleValue: 0.05)) var bolusIncrementDetected = StorageValue(key: "bolusIncrementDetected", defaultValue: false) // Statistics display preferences diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 8730da9eb..9173bec9f 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -168,6 +168,11 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele Storage.shared.migrationStep.value = 4 } + if Storage.shared.migrationStep.value < 5 { + Storage.shared.migrateStep5() + Storage.shared.migrationStep.value = 5 + } + // Synchronize info types to ensure arrays are the correct size synchronizeInfoTypes() @@ -385,29 +390,16 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele } .store(in: &cancellables) - Storage.shared.apnsKey.$value - .receive(on: DispatchQueue.main) - .removeDuplicates() - .sink { _ in - JWTManager.shared.invalidateCache() - } - .store(in: &cancellables) - - Storage.shared.teamId.$value - .receive(on: DispatchQueue.main) - .removeDuplicates() - .sink { _ in - JWTManager.shared.invalidateCache() - } - .store(in: &cancellables) - - Storage.shared.keyId.$value - .receive(on: DispatchQueue.main) - .removeDuplicates() - .sink { _ in - JWTManager.shared.invalidateCache() - } - .store(in: &cancellables) + Publishers.MergeMany( + Storage.shared.remoteApnsKey.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.teamId.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.remoteKeyId.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.lfApnsKey.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.lfKeyId.$value.map { _ in () }.eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { _ in JWTManager.shared.invalidateCache() } + .store(in: &cancellables) Storage.shared.device.$value .receive(on: DispatchQueue.main) @@ -1000,6 +992,9 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele @objc override func viewDidAppear(_: Bool) { showHideNSDetails() + if #available(iOS 16.1, *) { + LiveActivityManager.shared.startFromCurrentState() + } } func stringFromTimeInterval(interval: TimeInterval) -> String { diff --git a/LoopFollowLAExtension/ExtensionInfo.plist b/LoopFollowLAExtension/ExtensionInfo.plist new file mode 100644 index 000000000..cf08ba141 --- /dev/null +++ b/LoopFollowLAExtension/ExtensionInfo.plist @@ -0,0 +1,27 @@ + + + + + CFBundleDisplayName + LoopFollowLAExtension + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/LoopFollowLAExtension/LoopFollowLABundle.swift b/LoopFollowLAExtension/LoopFollowLABundle.swift new file mode 100644 index 000000000..e3a043783 --- /dev/null +++ b/LoopFollowLAExtension/LoopFollowLABundle.swift @@ -0,0 +1,18 @@ +// LoopFollow +// LoopFollowLABundle.swift + +// LoopFollowLABundle.swift +// Philippe Achkar +// 2026-03-07 + +import SwiftUI +import WidgetKit + +@main +struct LoopFollowLABundle: WidgetBundle { + var body: some Widget { + if #available(iOS 16.1, *) { + LoopFollowLiveActivityWidget() + } + } +} diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift new file mode 100644 index 000000000..cca77be83 --- /dev/null +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -0,0 +1,439 @@ +// LoopFollow +// LoopFollowLiveActivity.swift + +import ActivityKit +import SwiftUI +import WidgetKit + +@available(iOS 16.1, *) +struct LoopFollowLiveActivityWidget: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: GlucoseLiveActivityAttributes.self) { context in + // LOCK SCREEN / BANNER UI + LockScreenLiveActivityView(state: context.state /* , activityID: context.activityID */ ) + .id(context.state.seq) // force SwiftUI to re-render on every update + .activitySystemActionForegroundColor(.white) + .activityBackgroundTint(LAColors.backgroundTint(for: context.state.snapshot)) + .applyActivityContentMarginsFixIfAvailable() + } dynamicIsland: { context in + // DYNAMIC ISLAND UI + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + DynamicIslandLeadingView(snapshot: context.state.snapshot) + .id(context.state.seq) + } + DynamicIslandExpandedRegion(.trailing) { + DynamicIslandTrailingView(snapshot: context.state.snapshot) + .id(context.state.seq) + } + DynamicIslandExpandedRegion(.bottom) { + DynamicIslandBottomView(snapshot: context.state.snapshot) + .id(context.state.seq) + } + } compactLeading: { + DynamicIslandCompactLeadingView(snapshot: context.state.snapshot) + .id(context.state.seq) + } compactTrailing: { + DynamicIslandCompactTrailingView(snapshot: context.state.snapshot) + .id(context.state.seq) + } minimal: { + DynamicIslandMinimalView(snapshot: context.state.snapshot) + .id(context.state.seq) + } + .keylineTint(LAColors.keyline(for: context.state.snapshot).opacity(0.75)) + } + } +} + +// MARK: - Live Activity content margins helper + +private extension View { + @ViewBuilder + func applyActivityContentMarginsFixIfAvailable() -> some View { + if #available(iOS 17.0, *) { + // Use the generic SwiftUI API available in iOS 17+ (no placement enum) + self.contentMargins(Edge.Set.all, 0) + } else { + self + } + } +} + +// MARK: - Lock Screen Contract View + +@available(iOS 16.1, *) +private struct LockScreenLiveActivityView: View { + let state: GlucoseLiveActivityAttributes.ContentState + /* let activityID: String */ + + var body: some View { + let s = state.snapshot + + HStack(spacing: 12) { + // LEFT: Glucose + trend, update time below + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(LAFormat.glucose(s)) + .font(.system(size: 46, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white) + + Text(LAFormat.trendArrow(s)) + .font(.system(size: 46, weight: .bold, design: .rounded)) + .foregroundStyle(.white.opacity(0.95)) + } + + Text("Last Update: \(LAFormat.updated(s))") + .font(.system(size: 13, weight: .regular, design: .rounded)) + .foregroundStyle(.white.opacity(0.75)) + } + .frame(width: 168, alignment: .leading) + .layoutPriority(2) + + // Divider + Rectangle() + .fill(Color.white.opacity(0.20)) + .frame(width: 1) + .padding(.vertical, 8) + + // RIGHT: 2x2 grid — delta/proj | iob/cob + VStack(spacing: 10) { + HStack(spacing: 16) { + MetricBlock(label: "Delta", value: LAFormat.delta(s)) + MetricBlock(label: "IOB", value: LAFormat.iob(s)) + } + HStack(spacing: 16) { + MetricBlock(label: "Proj", value: LAFormat.projected(s)) + MetricBlock(label: "COB", value: LAFormat.cob(s)) + } + } + .frame(maxWidth: .infinity, alignment: .trailing) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 14) + .padding(.vertical, 12) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(Color.white.opacity(0.20), lineWidth: 1) + ) + .overlay( + Group { + if state.snapshot.isNotLooping { + ZStack { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color(uiColor: UIColor.systemRed).opacity(0.85)) + Text("Not Looping") + .font(.system(size: 20, weight: .heavy, design: .rounded)) + .foregroundStyle(.white) + .tracking(1.5) + } + } + } + ) + } +} + +private struct MetricBlock: View { + let label: String + let value: String + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.78)) + + Text(value) + .font(.system(size: 16, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white) + .lineLimit(1) + .minimumScaleFactor(0.85) + } + .frame(width: 64, alignment: .leading) // consistent 2×2 columns + } +} + +// MARK: - Dynamic Island + +@available(iOS 16.1, *) +private struct DynamicIslandLeadingView: View { + let snapshot: GlucoseSnapshot + var body: some View { + if snapshot.isNotLooping { + VStack(alignment: .leading, spacing: 2) { + Text("⚠️ Not Looping") + .font(.system(size: 20, weight: .heavy, design: .rounded)) + .foregroundStyle(.white) + .tracking(1.0) + .lineLimit(1) + .minimumScaleFactor(0.7) + } + } else { + VStack(alignment: .leading, spacing: 2) { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(LAFormat.glucose(snapshot)) + .font(.system(size: 28, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white) + Text(LAFormat.trendArrow(snapshot)) + .font(.system(size: 16, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.95)) + .padding(.top, 2) + } + Text(LAFormat.delta(snapshot)) + .font(.system(size: 14, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.9)) + } + } + } +} + +@available(iOS 16.1, *) +private struct DynamicIslandTrailingView: View { + let snapshot: GlucoseSnapshot + var body: some View { + if snapshot.isNotLooping { + EmptyView() + } else { + VStack(alignment: .trailing, spacing: 3) { + Text("Upd \(LAFormat.updated(snapshot))") + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.85)) + Text("Proj \(LAFormat.projected(snapshot))") + .font(.system(size: 13, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.95)) + } + } + } +} + +@available(iOS 16.1, *) +private struct DynamicIslandBottomView: View { + let snapshot: GlucoseSnapshot + var body: some View { + if snapshot.isNotLooping { + Text("Loop has not reported in 15+ minutes") + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.92)) + .lineLimit(1) + .minimumScaleFactor(0.75) + } else { + HStack(spacing: 14) { + Text("IOB \(LAFormat.iob(snapshot))") + Text("COB \(LAFormat.cob(snapshot))") + } + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.92)) + .lineLimit(1) + .minimumScaleFactor(0.85) + } + } +} + +@available(iOS 16.1, *) +private struct DynamicIslandCompactTrailingView: View { + let snapshot: GlucoseSnapshot + var body: some View { + if snapshot.isNotLooping { + Text("Not Looping") + .font(.system(size: 11, weight: .heavy, design: .rounded)) + .foregroundStyle(.white) + .lineLimit(1) + .minimumScaleFactor(0.7) + } else { + Text(LAFormat.trendArrow(snapshot)) + .font(.system(size: 14, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.95)) + } + } +} + +@available(iOS 16.1, *) +private struct DynamicIslandCompactLeadingView: View { + let snapshot: GlucoseSnapshot + var body: some View { + if snapshot.isNotLooping { + Text("⚠️") + .font(.system(size: 14)) + } else { + Text(LAFormat.glucose(snapshot)) + .font(.system(size: 16, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white) + } + } +} + +@available(iOS 16.1, *) +private struct DynamicIslandMinimalView: View { + let snapshot: GlucoseSnapshot + var body: some View { + if snapshot.isNotLooping { + Text("⚠️") + .font(.system(size: 12)) + } else { + Text(LAFormat.glucose(snapshot)) + .font(.system(size: 14, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white) + } + } +} + +// MARK: - Formatting + +private enum LAFormat { + // MARK: - NumberFormatters (locale-aware) + + private static let mgdlFormatter: NumberFormatter = { + let nf = NumberFormatter() + nf.numberStyle = .decimal + nf.maximumFractionDigits = 0 + nf.locale = .current + return nf + }() + + private static let mmolFormatter: NumberFormatter = { + let nf = NumberFormatter() + nf.numberStyle = .decimal + nf.minimumFractionDigits = 1 + nf.maximumFractionDigits = 1 + nf.locale = .current + return nf + }() + + private static func formatGlucoseValue(_ 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 mmolFormatter.string(from: NSNumber(value: mmol)) ?? String(format: "%.1f", mmol) + } + } + + // MARK: Glucose + + static func glucose(_ s: GlucoseSnapshot) -> String { + formatGlucoseValue(s.glucose, unit: s.unit) + } + + static func delta(_ s: GlucoseSnapshot) -> String { + switch s.unit { + case .mgdl: + let v = Int(round(s.delta)) + if v == 0 { return "0" } + return v > 0 ? "+\(v)" : "\(v)" + + case .mmol: + let mmol = GlucoseConversion.toMmol(s.delta) + let d = (abs(mmol) < 0.05) ? 0.0 : mmol + if d == 0 { return mmolFormatter.string(from: 0) ?? "0.0" } + let formatted = mmolFormatter.string(from: NSNumber(value: abs(d))) ?? String(format: "%.1f", abs(d)) + return d > 0 ? "+\(formatted)" : "-\(formatted)" + } + } + + // MARK: Trend + + static func trendArrow(_ s: GlucoseSnapshot) -> String { + switch s.trend { + case .upFast: return "↑↑" + case .up: return "↑" + case .upSlight: return "↗" + case .flat: return "→" + case .downSlight: return "↘︎" + case .down: return "↓" + case .downFast: return "↓↓" + case .unknown: return "–" + } + } + + // MARK: Secondary + + static func iob(_ s: GlucoseSnapshot) -> String { + guard let v = s.iob else { return "—" } + return String(format: "%.1f", v) + } + + static func cob(_ s: GlucoseSnapshot) -> String { + guard let v = s.cob else { return "—" } + return String(Int(round(v))) + } + + static func projected(_ s: GlucoseSnapshot) -> String { + guard let v = s.projected else { return "—" } + return formatGlucoseValue(v, unit: s.unit) + } + + // MARK: Update time + + private static let hhmmFormatter: DateFormatter = { + let df = DateFormatter() + df.locale = .current + df.timeZone = .current + df.dateFormat = "HH:mm" // 24h format + return df + }() + + private static let hhmmssFormatter: DateFormatter = { + let df = DateFormatter() + df.locale = .current + df.timeZone = .current + df.dateFormat = "HH:mm:ss" + return df + }() + + static func hhmmss(_ date: Date) -> String { + hhmmssFormatter.string(from: date) + } + + static func updated(_ s: GlucoseSnapshot) -> String { + hhmmFormatter.string(from: s.updatedAt) + } +} + +// MARK: - Threshold-driven colors (Option A, App Group-backed) + +private enum LAColors { + static func backgroundTint(for snapshot: GlucoseSnapshot) -> Color { + let mgdl = snapshot.glucose + + let t = LAAppGroupSettings.thresholdsMgdl() + let low = t.low + let high = t.high + + if mgdl < low { + let raw = 0.48 + (0.85 - 0.48) * ((low - mgdl) / (low - 54.0)) + let opacity = min(max(raw, 0.48), 0.85) + return Color(uiColor: UIColor.systemRed).opacity(opacity) + + } else if mgdl > high { + let raw = 0.44 + (0.85 - 0.44) * ((mgdl - high) / (324.0 - high)) + let opacity = min(max(raw, 0.44), 0.85) + return Color(uiColor: UIColor.systemOrange).opacity(opacity) + + } else { + return Color(uiColor: UIColor.systemGreen).opacity(0.36) + } + } + + static func keyline(for snapshot: GlucoseSnapshot) -> Color { + let mgdl = snapshot.glucose + + let t = LAAppGroupSettings.thresholdsMgdl() + let low = t.low + let high = t.high + + if mgdl < low { + return Color(uiColor: UIColor.systemRed) + } else if mgdl > high { + return Color(uiColor: UIColor.systemOrange) + } else { + return Color(uiColor: UIColor.systemGreen) + } + } +} diff --git a/LoopFollowLAExtensionExtension.entitlements b/LoopFollowLAExtensionExtension.entitlements new file mode 100644 index 000000000..5b963cc90 --- /dev/null +++ b/LoopFollowLAExtensionExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.$(unique_id).LoopFollow$(app_suffix) + + + diff --git a/docs/LiveActivity.md b/docs/LiveActivity.md new file mode 100644 index 000000000..979213a96 --- /dev/null +++ b/docs/LiveActivity.md @@ -0,0 +1,165 @@ +# LoopFollow Live Activity — Architecture & Design Decisions + +**Author:** Philippe Achkar (supported by Claude) +**Date:** 2026-03-07 + +--- + +## What Is the Live Activity? + +The Live Activity displays real-time glucose data on the iPhone lock screen and in the Dynamic Island. It shows: + +- Current glucose value (mg/dL or mmol/L) +- Trend arrow and delta +- IOB, COB, and projected glucose (when available) +- Threshold-driven background color (red (low) / green (in-range) / orange (high)) with user-set thresholds +- A "Not Looping" overlay when Loop has not reported in 15+ minutes + +It updates every 5 minutes, driven by LoopFollow's existing refresh engine. No separate data pipeline exists — the Live Activity is a rendering surface only. + +--- + +## Core Principles + +### 1. Single Source of Truth + +The Live Activity never fetches data directly from Nightscout or Dexcom. It reads exclusively from LoopFollow's internal storage layer (`Storage.shared`, `Observable.shared`). All glucose values, thresholds, IOB, COB, and loop status flow through the same path as the rest of the app. + +This means: +- No duplicated business logic +- No risk of the Live Activity showing different data than the app +- The architecture is reusable for Apple Watch and CarPlay in future phases + +### 2. Source-Agnostic Design + +LoopFollow supports both Nightscout and Dexcom. IOB, COB, or predicted glucose are modeled as optional (`Double?`) in `GlucoseSnapshot` and the UI renders a dash (—) when they are absent. The Live Activity never assumes these values exist. + +### 3. No Hardcoded Identifiers + +The App Group ID is derived dynamically at runtime: group.. No team-specific bundle IDs or App Group IDs are hardcoded anywhere. This ensures the project is safe to fork, clone, and submit as a pull request by any contributor. + +--- + +## Update Architecture — Why APNs Self-Push? + +This is the most important architectural decision in Phase 1. Understanding it will help you maintain and extend this feature correctly. + +### What We Tried First — Direct ``activity.update()`` + +The obvious approach to updating a Live Activity is to call ``activity.update()`` directly from the app. This works reliably when the app is in the foreground. + +The problem appears when the app is in the background. LoopFollow uses a background audio session (`.playback` category, silent WAV file) to stay alive in the background and continue fetching glucose data. We discovered that _liveactivitiesd_ (the iOS system daemon responsible for rendering Live Activities) refuses to process ``activity.update()`` calls from processes that hold an active background audio session. The update call either hangs indefinitely or is silently dropped. The Live Activity freezes on the lock screen while the app continues running normally. + +We attempted several workarounds; none of these approaches were reliable or production-safe: +- Call ``activity.update()`` while audio is playing | Updates hang or are dropped +- Pause the audio player before updating | Insufficient — iOS checks the process-level audio assertion, not just the player state +- Call `AVAudioSession.setActive(false)` before updating | Intermittently worked, but introduced a race condition and broke the audio session unpredictably +- Add a fixed 3-second wait after deactivation | Fragile, caused background task timeout warnings, and still failed intermittently + +### The Solution — APNs Self-Push + +Our solution is for LoopFollow to send an APNs (Apple Push Notification service) push notification to itself. + +Here is how it works: + +1. When a Live Activity is started, ActivityKit provides a **push token** — a unique identifier for that specific Live Activity instance. +2. LoopFollow captures this token via `activity.pushTokenUpdates`. +3. After each BG refresh, LoopFollow generates a signed JWT using its APNs authentication key and posts an HTTP/2 request directly to Apple's APNs servers. +4. Apple's APNs infrastructure delivers the push to `liveactivitiesd` on the device. +5. `liveactivitiesd` updates the Live Activity directly — the app process is **never involved in the rendering path**. + +Because `liveactivitiesd` receives the update via APNs rather than via an inter-process call from LoopFollow, it does not care that LoopFollow holds a background audio session. The update is processed reliably every time. + +### Why This Is Safe and Appropriate + +- This is an officially supported ActivityKit feature. Apple documents push-token-based Live Activity updates as the **recommended** update mechanism. +- The push is sent from the app itself, to itself. No external server or provider infrastructure is required. +- The APNs authentication key is injected at build time via xcconfig and Info.plist. It is never stored in the repository. +- The JWT is generated on-device using CryptoKit (`P256.Signing`) and cached for 55 minutes (APNs tokens are valid for 60 minutes). + +--- + +## File Map + +### Main App Target + +| File | Responsibility | +|---|---| +| `LiveActivityManager.swift` | Orchestration — start, update, end, bind, observe lifecycle | +| `GlucoseSnapshotBuilder.swift` | Pure data transformation — builds `GlucoseSnapshot` from storage | +| `StorageCurrentGlucoseStateProvider.swift` | Thin abstraction over `Storage.shared` and `Observable.shared` | +| `GlucoseSnapshotStore.swift` | App Group persistence — saves/loads latest snapshot | +| `PreferredGlucoseUnit.swift` | Reads user unit preference, converts mg/dL ↔ mmol/L | +| `APNSClient.swift` | Sends APNs self-push with Live Activity content state | +| `APNSJWTGenerator.swift` | Generates ES256-signed JWT for APNs authentication | + +### Shared (App + Extension) + +| File | Responsibility | +|---|---| +| `GlucoseLiveActivityAttributes.swift` | ActivityKit attributes and content state definition | +| `GlucoseSnapshot.swift` | Canonical cross-platform glucose data struct | +| `GlucoseConversion.swift` | Single source of truth for mg/dL ↔ mmol/L conversion | +| `LAAppGroupSettings.swift` | App Group UserDefaults access | +| `AppGroupID.swift` | Derives App Group ID dynamically from bundle identifier | + +### Extension Target + +| File | Responsibility | +|---|---| +| `LoopFollowLiveActivity.swift` | SwiftUI rendering — lock screen card and Dynamic Island | +| `LoopFollowLABundle.swift` | WidgetBundle entry point | + +--- + +## Update Flow + +``` +LoopFollow BG refresh completes + → Storage.shared updated (glucose, delta, trend, IOB, COB, projected) + → Observable.shared updated (isNotLooping) + → BGData calls LiveActivityManager.refreshFromCurrentState(reason: "bg") + → GlucoseSnapshotBuilder.build() reads from StorageCurrentGlucoseStateProvider + → GlucoseSnapshot constructed (unit-converted, threshold-classified) + → GlucoseSnapshotStore persists snapshot to App Group + → activity.update(content) called (direct update for foreground reliability) + → APNSClient.sendLiveActivityUpdate() sends self-push via APNs + → liveactivitiesd receives push + → Lock screen re-renders +``` + +--- + +## APNs Setup — Required for Contributors + +To build and run the Live Activity locally or via CI, you need an APNs authentication key. The key content is injected at build time via `LoopFollowConfigOverride.xcconfig` and is **never stored in the repository**. + +### What you need + +- An Apple Developer account +- An APNs Auth Key (`.p8` file) with the **Apple Push Notifications service (APNs)** capability enabled +- The 10-character Key ID associated with that key + +### Local Build Setup + +1. Generate or download your `.p8` key from [developer.apple.com](https://developer.apple.com) → Certificates, Identifiers & Profiles → Keys. +2. Open the key file in a text editor. Copy the base64 content between the header and footer lines — **exclude** `-----BEGIN PRIVATE KEY-----` and `-----END PRIVATE KEY-----`. Join all lines into a single unbroken string with no spaces or line breaks. +3. Create or edit `LoopFollowConfigOverride.xcconfig` in the project root (this file is gitignored): + +``` +APNS_KEY_ID = +APNS_KEY_CONTENT = +``` + +4. Build and run. The key is read at runtime from `Info.plist` which resolves `$(APNS_KEY_CONTENT)` from the xcconfig. + +### CI / GitHub Actions Setup + +Add two repository secrets under **Settings → Secrets and variables → Actions**: + +| Secret Name | Value | +|---|---| +| `APNS_KEY_ID` | Your 10-character key ID | +| `APNS_KEY` | Full contents of your `.p8` file including PEM headers | + +The build workflow strips the PEM headers automatically and injects the content into `LoopFollowConfigOverride.xcconfig` before building. diff --git a/fastlane/Fastfile b/fastlane/Fastfile index d81e60e5d..0871e7414 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -55,7 +55,8 @@ platform :ios do type: "appstore", git_basic_authorization: Base64.strict_encode64("#{GITHUB_REPOSITORY_OWNER}:#{GH_PAT}"), app_identifier: [ - "com.#{TEAMID}.LoopFollow" + "com.#{TEAMID}.LoopFollow", + "com.#{TEAMID}.LoopFollow.LoopFollowLAExtension" ] ) @@ -70,13 +71,26 @@ platform :ios do targets: ["LoopFollow"] ) + update_code_signing_settings( + path: "#{GITHUB_WORKSPACE}/LoopFollow.xcodeproj", + profile_name: mapping["com.#{TEAMID}.LoopFollow.LoopFollowLAExtension"], + code_sign_identity: "iPhone Distribution", + targets: ["LoopFollowLAExtensionExtension"] + ) + gym( export_method: "app-store", scheme: "LoopFollow", output_name: "LoopFollow.ipa", configuration: "Release", destination: 'generic/platform=iOS', - buildlog_path: 'buildlog' + buildlog_path: 'buildlog', + export_options: { + provisioningProfiles: { + "com.#{TEAMID}.LoopFollow" => mapping["com.#{TEAMID}.LoopFollow"], + "com.#{TEAMID}.LoopFollow.LoopFollowLAExtension" => mapping["com.#{TEAMID}.LoopFollow.LoopFollowLAExtension"] + } + } ) copy_artifacts( @@ -128,6 +142,8 @@ platform :ios do Spaceship::ConnectAPI::BundleIdCapability::Type::PUSH_NOTIFICATIONS ]) + configure_bundle_id("LoopFollow Live Activity Extension", "com.#{TEAMID}.LoopFollow.LoopFollowLAExtension", []) + end desc "Provision Certificates" @@ -148,6 +164,7 @@ platform :ios do git_basic_authorization: Base64.strict_encode64("#{GITHUB_REPOSITORY_OWNER}:#{GH_PAT}"), app_identifier: [ "com.#{TEAMID}.LoopFollow", + "com.#{TEAMID}.LoopFollow.LoopFollowLAExtension" ] ) end