From 03d1e0253c45ac1e0ec51bc3305165b417c3c110 Mon Sep 17 00:00:00 2001 From: codebymini Date: Wed, 9 Jul 2025 22:26:49 +0200 Subject: [PATCH 01/21] Add remote commands via APNS for Loop users --- .gitignore | 1 + Config.xcconfig | 2 +- LoopFollow.xcodeproj/project.pbxproj | 99 +- .../xcshareddata/swiftpm/Package.resolved | 20 +- LoopFollow/Helpers/NightscoutUtils.swift | 13 + LoopFollow/Helpers/TOTPGenerator.swift | 113 ++ .../Views/SimpleQRCodeScannerView.swift | 61 ++ LoopFollow/Info.plist | 2 + .../NightscoutSettingsViewModel.swift | 4 +- .../Loop/LoopNightscoutRemoteView.swift | 35 - LoopFollow/Remote/Loop/LoopOverrideView.swift | 226 ---- .../Remote/Loop/LoopOverrideViewModel.swift | 78 -- .../Remote/LoopAPNS/LoopAPNSBolusView.swift | 276 +++++ .../Remote/LoopAPNS/LoopAPNSCarbsView.swift | 254 +++++ .../Remote/LoopAPNS/LoopAPNSRemoteView.swift | 60 ++ .../Remote/LoopAPNS/LoopAPNSService.swift | 981 ++++++++++++++++++ .../LoopAPNS/LoopAPNSSettingsView.swift | 207 ++++ .../Remote/LoopAPNS/OverridePresetData.swift | 85 ++ .../Remote/LoopAPNS/OverridePresetsView.swift | 350 +++++++ LoopFollow/Remote/RemoteType.swift | 1 + LoopFollow/Remote/RemoteViewController.swift | 4 +- .../Remote/Settings/RemoteSettingsView.swift | 223 ++-- .../Settings/RemoteSettingsViewModel.swift | 259 +++++ LoopFollow/Settings/SettingsMenuView.swift | 4 + LoopFollow/Storage/Storage.swift | 16 +- 25 files changed, 2912 insertions(+), 462 deletions(-) create mode 100644 LoopFollow/Helpers/TOTPGenerator.swift create mode 100644 LoopFollow/Helpers/Views/SimpleQRCodeScannerView.swift delete mode 100644 LoopFollow/Remote/Loop/LoopNightscoutRemoteView.swift delete mode 100644 LoopFollow/Remote/Loop/LoopOverrideView.swift delete mode 100644 LoopFollow/Remote/Loop/LoopOverrideViewModel.swift create mode 100644 LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift create mode 100644 LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift create mode 100644 LoopFollow/Remote/LoopAPNS/LoopAPNSRemoteView.swift create mode 100644 LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift create mode 100644 LoopFollow/Remote/LoopAPNS/LoopAPNSSettingsView.swift create mode 100644 LoopFollow/Remote/LoopAPNS/OverridePresetData.swift create mode 100644 LoopFollow/Remote/LoopAPNS/OverridePresetsView.swift diff --git a/.gitignore b/.gitignore index 6625a980..f176e2f7 100644 --- a/.gitignore +++ b/.gitignore @@ -80,3 +80,4 @@ fastlane/test_output fastlane/FastlaneRunner LoopFollowConfigOverride.xcconfig +.history \ No newline at end of file diff --git a/Config.xcconfig b/Config.xcconfig index 7dcd976e..a5ca12a0 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 3.0.7 +LOOP_FOLLOW_MARKETING_VERSION = 2.8.11 diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 69d875dd..2cee8bcd 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -8,6 +8,10 @@ /* Begin PBXBuildFile section */ 3F1335F351590E573D8E6962 /* Pods_LoopFollow.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */; }; + 654132E72E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654132E62E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift */; }; + 654132EA2E19F24800BDBE08 /* TOTPGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654132E92E19F24800BDBE08 /* TOTPGenerator.swift */; }; + 654134182E1DC09700BDBE08 /* OverridePresetsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654134172E1DC09700BDBE08 /* OverridePresetsView.swift */; }; + 6541341A2E1DC27900BDBE08 /* OverridePresetData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654134192E1DC27900BDBE08 /* OverridePresetData.swift */; }; DD0247592DB2E89600FCADF6 /* AlarmCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */; }; DD0247712DB4337700FCADF6 /* BuildExpireCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD02475B2DB2E8FB00FCADF6 /* BuildExpireCondition.swift */; }; DD0650A92DCA8A10004D3B41 /* AlarmBGSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0650A82DCA8A10004D3B41 /* AlarmBGSection.swift */; }; @@ -196,9 +200,6 @@ DDDB86F12DF7223C00AADDAC /* DeleteAlarmSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDB86F02DF7223C00AADDAC /* DeleteAlarmSection.swift */; }; DDDC31CC2E13A7DF009EA0F3 /* AddAlarmSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDC31CB2E13A7DF009EA0F3 /* AddAlarmSheet.swift */; }; DDDC31CE2E13A811009EA0F3 /* AlarmTile.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDC31CD2E13A811009EA0F3 /* AlarmTile.swift */; }; - DDDF6F432D479A9900884336 /* LoopNightscoutRemoteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDF6F422D479A9800884336 /* LoopNightscoutRemoteView.swift */; }; - DDDF6F452D479AB100884336 /* LoopOverrideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDF6F442D479AB000884336 /* LoopOverrideView.swift */; }; - DDDF6F472D479AD200884336 /* LoopOverrideViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDF6F462D479AD100884336 /* LoopOverrideViewModel.swift */; }; DDDF6F492D479AF000884336 /* NoRemoteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDF6F482D479AEF00884336 /* NoRemoteView.swift */; }; DDE69ED22C7256260013EAEC /* RemoteType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE69ED12C7256260013EAEC /* RemoteType.swift */; }; DDE75D232DE5E505007C1FC1 /* Glyph.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE75D222DE5E505007C1FC1 /* Glyph.swift */; }; @@ -209,6 +210,11 @@ DDEF503A2D31615000999A5D /* LogManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDEF50392D31614200999A5D /* LogManager.swift */; }; DDEF503C2D31BE2D00999A5D /* TaskScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDEF503B2D31BE2A00999A5D /* TaskScheduler.swift */; }; DDEF503F2D32754F00999A5D /* ProfileTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDEF503E2D32754A00999A5D /* ProfileTask.swift */; }; + DDEF50402D479B8A00884336 /* LoopAPNSService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDEF503F2D479B8A00884336 /* LoopAPNSService.swift */; }; + DDEF50422D479BAA00884336 /* LoopAPNSCarbsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDEF50412D479BAA00884336 /* LoopAPNSCarbsView.swift */; }; + DDEF50432D479BBA00884336 /* LoopAPNSBolusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDEF50422D479BBA00884336 /* LoopAPNSBolusView.swift */; }; + DDEF50442D479BCA00884336 /* LoopAPNSSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDEF50432D479BCA00884336 /* LoopAPNSSettingsView.swift */; }; + DDEF50452D479BDA00884336 /* LoopAPNSRemoteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDEF50442D479BDA00884336 /* LoopAPNSRemoteView.swift */; }; DDF2C0102BEFA991007A20E6 /* GitHubService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF2C00F2BEFA991007A20E6 /* GitHubService.swift */; }; DDF2C0122BEFB733007A20E6 /* AppVersionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF2C0112BEFB733007A20E6 /* AppVersionManager.swift */; }; DDF2C0142BEFD468007A20E6 /* blacklisted-versions.json in Resources */ = {isa = PBXBuildFile; fileRef = DDF2C0132BEFD468007A20E6 /* blacklisted-versions.json */; }; @@ -384,6 +390,10 @@ /* 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 = ""; }; + 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 = ""; }; + 654134192E1DC27900BDBE08 /* OverridePresetData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverridePresetData.swift; sourceTree = ""; }; A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_LoopFollow.framework; sourceTree = BUILT_PRODUCTS_DIR; }; DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmCondition.swift; sourceTree = ""; }; DD02475B2DB2E8FB00FCADF6 /* BuildExpireCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildExpireCondition.swift; sourceTree = ""; }; @@ -575,9 +585,6 @@ DDDB86F02DF7223C00AADDAC /* DeleteAlarmSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteAlarmSection.swift; sourceTree = ""; }; DDDC31CB2E13A7DF009EA0F3 /* AddAlarmSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddAlarmSheet.swift; sourceTree = ""; }; DDDC31CD2E13A811009EA0F3 /* AlarmTile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmTile.swift; sourceTree = ""; }; - DDDF6F422D479A9800884336 /* LoopNightscoutRemoteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopNightscoutRemoteView.swift; sourceTree = ""; }; - DDDF6F442D479AB000884336 /* LoopOverrideView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopOverrideView.swift; sourceTree = ""; }; - DDDF6F462D479AD100884336 /* LoopOverrideViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopOverrideViewModel.swift; sourceTree = ""; }; DDDF6F482D479AEF00884336 /* NoRemoteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoRemoteView.swift; sourceTree = ""; }; DDE69ED12C7256260013EAEC /* RemoteType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteType.swift; sourceTree = ""; }; DDE75D222DE5E505007C1FC1 /* Glyph.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Glyph.swift; sourceTree = ""; }; @@ -588,6 +595,11 @@ DDEF50392D31614200999A5D /* LogManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogManager.swift; sourceTree = ""; }; DDEF503B2D31BE2A00999A5D /* TaskScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskScheduler.swift; sourceTree = ""; }; DDEF503E2D32754A00999A5D /* ProfileTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileTask.swift; sourceTree = ""; }; + DDEF503F2D479B8A00884336 /* LoopAPNSService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopAPNSService.swift; sourceTree = ""; }; + DDEF50412D479BAA00884336 /* LoopAPNSCarbsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopAPNSCarbsView.swift; sourceTree = ""; }; + DDEF50422D479BBA00884336 /* LoopAPNSBolusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopAPNSBolusView.swift; sourceTree = ""; }; + DDEF50432D479BCA00884336 /* LoopAPNSSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopAPNSSettingsView.swift; sourceTree = ""; }; + DDEF50442D479BDA00884336 /* LoopAPNSRemoteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopAPNSRemoteView.swift; sourceTree = ""; }; DDF2C00F2BEFA991007A20E6 /* GitHubService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitHubService.swift; sourceTree = ""; }; DDF2C0112BEFB733007A20E6 /* AppVersionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppVersionManager.swift; sourceTree = ""; }; DDF2C0132BEFD468007A20E6 /* blacklisted-versions.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "blacklisted-versions.json"; sourceTree = ""; }; @@ -845,7 +857,7 @@ children = ( DDDF6F4A2D479B6A00884336 /* Nightscout */, DDDF6F482D479AEF00884336 /* NoRemoteView.swift */, - DDDF6F412D479A8E00884336 /* Loop */, + DDEF503E2D479B8A00884336 /* LoopAPNS */, DD4878112C7B74F90048F05C /* TRC */, DD4878062C7B2E9E0048F05C /* Settings */, DDF699972C5AA2E50058A8D9 /* TempTargetPreset */, @@ -1150,16 +1162,6 @@ path = AddAlarm; sourceTree = ""; }; - DDDF6F412D479A8E00884336 /* Loop */ = { - isa = PBXGroup; - children = ( - DDDF6F462D479AD100884336 /* LoopOverrideViewModel.swift */, - DDDF6F442D479AB000884336 /* LoopOverrideView.swift */, - DDDF6F422D479A9800884336 /* LoopNightscoutRemoteView.swift */, - ); - path = Loop; - sourceTree = ""; - }; DDDF6F4A2D479B6A00884336 /* Nightscout */ = { isa = PBXGroup; children = ( @@ -1184,6 +1186,20 @@ path = Task; sourceTree = ""; }; + DDEF503E2D479B8A00884336 /* LoopAPNS */ = { + isa = PBXGroup; + children = ( + DDEF503F2D479B8A00884336 /* LoopAPNSService.swift */, + DDEF50412D479BAA00884336 /* LoopAPNSCarbsView.swift */, + DDEF50422D479BBA00884336 /* LoopAPNSBolusView.swift */, + DDEF50432D479BCA00884336 /* LoopAPNSSettingsView.swift */, + 654134172E1DC09700BDBE08 /* OverridePresetsView.swift */, + 654134192E1DC27900BDBE08 /* OverridePresetData.swift */, + DDEF50442D479BDA00884336 /* LoopAPNSRemoteView.swift */, + ); + path = LoopAPNS; + sourceTree = ""; + }; DDF699972C5AA2E50058A8D9 /* TempTargetPreset */ = { isa = PBXGroup; children = ( @@ -1204,6 +1220,7 @@ DD16AF0E2C99592F00FB655A /* HKQuantityInputView.swift */, DD16AF102C997B4600FB655A /* LoadingButtonView.swift */, DDE75D262DE5E539007C1FC1 /* ActionRow.swift */, + 654132E62E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift */, DDE75D282DE5E56C007C1FC1 /* LinkRow.swift */, DDE75D2A2DE5E613007C1FC1 /* NavigationRow.swift */, ); @@ -1396,27 +1413,27 @@ FC8DEEE32485D1680075863F /* LoopFollow */ = { isa = PBXGroup; children = ( - DDC7E5142DBCE1B900EB1127 /* Snoozer */, DDCF9A7E2D85FCE6004DF4DD /* Alarm */, - DD1A97122D429495000DDC11 /* Settings */, - DD2C2E4D2D3B8ACF006413A5 /* Nightscout */, - DD9ED0C62D355225000D2A63 /* Log */, - DDEF503D2D32753A00999A5D /* Task */, + FC16A97624995FEE003D6245 /* Application */, DDFF3D792D140F1800BF9D9E /* BackgroundRefresh */, DD50C74D2D0828250057AE6F /* Contact */, + FC16A9782499657E003D6245 /* Controllers */, + DD98F54224BCEF190007425A /* Extensions */, + FCC688542489367300A0279D /* Helpers */, + FC8DEEE62485D1ED0075863F /* Info.plist */, DD5334252C61667700062F9D /* InfoDisplaySettings */, - DD0C0C6E2C4AFFB800DBADDF /* Remote */, - DD0C0C692C4852A100DBADDF /* Metric */, DD13BC732C3FD60E0062313B /* InfoTable */, + DD9ED0C62D355225000D2A63 /* Log */, FCC688702489A57C00A0279D /* Loop Follow.entitlements */, - FC8DEEE62485D1ED0075863F /* Info.plist */, + DD0C0C692C4852A100DBADDF /* Metric */, + DD2C2E4D2D3B8ACF006413A5 /* Nightscout */, + DD0C0C6E2C4AFFB800DBADDF /* Remote */, FC7CE59A248D334B001F83B8 /* Resources */, - FCC68871248A736700A0279D /* ViewControllers */, - DD98F54224BCEF190007425A /* Extensions */, - FC16A9782499657E003D6245 /* Controllers */, - FCC688542489367300A0279D /* Helpers */, + DD1A97122D429495000DDC11 /* Settings */, + DDC7E5142DBCE1B900EB1127 /* Snoozer */, DDC7E5CD2DC6637800EB1127 /* Storage */, - FC16A97624995FEE003D6245 /* Application */, + DDEF503D2D32753A00999A5D /* Task */, + FCC68871248A736700A0279D /* ViewControllers */, ); path = LoopFollow; sourceTree = ""; @@ -1475,6 +1492,7 @@ FCFEEC9F2488157B00402A7F /* Chart.swift */, FCC0FAC124922A22003E610E /* DictionaryKeyPath.swift */, FC16A98024996C07003D6245 /* DateTime.swift */, + 654132E92E19F24800BDBE08 /* TOTPGenerator.swift */, FCE537BB249A4D7D00F80BF8 /* carbBolusArrays.swift */, FCEF87AA24A1417900AE6FA0 /* Localizer.swift */, FC1BDD2E24A232A3001B652C /* DataStructs.swift */, @@ -1583,6 +1601,7 @@ mainGroup = FC97880B2485969B00A7906C; packageReferences = ( DD48781A2C7DAF140048F05C /* XCRemoteSwiftPackageReference "Swift-JWT" */, + 654132E82E19F0B800BDBE08 /* XCRemoteSwiftPackageReference "swift-crypto" */, ); productRefGroup = FC9788152485969B00A7906C /* Products */; projectDirPath = ""; @@ -1853,6 +1872,7 @@ DD9ACA0E2D340BFF00415D8A /* AlarmTask.swift in Sources */, DDCF9A822D85FD15004DF4DD /* AlarmType.swift in Sources */, DD7FFAFD2A72953000C3A304 /* EKEventStore+Extensions.swift in Sources */, + 6541341A2E1DC27900BDBE08 /* OverridePresetData.swift in Sources */, FCC6886724898F8000A0279D /* UserDefaultsValue.swift in Sources */, DD7F4C092DD504A700D449E9 /* OverrideStartCondition.swift in Sources */, DDEF503F2D32754F00999A5D /* ProfileTask.swift in Sources */, @@ -1910,7 +1930,6 @@ DDD10F032C518A6500D76A8E /* TreatmentResponse.swift in Sources */, DDD10F0B2C54192A00D76A8E /* TemporaryTarget.swift in Sources */, DD7F4C1F2DD6648B00D449E9 /* FastRiseCondition.swift in Sources */, - DDDF6F472D479AD200884336 /* LoopOverrideViewModel.swift in Sources */, DD5334272C61668800062F9D /* InfoDisplaySettingsViewModel.swift in Sources */, DD0247592DB2E89600FCADF6 /* AlarmCondition.swift in Sources */, DD0650F32DCE9B3D004D3B41 /* MissedReadingEditor.swift in Sources */, @@ -1949,6 +1968,7 @@ FC1BDD3224A2585C001B652C /* DataStructs.swift in Sources */, DDF6999E2C5AAA640058A8D9 /* ErrorMessageView.swift in Sources */, DD1D52BB2E1EB60B00432050 /* MoreMenuViewController.swift in Sources */, + 654132E72E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift in Sources */, DD4878152C7B75230048F05C /* MealView.swift in Sources */, FC16A97F249969E2003D6245 /* Graphs.swift in Sources */, FC8589BF252B54F500C8FC73 /* Mobileprovision.swift in Sources */, @@ -1986,6 +2006,7 @@ DD493AD72ACF2139009A6922 /* SuspendPump.swift in Sources */, DDB9FC7F2DDB584500EFAA76 /* BolusEntry.swift in Sources */, FC9788182485969B00A7906C /* AppDelegate.swift in Sources */, + 654134182E1DC09700BDBE08 /* OverridePresetsView.swift in Sources */, DDD10F072C529DE800D76A8E /* Observable.swift in Sources */, DDFF3D852D14279B00BF9D9E /* BackgroundRefreshSettingsView.swift in Sources */, DDCF9A882D85FD33004DF4DD /* AlarmData.swift in Sources */, @@ -2014,7 +2035,6 @@ DDF699962C5582290058A8D9 /* TextFieldWithToolBar.swift in Sources */, DD91E4DD2BDEC3F8002D9E97 /* GlucoseConversion.swift in Sources */, DD0650EF2DCE96FF004D3B41 /* HighBGCondition.swift in Sources */, - DDDF6F452D479AB100884336 /* LoopOverrideView.swift in Sources */, DDC6CA472DD8D9010060EE25 /* PumpChangeAlarmEditor.swift in Sources */, DD4878132C7B750D0048F05C /* TempTargetView.swift in Sources */, DD0C0C682C48529400DBADDF /* Metric.swift in Sources */, @@ -2031,7 +2051,6 @@ DD4878192C7C56D60048F05C /* TrioNightscoutRemoteController.swift in Sources */, FC1BDD2B24A22650001B652C /* Stats.swift in Sources */, DDA9ACAC2D6B317100E6F1A9 /* ContactType.swift in Sources */, - DDDF6F432D479A9900884336 /* LoopNightscoutRemoteView.swift in Sources */, DDD10F052C529DA200D76A8E /* ObservableValue.swift in Sources */, FC1BDD2D24A23204001B652C /* MainViewController+updateStats.swift in Sources */, DD4878102C7B74BF0048F05C /* TrioRemoteControlView.swift in Sources */, @@ -2047,6 +2066,7 @@ DD7F4C112DD51ED900D449E9 /* TempTargetStartAlarmEditor.swift in Sources */, DD1D52B92E1EB5DC00432050 /* TabPosition.swift in Sources */, DD50C7552D0862770057AE6F /* ContactImageUpdater.swift in Sources */, + 654132EA2E19F24800BDBE08 /* TOTPGenerator.swift in Sources */, DD0650F52DCF303F004D3B41 /* AlarmStepperSection.swift in Sources */, DDCF9A802D85FD0B004DF4DD /* Alarm.swift in Sources */, DD7F4C132DD51FD500D449E9 /* TempTargetEndCondition.swift in Sources */, @@ -2066,6 +2086,11 @@ FC16A98124996C07003D6245 /* DateTime.swift in Sources */, FC3CAB022493B6220068A152 /* BackgroundTaskAudio.swift in Sources */, DDCC3A582DDC9655006F1C10 /* MissedBolusAlarmEditor.swift in Sources */, + DDEF50402D479B8A00884336 /* LoopAPNSService.swift in Sources */, + DDEF50422D479BAA00884336 /* LoopAPNSCarbsView.swift in Sources */, + DDEF50432D479BBA00884336 /* LoopAPNSBolusView.swift in Sources */, + DDEF50442D479BCA00884336 /* LoopAPNSSettingsView.swift in Sources */, + DDEF50452D479BDA00884336 /* LoopAPNSRemoteView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2351,6 +2376,14 @@ /* 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; + }; + }; DD48781A2C7DAF140048F05C /* XCRemoteSwiftPackageReference "Swift-JWT" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/Kitura/Swift-JWT.git"; diff --git a/LoopFollow.xcworkspace/xcshareddata/swiftpm/Package.resolved b/LoopFollow.xcworkspace/xcshareddata/swiftpm/Package.resolved index 807e518e..184f6cfe 100644 --- a/LoopFollow.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/LoopFollow.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "801a39fb95d2588f81665a9bc8eb33a63f406796d68926fcf6fdd6b655a0a6b0", + "originHash" : "7da8de315eadc567d44eb98fc0ead2ee437ac62830b8c28200a84636eb14e2f3", "pins" : [ { "identity" : "bluecryptor", @@ -46,6 +46,24 @@ "version" : "2.0.0" } }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "f70225981241859eb4aa1a18a75531d26637c8cc", + "version" : "1.4.0" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "e8d6eba1fef23ae5b359c46b03f7d94be2f41fed", + "version" : "3.12.3" + } + }, { "identity" : "swift-jwt", "kind" : "remoteSourceControl", diff --git a/LoopFollow/Helpers/NightscoutUtils.swift b/LoopFollow/Helpers/NightscoutUtils.swift index 0bd21bf2..947a21eb 100644 --- a/LoopFollow/Helpers/NightscoutUtils.swift +++ b/LoopFollow/Helpers/NightscoutUtils.swift @@ -2,8 +2,21 @@ // NightscoutUtils.swift // Created by bjorkert. +import CommonCrypto import Foundation +extension String { + var sha1: String { + let data = Data(utf8) + var digest = [UInt8](repeating: 0, count: Int(CC_SHA1_DIGEST_LENGTH)) + data.withUnsafeBytes { + _ = CC_SHA1($0.baseAddress, CC_LONG(data.count), &digest) + } + let hexBytes = digest.map { String(format: "%02hhx", $0) } + return hexBytes.joined() + } +} + class NightscoutUtils { enum NightscoutError: Error, LocalizedError { case emptyAddress diff --git a/LoopFollow/Helpers/TOTPGenerator.swift b/LoopFollow/Helpers/TOTPGenerator.swift new file mode 100644 index 00000000..2e4fa8c8 --- /dev/null +++ b/LoopFollow/Helpers/TOTPGenerator.swift @@ -0,0 +1,113 @@ +// LoopFollow +// TOTPGenerator.swift +// Created by codebymini. + +import CommonCrypto +import Foundation + +enum TOTPGenerator { + /// Generates a TOTP code from a base32 secret + /// - Parameter secret: The base32 encoded secret + /// - Returns: A 6-digit TOTP code as a string + static func generateTOTP(secret: String) -> String { + // Decode base32 secret + let decodedSecret = base32Decode(secret) + + // Get current time in 30-second intervals + let timeInterval = Int(Date().timeIntervalSince1970) + let timeStep = 30 + let counter = timeInterval / timeStep + + // Convert counter to 8-byte big-endian data + var counterData = Data() + for i in 0 ..< 8 { + counterData.append(UInt8((counter >> (56 - i * 8)) & 0xFF)) + } + + // Generate HMAC-SHA1 + let key = Data(decodedSecret) + let hmac = generateHMACSHA1(key: key, data: counterData) + + // Get the last 4 bits of the HMAC + let offset = Int(hmac.withUnsafeBytes { $0.last! } & 0x0F) + + // Extract 4 bytes starting at the offset + let hmacData = Data(hmac) + let codeBytes = hmacData.subdata(in: offset ..< (offset + 4)) + + // Convert to integer and get last 6 digits + let code = codeBytes.withUnsafeBytes { bytes in + let value = bytes.load(as: UInt32.self).bigEndian + return Int(value & 0x7FFF_FFFF) % 1_000_000 + } + + return String(format: "%06d", code) + } + + /// Extracts OTP from various URL formats + /// - Parameter urlString: The URL string to parse + /// - Returns: The OTP code as a string, or nil if not found + static func extractOTPFromURL(_ urlString: String) -> String? { + guard let url = URL(string: urlString), + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + else { + return nil + } + + // Check for TOTP format (otpauth://) + if url.scheme == "otpauth" { + if let secretItem = components.queryItems?.first(where: { $0.name == "secret" }), + let secret = secretItem.value + { + return generateTOTP(secret: secret) + } + } + + // Check for regular OTP format + if let otpItem = components.queryItems?.first(where: { $0.name == "otp" }) { + return otpItem.value + } + + return nil + } + + /// Decodes a base32 string to bytes + /// - Parameter string: The base32 encoded string + /// - Returns: Array of decoded bytes + private static func base32Decode(_ string: String) -> [UInt8] { + let alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" + var result: [UInt8] = [] + var buffer = 0 + var bitsLeft = 0 + + for char in string.uppercased() { + guard let index = alphabet.firstIndex(of: char) else { continue } + let value = alphabet.distance(from: alphabet.startIndex, to: index) + + buffer = (buffer << 5) | value + bitsLeft += 5 + + while bitsLeft >= 8 { + bitsLeft -= 8 + result.append(UInt8((buffer >> bitsLeft) & 0xFF)) + } + } + + return result + } + + /// Generates HMAC-SHA1 for the given key and data + /// - Parameters: + /// - key: The key to use for HMAC + /// - data: The data to hash + /// - Returns: The HMAC-SHA1 result as Data + private static func generateHMACSHA1(key: Data, data: Data) -> Data { + var hmac = [UInt8](repeating: 0, count: Int(CC_SHA1_DIGEST_LENGTH)) + key.withUnsafeBytes { keyBytes in + data.withUnsafeBytes { dataBytes in + CCHmac(CCHmacAlgorithm(kCCHmacAlgSHA1), keyBytes.baseAddress, key.count, dataBytes.baseAddress, data.count, &hmac) + } + } + return Data(hmac) + } +} diff --git a/LoopFollow/Helpers/Views/SimpleQRCodeScannerView.swift b/LoopFollow/Helpers/Views/SimpleQRCodeScannerView.swift new file mode 100644 index 00000000..348035c4 --- /dev/null +++ b/LoopFollow/Helpers/Views/SimpleQRCodeScannerView.swift @@ -0,0 +1,61 @@ +// LoopFollow +// SimpleQRCodeScannerView.swift +// Created by codebymini. + +import AVFoundation +import SwiftUI + +struct SimpleQRCodeScannerView: UIViewControllerRepresentable { + class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate { + var parent: SimpleQRCodeScannerView + + init(parent: SimpleQRCodeScannerView) { + self.parent = parent + } + + func metadataOutput(_: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from _: AVCaptureConnection) { + if let metadataObject = metadataObjects.first as? AVMetadataMachineReadableCodeObject, + metadataObject.type == .qr, + let stringValue = metadataObject.stringValue + { + parent.completion(.success(stringValue)) + parent.presentationMode.wrappedValue.dismiss() + } + } + } + + @Environment(\.presentationMode) var presentationMode + var completion: (Result) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + func makeUIViewController(context: Context) -> UIViewController { + let controller = UIViewController() + let session = AVCaptureSession() + + guard let videoCaptureDevice = AVCaptureDevice.default(for: .video) else { return controller } + guard let videoInput = try? AVCaptureDeviceInput(device: videoCaptureDevice) else { return controller } + if session.canAddInput(videoInput) { + session.addInput(videoInput) + } + + let metadataOutput = AVCaptureMetadataOutput() + if session.canAddOutput(metadataOutput) { + session.addOutput(metadataOutput) + metadataOutput.setMetadataObjectsDelegate(context.coordinator, queue: DispatchQueue.main) + metadataOutput.metadataObjectTypes = [.qr] + } + + let previewLayer = AVCaptureVideoPreviewLayer(session: session) + previewLayer.frame = controller.view.layer.bounds + previewLayer.videoGravity = .resizeAspectFill + controller.view.layer.addSublayer(previewLayer) + + session.startRunning() + return controller + } + + func updateUIViewController(_: UIViewController, context _: Context) {} +} diff --git a/LoopFollow/Info.plist b/LoopFollow/Info.plist index 5ab0c28b..15965bf6 100644 --- a/LoopFollow/Info.plist +++ b/LoopFollow/Info.plist @@ -57,6 +57,8 @@ This app requires access to contacts to update a contact image with real-time blood glucose information. NSFaceIDUsageDescription This app requires Face ID for secure authentication. + NSCameraUsageDescription + Used for scanning QR codes for remote authentication NSHumanReadableCopyright UIApplicationSceneManifest diff --git a/LoopFollow/Nightscout/NightscoutSettingsViewModel.swift b/LoopFollow/Nightscout/NightscoutSettingsViewModel.swift index 451494f5..25ac084d 100644 --- a/LoopFollow/Nightscout/NightscoutSettingsViewModel.swift +++ b/LoopFollow/Nightscout/NightscoutSettingsViewModel.swift @@ -96,12 +96,12 @@ class NightscoutSettingsViewModel: ObservableObject { } func checkNightscoutStatus() { - NightscoutUtils.verifyURLAndToken { error, _, nsWriteAuth, nsAdminAuth in + NightscoutUtils.verifyURLAndToken { [weak self] error, _, nsWriteAuth, nsAdminAuth in DispatchQueue.main.async { Storage.shared.nsWriteAuth.value = nsWriteAuth Storage.shared.nsAdminAuth.value = nsAdminAuth - self.updateStatusLabel(error: error) + self?.updateStatusLabel(error: error) } } } diff --git a/LoopFollow/Remote/Loop/LoopNightscoutRemoteView.swift b/LoopFollow/Remote/Loop/LoopNightscoutRemoteView.swift deleted file mode 100644 index dffe494d..00000000 --- a/LoopFollow/Remote/Loop/LoopNightscoutRemoteView.swift +++ /dev/null @@ -1,35 +0,0 @@ -// LoopFollow -// LoopNightscoutRemoteView.swift -// Created by Jonas Björkert. - -import SwiftUI - -struct LoopNightscoutRemoteView: View { - @Environment(\.presentationMode) var presentationMode - @ObservedObject var nsAdmin = Storage.shared.nsWriteAuth - - var body: some View { - NavigationView { - if !nsAdmin.value { - ErrorMessageView( - message: "Please update your token to include the 'admin' role in order to do remote commands with Loop." - ) - } else { - VStack { - let columns = [ - GridItem(.flexible(), spacing: 16), - GridItem(.flexible(), spacing: 16), - ] - - LazyVGrid(columns: columns, spacing: 16) { - CommandButtonView(command: "Overrides", iconName: "slider.horizontal.3", destination: LoopOverrideView()) - } - .padding(.horizontal) - - Spacer() - } - .navigationBarTitle("Loop Remote Control", displayMode: .inline) - } - } - } -} diff --git a/LoopFollow/Remote/Loop/LoopOverrideView.swift b/LoopFollow/Remote/Loop/LoopOverrideView.swift deleted file mode 100644 index f184cafa..00000000 --- a/LoopFollow/Remote/Loop/LoopOverrideView.swift +++ /dev/null @@ -1,226 +0,0 @@ -// LoopFollow -// LoopOverrideView.swift -// Created by Jonas Björkert. - -import HealthKit -import SwiftUI - -struct LoopOverrideView: View { - @Environment(\.presentationMode) private var presentationMode - - @ObservedObject var device = Storage.shared.device - @ObservedObject var overrideNote = Observable.shared.override - @ObservedObject var nsAdmin = Storage.shared.nsWriteAuth - - @StateObject private var viewModel = LoopOverrideViewModel() - - @State private var showAlert: Bool = false - @State private var alertType: AlertType? = nil - @State private var alertMessage: String? = nil - @State private var isLoading: Bool = false - @State private var statusMessage: String? = nil - - @State private var selectedOverride: ProfileManager.LoopOverride? = nil - @State private var showConfirmation: Bool = false - - @FocusState private var noteFieldIsFocused: Bool - - private let pushNotificationManager = PushNotificationManager() - private var profileManager = ProfileManager.shared - - enum AlertType { - case confirmActivation - case confirmCancellation - case statusSuccess - case statusFailure - case validation - } - - var body: some View { - NavigationView { - VStack { - if device.value != "Loop" { - ErrorMessageView( - message: "Remote commands are currently only available for Loop." - ) - } else if !nsAdmin.value { - ErrorMessageView( - message: "Please update your token to include the 'admin' role in order to do remote commands with Loop." - ) - } else { - Form { - if let activeNote = overrideNote.value { - Section(header: Text("Active Override")) { - HStack { - Text("Override") - Spacer() - Text(activeNote) - .foregroundColor(.secondary) - } - Button { - alertType = .confirmCancellation - showAlert = true - } label: { - HStack { - Text("Cancel Override") - Spacer() - Image(systemName: "xmark.app") - .font(.title) - } - } - .tint(.red) - } - Section { - Text("Setting a new override while one is active will cancel the active override and set the new one.") - .foregroundColor(.secondary) - } - } - - Section(header: Text("Available Overrides")) { - if profileManager.loopOverrides.isEmpty { - Text("No overrides available.") - .foregroundColor(.secondary) - } else { - ForEach(profileManager.loopOverrides, id: \.name) { override in - Button(action: { - selectedOverride = override - alertType = .confirmActivation - showAlert = true - }) { - HStack { - VStack(alignment: .leading) { - Text("\(override.symbol) \(override.name)") - .font(.headline) - Text("Duration: \(formattedDuration(from: override.duration))") - .font(.subheadline) - .foregroundColor(.secondary) - Text("Insulin Scale Factor: \(String(format: "%.2f", override.insulinNeedsScaleFactor))") - .font(.subheadline) - .foregroundColor(.secondary) - if !override.targetRange.isEmpty { - let range = override.targetRange.map { Localizer.formatQuantity($0) }.joined(separator: " - ") - Text("Target Range: \(range) \(Localizer.getPreferredUnit().localizedShortUnitString)") - .font(.subheadline) - .foregroundColor(.secondary) - } - } - Spacer() - Image(systemName: "arrow.right.circle") - .foregroundColor(.blue) - } - } - } - } - } - } - - if isLoading { - ProgressView("Please wait...") - .padding() - } - } - } - .navigationTitle("Loop Overrides") - .navigationBarTitleDisplayMode(.inline) - .alert(isPresented: $showAlert) { - switch alertType { - case .confirmActivation: - return Alert( - title: Text("Activate Override"), - message: Text("Do you want to activate the override '\(selectedOverride?.name ?? "")'?"), - primaryButton: .default(Text("Confirm"), action: { - if let override = selectedOverride { - activateOverride(override: override) - } - }), - secondaryButton: .cancel() - ) - case .confirmCancellation: - return Alert( - title: Text("Cancel Override"), - message: Text("Are you sure you want to cancel the active override?"), - primaryButton: .default(Text("Confirm"), action: { - cancelOverride() - }), - secondaryButton: .cancel() - ) - case .statusSuccess: - return Alert( - title: Text("Success"), - message: Text(statusMessage ?? ""), - dismissButton: .default(Text("OK"), action: { - presentationMode.wrappedValue.dismiss() - }) - ) - case .statusFailure: - return Alert( - title: Text("Error"), - message: Text(statusMessage ?? "An error occurred."), - dismissButton: .default(Text("OK")) - ) - case .validation: - return Alert( - title: Text("Validation Error"), - message: Text(alertMessage ?? "Invalid input."), - dismissButton: .default(Text("OK")) - ) - case .none: - return Alert(title: Text("Unknown Alert")) - } - } - } - } - - // MARK: - Functions - - private func formattedDuration(from duration: Int?) -> String { - guard let duration = duration, duration != 0 else { - return "Indefinitely" - } - - let hours = duration / 3600 - let minutes = (duration % 3600) / 60 - - if hours > 0 && minutes > 0 { - return "\(hours) hr, \(minutes) min" - } else if hours > 0 { - return "\(hours) hr" - } else { - return "\(minutes) min" - } - } - - private func activateOverride(override: ProfileManager.LoopOverride) { - isLoading = true - viewModel.sendActivateOverrideRequest(override: override) { success, message in - self.isLoading = false - if success { - self.statusMessage = "Override command sent successfully." - self.alertType = .statusSuccess - LogManager.shared.log(category: .nightscout, message: "LoopOverrideView: sendActivateOverrideRequest succeeded for override: \(override.name)") - } else { - self.statusMessage = message ?? "Failed to send override command." - self.alertType = .statusFailure - LogManager.shared.log(category: .nightscout, message: "LoopOverrideView: sendActivateOverrideRequest failed for override: \(override.name) with error: \(message ?? "unknown error")") - } - self.showAlert = true - } - } - - private func cancelOverride() { - isLoading = true - viewModel.sendCancelOverrideRequest { success, message in - self.isLoading = false - if success { - self.statusMessage = "Cancellation request successfully sent to Nightscout." - self.alertType = .statusSuccess - LogManager.shared.log(category: .nightscout, message: "LoopOverrideView: sendCancelOverrideRequest succeeded") - } else { - self.statusMessage = message ?? "Failed to cancel override." - self.alertType = .statusFailure - LogManager.shared.log(category: .nightscout, message: "LoopOverrideView: sendCancelOverrideRequest failed with error: \(message ?? "unknown error")") - } - self.showAlert = true - } - } -} diff --git a/LoopFollow/Remote/Loop/LoopOverrideViewModel.swift b/LoopFollow/Remote/Loop/LoopOverrideViewModel.swift deleted file mode 100644 index dd4a232a..00000000 --- a/LoopFollow/Remote/Loop/LoopOverrideViewModel.swift +++ /dev/null @@ -1,78 +0,0 @@ -// LoopFollow -// LoopOverrideViewModel.swift -// Created by Jonas Björkert. - -import Foundation - -final class LoopOverrideViewModel: ObservableObject, Sendable { - func sendActivateOverrideRequest( - override: ProfileManager.LoopOverride, - completion: @escaping (Bool, String?) -> Void - ) { - Task { - let body: [String: Any] = [ - "eventType": "Temporary Override", - "enteredBy": Storage.shared.user.value, - "reason": override.name, - "reasonDisplay": "\(override.symbol) \(override.name)", - ] - - do { - let response: String = try await NightscoutUtils.executePostRequest(eventType: .temporaryOverride, body: body) - DispatchQueue.main.async { - if response == "OK" { - Observable.shared.override.value = nil - NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil) - completion(true, response) - } else { - let errorTitle = NightscoutUtils.extractErrorReason(from: response) - let formattedError = self.formatErrorMessage(errorTitle) - completion(false, formattedError) - } - } - } catch { - DispatchQueue.main.async { - completion(false, nil) - } - } - } - } - - func sendCancelOverrideRequest(completion: @escaping (Bool, String?) -> Void) { - Task { - let body: [String: Any] = [ - "eventType": "Temporary Override Cancel", - ] - - do { - let response: String = try await NightscoutUtils.executePostRequest(eventType: .temporaryOverrideCancel, body: body) - DispatchQueue.main.async { - if response == "OK" { - Observable.shared.override.value = nil - NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil) - completion(true, response) - } else { - let errorTitle = NightscoutUtils.extractErrorReason(from: response) - let formattedError = self.formatErrorMessage(errorTitle) - completion(false, formattedError) - } - } - } catch { - DispatchQueue.main.async { - completion(false, nil) - } - } - } - } - - func formatErrorMessage(_ errorTitle: String) -> String { - switch errorTitle { - case "Unauthorized": - return "Unauthorized, verify that your token is correct and has admin auth" - case "APNs delivery failed: BadDeviceToken": - return "APNs delivery failed: BadDeviceToken, verify that the LOOP_PUSH_SERVER_ENVIRONMENT parameter in your Nightscout setup matches the build method used for Loop" - default: - return errorTitle - } - } -} diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift new file mode 100644 index 00000000..0cc01855 --- /dev/null +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift @@ -0,0 +1,276 @@ +// LoopFollow +// LoopAPNSBolusView.swift +// Created by codebymini. + +import HealthKit +import LocalAuthentication +import SwiftUI + +struct LoopAPNSBolusView: View { + @Environment(\.presentationMode) var presentationMode + @State private var insulinAmount = HKQuantity(unit: .internationalUnit(), doubleValue: 0.0) + @State private var isLoading = false + @State private var showAlert = false + @State private var alertMessage = "" + @State private var alertType: AlertType = .success + + @FocusState private var insulinFieldIsFocused: Bool + + // Add state for recommended bolus and warning + @State private var recommendedBolus: Double? = nil + @State private var lastLoopTime: TimeInterval? = nil + + enum AlertType { + case success + case error + case confirmation + } + + var body: some View { + NavigationView { + VStack { + Form { + Section { + HKQuantityInputView( + label: "Insulin Amount", + quantity: $insulinAmount, + unit: .internationalUnit(), + maxLength: 4, + minValue: HKQuantity(unit: .internationalUnit(), doubleValue: 0.05), + maxValue: Storage.shared.maxBolus.value, + isFocused: $insulinFieldIsFocused, + onValidationError: { message in + alertMessage = message + alertType = .error + showAlert = true + } + ) + } + + // Add warning section if recommended bolus is available + if let recommendedBolus = recommendedBolus, let lastLoopTime = lastLoopTime { + Section(header: Text("Warning")) { + VStack(alignment: .leading, spacing: 8) { + Text("WARNING: New treatments may have occurred since the last recommended bolus was calculated \(presentableMinutesFormat(timeInterval: Date().timeIntervalSince1970 - lastLoopTime)) ago.") + .font(.callout) + .foregroundColor(.red) + .multilineTextAlignment(.leading) + } + } + } + Section { + Button(action: sendInsulin) { + if isLoading { + HStack { + ProgressView() + .scaleEffect(0.8) + Text("Sending...") + } + } else { + Text("Send Insulin") + } + } + .disabled(insulinAmount.doubleValue(for: .internationalUnit()) <= 0 || isLoading) + .frame(maxWidth: .infinity) + } + Section(header: Text("Security")) { + VStack(alignment: .leading) { + Text("Current OTP Code") + .font(.headline) + if let otpCode = TOTPGenerator.extractOTPFromURL(Storage.shared.loopAPNSQrCodeURL.value) { + Text(otpCode) + .font(.system(.body, design: .monospaced)) + .foregroundColor(.green) + .padding(.vertical, 4) + .padding(.horizontal, 8) + .background(Color.green.opacity(0.1)) + .cornerRadius(4) + } else { + Text("Invalid QR code URL") + .foregroundColor(.red) + } + } + } + } + .navigationTitle("Insulin") + .navigationBarTitleDisplayMode(.inline) + } + + .onAppear { + // Validate APNS setup + let apnsService = LoopAPNSService() + if !apnsService.validateSetup() { + alertMessage = "Loop APNS setup is incomplete. Please configure all required fields in settings." + alertType = .error + showAlert = true + } + + loadRecommendedBolus() + } + .alert(isPresented: $showAlert) { + switch alertType { + case .success: + return Alert( + title: Text("Success"), + message: Text(alertMessage), + dismissButton: .default(Text("OK")) { + presentationMode.wrappedValue.dismiss() + } + ) + case .error: + return Alert( + title: Text("Error"), + message: Text(alertMessage), + dismissButton: .default(Text("OK")) + ) + case .confirmation: + return Alert( + title: Text("Confirm Insulin"), + message: Text("Send \(String(format: "%.1f", insulinAmount.doubleValue(for: .internationalUnit()))) units of insulin?"), + primaryButton: .default(Text("Send")) { + authenticateAndSendInsulin() + }, + secondaryButton: .cancel() + ) + } + } + } + } + + private func loadRecommendedBolus() { + // Load recommended bolus from Observable + recommendedBolus = Observable.shared.deviceRecBolus.value + lastLoopTime = Observable.shared.alertLastLoopTime.value + + // Pre-fill the insulin amount with recommended bolus if available + if let recommendedBolus = recommendedBolus, recommendedBolus > 0 { + insulinAmount = HKQuantity(unit: .internationalUnit(), doubleValue: recommendedBolus) + } + } + + private func presentableMinutesFormat(timeInterval: TimeInterval) -> String { + let minutes = Int(timeInterval / 60) + var result = "\(minutes) minute" + if minutes == 0 || minutes > 1 { + result += "s" + } + return result + } + + private func sendInsulin() { + guard insulinAmount.doubleValue(for: .internationalUnit()) > 0 else { + alertMessage = "Please enter a valid insulin amount" + alertType = .error + showAlert = true + return + } + + // Check guardrails + let maxBolus = Storage.shared.maxBolus.value.doubleValue(for: .internationalUnit()) + let insulinValue = insulinAmount.doubleValue(for: .internationalUnit()) + + if insulinValue > maxBolus { + alertMessage = "Insulin amount (\(String(format: "%.1f", insulinValue))U) exceeds the maximum allowed (\(String(format: "%.1f", maxBolus))U). Please reduce the amount." + alertType = .error + showAlert = true + return + } + + alertType = .confirmation + showAlert = true + } + + private func authenticateAndSendInsulin() { + let context = LAContext() + var error: NSError? + + let reason = "Confirm your identity to send insulin." + + if context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) { + context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { success, _ in + DispatchQueue.main.async { + if success { + sendInsulinConfirmed() + } else { + alertMessage = "Authentication failed" + alertType = .error + showAlert = true + } + } + } + } else if context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) { + context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { success, _ in + DispatchQueue.main.async { + if success { + sendInsulinConfirmed() + } else { + alertMessage = "Authentication failed" + alertType = .error + showAlert = true + } + } + } + } else { + alertMessage = "Biometric authentication not available" + alertType = .error + showAlert = true + } + } + + private func sendInsulinConfirmed() { + isLoading = true + + // Extract OTP from QR code URL + guard let otpCode = TOTPGenerator.extractOTPFromURL(Storage.shared.loopAPNSQrCodeURL.value) else { + alertMessage = "Invalid QR code URL. Please re-scan the QR code in settings." + alertType = .error + isLoading = false + showAlert = true + return + } + + let payload = LoopAPNSPayload( + type: .bolus, + bolusAmount: insulinAmount.doubleValue(for: .internationalUnit()), + otp: otpCode + ) + + Task { + do { + let apnsService = LoopAPNSService() + let success = try await apnsService.sendBolusViaAPNS(payload: payload) + + DispatchQueue.main.async { + isLoading = false + if success { + alertMessage = "Insulin sent successfully!" + alertType = .success + LogManager.shared.log( + category: .apns, + message: "Insulin sent - Amount: \(insulinAmount.doubleValue(for: .internationalUnit()))U" + ) + } else { + alertMessage = "Failed to send insulin. Check your Loop APNS configuration." + alertType = .error + LogManager.shared.log( + category: .apns, + message: "Failed to send insulin" + ) + } + showAlert = true + } + } catch { + DispatchQueue.main.async { + isLoading = false + alertMessage = "Error sending insulin: \(error.localizedDescription)" + alertType = .error + LogManager.shared.log( + category: .apns, + message: "APNS insulin error: \(error.localizedDescription)" + ) + showAlert = true + } + } + } + } +} diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift new file mode 100644 index 00000000..997cfa22 --- /dev/null +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift @@ -0,0 +1,254 @@ +// LoopFollow +// LoopAPNSCarbsView.swift +// Created by codebymini. + +import HealthKit +import SwiftUI + +struct LoopAPNSCarbsView: View { + @Environment(\.presentationMode) var presentationMode + @State private var carbsAmount = HKQuantity(unit: .gram(), doubleValue: 0.0) + @State private var absorptionTime = HKQuantity(unit: .hour(), doubleValue: 3.0) + @State private var foodType = "" + @State private var isLoading = false + @State private var showAlert = false + @State private var alertMessage = "" + @State private var alertType: AlertType = .success + + @FocusState private var carbsFieldIsFocused: Bool + @FocusState private var absorptionFieldIsFocused: Bool + + enum AlertType { + case success + case error + case confirmation + } + + var body: some View { + NavigationView { + VStack { + Form { + Section { + HKQuantityInputView( + label: "Carbs Amount", + quantity: $carbsAmount, + unit: .gram(), + maxLength: 4, + minValue: HKQuantity(unit: .gram(), doubleValue: 1.0), + maxValue: Storage.shared.maxCarbs.value, + isFocused: $carbsFieldIsFocused, + onValidationError: { message in + alertMessage = message + alertType = .error + showAlert = true + } + ) + + HKQuantityInputView( + label: "Absorption Time", + quantity: $absorptionTime, + unit: .hour(), + maxLength: 3, + minValue: HKQuantity(unit: .hour(), doubleValue: 1.0), + maxValue: HKQuantity(unit: .hour(), doubleValue: 8.0), + isFocused: $absorptionFieldIsFocused, + onValidationError: { message in + alertMessage = message + alertType = .error + showAlert = true + } + ) + + VStack(alignment: .leading) { + Text("Food Type (optional)") + .font(.headline) + TextField("e.g., Breakfast, Lunch, Snack", text: $foodType) + .autocapitalization(.none) + .disableAutocorrection(true) + } + } + Section { + Button(action: sendCarbs) { + if isLoading { + HStack { + ProgressView() + .scaleEffect(0.8) + Text("Sending...") + } + } else { + Text("Send Carbs") + } + } + .disabled(carbsAmount.doubleValue(for: .gram()) <= 0 || isLoading) + .frame(maxWidth: .infinity) + } + + Section(header: Text("Security")) { + VStack(alignment: .leading) { + Text("Current OTP Code") + .font(.headline) + if let otpCode = TOTPGenerator.extractOTPFromURL(Storage.shared.loopAPNSQrCodeURL.value) { + Text(otpCode) + .font(.system(.body, design: .monospaced)) + .foregroundColor(.green) + .padding(.vertical, 4) + .padding(.horizontal, 8) + .background(Color.green.opacity(0.1)) + .cornerRadius(4) + } else { + Text("Invalid QR code URL") + .foregroundColor(.red) + } + } + } + } + .navigationTitle("Carbs") + .navigationBarTitleDisplayMode(.inline) + } + + .onAppear { + // Validate APNS setup + let apnsService = LoopAPNSService() + if !apnsService.validateSetup() { + alertMessage = "Loop APNS setup is incomplete. Please configure all required fields in settings." + alertType = .error + showAlert = true + } + } + .alert(isPresented: $showAlert) { + switch alertType { + case .success: + return Alert( + title: Text("Success"), + message: Text(alertMessage), + dismissButton: .default(Text("OK")) { + presentationMode.wrappedValue.dismiss() + } + ) + case .error: + return Alert( + title: Text("Error"), + message: Text(alertMessage), + dismissButton: .default(Text("OK")) + ) + case .confirmation: + return Alert( + title: Text("Confirm Carbs"), + message: Text("Send \(Int(carbsAmount.doubleValue(for: .gram())))g of carbs with \(Int(absorptionTime.doubleValue(for: .hour())))h absorption time?"), + primaryButton: .default(Text("Send")) { + sendCarbsConfirmed() + }, + secondaryButton: .cancel() + ) + } + } + } + } + + private func sendCarbs() { + guard carbsAmount.doubleValue(for: .gram()) > 0 else { + alertMessage = "Please enter a valid carb amount" + alertType = .error + showAlert = true + return + } + + // Check guardrails + let maxCarbs = Storage.shared.maxCarbs.value.doubleValue(for: .gram()) + let carbsValue = carbsAmount.doubleValue(for: .gram()) + + if carbsValue > maxCarbs { + alertMessage = "Carbs amount (\(Int(carbsValue))g) exceeds the maximum allowed (\(Int(maxCarbs))g). Please reduce the amount." + alertType = .error + showAlert = true + return + } + + alertType = .confirmation + showAlert = true + } + + private func sendCarbsConfirmed() { + isLoading = true + + // Extract OTP from QR code URL + guard let otpCode = TOTPGenerator.extractOTPFromURL(Storage.shared.loopAPNSQrCodeURL.value) else { + alertMessage = "Invalid QR code URL. Please re-scan the QR code in settings." + alertType = .error + isLoading = false + showAlert = true + return + } + + // Create the APNS payload for carbs + let payload = LoopAPNSPayload( + type: .carbs, + carbsAmount: carbsAmount.doubleValue(for: .gram()), + absorptionTime: absorptionTime.doubleValue(for: .hour()), + foodType: foodType.isEmpty ? nil : foodType, + otp: otpCode + ) + + Task { + do { + let apnsService = LoopAPNSService() + let success = try await apnsService.sendCarbsViaAPNS(payload: payload) + + DispatchQueue.main.async { + isLoading = false + if success { + alertMessage = "Carbs sent successfully!" + alertType = .success + LogManager.shared.log( + category: .apns, + message: "Carbs sent - Amount: \(carbsAmount.doubleValue(for: .gram()))g, Absorption: \(absorptionTime.doubleValue(for: .hour()))h" + ) + } else { + alertMessage = "Failed to send carbs. Check your Loop APNS configuration." + alertType = .error + LogManager.shared.log( + category: .apns, + message: "Failed to send carbs" + ) + } + showAlert = true + } + } catch { + DispatchQueue.main.async { + isLoading = false + alertMessage = "Error sending carbs: \(error.localizedDescription)" + alertType = .error + LogManager.shared.log( + category: .apns, + message: "APNS carbs error: \(error.localizedDescription)" + ) + showAlert = true + } + } + } + } +} + +// APNS Payload structure for carbs +struct LoopAPNSPayload { + enum PayloadType { + case carbs + case bolus + } + + let type: PayloadType + let carbsAmount: Double? + let absorptionTime: Double? + let foodType: String? + let bolusAmount: Double? + let otp: String + + init(type: PayloadType, carbsAmount: Double? = nil, absorptionTime: Double? = nil, foodType: String? = nil, bolusAmount: Double? = nil, otp: String) { + self.type = type + self.carbsAmount = carbsAmount + self.absorptionTime = absorptionTime + self.foodType = foodType + self.bolusAmount = bolusAmount + self.otp = otp + } +} diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSRemoteView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSRemoteView.swift new file mode 100644 index 00000000..27f846e4 --- /dev/null +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSRemoteView.swift @@ -0,0 +1,60 @@ +// LoopFollow +// LoopAPNSRemoteView.swift +// Created by codebymini. + +import SwiftUI + +struct LoopAPNSRemoteView: View { + @Environment(\.presentationMode) var presentationMode + @ObservedObject var loopAPNSSetup = Storage.shared.loopAPNSSetup + + var body: some View { + NavigationView { + VStack { + let columns = [ + GridItem(.flexible(), spacing: 16), + GridItem(.flexible(), spacing: 16), + ] + + LazyVGrid(columns: columns, spacing: 16) { + if loopAPNSSetup.value { + // Show Loop APNS command buttons if APNS setup configured + CommandButtonView(command: "Carbs", iconName: "fork.knife.circle", destination: LoopAPNSCarbsView()) + CommandButtonView(command: "Bolus", iconName: "syringe.fill", destination: LoopAPNSBolusView()) + CommandButtonView(command: "Overrides", iconName: "slider.horizontal.3", destination: OverridePresetsView()) + } else { + // Show setup message if APNS is not configured + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 48)) + .foregroundColor(.orange) + + Text("Loop APNS Not Configured") + .font(.headline) + + Text("Please configure Loop APNS settings in Remote Settings to use APNS commands.") + .font(.body) + .multilineTextAlignment(.center) + .foregroundColor(.secondary) + + NavigationLink(destination: LoopAPNSSettingsView()) { + HStack { + Image(systemName: "gear") + Text("Configure Loop APNS") + } + .padding() + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(8) + } + } + .padding() + } + } + .padding(.horizontal) + Spacer() + } + .navigationBarTitle("Loop Remote Control", displayMode: .inline) + } + } +} diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift new file mode 100644 index 00000000..d04adf9a --- /dev/null +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift @@ -0,0 +1,981 @@ +// LoopFollow +// LoopAPNSService.swift +// Created by codebymini. + +import CryptoKit +import Foundation +import HealthKit +import SwiftJWT + +class LoopAPNSService { + private let storage = Storage.shared + + struct DeviceTokenResponse: Codable { + let deviceToken: String? + let bundleIdentifier: String? + } + + struct Profile: Codable { + let loopSettings: LoopSettings? + } + + struct LoopSettings: Codable { + let deviceToken: String? + let bundleIdentifier: String? + } + + struct LoopAPNSJWTClaims: Claims { + let iss: String + let iat: Date + } + + enum LoopAPNSError: Error, LocalizedError { + case invalidURL + case networkError + case invalidResponse + case noDeviceToken + case noBundleIdentifier + case unauthorized + case deviceTokenNotConfigured + case bundleIdentifierNotConfigured + case rateLimited + + var errorDescription: String? { + switch self { + case .invalidURL: + return "Invalid Nightscout URL" + case .networkError: + return "Network error occurred" + case .invalidResponse: + return "Invalid response from server" + case .noDeviceToken: + return "No device token found in profile" + case .noBundleIdentifier: + return "No bundle identifier found in profile" + case .unauthorized: + return "Unauthorized - check your API secret" + case .deviceTokenNotConfigured: + return "Device token not configured" + case .bundleIdentifierNotConfigured: + return "Bundle identifier not configured" + case .rateLimited: + return "Too many requests - please wait a few minutes before trying again" + } + } + } + + /// Fetches the device token from Nightscout profile endpoint + /// - Returns: A tuple containing device token and bundle identifier + func fetchDeviceToken() async throws -> (deviceToken: String, bundleIdentifier: String) { + let nightscoutURL = storage.url.value + let token = storage.token.value + + guard !nightscoutURL.isEmpty else { + throw LoopAPNSError.invalidURL + } + + guard !token.isEmpty else { + throw LoopAPNSError.unauthorized + } + + guard let url = NightscoutUtils.constructURL(baseURL: nightscoutURL, token: token, endpoint: "/api/v1/profile", parameters: [:]) else { + throw LoopAPNSError.invalidURL + } + + var request = URLRequest(url: url) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + do { + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw LoopAPNSError.networkError + } + + switch httpResponse.statusCode { + case 200: + let profiles = try JSONDecoder().decode([Profile].self, from: data) + + guard let firstProfile = profiles.first, + let loopSettings = firstProfile.loopSettings + else { + LogManager.shared.log(category: .apns, message: "Invalid response structure from Nightscout profile endpoint") + throw LoopAPNSError.invalidResponse + } + + guard let deviceToken = loopSettings.deviceToken, !deviceToken.isEmpty else { + LogManager.shared.log(category: .apns, message: "No device token found in Nightscout profile") + throw LoopAPNSError.noDeviceToken + } + + guard let bundleIdentifier = loopSettings.bundleIdentifier, !bundleIdentifier.isEmpty else { + LogManager.shared.log(category: .apns, message: "No bundle identifier found in Nightscout profile") + throw LoopAPNSError.noBundleIdentifier + } + + LogManager.shared.log(category: .apns, message: "Successfully retrieved device token from Nightscout") + return (deviceToken: deviceToken, bundleIdentifier: bundleIdentifier) + + case 401: + LogManager.shared.log(category: .apns, message: "Unauthorized access to Nightscout profile endpoint") + throw LoopAPNSError.unauthorized + + default: + LogManager.shared.log(category: .apns, message: "HTTP error \(httpResponse.statusCode) from Nightscout profile endpoint") + throw LoopAPNSError.networkError + } + } catch { + if error is LoopAPNSError { + throw error + } else { + LogManager.shared.log(category: .apns, message: "Network request failed with error: \(error.localizedDescription)") + throw LoopAPNSError.networkError + } + } + } + + /// Validates the Loop APNS setup by checking all required fields + /// - Returns: True if setup is valid, false otherwise + func validateSetup() -> Bool { + let hasKeyId = !storage.loopAPNSKeyId.value.isEmpty + let hasAPNSKey = !storage.loopAPNSKey.value.isEmpty + let hasQrCode = !storage.loopAPNSQrCodeURL.value.isEmpty + let hasDeviceToken = !storage.loopAPNSDeviceToken.value.isEmpty + let hasBundleIdentifier = !storage.loopAPNSBundleIdentifier.value.isEmpty + + // For initial setup, we don't require device token and bundle identifier + // These will be fetched when the user clicks "Refresh Device Token" + let hasBasicSetup = hasKeyId && hasAPNSKey && hasQrCode + + // For full validation (after device token is fetched), check everything + let hasFullSetup = hasBasicSetup && hasDeviceToken && hasBundleIdentifier + + return hasFullSetup + } + + /// Validates the basic Loop APNS setup (without device token) + /// - Returns: True if basic setup is valid, false otherwise + func validateBasicSetup() -> Bool { + let hasKeyId = !storage.loopAPNSKeyId.value.isEmpty + let hasAPNSKey = !storage.loopAPNSKey.value.isEmpty + let hasQrCode = !storage.loopAPNSQrCodeURL.value.isEmpty + + let isValid = hasKeyId && hasAPNSKey && hasQrCode + + // Log validation results for debugging + LogManager.shared.log(category: .apns, message: "Basic setup validation - Key ID: \(hasKeyId), APNS Key: \(hasAPNSKey), QR Code: \(hasQrCode), Valid: \(isValid)") + LogManager.shared.log(category: .apns, message: "QR Code URL: \(storage.loopAPNSQrCodeURL.value)") + + // Additional APNS key validation + if hasAPNSKey { + validateAPNSKeyFormat() + } + return isValid + } + + /// Sets a test device token for testing purposes + /// - Parameter testToken: The test device token to use + func setTestDeviceToken(_ testToken: String) { + storage.loopAPNSDeviceToken.value = testToken + LogManager.shared.log(category: .apns, message: "Test device token set: \(testToken)") + } + + /// Validates the APNS key format and provides debugging information + private func validateAPNSKeyFormat() { + let apnsKey = storage.loopAPNSKey.value + let keyId = storage.loopAPNSKeyId.value + let teamId = storage.teamId.value ?? keyId + + // Validate key format + let hasPrivateKeyHeader = apnsKey.contains("-----BEGIN PRIVATE KEY-----") + let hasEndHeader = apnsKey.contains("-----END PRIVATE KEY-----") + let keyLines = apnsKey.components(separatedBy: .newlines) + let keyLineCount = keyLines.count + + LogManager.shared.log(category: .apns, message: "APNS Key validation:") + LogManager.shared.log(category: .apns, message: "- Has PKCS8 header: \(hasPrivateKeyHeader)") + LogManager.shared.log(category: .apns, message: "- Has end header: \(hasEndHeader)") + LogManager.shared.log(category: .apns, message: "- Total lines: \(keyLineCount)") + LogManager.shared.log(category: .apns, message: "- Key ID: \(keyId)") + LogManager.shared.log(category: .apns, message: "- Team ID: \(teamId)") + + // Validate key ID and team ID format + let keyIdPattern = "^[A-Z0-9]{10}$" + let teamIdPattern = "^[A-Z0-9]{10}$" + let isValidKeyId = keyId.range(of: keyIdPattern, options: .regularExpression) != nil + let isValidTeamId = teamId.range(of: teamIdPattern, options: .regularExpression) != nil + + LogManager.shared.log(category: .apns, message: "- Key ID format valid: \(isValidKeyId)") + LogManager.shared.log(category: .apns, message: "- Team ID format valid: \(isValidTeamId)") + + if !isValidKeyId || !isValidTeamId { + LogManager.shared.log(category: .apns, message: "WARNING: Key ID or Team ID format is invalid") + } + + // Additional debugging for key format issues + if keyLineCount == 1 { + LogManager.shared.log(category: .apns, message: "WARNING: APNS Key appears to be on a single line - this may cause JWT creation to fail") + LogManager.shared.log(category: .apns, message: "Key length: \(apnsKey.count) characters") + LogManager.shared.log(category: .apns, message: "Key starts with: \(String(apnsKey.prefix(50)))") + LogManager.shared.log(category: .apns, message: "Key ends with: \(String(apnsKey.suffix(50)))") + } else { + LogManager.shared.log(category: .apns, message: "APNS Key appears to have proper line breaks (\(keyLineCount) lines)") + } + } + + /// Refreshes the device token from Nightscout + /// - Returns: True if successful, false otherwise + func refreshDeviceToken() async -> Bool { + do { + let (deviceToken, bundleIdentifier) = try await fetchDeviceToken() + + DispatchQueue.main.async { + self.storage.loopAPNSDeviceToken.value = deviceToken + self.storage.loopAPNSBundleIdentifier.value = bundleIdentifier + } + + return true + } catch { + LogManager.shared.log(category: .apns, message: "Failed to refresh device token: \(error.localizedDescription)") + + // Log additional debugging information + let nightscoutURL = storage.url.value + let token = storage.token.value + + LogManager.shared.log(category: .apns, message: "Nightscout URL: \(nightscoutURL.isEmpty ? "Not configured" : nightscoutURL)") + LogManager.shared.log(category: .apns, message: "Token: \(token.isEmpty ? "Not configured" : "Configured")") + + return false + } + } + + // Helper to ensure we have a valid device token and bundle identifier + private func getValidDeviceTokenAndBundle() async throws -> (deviceToken: String, bundleIdentifier: String) { + var deviceToken = storage.loopAPNSDeviceToken.value + var bundleIdentifier = storage.loopAPNSBundleIdentifier.value + if deviceToken.isEmpty { + LogManager.shared.log(category: .apns, message: "Device token is empty or test token, refreshing from Nightscout...") + let refreshSuccess = await refreshDeviceToken() + if !refreshSuccess { + throw LoopAPNSError.noDeviceToken + } + deviceToken = storage.loopAPNSDeviceToken.value + bundleIdentifier = storage.loopAPNSBundleIdentifier.value + } + + return (deviceToken, bundleIdentifier) + } + + /// Sends carbs via APNS push notification + /// - Parameter payload: The carbs payload to send + /// - Returns: True if successful, false otherwise + func sendCarbsViaAPNS(payload: LoopAPNSPayload) async throws -> Bool { + guard validateSetup() else { + throw LoopAPNSError.invalidURL + } + let (deviceToken, bundleIdentifier) = try await getValidDeviceTokenAndBundle() + let keyId = storage.loopAPNSKeyId.value + let apnsKey = storage.loopAPNSKey.value + + // Create APNS notification payload (matching Loop's expected format) + let now = Date() + let expiration = Date(timeIntervalSinceNow: 5 * 60) // 5 minutes from now + + // Create the complete notification payload (matching Nightscout's exact format) + // Based on Nightscout's loop.js implementation + let carbsAmount = payload.carbsAmount ?? 0.0 + let absorptionTime = payload.absorptionTime ?? 3.0 + var finalPayload = [ + "carbs-entry": carbsAmount, + "absorption-time": absorptionTime, + "otp": String(payload.otp), + "remote-address": "LoopFollow", + "notes": "Sent via LoopFollow APNS", + "entered-by": "LoopFollow", + "sent-at": formatDateForAPNS(now), + "expiration": formatDateForAPNS(expiration), + "start-time": formatDateForAPNS(now), + "alert": "Remote Carbs Entry: \(String(format: "%.1f", carbsAmount)) grams\nAbsorption Time: \(String(format: "%.1f", absorptionTime)) hours", + ] as [String: Any] + + // Log the exact carbs amount for debugging precision issues + LogManager.shared.log(category: .apns, message: "Carbs amount - Raw: \(payload.carbsAmount ?? 0.0), Formatted: \(String(format: "%.1f", carbsAmount)), JSON: \(carbsAmount)") + LogManager.shared.log(category: .apns, message: "Absorption time - Raw: \(payload.absorptionTime ?? 3.0), Formatted: \(String(format: "%.1f", absorptionTime)), JSON: \(absorptionTime)") + + // Log the final payload for debugging + if let payloadData = try? JSONSerialization.data(withJSONObject: finalPayload), + let payloadString = String(data: payloadData, encoding: .utf8) + { + LogManager.shared.log(category: .apns, message: "Final payload being sent: \(payloadString)") + } + return try await sendAPNSNotification( + deviceToken: deviceToken, + bundleIdentifier: bundleIdentifier, + keyId: keyId, + apnsKey: apnsKey, + payload: finalPayload + ) + } + + /// Sends bolus via APNS push notification + /// - Parameter payload: The bolus payload to send + /// - Returns: True if successful, false otherwise + func sendBolusViaAPNS(payload: LoopAPNSPayload) async throws -> Bool { + guard validateSetup() else { + throw LoopAPNSError.invalidURL + } + let (deviceToken, bundleIdentifier) = try await getValidDeviceTokenAndBundle() + let keyId = storage.loopAPNSKeyId.value + let apnsKey = storage.loopAPNSKey.value + + // Create APNS notification payload (matching Loop's expected format) + let now = Date() + let expiration = Date(timeIntervalSinceNow: 5 * 60) // 5 minutes from now + + // Create the complete notification payload (matching Nightscout's exact format) + // Based on Nightscout's loop.js implementation + let bolusAmount = payload.bolusAmount ?? 0.0 + var finalPayload = [ + "bolus-entry": bolusAmount, + "otp": String(payload.otp), + "remote-address": "LoopFollow", + "notes": "Sent via LoopFollow APNS", + "entered-by": "LoopFollow", + "sent-at": formatDateForAPNS(now), + "expiration": formatDateForAPNS(expiration), + "alert": "Remote Bolus Entry: \(String(format: "%.2f", bolusAmount)) U", + ] as [String: Any] + + // Log the exact bolus amount for debugging precision issues + LogManager.shared.log(category: .apns, message: "Bolus amount - Raw: \(payload.bolusAmount ?? 0.0), Formatted: \(String(format: "%.2f", bolusAmount)), JSON: \(bolusAmount)") + + // Log the final payload for debugging + if let payloadData = try? JSONSerialization.data(withJSONObject: finalPayload), + let payloadString = String(data: payloadData, encoding: .utf8) + { + LogManager.shared.log(category: .apns, message: "Final payload being sent: \(payloadString)") + } + return try await sendAPNSNotification( + deviceToken: deviceToken, + bundleIdentifier: bundleIdentifier, + keyId: keyId, + apnsKey: apnsKey, + payload: finalPayload + ) + } + + /// Sends an APNS notification + /// - Parameters: + /// - deviceToken: The device token to send to + /// - bundleIdentifier: The bundle identifier + /// - keyId: The APNS key ID + /// - apnsKey: The APNS key + /// - payload: The notification payload + /// - Returns: True if successful, false otherwise + private func sendAPNSNotification( + deviceToken: String, + bundleIdentifier: String, + keyId: String, + apnsKey: String, + payload: [String: Any] + ) async throws -> Bool { + // Create JWT token for APNS authentication + let jwt: String + do { + jwt = try createAPNSJWT(keyId: keyId, apnsKey: apnsKey, bundleIdentifier: bundleIdentifier) + } catch { + LogManager.shared.log(category: .apns, message: "Failed to create JWT: \(error.localizedDescription)") + throw LoopAPNSError.invalidURL + } + + // Determine APNS environment + let isProduction = storage.productionEnvironment.value + let apnsEnvironment = isProduction ? "production" : "development" + let apnsURL = isProduction ? "https://api.push.apple.com" : "https://api.sandbox.push.apple.com" + let requestURL = URL(string: "\(apnsURL)/3/device/\(deviceToken)")! + var request = URLRequest(url: requestURL) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "content-type") + request.setValue("bearer \(jwt)", forHTTPHeaderField: "authorization") + request.setValue(bundleIdentifier, forHTTPHeaderField: "apns-topic") + request.setValue("alert", forHTTPHeaderField: "apns-push-type") + request.setValue("10", forHTTPHeaderField: "apns-priority") // High priority + + // Log request details for debugging + LogManager.shared.log(category: .apns, message: "APNS Request URL: \(requestURL)") + LogManager.shared.log(category: .apns, message: "APNS Request Headers - Authorization: Bearer \(jwt.prefix(50))..., Topic: \(bundleIdentifier)") + + // Validate bundle identifier format + if !bundleIdentifier.contains(".") { + LogManager.shared.log(category: .apns, message: "Warning: Bundle identifier may be in wrong format: \(bundleIdentifier)") + } + + // Validate device token format (should be 64 hex characters) + let deviceTokenLength = deviceToken.count + let isHexToken = deviceToken.range(of: "^[0-9A-Fa-f]{64}$", options: .regularExpression) != nil + LogManager.shared.log(category: .apns, message: "Device token validation - Length: \(deviceTokenLength), Is hex: \(isHexToken)") + + // Create the proper APNS payload structure (matching @parse/node-apn format) + var apnsPayload: [String: Any] = [ + "aps": [ + "alert": payload["alert"] as? String ?? "", + "content-available": 1, + "interruption-level": "time-sensitive", + ], + ] + + // Add all the custom payload fields (excluding APNS-specific fields) + for (key, value) in payload { + if key != "alert" && key != "content-available" && key != "interruption-level" { + apnsPayload[key] = value + } + } + + // Remove nil values to clean up the payload + let cleanPayload = apnsPayload.compactMapValues { $0 } + + let jsonData: Data + do { + jsonData = try JSONSerialization.data(withJSONObject: cleanPayload) + LogManager.shared.log(category: .apns, message: "APNS payload serialized successfully, size: \(jsonData.count) bytes") + + // Log the actual payload being sent + if let payloadString = String(data: jsonData, encoding: .utf8) { + LogManager.shared.log(category: .apns, message: "APNS payload being sent: \(payloadString)") + } + } catch { + LogManager.shared.log(category: .apns, message: "Failed to serialize APNS payload: \(error.localizedDescription)") + throw LoopAPNSError.invalidURL + } + request.httpBody = jsonData + + 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 notification sent successfully") + return true + case 400: + let errorResponse = String(data: data, encoding: .utf8) ?? "Unknown error" + LogManager.shared.log(category: .apns, message: "APNS error 400: \(errorResponse)") + LogManager.shared.log(category: .apns, message: "BadDeviceToken error - this usually means:") + LogManager.shared.log(category: .apns, message: "1. Device token is expired or invalid") + LogManager.shared.log(category: .apns, message: "2. Device token is from different environment (dev vs prod)") + LogManager.shared.log(category: .apns, message: "3. Device token is not registered for this bundle identifier") + LogManager.shared.log(category: .apns, message: "Troubleshooting steps:") + LogManager.shared.log(category: .apns, message: "1. Refresh device token from Loop app") + LogManager.shared.log(category: .apns, message: "2. Check if Loop app is using same environment (dev/prod)") + LogManager.shared.log(category: .apns, message: "3. Verify device token is for bundle ID: \(bundleIdentifier)") + LogManager.shared.log(category: .apns, message: "4. Check if device token is from production environment") + LogManager.shared.log(category: .apns, message: "Current environment: \(storage.productionEnvironment.value ? "Production" : "Development")") + throw LoopAPNSError.invalidResponse + case 403: + let errorResponse = String(data: data, encoding: .utf8) ?? "Unknown error" + LogManager.shared.log(category: .apns, message: "APNS error 403: Forbidden - \(errorResponse)") + LogManager.shared.log(category: .apns, message: "This usually means the APNS key doesn't have permissions for this bundle ID") + LogManager.shared.log(category: .apns, message: "Troubleshooting steps:") + LogManager.shared.log(category: .apns, message: "1. Check that APNS key \(keyId) has 'Apple Push Notifications service (APNs)' capability enabled") + LogManager.shared.log(category: .apns, message: "2. Check that bundle ID \(bundleIdentifier) has 'Push Notifications' capability enabled") + LogManager.shared.log(category: .apns, message: "3. Verify the APNS key is associated with the bundle ID in Apple Developer account") + throw LoopAPNSError.unauthorized + case 410: + LogManager.shared.log(category: .apns, message: "APNS error 410: Device token is invalid or expired") + throw LoopAPNSError.noDeviceToken + case 429: + let errorResponse = String(data: data, encoding: .utf8) ?? "Unknown error" + LogManager.shared.log(category: .apns, message: "APNS error 429: Too Many Requests - \(errorResponse)") + LogManager.shared.log(category: .apns, message: "Rate limiting error - Apple is throttling APNS requests") + LogManager.shared.log(category: .apns, message: "Troubleshooting steps:") + LogManager.shared.log(category: .apns, message: "1. Wait a few minutes before trying again") + LogManager.shared.log(category: .apns, message: "2. Check if you're sending too many notifications too quickly") + LogManager.shared.log(category: .apns, message: "3. Consider implementing exponential backoff") + throw LoopAPNSError.rateLimited + default: + let errorResponse = String(data: data, encoding: .utf8) ?? "Unknown error" + LogManager.shared.log(category: .apns, message: "APNS error \(httpResponse.statusCode): \(errorResponse)") + throw LoopAPNSError.networkError + } + } else { + throw LoopAPNSError.networkError + } + } catch { + LogManager.shared.log(category: .apns, message: "APNS request failed: \(error.localizedDescription)") + throw LoopAPNSError.networkError + } + } + + /// Validates and fixes APNS key format if needed + /// - Parameter key: The APNS key to validate and fix + /// - Returns: The fixed APNS key + func validateAndFixAPNSKey(_ key: String) -> String { + // Normalize: replace all literal \n with real newlines + var fixedKey = key.replacingOccurrences(of: "\\n", with: "\n") + + // Strip leading/trailing quotes + fixedKey = fixedKey.trimmingCharacters(in: CharacterSet(charactersIn: "\"'")) + + // Check if the key has proper line breaks + if !fixedKey.contains("\n") { + LogManager.shared.log(category: .apns, message: "APNS Key missing line breaks, attempting to fix format") + + // Try to add line breaks if the key is all on one line + if fixedKey.contains("-----BEGIN PRIVATE KEY-----") && fixedKey.contains("-----END PRIVATE KEY-----") { + // Find the positions of the headers + if let beginRange = fixedKey.range(of: "-----BEGIN PRIVATE KEY-----"), + let endRange = fixedKey.range(of: "-----END PRIVATE KEY-----") + { + let beginIndex = fixedKey.index(beginRange.upperBound, offsetBy: 0) + let endIndex = endRange.lowerBound + + if beginIndex < endIndex { + let header = String(fixedKey[.. 2 { + let header = cleanedLines[0] + let footer = cleanedLines[cleanedLines.count - 1] + let keyLines = Array(cleanedLines[1 ..< (cleanedLines.count - 1)]) + + // Combine all key data lines and validate + let combinedKeyData = keyLines.joined() + LogManager.shared.log(category: .apns, message: "Combined key data length: \(combinedKeyData.count) characters") + + // Validate the key data length (should be 44 characters for P-256) + if combinedKeyData.count != 44 { + LogManager.shared.log(category: .apns, message: "WARNING: Combined key data length is \(combinedKeyData.count), expected 44 for P-256 private key") + } + + // Validate base64 format + if Data(base64Encoded: combinedKeyData) == nil { + LogManager.shared.log(category: .apns, message: "WARNING: Combined key data is not valid base64") + } + + // Ensure key lines are properly formatted (64 characters each) + var formattedKeyLines: [String] = [] + var currentLine = "" + + for line in keyLines { + let cleanLine = line.replacingOccurrences(of: " ", with: "") + .replacingOccurrences(of: "\t", with: "") + + for char in cleanLine { + currentLine.append(char) + if currentLine.count == 64 { + formattedKeyLines.append(currentLine) + currentLine = "" + } + } + } + + // Add any remaining characters + if !currentLine.isEmpty { + formattedKeyLines.append(currentLine) + } + + fixedKey = "\(header)\n\(formattedKeyLines.joined(separator: "\n"))\n\(footer)" + + LogManager.shared.log(category: .apns, message: "APNS Key reformatted - cleaned up existing line breaks") + } + } + + return fixedKey + } + + /// Provides guidance on proper APNS key format + /// - Parameter key: The APNS key to analyze + /// - Returns: A string with guidance on fixing the key + private func getAPNSKeyGuidance(_ key: String) -> String { + let lines = key.components(separatedBy: .newlines) + let keyDataLines = lines.filter { !$0.contains("-----BEGIN") && !$0.contains("-----END") && !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + let combinedKeyData = keyDataLines.joined() + + var guidance = "APNS Key Analysis:\n" + guidance += "- Total lines: \(lines.count)\n" + guidance += "- Key data lines: \(keyDataLines.count)\n" + guidance += "- Combined key length: \(combinedKeyData.count) characters\n" + + if combinedKeyData.count != 44 { + guidance += "- ❌ Key length should be 44 characters for P-256 private key\n" + } else { + guidance += "- ✅ Key length is correct (44 characters)\n" + } + + if Data(base64Encoded: combinedKeyData) == nil { + guidance += "- ❌ Key data is not valid base64\n" + } else { + guidance += "- ✅ Key data is valid base64\n" + } + + guidance += "\nA proper APNS key should:\n" + guidance += "1. Start with '-----BEGIN PRIVATE KEY-----'\n" + guidance += "2. Have key data that is exactly 44 base64 characters\n" + guidance += "3. End with '-----END PRIVATE KEY-----'\n" + guidance += "4. Have key data split into 64-character lines\n" + + return guidance + } + + /// Creates a JWT token for APNS authentication + /// - Parameters: + /// - keyId: The APNS key ID + /// - apnsKey: The APNS key + /// - bundleIdentifier: The bundle identifier + /// - Returns: The JWT token + /// - Throws: LoopAPNSError if JWT creation fails + private func createAPNSJWT(keyId: String, apnsKey: String, bundleIdentifier: String) throws -> String { + // Validate inputs + guard !keyId.isEmpty, !apnsKey.isEmpty, !bundleIdentifier.isEmpty else { + LogManager.shared.log(category: .apns, message: "Invalid JWT inputs - keyId: \(keyId.isEmpty), apnsKey: \(apnsKey.isEmpty), bundleIdentifier: \(bundleIdentifier.isEmpty)") + throw LoopAPNSError.invalidURL + } + + // Validate and fix APNS key format + let fixedApnsKey = validateAndFixAPNSKey(apnsKey) + + // Validate keyId format (should be 10 alphanumeric characters) + let keyIdPattern = "^[A-Z0-9]{10}$" + let isValidKeyId = keyId.range(of: keyIdPattern, options: .regularExpression) != nil + LogManager.shared.log(category: .apns, message: "Key ID validation - Key ID: \(keyId), Is valid format: \(isValidKeyId)") + + // For APNS, the issuer should be the Team ID + // Try to get the team ID from storage, but if not set, use the key ID (like Nightscout does) + let teamId: String + let storedTeamId = storage.loopDeveloperTeamId.value + if !storedTeamId.isEmpty { + teamId = storedTeamId + LogManager.shared.log(category: .apns, message: "Using Loop Team ID from storage: \(teamId)") + } else { + teamId = keyId + LogManager.shared.log(category: .apns, message: "No Loop Team ID in storage, using Key ID as Team ID: \(teamId)") + } + + // Validate Team ID format (should be 10 alphanumeric characters) + let teamIdPattern = "^[A-Z0-9]{10}$" + let isValidTeamId = teamId.range(of: teamIdPattern, options: .regularExpression) != nil + LogManager.shared.log(category: .apns, message: "Team ID validation - Team ID: \(teamId), Is valid format: \(isValidTeamId)") + + LogManager.shared.log(category: .apns, message: "Creating JWT with keyId: \(keyId), bundleIdentifier: \(bundleIdentifier), teamId: \(teamId)") + + // Log APNS key details for debugging (without exposing the actual key) + let apnsKeyLines = fixedApnsKey.components(separatedBy: .newlines) + let apnsKeyLineCount = apnsKeyLines.count + let hasPrivateKeyHeader = fixedApnsKey.contains("-----BEGIN PRIVATE KEY-----") + let hasEndHeader = fixedApnsKey.contains("-----END PRIVATE KEY-----") + LogManager.shared.log(category: .apns, message: "APNS Key details - Lines: \(apnsKeyLineCount), Has PKCS8 header: \(hasPrivateKeyHeader), Has end header: \(hasEndHeader)") + + // Log key guidance for debugging + let guidance = getAPNSKeyGuidance(fixedApnsKey) + LogManager.shared.log(category: .apns, message: guidance) + + do { + // Try using CryptoKit approach for JWT creation (like our original implementation) + LogManager.shared.log(category: .apns, message: "Creating JWT using CryptoKit approach") + + // Create JWT header + let header: [String: String] = [ + "alg": "ES256", + "kid": keyId, + "typ": "JWT", + ] + + let now = Date() + let payload: [String: Any] = [ + "iss": teamId, + "iat": Int(now.timeIntervalSince1970), + ] + + // Encode header and payload as base64url + let headerData = try JSONSerialization.data(withJSONObject: header) + let payloadData = try JSONSerialization.data(withJSONObject: payload) + + let headerBase64 = base64urlEncode(headerData) + let payloadBase64 = base64urlEncode(payloadData) + + // Create the signing input + let signingInput = "\(headerBase64).\(payloadBase64)" + + // Sign the input with the APNS key using CryptoKit + let signature = try signWithES256(signingInput: signingInput, pemKey: fixedApnsKey) + let signatureBase64 = base64urlEncode(signature) + + // Combine all parts + let jwt = "\(signingInput).\(signatureBase64)" + + LogManager.shared.log(category: .apns, message: "JWT created successfully using CryptoKit") + return jwt + } catch { + LogManager.shared.log(category: .apns, message: "Failed to create JWT with CryptoKit: \(error.localizedDescription)") + + // Try fallback method using SwiftJWT + LogManager.shared.log(category: .apns, message: "Attempting fallback JWT creation with SwiftJWT") + + do { + let jwt = try createJWTWithSwiftJWT(keyId: keyId, apnsKey: fixedApnsKey, teamId: teamId) + LogManager.shared.log(category: .apns, message: "JWT created successfully using SwiftJWT fallback") + return jwt + } catch { + LogManager.shared.log(category: .apns, message: "Failed to create JWT with SwiftJWT fallback: \(error.localizedDescription)") + + // Provide detailed error guidance + LogManager.shared.log(category: .apns, message: "Both JWT creation methods failed. This usually indicates:") + LogManager.shared.log(category: .apns, message: "1. The APNS key is incomplete or corrupted") + LogManager.shared.log(category: .apns, message: "2. The key is not a valid P-256 private key") + LogManager.shared.log(category: .apns, message: "3. The key was copied incorrectly from Apple Developer portal") + LogManager.shared.log(category: .apns, message: "Please verify the APNS key is complete and properly formatted.") + + throw LoopAPNSError.invalidURL + } + } + } + + /// Creates a JWT token using SwiftJWT as a fallback method + /// - Parameters: + /// - keyId: The APNS key ID + /// - apnsKey: The APNS key + /// - teamId: The team ID + /// - Returns: The JWT token + /// - Throws: LoopAPNSError if JWT creation fails + private func createJWTWithSwiftJWT(keyId: String, apnsKey: String, teamId: String) throws -> String { + let header = Header(kid: keyId) + let claims = LoopAPNSJWTClaims(iss: teamId, iat: Date()) + + var jwt = JWT(header: header, claims: claims) + + do { + let privateKey = Data(apnsKey.utf8) + let jwtSigner = JWTSigner.es256(privateKey: privateKey) + let signedJWT = try jwt.sign(using: jwtSigner) + return signedJWT + } catch { + LogManager.shared.log(category: .apns, message: "SwiftJWT signing failed: \(error.localizedDescription)") + throw LoopAPNSError.invalidURL + } + } + + /// Extracts key data from PEM format + /// - Parameter pemString: The PEM formatted private key + /// - Returns: The extracted key data string + private func extractKeyData(from pemString: String) -> String? { + let lines = pemString.components(separatedBy: "\n") + guard let startIndex = lines.firstIndex(of: "-----BEGIN PRIVATE KEY-----"), + let endIndex = lines.firstIndex(of: "-----END PRIVATE KEY-----"), + startIndex < endIndex + else { + return nil + } + let keyLines = lines[(startIndex + 1) ..< endIndex] + return keyLines.joined() + } + + /// Base64url encodes data + /// - Parameter data: The data to encode + /// - Returns: Base64url encoded string + private func base64urlEncode(_ data: Data) -> String { + return data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + /// Signs data with ES256 algorithm using the APNS key + /// - Parameters: + /// - signingInput: The data to sign + /// - pemKey: The PEM formatted private key + /// - Returns: Base64url encoded signature + private func signWithES256(signingInput: String, pemKey: String) throws -> Data { + guard let inputData = signingInput.data(using: .utf8) else { + LogManager.shared.log(category: .apns, message: "Failed to convert signing input to data") + throw LoopAPNSError.invalidURL + } + + // Log the PEM key format for debugging + let pemLines = pemKey.components(separatedBy: .newlines) + LogManager.shared.log(category: .apns, message: "PEM key format - Total lines: \(pemLines.count)") + LogManager.shared.log(category: .apns, message: "PEM key first line: \(pemLines.first ?? "nil")") + LogManager.shared.log(category: .apns, message: "PEM key last line: \(pemLines.last ?? "nil")") + + // Check if the key data looks valid + if pemLines.count > 2 { + let keyDataLines = Array(pemLines[1 ..< (pemLines.count - 1)]) + LogManager.shared.log(category: .apns, message: "PEM key data lines: \(keyDataLines.count)") + if !keyDataLines.isEmpty { + LogManager.shared.log(category: .apns, message: "PEM key data first line length: \(keyDataLines[0].count)") + LogManager.shared.log(category: .apns, message: "PEM key data last line length: \(keyDataLines.last?.count ?? 0)") + } + } + + do { + // Create a P256 private key from the PEM key + let privateKey = try P256.Signing.PrivateKey(pemRepresentation: pemKey) + let signature = try privateKey.signature(for: inputData) + return signature.derRepresentation + } catch { + LogManager.shared.log(category: .apns, message: "Failed to create signature with CryptoKit: \(error.localizedDescription)") + + // Provide more specific error information + if let cryptoError = error as? CryptoKitError { + LogManager.shared.log(category: .apns, message: "CryptoKit error details: \(cryptoError)") + } + + // Log additional debugging information + LogManager.shared.log(category: .apns, message: "PEM key length: \(pemKey.count)") + LogManager.shared.log(category: .apns, message: "PEM key contains BEGIN: \(pemKey.contains("-----BEGIN PRIVATE KEY-----"))") + LogManager.shared.log(category: .apns, message: "PEM key contains END: \(pemKey.contains("-----END PRIVATE KEY-----"))") + + throw LoopAPNSError.invalidURL + } + } + + // MARK: - Date Formatting Helper + + /// Creates a properly formatted ISO8601 date string with milliseconds (matching Nightscout's format) + /// - Parameter date: The date to format + /// - Returns: Formatted date string like "2022-12-24T21:34:02.090Z" + private func formatDateForAPNS(_ date: Date) -> String { + let dateFormatter = ISO8601DateFormatter() + dateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return dateFormatter.string(from: date) + } + + // MARK: - Override Methods + + func sendOverrideNotification(presetName: String, duration: TimeInterval? = nil) async throws { + let deviceToken = Storage.shared.loopAPNSDeviceToken.value + guard !deviceToken.isEmpty else { + throw LoopAPNSError.deviceTokenNotConfigured + } + + let bundleIdentifier = Storage.shared.loopAPNSBundleIdentifier.value + guard !bundleIdentifier.isEmpty else { + throw LoopAPNSError.bundleIdentifierNotConfigured + } + + // Create APNS notification payload (matching Loop's expected format) + let now = Date() + let expiration = Date(timeIntervalSinceNow: 5 * 60) // 5 minutes from now + + // Create alert text (matching Nightscout's format) + var alertText = "\(presetName) Temporary Override" + if let duration = duration, duration > 0 { + let hours = Int(duration / 3600) + let minutes = Int((duration.truncatingRemainder(dividingBy: 3600)) / 60) + if hours > 0 { + alertText += " (\(hours)h \(minutes)m)" + } else { + alertText += " (\(minutes)m)" + } + } + + var payload: [String: Any] = [ + "override-name": presetName, + "remote-address": "LoopFollow", + "entered-by": "LoopFollow", + "sent-at": formatDateForAPNS(now), + "expiration": formatDateForAPNS(expiration), + "alert": alertText, + ] + + if let duration = duration, duration > 0 { + payload["override-duration-minutes"] = Int(duration / 60) + } + + // Send the notification using the existing APNS infrastructure + try await sendAPNSNotification( + deviceToken: deviceToken, + bundleIdentifier: bundleIdentifier, + keyId: storage.loopAPNSKeyId.value, + apnsKey: storage.loopAPNSKey.value, + payload: payload + ) + } + + func sendCancelOverrideNotification() async throws { + let deviceToken = Storage.shared.loopAPNSDeviceToken.value + guard !deviceToken.isEmpty else { + throw LoopAPNSError.deviceTokenNotConfigured + } + + let bundleIdentifier = Storage.shared.loopAPNSBundleIdentifier.value + guard !bundleIdentifier.isEmpty else { + throw LoopAPNSError.bundleIdentifierNotConfigured + } + + // Create APNS notification payload (matching Loop's expected format) + let now = Date() + let expiration = Date(timeIntervalSinceNow: 5 * 60) // 5 minutes from now + + let payload: [String: Any] = [ + "cancel-temporary-override": "true", + "remote-address": "LoopFollow", + "entered-by": "LoopFollow", + "sent-at": formatDateForAPNS(now), + "expiration": formatDateForAPNS(expiration), + "alert": "Cancel Temporary Override", + ] + + // Send the notification using the existing APNS infrastructure + try await sendAPNSNotification( + deviceToken: deviceToken, + bundleIdentifier: bundleIdentifier, + keyId: storage.loopAPNSKeyId.value, + apnsKey: storage.loopAPNSKey.value, + payload: payload + ) + } +} diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSSettingsView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSSettingsView.swift new file mode 100644 index 00000000..37798407 --- /dev/null +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSSettingsView.swift @@ -0,0 +1,207 @@ +// LoopFollow +// LoopAPNSSettingsView.swift +// Created by codebymini. + +import SwiftUI + +struct LoopAPNSSettingsView: View { + @StateObject private var viewModel = RemoteSettingsViewModel() + @Environment(\.presentationMode) var presentationMode + + var body: some View { + NavigationView { + Form { + Section { + VStack(alignment: .leading, spacing: 8) { + HStack { + Image(systemName: viewModel.loopAPNSSetup ? "checkmark.circle.fill" : "exclamationmark.circle.fill") + .foregroundColor(viewModel.loopAPNSSetup ? .green : .orange) + Text(viewModel.loopAPNSSetup ? "Setup Complete" : "Setup Incomplete") + .font(.headline) + .foregroundColor(viewModel.loopAPNSSetup ? .green : .orange) + } + + if !viewModel.loopAPNSSetup { + Text("Configure all required fields below to complete the Loop APNS setup") + .font(.caption) + .foregroundColor(.secondary) + } else { + Text("All required fields are configured. You can now use Loop APNS features.") + .font(.caption) + .foregroundColor(.green) + } + } + .padding(.vertical, 4) + } header: { + Text("Setup Status") + } + + Section { + VStack(alignment: .leading, spacing: 12) { + Text("LOOP APNS KEY ID") + .font(.headline) + TogglableSecureInput( + placeholder: "Enter your APNS Key ID", + text: $viewModel.loopAPNSKeyId, + style: .singleLine + ) + .autocapitalization(.none) + .disableAutocorrection(true) + } + + VStack(alignment: .leading, spacing: 12) { + Text("LOOP DEVELOPER TEAM ID") + .font(.headline) + TogglableSecureInput( + placeholder: "Enter your Team ID (10 characters)", + text: $viewModel.loopDeveloperTeamId, + style: .singleLine + ) + .autocapitalization(.none) + .disableAutocorrection(true) + } + + VStack(alignment: .leading, spacing: 12) { + Text("LOOP APNS KEY") + .font(.headline) + TogglableSecureInput( + placeholder: "Enter your APNS Key including -----BEGIN PRIVATE KEY----- and -----END PRIVATE KEY-----", + text: $viewModel.loopAPNSKey, + style: .multiLine + ) + .frame(minHeight: 110) + } + + VStack(alignment: .leading, spacing: 12) { + Text("QR Code URL") + .font(.headline) + TextField("Enter QR code URL or scan from Loop app", text: $viewModel.loopAPNSQrCodeURL) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .autocapitalization(.none) + .disableAutocorrection(true) + } + + Button(action: { + viewModel.isShowingLoopAPNSScanner = true + }) { + HStack { + Image(systemName: "qrcode.viewfinder") + Text("Scan QR Code from Loop App") + } + } + .buttonStyle(.borderedProminent) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + VStack(alignment: .leading, spacing: 12) { + Text("Environment") + .font(.headline) + Toggle("Production Environment", isOn: $viewModel.productionEnvironment) + .toggleStyle(SwitchToggleStyle()) + Text("Production is used for browser builders and should be switched off for Xcode builders") + .font(.caption) + .foregroundColor(.secondary) + + // Environment status indicator + HStack { + Image(systemName: viewModel.productionEnvironment ? "checkmark.circle.fill" : "gearshape.fill") + .foregroundColor(viewModel.productionEnvironment ? .green : .blue) + Text(viewModel.productionEnvironment ? "Production Environment" : "Development Environment") + .font(.caption) + .foregroundColor(viewModel.productionEnvironment ? .green : .blue) + } + .padding(.top, 4) + } + VStack(alignment: .leading, spacing: 12) { + Text("Device Token") + .font(.headline) + HStack { + Text(viewModel.loopAPNSDeviceToken.isEmpty ? "Not configured" : viewModel.loopAPNSDeviceToken) + .foregroundColor(viewModel.loopAPNSDeviceToken.isEmpty ? .red : .primary) + .font(.system(.body, design: .monospaced)) + .lineLimit(1) + .truncationMode(.middle) + + Spacer() + + Button(action: { + Task { + await viewModel.refreshDeviceToken() + } + }) { + if viewModel.isRefreshingDeviceToken { + ProgressView() + .scaleEffect(0.8) + } else { + Image(systemName: "arrow.clockwise") + .foregroundColor(.blue) + } + } + .disabled(viewModel.isRefreshingDeviceToken) + } + + // Device token status indicator + if !viewModel.loopAPNSDeviceToken.isEmpty { + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + Text("Device token configured") + .font(.caption) + .foregroundColor(.green) + } + .padding(.top, 4) + } + } + + VStack(alignment: .leading, spacing: 12) { + Text("Bundle Identifier") + .font(.headline) + Text(viewModel.loopAPNSBundleIdentifier.isEmpty ? "Not configured" : viewModel.loopAPNSBundleIdentifier) + .foregroundColor(viewModel.loopAPNSBundleIdentifier.isEmpty ? .red : .primary) + .font(.system(.body, design: .monospaced)) + .lineLimit(1) + .truncationMode(.middle) + + // Bundle identifier status indicator + if !viewModel.loopAPNSBundleIdentifier.isEmpty { + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + Text("Bundle identifier configured") + .font(.caption) + .foregroundColor(.green) + } + .padding(.top, 4) + } + } + + } header: { + Text("Loop APNS Configuration") + } + + if let errorMessage = viewModel.loopAPNSErrorMessage, !errorMessage.isEmpty { + Section { + Text(errorMessage) + .foregroundColor(.red) + .font(.caption) + } + } + } + .navigationBarTitle("Loop APNS Settings", displayMode: .inline) + .sheet(isPresented: $viewModel.isShowingLoopAPNSScanner) { + SimpleQRCodeScannerView { result in + viewModel.handleLoopAPNSQRCodeScanResult(result) + } + } + .onAppear { + // Automatically fetch device token and bundle identifier when entering the setup screen + Task { + await viewModel.refreshDeviceToken() + } + } + } + } +} + +// MARK: - RemoteSettingsViewModel Extension for Loop APNS + +extension RemoteSettingsViewModel {} diff --git a/LoopFollow/Remote/LoopAPNS/OverridePresetData.swift b/LoopFollow/Remote/LoopAPNS/OverridePresetData.swift new file mode 100644 index 00000000..5deb6296 --- /dev/null +++ b/LoopFollow/Remote/LoopAPNS/OverridePresetData.swift @@ -0,0 +1,85 @@ +// LoopFollow +// OverridePresetData.swift +// Created by codebymini. + +import Foundation + +struct OverridePresetData: Codable, Identifiable { + let id: String + let name: String + let symbol: String? + let targetRange: ClosedRange? + let insulinNeedsScaleFactor: Double? + let duration: TimeInterval + + enum CodingKeys: String, CodingKey { + case name, symbol, duration + case targetRange + case insulinNeedsScaleFactor + } + + init(name: String, symbol: String? = nil, targetRange: ClosedRange? = nil, insulinNeedsScaleFactor: Double? = nil, duration: TimeInterval) { + id = UUID().uuidString + self.name = name + self.symbol = symbol + self.targetRange = targetRange + self.insulinNeedsScaleFactor = insulinNeedsScaleFactor + self.duration = duration + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + name = try container.decode(String.self, forKey: .name) + symbol = try container.decodeIfPresent(String.self, forKey: .symbol) + duration = try container.decode(TimeInterval.self, forKey: .duration) + + // Handle target range which might be stored as min/max values + if let targetRangeDict = try? container.decode([String: Double].self, forKey: .targetRange) { + if let min = targetRangeDict["min"], let max = targetRangeDict["max"] { + targetRange = min ... max + } else { + targetRange = nil + } + } else { + targetRange = nil + } + + insulinNeedsScaleFactor = try container.decodeIfPresent(Double.self, forKey: .insulinNeedsScaleFactor) + id = UUID().uuidString + } + + var durationDescription: String { + if duration == 0 { + return "Indefinite" + } else { + let hours = Int(duration / 3600) + let minutes = Int((duration.truncatingRemainder(dividingBy: 3600)) / 60) + if hours > 0 { + return "\(hours)h \(minutes)m" + } else { + return "\(minutes)m" + } + } + } +} + +// MARK: - Codable Extensions + +extension ClosedRange: Codable where Bound: Codable { + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let lower = try container.decode(Bound.self, forKey: .lower) + let upper = try container.decode(Bound.self, forKey: .upper) + self.init(uncheckedBounds: (lower: lower, upper: upper)) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(lowerBound, forKey: .lower) + try container.encode(upperBound, forKey: .upper) + } + + private enum CodingKeys: String, CodingKey { + case lower, upper + } +} diff --git a/LoopFollow/Remote/LoopAPNS/OverridePresetsView.swift b/LoopFollow/Remote/LoopAPNS/OverridePresetsView.swift new file mode 100644 index 00000000..37726af8 --- /dev/null +++ b/LoopFollow/Remote/LoopAPNS/OverridePresetsView.swift @@ -0,0 +1,350 @@ +// LoopFollow +// OverridePresetsView.swift +// Created by codebymini. + +import SwiftUI + +struct OverridePresetsView: View { + @StateObject private var viewModel = OverridePresetsViewModel() + @Environment(\.presentationMode) var presentationMode + + var body: some View { + NavigationView { + List { + Section(header: Text("Available Overrides")) { + if viewModel.isLoading { + HStack { + ProgressView() + .scaleEffect(0.8) + Text("Loading override presets...") + .foregroundColor(.secondary) + } + } else if viewModel.overridePresets.isEmpty { + Text("No override presets found. Configure presets in your Loop app.") + .foregroundColor(.secondary) + .italic() + } else { + ForEach(viewModel.overridePresets, id: \.name) { preset in + OverridePresetRow( + preset: preset, + isActivating: viewModel.isActivating, + onActivate: { + Task { + await viewModel.activateOverride(preset: preset) + } + } + ) + } + } + } + + if !viewModel.overridePresets.isEmpty { + Section { + Button(action: { + Task { + await viewModel.cancelOverride() + } + }) { + HStack { + if viewModel.isActivating { + ProgressView() + .scaleEffect(0.8) + .foregroundColor(.red) + } else { + Image(systemName: "xmark.circle") + .foregroundColor(.red) + } + Text("Cancel Active Override") + .foregroundColor(.red) + } + } + .disabled(viewModel.isActivating) + } + } + + if let errorMessage = viewModel.errorMessage, !errorMessage.isEmpty { + Section { + Text(errorMessage) + .foregroundColor(.red) + .font(.caption) + } + } + + if let successMessage = viewModel.successMessage, !successMessage.isEmpty { + Section { + Text(successMessage) + .foregroundColor(.green) + .font(.caption) + } + } + } + .navigationBarTitle("Remote Overrides", displayMode: .inline) + .onAppear { + viewModel.dismiss = { presentationMode.wrappedValue.dismiss() } + Task { + await viewModel.loadOverridePresets() + } + } + .alert("Success", isPresented: $viewModel.showSuccessAlert) { + Button("OK") {} + } message: { + Text(viewModel.successAlertMessage) + } + } + } +} + +struct OverridePresetRow: View { + let preset: OverridePreset + let isActivating: Bool + let onActivate: () -> Void + + var body: some View { + Button(action: onActivate) { + HStack { + if let symbol = preset.symbol { + Text(symbol) + .font(.title2) + } + + VStack(alignment: .leading, spacing: 4) { + Text(preset.name) + .font(.headline) + .foregroundColor(.primary) + + HStack(spacing: 8) { + if let targetRange = preset.targetRange { + Text("Target: \(Int(targetRange.lowerBound))-\(Int(targetRange.upperBound))") + .font(.caption) + .foregroundColor(.secondary) + } + + if let insulinNeedsScaleFactor = preset.insulinNeedsScaleFactor { + Text("Insulin: \(Int(insulinNeedsScaleFactor * 100))%") + .font(.caption) + .foregroundColor(.secondary) + } + + Text("Duration: \(preset.durationDescription)") + .font(.caption) + .foregroundColor(.secondary) + } + } + + Spacer() + + if isActivating { + ProgressView() + .scaleEffect(0.8) + .foregroundColor(.blue) + } else { + Image(systemName: "chevron.right") + .foregroundColor(.secondary) + .font(.caption) + } + } + .padding(.vertical, 4) + } + .buttonStyle(PlainButtonStyle()) + .disabled(isActivating) + } +} + +class OverridePresetsViewModel: ObservableObject { + @Published var overridePresets: [OverridePreset] = [] + @Published var isLoading = false + @Published var isActivating = false + @Published var errorMessage: String? + @Published var successMessage: String? + @Published var showSuccessAlert = false + @Published var successAlertMessage = "" + + var dismiss: (() -> Void)? + + func loadOverridePresets() async { + await MainActor.run { + isLoading = true + errorMessage = nil + } + + do { + let presets = try await fetchOverridePresetsFromNightscout() + await MainActor.run { + self.overridePresets = presets + self.isLoading = false + } + } catch { + await MainActor.run { + self.errorMessage = "Failed to load override presets: \(error.localizedDescription)" + self.isLoading = false + } + } + } + + func activateOverride(preset: OverridePreset) async { + await MainActor.run { + isActivating = true + errorMessage = nil + successMessage = nil + } + + do { + try await sendOverrideNotification(preset: preset) + await MainActor.run { + self.isActivating = false + self.successMessage = "\(preset.name) override activated successfully!" + self.successAlertMessage = "\(preset.name) Override Activated!" + self.showSuccessAlert = true + + // Dismiss the view after successful activation + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + self.dismiss?() + } + } + } catch { + await MainActor.run { + self.errorMessage = "Failed to activate override: \(error.localizedDescription)" + self.isActivating = false + } + } + } + + func cancelOverride() async { + await MainActor.run { + isActivating = true + errorMessage = nil + successMessage = nil + } + + do { + try await sendCancelOverrideNotification() + await MainActor.run { + self.isActivating = false + self.successMessage = "Active override cancelled successfully!" + + // Dismiss the view after successful cancellation + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + self.dismiss?() + } + } + } catch { + await MainActor.run { + self.errorMessage = "Failed to cancel override: \(error.localizedDescription)" + self.isActivating = false + } + } + } + + private func fetchOverridePresetsFromNightscout() async throws -> [OverridePreset] { + let url = Storage.shared.url.value + guard !url.isEmpty else { + throw OverrideError.nightscoutNotConfigured + } + + let token = Storage.shared.token.value + guard !token.isEmpty else { + throw OverrideError.nightscoutNotConfigured + } + + let nightscoutURL = URL(string: url)! + let profileURL = nightscoutURL.appendingPathComponent("api/v1/profile.json") + + var request = URLRequest(url: profileURL) + + // Add token authentication + var components = URLComponents(url: profileURL, resolvingAgainstBaseURL: false) + components?.queryItems = [URLQueryItem(name: "token", value: token)] + if let urlWithToken = components?.url { + request.url = urlWithToken + } + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw OverrideError.invalidResponse + } + + guard httpResponse.statusCode == 200 else { + throw OverrideError.serverError(httpResponse.statusCode) + } + + let profiles = try JSONDecoder().decode([ProfileData].self, from: data) + + // Find the most recent profile with loopSettings + guard let latestProfile = profiles.first(where: { $0.loopSettings?.overridePresets != nil }) else { + return [] + } + + return latestProfile.loopSettings!.overridePresets.map { preset in + OverridePreset( + name: preset.name, + symbol: preset.symbol, + targetRange: preset.targetRange, + insulinNeedsScaleFactor: preset.insulinNeedsScaleFactor, + duration: preset.duration + ) + } + } + + private func sendOverrideNotification(preset: OverridePreset) async throws { + let apnsService = LoopAPNSService() + try await apnsService.sendOverrideNotification( + presetName: preset.name, + duration: preset.duration + ) + } + + private func sendCancelOverrideNotification() async throws { + let apnsService = LoopAPNSService() + try await apnsService.sendCancelOverrideNotification() + } +} + +// MARK: - Data Models + +struct ProfileData: Codable { + let loopSettings: LoopSettings? +} + +struct LoopSettings: Codable { + let overridePresets: [OverridePresetData] +} + +struct OverridePreset { + let name: String + let symbol: String? + let targetRange: ClosedRange? + let insulinNeedsScaleFactor: Double? + let duration: TimeInterval + + var durationDescription: String { + if duration == 0 { + return "Indefinite" + } else { + let hours = Int(duration) / 3600 + let minutes = Int(duration) % 3600 / 60 + if hours > 0 { + return "\(hours)h \(minutes)m" + } else { + return "\(minutes)m" + } + } + } +} + +enum OverrideError: LocalizedError { + case nightscoutNotConfigured + case invalidResponse + case serverError(Int) + + var errorDescription: String? { + switch self { + case .nightscoutNotConfigured: + return "Nightscout URL and token not configured in settings" + case .invalidResponse: + return "Invalid response from server" + case let .serverError(code): + return "Server error: \(code)" + } + } +} diff --git a/LoopFollow/Remote/RemoteType.swift b/LoopFollow/Remote/RemoteType.swift index 3cfd0a11..41dc46f4 100644 --- a/LoopFollow/Remote/RemoteType.swift +++ b/LoopFollow/Remote/RemoteType.swift @@ -8,4 +8,5 @@ enum RemoteType: String, Codable { case none = "None" case nightscout = "Nightscout" case trc = "Trio Remote Control" + case loopAPNS = "Loop APNS" } diff --git a/LoopFollow/Remote/RemoteViewController.swift b/LoopFollow/Remote/RemoteViewController.swift index acc4524d..1e78d360 100644 --- a/LoopFollow/Remote/RemoteViewController.swift +++ b/LoopFollow/Remote/RemoteViewController.swift @@ -37,8 +37,6 @@ class RemoteViewController: UIViewController { switch Storage.shared.device.value { case "Trio": remoteView = AnyView(TrioNightscoutRemoteView()) - case "Loop": - remoteView = AnyView(LoopNightscoutRemoteView()) default: remoteView = AnyView(NoRemoteView()) } @@ -56,6 +54,8 @@ class RemoteViewController: UIViewController { let trioRemoteControlView = TrioRemoteControlView(viewModel: trioRemoteControlViewModel) hostingController = UIHostingController(rootView: AnyView(trioRemoteControlView)) } + } else if remoteType == .loopAPNS { + hostingController = UIHostingController(rootView: AnyView(LoopAPNSRemoteView())) } else { hostingController = UIHostingController(rootView: AnyView(Text("Please select a Remote Type in Settings."))) } diff --git a/LoopFollow/Remote/Settings/RemoteSettingsView.swift b/LoopFollow/Remote/Settings/RemoteSettingsView.swift index 881ee665..bc673cae 100644 --- a/LoopFollow/Remote/Settings/RemoteSettingsView.swift +++ b/LoopFollow/Remote/Settings/RemoteSettingsView.swift @@ -7,6 +7,7 @@ import SwiftUI struct RemoteSettingsView: View { @ObservedObject var viewModel: RemoteSettingsViewModel + @ObservedObject private var device = Storage.shared.device @State private var showAlert: Bool = false @State private var alertType: AlertType? = nil @@ -24,15 +25,19 @@ struct RemoteSettingsView: View { Section(header: Text("Remote Type")) { remoteTypeRow(type: .none, label: "None", isEnabled: true) - remoteTypeRow(type: .nightscout, label: "Nightscout", isEnabled: true) - remoteTypeRow( type: .trc, label: "Trio Remote Control", isEnabled: viewModel.isTrioDevice ) - Text("Nightscout is the only option for Loop.\nNightscout should be used for Trio 0.2.x or older.") + remoteTypeRow( + type: .loopAPNS, + label: "Loop", + isEnabled: true + ) + remoteTypeRow(type: .nightscout, label: "Nightscout", isEnabled: true) + Text("Nightscout should be used for Trio 0.2.x or older.") .font(.footnote) .foregroundColor(.secondary) } @@ -84,86 +89,6 @@ struct RemoteSettingsView: View { } } - // MARK: - Guardrails - - Section(header: Text("Guardrails")) { - HStack { - Text("Max Bolus") - Spacer() - TextFieldWithToolBar( - quantity: $viewModel.maxBolus, - maxLength: 4, - unit: HKUnit.internationalUnit(), - allowDecimalSeparator: true, - minValue: HKQuantity(unit: .internationalUnit(), doubleValue: 0.0), - maxValue: HKQuantity(unit: .internationalUnit(), doubleValue: 10.0), - onValidationError: { message in - handleValidationError(message) - } - ) - .frame(width: 100) - Text("U") - .foregroundColor(.secondary) - } - - HStack { - Text("Max Carbs") - Spacer() - TextFieldWithToolBar( - quantity: $viewModel.maxCarbs, - maxLength: 4, - unit: HKUnit.gram(), - allowDecimalSeparator: true, - minValue: HKQuantity(unit: .gram(), doubleValue: 0), - maxValue: HKQuantity(unit: .gram(), doubleValue: 100), - onValidationError: { message in - handleValidationError(message) - } - ) - .frame(width: 100) - Text("g") - .foregroundColor(.secondary) - } - - HStack { - Text("Max Protein") - Spacer() - TextFieldWithToolBar( - quantity: $viewModel.maxProtein, - maxLength: 4, - unit: HKUnit.gram(), - allowDecimalSeparator: true, - minValue: HKQuantity(unit: .gram(), doubleValue: 0), - maxValue: HKQuantity(unit: .gram(), doubleValue: 100), - onValidationError: { message in - handleValidationError(message) - } - ) - .frame(width: 100) - Text("g") - .foregroundColor(.secondary) - } - - HStack { - Text("Max Fat") - Spacer() - TextFieldWithToolBar( - quantity: $viewModel.maxFat, - maxLength: 4, - unit: HKUnit.gram(), - allowDecimalSeparator: true, - minValue: HKQuantity(unit: .gram(), doubleValue: 0), - maxValue: HKQuantity(unit: .gram(), doubleValue: 100), - onValidationError: { message in - handleValidationError(message) - } - ) - .frame(width: 100) - Text("g") - .foregroundColor(.secondary) - } - } - // MARK: - Meal Section Section(header: Text("Meal Settings")) { @@ -183,6 +108,42 @@ struct RemoteSettingsView: View { Text("Bundle ID: \(Storage.shared.bundleId.value)") } } + + // MARK: - Loop APNS Settings + + if viewModel.remoteType == .loopAPNS { + Section(header: Text("Loop APNS Settings")) { + VStack(alignment: .leading, spacing: 8) { + HStack { + Image(systemName: viewModel.loopAPNSSetup ? "checkmark.circle.fill" : "exclamationmark.circle") + .foregroundColor(viewModel.loopAPNSSetup ? .green : .orange) + Text(viewModel.loopAPNSSetup ? "Setup Complete" : "Setup Incomplete") + .font(.headline) + .foregroundColor(viewModel.loopAPNSSetup ? .green : .orange) + } + + if !viewModel.loopAPNSSetup { + Text("Configure Loop APNS settings to send carbs and insulin directly to Loop app") + .font(.caption) + .foregroundColor(.secondary) + } + } + .padding(.vertical, 8) + + NavigationLink(destination: LoopAPNSSettingsView()) { + HStack { + Image(systemName: "gear") + Text("Configure Loop APNS Settings") + } + } + } + } + + // MARK: - Shared Guardrails Section + + if viewModel.remoteType != .none { + guardrailsSection + } } .alert(isPresented: $showAlert) { switch alertType { @@ -197,8 +158,22 @@ struct RemoteSettingsView: View { } } } + + .sheet(isPresented: $viewModel.isShowingLoopAPNSScanner) { + SimpleQRCodeScannerView { result in + viewModel.handleLoopAPNSQRCodeScanResult(result) + } + } .preferredColorScheme(Storage.shared.forceDarkMode.value ? .dark : nil) .navigationBarTitle("Remote Settings", displayMode: .inline) + .onAppear { + // Refresh Loop APNS setup validation when returning to this screen + viewModel.validateLoopAPNSSetup() + } + .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("LoopAPNSSetupChanged"))) { _ in + // Update validation when Loop APNS setup changes + viewModel.validateLoopAPNSSetup() + } } // MARK: - Custom Row for Remote Type Selection @@ -230,4 +205,86 @@ struct RemoteSettingsView: View { alertType = .validation showAlert = true } + + private var guardrailsSection: some View { + Section(header: Text("Guardrails")) { + HStack { + Text("Max Bolus") + Spacer() + TextFieldWithToolBar( + quantity: $viewModel.maxBolus, + maxLength: 4, + unit: HKUnit.internationalUnit(), + allowDecimalSeparator: true, + minValue: HKQuantity(unit: .internationalUnit(), doubleValue: 0), + maxValue: HKQuantity(unit: .internationalUnit(), doubleValue: 10), + onValidationError: { message in + handleValidationError(message) + } + ) + .frame(width: 100) + Text("U") + .foregroundColor(.secondary) + } + + HStack { + Text("Max Carbs") + Spacer() + TextFieldWithToolBar( + quantity: $viewModel.maxCarbs, + maxLength: 4, + unit: HKUnit.gram(), + allowDecimalSeparator: true, + minValue: HKQuantity(unit: .gram(), doubleValue: 0), + maxValue: HKQuantity(unit: .gram(), doubleValue: 100), + onValidationError: { message in + handleValidationError(message) + } + ) + .frame(width: 100) + Text("g") + .foregroundColor(.secondary) + } + + if device.value == "Trio" { + HStack { + Text("Max Protein") + Spacer() + TextFieldWithToolBar( + quantity: $viewModel.maxProtein, + maxLength: 4, + unit: HKUnit.gram(), + allowDecimalSeparator: true, + minValue: HKQuantity(unit: .gram(), doubleValue: 0), + maxValue: HKQuantity(unit: .gram(), doubleValue: 100), + onValidationError: { message in + handleValidationError(message) + } + ) + .frame(width: 100) + Text("g") + .foregroundColor(.secondary) + } + + HStack { + Text("Max Fat") + Spacer() + TextFieldWithToolBar( + quantity: $viewModel.maxFat, + maxLength: 4, + unit: HKUnit.gram(), + allowDecimalSeparator: true, + minValue: HKQuantity(unit: .gram(), doubleValue: 0), + maxValue: HKQuantity(unit: .gram(), doubleValue: 100), + onValidationError: { message in + handleValidationError(message) + } + ) + .frame(width: 100) + Text("g") + .foregroundColor(.secondary) + } + } + } + } } diff --git a/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift b/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift index d3fd8054..76ec5731 100644 --- a/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift +++ b/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift @@ -20,11 +20,27 @@ class RemoteSettingsViewModel: ObservableObject { @Published var mealWithBolus: Bool @Published var mealWithFatProtein: Bool @Published var isTrioDevice: Bool = (Storage.shared.device.value == "Trio") + @Published var isLoopDevice: Bool = (Storage.shared.device.value == "Loop") + + // MARK: - Loop APNS Setup Properties + + @Published var loopAPNSKeyId: String + @Published var loopAPNSKey: String + @Published var loopDeveloperTeamId: String + @Published var loopAPNSQrCodeURL: String + @Published var loopAPNSDeviceToken: String + @Published var loopAPNSBundleIdentifier: String + @Published var loopAPNSSetup: Bool + @Published var productionEnvironment: Bool + @Published var isShowingLoopAPNSScanner: Bool = false + @Published var loopAPNSErrorMessage: String? + @Published var isRefreshingDeviceToken: Bool = false private var storage = Storage.shared private var cancellables = Set() init() { + // Initialize published properties from storage remoteType = storage.remoteType.value user = storage.user.value sharedSecret = storage.sharedSecret.value @@ -37,51 +53,74 @@ class RemoteSettingsViewModel: ObservableObject { mealWithBolus = storage.mealWithBolus.value mealWithFatProtein = storage.mealWithFatProtein.value + loopAPNSKeyId = storage.loopAPNSKeyId.value + loopAPNSKey = storage.loopAPNSKey.value + loopDeveloperTeamId = storage.loopDeveloperTeamId.value + loopAPNSQrCodeURL = storage.loopAPNSQrCodeURL.value + loopAPNSDeviceToken = storage.loopAPNSDeviceToken.value + loopAPNSBundleIdentifier = storage.loopAPNSBundleIdentifier.value + loopAPNSSetup = storage.loopAPNSSetup.value + productionEnvironment = storage.productionEnvironment.value + setupBindings() + + // Trigger initial validation + validateLoopAPNSSetup() } private func setupBindings() { $remoteType + .dropFirst() .sink { [weak self] in self?.storage.remoteType.value = $0 } .store(in: &cancellables) $user + .dropFirst() .sink { [weak self] in self?.storage.user.value = $0 } .store(in: &cancellables) $sharedSecret + .dropFirst() .sink { [weak self] in self?.storage.sharedSecret.value = $0 } .store(in: &cancellables) $apnsKey + .dropFirst() .sink { [weak self] in self?.storage.apnsKey.value = $0 } .store(in: &cancellables) $keyId + .dropFirst() .sink { [weak self] in self?.storage.keyId.value = $0 } .store(in: &cancellables) $maxBolus + .dropFirst() .sink { [weak self] in self?.storage.maxBolus.value = $0 } .store(in: &cancellables) $maxCarbs + .dropFirst() .sink { [weak self] in self?.storage.maxCarbs.value = $0 } .store(in: &cancellables) $maxProtein + .dropFirst() .sink { [weak self] in self?.storage.maxProtein.value = $0 } .store(in: &cancellables) $maxFat + .dropFirst() .sink { [weak self] in self?.storage.maxFat.value = $0 } .store(in: &cancellables) $mealWithBolus + .dropFirst() .sink { [weak self] in self?.storage.mealWithBolus.value = $0 } .store(in: &cancellables) $mealWithFatProtein + .dropFirst() .sink { [weak self] in self?.storage.mealWithFatProtein.value = $0 } .store(in: &cancellables) @@ -89,7 +128,227 @@ class RemoteSettingsViewModel: ObservableObject { .receive(on: DispatchQueue.main) .sink { [weak self] newValue in self?.isTrioDevice = (newValue == "Trio") + self?.isLoopDevice = (newValue == "Loop") + } + .store(in: &cancellables) + + // Loop APNS setup bindings + $loopAPNSKeyId + .dropFirst() + .sink { [weak self] in self?.storage.loopAPNSKeyId.value = $0 } + .store(in: &cancellables) + + $loopAPNSKey + .dropFirst() + .sink { [weak self] newValue in + // Log APNS key changes for debugging + LogManager.shared.log(category: .apns, message: "APNS Key changed - Length: \(newValue.count)") + LogManager.shared.log(category: .apns, message: "APNS Key contains line breaks: \(newValue.contains("\n"))") + LogManager.shared.log(category: .apns, message: "APNS Key contains BEGIN header: \(newValue.contains("-----BEGIN PRIVATE KEY-----"))") + LogManager.shared.log(category: .apns, message: "APNS Key contains END header: \(newValue.contains("-----END PRIVATE KEY-----"))") + + // Validate and fix the APNS key format using the service + let apnsService = LoopAPNSService() + let fixedKey = apnsService.validateAndFixAPNSKey(newValue) + + self?.storage.loopAPNSKey.value = fixedKey } .store(in: &cancellables) + + $loopDeveloperTeamId + .dropFirst() + .sink { [weak self] in self?.storage.loopDeveloperTeamId.value = $0 } + .store(in: &cancellables) + + $loopAPNSQrCodeURL + .dropFirst() + .sink { [weak self] in self?.storage.loopAPNSQrCodeURL.value = $0 } + .store(in: &cancellables) + + $loopAPNSDeviceToken + .dropFirst() + .sink { [weak self] in self?.storage.loopAPNSDeviceToken.value = $0 } + .store(in: &cancellables) + + $loopAPNSBundleIdentifier + .dropFirst() + .sink { [weak self] in self?.storage.loopAPNSBundleIdentifier.value = $0 } + .store(in: &cancellables) + + $loopAPNSSetup + .dropFirst() + .sink { [weak self] in self?.storage.loopAPNSSetup.value = $0 } + .store(in: &cancellables) + + $productionEnvironment + .dropFirst() + .sink { [weak self] in self?.storage.productionEnvironment.value = $0 } + .store(in: &cancellables) + + // Auto-validate Loop APNS setup when key ID, APNS key, or QR code changes + Publishers.CombineLatest3($loopAPNSKeyId, $loopAPNSKey, $loopAPNSQrCodeURL) + .dropFirst() + .receive(on: DispatchQueue.main) + .sink { [weak self] _, _, _ in + self?.validateLoopAPNSSetup() + } + .store(in: &cancellables) + + // Auto-validate when device token or bundle identifier changes + Publishers.CombineLatest($loopAPNSDeviceToken, $loopAPNSBundleIdentifier) + .dropFirst() + .receive(on: DispatchQueue.main) + .sink { [weak self] _, _ in + self?.validateFullLoopAPNSSetup() + } + .store(in: &cancellables) + + // Auto-validate when production environment changes + $productionEnvironment + .dropFirst() + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.validateFullLoopAPNSSetup() + } + .store(in: &cancellables) + } + + // MARK: - Loop APNS Setup Methods + + /// Validates the Loop APNS setup by checking all required fields + /// - Returns: True if setup is valid, false otherwise + func validateLoopAPNSSetup() { + let hasKeyId = !loopAPNSKeyId.isEmpty + let hasAPNSKey = !loopAPNSKey.isEmpty + let hasQrCode = !loopAPNSQrCodeURL.isEmpty + let hasDeviceToken = !loopAPNSDeviceToken.isEmpty + let hasBundleIdentifier = !loopAPNSBundleIdentifier.isEmpty + + // For initial setup, we don't require device token and bundle identifier + // These will be fetched when the user clicks "Refresh Device Token" + let hasBasicSetup = hasKeyId && hasAPNSKey && hasQrCode + + // For full validation (after device token is fetched), check everything + let hasFullSetup = hasBasicSetup && hasDeviceToken && hasBundleIdentifier + + loopAPNSSetup = hasFullSetup + + // Log validation results for debugging + LogManager.shared.log(category: .apns, message: "Loop APNS setup validation - Key ID: \(hasKeyId), APNS Key: \(hasAPNSKey), QR Code: \(hasQrCode), Device Token: \(hasDeviceToken), Bundle ID: \(hasBundleIdentifier), Valid: \(hasFullSetup)") + } + + /// Validates the full Loop APNS setup including device token and bundle identifier + /// - Returns: True if full setup is valid, false otherwise + func validateFullLoopAPNSSetup() { + let hasKeyId = !loopAPNSKeyId.isEmpty + let hasAPNSKey = !loopAPNSKey.isEmpty + let hasQrCode = !loopAPNSQrCodeURL.isEmpty + let hasDeviceToken = !loopAPNSDeviceToken.isEmpty + let hasBundleIdentifier = !loopAPNSBundleIdentifier.isEmpty + + let hasFullSetup = hasKeyId && hasAPNSKey && hasQrCode && hasDeviceToken && hasBundleIdentifier + + loopAPNSSetup = hasFullSetup + + // Log validation results for debugging + LogManager.shared.log(category: .apns, message: "Full Loop APNS setup validation - Key ID: \(hasKeyId), APNS Key: \(hasAPNSKey), QR Code: \(hasQrCode), Device Token: \(hasDeviceToken), Bundle ID: \(hasBundleIdentifier), Valid: \(hasFullSetup)") + } + + func refreshDeviceToken() async { + await MainActor.run { + isRefreshingDeviceToken = true + loopAPNSErrorMessage = nil + } + + // Use the regular Nightscout profile endpoint instead of the Loop APNS service + let success = await fetchDeviceTokenFromNightscoutProfile() + + await MainActor.run { + self.isRefreshingDeviceToken = false + if success { + self.loopAPNSDeviceToken = self.storage.loopAPNSDeviceToken.value + self.loopAPNSBundleIdentifier = self.storage.loopAPNSBundleIdentifier.value + // Trigger validation immediately after updating values + self.validateFullLoopAPNSSetup() + } else { + self.loopAPNSErrorMessage = "Failed to refresh device token. Check your Nightscout URL and token." + } + } + } + + private func fetchDeviceTokenFromNightscoutProfile() async -> Bool { + // Check if Nightscout is configured + guard !Storage.shared.url.value.isEmpty else { + LogManager.shared.log(category: .apns, message: "Nightscout URL not configured") + return false + } + + guard !Storage.shared.token.value.isEmpty else { + LogManager.shared.log(category: .apns, message: "Nightscout token not configured") + return false + } + + // Fetch profile from Nightscout using the regular profile endpoint + return await withCheckedContinuation { continuation in + NightscoutUtils.executeRequest(eventType: .profile, parameters: [:]) { (result: Result) in + DispatchQueue.main.async { + switch result { + case let .success(profileData): + // Log the profile data for debugging + LogManager.shared.log(category: .apns, message: "Profile fetched successfully for device token") + LogManager.shared.log(category: .apns, message: "Device token from profile: \(profileData.deviceToken ?? "nil")") + LogManager.shared.log(category: .apns, message: "Bundle identifier from profile: \(profileData.bundleIdentifier ?? "nil")") + + if let loopSettings = profileData.loopSettings { + LogManager.shared.log(category: .apns, message: "Loop settings device token: \(loopSettings.deviceToken ?? "nil")") + LogManager.shared.log(category: .apns, message: "Loop settings bundle identifier: \(loopSettings.bundleIdentifier ?? "nil")") + } + + // Update profile data which includes device token and bundle identifier + ProfileManager.shared.loadProfile(from: profileData) + + // Store the device token and bundle identifier in the Loop APNS storage + if let deviceToken = profileData.deviceToken, !deviceToken.isEmpty { + self.storage.loopAPNSDeviceToken.value = deviceToken + } else if let loopSettings = profileData.loopSettings, let deviceToken = loopSettings.deviceToken, !deviceToken.isEmpty { + self.storage.loopAPNSDeviceToken.value = deviceToken + } + + if let bundleIdentifier = profileData.bundleIdentifier, !bundleIdentifier.isEmpty { + self.storage.loopAPNSBundleIdentifier.value = bundleIdentifier + } else if let loopSettings = profileData.loopSettings, let bundleIdentifier = loopSettings.bundleIdentifier, !bundleIdentifier.isEmpty { + self.storage.loopAPNSBundleIdentifier.value = bundleIdentifier + } + + // Log the stored values after processing + LogManager.shared.log(category: .apns, message: "Stored device token: \(self.storage.loopAPNSDeviceToken.value)") + LogManager.shared.log(category: .apns, message: "Stored bundle ID: \(self.storage.loopAPNSBundleIdentifier.value)") + + // Log successful configuration + LogManager.shared.log(category: .apns, message: "Successfully configured device tokens from Nightscout profile") + + continuation.resume(returning: true) + + case let .failure(error): + LogManager.shared.log(category: .apns, message: "Failed to fetch profile for device token configuration: \(error.localizedDescription)") + continuation.resume(returning: false) + } + } + } + } + } + + func handleLoopAPNSQRCodeScanResult(_ result: Result) { + DispatchQueue.main.async { + switch result { + case let .success(code): + self.loopAPNSQrCodeURL = code + // Trigger validation after QR code is scanned + self.validateLoopAPNSSetup() + case let .failure(error): + self.loopAPNSErrorMessage = "Scanning failed: \(error.localizedDescription)" + } + self.isShowingLoopAPNSScanner = false + } } } diff --git a/LoopFollow/Settings/SettingsMenuView.swift b/LoopFollow/Settings/SettingsMenuView.swift index 37024ae4..663669cc 100644 --- a/LoopFollow/Settings/SettingsMenuView.swift +++ b/LoopFollow/Settings/SettingsMenuView.swift @@ -17,6 +17,10 @@ struct SettingsMenuView: View { @State private var versionTint: Color = .secondary @State private var showingTabCustomization = false + // MARK: – Observed objects + + @ObservedObject private var url = Storage.shared.url + // MARK: – Body var body: some View { diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 9d65aeb6..f37a03cd 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -15,7 +15,7 @@ class Storage { var deviceToken = StorageValue(key: "deviceToken", defaultValue: "") var expirationDate = StorageValue(key: "expirationDate", defaultValue: nil) var sharedSecret = StorageValue(key: "sharedSecret", defaultValue: "") - var productionEnvironment = StorageValue(key: "productionEnvironment", defaultValue: true) + var productionEnvironment = StorageValue(key: "productionEnvironment", defaultValue: false) var apnsKey = StorageValue(key: "apnsKey", defaultValue: "") var teamId = StorageValue(key: "teamId", defaultValue: nil) var keyId = StorageValue(key: "keyId", defaultValue: "") @@ -166,6 +166,20 @@ class Storage { var remotePosition = StorageValue(key: "remotePosition", defaultValue: .more) var nightscoutPosition = StorageValue(key: "nightscoutPosition", defaultValue: .position4) + // MARK: - Loop APNS Setup --------------------------------------------------- + + var loopAPNSSetup = StorageValue(key: "loopAPNSSetup", defaultValue: false) + var loopAPNSKeyId = StorageValue(key: "loopAPNSKeyId", defaultValue: "") + var loopAPNSKey = StorageValue(key: "loopAPNSKey", defaultValue: "") + var loopDeveloperTeamId = StorageValue(key: "loopDeveloperTeamId", defaultValue: "") + var loopAPNSQrCodeURL = StorageValue(key: "loopAPNSQrCodeURL", defaultValue: "") + var loopAPNSDeviceToken = StorageValue(key: "loopAPNSDeviceToken", defaultValue: "") + var loopAPNSBundleIdentifier = StorageValue(key: "loopAPNSBundleIdentifier", defaultValue: "") + + // MARK: - Override Presets --------------------------------------------------- + + // Override presets are fetched from Nightscout, not stored locally + static let shared = Storage() private init() {} } From 1e43b7d656f163734c9de70907eb3a66b94937a0 Mon Sep 17 00:00:00 2001 From: codebymini Date: Fri, 11 Jul 2025 09:10:05 +0200 Subject: [PATCH 02/21] Use already available overrides from ProfileManager for Loop APNS --- .../Remote/LoopAPNS/OverridePresetsView.swift | 71 +++++-------------- 1 file changed, 18 insertions(+), 53 deletions(-) diff --git a/LoopFollow/Remote/LoopAPNS/OverridePresetsView.swift b/LoopFollow/Remote/LoopAPNS/OverridePresetsView.swift index 37726af8..c43e0af3 100644 --- a/LoopFollow/Remote/LoopAPNS/OverridePresetsView.swift +++ b/LoopFollow/Remote/LoopAPNS/OverridePresetsView.swift @@ -236,52 +236,25 @@ class OverridePresetsViewModel: ObservableObject { } private func fetchOverridePresetsFromNightscout() async throws -> [OverridePreset] { - let url = Storage.shared.url.value - guard !url.isEmpty else { - throw OverrideError.nightscoutNotConfigured - } - - let token = Storage.shared.token.value - guard !token.isEmpty else { - throw OverrideError.nightscoutNotConfigured - } - - let nightscoutURL = URL(string: url)! - let profileURL = nightscoutURL.appendingPathComponent("api/v1/profile.json") - - var request = URLRequest(url: profileURL) - - // Add token authentication - var components = URLComponents(url: profileURL, resolvingAgainstBaseURL: false) - components?.queryItems = [URLQueryItem(name: "token", value: token)] - if let urlWithToken = components?.url { - request.url = urlWithToken - } - - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse else { - throw OverrideError.invalidResponse - } - - guard httpResponse.statusCode == 200 else { - throw OverrideError.serverError(httpResponse.statusCode) - } - - let profiles = try JSONDecoder().decode([ProfileData].self, from: data) - - // Find the most recent profile with loopSettings - guard let latestProfile = profiles.first(where: { $0.loopSettings?.overridePresets != nil }) else { - return [] - } + // Use ProfileManager's already loaded overrides instead of fetching from Nightscout + let loopOverrides = ProfileManager.shared.loopOverrides + + return loopOverrides.map { override in + let targetRange: ClosedRange? + if override.targetRange.count >= 2 { + let lowValue = override.targetRange[0].doubleValue(for: ProfileManager.shared.units) + let highValue = override.targetRange[1].doubleValue(for: ProfileManager.shared.units) + targetRange = lowValue ... highValue + } else { + targetRange = nil + } - return latestProfile.loopSettings!.overridePresets.map { preset in - OverridePreset( - name: preset.name, - symbol: preset.symbol, - targetRange: preset.targetRange, - insulinNeedsScaleFactor: preset.insulinNeedsScaleFactor, - duration: preset.duration + return OverridePreset( + name: override.name, + symbol: override.symbol.isEmpty ? nil : override.symbol, + targetRange: targetRange, + insulinNeedsScaleFactor: override.insulinNeedsScaleFactor, + duration: TimeInterval(override.duration ?? 0) ) } } @@ -302,14 +275,6 @@ class OverridePresetsViewModel: ObservableObject { // MARK: - Data Models -struct ProfileData: Codable { - let loopSettings: LoopSettings? -} - -struct LoopSettings: Codable { - let overridePresets: [OverridePresetData] -} - struct OverridePreset { let name: String let symbol: String? From 212ea49142be6e2464811aa100adda2cc5438d98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Fri, 11 Jul 2025 13:36:42 +0200 Subject: [PATCH 03/21] sha1 is no longer needed --- LoopFollow/Helpers/NightscoutUtils.swift | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/LoopFollow/Helpers/NightscoutUtils.swift b/LoopFollow/Helpers/NightscoutUtils.swift index 947a21eb..92b73f37 100644 --- a/LoopFollow/Helpers/NightscoutUtils.swift +++ b/LoopFollow/Helpers/NightscoutUtils.swift @@ -5,18 +5,6 @@ import CommonCrypto import Foundation -extension String { - var sha1: String { - let data = Data(utf8) - var digest = [UInt8](repeating: 0, count: Int(CC_SHA1_DIGEST_LENGTH)) - data.withUnsafeBytes { - _ = CC_SHA1($0.baseAddress, CC_LONG(data.count), &digest) - } - let hexBytes = digest.map { String(format: "%02hhx", $0) } - return hexBytes.joined() - } -} - class NightscoutUtils { enum NightscoutError: Error, LocalizedError { case emptyAddress From 56eb9c8b56572b5df3869e39360bbc360decaf7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sat, 12 Jul 2025 19:01:27 +0200 Subject: [PATCH 04/21] Removed CommonCrypto since it is not used. --- LoopFollow/Helpers/NightscoutUtils.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/LoopFollow/Helpers/NightscoutUtils.swift b/LoopFollow/Helpers/NightscoutUtils.swift index 92b73f37..0bd21bf2 100644 --- a/LoopFollow/Helpers/NightscoutUtils.swift +++ b/LoopFollow/Helpers/NightscoutUtils.swift @@ -2,7 +2,6 @@ // NightscoutUtils.swift // Created by bjorkert. -import CommonCrypto import Foundation class NightscoutUtils { From 624e738483975fe3b6aac715dac5791d9d1abeec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sat, 12 Jul 2025 19:09:15 +0200 Subject: [PATCH 05/21] Undo change --- LoopFollow/Nightscout/NightscoutSettingsViewModel.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/LoopFollow/Nightscout/NightscoutSettingsViewModel.swift b/LoopFollow/Nightscout/NightscoutSettingsViewModel.swift index 25ac084d..451494f5 100644 --- a/LoopFollow/Nightscout/NightscoutSettingsViewModel.swift +++ b/LoopFollow/Nightscout/NightscoutSettingsViewModel.swift @@ -96,12 +96,12 @@ class NightscoutSettingsViewModel: ObservableObject { } func checkNightscoutStatus() { - NightscoutUtils.verifyURLAndToken { [weak self] error, _, nsWriteAuth, nsAdminAuth in + NightscoutUtils.verifyURLAndToken { error, _, nsWriteAuth, nsAdminAuth in DispatchQueue.main.async { Storage.shared.nsWriteAuth.value = nsWriteAuth Storage.shared.nsAdminAuth.value = nsAdminAuth - self?.updateStatusLabel(error: error) + self.updateStatusLabel(error: error) } } } From e1eb1698b2266489fb64dd434de0aec30b751529 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sat, 12 Jul 2025 19:11:01 +0200 Subject: [PATCH 06/21] Revert APNS-related changes in Config.xcconfig from commit d5ce515 --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index a5ca12a0..ecab50c7 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 2.8.11 +LOOP_FOLLOW_MARKETING_VERSION = 2.8.10 From b8b51185f301316e4381753d5007dacd6fc789e8 Mon Sep 17 00:00:00 2001 From: codebymini Date: Sat, 12 Jul 2025 21:05:32 +0200 Subject: [PATCH 07/21] Fix for loopapns setup not updating and reverting to default value --- .../Nightscout/ProfileManager.swift | 2 +- .../Remote/LoopAPNS/LoopAPNSRemoteView.swift | 7 +- .../LoopAPNS/LoopAPNSSettingsView.swift | 13 +++- .../Remote/Settings/RemoteSettingsView.swift | 6 +- .../Settings/RemoteSettingsViewModel.swift | 71 +++++++++++++++++++ 5 files changed, 95 insertions(+), 4 deletions(-) diff --git a/LoopFollow/Controllers/Nightscout/ProfileManager.swift b/LoopFollow/Controllers/Nightscout/ProfileManager.swift index be1ac3e2..e6016858 100644 --- a/LoopFollow/Controllers/Nightscout/ProfileManager.swift +++ b/LoopFollow/Controllers/Nightscout/ProfileManager.swift @@ -116,7 +116,7 @@ final class ProfileManager { Storage.shared.expirationDate.value = nil } Storage.shared.bundleId.value = profileData.bundleIdentifier ?? "" - Storage.shared.productionEnvironment.value = profileData.isAPNSProduction ?? false + Storage.shared.teamId.value = profileData.teamID ?? Storage.shared.teamId.value ?? "" } diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSRemoteView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSRemoteView.swift index 27f846e4..dde0b2d6 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSRemoteView.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSRemoteView.swift @@ -7,6 +7,7 @@ import SwiftUI struct LoopAPNSRemoteView: View { @Environment(\.presentationMode) var presentationMode @ObservedObject var loopAPNSSetup = Storage.shared.loopAPNSSetup + @StateObject private var viewModel = RemoteSettingsViewModel() var body: some View { NavigationView { @@ -37,7 +38,7 @@ struct LoopAPNSRemoteView: View { .multilineTextAlignment(.center) .foregroundColor(.secondary) - NavigationLink(destination: LoopAPNSSettingsView()) { + NavigationLink(destination: LoopAPNSSettingsView(viewModel: viewModel)) { HStack { Image(systemName: "gear") Text("Configure Loop APNS") @@ -55,6 +56,10 @@ struct LoopAPNSRemoteView: View { Spacer() } .navigationBarTitle("Loop Remote Control", displayMode: .inline) + .onAppear { + // Validate Loop APNS setup when view appears + viewModel.validateLoopAPNSSetup() + } } } } diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSSettingsView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSSettingsView.swift index 37798407..5a6616ac 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSSettingsView.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSSettingsView.swift @@ -5,7 +5,7 @@ import SwiftUI struct LoopAPNSSettingsView: View { - @StateObject private var viewModel = RemoteSettingsViewModel() + @ObservedObject var viewModel: RemoteSettingsViewModel @Environment(\.presentationMode) var presentationMode var body: some View { @@ -193,11 +193,22 @@ struct LoopAPNSSettingsView: View { } } .onAppear { + // Validate Loop APNS setup when view appears + viewModel.validateLoopAPNSSetup() + // Automatically fetch device token and bundle identifier when entering the setup screen Task { await viewModel.refreshDeviceToken() } } + .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("LoopAPNSSetupChanged"))) { _ in + // Update validation when Loop APNS setup changes + viewModel.validateLoopAPNSSetup() + } + .onDisappear { + // Force validation when leaving the settings view + viewModel.forceValidateLoopAPNSSetup() + } } } } diff --git a/LoopFollow/Remote/Settings/RemoteSettingsView.swift b/LoopFollow/Remote/Settings/RemoteSettingsView.swift index bc673cae..2532baca 100644 --- a/LoopFollow/Remote/Settings/RemoteSettingsView.swift +++ b/LoopFollow/Remote/Settings/RemoteSettingsView.swift @@ -130,7 +130,7 @@ struct RemoteSettingsView: View { } .padding(.vertical, 8) - NavigationLink(destination: LoopAPNSSettingsView()) { + NavigationLink(destination: LoopAPNSSettingsView(viewModel: viewModel)) { HStack { Image(systemName: "gear") Text("Configure Loop APNS Settings") @@ -174,6 +174,10 @@ struct RemoteSettingsView: View { // Update validation when Loop APNS setup changes viewModel.validateLoopAPNSSetup() } + .onDisappear { + // Force validation when leaving the settings view + viewModel.forceValidateLoopAPNSSetup() + } } // MARK: - Custom Row for Remote Type Selection diff --git a/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift b/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift index 76ec5731..4275483c 100644 --- a/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift +++ b/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift @@ -211,6 +211,55 @@ class RemoteSettingsViewModel: ObservableObject { self?.validateFullLoopAPNSSetup() } .store(in: &cancellables) + + // Auto-validate when individual Loop APNS fields change + $loopAPNSKeyId + .dropFirst() + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.validateLoopAPNSSetup() + } + .store(in: &cancellables) + + $loopAPNSKey + .dropFirst() + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.validateLoopAPNSSetup() + } + .store(in: &cancellables) + + $loopDeveloperTeamId + .dropFirst() + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.validateLoopAPNSSetup() + } + .store(in: &cancellables) + + $loopAPNSDeviceToken + .dropFirst() + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.validateFullLoopAPNSSetup() + } + .store(in: &cancellables) + + $loopAPNSBundleIdentifier + .dropFirst() + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.validateFullLoopAPNSSetup() + } + .store(in: &cancellables) + + $loopAPNSQrCodeURL + .dropFirst() + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.validateLoopAPNSSetup() + } + .store(in: &cancellables) } // MARK: - Loop APNS Setup Methods @@ -231,10 +280,16 @@ class RemoteSettingsViewModel: ObservableObject { // For full validation (after device token is fetched), check everything let hasFullSetup = hasBasicSetup && hasDeviceToken && hasBundleIdentifier + let oldSetup = loopAPNSSetup loopAPNSSetup = hasFullSetup // Log validation results for debugging LogManager.shared.log(category: .apns, message: "Loop APNS setup validation - Key ID: \(hasKeyId), APNS Key: \(hasAPNSKey), QR Code: \(hasQrCode), Device Token: \(hasDeviceToken), Bundle ID: \(hasBundleIdentifier), Valid: \(hasFullSetup)") + + // Post notification if setup status changed + if oldSetup != hasFullSetup { + NotificationCenter.default.post(name: NSNotification.Name("LoopAPNSSetupChanged"), object: nil) + } } /// Validates the full Loop APNS setup including device token and bundle identifier @@ -248,10 +303,16 @@ class RemoteSettingsViewModel: ObservableObject { let hasFullSetup = hasKeyId && hasAPNSKey && hasQrCode && hasDeviceToken && hasBundleIdentifier + let oldSetup = loopAPNSSetup loopAPNSSetup = hasFullSetup // Log validation results for debugging LogManager.shared.log(category: .apns, message: "Full Loop APNS setup validation - Key ID: \(hasKeyId), APNS Key: \(hasAPNSKey), QR Code: \(hasQrCode), Device Token: \(hasDeviceToken), Bundle ID: \(hasBundleIdentifier), Valid: \(hasFullSetup)") + + // Post notification if setup status changed + if oldSetup != hasFullSetup { + NotificationCenter.default.post(name: NSNotification.Name("LoopAPNSSetupChanged"), object: nil) + } } func refreshDeviceToken() async { @@ -351,4 +412,14 @@ class RemoteSettingsViewModel: ObservableObject { self.isShowingLoopAPNSScanner = false } } + + /// Forces validation of Loop APNS setup + func forceValidateLoopAPNSSetup() { + validateLoopAPNSSetup() + } + + /// Forces validation of full Loop APNS setup including device token + func forceValidateFullLoopAPNSSetup() { + validateFullLoopAPNSSetup() + } } From 1ee977959556f07cffa4fbd3d1af24a691755f4a Mon Sep 17 00:00:00 2001 From: codebymini Date: Sun, 13 Jul 2025 21:28:05 +0200 Subject: [PATCH 08/21] Fix rounding for bolus confirm button --- LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift index 0cc01855..89104f4a 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift @@ -126,7 +126,7 @@ struct LoopAPNSBolusView: View { case .confirmation: return Alert( title: Text("Confirm Insulin"), - message: Text("Send \(String(format: "%.1f", insulinAmount.doubleValue(for: .internationalUnit()))) units of insulin?"), + message: Text("Send \(String(format: "%.2f", insulinAmount.doubleValue(for: .internationalUnit()))) units of insulin?"), primaryButton: .default(Text("Send")) { authenticateAndSendInsulin() }, @@ -170,7 +170,7 @@ struct LoopAPNSBolusView: View { let insulinValue = insulinAmount.doubleValue(for: .internationalUnit()) if insulinValue > maxBolus { - alertMessage = "Insulin amount (\(String(format: "%.1f", insulinValue))U) exceeds the maximum allowed (\(String(format: "%.1f", maxBolus))U). Please reduce the amount." + alertMessage = "Insulin amount (\(String(format: "%.2f", insulinValue))U) exceeds the maximum allowed (\(String(format: "%.2f", maxBolus))U). Please reduce the amount." alertType = .error showAlert = true return From 55edb5af8e81e9804ec74982ace6cb24a3db42cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 13 Jul 2025 23:05:50 +0200 Subject: [PATCH 09/21] Centralize jwt-management --- LoopFollow.xcodeproj/project.pbxproj | 4 + LoopFollow/Helpers/JWTManager.swift | 53 +++++++ .../Remote/LoopAPNS/LoopAPNSService.swift | 149 +----------------- .../Remote/TRC/PushNotificationManager.swift | 29 +--- .../ViewControllers/MainViewController.swift | 24 +++ 5 files changed, 84 insertions(+), 175 deletions(-) create mode 100644 LoopFollow/Helpers/JWTManager.swift diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 2cee8bcd..cdec2494 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -198,6 +198,7 @@ DDD10F072C529DE800D76A8E /* Observable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD10F062C529DE800D76A8E /* Observable.swift */; }; DDD10F0B2C54192A00D76A8E /* TemporaryTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD10F0A2C54192A00D76A8E /* TemporaryTarget.swift */; }; DDDB86F12DF7223C00AADDAC /* DeleteAlarmSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDB86F02DF7223C00AADDAC /* DeleteAlarmSection.swift */; }; + DDDC01DD2E244B3100D9975C /* JWTManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDC01DC2E244B3100D9975C /* JWTManager.swift */; }; DDDC31CC2E13A7DF009EA0F3 /* AddAlarmSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDC31CB2E13A7DF009EA0F3 /* AddAlarmSheet.swift */; }; DDDC31CE2E13A811009EA0F3 /* AlarmTile.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDC31CD2E13A811009EA0F3 /* AlarmTile.swift */; }; DDDF6F492D479AF000884336 /* NoRemoteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDF6F482D479AEF00884336 /* NoRemoteView.swift */; }; @@ -583,6 +584,7 @@ DDD10F062C529DE800D76A8E /* Observable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Observable.swift; sourceTree = ""; }; DDD10F0A2C54192A00D76A8E /* TemporaryTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryTarget.swift; sourceTree = ""; }; DDDB86F02DF7223C00AADDAC /* DeleteAlarmSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteAlarmSection.swift; sourceTree = ""; }; + DDDC01DC2E244B3100D9975C /* JWTManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JWTManager.swift; sourceTree = ""; }; DDDC31CB2E13A7DF009EA0F3 /* AddAlarmSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddAlarmSheet.swift; sourceTree = ""; }; DDDC31CD2E13A811009EA0F3 /* AlarmTile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmTile.swift; sourceTree = ""; }; DDDF6F482D479AEF00884336 /* NoRemoteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoRemoteView.swift; sourceTree = ""; }; @@ -1505,6 +1507,7 @@ DDF2C0112BEFB733007A20E6 /* AppVersionManager.swift */, DDF699952C5582290058A8D9 /* TextFieldWithToolBar.swift */, DDC7E5372DBD887400EB1127 /* isOnPhoneCall.swift */, + DDDC01DC2E244B3100D9975C /* JWTManager.swift */, ); path = Helpers; sourceTree = ""; @@ -2007,6 +2010,7 @@ DDB9FC7F2DDB584500EFAA76 /* BolusEntry.swift in Sources */, FC9788182485969B00A7906C /* AppDelegate.swift in Sources */, 654134182E1DC09700BDBE08 /* OverridePresetsView.swift in Sources */, + DDDC01DD2E244B3100D9975C /* JWTManager.swift in Sources */, DDD10F072C529DE800D76A8E /* Observable.swift in Sources */, DDFF3D852D14279B00BF9D9E /* BackgroundRefreshSettingsView.swift in Sources */, DDCF9A882D85FD33004DF4DD /* AlarmData.swift in Sources */, diff --git a/LoopFollow/Helpers/JWTManager.swift b/LoopFollow/Helpers/JWTManager.swift new file mode 100644 index 00000000..b3a55b35 --- /dev/null +++ b/LoopFollow/Helpers/JWTManager.swift @@ -0,0 +1,53 @@ +// LoopFollow +// JWTManager.swift +// Created by Jonas Björkert. + +import Foundation +import SwiftJWT + +struct JWTClaims: Claims { + let iss: String + let iat: Date +} + +class JWTManager { + static let shared = JWTManager() + + 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 + } + + // 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) + + do { + let privateKey = Data(apnsKey.utf8) + let jwtSigner = JWTSigner.es256(privateKey: privateKey) + let signedJWT = try jwt.sign(using: jwtSigner) + + // 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 + + return signedJWT + } catch { + LogManager.shared.log(category: .apns, message: "Failed to sign JWT: \(error.localizedDescription)") + return nil + } + } + + // Invalidate the cache by clearing values in Storage.shared + func invalidateCache() { + Storage.shared.cachedJWT.value = nil + Storage.shared.jwtExpirationDate.value = nil + } +} diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift index d04adf9a..89cfc0af 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift @@ -24,11 +24,6 @@ class LoopAPNSService { let bundleIdentifier: String? } - struct LoopAPNSJWTClaims: Claims { - let iss: String - let iat: Date - } - enum LoopAPNSError: Error, LocalizedError { case invalidURL case networkError @@ -380,11 +375,8 @@ class LoopAPNSService { payload: [String: Any] ) async throws -> Bool { // Create JWT token for APNS authentication - let jwt: String - do { - jwt = try createAPNSJWT(keyId: keyId, apnsKey: apnsKey, bundleIdentifier: bundleIdentifier) - } catch { - LogManager.shared.log(category: .apns, message: "Failed to create JWT: \(error.localizedDescription)") + guard let jwt = JWTManager.shared.getOrGenerateJWT(keyId: keyId, teamId: Storage.shared.loopDeveloperTeamId.value, apnsKey: apnsKey) else { + LogManager.shared.log(category: .apns, message: "Failed to create JWT using JWTManager. Check APNS credentials.") throw LoopAPNSError.invalidURL } @@ -671,143 +663,6 @@ class LoopAPNSService { return guidance } - /// Creates a JWT token for APNS authentication - /// - Parameters: - /// - keyId: The APNS key ID - /// - apnsKey: The APNS key - /// - bundleIdentifier: The bundle identifier - /// - Returns: The JWT token - /// - Throws: LoopAPNSError if JWT creation fails - private func createAPNSJWT(keyId: String, apnsKey: String, bundleIdentifier: String) throws -> String { - // Validate inputs - guard !keyId.isEmpty, !apnsKey.isEmpty, !bundleIdentifier.isEmpty else { - LogManager.shared.log(category: .apns, message: "Invalid JWT inputs - keyId: \(keyId.isEmpty), apnsKey: \(apnsKey.isEmpty), bundleIdentifier: \(bundleIdentifier.isEmpty)") - throw LoopAPNSError.invalidURL - } - - // Validate and fix APNS key format - let fixedApnsKey = validateAndFixAPNSKey(apnsKey) - - // Validate keyId format (should be 10 alphanumeric characters) - let keyIdPattern = "^[A-Z0-9]{10}$" - let isValidKeyId = keyId.range(of: keyIdPattern, options: .regularExpression) != nil - LogManager.shared.log(category: .apns, message: "Key ID validation - Key ID: \(keyId), Is valid format: \(isValidKeyId)") - - // For APNS, the issuer should be the Team ID - // Try to get the team ID from storage, but if not set, use the key ID (like Nightscout does) - let teamId: String - let storedTeamId = storage.loopDeveloperTeamId.value - if !storedTeamId.isEmpty { - teamId = storedTeamId - LogManager.shared.log(category: .apns, message: "Using Loop Team ID from storage: \(teamId)") - } else { - teamId = keyId - LogManager.shared.log(category: .apns, message: "No Loop Team ID in storage, using Key ID as Team ID: \(teamId)") - } - - // Validate Team ID format (should be 10 alphanumeric characters) - let teamIdPattern = "^[A-Z0-9]{10}$" - let isValidTeamId = teamId.range(of: teamIdPattern, options: .regularExpression) != nil - LogManager.shared.log(category: .apns, message: "Team ID validation - Team ID: \(teamId), Is valid format: \(isValidTeamId)") - - LogManager.shared.log(category: .apns, message: "Creating JWT with keyId: \(keyId), bundleIdentifier: \(bundleIdentifier), teamId: \(teamId)") - - // Log APNS key details for debugging (without exposing the actual key) - let apnsKeyLines = fixedApnsKey.components(separatedBy: .newlines) - let apnsKeyLineCount = apnsKeyLines.count - let hasPrivateKeyHeader = fixedApnsKey.contains("-----BEGIN PRIVATE KEY-----") - let hasEndHeader = fixedApnsKey.contains("-----END PRIVATE KEY-----") - LogManager.shared.log(category: .apns, message: "APNS Key details - Lines: \(apnsKeyLineCount), Has PKCS8 header: \(hasPrivateKeyHeader), Has end header: \(hasEndHeader)") - - // Log key guidance for debugging - let guidance = getAPNSKeyGuidance(fixedApnsKey) - LogManager.shared.log(category: .apns, message: guidance) - - do { - // Try using CryptoKit approach for JWT creation (like our original implementation) - LogManager.shared.log(category: .apns, message: "Creating JWT using CryptoKit approach") - - // Create JWT header - let header: [String: String] = [ - "alg": "ES256", - "kid": keyId, - "typ": "JWT", - ] - - let now = Date() - let payload: [String: Any] = [ - "iss": teamId, - "iat": Int(now.timeIntervalSince1970), - ] - - // Encode header and payload as base64url - let headerData = try JSONSerialization.data(withJSONObject: header) - let payloadData = try JSONSerialization.data(withJSONObject: payload) - - let headerBase64 = base64urlEncode(headerData) - let payloadBase64 = base64urlEncode(payloadData) - - // Create the signing input - let signingInput = "\(headerBase64).\(payloadBase64)" - - // Sign the input with the APNS key using CryptoKit - let signature = try signWithES256(signingInput: signingInput, pemKey: fixedApnsKey) - let signatureBase64 = base64urlEncode(signature) - - // Combine all parts - let jwt = "\(signingInput).\(signatureBase64)" - - LogManager.shared.log(category: .apns, message: "JWT created successfully using CryptoKit") - return jwt - } catch { - LogManager.shared.log(category: .apns, message: "Failed to create JWT with CryptoKit: \(error.localizedDescription)") - - // Try fallback method using SwiftJWT - LogManager.shared.log(category: .apns, message: "Attempting fallback JWT creation with SwiftJWT") - - do { - let jwt = try createJWTWithSwiftJWT(keyId: keyId, apnsKey: fixedApnsKey, teamId: teamId) - LogManager.shared.log(category: .apns, message: "JWT created successfully using SwiftJWT fallback") - return jwt - } catch { - LogManager.shared.log(category: .apns, message: "Failed to create JWT with SwiftJWT fallback: \(error.localizedDescription)") - - // Provide detailed error guidance - LogManager.shared.log(category: .apns, message: "Both JWT creation methods failed. This usually indicates:") - LogManager.shared.log(category: .apns, message: "1. The APNS key is incomplete or corrupted") - LogManager.shared.log(category: .apns, message: "2. The key is not a valid P-256 private key") - LogManager.shared.log(category: .apns, message: "3. The key was copied incorrectly from Apple Developer portal") - LogManager.shared.log(category: .apns, message: "Please verify the APNS key is complete and properly formatted.") - - throw LoopAPNSError.invalidURL - } - } - } - - /// Creates a JWT token using SwiftJWT as a fallback method - /// - Parameters: - /// - keyId: The APNS key ID - /// - apnsKey: The APNS key - /// - teamId: The team ID - /// - Returns: The JWT token - /// - Throws: LoopAPNSError if JWT creation fails - private func createJWTWithSwiftJWT(keyId: String, apnsKey: String, teamId: String) throws -> String { - let header = Header(kid: keyId) - let claims = LoopAPNSJWTClaims(iss: teamId, iat: Date()) - - var jwt = JWT(header: header, claims: claims) - - do { - let privateKey = Data(apnsKey.utf8) - let jwtSigner = JWTSigner.es256(privateKey: privateKey) - let signedJWT = try jwt.sign(using: jwtSigner) - return signedJWT - } catch { - LogManager.shared.log(category: .apns, message: "SwiftJWT signing failed: \(error.localizedDescription)") - throw LoopAPNSError.invalidURL - } - } - /// Extracts key data from PEM format /// - Parameter pemString: The PEM formatted private key /// - Returns: The extracted key data string diff --git a/LoopFollow/Remote/TRC/PushNotificationManager.swift b/LoopFollow/Remote/TRC/PushNotificationManager.swift index 832e8657..5f363403 100644 --- a/LoopFollow/Remote/TRC/PushNotificationManager.swift +++ b/LoopFollow/Remote/TRC/PushNotificationManager.swift @@ -234,7 +234,7 @@ class PushNotificationManager { return } - guard let jwt = getOrGenerateJWT() else { + guard let jwt = JWTManager.shared.getOrGenerateJWT(keyId: keyId, teamId: teamId, apnsKey: apnsKey) else { let errorMessage = "Failed to generate JWT, please check that the token is correct." LogManager.shared.log(category: .apns, message: errorMessage) completion(false, errorMessage) @@ -326,31 +326,4 @@ class PushNotificationManager { let urlString = "https://\(host)/3/device/\(deviceToken)" return URL(string: urlString) } - - private func getOrGenerateJWT() -> String? { - if let cachedJWT = Storage.shared.cachedJWT.value, let expirationDate = Storage.shared.jwtExpirationDate.value { - if Date() < expirationDate { - return cachedJWT - } - } - - let header = Header(kid: keyId) - let claims = APNsJWTClaims(iss: teamId, iat: Date()) - - var jwt = JWT(header: header, claims: claims) - - do { - let privateKey = Data(apnsKey.utf8) - let jwtSigner = JWTSigner.es256(privateKey: privateKey) - let signedJWT = try jwt.sign(using: jwtSigner) - - Storage.shared.cachedJWT.value = signedJWT - Storage.shared.jwtExpirationDate.value = Date().addingTimeInterval(3600) - - return signedJWT - } catch { - print("Failed to sign JWT: \(error.localizedDescription)") - return nil - } - } } diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 6d55114e..5933f978 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -313,6 +313,30 @@ 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) + updateQuickActions() setupTabBar() From a3fb1baffadd1df740d22e713d9291a0e02d3db9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 13 Jul 2025 23:31:03 +0200 Subject: [PATCH 10/21] Use the sama apnskey, keyid and team storage as trio --- .../Remote/LoopAPNS/LoopAPNSService.swift | 30 +++++++++---------- .../Settings/RemoteSettingsViewModel.swift | 12 ++++---- LoopFollow/Storage/Storage.swift | 3 -- 3 files changed, 21 insertions(+), 24 deletions(-) diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift index 89cfc0af..c5e2d8df 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift @@ -132,8 +132,8 @@ 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.loopAPNSKeyId.value.isEmpty - let hasAPNSKey = !storage.loopAPNSKey.value.isEmpty + let hasKeyId = !storage.keyId.value.isEmpty + let hasAPNSKey = !storage.apnsKey.value.isEmpty let hasQrCode = !storage.loopAPNSQrCodeURL.value.isEmpty let hasDeviceToken = !storage.loopAPNSDeviceToken.value.isEmpty let hasBundleIdentifier = !storage.loopAPNSBundleIdentifier.value.isEmpty @@ -151,8 +151,8 @@ class LoopAPNSService { /// Validates the basic Loop APNS setup (without device token) /// - Returns: True if basic setup is valid, false otherwise func validateBasicSetup() -> Bool { - let hasKeyId = !storage.loopAPNSKeyId.value.isEmpty - let hasAPNSKey = !storage.loopAPNSKey.value.isEmpty + let hasKeyId = !storage.keyId.value.isEmpty + let hasAPNSKey = !storage.apnsKey.value.isEmpty let hasQrCode = !storage.loopAPNSQrCodeURL.value.isEmpty let isValid = hasKeyId && hasAPNSKey && hasQrCode @@ -177,8 +177,8 @@ class LoopAPNSService { /// Validates the APNS key format and provides debugging information private func validateAPNSKeyFormat() { - let apnsKey = storage.loopAPNSKey.value - let keyId = storage.loopAPNSKeyId.value + let apnsKey = storage.apnsKey.value + let keyId = storage.keyId.value let teamId = storage.teamId.value ?? keyId // Validate key format @@ -269,8 +269,8 @@ class LoopAPNSService { throw LoopAPNSError.invalidURL } let (deviceToken, bundleIdentifier) = try await getValidDeviceTokenAndBundle() - let keyId = storage.loopAPNSKeyId.value - let apnsKey = storage.loopAPNSKey.value + let keyId = storage.keyId.value + let apnsKey = storage.apnsKey.value // Create APNS notification payload (matching Loop's expected format) let now = Date() @@ -320,8 +320,8 @@ class LoopAPNSService { throw LoopAPNSError.invalidURL } let (deviceToken, bundleIdentifier) = try await getValidDeviceTokenAndBundle() - let keyId = storage.loopAPNSKeyId.value - let apnsKey = storage.loopAPNSKey.value + let keyId = storage.keyId.value + let apnsKey = storage.apnsKey.value // Create APNS notification payload (matching Loop's expected format) let now = Date() @@ -375,7 +375,7 @@ class LoopAPNSService { payload: [String: Any] ) async throws -> Bool { // Create JWT token for APNS authentication - guard let jwt = JWTManager.shared.getOrGenerateJWT(keyId: keyId, teamId: Storage.shared.loopDeveloperTeamId.value, apnsKey: apnsKey) else { + guard let jwt = JWTManager.shared.getOrGenerateJWT(keyId: keyId, teamId: Storage.shared.teamId.value ?? "", apnsKey: apnsKey) else { LogManager.shared.log(category: .apns, message: "Failed to create JWT using JWTManager. Check APNS credentials.") throw LoopAPNSError.invalidURL } @@ -794,8 +794,8 @@ class LoopAPNSService { try await sendAPNSNotification( deviceToken: deviceToken, bundleIdentifier: bundleIdentifier, - keyId: storage.loopAPNSKeyId.value, - apnsKey: storage.loopAPNSKey.value, + keyId: storage.keyId.value, + apnsKey: storage.apnsKey.value, payload: payload ) } @@ -828,8 +828,8 @@ class LoopAPNSService { try await sendAPNSNotification( deviceToken: deviceToken, bundleIdentifier: bundleIdentifier, - keyId: storage.loopAPNSKeyId.value, - apnsKey: storage.loopAPNSKey.value, + keyId: storage.keyId.value, + apnsKey: storage.apnsKey.value, payload: payload ) } diff --git a/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift b/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift index 4275483c..9bbcb96e 100644 --- a/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift +++ b/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift @@ -53,9 +53,9 @@ class RemoteSettingsViewModel: ObservableObject { mealWithBolus = storage.mealWithBolus.value mealWithFatProtein = storage.mealWithFatProtein.value - loopAPNSKeyId = storage.loopAPNSKeyId.value - loopAPNSKey = storage.loopAPNSKey.value - loopDeveloperTeamId = storage.loopDeveloperTeamId.value + loopAPNSKeyId = storage.keyId.value + loopAPNSKey = storage.apnsKey.value + loopDeveloperTeamId = storage.teamId.value ?? "" loopAPNSQrCodeURL = storage.loopAPNSQrCodeURL.value loopAPNSDeviceToken = storage.loopAPNSDeviceToken.value loopAPNSBundleIdentifier = storage.loopAPNSBundleIdentifier.value @@ -135,7 +135,7 @@ class RemoteSettingsViewModel: ObservableObject { // Loop APNS setup bindings $loopAPNSKeyId .dropFirst() - .sink { [weak self] in self?.storage.loopAPNSKeyId.value = $0 } + .sink { [weak self] in self?.storage.keyId.value = $0 } .store(in: &cancellables) $loopAPNSKey @@ -151,13 +151,13 @@ class RemoteSettingsViewModel: ObservableObject { let apnsService = LoopAPNSService() let fixedKey = apnsService.validateAndFixAPNSKey(newValue) - self?.storage.loopAPNSKey.value = fixedKey + self?.storage.apnsKey.value = fixedKey } .store(in: &cancellables) $loopDeveloperTeamId .dropFirst() - .sink { [weak self] in self?.storage.loopDeveloperTeamId.value = $0 } + .sink { [weak self] in self?.storage.teamId.value = $0 } .store(in: &cancellables) $loopAPNSQrCodeURL diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index f37a03cd..3348d1d6 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -169,9 +169,6 @@ class Storage { // MARK: - Loop APNS Setup --------------------------------------------------- var loopAPNSSetup = StorageValue(key: "loopAPNSSetup", defaultValue: false) - var loopAPNSKeyId = StorageValue(key: "loopAPNSKeyId", defaultValue: "") - var loopAPNSKey = StorageValue(key: "loopAPNSKey", defaultValue: "") - var loopDeveloperTeamId = StorageValue(key: "loopDeveloperTeamId", defaultValue: "") var loopAPNSQrCodeURL = StorageValue(key: "loopAPNSQrCodeURL", defaultValue: "") var loopAPNSDeviceToken = StorageValue(key: "loopAPNSDeviceToken", defaultValue: "") var loopAPNSBundleIdentifier = StorageValue(key: "loopAPNSBundleIdentifier", defaultValue: "") From d5c4c10344eb59707fd2c37464a830e200ca1745 Mon Sep 17 00:00:00 2001 From: codebymini Date: Mon, 14 Jul 2025 08:41:49 +0200 Subject: [PATCH 11/21] Remove stray references to loopAPNS variables --- .../LoopAPNS/LoopAPNSSettingsView.swift | 4 ++-- .../Settings/RemoteSettingsViewModel.swift | 22 ++++++++----------- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSSettingsView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSSettingsView.swift index 5a6616ac..912df68e 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSSettingsView.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSSettingsView.swift @@ -42,7 +42,7 @@ struct LoopAPNSSettingsView: View { .font(.headline) TogglableSecureInput( placeholder: "Enter your APNS Key ID", - text: $viewModel.loopAPNSKeyId, + text: $viewModel.keyId, style: .singleLine ) .autocapitalization(.none) @@ -66,7 +66,7 @@ struct LoopAPNSSettingsView: View { .font(.headline) TogglableSecureInput( placeholder: "Enter your APNS Key including -----BEGIN PRIVATE KEY----- and -----END PRIVATE KEY-----", - text: $viewModel.loopAPNSKey, + text: $viewModel.apnsKey, style: .multiLine ) .frame(minHeight: 110) diff --git a/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift b/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift index 9bbcb96e..3987eb62 100644 --- a/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift +++ b/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift @@ -24,8 +24,6 @@ class RemoteSettingsViewModel: ObservableObject { // MARK: - Loop APNS Setup Properties - @Published var loopAPNSKeyId: String - @Published var loopAPNSKey: String @Published var loopDeveloperTeamId: String @Published var loopAPNSQrCodeURL: String @Published var loopAPNSDeviceToken: String @@ -53,8 +51,6 @@ class RemoteSettingsViewModel: ObservableObject { mealWithBolus = storage.mealWithBolus.value mealWithFatProtein = storage.mealWithFatProtein.value - loopAPNSKeyId = storage.keyId.value - loopAPNSKey = storage.apnsKey.value loopDeveloperTeamId = storage.teamId.value ?? "" loopAPNSQrCodeURL = storage.loopAPNSQrCodeURL.value loopAPNSDeviceToken = storage.loopAPNSDeviceToken.value @@ -133,12 +129,12 @@ class RemoteSettingsViewModel: ObservableObject { .store(in: &cancellables) // Loop APNS setup bindings - $loopAPNSKeyId + $keyId .dropFirst() .sink { [weak self] in self?.storage.keyId.value = $0 } .store(in: &cancellables) - $loopAPNSKey + $apnsKey .dropFirst() .sink { [weak self] newValue in // Log APNS key changes for debugging @@ -186,7 +182,7 @@ class RemoteSettingsViewModel: ObservableObject { .store(in: &cancellables) // Auto-validate Loop APNS setup when key ID, APNS key, or QR code changes - Publishers.CombineLatest3($loopAPNSKeyId, $loopAPNSKey, $loopAPNSQrCodeURL) + Publishers.CombineLatest3($keyId, $apnsKey, $loopAPNSQrCodeURL) .dropFirst() .receive(on: DispatchQueue.main) .sink { [weak self] _, _, _ in @@ -213,7 +209,7 @@ class RemoteSettingsViewModel: ObservableObject { .store(in: &cancellables) // Auto-validate when individual Loop APNS fields change - $loopAPNSKeyId + $keyId .dropFirst() .receive(on: DispatchQueue.main) .sink { [weak self] _ in @@ -221,7 +217,7 @@ class RemoteSettingsViewModel: ObservableObject { } .store(in: &cancellables) - $loopAPNSKey + $apnsKey .dropFirst() .receive(on: DispatchQueue.main) .sink { [weak self] _ in @@ -267,8 +263,8 @@ class RemoteSettingsViewModel: ObservableObject { /// Validates the Loop APNS setup by checking all required fields /// - Returns: True if setup is valid, false otherwise func validateLoopAPNSSetup() { - let hasKeyId = !loopAPNSKeyId.isEmpty - let hasAPNSKey = !loopAPNSKey.isEmpty + let hasKeyId = !keyId.isEmpty + let hasAPNSKey = !apnsKey.isEmpty let hasQrCode = !loopAPNSQrCodeURL.isEmpty let hasDeviceToken = !loopAPNSDeviceToken.isEmpty let hasBundleIdentifier = !loopAPNSBundleIdentifier.isEmpty @@ -295,8 +291,8 @@ class RemoteSettingsViewModel: ObservableObject { /// Validates the full Loop APNS setup including device token and bundle identifier /// - Returns: True if full setup is valid, false otherwise func validateFullLoopAPNSSetup() { - let hasKeyId = !loopAPNSKeyId.isEmpty - let hasAPNSKey = !loopAPNSKey.isEmpty + let hasKeyId = !keyId.isEmpty + let hasAPNSKey = !apnsKey.isEmpty let hasQrCode = !loopAPNSQrCodeURL.isEmpty let hasDeviceToken = !loopAPNSDeviceToken.isEmpty let hasBundleIdentifier = !loopAPNSBundleIdentifier.isEmpty From e0f6cea2c1578806f9f3a7219a95f714c1acdf93 Mon Sep 17 00:00:00 2001 From: codebymini Date: Tue, 15 Jul 2025 10:31:19 +0200 Subject: [PATCH 12/21] Avoid publishing changes from within view updates for Loop APNS setup --- .../Remote/Settings/RemoteSettingsViewModel.swift | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift b/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift index 3987eb62..3e0c1296 100644 --- a/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift +++ b/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift @@ -171,9 +171,12 @@ class RemoteSettingsViewModel: ObservableObject { .sink { [weak self] in self?.storage.loopAPNSBundleIdentifier.value = $0 } .store(in: &cancellables) - $loopAPNSSetup - .dropFirst() - .sink { [weak self] in self?.storage.loopAPNSSetup.value = $0 } + // Sync loopAPNSSetup with storage + Storage.shared.loopAPNSSetup.$value + .receive(on: DispatchQueue.main) + .sink { [weak self] newValue in + self?.loopAPNSSetup = newValue + } .store(in: &cancellables) $productionEnvironment @@ -277,7 +280,7 @@ class RemoteSettingsViewModel: ObservableObject { let hasFullSetup = hasBasicSetup && hasDeviceToken && hasBundleIdentifier let oldSetup = loopAPNSSetup - loopAPNSSetup = hasFullSetup + storage.loopAPNSSetup.value = hasFullSetup // Log validation results for debugging LogManager.shared.log(category: .apns, message: "Loop APNS setup validation - Key ID: \(hasKeyId), APNS Key: \(hasAPNSKey), QR Code: \(hasQrCode), Device Token: \(hasDeviceToken), Bundle ID: \(hasBundleIdentifier), Valid: \(hasFullSetup)") @@ -300,7 +303,7 @@ class RemoteSettingsViewModel: ObservableObject { let hasFullSetup = hasKeyId && hasAPNSKey && hasQrCode && hasDeviceToken && hasBundleIdentifier let oldSetup = loopAPNSSetup - loopAPNSSetup = hasFullSetup + storage.loopAPNSSetup.value = hasFullSetup // Log validation results for debugging LogManager.shared.log(category: .apns, message: "Full Loop APNS setup validation - Key ID: \(hasKeyId), APNS Key: \(hasAPNSKey), QR Code: \(hasQrCode), Device Token: \(hasDeviceToken), Bundle ID: \(hasBundleIdentifier), Valid: \(hasFullSetup)") From b361b43f842d254a70fc419f4babda1f430e5e69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Thu, 17 Jul 2025 18:38:13 +0200 Subject: [PATCH 13/21] Align buttons with TRC --- LoopFollow/Remote/LoopAPNS/LoopAPNSRemoteView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSRemoteView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSRemoteView.swift index dde0b2d6..b619c8df 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSRemoteView.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSRemoteView.swift @@ -20,8 +20,8 @@ struct LoopAPNSRemoteView: View { LazyVGrid(columns: columns, spacing: 16) { if loopAPNSSetup.value { // Show Loop APNS command buttons if APNS setup configured - CommandButtonView(command: "Carbs", iconName: "fork.knife.circle", destination: LoopAPNSCarbsView()) - CommandButtonView(command: "Bolus", iconName: "syringe.fill", destination: LoopAPNSBolusView()) + CommandButtonView(command: "Meal", iconName: "fork.knife", destination: LoopAPNSCarbsView()) + CommandButtonView(command: "Bolus", iconName: "syringe", destination: LoopAPNSBolusView()) CommandButtonView(command: "Overrides", iconName: "slider.horizontal.3", destination: OverridePresetsView()) } else { // Show setup message if APNS is not configured From eafdf45b877427aa4dc5fd5c91636bc50543c660 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Thu, 17 Jul 2025 22:12:48 +0200 Subject: [PATCH 14/21] Fix for crashing camera --- .../Views/SimpleQRCodeScannerView.swift | 56 ++++++++++++++----- .../Remote/Settings/RemoteSettingsView.swift | 5 -- 2 files changed, 43 insertions(+), 18 deletions(-) diff --git a/LoopFollow/Helpers/Views/SimpleQRCodeScannerView.swift b/LoopFollow/Helpers/Views/SimpleQRCodeScannerView.swift index 348035c4..1ae542e0 100644 --- a/LoopFollow/Helpers/Views/SimpleQRCodeScannerView.swift +++ b/LoopFollow/Helpers/Views/SimpleQRCodeScannerView.swift @@ -6,26 +6,35 @@ import AVFoundation import SwiftUI struct SimpleQRCodeScannerView: UIViewControllerRepresentable { + @Environment(\.presentationMode) var presentationMode + var completion: (Result) -> Void + + // MARK: - Coordinator + class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate { var parent: SimpleQRCodeScannerView + var session: AVCaptureSession? init(parent: SimpleQRCodeScannerView) { self.parent = parent } func metadataOutput(_: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from _: AVCaptureConnection) { - if let metadataObject = metadataObjects.first as? AVMetadataMachineReadableCodeObject, - metadataObject.type == .qr, - let stringValue = metadataObject.stringValue - { - parent.completion(.success(stringValue)) - parent.presentationMode.wrappedValue.dismiss() + if let session, session.isRunning { + if let metadataObject = metadataObjects.first as? AVMetadataMachineReadableCodeObject, + metadataObject.type == .qr, + let stringValue = metadataObject.stringValue + { + DispatchQueue.global(qos: .userInitiated).async { + session.stopRunning() + } + parent.completion(.success(stringValue)) + } } } } - @Environment(\.presentationMode) var presentationMode - var completion: (Result) -> Void + // MARK: - UIViewControllerRepresentable Methods func makeCoordinator() -> Coordinator { Coordinator(parent: self) @@ -34,18 +43,28 @@ struct SimpleQRCodeScannerView: UIViewControllerRepresentable { func makeUIViewController(context: Context) -> UIViewController { let controller = UIViewController() let session = AVCaptureSession() + context.coordinator.session = session // Assign session to coordinator - guard let videoCaptureDevice = AVCaptureDevice.default(for: .video) else { return controller } - guard let videoInput = try? AVCaptureDeviceInput(device: videoCaptureDevice) else { return controller } - if session.canAddInput(videoInput) { - session.addInput(videoInput) + guard let videoCaptureDevice = AVCaptureDevice.default(for: .video), + let videoInput = try? AVCaptureDeviceInput(device: videoCaptureDevice), + session.canAddInput(videoInput) + else { + let error = NSError(domain: "QRCodeScannerError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to set up camera input."]) + completion(.failure(error)) + return controller } + session.addInput(videoInput) + let metadataOutput = AVCaptureMetadataOutput() if session.canAddOutput(metadataOutput) { session.addOutput(metadataOutput) metadataOutput.setMetadataObjectsDelegate(context.coordinator, queue: DispatchQueue.main) metadataOutput.metadataObjectTypes = [.qr] + } else { + let error = NSError(domain: "QRCodeScannerError", code: 2, userInfo: [NSLocalizedDescriptionKey: "Failed to set up metadata output."]) + completion(.failure(error)) + return controller } let previewLayer = AVCaptureVideoPreviewLayer(session: session) @@ -53,9 +72,20 @@ struct SimpleQRCodeScannerView: UIViewControllerRepresentable { previewLayer.videoGravity = .resizeAspectFill controller.view.layer.addSublayer(previewLayer) - session.startRunning() + DispatchQueue.global(qos: .userInitiated).async { + session.startRunning() + } + return controller } func updateUIViewController(_: UIViewController, context _: Context) {} + + func dismantleUIViewController(_: UIViewController, coordinator: Coordinator) { + DispatchQueue.global(qos: .userInitiated).async { + if let session = coordinator.session, session.isRunning { + session.stopRunning() + } + } + } } diff --git a/LoopFollow/Remote/Settings/RemoteSettingsView.swift b/LoopFollow/Remote/Settings/RemoteSettingsView.swift index 2532baca..41a36695 100644 --- a/LoopFollow/Remote/Settings/RemoteSettingsView.swift +++ b/LoopFollow/Remote/Settings/RemoteSettingsView.swift @@ -159,11 +159,6 @@ struct RemoteSettingsView: View { } } - .sheet(isPresented: $viewModel.isShowingLoopAPNSScanner) { - SimpleQRCodeScannerView { result in - viewModel.handleLoopAPNSQRCodeScanResult(result) - } - } .preferredColorScheme(Storage.shared.forceDarkMode.value ? .dark : nil) .navigationBarTitle("Remote Settings", displayMode: .inline) .onAppear { From 54d9b175c10dbb329bc167d56c479a9381a5740e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Thu, 17 Jul 2025 22:42:59 +0200 Subject: [PATCH 15/21] cleanup --- .../Settings/RemoteSettingsViewModel.swift | 25 ------------------- 1 file changed, 25 deletions(-) diff --git a/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift b/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift index 3e0c1296..ae478b7f 100644 --- a/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift +++ b/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift @@ -211,23 +211,6 @@ class RemoteSettingsViewModel: ObservableObject { } .store(in: &cancellables) - // Auto-validate when individual Loop APNS fields change - $keyId - .dropFirst() - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.validateLoopAPNSSetup() - } - .store(in: &cancellables) - - $apnsKey - .dropFirst() - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.validateLoopAPNSSetup() - } - .store(in: &cancellables) - $loopDeveloperTeamId .dropFirst() .receive(on: DispatchQueue.main) @@ -251,14 +234,6 @@ class RemoteSettingsViewModel: ObservableObject { self?.validateFullLoopAPNSSetup() } .store(in: &cancellables) - - $loopAPNSQrCodeURL - .dropFirst() - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.validateLoopAPNSSetup() - } - .store(in: &cancellables) } // MARK: - Loop APNS Setup Methods From 4895b8a0180b58b9560cd59fe644baf16eaa964a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Thu, 17 Jul 2025 23:00:36 +0200 Subject: [PATCH 16/21] Cleanup --- .../Settings/RemoteSettingsViewModel.swift | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift b/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift index ae478b7f..51873e05 100644 --- a/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift +++ b/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift @@ -218,22 +218,6 @@ class RemoteSettingsViewModel: ObservableObject { self?.validateLoopAPNSSetup() } .store(in: &cancellables) - - $loopAPNSDeviceToken - .dropFirst() - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.validateFullLoopAPNSSetup() - } - .store(in: &cancellables) - - $loopAPNSBundleIdentifier - .dropFirst() - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.validateFullLoopAPNSSetup() - } - .store(in: &cancellables) } // MARK: - Loop APNS Setup Methods From 165e12ccd941908303c6854972e5f75802ed60fb Mon Sep 17 00:00:00 2001 From: codebymini Date: Fri, 18 Jul 2025 09:57:36 +0200 Subject: [PATCH 17/21] Add countdown for Loop TOTP code --- .../Remote/LoopAPNS/LoopAPNSBolusView.swift | 28 ++++++++++++++----- .../Remote/LoopAPNS/LoopAPNSCarbsView.swift | 28 ++++++++++++++----- 2 files changed, 42 insertions(+), 14 deletions(-) diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift index 89104f4a..de0337ec 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift @@ -19,6 +19,9 @@ struct LoopAPNSBolusView: View { // Add state for recommended bolus and warning @State private var recommendedBolus: Double? = nil @State private var lastLoopTime: TimeInterval? = nil + @State private var otpTimeRemaining: Int? = nil + private let otpPeriod: TimeInterval = 30 + private var otpTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() enum AlertType { case success @@ -78,13 +81,18 @@ struct LoopAPNSBolusView: View { Text("Current OTP Code") .font(.headline) if let otpCode = TOTPGenerator.extractOTPFromURL(Storage.shared.loopAPNSQrCodeURL.value) { - Text(otpCode) - .font(.system(.body, design: .monospaced)) - .foregroundColor(.green) - .padding(.vertical, 4) - .padding(.horizontal, 8) - .background(Color.green.opacity(0.1)) - .cornerRadius(4) + HStack { + Text(otpCode) + .font(.system(.body, design: .monospaced)) + .foregroundColor(.green) + .padding(.vertical, 4) + .padding(.horizontal, 8) + .background(Color.green.opacity(0.1)) + .cornerRadius(4) + Text("(" + (otpTimeRemaining.map { "\($0)s left" } ?? "-") + ")") + .font(.caption) + .foregroundColor(.secondary) + } } else { Text("Invalid QR code URL") .foregroundColor(.red) @@ -106,6 +114,12 @@ struct LoopAPNSBolusView: View { } loadRecommendedBolus() + // Reset timer state so it shows '-' until first tick + otpTimeRemaining = nil + } + .onReceive(otpTimer) { _ in + let now = Date().timeIntervalSince1970 + otpTimeRemaining = Int(otpPeriod - (now.truncatingRemainder(dividingBy: otpPeriod))) } .alert(isPresented: $showAlert) { switch alertType { diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift index 997cfa22..562d4967 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift @@ -14,6 +14,9 @@ struct LoopAPNSCarbsView: View { @State private var showAlert = false @State private var alertMessage = "" @State private var alertType: AlertType = .success + @State private var otpTimeRemaining: Int? = nil + private let otpPeriod: TimeInterval = 30 + private var otpTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() @FocusState private var carbsFieldIsFocused: Bool @FocusState private var absorptionFieldIsFocused: Bool @@ -88,13 +91,18 @@ struct LoopAPNSCarbsView: View { Text("Current OTP Code") .font(.headline) if let otpCode = TOTPGenerator.extractOTPFromURL(Storage.shared.loopAPNSQrCodeURL.value) { - Text(otpCode) - .font(.system(.body, design: .monospaced)) - .foregroundColor(.green) - .padding(.vertical, 4) - .padding(.horizontal, 8) - .background(Color.green.opacity(0.1)) - .cornerRadius(4) + HStack { + Text(otpCode) + .font(.system(.body, design: .monospaced)) + .foregroundColor(.green) + .padding(.vertical, 4) + .padding(.horizontal, 8) + .background(Color.green.opacity(0.1)) + .cornerRadius(4) + Text("(" + (otpTimeRemaining.map { "\($0)s left" } ?? "-") + ")") + .font(.caption) + .foregroundColor(.secondary) + } } else { Text("Invalid QR code URL") .foregroundColor(.red) @@ -114,6 +122,12 @@ struct LoopAPNSCarbsView: View { alertType = .error showAlert = true } + // Reset timer state so it shows '-' until first tick + otpTimeRemaining = nil + } + .onReceive(otpTimer) { _ in + let now = Date().timeIntervalSince1970 + otpTimeRemaining = Int(otpPeriod - (now.truncatingRemainder(dividingBy: otpPeriod))) } .alert(isPresented: $showAlert) { switch alertType { From 33b2054c5b94498ea417ac37e884dd50cc5f882e Mon Sep 17 00:00:00 2001 From: codebymini Date: Fri, 18 Jul 2025 20:53:29 +0200 Subject: [PATCH 18/21] Mitigate app hang when scanning or adding totp url --- .../Remote/LoopAPNS/LoopAPNSRemoteView.swift | 2 +- .../LoopAPNS/LoopAPNSSettingsView.swift | 6 +- .../Remote/Settings/RemoteSettingsView.swift | 6 +- .../Settings/RemoteSettingsViewModel.swift | 72 ++++++++----------- 4 files changed, 35 insertions(+), 51 deletions(-) diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSRemoteView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSRemoteView.swift index b619c8df..8351b6b1 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSRemoteView.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSRemoteView.swift @@ -58,7 +58,7 @@ struct LoopAPNSRemoteView: View { .navigationBarTitle("Loop Remote Control", displayMode: .inline) .onAppear { // Validate Loop APNS setup when view appears - viewModel.validateLoopAPNSSetup() + viewModel.validateFullLoopAPNSSetup() } } } diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSSettingsView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSSettingsView.swift index 912df68e..618bc93a 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSSettingsView.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSSettingsView.swift @@ -194,7 +194,7 @@ struct LoopAPNSSettingsView: View { } .onAppear { // Validate Loop APNS setup when view appears - viewModel.validateLoopAPNSSetup() + viewModel.validateFullLoopAPNSSetup() // Automatically fetch device token and bundle identifier when entering the setup screen Task { @@ -203,11 +203,11 @@ struct LoopAPNSSettingsView: View { } .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("LoopAPNSSetupChanged"))) { _ in // Update validation when Loop APNS setup changes - viewModel.validateLoopAPNSSetup() + viewModel.validateFullLoopAPNSSetup() } .onDisappear { // Force validation when leaving the settings view - viewModel.forceValidateLoopAPNSSetup() + viewModel.validateFullLoopAPNSSetup() } } } diff --git a/LoopFollow/Remote/Settings/RemoteSettingsView.swift b/LoopFollow/Remote/Settings/RemoteSettingsView.swift index 41a36695..c5ef07f8 100644 --- a/LoopFollow/Remote/Settings/RemoteSettingsView.swift +++ b/LoopFollow/Remote/Settings/RemoteSettingsView.swift @@ -163,15 +163,15 @@ struct RemoteSettingsView: View { .navigationBarTitle("Remote Settings", displayMode: .inline) .onAppear { // Refresh Loop APNS setup validation when returning to this screen - viewModel.validateLoopAPNSSetup() + viewModel.validateFullLoopAPNSSetup() } .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("LoopAPNSSetupChanged"))) { _ in // Update validation when Loop APNS setup changes - viewModel.validateLoopAPNSSetup() + viewModel.validateFullLoopAPNSSetup() } .onDisappear { // Force validation when leaving the settings view - viewModel.forceValidateLoopAPNSSetup() + viewModel.validateFullLoopAPNSSetup() } } diff --git a/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift b/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift index 51873e05..3c50dbec 100644 --- a/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift +++ b/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift @@ -36,6 +36,8 @@ class RemoteSettingsViewModel: ObservableObject { private var storage = Storage.shared private var cancellables = Set() + private var isUpdatingLoopAPNSSetup = false + private var lastValidationTime: Date = .distantPast init() { // Initialize published properties from storage @@ -61,7 +63,7 @@ class RemoteSettingsViewModel: ObservableObject { setupBindings() // Trigger initial validation - validateLoopAPNSSetup() + validateFullLoopAPNSSetup() } private func setupBindings() { @@ -175,7 +177,8 @@ class RemoteSettingsViewModel: ObservableObject { Storage.shared.loopAPNSSetup.$value .receive(on: DispatchQueue.main) .sink { [weak self] newValue in - self?.loopAPNSSetup = newValue + guard let self = self, !self.isUpdatingLoopAPNSSetup else { return } + self.loopAPNSSetup = newValue } .store(in: &cancellables) @@ -189,7 +192,7 @@ class RemoteSettingsViewModel: ObservableObject { .dropFirst() .receive(on: DispatchQueue.main) .sink { [weak self] _, _, _ in - self?.validateLoopAPNSSetup() + self?.validateFullLoopAPNSSetup() } .store(in: &cancellables) @@ -215,44 +218,28 @@ class RemoteSettingsViewModel: ObservableObject { .dropFirst() .receive(on: DispatchQueue.main) .sink { [weak self] _ in - self?.validateLoopAPNSSetup() + self?.validateFullLoopAPNSSetup() } .store(in: &cancellables) } // MARK: - Loop APNS Setup Methods - /// Validates the Loop APNS setup by checking all required fields - /// - Returns: True if setup is valid, false otherwise - func validateLoopAPNSSetup() { - let hasKeyId = !keyId.isEmpty - let hasAPNSKey = !apnsKey.isEmpty - let hasQrCode = !loopAPNSQrCodeURL.isEmpty - let hasDeviceToken = !loopAPNSDeviceToken.isEmpty - let hasBundleIdentifier = !loopAPNSBundleIdentifier.isEmpty - - // For initial setup, we don't require device token and bundle identifier - // These will be fetched when the user clicks "Refresh Device Token" - let hasBasicSetup = hasKeyId && hasAPNSKey && hasQrCode - - // For full validation (after device token is fetched), check everything - let hasFullSetup = hasBasicSetup && hasDeviceToken && hasBundleIdentifier - - let oldSetup = loopAPNSSetup - storage.loopAPNSSetup.value = hasFullSetup - - // Log validation results for debugging - LogManager.shared.log(category: .apns, message: "Loop APNS setup validation - Key ID: \(hasKeyId), APNS Key: \(hasAPNSKey), QR Code: \(hasQrCode), Device Token: \(hasDeviceToken), Bundle ID: \(hasBundleIdentifier), Valid: \(hasFullSetup)") - - // Post notification if setup status changed - if oldSetup != hasFullSetup { - NotificationCenter.default.post(name: NSNotification.Name("LoopAPNSSetupChanged"), object: nil) - } - } - /// Validates the full Loop APNS setup including device token and bundle identifier /// - Returns: True if full setup is valid, false otherwise func validateFullLoopAPNSSetup() { + // Debounce rapid successive calls (prevent calls within 100ms of each other) + let now = Date() + if now.timeIntervalSince(lastValidationTime) < 0.1 { + LogManager.shared.log(category: .apns, message: "Skipping validation - too soon since last call") + return + } + lastValidationTime = now + + // Add call stack debugging + let callStack = Thread.callStackSymbols.prefix(3).map { $0.components(separatedBy: " ").last ?? "unknown" }.joined(separator: " -> ") + LogManager.shared.log(category: .apns, message: "validateFullLoopAPNSSetup called from: \(callStack)") + let hasKeyId = !keyId.isEmpty let hasAPNSKey = !apnsKey.isEmpty let hasQrCode = !loopAPNSQrCodeURL.isEmpty @@ -262,7 +249,13 @@ class RemoteSettingsViewModel: ObservableObject { let hasFullSetup = hasKeyId && hasAPNSKey && hasQrCode && hasDeviceToken && hasBundleIdentifier let oldSetup = loopAPNSSetup - storage.loopAPNSSetup.value = hasFullSetup + + // Only update storage if the value has actually changed to prevent infinite loops + if storage.loopAPNSSetup.value != hasFullSetup { + isUpdatingLoopAPNSSetup = true + storage.loopAPNSSetup.value = hasFullSetup + isUpdatingLoopAPNSSetup = false + } // Log validation results for debugging LogManager.shared.log(category: .apns, message: "Full Loop APNS setup validation - Key ID: \(hasKeyId), APNS Key: \(hasAPNSKey), QR Code: \(hasQrCode), Device Token: \(hasDeviceToken), Bundle ID: \(hasBundleIdentifier), Valid: \(hasFullSetup)") @@ -363,21 +356,12 @@ class RemoteSettingsViewModel: ObservableObject { case let .success(code): self.loopAPNSQrCodeURL = code // Trigger validation after QR code is scanned - self.validateLoopAPNSSetup() + LogManager.shared.log(category: .apns, message: "Loop APNS QR code scanned: \(code)") + self.validateFullLoopAPNSSetup() case let .failure(error): self.loopAPNSErrorMessage = "Scanning failed: \(error.localizedDescription)" } self.isShowingLoopAPNSScanner = false } } - - /// Forces validation of Loop APNS setup - func forceValidateLoopAPNSSetup() { - validateLoopAPNSSetup() - } - - /// Forces validation of full Loop APNS setup including device token - func forceValidateFullLoopAPNSSetup() { - validateFullLoopAPNSSetup() - } } From 1d652c63545c32b838a4836e45d8e2e5f8caf73e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sat, 19 Jul 2025 10:46:46 +0200 Subject: [PATCH 19/21] Simplified validation --- .../Remote/LoopAPNS/LoopAPNSRemoteView.swift | 7 +- .../LoopAPNS/LoopAPNSSettingsView.swift | 13 +- .../Remote/Settings/RemoteSettingsView.swift | 12 -- .../Settings/RemoteSettingsViewModel.swift | 150 +++--------------- LoopFollow/Storage/Storage.swift | 1 - 5 files changed, 22 insertions(+), 161 deletions(-) diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSRemoteView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSRemoteView.swift index 8351b6b1..0176d5f3 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSRemoteView.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSRemoteView.swift @@ -6,7 +6,6 @@ import SwiftUI struct LoopAPNSRemoteView: View { @Environment(\.presentationMode) var presentationMode - @ObservedObject var loopAPNSSetup = Storage.shared.loopAPNSSetup @StateObject private var viewModel = RemoteSettingsViewModel() var body: some View { @@ -18,7 +17,7 @@ struct LoopAPNSRemoteView: View { ] LazyVGrid(columns: columns, spacing: 16) { - if loopAPNSSetup.value { + if viewModel.loopAPNSSetup { // Show Loop APNS command buttons if APNS setup configured CommandButtonView(command: "Meal", iconName: "fork.knife", destination: LoopAPNSCarbsView()) CommandButtonView(command: "Bolus", iconName: "syringe", destination: LoopAPNSBolusView()) @@ -56,10 +55,6 @@ struct LoopAPNSRemoteView: View { Spacer() } .navigationBarTitle("Loop Remote Control", displayMode: .inline) - .onAppear { - // Validate Loop APNS setup when view appears - viewModel.validateFullLoopAPNSSetup() - } } } } diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSSettingsView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSSettingsView.swift index 618bc93a..70295863 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSSettingsView.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSSettingsView.swift @@ -92,6 +92,7 @@ struct LoopAPNSSettingsView: View { .buttonStyle(.borderedProminent) .frame(maxWidth: .infinity) .padding(.vertical, 10) + VStack(alignment: .leading, spacing: 12) { Text("Environment") .font(.headline) @@ -111,6 +112,7 @@ struct LoopAPNSSettingsView: View { } .padding(.top, 4) } + VStack(alignment: .leading, spacing: 12) { Text("Device Token") .font(.headline) @@ -193,22 +195,11 @@ struct LoopAPNSSettingsView: View { } } .onAppear { - // Validate Loop APNS setup when view appears - viewModel.validateFullLoopAPNSSetup() - // Automatically fetch device token and bundle identifier when entering the setup screen Task { await viewModel.refreshDeviceToken() } } - .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("LoopAPNSSetupChanged"))) { _ in - // Update validation when Loop APNS setup changes - viewModel.validateFullLoopAPNSSetup() - } - .onDisappear { - // Force validation when leaving the settings view - viewModel.validateFullLoopAPNSSetup() - } } } } diff --git a/LoopFollow/Remote/Settings/RemoteSettingsView.swift b/LoopFollow/Remote/Settings/RemoteSettingsView.swift index c5ef07f8..ca808197 100644 --- a/LoopFollow/Remote/Settings/RemoteSettingsView.swift +++ b/LoopFollow/Remote/Settings/RemoteSettingsView.swift @@ -161,18 +161,6 @@ struct RemoteSettingsView: View { .preferredColorScheme(Storage.shared.forceDarkMode.value ? .dark : nil) .navigationBarTitle("Remote Settings", displayMode: .inline) - .onAppear { - // Refresh Loop APNS setup validation when returning to this screen - viewModel.validateFullLoopAPNSSetup() - } - .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("LoopAPNSSetupChanged"))) { _ in - // Update validation when Loop APNS setup changes - viewModel.validateFullLoopAPNSSetup() - } - .onDisappear { - // Force validation when leaving the settings view - viewModel.validateFullLoopAPNSSetup() - } } // MARK: - Custom Row for Remote Type Selection diff --git a/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift b/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift index 3c50dbec..a750e8c7 100644 --- a/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift +++ b/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift @@ -28,16 +28,23 @@ class RemoteSettingsViewModel: ObservableObject { @Published var loopAPNSQrCodeURL: String @Published var loopAPNSDeviceToken: String @Published var loopAPNSBundleIdentifier: String - @Published var loopAPNSSetup: Bool @Published var productionEnvironment: Bool @Published var isShowingLoopAPNSScanner: Bool = false @Published var loopAPNSErrorMessage: String? @Published var isRefreshingDeviceToken: Bool = false + // MARK: - Computed property for Loop APNS Setup validation + + var loopAPNSSetup: Bool { + !keyId.isEmpty && + !apnsKey.isEmpty && + !loopAPNSQrCodeURL.isEmpty && + !loopAPNSDeviceToken.isEmpty && + !loopAPNSBundleIdentifier.isEmpty + } + private var storage = Storage.shared private var cancellables = Set() - private var isUpdatingLoopAPNSSetup = false - private var lastValidationTime: Date = .distantPast init() { // Initialize published properties from storage @@ -57,16 +64,13 @@ class RemoteSettingsViewModel: ObservableObject { loopAPNSQrCodeURL = storage.loopAPNSQrCodeURL.value loopAPNSDeviceToken = storage.loopAPNSDeviceToken.value loopAPNSBundleIdentifier = storage.loopAPNSBundleIdentifier.value - loopAPNSSetup = storage.loopAPNSSetup.value productionEnvironment = storage.productionEnvironment.value setupBindings() - - // Trigger initial validation - validateFullLoopAPNSSetup() } private func setupBindings() { + // Basic property bindings $remoteType .dropFirst() .sink { [weak self] in self?.storage.remoteType.value = $0 } @@ -84,7 +88,12 @@ class RemoteSettingsViewModel: ObservableObject { $apnsKey .dropFirst() - .sink { [weak self] in self?.storage.apnsKey.value = $0 } + .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 + } .store(in: &cancellables) $keyId @@ -122,6 +131,7 @@ class RemoteSettingsViewModel: ObservableObject { .sink { [weak self] in self?.storage.mealWithFatProtein.value = $0 } .store(in: &cancellables) + // Device type monitoring Storage.shared.device.$value .receive(on: DispatchQueue.main) .sink { [weak self] newValue in @@ -130,29 +140,7 @@ class RemoteSettingsViewModel: ObservableObject { } .store(in: &cancellables) - // Loop APNS setup bindings - $keyId - .dropFirst() - .sink { [weak self] in self?.storage.keyId.value = $0 } - .store(in: &cancellables) - - $apnsKey - .dropFirst() - .sink { [weak self] newValue in - // Log APNS key changes for debugging - LogManager.shared.log(category: .apns, message: "APNS Key changed - Length: \(newValue.count)") - LogManager.shared.log(category: .apns, message: "APNS Key contains line breaks: \(newValue.contains("\n"))") - LogManager.shared.log(category: .apns, message: "APNS Key contains BEGIN header: \(newValue.contains("-----BEGIN PRIVATE KEY-----"))") - LogManager.shared.log(category: .apns, message: "APNS Key contains END header: \(newValue.contains("-----END PRIVATE KEY-----"))") - - // Validate and fix the APNS key format using the service - let apnsService = LoopAPNSService() - let fixedKey = apnsService.validateAndFixAPNSKey(newValue) - - self?.storage.apnsKey.value = fixedKey - } - .store(in: &cancellables) - + // Loop APNS bindings $loopDeveloperTeamId .dropFirst() .sink { [weak self] in self?.storage.teamId.value = $0 } @@ -173,99 +161,14 @@ class RemoteSettingsViewModel: ObservableObject { .sink { [weak self] in self?.storage.loopAPNSBundleIdentifier.value = $0 } .store(in: &cancellables) - // Sync loopAPNSSetup with storage - Storage.shared.loopAPNSSetup.$value - .receive(on: DispatchQueue.main) - .sink { [weak self] newValue in - guard let self = self, !self.isUpdatingLoopAPNSSetup else { return } - self.loopAPNSSetup = newValue - } - .store(in: &cancellables) - $productionEnvironment .dropFirst() .sink { [weak self] in self?.storage.productionEnvironment.value = $0 } .store(in: &cancellables) - - // Auto-validate Loop APNS setup when key ID, APNS key, or QR code changes - Publishers.CombineLatest3($keyId, $apnsKey, $loopAPNSQrCodeURL) - .dropFirst() - .receive(on: DispatchQueue.main) - .sink { [weak self] _, _, _ in - self?.validateFullLoopAPNSSetup() - } - .store(in: &cancellables) - - // Auto-validate when device token or bundle identifier changes - Publishers.CombineLatest($loopAPNSDeviceToken, $loopAPNSBundleIdentifier) - .dropFirst() - .receive(on: DispatchQueue.main) - .sink { [weak self] _, _ in - self?.validateFullLoopAPNSSetup() - } - .store(in: &cancellables) - - // Auto-validate when production environment changes - $productionEnvironment - .dropFirst() - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.validateFullLoopAPNSSetup() - } - .store(in: &cancellables) - - $loopDeveloperTeamId - .dropFirst() - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.validateFullLoopAPNSSetup() - } - .store(in: &cancellables) } // MARK: - Loop APNS Setup Methods - /// Validates the full Loop APNS setup including device token and bundle identifier - /// - Returns: True if full setup is valid, false otherwise - func validateFullLoopAPNSSetup() { - // Debounce rapid successive calls (prevent calls within 100ms of each other) - let now = Date() - if now.timeIntervalSince(lastValidationTime) < 0.1 { - LogManager.shared.log(category: .apns, message: "Skipping validation - too soon since last call") - return - } - lastValidationTime = now - - // Add call stack debugging - let callStack = Thread.callStackSymbols.prefix(3).map { $0.components(separatedBy: " ").last ?? "unknown" }.joined(separator: " -> ") - LogManager.shared.log(category: .apns, message: "validateFullLoopAPNSSetup called from: \(callStack)") - - let hasKeyId = !keyId.isEmpty - let hasAPNSKey = !apnsKey.isEmpty - let hasQrCode = !loopAPNSQrCodeURL.isEmpty - let hasDeviceToken = !loopAPNSDeviceToken.isEmpty - let hasBundleIdentifier = !loopAPNSBundleIdentifier.isEmpty - - let hasFullSetup = hasKeyId && hasAPNSKey && hasQrCode && hasDeviceToken && hasBundleIdentifier - - let oldSetup = loopAPNSSetup - - // Only update storage if the value has actually changed to prevent infinite loops - if storage.loopAPNSSetup.value != hasFullSetup { - isUpdatingLoopAPNSSetup = true - storage.loopAPNSSetup.value = hasFullSetup - isUpdatingLoopAPNSSetup = false - } - - // Log validation results for debugging - LogManager.shared.log(category: .apns, message: "Full Loop APNS setup validation - Key ID: \(hasKeyId), APNS Key: \(hasAPNSKey), QR Code: \(hasQrCode), Device Token: \(hasDeviceToken), Bundle ID: \(hasBundleIdentifier), Valid: \(hasFullSetup)") - - // Post notification if setup status changed - if oldSetup != hasFullSetup { - NotificationCenter.default.post(name: NSNotification.Name("LoopAPNSSetupChanged"), object: nil) - } - } - func refreshDeviceToken() async { await MainActor.run { isRefreshingDeviceToken = true @@ -280,8 +183,6 @@ class RemoteSettingsViewModel: ObservableObject { if success { self.loopAPNSDeviceToken = self.storage.loopAPNSDeviceToken.value self.loopAPNSBundleIdentifier = self.storage.loopAPNSBundleIdentifier.value - // Trigger validation immediately after updating values - self.validateFullLoopAPNSSetup() } else { self.loopAPNSErrorMessage = "Failed to refresh device token. Check your Nightscout URL and token." } @@ -308,13 +209,6 @@ class RemoteSettingsViewModel: ObservableObject { case let .success(profileData): // Log the profile data for debugging LogManager.shared.log(category: .apns, message: "Profile fetched successfully for device token") - LogManager.shared.log(category: .apns, message: "Device token from profile: \(profileData.deviceToken ?? "nil")") - LogManager.shared.log(category: .apns, message: "Bundle identifier from profile: \(profileData.bundleIdentifier ?? "nil")") - - if let loopSettings = profileData.loopSettings { - LogManager.shared.log(category: .apns, message: "Loop settings device token: \(loopSettings.deviceToken ?? "nil")") - LogManager.shared.log(category: .apns, message: "Loop settings bundle identifier: \(loopSettings.bundleIdentifier ?? "nil")") - } // Update profile data which includes device token and bundle identifier ProfileManager.shared.loadProfile(from: profileData) @@ -332,10 +226,6 @@ class RemoteSettingsViewModel: ObservableObject { self.storage.loopAPNSBundleIdentifier.value = bundleIdentifier } - // Log the stored values after processing - LogManager.shared.log(category: .apns, message: "Stored device token: \(self.storage.loopAPNSDeviceToken.value)") - LogManager.shared.log(category: .apns, message: "Stored bundle ID: \(self.storage.loopAPNSBundleIdentifier.value)") - // Log successful configuration LogManager.shared.log(category: .apns, message: "Successfully configured device tokens from Nightscout profile") @@ -355,9 +245,7 @@ class RemoteSettingsViewModel: ObservableObject { switch result { case let .success(code): self.loopAPNSQrCodeURL = code - // Trigger validation after QR code is scanned LogManager.shared.log(category: .apns, message: "Loop APNS QR code scanned: \(code)") - self.validateFullLoopAPNSSetup() case let .failure(error): self.loopAPNSErrorMessage = "Scanning failed: \(error.localizedDescription)" } diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 3348d1d6..962cb81b 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -168,7 +168,6 @@ class Storage { // MARK: - Loop APNS Setup --------------------------------------------------- - var loopAPNSSetup = StorageValue(key: "loopAPNSSetup", defaultValue: false) var loopAPNSQrCodeURL = StorageValue(key: "loopAPNSQrCodeURL", defaultValue: "") var loopAPNSDeviceToken = StorageValue(key: "loopAPNSDeviceToken", defaultValue: "") var loopAPNSBundleIdentifier = StorageValue(key: "loopAPNSBundleIdentifier", defaultValue: "") From 6d9078d98ecda93d83609754df984069a7a1b72e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sat, 19 Jul 2025 23:13:48 +0200 Subject: [PATCH 20/21] Remove manual device token refresh and move debug info to main settings --- .../Remote/LoopAPNS/LoopAPNSService.swift | 54 ++----------- .../LoopAPNS/LoopAPNSSettingsView.swift | 79 ------------------- .../Remote/Settings/RemoteSettingsView.swift | 13 +-- .../Settings/RemoteSettingsViewModel.swift | 74 ----------------- 4 files changed, 14 insertions(+), 206 deletions(-) diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift index c5e2d8df..9aa09f25 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift @@ -218,49 +218,6 @@ class LoopAPNSService { } } - /// Refreshes the device token from Nightscout - /// - Returns: True if successful, false otherwise - func refreshDeviceToken() async -> Bool { - do { - let (deviceToken, bundleIdentifier) = try await fetchDeviceToken() - - DispatchQueue.main.async { - self.storage.loopAPNSDeviceToken.value = deviceToken - self.storage.loopAPNSBundleIdentifier.value = bundleIdentifier - } - - return true - } catch { - LogManager.shared.log(category: .apns, message: "Failed to refresh device token: \(error.localizedDescription)") - - // Log additional debugging information - let nightscoutURL = storage.url.value - let token = storage.token.value - - LogManager.shared.log(category: .apns, message: "Nightscout URL: \(nightscoutURL.isEmpty ? "Not configured" : nightscoutURL)") - LogManager.shared.log(category: .apns, message: "Token: \(token.isEmpty ? "Not configured" : "Configured")") - - return false - } - } - - // Helper to ensure we have a valid device token and bundle identifier - private func getValidDeviceTokenAndBundle() async throws -> (deviceToken: String, bundleIdentifier: String) { - var deviceToken = storage.loopAPNSDeviceToken.value - var bundleIdentifier = storage.loopAPNSBundleIdentifier.value - if deviceToken.isEmpty { - LogManager.shared.log(category: .apns, message: "Device token is empty or test token, refreshing from Nightscout...") - let refreshSuccess = await refreshDeviceToken() - if !refreshSuccess { - throw LoopAPNSError.noDeviceToken - } - deviceToken = storage.loopAPNSDeviceToken.value - bundleIdentifier = storage.loopAPNSBundleIdentifier.value - } - - return (deviceToken, bundleIdentifier) - } - /// Sends carbs via APNS push notification /// - Parameter payload: The carbs payload to send /// - Returns: True if successful, false otherwise @@ -268,7 +225,8 @@ class LoopAPNSService { guard validateSetup() else { throw LoopAPNSError.invalidURL } - let (deviceToken, bundleIdentifier) = try await getValidDeviceTokenAndBundle() + let deviceToken = Storage.shared.loopAPNSDeviceToken.value + let bundleIdentifier = Storage.shared.loopAPNSBundleIdentifier.value let keyId = storage.keyId.value let apnsKey = storage.apnsKey.value @@ -280,7 +238,7 @@ class LoopAPNSService { // Based on Nightscout's loop.js implementation let carbsAmount = payload.carbsAmount ?? 0.0 let absorptionTime = payload.absorptionTime ?? 3.0 - var finalPayload = [ + let finalPayload = [ "carbs-entry": carbsAmount, "absorption-time": absorptionTime, "otp": String(payload.otp), @@ -319,7 +277,8 @@ class LoopAPNSService { guard validateSetup() else { throw LoopAPNSError.invalidURL } - let (deviceToken, bundleIdentifier) = try await getValidDeviceTokenAndBundle() + let deviceToken = Storage.shared.loopAPNSDeviceToken.value + let bundleIdentifier = Storage.shared.loopAPNSBundleIdentifier.value let keyId = storage.keyId.value let apnsKey = storage.apnsKey.value @@ -330,7 +289,7 @@ class LoopAPNSService { // Create the complete notification payload (matching Nightscout's exact format) // Based on Nightscout's loop.js implementation let bolusAmount = payload.bolusAmount ?? 0.0 - var finalPayload = [ + let finalPayload = [ "bolus-entry": bolusAmount, "otp": String(payload.otp), "remote-address": "LoopFollow", @@ -382,7 +341,6 @@ class LoopAPNSService { // Determine APNS environment let isProduction = storage.productionEnvironment.value - let apnsEnvironment = isProduction ? "production" : "development" let apnsURL = isProduction ? "https://api.push.apple.com" : "https://api.sandbox.push.apple.com" let requestURL = URL(string: "\(apnsURL)/3/device/\(deviceToken)")! var request = URLRequest(url: requestURL) diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSSettingsView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSSettingsView.swift index 70295863..e8359535 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSSettingsView.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSSettingsView.swift @@ -101,79 +101,6 @@ struct LoopAPNSSettingsView: View { Text("Production is used for browser builders and should be switched off for Xcode builders") .font(.caption) .foregroundColor(.secondary) - - // Environment status indicator - HStack { - Image(systemName: viewModel.productionEnvironment ? "checkmark.circle.fill" : "gearshape.fill") - .foregroundColor(viewModel.productionEnvironment ? .green : .blue) - Text(viewModel.productionEnvironment ? "Production Environment" : "Development Environment") - .font(.caption) - .foregroundColor(viewModel.productionEnvironment ? .green : .blue) - } - .padding(.top, 4) - } - - VStack(alignment: .leading, spacing: 12) { - Text("Device Token") - .font(.headline) - HStack { - Text(viewModel.loopAPNSDeviceToken.isEmpty ? "Not configured" : viewModel.loopAPNSDeviceToken) - .foregroundColor(viewModel.loopAPNSDeviceToken.isEmpty ? .red : .primary) - .font(.system(.body, design: .monospaced)) - .lineLimit(1) - .truncationMode(.middle) - - Spacer() - - Button(action: { - Task { - await viewModel.refreshDeviceToken() - } - }) { - if viewModel.isRefreshingDeviceToken { - ProgressView() - .scaleEffect(0.8) - } else { - Image(systemName: "arrow.clockwise") - .foregroundColor(.blue) - } - } - .disabled(viewModel.isRefreshingDeviceToken) - } - - // Device token status indicator - if !viewModel.loopAPNSDeviceToken.isEmpty { - HStack { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.green) - Text("Device token configured") - .font(.caption) - .foregroundColor(.green) - } - .padding(.top, 4) - } - } - - VStack(alignment: .leading, spacing: 12) { - Text("Bundle Identifier") - .font(.headline) - Text(viewModel.loopAPNSBundleIdentifier.isEmpty ? "Not configured" : viewModel.loopAPNSBundleIdentifier) - .foregroundColor(viewModel.loopAPNSBundleIdentifier.isEmpty ? .red : .primary) - .font(.system(.body, design: .monospaced)) - .lineLimit(1) - .truncationMode(.middle) - - // Bundle identifier status indicator - if !viewModel.loopAPNSBundleIdentifier.isEmpty { - HStack { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.green) - Text("Bundle identifier configured") - .font(.caption) - .foregroundColor(.green) - } - .padding(.top, 4) - } } } header: { @@ -194,12 +121,6 @@ struct LoopAPNSSettingsView: View { viewModel.handleLoopAPNSQRCodeScanResult(result) } } - .onAppear { - // Automatically fetch device token and bundle identifier when entering the setup screen - Task { - await viewModel.refreshDeviceToken() - } - } } } } diff --git a/LoopFollow/Remote/Settings/RemoteSettingsView.swift b/LoopFollow/Remote/Settings/RemoteSettingsView.swift index ca808197..fbef2505 100644 --- a/LoopFollow/Remote/Settings/RemoteSettingsView.swift +++ b/LoopFollow/Remote/Settings/RemoteSettingsView.swift @@ -34,7 +34,7 @@ struct RemoteSettingsView: View { remoteTypeRow( type: .loopAPNS, label: "Loop", - isEnabled: true + isEnabled: viewModel.isLoopDevice ) remoteTypeRow(type: .nightscout, label: "Nightscout", isEnabled: true) Text("Nightscout should be used for Trio 0.2.x or older.") @@ -99,6 +99,8 @@ struct RemoteSettingsView: View { .toggleStyle(SwitchToggleStyle()) } + guardrailsSection + // MARK: - Debug / Info Section(header: Text("Debug / Info")) { @@ -137,12 +139,13 @@ struct RemoteSettingsView: View { } } } - } - - // MARK: - Shared Guardrails Section - if viewModel.remoteType != .none { guardrailsSection + + Section(header: Text("Debug / Info")) { + Text("Device Token: \(Storage.shared.loopAPNSDeviceToken.value)") + Text("Bundle ID: \(Storage.shared.loopAPNSBundleIdentifier.value)") + } } } .alert(isPresented: $showAlert) { diff --git a/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift b/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift index a750e8c7..67ac6678 100644 --- a/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift +++ b/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift @@ -31,7 +31,6 @@ class RemoteSettingsViewModel: ObservableObject { @Published var productionEnvironment: Bool @Published var isShowingLoopAPNSScanner: Bool = false @Published var loopAPNSErrorMessage: String? - @Published var isRefreshingDeviceToken: Bool = false // MARK: - Computed property for Loop APNS Setup validation @@ -167,79 +166,6 @@ class RemoteSettingsViewModel: ObservableObject { .store(in: &cancellables) } - // MARK: - Loop APNS Setup Methods - - func refreshDeviceToken() async { - await MainActor.run { - isRefreshingDeviceToken = true - loopAPNSErrorMessage = nil - } - - // Use the regular Nightscout profile endpoint instead of the Loop APNS service - let success = await fetchDeviceTokenFromNightscoutProfile() - - await MainActor.run { - self.isRefreshingDeviceToken = false - if success { - self.loopAPNSDeviceToken = self.storage.loopAPNSDeviceToken.value - self.loopAPNSBundleIdentifier = self.storage.loopAPNSBundleIdentifier.value - } else { - self.loopAPNSErrorMessage = "Failed to refresh device token. Check your Nightscout URL and token." - } - } - } - - private func fetchDeviceTokenFromNightscoutProfile() async -> Bool { - // Check if Nightscout is configured - guard !Storage.shared.url.value.isEmpty else { - LogManager.shared.log(category: .apns, message: "Nightscout URL not configured") - return false - } - - guard !Storage.shared.token.value.isEmpty else { - LogManager.shared.log(category: .apns, message: "Nightscout token not configured") - return false - } - - // Fetch profile from Nightscout using the regular profile endpoint - return await withCheckedContinuation { continuation in - NightscoutUtils.executeRequest(eventType: .profile, parameters: [:]) { (result: Result) in - DispatchQueue.main.async { - switch result { - case let .success(profileData): - // Log the profile data for debugging - LogManager.shared.log(category: .apns, message: "Profile fetched successfully for device token") - - // Update profile data which includes device token and bundle identifier - ProfileManager.shared.loadProfile(from: profileData) - - // Store the device token and bundle identifier in the Loop APNS storage - if let deviceToken = profileData.deviceToken, !deviceToken.isEmpty { - self.storage.loopAPNSDeviceToken.value = deviceToken - } else if let loopSettings = profileData.loopSettings, let deviceToken = loopSettings.deviceToken, !deviceToken.isEmpty { - self.storage.loopAPNSDeviceToken.value = deviceToken - } - - if let bundleIdentifier = profileData.bundleIdentifier, !bundleIdentifier.isEmpty { - self.storage.loopAPNSBundleIdentifier.value = bundleIdentifier - } else if let loopSettings = profileData.loopSettings, let bundleIdentifier = loopSettings.bundleIdentifier, !bundleIdentifier.isEmpty { - self.storage.loopAPNSBundleIdentifier.value = bundleIdentifier - } - - // Log successful configuration - LogManager.shared.log(category: .apns, message: "Successfully configured device tokens from Nightscout profile") - - continuation.resume(returning: true) - - case let .failure(error): - LogManager.shared.log(category: .apns, message: "Failed to fetch profile for device token configuration: \(error.localizedDescription)") - continuation.resume(returning: false) - } - } - } - } - } - func handleLoopAPNSQRCodeScanResult(_ result: Result) { DispatchQueue.main.async { switch result { From 3882f2b1f59250cb003a87e7b4e1b17bc928b568 Mon Sep 17 00:00:00 2001 From: codebymini Date: Sat, 19 Jul 2025 23:36:24 +0200 Subject: [PATCH 21/21] Add current totp code to debug / info section --- .../Remote/Settings/RemoteSettingsView.swift | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/LoopFollow/Remote/Settings/RemoteSettingsView.swift b/LoopFollow/Remote/Settings/RemoteSettingsView.swift index fbef2505..2dd4d1b5 100644 --- a/LoopFollow/Remote/Settings/RemoteSettingsView.swift +++ b/LoopFollow/Remote/Settings/RemoteSettingsView.swift @@ -12,11 +12,18 @@ struct RemoteSettingsView: View { @State private var showAlert: Bool = false @State private var alertType: AlertType? = nil @State private var alertMessage: String? = nil + @State private var otpTimeRemaining: Int? = nil + private let otpPeriod: TimeInterval = 30 + private var otpTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() enum AlertType { case validation } + init(viewModel: RemoteSettingsViewModel) { + self.viewModel = viewModel + } + var body: some View { NavigationView { Form { @@ -145,6 +152,25 @@ struct RemoteSettingsView: View { Section(header: Text("Debug / Info")) { Text("Device Token: \(Storage.shared.loopAPNSDeviceToken.value)") Text("Bundle ID: \(Storage.shared.loopAPNSBundleIdentifier.value)") + + if let otpCode = TOTPGenerator.extractOTPFromURL(Storage.shared.loopAPNSQrCodeURL.value) { + HStack { + Text("Current TOTP Code:") + Text(otpCode) + .font(.system(.body, design: .monospaced)) + .foregroundColor(.green) + .padding(.vertical, 2) + .padding(.horizontal, 6) + .background(Color.green.opacity(0.1)) + .cornerRadius(4) + Text("(" + (otpTimeRemaining.map { "\($0)s left" } ?? "-") + ")") + .font(.caption) + .foregroundColor(.secondary) + } + } else { + Text("TOTP Code: Invalid QR code URL") + .foregroundColor(.red) + } } } } @@ -161,6 +187,14 @@ struct RemoteSettingsView: View { } } } + .onAppear { + // Reset timer state so it shows '-' until first tick + otpTimeRemaining = nil + } + .onReceive(otpTimer) { _ in + let now = Date().timeIntervalSince1970 + otpTimeRemaining = Int(otpPeriod - (now.truncatingRemainder(dividingBy: otpPeriod))) + } .preferredColorScheme(Storage.shared.forceDarkMode.value ? .dark : nil) .navigationBarTitle("Remote Settings", displayMode: .inline)