diff --git a/.gitignore b/.gitignore index f176e2f72..d372f7c5a 100644 --- a/.gitignore +++ b/.gitignore @@ -80,4 +80,6 @@ fastlane/test_output fastlane/FastlaneRunner LoopFollowConfigOverride.xcconfig -.history \ No newline at end of file +.history*.xcuserstate +docs/PR_configurable_slots.md +docs/LiveActivityTestPlan.md diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index e767b05c5..cf8019fba 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -8,7 +8,27 @@ /* Begin PBXBuildFile section */ 2D8068C66833EEAED7B4BEB8 /* FutureCarbsCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EBAB9EECE7095238A558060 /* FutureCarbsCondition.swift */; }; + 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 */; }; + 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 */; }; + 379BECAA2F6588300069DC62 /* RestartLiveActivityIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379BECA92F6588300069DC62 /* RestartLiveActivityIntent.swift */; }; + 379BECB02F65DA4B0069DC62 /* LiveActivitySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379BECAF2F65DA4B0069DC62 /* LiveActivitySettingsView.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 */; platformFilter = ios; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 3F1335F351590E573D8E6962 /* Pods_LoopFollow.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */; }; + 5544D8C363FB5D3B9BF8CE4A /* APNSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7C2676561D686C6459CAA2D /* APNSettingsView.swift */; }; 654132E72E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654132E62E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift */; }; 654132EA2E19F24800BDBE08 /* TOTPGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654132E92E19F24800BDBE08 /* TOTPGenerator.swift */; }; 654134182E1DC09700BDBE08 /* OverridePresetsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654134172E1DC09700BDBE08 /* OverridePresetsView.swift */; }; @@ -78,7 +98,6 @@ DD2C2E4F2D3B8AF1006413A5 /* NightscoutSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2C2E4E2D3B8AEC006413A5 /* NightscoutSettingsView.swift */; }; DD2C2E512D3B8B0C006413A5 /* NightscoutSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2C2E502D3B8B0B006413A5 /* NightscoutSettingsViewModel.swift */; }; DD485F142E454B2600CE8CBF /* SecureMessenger.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD485F132E454B2600CE8CBF /* SecureMessenger.swift */; }; - DD485F162E46631000CE8CBF /* CryptoSwift in Frameworks */ = {isa = PBXBuildFile; productRef = DD485F152E46631000CE8CBF /* CryptoSwift */; }; DD4878032C7B297E0048F05C /* StorageValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878022C7B297E0048F05C /* StorageValue.swift */; }; DD4878052C7B2C970048F05C /* Storage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878042C7B2C970048F05C /* Storage.swift */; }; DD4878082C7B30BF0048F05C /* RemoteSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878072C7B30BF0048F05C /* RemoteSettingsView.swift */; }; @@ -89,7 +108,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 */; }; @@ -124,6 +142,7 @@ DD608A082C1F584900F91132 /* DeviceStatusLoop.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD608A072C1F584900F91132 /* DeviceStatusLoop.swift */; }; DD608A0A2C23593900F91132 /* SMB.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD608A092C23593900F91132 /* SMB.swift */; }; DD608A0C2C27415C00F91132 /* BackgroundAlertManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD608A0B2C27415C00F91132 /* BackgroundAlertManager.swift */; }; + 66E3D12E66AA4534A144A54B /* BackgroundRefreshManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8CA8BE0B3D247408FE088B4 /* BackgroundRefreshManager.swift */; }; DD6A935E2BFA6FA2003FFB8E /* DeviceStatusOpenAPS.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD6A935D2BFA6FA2003FFB8E /* DeviceStatusOpenAPS.swift */; }; DD7B0D442D730A3B0063DCB6 /* CycleHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7B0D432D730A320063DCB6 /* CycleHelper.swift */; }; DD7E19842ACDA50C00DBD158 /* Overrides.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7E19832ACDA50C00DBD158 /* Overrides.swift */; }; @@ -160,6 +179,7 @@ DD83164C2DE4DB3A004467AA /* BinaryFloatingPoint+localized.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD83164B2DE4DB3A004467AA /* BinaryFloatingPoint+localized.swift */; }; DD85E9952D739CFE001C8BB7 /* OmnipodDashHeartbeatBluetoothTransmitter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD85E9942D739CED001C8BB7 /* OmnipodDashHeartbeatBluetoothTransmitter.swift */; }; DD91E4DD2BDEC3F8002D9E97 /* GlucoseConversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD91E4DC2BDEC3F8002D9E97 /* GlucoseConversion.swift */; }; + DD91E4DE2BDEC3F8002D9E97 /* GlucoseConversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD91E4DC2BDEC3F8002D9E97 /* GlucoseConversion.swift */; }; DD98F54424BCEFEE0007425A /* ShareClientExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD98F54324BCEFEE0007425A /* ShareClientExtension.swift */; }; DD9ACA042D32821400415D8A /* DeviceStatusTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ACA032D32821200415D8A /* DeviceStatusTask.swift */; }; DD9ACA062D32AF7900415D8A /* TreatmentsTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ACA052D32AF6E00415D8A /* TreatmentsTask.swift */; }; @@ -404,6 +424,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 */; @@ -413,10 +440,40 @@ }; /* 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 = ""; }; 2B9BEC26E4E48EF9B811A372 /* PendingFutureCarb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingFutureCarb.swift; sourceTree = ""; }; 2EBAB9EECE7095238A558060 /* FutureCarbsCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FutureCarbsCondition.swift; 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 = ""; }; + 379BECA92F6588300069DC62 /* RestartLiveActivityIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestartLiveActivityIntent.swift; sourceTree = ""; }; + 379BECAF2F65DA4B0069DC62 /* LiveActivitySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivitySettingsView.swift; 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 = ""; }; @@ -531,6 +588,7 @@ DD608A072C1F584900F91132 /* DeviceStatusLoop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStatusLoop.swift; sourceTree = ""; }; DD608A092C23593900F91132 /* SMB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMB.swift; sourceTree = ""; }; DD608A0B2C27415C00F91132 /* BackgroundAlertManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundAlertManager.swift; sourceTree = ""; }; + A8CA8BE0B3D247408FE088B4 /* BackgroundRefreshManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundRefreshManager.swift; sourceTree = ""; }; DD6A935D2BFA6FA2003FFB8E /* DeviceStatusOpenAPS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStatusOpenAPS.swift; sourceTree = ""; }; DD7B0D432D730A320063DCB6 /* CycleHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CycleHelper.swift; sourceTree = ""; }; DD7E19832ACDA50C00DBD158 /* Overrides.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Overrides.swift; sourceTree = ""; }; @@ -662,6 +720,7 @@ DDFF3D842D14279B00BF9D9E /* BackgroundRefreshSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundRefreshSettingsView.swift; sourceTree = ""; }; DDFF3D862D14280500BF9D9E /* BackgroundRefreshSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundRefreshSettingsViewModel.swift; sourceTree = ""; }; DDFF3D882D1429AB00BF9D9E /* BackgroundRefreshType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundRefreshType.swift; sourceTree = ""; }; + E7C2676561D686C6459CAA2D /* APNSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNSettingsView.swift; sourceTree = ""; }; ECA3EFB4037410B4973BB632 /* Pods-LoopFollow.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-LoopFollow.debug.xcconfig"; path = "Target Support Files/Pods-LoopFollow/Pods-LoopFollow.debug.xcconfig"; sourceTree = ""; }; FC16A97924996673003D6245 /* NightScout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightScout.swift; sourceTree = ""; }; FC16A97C24996747003D6245 /* SpeakBG.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeakBG.swift; sourceTree = ""; }; @@ -818,12 +877,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; @@ -836,15 +905,30 @@ buildActionMask = 2147483647; files = ( 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 = ( @@ -862,6 +946,7 @@ children = ( 6589CC552E9E7D1600BB18FE /* ImportExport */, 6589CC562E9E7D1600BB18FE /* AdvancedSettingsView.swift */, + E7C2676561D686C6459CAA2D /* APNSettingsView.swift */, 6589CC572E9E7D1600BB18FE /* AdvancedSettingsViewModel.swift */, 6589CC582E9E7D1600BB18FE /* CalendarSettingsView.swift */, 6589CC592E9E7D1600BB18FE /* ContactSettingsView.swift */, @@ -884,6 +969,8 @@ FCFEEC9D2486E68E00402A7F /* WebKit.framework */, A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */, FCE537C2249AAB2600F80BF8 /* NotificationCenter.framework */, + 37A4BDDA2F5B6B4A00EEB289 /* WidgetKit.framework */, + 37A4BDDC2F5B6B4A00EEB289 /* SwiftUI.framework */, ); name = Frameworks; sourceTree = ""; @@ -1490,6 +1577,8 @@ FC8DEEE32485D1680075863F /* LoopFollow */ = { isa = PBXGroup; children = ( + 379BECAF2F65DA4B0069DC62 /* LiveActivitySettingsView.swift */, + 376310762F5CD65100656488 /* LiveActivity */, 6589CC612E9E7D1600BB18FE /* Settings */, 65AC26702ED245DF00421360 /* Treatments */, 65AC25F52ECFD5E800421360 /* Stats */, @@ -1520,6 +1609,8 @@ FC97880B2485969B00A7906C = { isa = PBXGroup; children = ( + 379BECA92F6588300069DC62 /* RestartLiveActivityIntent.swift */, + 374DAACA2F5B924B00BB663B /* LoopFollowLAExtensionExtension.entitlements */, DDF2C0132BEFD468007A20E6 /* blacklisted-versions.json */, DDB0AF542BB1B24A00AFA48B /* BuildDetails.plist */, DDB0AF4F2BB1A81F00AFA48B /* Scripts */, @@ -1528,6 +1619,7 @@ FC5A5C3C2497B229009C550E /* Config.xcconfig */, FC8DEEE32485D1680075863F /* LoopFollow */, DDCC3AD72DDE1790006F1C10 /* Tests */, + 37A4BDDE2F5B6B4A00EEB289 /* LoopFollowLAExtension */, FC9788152485969B00A7906C /* Products */, 8E32230C453C93FDCE59C2B9 /* Pods */, 6A5880E0B811AF443B05AB02 /* Frameworks */, @@ -1539,6 +1631,7 @@ children = ( FC9788142485969B00A7906C /* Loop Follow.app */, DDCC3AD62DDE1790006F1C10 /* Tests.xctest */, + 37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */, ); name = Products; sourceTree = ""; @@ -1573,6 +1666,7 @@ FCC6886A24898FD800A0279D /* ObservationToken.swift */, FCC6886C2489909D00A0279D /* AnyConvertible.swift */, FCC688592489554800A0279D /* BackgroundTaskAudio.swift */, + A8CA8BE0B3D247408FE088B4 /* BackgroundRefreshManager.swift */, FCFEEC9F2488157B00402A7F /* Chart.swift */, FCC0FAC124922A22003E610E /* DictionaryKeyPath.swift */, FC16A98024996C07003D6245 /* DateTime.swift */, @@ -1610,6 +1704,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" */; @@ -1644,10 +1760,12 @@ FC9788122485969B00A7906C /* Resources */, 04DA71CCA0280FA5FA2DF7A6 /* [CP] Embed Pods Frameworks */, DDB0AF532BB1AA0900AFA48B /* Capture Build Details */, + 37A4BDED2F5B6B4C00EEB289 /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( + 37A4BDE72F5B6B4C00EEB289 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 65AC25F52ECFD5E800421360 /* Stats */, @@ -1655,8 +1773,6 @@ ); name = LoopFollow; packageProductDependencies = ( - DD48781B2C7DAF140048F05C /* SwiftJWT */, - DD485F152E46631000CE8CBF /* CryptoSwift */, ); productName = LoopFollow; productReference = FC9788142485969B00A7906C /* Loop Follow.app */; @@ -1668,10 +1784,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; @@ -1691,9 +1810,6 @@ ); mainGroup = FC97880B2485969B00A7906C; packageReferences = ( - DD48781A2C7DAF140048F05C /* XCRemoteSwiftPackageReference "Swift-JWT" */, - 654132E82E19F0B800BDBE08 /* XCRemoteSwiftPackageReference "swift-crypto" */, - DD485F0B2E4547C800CE8CBF /* XCRemoteSwiftPackageReference "CryptoSwift" */, ); productRefGroup = FC9788152485969B00A7906C /* Products */; projectDirPath = ""; @@ -1701,11 +1817,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; @@ -1929,6 +2053,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; @@ -1952,6 +2088,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 */, @@ -2035,6 +2176,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 */, @@ -2076,6 +2218,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 */, @@ -2128,6 +2271,8 @@ DDFF3D852D14279B00BF9D9E /* BackgroundRefreshSettingsView.swift in Sources */, DDCF9A882D85FD33004DF4DD /* AlarmData.swift in Sources */, DD608A0C2C27415C00F91132 /* BackgroundAlertManager.swift in Sources */, + 66E3D12E66AA4534A144A54B /* BackgroundRefreshManager.swift in Sources */, + 379BECB02F65DA4B0069DC62 /* LiveActivitySettingsView.swift in Sources */, DD4878082C7B30BF0048F05C /* RemoteSettingsView.swift in Sources */, DDE75D2B2DE5E613007C1FC1 /* NavigationRow.swift in Sources */, DD4AFB3B2DB55CB600BB593F /* TimeOfDay.swift in Sources */, @@ -2161,6 +2306,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 */, @@ -2186,6 +2335,7 @@ 654132EA2E19F24800BDBE08 /* TOTPGenerator.swift in Sources */, 6541341C2E1DC28000BDBE08 /* DateExtensions.swift in Sources */, DD0650F52DCF303F004D3B41 /* AlarmStepperSection.swift in Sources */, + 379BECAA2F6588300069DC62 /* RestartLiveActivityIntent.swift in Sources */, DDCF9A802D85FD0B004DF4DD /* Alarm.swift in Sources */, DD7F4C132DD51FD500D449E9 /* TempTargetEndCondition.swift in Sources */, DDD10F012C510C6B00D76A8E /* ObservableUserDefaults.swift in Sources */, @@ -2213,6 +2363,12 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 37A4BDE72F5B6B4C00EEB289 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + platformFilter = ios; + target = 37A4BDD82F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension */; + targetProxy = 37A4BDE62F5B6B4C00EEB289 /* PBXContainerItemProxy */; + }; DDCC3ADB2DDE1790006F1C10 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = FC9788132485969B00A7906C /* LoopFollow */; @@ -2240,6 +2396,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 = "$(MARKETING_VERSION)"; + 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 = "$(MARKETING_VERSION)"; + 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 = { @@ -2262,7 +2521,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 18.4; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = "com.--unique-id-.LoopFollowTests--app-suffix-.Tests"; + PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollowTests$(app_suffix).Tests"; PRODUCT_MODULE_NAME = Tests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; @@ -2295,7 +2554,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 18.4; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = "com.--unique-id-.LoopFollowTests--app-suffix-.Tests"; + PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollowTests$(app_suffix).Tests"; PRODUCT_MODULE_NAME = Tests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; @@ -2443,6 +2702,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"; @@ -2467,6 +2727,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"; @@ -2476,6 +2737,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 = ( @@ -2505,44 +2775,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"; - requirement = { - kind = upToNextMajorVersion; - 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 */ - -/* Begin XCSwiftPackageProductDependency section */ - DD485F152E46631000CE8CBF /* CryptoSwift */ = { - isa = XCSwiftPackageProductDependency; - productName = CryptoSwift; - }; - DD48781B2C7DAF140048F05C /* SwiftJWT */ = { - isa = XCSwiftPackageProductDependency; - package = DD48781A2C7DAF140048F05C /* XCRemoteSwiftPackageReference "Swift-JWT" */; - productName = SwiftJWT; - }; -/* 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 fc653f9a3..000000000 --- a/LoopFollow.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,96 +0,0 @@ -{ - "originHash" : "94a024be279d128a7e82f3c76785db1e4cf7c9380d0c4aa59dfdf54952403b8d", - "pins" : [ - { - "identity" : "bluecryptor", - "kind" : "remoteSourceControl", - "location" : "https://github.com/Kitura/BlueCryptor.git", - "state" : { - "revision" : "cec97c24b111351e70e448972a7d3fe68a756d6d", - "version" : "2.0.2" - } - }, - { - "identity" : "blueecc", - "kind" : "remoteSourceControl", - "location" : "https://github.com/Kitura/BlueECC.git", - "state" : { - "revision" : "1485268a54f8135435a825a855e733f026fa6cc8", - "version" : "1.2.201" - } - }, - { - "identity" : "bluersa", - "kind" : "remoteSourceControl", - "location" : "https://github.com/Kitura/BlueRSA.git", - "state" : { - "revision" : "f40325520344a966523b214394aa350132a6af68", - "version" : "1.0.203" - } - }, - { - "identity" : "cryptoswift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/krzyzanowskim/CryptoSwift.git", - "state" : { - "revision" : "e45a26384239e028ec87fbcc788f513b67e10d8f", - "version" : "1.9.0" - } - }, - { - "identity" : "kituracontracts", - "kind" : "remoteSourceControl", - "location" : "https://github.com/Kitura/KituraContracts.git", - "state" : { - "revision" : "6edf7ac3dd2b3a2c61284778d430bbad7d8a6f23", - "version" : "2.0.1" - } - }, - { - "identity" : "loggerapi", - "kind" : "remoteSourceControl", - "location" : "https://github.com/Kitura/LoggerAPI.git", - "state" : { - "revision" : "4e6b45e850ffa275e8e26a24c6454fd709d5b6ac", - "version" : "2.0.0" - } - }, - { - "identity" : "swift-asn1", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-asn1.git", - "state" : { - "revision" : "810496cf121e525d660cd0ea89a758740476b85f", - "version" : "1.5.1" - } - }, - { - "identity" : "swift-crypto", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-crypto.git", - "state" : { - "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc", - "version" : "3.15.1" - } - }, - { - "identity" : "swift-jwt", - "kind" : "remoteSourceControl", - "location" : "https://github.com/Kitura/Swift-JWT.git", - "state" : { - "revision" : "f68ec28fbd90a651597e9e825ea7f315f8d52a1f", - "version" : "4.0.1" - } - }, - { - "identity" : "swift-log", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-log.git", - "state" : { - "revision" : "bbd81b6725ae874c69e9b8c8804d462356b55523", - "version" : "1.10.1" - } - } - ], - "version" : 3 -} diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index 604cf3e9e..a6fd9f2b9 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -6,7 +6,7 @@ import EventKit import UIKit import UserNotifications -@UIApplicationMain +@main class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? let notificationCenter = UNUserNotificationCenter.current() @@ -32,7 +32,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } let action = UNNotificationAction(identifier: "OPEN_APP_ACTION", title: "Open App", options: .foreground) - let category = UNNotificationCategory(identifier: "loopfollow.background.alert", actions: [action], intentIdentifiers: [], options: []) + let category = UNNotificationCategory(identifier: BackgroundAlertIdentifier.categoryIdentifier, actions: [action], intentIdentifiers: [], options: []) UNUserNotificationCenter.current().setNotificationCategories([category]) UNUserNotificationCenter.current().delegate = self @@ -45,30 +45,44 @@ class AppDelegate: UIResponder, UIApplicationDelegate { DispatchQueue.main.async { UIApplication.shared.registerForRemoteNotifications() } + + BackgroundRefreshManager.shared.register() + + // Detect Before-First-Unlock launch. If protected data is unavailable here, + // StorageValues were cached from encrypted UserDefaults and need a reload + // on the first foreground after the user unlocks. + let bfu = !UIApplication.shared.isProtectedDataAvailable + Storage.shared.needsBFUReload = bfu + LogManager.shared.log(category: .general, message: "BFU check: isProtectedDataAvailable=\(!bfu), needsBFUReload=\(bfu)") + return true } - func applicationWillTerminate(_: UIApplication) {} + func applicationWillTerminate(_: UIApplication) { + #if !targetEnvironment(macCatalyst) + LiveActivityManager.shared.endOnTerminate() + #endif + } // MARK: - Remote Notifications - // Called when successfully registered for remote notifications + /// Called when successfully registered for remote notifications func application(_: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { let tokenString = deviceToken.map { String(format: "%02.2hhx", $0) }.joined() Observable.shared.loopFollowDeviceToken.value = tokenString - LogManager.shared.log(category: .general, message: "Successfully registered for remote notifications with token: \(tokenString)") + LogManager.shared.log(category: .apns, message: "Successfully registered for remote notifications with token: \(tokenString)") } - // Called when failed to register for remote notifications + /// Called when failed to register for remote notifications func application(_: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { - LogManager.shared.log(category: .general, message: "Failed to register for remote notifications: \(error.localizedDescription)") + LogManager.shared.log(category: .apns, message: "Failed to register for remote notifications: \(error.localizedDescription)") } - // Called when a remote notification is received + /// Called when a remote notification is received func application(_: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { - LogManager.shared.log(category: .general, message: "Received remote notification: \(userInfo)") + LogManager.shared.log(category: .apns, message: "Received remote notification: \(userInfo)") // Check if this is a response notification from Loop or Trio if let aps = userInfo["aps"] as? [String: Any] { @@ -76,7 +90,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { if let alert = aps["alert"] as? [String: Any] { let title = alert["title"] as? String ?? "" let body = alert["body"] as? String ?? "" - LogManager.shared.log(category: .general, message: "Notification - Title: \(title), Body: \(body)") + LogManager.shared.log(category: .apns, message: "Notification - Title: \(title), Body: \(body)") } // Handle silent notification (content-available) @@ -84,11 +98,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // This is a silent push, nothing implemented but logging for now if let commandStatus = userInfo["command_status"] as? String { - LogManager.shared.log(category: .general, message: "Command status: \(commandStatus)") + LogManager.shared.log(category: .apns, message: "Command status: \(commandStatus)") } if let commandType = userInfo["command_type"] as? String { - LogManager.shared.log(category: .general, message: "Command type: \(commandType)") + LogManager.shared.log(category: .apns, message: "Command type: \(commandType)") } } } @@ -97,6 +111,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate { completionHandler(.newData) } + // MARK: - URL handling + + // Note: with scene-based lifecycle (iOS 13+), URLs are delivered to + // SceneDelegate.scene(_:openURLContexts:) — not here. The scene delegate + // handles ://la-tap for Live Activity tap navigation. + // MARK: UISceneSession Lifecycle func application(_: UIApplication, willFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { @@ -110,7 +130,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options _: UIScene.ConnectionOptions) -> UISceneConfiguration { // Called when a new scene session is being created. // Use this method to select a configuration to create the new scene with. - return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) } func application(_: UIApplication, didDiscardSceneSessions _: Set) { @@ -166,7 +186,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func userNotificationCenter(_: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { if response.actionIdentifier == "OPEN_APP_ACTION" { - if let window = window { + if let window { window.rootViewController?.dismiss(animated: true, completion: nil) window.rootViewController?.present(MainViewController(), animated: true, completion: nil) } diff --git a/LoopFollow/Application/SceneDelegate.swift b/LoopFollow/Application/SceneDelegate.swift index b15fb0bd5..e702db267 100644 --- a/LoopFollow/Application/SceneDelegate.swift +++ b/LoopFollow/Application/SceneDelegate.swift @@ -34,6 +34,16 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. } + func scene(_: UIScene, openURLContexts URLContexts: Set) { + guard URLContexts.contains(where: { $0.url.scheme == AppGroupID.urlScheme && $0.url.host == "la-tap" }) else { return } + // scene(_:openURLContexts:) fires after sceneDidBecomeActive when the app + // foregrounds from background. Post on the next run loop so the view + // hierarchy (including any presented modals) is fully settled. + DispatchQueue.main.async { + NotificationCenter.default.post(name: .liveActivityDidForeground, object: nil) + } + } + func sceneWillResignActive(_: UIScene) { // Called when the scene will move from an active state to an inactive state. // This may occur due to temporary interruptions (ex. an incoming phone call). @@ -53,7 +63,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { (UIApplication.shared.delegate as? AppDelegate)?.saveContext() } - // Handle the UIApplicationShortcutItem when the user taps on the Home Screen Quick Action. This function toggles the "Speak BG" setting in UserDefaultsRepository, speaks the current state (on/off) using AVSpeechSynthesizer, and updates the Quick Action appearance. + /// Handle the UIApplicationShortcutItem when the user taps on the Home Screen Quick Action. This function toggles the "Speak BG" setting in UserDefaultsRepository, speaks the current state (on/off) using AVSpeechSynthesizer, and updates the Quick Action appearance. func handleShortcutItem(_ shortcutItem: UIApplicationShortcutItem) { if let bundleIdentifier = Bundle.main.bundleIdentifier { let expectedType = bundleIdentifier + ".toggleSpeakBG" @@ -66,7 +76,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } } - // The following method is called when the user taps on the Home Screen Quick Action + /// The following method is called when the user taps on the Home Screen Quick Action func windowScene(_: UIWindowScene, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler _: @escaping (Bool) -> Void) { handleShortcutItem(shortcutItem) } diff --git a/LoopFollow/Controllers/BackgroundAlertManager.swift b/LoopFollow/Controllers/BackgroundAlertManager.swift index d8a80b8f1..0ba3664b1 100644 --- a/LoopFollow/Controllers/BackgroundAlertManager.swift +++ b/LoopFollow/Controllers/BackgroundAlertManager.swift @@ -11,11 +11,24 @@ enum BackgroundAlertDuration: TimeInterval, CaseIterable { case eighteenMinutes = 1080 // 18 minutes in seconds } -/// Enum representing unique identifiers for each background alert. -enum BackgroundAlertIdentifier: String, CaseIterable { - case sixMin = "loopfollow.background.alert.6min" - case twelveMin = "loopfollow.background.alert.12min" - case eighteenMin = "loopfollow.background.alert.18min" +/// Unique identifiers for each background alert, scoped to the current bundle +/// so multiple LoopFollow instances don't interfere with each other's notifications. +enum BackgroundAlertIdentifier: CaseIterable { + case sixMin + case twelveMin + case eighteenMin + + private static let prefix = Bundle.main.bundleIdentifier ?? "loopfollow" + + var rawValue: String { + switch self { + case .sixMin: "\(Self.prefix).background.alert.6min" + case .twelveMin: "\(Self.prefix).background.alert.12min" + case .eighteenMin: "\(Self.prefix).background.alert.18min" + } + } + + static let categoryIdentifier = "\(prefix).background.alert" } class BackgroundAlertManager { @@ -118,7 +131,7 @@ class BackgroundAlertManager { content.title = title content.body = body content.sound = .defaultCritical - content.categoryIdentifier = "loopfollow.background.alert" + content.categoryIdentifier = BackgroundAlertIdentifier.categoryIdentifier return content } diff --git a/LoopFollow/Controllers/Nightscout/BGData.swift b/LoopFollow/Controllers/Nightscout/BGData.swift index c07da66d5..c0721b8a4 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 !targetEnvironment(macCatalyst) + LiveActivityManager.shared.refreshFromCurrentState(reason: "bg") + #endif + // 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..6c6cac03f 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 !targetEnvironment(macCatalyst) + LiveActivityManager.shared.refreshFromCurrentState(reason: "notLooping") + #endif } 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 !targetEnvironment(macCatalyst) + LiveActivityManager.shared.refreshFromCurrentState(reason: "loopingResumed") + #endif } } @@ -119,14 +127,17 @@ extension MainViewController { let storedTime = Observable.shared.alertLastLoopTime.value ?? 0 if lastPumpTime > storedTime { Observable.shared.alertLastLoopTime.value = lastPumpTime + Storage.shared.lastLoopTime.value = lastPumpTime } if let reservoirData = lastPumpRecord["reservoir"] as? Double { latestPumpVolume = reservoirData infoManager.updateInfoData(type: .pump, value: String(format: "%.0f", reservoirData) + "U") + Storage.shared.lastPumpReservoirU.value = reservoirData } else { latestPumpVolume = 50.0 infoManager.updateInfoData(type: .pump, value: "50+U") + Storage.shared.lastPumpReservoirU.value = nil } } diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift index fe10b62b9..daeea40f7 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift @@ -30,12 +30,14 @@ extension MainViewController { let profileISF = profileManager.currentISF() if let profileISF = profileISF { infoManager.updateInfoData(type: .isf, value: profileISF) + Storage.shared.lastIsfMgdlPerU.value = profileISF.doubleValue(for: .milligramsPerDeciliter) } // Carb Ratio (CR) let profileCR = profileManager.currentCarbRatio() if let profileCR = profileCR { infoManager.updateInfoData(type: .carbRatio, value: profileCR) + Storage.shared.lastCarbRatio.value = profileCR } // Target @@ -47,6 +49,8 @@ extension MainViewController { } else if let profileTargetLow = profileTargetLow { infoManager.updateInfoData(type: .target, value: profileTargetLow) } + Storage.shared.lastTargetLowMgdl.value = profileTargetLow?.doubleValue(for: .milligramsPerDeciliter) + Storage.shared.lastTargetHighMgdl.value = profileTargetHigh?.doubleValue(for: .milligramsPerDeciliter) // IOB if let insulinMetric = InsulinMetric(from: lastLoopRecord["iob"], key: "iob") { @@ -62,7 +66,7 @@ extension MainViewController { if let predictdata = lastLoopRecord["predicted"] as? [String: AnyObject] { let prediction = predictdata["values"] as! [Double] - PredictionLabel.text = Localizer.toDisplayUnits(String(Int(prediction.last!))) + PredictionLabel.text = Localizer.toDisplayUnits(String(Int(round(prediction.last!)))) PredictionLabel.textColor = UIColor.systemPurple if Storage.shared.downloadPrediction.value, previousLastLoopTime < lastLoopTime { predictionData.removeAll() @@ -87,6 +91,8 @@ extension MainViewController { let formattedMax = Localizer.toDisplayUnits(String(predMax)) let value = "\(formattedMin)/\(formattedMax)" infoManager.updateInfoData(type: .minMax, value: value) + Storage.shared.lastMinBgMgdl.value = predMin + Storage.shared.lastMaxBgMgdl.value = predMax } updatePredictionGraph() @@ -119,6 +125,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..150e5da4f 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift @@ -37,8 +37,10 @@ extension MainViewController { } if let profileISF = profileISF, let enactedISF = enactedISF, profileISF != enactedISF { infoManager.updateInfoData(type: .isf, firstValue: profileISF, secondValue: enactedISF, separator: .arrow) + Storage.shared.lastIsfMgdlPerU.value = enactedISF.doubleValue(for: .milligramsPerDeciliter) } else if let profileISF = profileISF { infoManager.updateInfoData(type: .isf, value: profileISF) + Storage.shared.lastIsfMgdlPerU.value = profileISF.doubleValue(for: .milligramsPerDeciliter) } // Carb Ratio (CR) @@ -57,8 +59,10 @@ extension MainViewController { if let profileCR = profileCR, let enactedCR = enactedCR, profileCR != enactedCR { infoManager.updateInfoData(type: .carbRatio, value: profileCR, enactedValue: enactedCR, separator: .arrow) + Storage.shared.lastCarbRatio.value = enactedCR } else if let profileCR = profileCR { infoManager.updateInfoData(type: .carbRatio, value: profileCR) + Storage.shared.lastCarbRatio.value = profileCR } // IOB @@ -98,6 +102,7 @@ extension MainViewController { if let sens = enactedOrSuggested["sensitivityRatio"] as? Double { let formattedSens = String(format: "%.0f", sens * 100.0) + "%" infoManager.updateInfoData(type: .autosens, value: formattedSens) + Storage.shared.lastAutosens.value = sens } // Recommended Bolus @@ -112,6 +117,9 @@ extension MainViewController { if let eventualBGValue = enactedOrSuggested["eventualBG"] as? Double { let eventualBGQuantity = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: eventualBGValue) PredictionLabel.text = Localizer.formatQuantity(eventualBGQuantity) + Storage.shared.projectedBgMgdl.value = eventualBGValue + } else { + Storage.shared.projectedBgMgdl.value = nil } // Target @@ -136,11 +144,19 @@ extension MainViewController { } else { infoManager.updateInfoData(type: .target, value: profileTargetHigh) } + let effectiveMgdl = enactedTarget.doubleValue(for: .milligramsPerDeciliter) + Storage.shared.lastTargetLowMgdl.value = effectiveMgdl + Storage.shared.lastTargetHighMgdl.value = effectiveMgdl + } else if let profileTargetHigh = profileTargetHigh { + let profileMgdl = profileTargetHigh.doubleValue(for: .milligramsPerDeciliter) + Storage.shared.lastTargetLowMgdl.value = profileMgdl + Storage.shared.lastTargetHighMgdl.value = profileMgdl } // TDD if let tddMetric = InsulinMetric(from: enactedOrSuggested, key: "TDD") { infoManager.updateInfoData(type: .tdd, value: tddMetric) + Storage.shared.lastTdd.value = tddMetric.value } let predBGsData: [String: AnyObject]? = { @@ -201,6 +217,8 @@ extension MainViewController { if minPredBG != Double.infinity, maxPredBG != -Double.infinity { let value = "\(Localizer.toDisplayUnits(String(minPredBG)))/\(Localizer.toDisplayUnits(String(maxPredBG)))" infoManager.updateInfoData(type: .minMax, value: value) + Storage.shared.lastMinBgMgdl.value = minPredBG + Storage.shared.lastMaxBgMgdl.value = maxPredBG } else { infoManager.updateInfoData(type: .minMax, value: "N/A") } @@ -224,6 +242,10 @@ extension MainViewController { LoopStatusLabel.text = "↻" latestLoopStatusString = "↻" } + + // Live Activity storage + Storage.shared.lastIOB.value = latestIOB?.value + Storage.shared.lastCOB.value = latestCOB?.value } } } diff --git a/LoopFollow/Controllers/Nightscout/IAge.swift b/LoopFollow/Controllers/Nightscout/IAge.swift index 69a683c57..50e9bd592 100644 --- a/LoopFollow/Controllers/Nightscout/IAge.swift +++ b/LoopFollow/Controllers/Nightscout/IAge.swift @@ -45,6 +45,7 @@ extension MainViewController { .withColonSeparatorInTime] if let iageTime = formatter.date(from: (lastIageString as! String))?.timeIntervalSince1970 { + Storage.shared.iageInsertTime.value = iageTime let now = dateTimeUtils.getNowTimeIntervalUTC() let secondsAgo = now - iageTime diff --git a/LoopFollow/Controllers/Nightscout/Profile.swift b/LoopFollow/Controllers/Nightscout/Profile.swift index c00ac195e..f76c74a4c 100644 --- a/LoopFollow/Controllers/Nightscout/Profile.swift +++ b/LoopFollow/Controllers/Nightscout/Profile.swift @@ -23,6 +23,7 @@ extension MainViewController { } profileManager.loadProfile(from: profileData) infoManager.updateInfoData(type: .profile, value: profileData.defaultProfile) + Storage.shared.lastProfileName.value = profileData.defaultProfile // Mark profile data as loaded for initial loading state markDataLoaded("profile") diff --git a/LoopFollow/Controllers/Nightscout/Treatments/Basals.swift b/LoopFollow/Controllers/Nightscout/Treatments/Basals.swift index 5ee0891fe..405281926 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/Basals.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/Basals.swift @@ -147,5 +147,6 @@ extension MainViewController { latestBasal = "\(profileBasal) → \(latestBasal)" } infoManager.updateInfoData(type: .basal, value: latestBasal) + Storage.shared.lastBasal.value = latestBasal } } diff --git a/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift b/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift index baa4af7a1..5d75adb2d 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift @@ -82,5 +82,6 @@ extension MainViewController { let resultString = String(format: "%.0f", totalCarbs) infoManager.updateInfoData(type: .carbsToday, value: resultString) + Storage.shared.lastCarbsToday.value = totalCarbs } } diff --git a/LoopFollow/Helpers/BackgroundRefreshManager.swift b/LoopFollow/Helpers/BackgroundRefreshManager.swift new file mode 100644 index 000000000..a1168174d --- /dev/null +++ b/LoopFollow/Helpers/BackgroundRefreshManager.swift @@ -0,0 +1,96 @@ +// LoopFollow +// BackgroundRefreshManager.swift + +import BackgroundTasks +import UIKit + +class BackgroundRefreshManager { + static let shared = BackgroundRefreshManager() + private init() {} + + private let taskIdentifier = "\(Bundle.main.bundleIdentifier ?? "com.loopfollow").audiorefresh" + + func register() { + BGTaskScheduler.shared.register(forTaskWithIdentifier: taskIdentifier, using: nil) { task in + guard let refreshTask = task as? BGAppRefreshTask else { return } + self.handleRefreshTask(refreshTask) + } + } + + private func handleRefreshTask(_ task: BGAppRefreshTask) { + LogManager.shared.log(category: .taskScheduler, message: "BGAppRefreshTask fired") + + // Guard against double setTaskCompleted if expiration fires while the + // main-queue block is in-flight (Apple documents this as a programming error). + var completed = false + + task.expirationHandler = { + guard !completed else { return } + completed = true + LogManager.shared.log(category: .taskScheduler, message: "BGAppRefreshTask expired") + task.setTaskCompleted(success: false) + self.scheduleRefresh() + } + + DispatchQueue.main.async { + guard !completed else { return } + completed = true + if let mainVC = self.getMainViewController() { + if !mainVC.backgroundTask.player.isPlaying { + LogManager.shared.log(category: .taskScheduler, message: "audio dead, attempting restart") + mainVC.backgroundTask.stopBackgroundTask() + mainVC.backgroundTask.startBackgroundTask() + LogManager.shared.log(category: .taskScheduler, message: "audio restart initiated") + } else { + LogManager.shared.log(category: .taskScheduler, message: "audio alive, no action needed", isDebug: true) + } + } + self.scheduleRefresh() + task.setTaskCompleted(success: true) + } + } + + func scheduleRefresh() { + let request = BGAppRefreshTaskRequest(identifier: taskIdentifier) + request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) + do { + try BGTaskScheduler.shared.submit(request) + } catch { + LogManager.shared.log(category: .taskScheduler, message: "Failed to schedule BGAppRefreshTask: \(error)") + } + } + + private func getMainViewController() -> MainViewController? { + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first, + let rootVC = window.rootViewController + else { + return nil + } + + if let mainVC = rootVC as? MainViewController { + return mainVC + } + + if let navVC = rootVC as? UINavigationController, + let mainVC = navVC.viewControllers.first as? MainViewController + { + return mainVC + } + + if let tabVC = rootVC as? UITabBarController { + for vc in tabVC.viewControllers ?? [] { + if let mainVC = vc as? MainViewController { + return mainVC + } + if let navVC = vc as? UINavigationController, + let mainVC = navVC.viewControllers.first as? MainViewController + { + return mainVC + } + } + } + + return nil + } +} diff --git a/LoopFollow/Helpers/BackgroundTaskAudio.swift b/LoopFollow/Helpers/BackgroundTaskAudio.swift index 91504ab5d..25aa6b3c8 100755 --- a/LoopFollow/Helpers/BackgroundTaskAudio.swift +++ b/LoopFollow/Helpers/BackgroundTaskAudio.swift @@ -7,12 +7,16 @@ class BackgroundTask { // MARK: - Vars var player = AVAudioPlayer() - var timer = Timer() + + private var retryCount = 0 + private let maxRetries = 3 // MARK: - Methods func startBackgroundTask() { + NotificationCenter.default.removeObserver(self, name: AVAudioSession.interruptionNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(interruptedAudio), name: AVAudioSession.interruptionNotification, object: AVAudioSession.sharedInstance()) + retryCount = 0 playAudio() } @@ -22,21 +26,43 @@ class BackgroundTask { LogManager.shared.log(category: .general, message: "Silent audio stopped", isDebug: true) } - @objc fileprivate func interruptedAudio(_ notification: Notification) { - LogManager.shared.log(category: .general, message: "Silent audio interrupted") - if notification.name == AVAudioSession.interruptionNotification, notification.userInfo != nil { - var info = notification.userInfo! - var intValue = 0 - (info[AVAudioSessionInterruptionTypeKey]! as AnyObject).getValue(&intValue) - if intValue == 1 { playAudio() } + @objc private func interruptedAudio(_ notification: Notification) { + guard notification.name == AVAudioSession.interruptionNotification, + let userInfo = notification.userInfo, + let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt, + let type = AVAudioSession.InterruptionType(rawValue: typeValue) + else { return } + + switch type { + case .began: + LogManager.shared.log(category: .general, message: "[LA] Silent audio session interrupted (began)") + + case .ended: + if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt { + let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue) + if !options.contains(.shouldResume) { + LogManager.shared.log(category: .general, message: "[LA] Silent audio interruption ended — shouldResume not set, attempting restart anyway") + } + } + LogManager.shared.log(category: .general, message: "[LA] Silent audio interruption ended — scheduling restart in 0.5s") + retryCount = 0 + // Brief delay to let the interrupting app (e.g. Clock alarm) fully release the audio + // session before we attempt to reactivate. Without this, setActive(true) races with + // the alarm and fails with AVAudioSession.ErrorCode.cannotInterruptOthers (560557684). + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + self?.playAudio() + } + + @unknown default: + break } } - fileprivate func playAudio() { + private func playAudio() { + let attemptDesc = retryCount == 0 ? "initial attempt" : "retry \(retryCount)/\(maxRetries)" do { let bundle = Bundle.main.path(forResource: "blank", ofType: "wav") let alertSound = URL(fileURLWithPath: bundle!) - // try AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback) try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: .mixWithOthers) try AVAudioSession.sharedInstance().setActive(true) try player = AVAudioPlayer(contentsOf: alertSound) @@ -45,9 +71,24 @@ class BackgroundTask { player.volume = 0.01 player.prepareToPlay() player.play() - LogManager.shared.log(category: .general, message: "Silent audio playing", isDebug: true) + retryCount = 0 + LogManager.shared.log(category: .general, message: "Silent audio playing (\(attemptDesc))", isDebug: true) } catch { - LogManager.shared.log(category: .general, message: "playAudio, error: \(error)") + LogManager.shared.log(category: .general, message: "playAudio failed (\(attemptDesc)), error: \(error)") + if retryCount < maxRetries { + retryCount += 1 + LogManager.shared.log(category: .general, message: "playAudio scheduling retry \(retryCount)/\(maxRetries) in 2s") + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in + self?.playAudio() + } + } else { + LogManager.shared.log(category: .general, message: "playAudio failed after \(maxRetries) retries — posting BackgroundAudioFailed") + NotificationCenter.default.post(name: .backgroundAudioFailed, object: nil) + } } } } + +extension Notification.Name { + static let backgroundAudioFailed = Notification.Name("BackgroundAudioFailed") +} 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..3e847b999 100644 --- a/LoopFollow/Helpers/JWTManager.swift +++ b/LoopFollow/Helpers/JWTManager.swift @@ -1,42 +1,51 @@ // 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 let lock = NSLock() + 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) + lock.lock() + defer { lock.unlock() } + + 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)) + LogManager.shared.log(category: .apns, message: "JWT generated for key \(keyId) (TTL 55 min)") return signedJWT } catch { LogManager.shared.log(category: .apns, message: "Failed to sign JWT: \(error.localizedDescription)") @@ -44,9 +53,64 @@ class JWTManager { } } - // Invalidate the cache by clearing values in Storage.shared func invalidateCache() { - Storage.shared.cachedJWT.value = nil - Storage.shared.jwtExpirationDate.value = nil + lock.lock() + defer { lock.unlock() } + cache.removeAll() + LogManager.shared.log(category: .apns, message: "JWT cache invalidated") + } + + // 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..9e0f99340 100644 --- a/LoopFollow/Info.plist +++ b/LoopFollow/Info.plist @@ -7,6 +7,7 @@ BGTaskSchedulerPermittedIdentifiers com.$(unique_id).LoopFollow$(app_suffix) + com.$(unique_id).LoopFollow$(app_suffix).audiorefresh CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) @@ -16,6 +17,8 @@ $(EXECUTABLE_NAME) CFBundleGetInfoString + CFBundleIconFile + Activities CFBundleIdentifier com.$(unique_id).LoopFollow$(app_suffix) CFBundleInfoDictionaryVersion @@ -31,7 +34,7 @@ CFBundleURLSchemes - loopfollow + loopfollow$(app_suffix) @@ -61,6 +64,8 @@ This app requires Face ID for secure authentication. NSHumanReadableCopyright + NSSupportsLiveActivities + UIApplicationSceneManifest UIApplicationSupportsMultipleScenes @@ -83,6 +88,7 @@ UIBackgroundModes audio + fetch processing bluetooth-central remote-notification diff --git a/LoopFollow/LiveActivity/APNSClient.swift b/LoopFollow/LiveActivity/APNSClient.swift new file mode 100644 index 000000000..8755b1b27 --- /dev/null +++ b/LoopFollow/LiveActivity/APNSClient.swift @@ -0,0 +1,161 @@ +// LoopFollow +// APNSClient.swift + +// swiftformat:disable indent +#if !targetEnvironment(macCatalyst) + +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: .apns, 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: .apns, 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: .apns, message: "APNs push sent successfully", isDebug: true) + + case 400: + let responseBody = String(data: data, encoding: .utf8) ?? "empty" + LogManager.shared.log(category: .apns, message: "APNs bad request (400) — malformed payload: \(responseBody)") + + case 403: + // JWT rejected — force regenerate on next push + JWTManager.shared.invalidateCache() + LogManager.shared.log(category: .apns, 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: .apns, message: "APNs token \(reason) — restarting Live Activity") + LiveActivityManager.shared.handleExpiredToken() + + case 429: + LogManager.shared.log(category: .apns, 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: .apns, 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: .apns, message: "APNs push failed status=\(httpResponse.statusCode) body=\(responseBody)") + } + } + + } catch { + LogManager.shared.log(category: .apns, 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 + snapshotDict["showRenewalOverlay"] = snapshot.showRenewalOverlay + if let iob = snapshot.iob { snapshotDict["iob"] = iob } + if let cob = snapshot.cob { snapshotDict["cob"] = cob } + if let projected = snapshot.projected { snapshotDict["projected"] = projected } + if let override = snapshot.override { snapshotDict["override"] = override } + if let recBolus = snapshot.recBolus { snapshotDict["recBolus"] = recBolus } + if let battery = snapshot.battery { snapshotDict["battery"] = battery } + if let pumpBattery = snapshot.pumpBattery { snapshotDict["pumpBattery"] = pumpBattery } + if !snapshot.basalRate.isEmpty { snapshotDict["basalRate"] = snapshot.basalRate } + if let pumpReservoirU = snapshot.pumpReservoirU { snapshotDict["pumpReservoirU"] = pumpReservoirU } + if let autosens = snapshot.autosens { snapshotDict["autosens"] = autosens } + if let tdd = snapshot.tdd { snapshotDict["tdd"] = tdd } + if let targetLowMgdl = snapshot.targetLowMgdl { snapshotDict["targetLowMgdl"] = targetLowMgdl } + if let targetHighMgdl = snapshot.targetHighMgdl { snapshotDict["targetHighMgdl"] = targetHighMgdl } + if let isfMgdlPerU = snapshot.isfMgdlPerU { snapshotDict["isfMgdlPerU"] = isfMgdlPerU } + if let carbRatio = snapshot.carbRatio { snapshotDict["carbRatio"] = carbRatio } + if let carbsToday = snapshot.carbsToday { snapshotDict["carbsToday"] = carbsToday } + if let profileName = snapshot.profileName { snapshotDict["profileName"] = profileName } + if snapshot.sageInsertTime > 0 { snapshotDict["sageInsertTime"] = snapshot.sageInsertTime } + if snapshot.cageInsertTime > 0 { snapshotDict["cageInsertTime"] = snapshot.cageInsertTime } + if snapshot.iageInsertTime > 0 { snapshotDict["iageInsertTime"] = snapshot.iageInsertTime } + if let minBgMgdl = snapshot.minBgMgdl { snapshotDict["minBgMgdl"] = minBgMgdl } + if let maxBgMgdl = snapshot.maxBgMgdl { snapshotDict["maxBgMgdl"] = maxBgMgdl } + + 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) + } +} + +#endif diff --git a/LoopFollow/LiveActivity/AppGroupID.swift b/LoopFollow/LiveActivity/AppGroupID.swift new file mode 100644 index 000000000..5eb1187b8 --- /dev/null +++ b/LoopFollow/LiveActivity/AppGroupID.swift @@ -0,0 +1,77 @@ +// 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" + + /// The base bundle identifier for the main app, with extension suffixes stripped. + /// Usable from both the main app and extensions. + static var baseBundleID: String { + if let base = Bundle.main.object(forInfoDictionaryKey: baseBundleIDPlistKey) as? String, + !base.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + return base + } + let bundleID = Bundle.main.bundleIdentifier ?? "unknown" + return stripLikelyExtensionSuffixes(from: bundleID) + } + + /// URL scheme derived from the bundle identifier. Works across app and extensions. + /// Default build: "loopfollow", second: "loopfollow2", third: "loopfollow3", etc. + static var urlScheme: String { + let base = baseBundleID + // Extract the suffix after "LoopFollow" in the bundle ID + // e.g. "com.TEAM.LoopFollow2" → "2", "com.TEAM.LoopFollow" → "" + if let range = base.range(of: "LoopFollow", options: .backwards) { + let suffix = base[range.upperBound...] + return "loopfollow\(suffix)" + } + return "loopfollow" + } + + static func current() -> String { + "group.\(baseBundleID)" + } + + 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..6d6ddb9a9 --- /dev/null +++ b/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift @@ -0,0 +1,50 @@ +// LoopFollow +// GlucoseLiveActivityAttributes.swift + +// swiftformat:disable indent +#if !targetEnvironment(macCatalyst) + +import ActivityKit +import Foundation + +struct GlucoseLiveActivityAttributes: ActivityAttributes { + 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) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(snapshot, forKey: .snapshot) + try container.encode(seq, forKey: .seq) + try container.encode(reason, forKey: .reason) + try container.encode(producedAt.timeIntervalSince1970, forKey: .producedAt) + } + + private enum CodingKeys: String, CodingKey { + case snapshot, seq, reason, producedAt + } + } + + /// Reserved for future metadata. Keep minimal for stability. + let title: String +} + +#endif diff --git a/LoopFollow/LiveActivity/GlucoseSnapshot.swift b/LoopFollow/LiveActivity/GlucoseSnapshot.swift new file mode 100644 index 000000000..c5f5fffcc --- /dev/null +++ b/LoopFollow/LiveActivity/GlucoseSnapshot.swift @@ -0,0 +1,331 @@ +// 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 + + /// Human-readable display string for the unit (e.g. "mg/dL" or "mmol/L"). + var displayName: String { + switch self { + case .mgdl: return "mg/dL" + case .mmol: return "mmol/L" + } + } + } + + // 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: - Extended InfoType Metrics + + /// Active override name (nil if no active override) + let override: String? + + /// Recommended bolus in units (nil if not available) + let recBolus: Double? + + /// CGM/uploader device battery % (nil if not available) + let battery: Double? + + /// Pump battery % (nil if not available) + let pumpBattery: Double? + + /// Formatted current basal rate string (empty if not available) + let basalRate: String + + /// Pump reservoir in units (nil if >50U or unknown) + let pumpReservoirU: Double? + + /// Autosensitivity ratio, e.g. 0.9 = 90% (nil if not available) + let autosens: Double? + + /// Total daily dose in units (nil if not available) + let tdd: Double? + + /// BG target low in mg/dL (nil if not available) + let targetLowMgdl: Double? + + /// BG target high in mg/dL (nil if not available) + let targetHighMgdl: Double? + + /// Insulin Sensitivity Factor in mg/dL per unit (nil if not available) + let isfMgdlPerU: Double? + + /// Carb ratio in g per unit (nil if not available) + let carbRatio: Double? + + /// Total carbs entered today in grams (nil if not available) + let carbsToday: Double? + + /// Active profile name (nil if not available) + let profileName: String? + + /// Sensor insert time as Unix epoch seconds UTC (0 = not set) + let sageInsertTime: TimeInterval + + /// Cannula insert time as Unix epoch seconds UTC (0 = not set) + let cageInsertTime: TimeInterval + + /// Insulin/pod insert time as Unix epoch seconds UTC (0 = not set) + let iageInsertTime: TimeInterval + + /// Min predicted BG in mg/dL (nil if not available) + let minBgMgdl: Double? + + /// Max predicted BG in mg/dL (nil if not available) + let maxBgMgdl: 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 + + // MARK: - Renewal + + /// True when the Live Activity is within renewalWarning seconds of its renewal deadline. + /// The extension renders a "Tap to update" overlay so the user knows renewal is imminent. + let showRenewalOverlay: Bool + + // MARK: - Init + + init( + glucose: Double, + delta: Double, + trend: Trend, + updatedAt: Date, + iob: Double?, + cob: Double?, + projected: Double?, + override: String? = nil, + recBolus: Double? = nil, + battery: Double? = nil, + pumpBattery: Double? = nil, + basalRate: String = "", + pumpReservoirU: Double? = nil, + autosens: Double? = nil, + tdd: Double? = nil, + targetLowMgdl: Double? = nil, + targetHighMgdl: Double? = nil, + isfMgdlPerU: Double? = nil, + carbRatio: Double? = nil, + carbsToday: Double? = nil, + profileName: String? = nil, + sageInsertTime: TimeInterval = 0, + cageInsertTime: TimeInterval = 0, + iageInsertTime: TimeInterval = 0, + minBgMgdl: Double? = nil, + maxBgMgdl: Double? = nil, + unit: Unit, + isNotLooping: Bool, + showRenewalOverlay: Bool = false, + ) { + self.glucose = glucose + self.delta = delta + self.trend = trend + self.updatedAt = updatedAt + self.iob = iob + self.cob = cob + self.projected = projected + self.override = override + self.recBolus = recBolus + self.battery = battery + self.pumpBattery = pumpBattery + self.basalRate = basalRate + self.pumpReservoirU = pumpReservoirU + self.autosens = autosens + self.tdd = tdd + self.targetLowMgdl = targetLowMgdl + self.targetHighMgdl = targetHighMgdl + self.isfMgdlPerU = isfMgdlPerU + self.carbRatio = carbRatio + self.carbsToday = carbsToday + self.profileName = profileName + self.sageInsertTime = sageInsertTime + self.cageInsertTime = cageInsertTime + self.iageInsertTime = iageInsertTime + self.minBgMgdl = minBgMgdl + self.maxBgMgdl = maxBgMgdl + self.unit = unit + self.isNotLooping = isNotLooping + self.showRenewalOverlay = showRenewalOverlay + } + + // MARK: - Derived Convenience + + /// Age of reading in seconds. + var age: TimeInterval { + Date().timeIntervalSince(updatedAt) + } + + /// Returns a copy of this snapshot with `showRenewalOverlay` set to the given value. + /// All other fields are preserved exactly. Use this instead of manually copying + /// every field when only the overlay flag needs to change. + func withRenewalOverlay(_ value: Bool) -> GlucoseSnapshot { + GlucoseSnapshot( + glucose: glucose, + delta: delta, + trend: trend, + updatedAt: updatedAt, + iob: iob, + cob: cob, + projected: projected, + override: override, + recBolus: recBolus, + battery: battery, + pumpBattery: pumpBattery, + basalRate: basalRate, + pumpReservoirU: pumpReservoirU, + autosens: autosens, + tdd: tdd, + targetLowMgdl: targetLowMgdl, + targetHighMgdl: targetHighMgdl, + isfMgdlPerU: isfMgdlPerU, + carbRatio: carbRatio, + carbsToday: carbsToday, + profileName: profileName, + sageInsertTime: sageInsertTime, + cageInsertTime: cageInsertTime, + iageInsertTime: iageInsertTime, + minBgMgdl: minBgMgdl, + maxBgMgdl: maxBgMgdl, + unit: unit, + isNotLooping: isNotLooping, + showRenewalOverlay: value, + ) + } + + // MARK: - Codable + + 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.encodeIfPresent(override, forKey: .override) + try container.encodeIfPresent(recBolus, forKey: .recBolus) + try container.encodeIfPresent(battery, forKey: .battery) + try container.encodeIfPresent(pumpBattery, forKey: .pumpBattery) + try container.encode(basalRate, forKey: .basalRate) + try container.encodeIfPresent(pumpReservoirU, forKey: .pumpReservoirU) + try container.encodeIfPresent(autosens, forKey: .autosens) + try container.encodeIfPresent(tdd, forKey: .tdd) + try container.encodeIfPresent(targetLowMgdl, forKey: .targetLowMgdl) + try container.encodeIfPresent(targetHighMgdl, forKey: .targetHighMgdl) + try container.encodeIfPresent(isfMgdlPerU, forKey: .isfMgdlPerU) + try container.encodeIfPresent(carbRatio, forKey: .carbRatio) + try container.encodeIfPresent(carbsToday, forKey: .carbsToday) + try container.encodeIfPresent(profileName, forKey: .profileName) + try container.encode(sageInsertTime, forKey: .sageInsertTime) + try container.encode(cageInsertTime, forKey: .cageInsertTime) + try container.encode(iageInsertTime, forKey: .iageInsertTime) + try container.encodeIfPresent(minBgMgdl, forKey: .minBgMgdl) + try container.encodeIfPresent(maxBgMgdl, forKey: .maxBgMgdl) + try container.encode(unit, forKey: .unit) + try container.encode(isNotLooping, forKey: .isNotLooping) + try container.encode(showRenewalOverlay, forKey: .showRenewalOverlay) + } + + 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) + override = try container.decodeIfPresent(String.self, forKey: .override) + recBolus = try container.decodeIfPresent(Double.self, forKey: .recBolus) + battery = try container.decodeIfPresent(Double.self, forKey: .battery) + pumpBattery = try container.decodeIfPresent(Double.self, forKey: .pumpBattery) + basalRate = try container.decodeIfPresent(String.self, forKey: .basalRate) ?? "" + pumpReservoirU = try container.decodeIfPresent(Double.self, forKey: .pumpReservoirU) + autosens = try container.decodeIfPresent(Double.self, forKey: .autosens) + tdd = try container.decodeIfPresent(Double.self, forKey: .tdd) + targetLowMgdl = try container.decodeIfPresent(Double.self, forKey: .targetLowMgdl) + targetHighMgdl = try container.decodeIfPresent(Double.self, forKey: .targetHighMgdl) + isfMgdlPerU = try container.decodeIfPresent(Double.self, forKey: .isfMgdlPerU) + carbRatio = try container.decodeIfPresent(Double.self, forKey: .carbRatio) + carbsToday = try container.decodeIfPresent(Double.self, forKey: .carbsToday) + profileName = try container.decodeIfPresent(String.self, forKey: .profileName) + sageInsertTime = try container.decodeIfPresent(Double.self, forKey: .sageInsertTime) ?? 0 + cageInsertTime = try container.decodeIfPresent(Double.self, forKey: .cageInsertTime) ?? 0 + iageInsertTime = try container.decodeIfPresent(Double.self, forKey: .iageInsertTime) ?? 0 + minBgMgdl = try container.decodeIfPresent(Double.self, forKey: .minBgMgdl) + maxBgMgdl = try container.decodeIfPresent(Double.self, forKey: .maxBgMgdl) + unit = try container.decode(Unit.self, forKey: .unit) + isNotLooping = try container.decodeIfPresent(Bool.self, forKey: .isNotLooping) ?? false + showRenewalOverlay = try container.decodeIfPresent(Bool.self, forKey: .showRenewalOverlay) ?? false + } + + private enum CodingKeys: String, CodingKey { + case glucose, delta, trend, updatedAt + case iob, cob, projected + case override, recBolus, battery, pumpBattery, basalRate, pumpReservoirU + case autosens, tdd, targetLowMgdl, targetHighMgdl, isfMgdlPerU, carbRatio, carbsToday + case profileName, sageInsertTime, cageInsertTime, iageInsertTime, minBgMgdl, maxBgMgdl + case unit, isNotLooping, showRenewalOverlay + } +} + +// 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..40ff076af --- /dev/null +++ b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift @@ -0,0 +1,202 @@ +// LoopFollow +// GlucoseSnapshotBuilder.swift + +import Foundation + +/// Provides the latest glucose-relevant values from LoopFollow's single source of truth. +/// Intentionally provider-agnostic (Nightscout vs Dexcom doesn't matter). +protocol CurrentGlucoseStateProviding { + // MARK: - Core Glucose + + /// Canonical glucose value in mg/dL. + 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. + var updatedAt: Date? { get } + + /// Trend string from LoopFollow (mapped to GlucoseSnapshot.Trend by the builder). + var trendCode: String? { get } + + // MARK: - Secondary Metrics + + var iob: Double? { get } + var cob: Double? { get } + + // MARK: - Extended Metrics + + /// Active override name (nil if no active override). + var override: String? { get } + + /// Recommended bolus in units. + var recBolus: Double? { get } + + /// CGM/uploader device battery %. + var battery: Double? { get } + + /// Pump battery %. + var pumpBattery: Double? { get } + + /// Formatted current basal rate string (empty if not available). + var basalRate: String { get } + + /// Pump reservoir in units (nil if >50U or unknown). + var pumpReservoirU: Double? { get } + + /// Autosensitivity ratio, e.g. 0.9 = 90%. + var autosens: Double? { get } + + /// Total daily dose in units. + var tdd: Double? { get } + + /// BG target low in mg/dL. + var targetLowMgdl: Double? { get } + + /// BG target high in mg/dL. + var targetHighMgdl: Double? { get } + + /// Insulin Sensitivity Factor in mg/dL per unit. + var isfMgdlPerU: Double? { get } + + /// Carb ratio in g per unit. + var carbRatio: Double? { get } + + /// Total carbs entered today in grams. + var carbsToday: Double? { get } + + /// Active profile name. + var profileName: String? { get } + + /// Sensor insert time as Unix epoch seconds UTC (0 = not set). + var sageInsertTime: TimeInterval { get } + + /// Cannula insert time as Unix epoch seconds UTC (0 = not set). + var cageInsertTime: TimeInterval { get } + + /// Insulin/pod insert time as Unix epoch seconds UTC (0 = not set). + var iageInsertTime: TimeInterval { get } + + /// Min predicted BG in mg/dL. + var minBgMgdl: Double? { get } + + /// Max predicted BG in mg/dL. + var maxBgMgdl: Double? { get } + + // MARK: - Loop Status + + /// True when LoopFollow detects the loop has not reported in 15+ minutes. + var isNotLooping: Bool { get } + + // MARK: - Renewal + + /// True when the Live Activity is within renewalWarning seconds of its deadline. + var showRenewalOverlay: Bool { get } +} + +// MARK: - Builder + +/// Pure transformation layer. Reads exclusively from the provider — no direct +/// Storage.shared or Observable.shared access. This makes it testable and reusable +/// across Live Activity, Watch, and CarPlay. +enum GlucoseSnapshotBuilder { + static func build(from provider: CurrentGlucoseStateProviding) -> GlucoseSnapshot? { + guard + let glucoseMgdl = provider.glucoseMgdl, + glucoseMgdl > 0, + let updatedAt = provider.updatedAt + else { + 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) + + if provider.showRenewalOverlay { + LogManager.shared.log(category: .general, message: "[LA] renewal overlay ON") + } + + 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, + override: provider.override, + recBolus: provider.recBolus, + battery: provider.battery, + pumpBattery: provider.pumpBattery, + basalRate: provider.basalRate, + pumpReservoirU: provider.pumpReservoirU, + autosens: provider.autosens, + tdd: provider.tdd, + targetLowMgdl: provider.targetLowMgdl, + targetHighMgdl: provider.targetHighMgdl, + isfMgdlPerU: provider.isfMgdlPerU, + carbRatio: provider.carbRatio, + carbsToday: provider.carbsToday, + profileName: provider.profileName, + sageInsertTime: provider.sageInsertTime, + cageInsertTime: provider.cageInsertTime, + iageInsertTime: provider.iageInsertTime, + minBgMgdl: provider.minBgMgdl, + maxBgMgdl: provider.maxBgMgdl, + unit: preferredUnit, + isNotLooping: provider.isNotLooping, + showRenewalOverlay: provider.showRenewalOverlay, + ) + } + + // MARK: - Trend Mapping + + private static func mapTrend(_ code: String?) -> GlucoseSnapshot.Trend { + guard + let raw = code? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased(), + !raw.isEmpty + else { return .unknown } + + if raw.contains("doubleup") || raw.contains("rapidrise") || raw == "up2" || raw == "upfast" { + return .upFast + } + if raw.contains("fortyfiveup") { + return .upSlight + } + if raw.contains("singleup") || raw == "up" || raw == "up1" || raw == "rising" { + return .up + } + if raw.contains("flat") || raw == "steady" || raw == "none" { + return .flat + } + if raw.contains("doubledown") || raw.contains("rapidfall") || raw == "down2" || raw == "downfast" { + return .downFast + } + if raw.contains("fortyfivedown") { + return .downSlight + } + if raw.contains("singledown") || raw == "down" || raw == "down1" || raw == "falling" { + return .down + } + + return .unknown + } +} diff --git a/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift b/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift new file mode 100644 index 000000000..7951e122a --- /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..6359fe55e --- /dev/null +++ b/LoopFollow/LiveActivity/LAAppGroupSettings.swift @@ -0,0 +1,223 @@ +// LoopFollow +// LAAppGroupSettings.swift + +import Foundation + +// MARK: - Slot option enum + +/// One displayable metric that can occupy a slot in the Live Activity 2×2 grid. +/// +/// - `.none` is the empty/blank state — leaves the slot visually empty. +/// - Optional cases (isOptional == true) may display "—" for Dexcom-only users +/// whose setup does not provide that metric. +/// - All values are read from GlucoseSnapshot at render time inside the widget +/// extension; no additional App Group reads are required per slot. +enum LiveActivitySlotOption: String, CaseIterable, Codable { + // Core glucose + case none + case delta + case projectedBG + case minMax + // Loop metrics + case iob + case cob + case recBolus + case autosens + case tdd + // Pump / device + case basal + case pump + case pumpBattery + case battery + case target + case isf + case carbRatio + // Ages + case sage + case cage + case iage + // Other + case carbsToday + case override + case profile + + /// Human-readable label shown in the slot picker in Settings. + var displayName: String { + switch self { + case .none: "Empty" + case .delta: "Delta" + case .projectedBG: "Projected BG" + case .minMax: "Min/Max" + case .iob: "IOB" + case .cob: "COB" + case .recBolus: "Rec. Bolus" + case .autosens: "Autosens" + case .tdd: "TDD" + case .basal: "Basal" + case .pump: "Pump" + case .pumpBattery: "Pump Battery" + case .battery: "Battery" + case .target: "Target" + case .isf: "ISF" + case .carbRatio: "CR" + case .sage: "SAGE" + case .cage: "CAGE" + case .iage: "IAGE" + case .carbsToday: "Carbs today" + case .override: "Override" + case .profile: "Profile" + } + } + + /// Short label used inside the MetricBlock on the Live Activity card. + var gridLabel: String { + switch self { + case .none: "" + case .delta: "Delta" + case .projectedBG: "Proj" + case .minMax: "Min/Max" + case .iob: "IOB" + case .cob: "COB" + case .recBolus: "Rec." + case .autosens: "Sens" + case .tdd: "TDD" + case .basal: "Basal" + case .pump: "Pump" + case .pumpBattery: "Pump%" + case .battery: "Bat." + case .target: "Target" + case .isf: "ISF" + case .carbRatio: "CR" + case .sage: "SAGE" + case .cage: "CAGE" + case .iage: "IAGE" + case .carbsToday: "Carbs" + case .override: "Ovrd" + case .profile: "Prof" + } + } + + /// True when the value is a glucose measurement and should be followed by + /// the user's preferred unit label (mg/dL or mmol/L) in compact displays. + var isGlucoseUnit: Bool { + switch self { + case .projectedBG, .delta, .minMax, .target, .isf: return true + default: return false + } + } + + /// True when the underlying value may be nil (e.g. Dexcom-only users who have + /// no Loop data). The widget renders "—" in those cases. + var isOptional: Bool { + switch self { + case .none, .delta: false + default: true + } + } +} + +// MARK: - Default slot assignments + +enum LiveActivitySlotDefaults { + /// Top-left slot + static let slot1: LiveActivitySlotOption = .iob + /// Bottom-left slot + static let slot2: LiveActivitySlotOption = .cob + /// Top-right slot + static let slot3: LiveActivitySlotOption = .projectedBG + /// Bottom-right slot — intentionally empty until the user configures it + static let slot4: LiveActivitySlotOption = .none + /// Small widget (CarPlay / Watch Smart Stack) right slot + static let smallWidgetSlot: LiveActivitySlotOption = .projectedBG + + static var all: [LiveActivitySlotOption] { + [slot1, slot2, slot3, slot4] + } +} + +// MARK: - App Group settings + +/// 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" + static let slots = "la.slots" + static let smallWidgetSlot = "la.smallWidgetSlot" + static let displayName = "la.displayName" + static let showDisplayName = "la.showDisplayName" + } + + private static var defaults: UserDefaults? { + UserDefaults(suiteName: AppGroupID.current()) + } + + // MARK: - Thresholds (Write) + + static func setThresholds(lowMgdl: Double, highMgdl: Double) { + defaults?.set(lowMgdl, forKey: Keys.lowLineMgdl) + defaults?.set(highMgdl, forKey: Keys.highLineMgdl) + } + + // MARK: - Thresholds (Read) + + 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) + } + + // MARK: - Slot configuration (Write) + + /// Persists a 4-slot configuration to the App Group container. + /// - Parameter slots: Array of exactly 4 `LiveActivitySlotOption` values; + /// extra elements are ignored, missing elements are filled with `.none`. + static func setSlots(_ slots: [LiveActivitySlotOption]) { + let raw = slots.prefix(4).map(\.rawValue) + defaults?.set(raw, forKey: Keys.slots) + } + + // MARK: - Slot configuration (Read) + + /// Returns the current 4-slot configuration, falling back to defaults + /// if no configuration has been saved yet. + static func slots() -> [LiveActivitySlotOption] { + guard let raw = defaults?.stringArray(forKey: Keys.slots), raw.count == 4 else { + return LiveActivitySlotDefaults.all + } + return raw.map { LiveActivitySlotOption(rawValue: $0) ?? .none } + } + + // MARK: - Small widget slot (Write) + + static func setSmallWidgetSlot(_ slot: LiveActivitySlotOption) { + defaults?.set(slot.rawValue, forKey: Keys.smallWidgetSlot) + } + + // MARK: - Small widget slot (Read) + + static func smallWidgetSlot() -> LiveActivitySlotOption { + guard let raw = defaults?.string(forKey: Keys.smallWidgetSlot) else { + return LiveActivitySlotDefaults.smallWidgetSlot + } + return LiveActivitySlotOption(rawValue: raw) ?? LiveActivitySlotDefaults.smallWidgetSlot + } + + // MARK: - Display Name + + static func setDisplayName(_ name: String, show: Bool) { + defaults?.set(name, forKey: Keys.displayName) + defaults?.set(show, forKey: Keys.showDisplayName) + } + + static func displayName() -> String { + defaults?.string(forKey: Keys.displayName) ?? "LoopFollow" + } + + static func showDisplayName() -> Bool { + defaults?.bool(forKey: Keys.showDisplayName) ?? false + } +} diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift new file mode 100644 index 000000000..7b32adedc --- /dev/null +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -0,0 +1,714 @@ +// LoopFollow +// LiveActivityManager.swift + +// swiftformat:disable indent +#if !targetEnvironment(macCatalyst) + +@preconcurrency import ActivityKit +import Foundation +import os +import UIKit +import UserNotifications + +// Live Activity manager for LoopFollow. + +final class LiveActivityManager { + static let shared = LiveActivityManager() + private init() { + NotificationCenter.default.addObserver( + self, + selector: #selector(handleForeground), + name: UIApplication.willEnterForegroundNotification, + object: nil, + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleDidBecomeActive), + name: UIApplication.didBecomeActiveNotification, + object: nil, + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleWillResignActive), + name: UIApplication.willResignActiveNotification, + object: nil, + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleBackgroundAudioFailed), + name: .backgroundAudioFailed, + object: nil, + ) + } + + /// Fires before the app loses focus (lock screen, home button, etc.). + /// Cancels any pending debounced refresh and pushes the latest snapshot + /// directly to the Live Activity while the app is still foreground-active, + /// ensuring the LA is up to date the moment the lock screen appears. + @objc private func handleWillResignActive() { + guard Storage.shared.laEnabled.value, let activity = current else { return } + + refreshWorkItem?.cancel() + refreshWorkItem = nil + + let provider = StorageCurrentGlucoseStateProvider() + guard let snapshot = GlucoseSnapshotBuilder.build(from: provider) else { return } + + LAAppGroupSettings.setThresholds( + lowMgdl: Storage.shared.lowLine.value, + highMgdl: Storage.shared.highLine.value, + ) + GlucoseSnapshotStore.shared.save(snapshot) + + seq += 1 + let nextSeq = seq + let state = GlucoseLiveActivityAttributes.ContentState( + snapshot: snapshot, + seq: nextSeq, + reason: "resign-active", + producedAt: Date(), + ) + let content = ActivityContent( + state: state, + staleDate: Date(timeIntervalSince1970: Storage.shared.laRenewBy.value), + relevanceScore: 100.0, + ) + + Task { + // Direct ActivityKit update — app is still active at this point. + await activity.update(content) + LogManager.shared.log(category: .general, message: "[LA] resign-active flush sent seq=\(nextSeq)", isDebug: true) + // Also send APNs so the extension receives the latest token-based update. + if let token = pushToken { + await APNSClient.shared.sendLiveActivityUpdate(pushToken: token, state: state) + } + } + } + + @objc private func handleDidBecomeActive() { + guard Storage.shared.laEnabled.value else { return } + if skipNextDidBecomeActive { + LogManager.shared.log(category: .general, message: "[LA] didBecomeActive: skipped (handleForeground owns restart)", isDebug: true) + skipNextDidBecomeActive = false + return + } + LogManager.shared.log(category: .general, message: "[LA] didBecomeActive: calling startFromCurrentState, dismissedByUser=\(dismissedByUser)", isDebug: true) + Task { @MainActor in + self.startFromCurrentState() + } + } + + @objc private func handleForeground() { + guard Storage.shared.laEnabled.value else { return } + + let renewalFailed = Storage.shared.laRenewalFailed.value + let renewBy = Storage.shared.laRenewBy.value + let now = Date().timeIntervalSince1970 + let overlayIsShowing = renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning + + LogManager.shared.log(category: .general, message: "[LA] foreground: renewalFailed=\(renewalFailed), overlayShowing=\(overlayIsShowing), current=\(current?.id ?? "nil"), dismissedByUser=\(dismissedByUser), renewBy=\(renewBy), now=\(now)") + guard renewalFailed || overlayIsShowing else { + LogManager.shared.log(category: .general, message: "[LA] foreground: no action needed (not in renewal window)") + return + } + + // Overlay is showing or renewal previously failed — end the stale LA and start a fresh one. + // We cannot call startIfNeeded() here: it finds the existing activity in + // Activity.activities and reuses it rather than replacing it. + LogManager.shared.log(category: .general, message: "[LA] ending stale LA and restarting (renewalFailed=\(renewalFailed), overlayShowing=\(overlayIsShowing))") + // Suppress the handleDidBecomeActive() call that always fires after willEnterForeground. + // Without this, the two methods race: didBecomeActive binds to the old (dying) activity + // and observes its push token, while handleForeground's async end+restart creates a new + // activity — leaving pushToken nil when the new activity tries to start. + skipNextDidBecomeActive = true + // Clear state synchronously so any snapshot built between now and when the + // new LA is started computes showRenewalOverlay = false. + Storage.shared.laRenewBy.value = 0 + Storage.shared.laRenewalFailed.value = false + cancelRenewalFailedNotification() + + guard let activity = current else { + // LA was already gone (ended by iOS or user). If the user explicitly swiped, + // laRenewBy was cleared to 0 at that point, so overlayIsShowing would be false + // and we would never reach here. Reaching here means iOS ended it while the + // renewal window was open — restart is correct. + LogManager.shared.log(category: .general, message: "[LA] foreground restart: current=nil (iOS-ended during renewal window), dismissedByUser=\(dismissedByUser), restarting") + startFromCurrentState() + return + } + + current = nil + updateTask?.cancel() + updateTask = nil + tokenObservationTask?.cancel() + tokenObservationTask = nil + stateObserverTask?.cancel() + stateObserverTask = nil + pushToken = nil + + // Signal the state observer that the upcoming .dismissed event is our own + // end() call, not a user swipe. This must be set synchronously before end() + // is awaited so the observer sees it regardless of MainActor scheduling order. + endingForRestart = true + + Task { + // Await end so the activity is removed from Activity.activities before + // startIfNeeded() runs — otherwise it hits the reuse path and skips + // writing a new laRenewBy deadline. + await activity.end(nil, dismissalPolicy: .immediate) + await MainActor.run { + self.endingForRestart = false + self.dismissedByUser = false + // startFromCurrentState rebuilds the snapshot (showRenewalOverlay = false + // since laRenewBy is 0), saves it to the store, then calls startIfNeeded() + // which finds no existing activity and requests a fresh LA with a new deadline. + self.startFromCurrentState() + LogManager.shared.log(category: .general, message: "[LA] Live Activity restarted after foreground retry") + } + } + } + + @objc private func handleBackgroundAudioFailed() { + guard Storage.shared.laEnabled.value, current != nil else { return } + // The background audio session has permanently failed — the app will lose its + // background keep-alive. Immediately push the renewal overlay so the user sees + // "Tap to update" on the lock screen and knows to foreground the app. + LogManager.shared.log(category: .general, message: "[LA] background audio failed — forcing renewal overlay") + Storage.shared.laRenewBy.value = Date().timeIntervalSince1970 + refreshFromCurrentState(reason: "audio-session-failed") + } + + static let renewalThreshold: TimeInterval = 7.5 * 3600 + static let renewalWarning: TimeInterval = 30 * 60 + + 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? + /// Set when the user manually swipes away the LA. Blocks auto-restart until + /// an explicit user action (Restart button, App Intent) clears it. + /// In-memory only — resets to false on app relaunch, so a kill + relaunch + /// starts fresh as expected. + private var dismissedByUser = false + /// Set to true immediately before we call activity.end() as part of a planned restart. + /// Cleared after the restart completes. The state observer checks this flag so that + /// a .dismissed delivery triggered by our own end() call is never misclassified as a + /// user swipe — regardless of the order in which the MainActor executes the two writes. + private var endingForRestart = false + /// Set by handleForeground() when it takes ownership of the restart sequence. + /// Prevents handleDidBecomeActive() from racing with an in-flight end+restart. + private var skipNextDidBecomeActive = false + + // 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 { + // Before reusing, check whether this activity needs a restart. This covers cold + // starts (app was killed while the overlay was showing — willEnterForeground is + // never sent, so handleForeground never runs) and any other path that lands here + // without first going through handleForeground. + let renewBy = Storage.shared.laRenewBy.value + let now = Date().timeIntervalSince1970 + let staleDatePassed = existing.content.staleDate.map { $0 <= Date() } ?? false + let inRenewalWindow = renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning + let needsRestart = Storage.shared.laRenewalFailed.value || inRenewalWindow || staleDatePassed + + if needsRestart { + LogManager.shared.log(category: .general, message: "[LA] existing activity is stale on startIfNeeded — ending and restarting (staleDatePassed=\(staleDatePassed), inRenewalWindow=\(inRenewalWindow))") + Storage.shared.laRenewBy.value = 0 + Storage.shared.laRenewalFailed.value = false + cancelRenewalFailedNotification() + Task { + await existing.end(nil, dismissalPolicy: .immediate) + await MainActor.run { self.startIfNeeded() } + } + return + } + + bind(to: existing, logReason: "reuse") + Storage.shared.laRenewalFailed.value = false + return + } + + do { + let attributes = GlucoseLiveActivityAttributes(title: "LoopFollow") + + // Prefer a freshly built snapshot so all extended fields are populated. + // Fall back to the persisted store (covers cold-start with real data), + // then to a zero seed (true first-ever launch with no data yet). + let provider = StorageCurrentGlucoseStateProvider() + let seedSnapshot = GlucoseSnapshotBuilder.build(from: provider) + ?? 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 renewDeadline = Date().addingTimeInterval(LiveActivityManager.renewalThreshold) + let content = ActivityContent(state: initialState, staleDate: renewDeadline) + let activity = try Activity.request(attributes: attributes, content: content, pushType: .token) + + bind(to: activity, logReason: "start-new") + Storage.shared.laRenewBy.value = renewDeadline.timeIntervalSince1970 + Storage.shared.laRenewalFailed.value = false + 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)") + } + } + + /// Called from applicationWillTerminate. Ends the LA synchronously (blocking + /// up to 3 s) so it clears from the lock screen before the process exits. + /// Does not clear laEnabled — the user's preference is preserved for relaunch. + func endOnTerminate() { + guard let activity = current else { return } + current = nil + Storage.shared.laRenewBy.value = 0 + let semaphore = DispatchSemaphore(value: 0) + Task.detached { + await activity.end(nil, dismissalPolicy: .immediate) + semaphore.signal() + } + _ = semaphore.wait(timeout: .now() + 3) + LogManager.shared.log(category: .general, message: "[LA] ended on app terminate") + } + + 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 + Storage.shared.laRenewBy.value = 0 + } + } + } + + /// Ends all running Live Activities and starts a fresh one from the current state. + /// Intended for the "Restart Live Activity" button and the AppIntent. + @MainActor + func forceRestart() { + guard Storage.shared.laEnabled.value else { return } + LogManager.shared.log(category: .general, message: "[LA] forceRestart called") + dismissedByUser = false + Storage.shared.laRenewBy.value = 0 + Storage.shared.laRenewalFailed.value = false + cancelRenewalFailedNotification() + current = nil + updateTask?.cancel(); updateTask = nil + tokenObservationTask?.cancel(); tokenObservationTask = nil + stateObserverTask?.cancel(); stateObserverTask = nil + pushToken = nil + Task { + for activity in Activity.activities { + await activity.end(nil, dismissalPolicy: .immediate) + } + await MainActor.run { + self.startFromCurrentState() + LogManager.shared.log(category: .general, message: "[LA] forceRestart: Live Activity restarted") + } + } + } + + func startFromCurrentState() { + guard Storage.shared.laEnabled.value, !dismissedByUser else { return } + endOrphanedActivities() + let provider = StorageCurrentGlucoseStateProvider() + if let snapshot = GlucoseSnapshotBuilder.build(from: provider) { + LAAppGroupSettings.setThresholds( + lowMgdl: Storage.shared.lowLine.value, + highMgdl: Storage.shared.highLine.value, + ) + LAAppGroupSettings.setDisplayName( + Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? "LoopFollow", + show: Storage.shared.showDisplayName.value + ) + GlucoseSnapshotStore.shared.save(snapshot) + } + startIfNeeded() + } + + func refreshFromCurrentState(reason: String) { + guard Storage.shared.laEnabled.value, !dismissedByUser else { return } + refreshWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + self?.performRefresh(reason: reason) + } + refreshWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 20.0, execute: workItem) + } + + // MARK: - Renewal + + /// Requests a fresh Live Activity to replace the current one when the renewal + /// deadline has passed, working around Apple's 8-hour maximum LA lifetime. + /// The new LA is requested FIRST — the old one is only ended if that succeeds, + /// so the user keeps live data if Activity.request() throws. + /// Returns true if renewal was performed (caller should return early). + private func renewIfNeeded(snapshot: GlucoseSnapshot) -> Bool { + guard let oldActivity = current else { return false } + + let renewBy = Storage.shared.laRenewBy.value + guard renewBy > 0, Date().timeIntervalSince1970 >= renewBy else { return false } + + let overdueBy = Date().timeIntervalSince1970 - renewBy + LogManager.shared.log(category: .general, message: "[LA] renewal deadline passed by \(Int(overdueBy))s, requesting new LA") + + let renewDeadline = Date().addingTimeInterval(LiveActivityManager.renewalThreshold) + let attributes = GlucoseLiveActivityAttributes(title: "LoopFollow") + + // Build the fresh snapshot with showRenewalOverlay: false — the new LA has a + // fresh deadline so no overlay is needed from the first frame. We pass the + // deadline as staleDate to ActivityContent below, not to Storage yet; Storage + // is only updated after Activity.request succeeds so a crash between the two + // can't leave the deadline permanently stuck in the future. + let freshSnapshot = snapshot.withRenewalOverlay(false) + + let state = GlucoseLiveActivityAttributes.ContentState( + snapshot: freshSnapshot, + seq: seq, + reason: "renew", + producedAt: Date(), + ) + let content = ActivityContent(state: state, staleDate: renewDeadline) + + do { + let newActivity = try Activity.request(attributes: attributes, content: content, pushType: .token) + + Task { + await oldActivity.end(nil, dismissalPolicy: .immediate) + } + + updateTask?.cancel() + updateTask = nil + tokenObservationTask?.cancel() + tokenObservationTask = nil + stateObserverTask?.cancel() + stateObserverTask = nil + pushToken = nil + + // Write deadline only on success — avoids a stuck future deadline if we crash + // between the write and the Activity.request call. + Storage.shared.laRenewBy.value = renewDeadline.timeIntervalSince1970 + bind(to: newActivity, logReason: "renew") + Storage.shared.laRenewalFailed.value = false + cancelRenewalFailedNotification() + GlucoseSnapshotStore.shared.save(freshSnapshot) + LogManager.shared.log(category: .general, message: "[LA] Live Activity renewed successfully id=\(newActivity.id)") + return true + } catch { + // Renewal failed — deadline was never written, so no rollback needed. + let isFirstFailure = !Storage.shared.laRenewalFailed.value + Storage.shared.laRenewalFailed.value = true + LogManager.shared.log(category: .general, message: "[LA] renewal failed, keeping existing LA: \(error)") + if isFirstFailure { + scheduleRenewalFailedNotification() + } + return false + } + } + + 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) + + // Check if the Live Activity is approaching Apple's 8-hour limit and renew if so. + if renewIfNeeded(snapshot: snapshot) { return } + + if snapshot.showRenewalOverlay { + LogManager.shared.log(category: .general, message: "[LA] sending update with renewal overlay visible") + } + + 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 current?.id == activityID { current = nil } + return + } + + let content = ActivityContent( + state: state, + staleDate: Date(timeIntervalSince1970: Storage.shared.laRenewBy.value), + 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 current?.id == activityID else { + LogManager.shared.log(category: .general, message: "Live Activity update — activity ID mismatch, discarding") + return + } + + lastUpdateTime = Date() + LogManager.shared.log(category: .general, message: "[LA] updated id=\(activityID) seq=\(nextSeq) reason=\(reason)", isDebug: true) + + if let token = pushToken { + await APNSClient.shared.sendLiveActivityUpdate(pushToken: token, state: state) + } + } + } + + // MARK: - Binding / Lifecycle + + /// Ends any Live Activities of this type that are not the one currently tracked. + /// Called on app launch to clean up cards left behind by a previous crash. + private func endOrphanedActivities() { + for activity in Activity.activities { + guard activity.id != current?.id else { continue } + let orphanID = activity.id + Task { + await activity.end(nil, dismissalPolicy: .immediate) + LogManager.shared.log(category: .general, message: "Ended orphaned Live Activity id=\(orphanID)") + } + } + } + + 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() + } + + // MARK: - Renewal Notifications + + private static let renewalNotificationID = "\(Bundle.main.bundleIdentifier ?? "loopfollow").la.renewal.failed" + + private func scheduleRenewalFailedNotification() { + let content = UNMutableNotificationContent() + content.title = "Live Activity Expiring" + content.body = "Live Activity will expire soon. Open LoopFollow to restart." + content.sound = .default + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false) + let request = UNNotificationRequest( + identifier: LiveActivityManager.renewalNotificationID, + content: content, + trigger: trigger, + ) + UNUserNotificationCenter.current().add(request) { error in + if let error { + LogManager.shared.log(category: .general, message: "[LA] failed to schedule renewal notification: \(error)") + } + } + LogManager.shared.log(category: .general, message: "[LA] renewal failed notification scheduled") + } + + private func cancelRenewalFailedNotification() { + let id = LiveActivityManager.renewalNotificationID + UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [id]) + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [id]) + } + + 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 + // Do NOT clear laRenewBy here. Preserving it means handleForeground() + // can detect the renewal window on the next foreground event and restart + // automatically — whether the LA ended normally (.ended) or was + // system-dismissed (.dismissed). laRenewBy is only set to 0 when: + // • the user explicitly swipes (below) — renewal intent cancelled + // • a new LA starts (startIfNeeded writes the new deadline) + // • handleForeground() clears it synchronously before restarting + // • the user disables LA or calls forceRestart + LogManager.shared.log(category: .general, message: "[LA] activity cleared id=\(activity.id) state=\(state)", isDebug: true) + } + if state == .dismissed { + // Three possible sources of .dismissed — only the third blocks restart: + // + // (a) endingForRestart: our own end() during a planned restart. + // Must be checked first: handleForeground() clears laRenewalFailed + // and laRenewBy synchronously before calling end(), so those flags + // would read as "no problem" even though we initiated the dismissal. + // + // (b) iOS system force-dismiss: either laRenewalFailed is set (our 8-hour + // renewal logic marked it) or the renewal deadline has already passed + // (laRenewBy > 0 && now >= laRenewBy). In both cases iOS acted, not + // the user. laRenewBy is preserved so handleForeground() restarts on + // the next foreground. + // + // (c) User decision: the user explicitly swiped the LA away. Block + // auto-restart until forceRestart() is called. Clear laRenewBy so + // handleForeground() does NOT re-enter the renewal path on the next + // foreground — the renewal intent is cancelled by the user's choice. + let now = Date().timeIntervalSince1970 + let renewBy = Storage.shared.laRenewBy.value + let renewalFailed = Storage.shared.laRenewalFailed.value + let pastDeadline = renewBy > 0 && now >= renewBy + LogManager.shared.log(category: .general, message: "[LA] .dismissed: endingForRestart=\(endingForRestart), renewalFailed=\(renewalFailed), pastDeadline=\(pastDeadline), renewBy=\(renewBy), now=\(now)") + if endingForRestart { + // (a) Our own restart — do nothing, Task handles the rest. + LogManager.shared.log(category: .general, message: "[LA] dismissed by self (endingForRestart) — restart in-flight, no action") + } else if renewalFailed || pastDeadline { + // (b) iOS system force-dismiss — allow auto-restart on next foreground. + LogManager.shared.log(category: .general, message: "[LA] dismissed by iOS (renewalFailed=\(renewalFailed), pastDeadline=\(pastDeadline)) — auto-restart on next foreground") + } else { + // (c) User decision — cancel renewal intent, block auto-restart. + dismissedByUser = true + Storage.shared.laRenewBy.value = 0 + LogManager.shared.log(category: .general, message: "[LA] dismissed by USER (renewBy=\(renewBy), now=\(now)) — laRenewBy cleared, auto-restart BLOCKED until forceRestart") + } + } + } + } + } + } +} + +#endif + +extension Notification.Name { + /// Posted when the user taps the Live Activity or Dynamic Island. + /// Observers navigate to the Home or Snoozer tab as appropriate. + static let liveActivityDidForeground = Notification.Name("liveActivityDidForeground") +} diff --git a/LoopFollow/LiveActivity/PreferredGlucoseUnit.swift b/LoopFollow/LiveActivity/PreferredGlucoseUnit.swift new file mode 100644 index 000000000..3ce52f948 --- /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: + .mmol + default: + .mgdl + } + } +} diff --git a/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift b/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift new file mode 100644 index 000000000..00740e10e --- /dev/null +++ b/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift @@ -0,0 +1,39 @@ +// LoopFollow +// RestartLiveActivityIntent.swift + +import AppIntents +import UIKit + +struct RestartLiveActivityIntent: AppIntent { + static var title: LocalizedStringResource = "Restart Live Activity" + static var description = IntentDescription("Starts or restarts the LoopFollow Live Activity.") + + func perform() async throws -> some IntentResult & ProvidesDialog { + Storage.shared.laEnabled.value = true + + let keyId = Storage.shared.lfKeyId.value + let apnsKey = Storage.shared.lfApnsKey.value + + if keyId.isEmpty || apnsKey.isEmpty { + if let url = URL(string: "\(AppGroupID.urlScheme)://settings/live-activity") { + await MainActor.run { UIApplication.shared.open(url) } + } + return .result(dialog: "Please enter your APNs credentials in LoopFollow settings to use the Live Activity.") + } + + await MainActor.run { LiveActivityManager.shared.forceRestart() } + + return .result(dialog: "Live Activity restarted.") + } +} + +struct LoopFollowAppShortcuts: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: RestartLiveActivityIntent(), + phrases: ["Restart Live Activity in \(.applicationName)"], + shortTitle: "Restart Live Activity", + systemImageName: "dot.radiowaves.left.and.right", + ) + } +} diff --git a/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift b/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift new file mode 100644 index 000000000..38bde98d0 --- /dev/null +++ b/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift @@ -0,0 +1,138 @@ +// LoopFollow +// StorageCurrentGlucoseStateProvider.swift + +import Foundation + +/// Reads the latest glucose state from LoopFollow's Storage and Observable layers. +/// This is the only file in the pipeline that is allowed to touch Storage.shared +/// or Observable.shared — all other layers read exclusively from this provider. +struct StorageCurrentGlucoseStateProvider: CurrentGlucoseStateProviding { + // MARK: - Core Glucose + + 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 + } + + // MARK: - Secondary Metrics + + var iob: Double? { + Storage.shared.lastIOB.value + } + + var cob: Double? { + Storage.shared.lastCOB.value + } + + // MARK: - Extended Metrics + + var override: String? { + Observable.shared.override.value + } + + var recBolus: Double? { + Observable.shared.deviceRecBolus.value + } + + var battery: Double? { + Observable.shared.deviceBatteryLevel.value + } + + var pumpBattery: Double? { + Observable.shared.pumpBatteryLevel.value + } + + var basalRate: String { + Storage.shared.lastBasal.value + } + + var pumpReservoirU: Double? { + Storage.shared.lastPumpReservoirU.value + } + + var autosens: Double? { + Storage.shared.lastAutosens.value + } + + var tdd: Double? { + Storage.shared.lastTdd.value + } + + var targetLowMgdl: Double? { + Storage.shared.lastTargetLowMgdl.value + } + + var targetHighMgdl: Double? { + Storage.shared.lastTargetHighMgdl.value + } + + var isfMgdlPerU: Double? { + Storage.shared.lastIsfMgdlPerU.value + } + + var carbRatio: Double? { + Storage.shared.lastCarbRatio.value + } + + var carbsToday: Double? { + Storage.shared.lastCarbsToday.value + } + + var profileName: String? { + let raw = Storage.shared.lastProfileName.value + return raw.isEmpty ? nil : raw + } + + var sageInsertTime: TimeInterval { + Storage.shared.sageInsertTime.value + } + + var cageInsertTime: TimeInterval { + Storage.shared.cageInsertTime.value + } + + var iageInsertTime: TimeInterval { + Storage.shared.iageInsertTime.value + } + + var minBgMgdl: Double? { + Storage.shared.lastMinBgMgdl.value + } + + var maxBgMgdl: Double? { + Storage.shared.lastMaxBgMgdl.value + } + + // MARK: - Loop Status + + var isNotLooping: Bool { + let lastLoopTime = Storage.shared.lastLoopTime.value + guard lastLoopTime > 0, !Storage.shared.url.value.isEmpty else { return false } + return Date().timeIntervalSince1970 - lastLoopTime >= 15 * 60 + } + + // MARK: - Renewal + + var showRenewalOverlay: Bool { + let renewBy = Storage.shared.laRenewBy.value + let now = Date().timeIntervalSince1970 + return renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning + } +} diff --git a/LoopFollow/LiveActivitySettingsView.swift b/LoopFollow/LiveActivitySettingsView.swift new file mode 100644 index 000000000..2264881bb --- /dev/null +++ b/LoopFollow/LiveActivitySettingsView.swift @@ -0,0 +1,89 @@ +// LoopFollow +// LiveActivitySettingsView.swift + +import SwiftUI + +struct LiveActivitySettingsView: View { + @State private var laEnabled: Bool = Storage.shared.laEnabled.value + @State private var restartConfirmed = false + @State private var slots: [LiveActivitySlotOption] = LAAppGroupSettings.slots() + @State private var smallWidgetSlot: LiveActivitySlotOption = LAAppGroupSettings.smallWidgetSlot() + + private let slotLabels = ["Top left", "Top right", "Bottom left", "Bottom right"] + + var body: some View { + Form { + Section(header: Text("Live Activity")) { + Toggle("Enable Live Activity", isOn: $laEnabled) + } + + if laEnabled { + Section { + Button(restartConfirmed ? "Live Activity Restarted" : "Restart Live Activity") { + LiveActivityManager.shared.forceRestart() + restartConfirmed = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + restartConfirmed = false + } + } + .disabled(restartConfirmed) + } + } + + Section(header: Text("Grid Slots - Live Activity")) { + ForEach(0 ..< 4, id: \.self) { index in + Picker(slotLabels[index], selection: Binding( + get: { slots[index] }, + set: { selectSlot($0, at: index) } + )) { + ForEach(LiveActivitySlotOption.allCases, id: \.self) { option in + Text(option.displayName).tag(option) + } + } + } + } + + Section(header: Text("Grid Slot - CarPlay / Watch")) { + Picker("Right slot", selection: Binding( + get: { smallWidgetSlot }, + set: { newValue in + smallWidgetSlot = newValue + LAAppGroupSettings.setSmallWidgetSlot(newValue) + LiveActivityManager.shared.refreshFromCurrentState(reason: "small widget slot changed") + } + )) { + ForEach(LiveActivitySlotOption.allCases, id: \.self) { option in + Text(option.displayName).tag(option) + } + } + } + } + .onReceive(Storage.shared.laEnabled.$value) { newValue in + if newValue != laEnabled { laEnabled = newValue } + } + .onChange(of: laEnabled) { newValue in + Storage.shared.laEnabled.value = newValue + if newValue { + LiveActivityManager.shared.forceRestart() + } else { + LiveActivityManager.shared.end(dismissalPolicy: .immediate) + } + } + .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) + .navigationTitle("Live Activity") + .navigationBarTitleDisplayMode(.inline) + } + + /// Selects an option for the given slot index, enforcing uniqueness: + /// if the chosen option is already in another slot, that slot is cleared to `.none`. + private func selectSlot(_ option: LiveActivitySlotOption, at index: Int) { + if option != .none { + for i in 0 ..< slots.count where i != index && slots[i] == option { + slots[i] = .none + } + } + slots[index] = option + LAAppGroupSettings.setSlots(slots) + LiveActivityManager.shared.refreshFromCurrentState(reason: "slot config changed") + } +} 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..feca46e0d 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) @@ -429,6 +429,7 @@ class LoopAPNSService { LogManager.shared.log(category: .apns, message: "APNS error 400: \(responseBodyMessage) - Check device token and environment settings") completion(false, errorMessage) case 403: + JWTManager.shared.invalidateCache() let errorMessage = "Authentication error. Check your certificate or authentication token. \(responseBodyMessage)" LogManager.shared.log(category: .apns, message: "APNS error 403: \(responseBodyMessage) - Check APNS key permissions for bundle ID") completion(false, errorMessage) @@ -699,11 +700,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 +756,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..04c9977c2 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 ) } @@ -294,6 +295,7 @@ class PushNotificationManager { case 400: completion(false, "Bad request. The request was invalid or malformed. \(responseBodyMessage)") case 403: + JWTManager.shared.invalidateCache() completion(false, "Authentication error. Check your certificate or authentication token. \(responseBodyMessage)") case 404: completion(false, "Invalid request: The :path value was incorrect. \(responseBodyMessage)") diff --git a/LoopFollow/Remote/TRC/SecureMessenger.swift b/LoopFollow/Remote/TRC/SecureMessenger.swift index c159a94ea..8eba7fc77 100644 --- a/LoopFollow/Remote/TRC/SecureMessenger.swift +++ b/LoopFollow/Remote/TRC/SecureMessenger.swift @@ -1,38 +1,28 @@ // LoopFollow // SecureMessenger.swift -import CryptoSwift +import CryptoKit import Foundation -import Security struct SecureMessenger { - private let sharedKey: [UInt8] + private let encryptionKey: SymmetricKey init?(sharedSecret: String) { guard let secretData = sharedSecret.data(using: .utf8) else { return nil } - sharedKey = Array(secretData.sha256()) - } - - private func generateSecureRandomBytes(count: Int) -> [UInt8]? { - var bytes = [UInt8](repeating: 0, count: count) - let status = SecRandomCopyBytes(kSecRandomDefault, count, &bytes) - return status == errSecSuccess ? bytes : nil + let hashed = SHA256.hash(data: secretData) + encryptionKey = SymmetricKey(data: hashed) } func encrypt(_ object: T) throws -> String { let dataToEncrypt = try JSONEncoder().encode(object) - guard let nonce = generateSecureRandomBytes(count: 12) else { - throw NSError(domain: "SecureMessenger", code: 500, userInfo: [NSLocalizedDescriptionKey: "Failed to generate secure random nonce."]) - } + let nonce = AES.GCM.Nonce() + let sealedBox = try AES.GCM.seal(dataToEncrypt, using: encryptionKey, nonce: nonce) - let gcm = GCM(iv: nonce, mode: .combined) - let aes = try AES(key: sharedKey, blockMode: gcm, padding: .noPadding) - let encryptedBytes = try aes.encrypt(Array(dataToEncrypt)) - let finalData = Data(nonce + encryptedBytes) + let combinedData = Data(nonce) + sealedBox.ciphertext + sealedBox.tag - return finalData.base64EncodedString() + return combinedData.base64EncodedString() } } 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/LiveActivitySettingsView.swift b/LoopFollow/Settings/LiveActivitySettingsView.swift new file mode 100644 index 000000000..bfe39b3ee --- /dev/null +++ b/LoopFollow/Settings/LiveActivitySettingsView.swift @@ -0,0 +1,42 @@ +// LoopFollow +// LiveActivitySettingsView.swift + +import SwiftUI + +struct LiveActivitySettingsView: View { + @State private var laEnabled: Bool = Storage.shared.laEnabled.value + @State private var restartConfirmed = false + + var body: some View { + Form { + Section(header: Text("Live Activity")) { + Toggle("Enable Live Activity", isOn: $laEnabled) + } + + if laEnabled { + Section { + Button(restartConfirmed ? "Live Activity Restarted" : "Restart Live Activity") { + LiveActivityManager.shared.forceRestart() + restartConfirmed = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + restartConfirmed = false + } + } + .disabled(restartConfirmed) + } + } + } + .onReceive(Storage.shared.laEnabled.$value) { newValue in + if newValue != laEnabled { laEnabled = newValue } + } + .onChange(of: laEnabled) { newValue in + Storage.shared.laEnabled.value = newValue + if !newValue { + LiveActivityManager.shared.end(dismissalPolicy: .immediate) + } + } + .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) + .navigationTitle("Live Activity") + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/LoopFollow/Settings/SettingsMenuView.swift b/LoopFollow/Settings/SettingsMenuView.swift index b84f70a92..80ae07f16 100644 --- a/LoopFollow/Settings/SettingsMenuView.swift +++ b/LoopFollow/Settings/SettingsMenuView.swift @@ -65,6 +65,18 @@ struct SettingsMenuView: View { settingsPath.value.append(Sheet.importExport) } + NavigationRow(title: "APN", + icon: "bell.and.waves.left.and.right") + { + settingsPath.value.append(Sheet.apn) + } + + NavigationRow(title: "Live Activity", + icon: "dot.radiowaves.left.and.right") + { + settingsPath.value.append(Sheet.liveActivity) + } + if !nightscoutURL.value.isEmpty { NavigationRow(title: "Remote", icon: "antenna.radiowaves.left.and.right") @@ -158,6 +170,8 @@ private enum Sheet: Hashable, Identifiable { case tabSettings case infoDisplay case alarmSettings + case apn + case liveActivity case remote case importExport case calendar, contact @@ -177,6 +191,8 @@ private enum Sheet: Hashable, Identifiable { case .tabSettings: TabCustomizationModal() case .infoDisplay: InfoDisplaySettingsView(viewModel: .init()) case .alarmSettings: AlarmSettingsView() + case .apn: APNSettingsView() + case .liveActivity: LiveActivitySettingsView() case .remote: RemoteSettingsView(viewModel: .init()) case .importExport: ImportExportSettingsView() case .calendar: CalendarSettingsView() diff --git a/LoopFollow/Storage/Framework/StorageValue.swift b/LoopFollow/Storage/Framework/StorageValue.swift index f27f49b17..86faa34da 100644 --- a/LoopFollow/Storage/Framework/StorageValue.swift +++ b/LoopFollow/Storage/Framework/StorageValue.swift @@ -40,4 +40,16 @@ class StorageValue: ObservableObject { func remove() { StorageValue.defaults.removeObject(forKey: key) } + + /// Re-reads the value from UserDefaults, updating the @Published cache. + /// Call this when the app foregrounds after a Before-First-Unlock background launch, + /// where StorageValue was initialized while UserDefaults was locked (returning defaults). + func reload() { + if let data = StorageValue.defaults.data(forKey: key), + let decodedValue = try? JSONDecoder().decode(T.self, from: data), + decodedValue != value + { + value = decodedValue + } + } } 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 fa8000ac6..70cd30dc1 100644 --- a/LoopFollow/Storage/Storage+Migrate.swift +++ b/LoopFollow/Storage/Storage+Migrate.swift @@ -2,6 +2,7 @@ // Storage+Migrate.swift import Foundation +import UserNotifications extension Storage { func migrateStep5() { @@ -32,6 +33,61 @@ extension Storage { } } + func migrateStep7() { + // Cancel notifications scheduled with old hardcoded identifiers. + // Replaced with bundle-ID-scoped identifiers for multi-instance support. + LogManager.shared.log(category: .general, message: "Running migrateStep7 — cancel legacy notification identifiers") + + let legacyNotificationIDs = [ + "loopfollow.background.alert.6min", + "loopfollow.background.alert.12min", + "loopfollow.background.alert.18min", + "loopfollow.la.renewal.failed", + ] + UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: legacyNotificationIDs) + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: legacyNotificationIDs) + } + + func migrateStep6() { + // APNs credential separation + LogManager.shared.log(category: .general, message: "Running migrateStep6 — 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) @@ -85,10 +141,21 @@ extension Storage { } func migrateStep1() { - Storage.shared.url.value = ObservableUserDefaults.shared.old_url.value - Storage.shared.device.value = ObservableUserDefaults.shared.old_device.value - Storage.shared.nsWriteAuth.value = ObservableUserDefaults.shared.old_nsWriteAuth.value - Storage.shared.nsAdminAuth.value = ObservableUserDefaults.shared.old_nsAdminAuth.value + // Guard each field with .exists so that if the App Group suite is unreadable + // (e.g. Before-First-Unlock state after a reboot), we skip the write rather + // than overwriting the already-migrated Standard value with an empty default. + if ObservableUserDefaults.shared.old_url.exists { + Storage.shared.url.value = ObservableUserDefaults.shared.old_url.value + } + if ObservableUserDefaults.shared.old_device.exists { + Storage.shared.device.value = ObservableUserDefaults.shared.old_device.value + } + if ObservableUserDefaults.shared.old_nsWriteAuth.exists { + Storage.shared.nsWriteAuth.value = ObservableUserDefaults.shared.old_nsWriteAuth.value + } + if ObservableUserDefaults.shared.old_nsAdminAuth.exists { + Storage.shared.nsAdminAuth.value = ObservableUserDefaults.shared.old_nsAdminAuth.value + } // Helper: 1-to-1 type ----------------------------------------------------------------- func move( diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 97e7a3d8c..33d2530b3 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) @@ -84,6 +84,34 @@ 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) + + // Live Activity extended InfoType data + var lastBasal = StorageValue(key: "lastBasal", defaultValue: "") + var lastPumpReservoirU = StorageValue(key: "lastPumpReservoirU", defaultValue: nil) + var lastAutosens = StorageValue(key: "lastAutosens", defaultValue: nil) + var lastTdd = StorageValue(key: "lastTdd", defaultValue: nil) + var lastTargetLowMgdl = StorageValue(key: "lastTargetLowMgdl", defaultValue: nil) + var lastTargetHighMgdl = StorageValue(key: "lastTargetHighMgdl", defaultValue: nil) + var lastIsfMgdlPerU = StorageValue(key: "lastIsfMgdlPerU", defaultValue: nil) + var lastCarbRatio = StorageValue(key: "lastCarbRatio", defaultValue: nil) + var lastCarbsToday = StorageValue(key: "lastCarbsToday", defaultValue: nil) + var lastProfileName = StorageValue(key: "lastProfileName", defaultValue: "") + var iageInsertTime = StorageValue(key: "iageInsertTime", defaultValue: 0) + var lastMinBgMgdl = StorageValue(key: "lastMinBgMgdl", defaultValue: nil) + var lastMaxBgMgdl = StorageValue(key: "lastMaxBgMgdl", defaultValue: nil) + + // Live Activity + var laEnabled = StorageValue(key: "laEnabled", defaultValue: false) + var laRenewBy = StorageValue(key: "laRenewBy", defaultValue: 0) + var laRenewalFailed = StorageValue(key: "laRenewalFailed", defaultValue: false) + // Graph Settings [BEGIN] var showDots = StorageValue(key: "showDots", defaultValue: true) var showLines = StorageValue(key: "showLines", defaultValue: true) @@ -152,8 +180,8 @@ class Storage { var token = StorageValue(key: "token", defaultValue: "") var units = StorageValue(key: "units", defaultValue: "mg/dL") - var infoSort = StorageValue<[Int]>(key: "infoSort", defaultValue: InfoType.allCases.map { $0.sortOrder }) - var infoVisible = StorageValue<[Bool]>(key: "infoVisible", defaultValue: InfoType.allCases.map { $0.defaultVisible }) + var infoSort = StorageValue<[Int]>(key: "infoSort", defaultValue: InfoType.allCases.map(\.sortOrder)) + var infoVisible = StorageValue<[Bool]>(key: "infoVisible", defaultValue: InfoType.allCases.map(\.defaultVisible)) var url = StorageValue(key: "url", defaultValue: "") var device = StorageValue(key: "device", defaultValue: "") @@ -167,6 +195,7 @@ class Storage { var lastLoopingChecked = StorageValue(key: "lastLoopingChecked", defaultValue: nil) var lastBGChecked = StorageValue(key: "lastBGChecked", defaultValue: nil) + var lastLoopTime = StorageValue(key: "lastLoopTime", defaultValue: 0) // Tab positions - which position each item is in (positions 1-4 are customizable, 5 is always Menu) var homePosition = StorageValue(key: "homePosition", defaultValue: .position1) @@ -179,9 +208,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 @@ -192,18 +218,204 @@ class Storage { static let shared = Storage() private init() {} + /// Set to true at launch if isProtectedDataAvailable was false (BFU state). + /// Consumed and cleared on the first foreground after that launch. + var needsBFUReload = false + + /// Re-reads every StorageValue from UserDefaults, firing @Published only where the value + /// actually changed. Call this when foregrounding after a Before-First-Unlock (BFU) background + /// launch, where Storage was initialized while UserDefaults was encrypted and all values were + /// cached as their defaults. + /// + /// `migrationStep` is intentionally excluded: viewDidLoad writes it to 6 during the BFU + /// launch; if we reloaded it and the flush had somehow not landed yet, migrations would re-run. + /// + /// SecureStorageValue properties (maxBolus, maxCarbs, maxProtein, maxFat, bolusIncrement) are + /// not covered here — SecureStorageValue does not implement reload() and Keychain has the same + /// BFU inaccessibility; that is a separate problem. + func reloadAll() { + remoteType.reload() + deviceToken.reload() + expirationDate.reload() + sharedSecret.reload() + productionEnvironment.reload() + remoteApnsKey.reload() + teamId.reload() + remoteKeyId.reload() + + lfApnsKey.reload() + lfKeyId.reload() + bundleId.reload() + user.reload() + + mealWithBolus.reload() + mealWithFatProtein.reload() + hasSeenFatProteinOrderChange.reload() + + backgroundRefreshType.reload() + selectedBLEDevice.reload() + debugLogLevel.reload() + + contactTrend.reload() + contactDelta.reload() + contactEnabled.reload() + contactBackgroundColor.reload() + contactTextColor.reload() + + sensorScheduleOffset.reload() + alarms.reload() + alarmConfiguration.reload() + + lastOverrideStartNotified.reload() + lastOverrideEndNotified.reload() + lastTempTargetStartNotified.reload() + lastTempTargetEndNotified.reload() + lastRecBolusNotified.reload() + lastCOBNotified.reload() + lastMissedBolusNotified.reload() + + appBadge.reload() + colorBGText.reload() + appearanceMode.reload() + showStats.reload() + useIFCC.reload() + showSmallGraph.reload() + screenlockSwitchState.reload() + showDisplayName.reload() + snoozerEmoji.reload() + forcePortraitMode.reload() + + speakBG.reload() + speakBGAlways.reload() + speakLowBG.reload() + speakProactiveLowBG.reload() + speakFastDropDelta.reload() + speakLowBGLimit.reload() + speakHighBGLimit.reload() + speakHighBG.reload() + speakLanguage.reload() + + lastBgReadingTimeSeconds.reload() + lastDeltaMgdl.reload() + lastTrendCode.reload() + lastIOB.reload() + lastCOB.reload() + projectedBgMgdl.reload() + + lastBasal.reload() + lastPumpReservoirU.reload() + lastAutosens.reload() + lastTdd.reload() + lastTargetLowMgdl.reload() + lastTargetHighMgdl.reload() + lastIsfMgdlPerU.reload() + lastCarbRatio.reload() + lastCarbsToday.reload() + lastProfileName.reload() + iageInsertTime.reload() + lastMinBgMgdl.reload() + lastMaxBgMgdl.reload() + + laEnabled.reload() + laRenewBy.reload() + laRenewalFailed.reload() + + showDots.reload() + showLines.reload() + showValues.reload() + showAbsorption.reload() + showDIALines.reload() + show30MinLine.reload() + show90MinLine.reload() + showMidnightLines.reload() + smallGraphTreatments.reload() + smallGraphHeight.reload() + predictionToLoad.reload() + minBasalScale.reload() + minBGScale.reload() + lowLine.reload() + highLine.reload() + downloadDays.reload() + graphTimeZoneEnabled.reload() + graphTimeZoneIdentifier.reload() + + writeCalendarEvent.reload() + calendarIdentifier.reload() + watchLine1.reload() + watchLine2.reload() + + shareUserName.reload() + sharePassword.reload() + shareServer.reload() + + chartScaleX.reload() + + downloadTreatments.reload() + downloadPrediction.reload() + graphOtherTreatments.reload() + graphBasal.reload() + graphBolus.reload() + graphCarbs.reload() + bgUpdateDelay.reload() + + cageInsertTime.reload() + sageInsertTime.reload() + + cachedForVersion.reload() + latestVersion.reload() + latestVersionChecked.reload() + currentVersionBlackListed.reload() + lastBlacklistNotificationShown.reload() + lastVersionUpdateNotificationShown.reload() + lastExpirationNotificationShown.reload() + + hideInfoTable.reload() + token.reload() + units.reload() + infoSort.reload() + infoVisible.reload() + + url.reload() + device.reload() + nsWriteAuth.reload() + nsAdminAuth.reload() + + // migrationStep intentionally excluded — see method comment above. + + persistentNotification.reload() + persistentNotificationLastBGTime.reload() + + lastLoopingChecked.reload() + lastBGChecked.reload() + lastLoopTime.reload() + + homePosition.reload() + alarmsPosition.reload() + snoozerPosition.reload() + nightscoutPosition.reload() + remotePosition.reload() + statisticsPosition.reload() + treatmentsPosition.reload() + + loopAPNSQrCodeURL.reload() + bolusIncrementDetected.reload() + showGMI.reload() + showStdDev.reload() + showTITR.reload() + } + // MARK: - Tab Position Helpers /// Get the position for a given tab item func position(for item: TabItem) -> TabPosition { switch item { - case .home: return homePosition.value - case .alarms: return alarmsPosition.value - case .remote: return remotePosition.value - case .nightscout: return nightscoutPosition.value - case .snoozer: return snoozerPosition.value - case .stats: return statisticsPosition.value - case .treatments: return treatmentsPosition.value + case .home: homePosition.value + case .alarms: alarmsPosition.value + case .remote: remotePosition.value + case .nightscout: nightscoutPosition.value + case .snoozer: snoozerPosition.value + case .stats: statisticsPosition.value + case .treatments: treatmentsPosition.value } } diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 34cb1063c..d43abb2a2 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -15,6 +15,14 @@ func IsNightscoutEnabled() -> Bool { return !Storage.shared.url.value.isEmpty } +private struct APNSCredentialSnapshot: Equatable { + let remoteApnsKey: String + let teamId: String? + let remoteKeyId: String + let lfApnsKey: String + let lfKeyId: String +} + class MainViewController: UIViewController, UITableViewDataSource, ChartViewDelegate, UNUserNotificationCenterDelegate, UIScrollViewDelegate { var isPresentedAsModal: Bool = false @@ -142,40 +150,8 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele loadDebugData() - // Capture before migrations run: true for existing users, false for fresh installs. - let isExistingUser = Storage.shared.migrationStep.exists - - // Step 1: Released in v3.0.0 (2025-07-07). Can be removed after 2026-07-07. - if Storage.shared.migrationStep.value < 1 { - Storage.shared.migrateStep1() - Storage.shared.migrationStep.value = 1 - } - - // Step 2: Released in v3.1.0 (2025-07-21). Can be removed after 2026-07-21. - if Storage.shared.migrationStep.value < 2 { - Storage.shared.migrateStep2() - Storage.shared.migrationStep.value = 2 - } - - // Step 3: Released in v4.5.0 (2026-02-01). Can be removed after 2027-02-01. - if Storage.shared.migrationStep.value < 3 { - Storage.shared.migrateStep3() - Storage.shared.migrationStep.value = 3 - } - - // Step 4: Released in v5.0.0 (2026-03-20). Can be removed after 2027-03-20. - if Storage.shared.migrationStep.value < 4 { - // Existing users need to see the fat/protein order change banner. - // New users never saw the old order, so mark it as already seen. - Storage.shared.hasSeenFatProteinOrderChange.value = !isExistingUser - Storage.shared.migrationStep.value = 4 - } - - // Step 5: Released in v5.0.0 (2026-03-20). Can be removed after 2027-03-20. - if Storage.shared.migrationStep.value < 5 { - Storage.shared.migrateStep5() - Storage.shared.migrationStep.value = 5 - } + // Migrations run in foreground only — see runMigrationsIfNeeded() for details. + runMigrationsIfNeeded() // Synchronize info types to ensure arrays are the correct size synchronizeInfoTypes() @@ -214,6 +190,11 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele let notificationCenter = NotificationCenter.default notificationCenter.addObserver(self, selector: #selector(appMovedToBackground), name: UIApplication.didEnterBackgroundNotification, object: nil) notificationCenter.addObserver(self, selector: #selector(appCameToForeground), name: UIApplication.willEnterForegroundNotification, object: nil) + // didBecomeActive is used (not willEnterForeground) to ensure applicationState == .active + // when runMigrationsIfNeeded() is called. This catches migrations deferred by a + // background BGAppRefreshTask launch in Before-First-Unlock state. + notificationCenter.addObserver(self, selector: #selector(appDidBecomeActive), name: UIApplication.didBecomeActiveNotification, object: nil) + notificationCenter.addObserver(self, selector: #selector(navigateOnLAForeground), name: .liveActivityDidForeground, object: nil) // Setup the Graph if firstGraphLoad { @@ -398,29 +379,27 @@ 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.CombineLatest4( + Storage.shared.remoteApnsKey.$value, + Storage.shared.teamId.$value, + Storage.shared.remoteKeyId.$value, + Storage.shared.lfApnsKey.$value + ) + .combineLatest(Storage.shared.lfKeyId.$value) + .map { values, lfKeyId in + APNSCredentialSnapshot( + remoteApnsKey: values.0, + teamId: values.1, + remoteKeyId: values.2, + lfApnsKey: values.3, + lfKeyId: lfKeyId + ) + } + .removeDuplicates() + .dropFirst() + .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main) + .sink { _ in JWTManager.shared.invalidateCache() } + .store(in: &cancellables) Storage.shared.device.$value .receive(on: DispatchQueue.main) @@ -717,6 +696,28 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele updateNightscoutTabState() } + @objc private func navigateOnLAForeground() { + guard let tabBarController = tabBarController, + let vcs = tabBarController.viewControllers, !vcs.isEmpty else { return } + + let targetIndex: Int + if Observable.shared.currentAlarm.value != nil, + let snoozerIndex = getSnoozerTabIndex(), snoozerIndex < vcs.count + { + targetIndex = snoozerIndex + } else { + targetIndex = 0 + } + + if let presented = tabBarController.presentedViewController { + presented.dismiss(animated: false) { + tabBarController.selectedIndex = targetIndex + } + } else { + tabBarController.selectedIndex = targetIndex + } + } + private func getSnoozerTabIndex() -> Int? { guard let tabBarController = tabBarController, let viewControllers = tabBarController.viewControllers else { return nil } @@ -973,6 +974,7 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele if Storage.shared.backgroundRefreshType.value == .silentTune { backgroundTask.startBackgroundTask() + BackgroundRefreshManager.shared.scheduleRefresh() } if Storage.shared.backgroundRefreshType.value != .none { @@ -980,7 +982,93 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele } } + // Migrations must only run when UserDefaults is accessible (i.e. after first unlock). + // When the app is launched in the background by BGAppRefreshTask immediately after a + // reboot, the device may be in Before-First-Unlock (BFU) state: UserDefaults files are + // still encrypted, so every read returns the default value (0 / ""). Running migrations + // in that state would overwrite real settings with empty strings. + // + // Strategy: skip migrations if applicationState == .background; call this method again + // from appCameToForeground() so they run on the first foreground after a BFU launch. + func runMigrationsIfNeeded() { + guard UIApplication.shared.applicationState != .background else { return } + + // Capture before migrations run: true for existing users, false for fresh installs. + let isExistingUser = Storage.shared.migrationStep.exists + + // Step 1: Released in v3.0.0 (2025-07-07). Can be removed after 2026-07-07. + if Storage.shared.migrationStep.value < 1 { + Storage.shared.migrateStep1() + Storage.shared.migrationStep.value = 1 + } + + // Step 2: Released in v3.1.0 (2025-07-21). Can be removed after 2026-07-21. + if Storage.shared.migrationStep.value < 2 { + Storage.shared.migrateStep2() + Storage.shared.migrationStep.value = 2 + } + + // Step 3: Released in v4.5.0 (2026-02-01). Can be removed after 2027-02-01. + if Storage.shared.migrationStep.value < 3 { + Storage.shared.migrateStep3() + Storage.shared.migrationStep.value = 3 + } + + // Step 4: Released in v5.0.0 (2026-03-20). Can be removed after 2027-03-20. + if Storage.shared.migrationStep.value < 4 { + // Existing users need to see the fat/protein order change banner. + // New users never saw the old order, so mark it as already seen. + Storage.shared.hasSeenFatProteinOrderChange.value = !isExistingUser + Storage.shared.migrationStep.value = 4 + } + + // Step 5: Released in v5.0.0 (2026-03-20). Can be removed after 2027-03-20. + if Storage.shared.migrationStep.value < 5 { + Storage.shared.migrateStep5() + Storage.shared.migrationStep.value = 5 + } + + if Storage.shared.migrationStep.value < 6 { + Storage.shared.migrateStep6() + Storage.shared.migrationStep.value = 6 + } + + if Storage.shared.migrationStep.value < 7 { + Storage.shared.migrateStep7() + Storage.shared.migrationStep.value = 7 + } + } + + @objc func appDidBecomeActive() { + // applicationState == .active is guaranteed here, so the BFU guard in + // runMigrationsIfNeeded() will always pass. Catches the case where viewDidLoad + // ran during a BGAppRefreshTask background launch and deferred migrations. + runMigrationsIfNeeded() + } + @objc func appCameToForeground() { + // If the app was cold-launched in Before-First-Unlock state (e.g. by BGAppRefreshTask + // after a reboot), all StorageValues were cached from encrypted UserDefaults and hold + // their defaults. Reload everything from disk now that the device is unlocked, firing + // Combine observers only for values that actually changed. + LogManager.shared.log(category: .general, message: "appCameToForeground: needsBFUReload=\(Storage.shared.needsBFUReload), url='\(Storage.shared.url.value)'") + if Storage.shared.needsBFUReload { + Storage.shared.needsBFUReload = false + LogManager.shared.log(category: .general, message: "BFU reload triggered — reloading all StorageValues") + Storage.shared.reloadAll() + LogManager.shared.log(category: .general, message: "BFU reload complete: url='\(Storage.shared.url.value)'") + // Show the loading overlay so the user sees feedback during the 2-5s + // while tasks re-run with the now-correct credentials. + loadingStates = ["bg": false, "profile": false, "deviceStatus": false] + isInitialLoad = true + setupLoadingState() + showLoadingOverlay() + // Tasks were scheduled during BFU viewDidLoad with url="" — they fired, found no + // data source, and rescheduled themselves 60s out. Reset them now so they run + // within their normal 2-5s initial delay using the now-correct credentials. + scheduleAllTasks() + } + // reset screenlock state if needed UIApplication.shared.isIdleTimerDisabled = Storage.shared.screenlockSwitchState.value @@ -1053,6 +1141,9 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele @objc override func viewDidAppear(_: Bool) { showHideNSDetails() + #if !targetEnvironment(macCatalyst) + LiveActivityManager.shared.startFromCurrentState() + #endif } 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..1dc9d75ed --- /dev/null +++ b/LoopFollowLAExtension/LoopFollowLABundle.swift @@ -0,0 +1,12 @@ +// LoopFollow +// LoopFollowLABundle.swift + +import SwiftUI +import WidgetKit + +@main +struct LoopFollowLABundle: WidgetBundle { + var body: some Widget { + LoopFollowLiveActivityWidget() + } +} diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift new file mode 100644 index 000000000..14a513674 --- /dev/null +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -0,0 +1,740 @@ +// LoopFollow +// LoopFollowLiveActivity.swift + +import ActivityKit +import SwiftUI +import WidgetKit + +/// Builds the shared Dynamic Island content used by the Live Activity widget. +private func makeDynamicIsland(context: ActivityViewContext) -> DynamicIsland { + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + Link(destination: URL(string: "\(AppGroupID.urlScheme)://la-tap")!) { + DynamicIslandLeadingView(snapshot: context.state.snapshot) + .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) + } + .id(context.state.seq) + } + DynamicIslandExpandedRegion(.trailing) { + Link(destination: URL(string: "\(AppGroupID.urlScheme)://la-tap")!) { + DynamicIslandTrailingView(snapshot: context.state.snapshot) + .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) + } + .id(context.state.seq) + } + DynamicIslandExpandedRegion(.bottom) { + Link(destination: URL(string: "\(AppGroupID.urlScheme)://la-tap")!) { + DynamicIslandBottomView(snapshot: context.state.snapshot) + .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay, showText: true)) + } + .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 widget + +/// Single widget for all supported OS versions. +/// - iOS 18+: enables supplemental `.small` family and routes via `LockScreenFamilyAdaptiveView`. +/// - iOS 16.1–17.x: uses the regular lock screen view. +@available(iOSApplicationExtension 16.1, *) +struct LoopFollowLiveActivityWidget: Widget { + var body: some WidgetConfiguration { + if #available(iOSApplicationExtension 18.0, *) { + return ActivityConfiguration(for: GlucoseLiveActivityAttributes.self) { context in + LockScreenFamilyAdaptiveView(state: context.state) + .id(context.state.seq) + .activitySystemActionForegroundColor(.white) + .applyActivityContentMarginsFixIfAvailable() + .widgetURL(URL(string: "\(AppGroupID.urlScheme)://la-tap")!) + } dynamicIsland: { context in + makeDynamicIsland(context: context) + } + .supplementalActivityFamilies([.small]) + } else { + return ActivityConfiguration(for: GlucoseLiveActivityAttributes.self) { context in + LockScreenLiveActivityView(state: context.state) + .id(context.state.seq) + .activitySystemActionForegroundColor(.white) + .activityBackgroundTint(LAColors.backgroundTint(for: context.state.snapshot)) + .applyActivityContentMarginsFixIfAvailable() + .widgetURL(URL(string: "\(AppGroupID.urlScheme)://la-tap")!) + } dynamicIsland: { context in + makeDynamicIsland(context: context) + } + } + } +} + +// MARK: - Live Activity content margins helper + +private extension View { + @ViewBuilder + func applyActivityContentMarginsFixIfAvailable() -> some View { + if #available(iOS 17.0, *) { + contentMargins(Edge.Set.all, 0) + } else { + self + } + } +} + +// MARK: - Family-adaptive wrapper (Lock Screen / CarPlay / Watch Smart Stack) + +/// Reads the activityFamily environment value and routes to the appropriate layout. +/// - `.small` → CarPlay Dashboard & Watch Smart Stack +/// - everything else → full lock screen layout +@available(iOS 18.0, *) +private struct LockScreenFamilyAdaptiveView: View { + let state: GlucoseLiveActivityAttributes.ContentState + + @Environment(\.activityFamily) private var activityFamily + + var body: some View { + if activityFamily == .small { + SmallFamilyView(snapshot: state.snapshot) + .activityBackgroundTint(Color.black.opacity(0.25)) + } else { + LockScreenLiveActivityView(state: state) + .activityBackgroundTint(LAColors.backgroundTint(for: state.snapshot)) + } + } +} + +// MARK: - Small family view (CarPlay Dashboard + Watch Smart Stack) + +@available(iOS 18.0, *) +private struct SmallFamilyView: View { + let snapshot: GlucoseSnapshot + + /// Unit label for the right slot — ISF appends "/U", other glucose slots + /// use the plain glucose unit, non-glucose slots return nil. + private func rightSlotUnitLabel(for slot: LiveActivitySlotOption) -> String? { + guard slot.isGlucoseUnit else { return nil } + if slot == .isf { return snapshot.unit.displayName + "/U" } + return snapshot.unit.displayName + } + + var body: some View { + let rightSlot = LAAppGroupSettings.smallWidgetSlot() + + HStack(alignment: .center, spacing: 0) { + VStack(alignment: .leading, spacing: 2) { + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(LAFormat.glucose(snapshot)) + .font(.system(size: 28, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(LAColors.keyline(for: snapshot)) + + Text(LAFormat.trendArrow(snapshot)) + .font(.system(size: 22, weight: .semibold, design: .rounded)) + .foregroundStyle(LAColors.keyline(for: snapshot)) + } + + Text("\(LAFormat.delta(snapshot)) \(snapshot.unit.displayName)") + .font(.system(size: 14, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.85)) + } + .layoutPriority(1) + + Spacer() + + if rightSlot != .none { + if let unitLabel = rightSlotUnitLabel(for: rightSlot) { + // Use ViewThatFits so the unit label appears on surfaces with + // enough vertical space (CarPlay) and is omitted where it doesn't + // fit (Watch Smart Stack). + ViewThatFits(in: .vertical) { + VStack(alignment: .trailing, spacing: 2) { + Text(rightSlot.gridLabel) + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.65)) + Text(slotFormattedValue(option: rightSlot, snapshot: snapshot)) + .font(.system(size: 20, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white) + .lineLimit(1) + .minimumScaleFactor(0.8) + Text(unitLabel) + .font(.system(size: 11, weight: .regular, design: .rounded)) + .foregroundStyle(.white.opacity(0.55)) + } + VStack(alignment: .trailing, spacing: 2) { + Text(rightSlot.gridLabel) + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.65)) + Text(slotFormattedValue(option: rightSlot, snapshot: snapshot)) + .font(.system(size: 20, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + } + } else { + VStack(alignment: .trailing, spacing: 2) { + Text(rightSlot.gridLabel) + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.65)) + Text(slotFormattedValue(option: rightSlot, snapshot: snapshot)) + .font(.system(size: 20, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .padding(10) + } +} + +// MARK: - Lock Screen Contract View + +private struct LockScreenLiveActivityView: View { + let state: GlucoseLiveActivityAttributes.ContentState + + var body: some View { + let s = state.snapshot + let slotConfig = LAAppGroupSettings.slots() + + VStack(spacing: 6) { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(LAFormat.glucose(s)) + .font(.system(size: 46, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white) + .lineLimit(1) + .minimumScaleFactor(0.78) + .allowsTightening(true) + .layoutPriority(3) + + Text(LAFormat.trendArrow(s)) + .font(.system(size: 32, weight: .bold, design: .rounded)) + .foregroundStyle(.white.opacity(0.95)) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + } + + Text("Delta: \(LAFormat.delta(s))") + .font(.system(size: 15, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.80)) + .lineLimit(1) + } + .frame(minWidth: 168, maxWidth: 190, alignment: .leading) + .layoutPriority(2) + + Rectangle() + .fill(Color.white.opacity(0.20)) + .frame(width: 1) + .padding(.vertical, 8) + + VStack(spacing: 8) { + HStack(spacing: 12) { + SlotView(option: slotConfig[0], snapshot: s) + SlotView(option: slotConfig[1], snapshot: s) + } + HStack(spacing: 12) { + SlotView(option: slotConfig[2], snapshot: s) + SlotView(option: slotConfig[3], snapshot: s) + } + } + .frame(maxWidth: .infinity, alignment: .trailing) + } + + Text(LAAppGroupSettings.showDisplayName() + ? "\(LAAppGroupSettings.displayName()) — \(LAFormat.updated(s))" + : "Last Update: \(LAFormat.updated(s))") + .font(.system(size: 11, weight: .regular, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.65)) + .frame(maxWidth: .infinity, alignment: .center) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 14) + .padding(.top, 12) + .padding(.bottom, 8) + .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) + } + } + } + ) + .overlay( + ZStack { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color.gray.opacity(0.9)) + + Text("Tap to update") + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(.white) + } + .opacity(state.snapshot.showRenewalOverlay ? 1 : 0) + ) + } +} + +/// Full-size gray overlay shown 30 minutes before the LA renewal deadline. +/// Applied to both the lock screen view and each expanded Dynamic Island region. +private struct RenewalOverlayView: View { + let show: Bool + var showText: Bool = false + + var body: some View { + ZStack { + Color.gray.opacity(0.9) + if showText { + Text("Tap to update") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.white) + } + } + .opacity(show ? 1 : 0) + } +} + +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: 60, alignment: .leading) + } +} + +private func slotFormattedValue(option: LiveActivitySlotOption, snapshot: GlucoseSnapshot) -> String { + switch option { + case .none: "" + case .delta: LAFormat.delta(snapshot) + case .projectedBG: LAFormat.projected(snapshot) + case .minMax: LAFormat.minMax(snapshot) + case .iob: LAFormat.iob(snapshot) + case .cob: LAFormat.cob(snapshot) + case .recBolus: LAFormat.recBolus(snapshot) + case .autosens: LAFormat.autosens(snapshot) + case .tdd: LAFormat.tdd(snapshot) + case .basal: LAFormat.basal(snapshot) + case .pump: LAFormat.pump(snapshot) + case .pumpBattery: LAFormat.pumpBattery(snapshot) + case .battery: LAFormat.battery(snapshot) + case .target: LAFormat.target(snapshot) + case .isf: LAFormat.isf(snapshot) + case .carbRatio: LAFormat.carbRatio(snapshot) + case .sage: LAFormat.age(insertTime: snapshot.sageInsertTime) + case .cage: LAFormat.age(insertTime: snapshot.cageInsertTime) + case .iage: LAFormat.age(insertTime: snapshot.iageInsertTime) + case .carbsToday: LAFormat.carbsToday(snapshot) + case .override: LAFormat.override(snapshot) + case .profile: LAFormat.profileName(snapshot) + } +} + +private struct SlotView: View { + let option: LiveActivitySlotOption + let snapshot: GlucoseSnapshot + + var body: some View { + if option == .none { + Color.clear + .frame(width: 60, height: 36) + } else { + MetricBlock(label: option.gridLabel, value: slotFormattedValue(option: option, snapshot: snapshot)) + } + } +} + +// MARK: - Dynamic Island + +private struct DynamicIslandLeadingView: View { + let snapshot: GlucoseSnapshot + + var body: some View { + if snapshot.isNotLooping { + 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: 4) { + Text(LAFormat.glucose(snapshot)) + .font(.system(size: 28, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(LAColors.keyline(for: snapshot)) + Text(LAFormat.trendArrow(snapshot)) + .font(.system(size: 22, weight: .semibold, design: .rounded)) + .foregroundStyle(LAColors.keyline(for: snapshot)) + } + Text("\(LAFormat.delta(snapshot)) \(snapshot.unit.displayName)") + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.85)) + } + } + } +} + +private struct DynamicIslandTrailingView: View { + let snapshot: GlucoseSnapshot + + var body: some View { + if snapshot.isNotLooping { + EmptyView() + } else { + let slot = LAAppGroupSettings.smallWidgetSlot() + if slot != .none { + VStack(alignment: .trailing, spacing: 2) { + Text(slot.gridLabel) + .font(.system(size: 11, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.65)) + Text(slotFormattedValue(option: slot, snapshot: snapshot)) + .font(.system(size: 18, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + .padding(.trailing, 6) + } + } + } +} + +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 { + Text("Updated at: \(LAFormat.updated(snapshot))") + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.92)) + .lineLimit(1) + .minimumScaleFactor(0.85) + } + } +} + +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.delta(snapshot)) + .font(.system(size: 14, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.95)) + } + } +} + +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) + } + } +} + +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 { + 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) + } + } + + 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)" + } + } + + static func trendArrow(_ s: GlucoseSnapshot) -> String { + switch s.trend { + case .upFast: "↑↑" + case .up: "↑" + case .upSlight: "↗" + case .flat: "→" + case .downSlight: "↘︎" + case .down: "↓" + case .downFast: "↓↓" + case .unknown: "–" + } + } + + 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) + } + + private static let ageFormatter: DateComponentsFormatter = { + let f = DateComponentsFormatter() + f.unitsStyle = .positional + f.allowedUnits = [.day, .hour] + f.zeroFormattingBehavior = [.pad] + return f + }() + + static func age(insertTime: TimeInterval) -> String { + guard insertTime > 0 else { return "—" } + let secondsAgo = Date().timeIntervalSince1970 - insertTime + return ageFormatter.string(from: secondsAgo) ?? "—" + } + + static func recBolus(_ s: GlucoseSnapshot) -> String { + guard let v = s.recBolus else { return "—" } + return String(format: "%.2fU", v) + } + + static func autosens(_ s: GlucoseSnapshot) -> String { + guard let v = s.autosens else { return "—" } + return String(format: "%.0f%%", v * 100) + } + + static func tdd(_ s: GlucoseSnapshot) -> String { + guard let v = s.tdd else { return "—" } + return String(format: "%.1fU", v) + } + + static func basal(_ s: GlucoseSnapshot) -> String { + s.basalRate.isEmpty ? "—" : s.basalRate + } + + static func pump(_ s: GlucoseSnapshot) -> String { + guard let v = s.pumpReservoirU else { return "50+U" } + return "\(Int(round(v)))U" + } + + static func pumpBattery(_ s: GlucoseSnapshot) -> String { + guard let v = s.pumpBattery else { return "—" } + return String(format: "%.0f%%", v) + } + + static func battery(_ s: GlucoseSnapshot) -> String { + guard let v = s.battery else { return "—" } + return String(format: "%.0f%%", v) + } + + static func target(_ s: GlucoseSnapshot) -> String { + guard let low = s.targetLowMgdl, low > 0 else { return "—" } + let lowStr = formatGlucoseValue(low, unit: s.unit) + if let high = s.targetHighMgdl, high > 0, abs(high - low) > 0.5 { + return "\(lowStr)-\(formatGlucoseValue(high, unit: s.unit))" + } + return lowStr + } + + static func isf(_ s: GlucoseSnapshot) -> String { + guard let v = s.isfMgdlPerU, v > 0 else { return "—" } + return formatGlucoseValue(v, unit: s.unit) + } + + static func carbRatio(_ s: GlucoseSnapshot) -> String { + guard let v = s.carbRatio, v > 0 else { return "—" } + return String(format: "%.0fg", v) + } + + static func carbsToday(_ s: GlucoseSnapshot) -> String { + guard let v = s.carbsToday else { return "—" } + return "\(Int(round(v)))g" + } + + static func minMax(_ s: GlucoseSnapshot) -> String { + guard let mn = s.minBgMgdl, let mx = s.maxBgMgdl else { return "—" } + return "\(formatGlucoseValue(mn, unit: s.unit))/\(formatGlucoseValue(mx, unit: s.unit))" + } + + static func override(_ s: GlucoseSnapshot) -> String { + s.override ?? "—" + } + + static func profileName(_ s: GlucoseSnapshot) -> String { + s.profileName ?? "—" + } + + private static let hhmmFormatter: DateFormatter = { + let df = DateFormatter() + df.locale = .current + df.timeZone = .current + df.dateFormat = "HH:mm" + 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 + +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/Podfile b/Podfile index 5a8c0f868..f8c2df3b2 100644 --- a/Podfile +++ b/Podfile @@ -7,6 +7,15 @@ target 'LoopFollow' do end post_install do |installer| + # Set minimum deployment target for all pods to match the app (suppresses deprecation warnings) + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + if config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'].to_f < 16.6 + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '16.6' + end + end + end + # Patch Charts Transformer to avoid "CGAffineTransformInvert: singular matrix" # warnings when chart views have zero dimensions (before layout). transformer = 'Pods/Charts/Source/Charts/Utils/Transformer.swift' diff --git a/RestartLiveActivityIntent.swift b/RestartLiveActivityIntent.swift new file mode 100644 index 000000000..c594d5fa9 --- /dev/null +++ b/RestartLiveActivityIntent.swift @@ -0,0 +1,45 @@ +// LoopFollow +// RestartLiveActivityIntent.swift + +import AppIntents +import UIKit + +@available(iOS 16.4, *) +struct RestartLiveActivityIntent: AppIntent, ForegroundContinuableIntent { + static var title: LocalizedStringResource = "Restart Live Activity" + static var description = IntentDescription("Starts or restarts the LoopFollow Live Activity.") + + func perform() async throws -> some IntentResult & ProvidesDialog { + Storage.shared.laEnabled.value = true + + let keyId = Storage.shared.lfKeyId.value + let apnsKey = Storage.shared.lfApnsKey.value + + if keyId.isEmpty || apnsKey.isEmpty { + if let url = URL(string: "loopfollow://settings/live-activity") { + await MainActor.run { UIApplication.shared.open(url) } + } + return .result(dialog: "Please enter your APNs credentials in LoopFollow settings to use the Live Activity.") + } + + if #available(iOS 26.0, *) { + try await continueInForeground() + } + + await MainActor.run { LiveActivityManager.shared.forceRestart() } + + return .result(dialog: "Live Activity restarted.") + } +} + +@available(iOS 16.4, *) +struct LoopFollowAppShortcuts: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: RestartLiveActivityIntent(), + phrases: ["Restart Live Activity in \(.applicationName)"], + shortTitle: "Restart Live Activity", + systemImageName: "dot.radiowaves.left.and.right" + ) + } +} 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