Преглед изворни кода

Live activity (#537)

Co-author: @MtlPhil
Jonas Björkert пре 3 месеци
родитељ
комит
6268dbb4e4
56 измењених фајлова са 4544 додато и 485 уклоњено
  1. 8 1
      .gitignore
  2. 292 51
      LoopFollow.xcodeproj/project.pbxproj
  3. 0 96
      LoopFollow.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
  4. 34 14
      LoopFollow/Application/AppDelegate.swift
  5. 12 2
      LoopFollow/Application/SceneDelegate.swift
  6. 19 6
      LoopFollow/Controllers/BackgroundAlertManager.swift
  7. 10 0
      LoopFollow/Controllers/Nightscout/BGData.swift
  8. 11 0
      LoopFollow/Controllers/Nightscout/DeviceStatus.swift
  9. 18 1
      LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift
  10. 22 0
      LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift
  11. 1 0
      LoopFollow/Controllers/Nightscout/IAge.swift
  12. 1 0
      LoopFollow/Controllers/Nightscout/Profile.swift
  13. 1 0
      LoopFollow/Controllers/Nightscout/Treatments/Basals.swift
  14. 1 0
      LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift
  15. 96 0
      LoopFollow/Helpers/BackgroundRefreshManager.swift
  16. 53 12
      LoopFollow/Helpers/BackgroundTaskAudio.swift
  17. 5 1
      LoopFollow/Helpers/GlucoseConversion.swift
  18. 90 26
      LoopFollow/Helpers/JWTManager.swift
  19. 7 1
      LoopFollow/Info.plist
  20. 161 0
      LoopFollow/LiveActivity/APNSClient.swift
  21. 77 0
      LoopFollow/LiveActivity/AppGroupID.swift
  22. 50 0
      LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift
  23. 331 0
      LoopFollow/LiveActivity/GlucoseSnapshot.swift
  24. 202 0
      LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift
  25. 75 0
      LoopFollow/LiveActivity/GlucoseSnapshotStore.swift
  26. 223 0
      LoopFollow/LiveActivity/LAAppGroupSettings.swift
  27. 27 0
      LoopFollow/LiveActivity/LALivenessMarker.swift
  28. 44 0
      LoopFollow/LiveActivity/LALivenessStore.swift
  29. 789 0
      LoopFollow/LiveActivity/LiveActivityManager.swift
  30. 22 0
      LoopFollow/LiveActivity/PreferredGlucoseUnit.swift
  31. 39 0
      LoopFollow/LiveActivity/RestartLiveActivityIntent.swift
  32. 138 0
      LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift
  33. 89 0
      LoopFollow/LiveActivitySettingsView.swift
  34. 4 0
      LoopFollow/Loop Follow.entitlements
  35. 46 41
      LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift
  36. 12 12
      LoopFollow/Remote/Settings/RemoteCommandSettings.swift
  37. 36 55
      LoopFollow/Remote/Settings/RemoteSettingsView.swift
  38. 19 37
      LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift
  39. 21 19
      LoopFollow/Remote/TRC/PushNotificationManager.swift
  40. 8 18
      LoopFollow/Remote/TRC/SecureMessenger.swift
  41. 44 0
      LoopFollow/Settings/APNSettingsView.swift
  42. 12 12
      LoopFollow/Settings/ImportExport/ExportableSettings.swift
  43. 42 0
      LoopFollow/Settings/LiveActivitySettingsView.swift
  44. 16 0
      LoopFollow/Settings/SettingsMenuView.swift
  45. 12 0
      LoopFollow/Storage/Framework/StorageValue.swift
  46. 2 0
      LoopFollow/Storage/Observable.swift
  47. 71 4
      LoopFollow/Storage/Storage+Migrate.swift
  48. 229 17
      LoopFollow/Storage/Storage.swift
  49. 148 57
      LoopFollow/ViewControllers/MainViewController.swift
  50. 27 0
      LoopFollowLAExtension/ExtensionInfo.plist
  51. 12 0
      LoopFollowLAExtension/LoopFollowLABundle.swift
  52. 752 0
      LoopFollowLAExtension/LoopFollowLiveActivity.swift
  53. 10 0
      LoopFollowLAExtensionExtension.entitlements
  54. 9 0
      Podfile
  55. 45 0
      RestartLiveActivityIntent.swift
  56. 19 2
      fastlane/Fastfile

+ 8 - 1
.gitignore

@@ -80,4 +80,11 @@ fastlane/test_output
 fastlane/FastlaneRunner
 
 LoopFollowConfigOverride.xcconfig
-.history
+.history*.xcuserstate
+docs/PR_configurable_slots.md
+docs/LiveActivityTestPlan.md
+
+# Claude
+CLAUDE.md
+
+node_modules/

+ 292 - 51
LoopFollow.xcodeproj/project.pbxproj

@@ -8,7 +8,30 @@
 
 /* Begin PBXBuildFile section */
 		2D8068C66833EEAED7B4BEB8 /* FutureCarbsCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EBAB9EECE7095238A558060 /* FutureCarbsCondition.swift */; };
+		374A77992F5BD8B200E96858 /* APNSClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77982F5BD8AB00E96858 /* APNSClient.swift */; };
+		374A77A52F5BE17000E96858 /* AppGroupID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A779F2F5BE17000E96858 /* AppGroupID.swift */; };
+		374A77A62F5BE17000E96858 /* GlucoseSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A12F5BE17000E96858 /* GlucoseSnapshot.swift */; };
+		374A77A72F5BE17000E96858 /* LAAppGroupSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A32F5BE17000E96858 /* LAAppGroupSettings.swift */; };
+		374A77A82F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A02F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift */; };
+		374A77AA2F5BE17000E96858 /* AppGroupID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A779F2F5BE17000E96858 /* AppGroupID.swift */; };
+		374A77AB2F5BE17000E96858 /* GlucoseSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A12F5BE17000E96858 /* GlucoseSnapshot.swift */; };
+		374A77AC2F5BE17000E96858 /* LAAppGroupSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A32F5BE17000E96858 /* LAAppGroupSettings.swift */; };
+		374A77AD2F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A02F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift */; };
+		374A77B42F5BE1AC00E96858 /* StorageCurrentGlucoseStateProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77B32F5BE1AC00E96858 /* StorageCurrentGlucoseStateProvider.swift */; };
+		374A77B52F5BE1AC00E96858 /* GlucoseSnapshotBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77AE2F5BE1AC00E96858 /* GlucoseSnapshotBuilder.swift */; };
+		374A77B72F5BE1AC00E96858 /* LiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77B12F5BE1AC00E96858 /* LiveActivityManager.swift */; };
+		374A77B82F5BE1AC00E96858 /* GlucoseSnapshotStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77AF2F5BE1AC00E96858 /* GlucoseSnapshotStore.swift */; };
+		374A77B92F5BE1AC00E96858 /* PreferredGlucoseUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77B22F5BE1AC00E96858 /* PreferredGlucoseUnit.swift */; };
+		379BECAA2F6588300069DC62 /* RestartLiveActivityIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379BECA92F6588300069DC62 /* RestartLiveActivityIntent.swift */; };
+		379BECB02F65DA4B0069DC62 /* LiveActivitySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379BECAF2F65DA4B0069DC62 /* LiveActivitySettingsView.swift */; };
+		37A4BDDB2F5B6B4A00EEB289 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 37A4BDDA2F5B6B4A00EEB289 /* WidgetKit.framework */; };
+		37A4BDDD2F5B6B4A00EEB289 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 37A4BDDC2F5B6B4A00EEB289 /* SwiftUI.framework */; };
+		37A4BDE82F5B6B4C00EEB289 /* LoopFollowLAExtensionExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */; platformFilter = ios; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
+		37E4DD0D2F7E0967000511C8 /* LALivenessStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E4DD0C2F7E0967000511C8 /* LALivenessStore.swift */; };
+		37E4DD0E2F7E097D000511C8 /* LALivenessStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E4DD0C2F7E0967000511C8 /* LALivenessStore.swift */; };
+		37E4DD112F7E0D35000511C8 /* LALivenessMarker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E4DD0F2F7E0985000511C8 /* LALivenessMarker.swift */; };
 		3F1335F351590E573D8E6962 /* Pods_LoopFollow.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */; };
+		5544D8C363FB5D3B9BF8CE4A /* APNSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7C2676561D686C6459CAA2D /* APNSettingsView.swift */; };
 		654132E72E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654132E62E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift */; };
 		654132EA2E19F24800BDBE08 /* TOTPGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654132E92E19F24800BDBE08 /* TOTPGenerator.swift */; };
 		654134182E1DC09700BDBE08 /* OverridePresetsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654134172E1DC09700BDBE08 /* OverridePresetsView.swift */; };
@@ -38,6 +61,7 @@
 		6589CC752E9EAFB700BB18FE /* SettingsMigrationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC742E9EAFB700BB18FE /* SettingsMigrationManager.swift */; };
 		65E153C32E4BB69100693A4F /* URLTokenValidationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */; };
 		65E8A2862E44B0300065037B /* VolumeButtonHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */; };
+		66E3D12E66AA4534A144A54B /* BackgroundRefreshManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8CA8BE0B3D247408FE088B4 /* BackgroundRefreshManager.swift */; };
 		ACE7F6DE0D065BEB52CDC0DB /* FutureCarbsAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7D2A4EFD18B7B7748B6669E /* FutureCarbsAlarmEditor.swift */; };
 		DD0247592DB2E89600FCADF6 /* AlarmCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */; };
 		DD0247712DB4337700FCADF6 /* BuildExpireCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD02475B2DB2E8FB00FCADF6 /* BuildExpireCondition.swift */; };
@@ -78,7 +102,6 @@
 		DD2C2E4F2D3B8AF1006413A5 /* NightscoutSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2C2E4E2D3B8AEC006413A5 /* NightscoutSettingsView.swift */; };
 		DD2C2E512D3B8B0C006413A5 /* NightscoutSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2C2E502D3B8B0B006413A5 /* NightscoutSettingsViewModel.swift */; };
 		DD485F142E454B2600CE8CBF /* SecureMessenger.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD485F132E454B2600CE8CBF /* SecureMessenger.swift */; };
-		DD485F162E46631000CE8CBF /* CryptoSwift in Frameworks */ = {isa = PBXBuildFile; productRef = DD485F152E46631000CE8CBF /* CryptoSwift */; };
 		DD4878032C7B297E0048F05C /* StorageValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878022C7B297E0048F05C /* StorageValue.swift */; };
 		DD4878052C7B2C970048F05C /* Storage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878042C7B2C970048F05C /* Storage.swift */; };
 		DD4878082C7B30BF0048F05C /* RemoteSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878072C7B30BF0048F05C /* RemoteSettingsView.swift */; };
@@ -89,7 +112,6 @@
 		DD4878152C7B75230048F05C /* MealView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878142C7B75230048F05C /* MealView.swift */; };
 		DD4878172C7B75350048F05C /* BolusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878162C7B75350048F05C /* BolusView.swift */; };
 		DD4878192C7C56D60048F05C /* TrioNightscoutRemoteController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878182C7C56D60048F05C /* TrioNightscoutRemoteController.swift */; };
-		DD48781C2C7DAF140048F05C /* SwiftJWT in Frameworks */ = {isa = PBXBuildFile; productRef = DD48781B2C7DAF140048F05C /* SwiftJWT */; };
 		DD48781E2C7DAF2F0048F05C /* PushNotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD48781D2C7DAF2F0048F05C /* PushNotificationManager.swift */; };
 		DD4878202C7DAF890048F05C /* PushMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD48781F2C7DAF890048F05C /* PushMessage.swift */; };
 		DD493AD52ACF2109009A6922 /* ResumePump.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD493AD42ACF2109009A6922 /* ResumePump.swift */; };
@@ -160,6 +182,7 @@
 		DD83164C2DE4DB3A004467AA /* BinaryFloatingPoint+localized.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD83164B2DE4DB3A004467AA /* BinaryFloatingPoint+localized.swift */; };
 		DD85E9952D739CFE001C8BB7 /* OmnipodDashHeartbeatBluetoothTransmitter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD85E9942D739CED001C8BB7 /* OmnipodDashHeartbeatBluetoothTransmitter.swift */; };
 		DD91E4DD2BDEC3F8002D9E97 /* GlucoseConversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD91E4DC2BDEC3F8002D9E97 /* GlucoseConversion.swift */; };
+		DD91E4DE2BDEC3F8002D9E97 /* GlucoseConversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD91E4DC2BDEC3F8002D9E97 /* GlucoseConversion.swift */; };
 		DD98F54424BCEFEE0007425A /* ShareClientExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD98F54324BCEFEE0007425A /* ShareClientExtension.swift */; };
 		DD9ACA042D32821400415D8A /* DeviceStatusTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ACA032D32821200415D8A /* DeviceStatusTask.swift */; };
 		DD9ACA062D32AF7900415D8A /* TreatmentsTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ACA052D32AF6E00415D8A /* TreatmentsTask.swift */; };
@@ -405,6 +428,13 @@
 /* End PBXBuildFile section */
 
 /* Begin PBXContainerItemProxy section */
+		37A4BDE62F5B6B4C00EEB289 /* PBXContainerItemProxy */ = {
+			isa = PBXContainerItemProxy;
+			containerPortal = FC97880C2485969B00A7906C /* Project object */;
+			proxyType = 1;
+			remoteGlobalIDString = 37A4BDD82F5B6B4A00EEB289;
+			remoteInfo = LoopFollowLAExtensionExtension;
+		};
 		DDCC3ADA2DDE1790006F1C10 /* PBXContainerItemProxy */ = {
 			isa = PBXContainerItemProxy;
 			containerPortal = FC97880C2485969B00A7906C /* Project object */;
@@ -414,10 +444,42 @@
 		};
 /* End PBXContainerItemProxy section */
 
+/* Begin PBXCopyFilesBuildPhase section */
+		37A4BDED2F5B6B4C00EEB289 /* Embed Foundation Extensions */ = {
+			isa = PBXCopyFilesBuildPhase;
+			buildActionMask = 2147483647;
+			dstPath = "";
+			dstSubfolderSpec = 13;
+			files = (
+				37A4BDE82F5B6B4C00EEB289 /* LoopFollowLAExtensionExtension.appex in Embed Foundation Extensions */,
+			);
+			name = "Embed Foundation Extensions";
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+/* End PBXCopyFilesBuildPhase section */
+
 /* Begin PBXFileReference section */
 		059B0FA59AABFE72FE13DDDA /* Pods-LoopFollow.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-LoopFollow.release.xcconfig"; path = "Target Support Files/Pods-LoopFollow/Pods-LoopFollow.release.xcconfig"; sourceTree = "<group>"; };
 		2B9BEC26E4E48EF9B811A372 /* PendingFutureCarb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingFutureCarb.swift; sourceTree = "<group>"; };
 		2EBAB9EECE7095238A558060 /* FutureCarbsCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FutureCarbsCondition.swift; sourceTree = "<group>"; };
+		374A77982F5BD8AB00E96858 /* APNSClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNSClient.swift; sourceTree = "<group>"; };
+		374A779F2F5BE17000E96858 /* AppGroupID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppGroupID.swift; sourceTree = "<group>"; };
+		374A77A02F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseLiveActivityAttributes.swift; sourceTree = "<group>"; };
+		374A77A12F5BE17000E96858 /* GlucoseSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseSnapshot.swift; sourceTree = "<group>"; };
+		374A77A32F5BE17000E96858 /* LAAppGroupSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LAAppGroupSettings.swift; sourceTree = "<group>"; };
+		374A77AE2F5BE1AC00E96858 /* GlucoseSnapshotBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseSnapshotBuilder.swift; sourceTree = "<group>"; };
+		374A77AF2F5BE1AC00E96858 /* GlucoseSnapshotStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseSnapshotStore.swift; sourceTree = "<group>"; };
+		374A77B12F5BE1AC00E96858 /* LiveActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = "<group>"; };
+		374A77B22F5BE1AC00E96858 /* PreferredGlucoseUnit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferredGlucoseUnit.swift; sourceTree = "<group>"; };
+		374A77B32F5BE1AC00E96858 /* StorageCurrentGlucoseStateProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageCurrentGlucoseStateProvider.swift; sourceTree = "<group>"; };
+		374DAACA2F5B924B00BB663B /* LoopFollowLAExtensionExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = LoopFollowLAExtensionExtension.entitlements; sourceTree = "<group>"; };
+		379BECA92F6588300069DC62 /* RestartLiveActivityIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestartLiveActivityIntent.swift; sourceTree = "<group>"; };
+		379BECAF2F65DA4B0069DC62 /* LiveActivitySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivitySettingsView.swift; sourceTree = "<group>"; };
+		37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = LoopFollowLAExtensionExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
+		37A4BDDA2F5B6B4A00EEB289 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = /System/Library/Frameworks/WidgetKit.framework; sourceTree = "<absolute>"; };
+		37A4BDDC2F5B6B4A00EEB289 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = /System/Library/Frameworks/SwiftUI.framework; sourceTree = "<absolute>"; };
+		37E4DD0C2F7E0967000511C8 /* LALivenessStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LALivenessStore.swift; sourceTree = "<group>"; };
+		37E4DD0F2F7E0985000511C8 /* LALivenessMarker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LALivenessMarker.swift; sourceTree = "<group>"; };
 		654132E62E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleQRCodeScannerView.swift; sourceTree = "<group>"; };
 		654132E92E19F24800BDBE08 /* TOTPGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOTPGenerator.swift; sourceTree = "<group>"; };
 		654134172E1DC09700BDBE08 /* OverridePresetsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverridePresetsView.swift; sourceTree = "<group>"; };
@@ -448,6 +510,7 @@
 		65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLTokenValidationView.swift; sourceTree = "<group>"; };
 		65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeButtonHandler.swift; sourceTree = "<group>"; };
 		A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_LoopFollow.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+		A8CA8BE0B3D247408FE088B4 /* BackgroundRefreshManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundRefreshManager.swift; sourceTree = "<group>"; };
 		B7D2A4EFD18B7B7748B6669E /* FutureCarbsAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FutureCarbsAlarmEditor.swift; sourceTree = "<group>"; };
 		DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmCondition.swift; sourceTree = "<group>"; };
 		DD02475B2DB2E8FB00FCADF6 /* BuildExpireCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildExpireCondition.swift; sourceTree = "<group>"; };
@@ -664,6 +727,7 @@
 		DDFF3D842D14279B00BF9D9E /* BackgroundRefreshSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundRefreshSettingsView.swift; sourceTree = "<group>"; };
 		DDFF3D862D14280500BF9D9E /* BackgroundRefreshSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundRefreshSettingsViewModel.swift; sourceTree = "<group>"; };
 		DDFF3D882D1429AB00BF9D9E /* BackgroundRefreshType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundRefreshType.swift; sourceTree = "<group>"; };
+		E7C2676561D686C6459CAA2D /* APNSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNSettingsView.swift; sourceTree = "<group>"; };
 		ECA3EFB4037410B4973BB632 /* Pods-LoopFollow.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-LoopFollow.debug.xcconfig"; path = "Target Support Files/Pods-LoopFollow/Pods-LoopFollow.debug.xcconfig"; sourceTree = "<group>"; };
 		FC16A97924996673003D6245 /* NightScout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightScout.swift; sourceTree = "<group>"; };
 		FC16A97C24996747003D6245 /* SpeakBG.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeakBG.swift; sourceTree = "<group>"; };
@@ -820,12 +884,22 @@
 /* End PBXFileReference section */
 
 /* Begin PBXFileSystemSynchronizedRootGroup section */
+		37A4BDDE2F5B6B4A00EEB289 /* LoopFollowLAExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = LoopFollowLAExtension; sourceTree = "<group>"; };
 		65AC25F52ECFD5E800421360 /* Stats */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Stats; sourceTree = "<group>"; };
 		65AC26702ED245DF00421360 /* Treatments */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Treatments; sourceTree = "<group>"; };
 		DDCC3AD72DDE1790006F1C10 /* Tests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Tests; sourceTree = "<group>"; };
 /* End PBXFileSystemSynchronizedRootGroup section */
 
 /* Begin PBXFrameworksBuildPhase section */
+		37A4BDD62F5B6B4A00EEB289 /* Frameworks */ = {
+			isa = PBXFrameworksBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				37A4BDDD2F5B6B4A00EEB289 /* SwiftUI.framework in Frameworks */,
+				37A4BDDB2F5B6B4A00EEB289 /* WidgetKit.framework in Frameworks */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
 		DDCC3AD32DDE1790006F1C10 /* Frameworks */ = {
 			isa = PBXFrameworksBuildPhase;
 			buildActionMask = 2147483647;
@@ -838,15 +912,32 @@
 			buildActionMask = 2147483647;
 			files = (
 				FCFEEC9E2486E68E00402A7F /* WebKit.framework in Frameworks */,
-				DD485F162E46631000CE8CBF /* CryptoSwift in Frameworks */,
 				3F1335F351590E573D8E6962 /* Pods_LoopFollow.framework in Frameworks */,
-				DD48781C2C7DAF140048F05C /* SwiftJWT in Frameworks */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};
 /* End PBXFrameworksBuildPhase section */
 
 /* Begin PBXGroup section */
+		376310762F5CD65100656488 /* LiveActivity */ = {
+			isa = PBXGroup;
+			children = (
+				37E4DD0F2F7E0985000511C8 /* LALivenessMarker.swift */,
+				37E4DD0C2F7E0967000511C8 /* LALivenessStore.swift */,
+				374A77AE2F5BE1AC00E96858 /* GlucoseSnapshotBuilder.swift */,
+				374A77AF2F5BE1AC00E96858 /* GlucoseSnapshotStore.swift */,
+				374A77B12F5BE1AC00E96858 /* LiveActivityManager.swift */,
+				374A77B22F5BE1AC00E96858 /* PreferredGlucoseUnit.swift */,
+				374A77B32F5BE1AC00E96858 /* StorageCurrentGlucoseStateProvider.swift */,
+				374A779F2F5BE17000E96858 /* AppGroupID.swift */,
+				374A77A02F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift */,
+				374A77A12F5BE17000E96858 /* GlucoseSnapshot.swift */,
+				374A77A32F5BE17000E96858 /* LAAppGroupSettings.swift */,
+				374A77982F5BD8AB00E96858 /* APNSClient.swift */,
+			);
+			path = LiveActivity;
+			sourceTree = "<group>";
+		};
 		6589CC552E9E7D1600BB18FE /* ImportExport */ = {
 			isa = PBXGroup;
 			children = (
@@ -864,6 +955,7 @@
 			children = (
 				6589CC552E9E7D1600BB18FE /* ImportExport */,
 				6589CC562E9E7D1600BB18FE /* AdvancedSettingsView.swift */,
+				E7C2676561D686C6459CAA2D /* APNSettingsView.swift */,
 				6589CC572E9E7D1600BB18FE /* AdvancedSettingsViewModel.swift */,
 				6589CC582E9E7D1600BB18FE /* CalendarSettingsView.swift */,
 				6589CC592E9E7D1600BB18FE /* ContactSettingsView.swift */,
@@ -886,6 +978,8 @@
 				FCFEEC9D2486E68E00402A7F /* WebKit.framework */,
 				A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */,
 				FCE537C2249AAB2600F80BF8 /* NotificationCenter.framework */,
+				37A4BDDA2F5B6B4A00EEB289 /* WidgetKit.framework */,
+				37A4BDDC2F5B6B4A00EEB289 /* SwiftUI.framework */,
 			);
 			name = Frameworks;
 			sourceTree = "<group>";
@@ -1493,6 +1587,8 @@
 		FC8DEEE32485D1680075863F /* LoopFollow */ = {
 			isa = PBXGroup;
 			children = (
+				379BECAF2F65DA4B0069DC62 /* LiveActivitySettingsView.swift */,
+				376310762F5CD65100656488 /* LiveActivity */,
 				6589CC612E9E7D1600BB18FE /* Settings */,
 				65AC26702ED245DF00421360 /* Treatments */,
 				65AC25F52ECFD5E800421360 /* Stats */,
@@ -1523,6 +1619,8 @@
 		FC97880B2485969B00A7906C = {
 			isa = PBXGroup;
 			children = (
+				379BECA92F6588300069DC62 /* RestartLiveActivityIntent.swift */,
+				374DAACA2F5B924B00BB663B /* LoopFollowLAExtensionExtension.entitlements */,
 				DDF2C0132BEFD468007A20E6 /* blacklisted-versions.json */,
 				DDB0AF542BB1B24A00AFA48B /* BuildDetails.plist */,
 				DDB0AF4F2BB1A81F00AFA48B /* Scripts */,
@@ -1531,6 +1629,7 @@
 				FC5A5C3C2497B229009C550E /* Config.xcconfig */,
 				FC8DEEE32485D1680075863F /* LoopFollow */,
 				DDCC3AD72DDE1790006F1C10 /* Tests */,
+				37A4BDDE2F5B6B4A00EEB289 /* LoopFollowLAExtension */,
 				FC9788152485969B00A7906C /* Products */,
 				8E32230C453C93FDCE59C2B9 /* Pods */,
 				6A5880E0B811AF443B05AB02 /* Frameworks */,
@@ -1542,6 +1641,7 @@
 			children = (
 				FC9788142485969B00A7906C /* Loop Follow.app */,
 				DDCC3AD62DDE1790006F1C10 /* Tests.xctest */,
+				37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */,
 			);
 			name = Products;
 			sourceTree = "<group>";
@@ -1576,6 +1676,7 @@
 				FCC6886A24898FD800A0279D /* ObservationToken.swift */,
 				FCC6886C2489909D00A0279D /* AnyConvertible.swift */,
 				FCC688592489554800A0279D /* BackgroundTaskAudio.swift */,
+				A8CA8BE0B3D247408FE088B4 /* BackgroundRefreshManager.swift */,
 				FCFEEC9F2488157B00402A7F /* Chart.swift */,
 				FCC0FAC124922A22003E610E /* DictionaryKeyPath.swift */,
 				FC16A98024996C07003D6245 /* DateTime.swift */,
@@ -1613,6 +1714,28 @@
 /* End PBXGroup section */
 
 /* Begin PBXNativeTarget section */
+		37A4BDD82F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension */ = {
+			isa = PBXNativeTarget;
+			buildConfigurationList = 37A4BDEA2F5B6B4C00EEB289 /* Build configuration list for PBXNativeTarget "LoopFollowLAExtensionExtension" */;
+			buildPhases = (
+				37A4BDD52F5B6B4A00EEB289 /* Sources */,
+				37A4BDD62F5B6B4A00EEB289 /* Frameworks */,
+				37A4BDD72F5B6B4A00EEB289 /* Resources */,
+			);
+			buildRules = (
+			);
+			dependencies = (
+			);
+			fileSystemSynchronizedGroups = (
+				37A4BDDE2F5B6B4A00EEB289 /* LoopFollowLAExtension */,
+			);
+			name = LoopFollowLAExtensionExtension;
+			packageProductDependencies = (
+			);
+			productName = LoopFollowLAExtensionExtension;
+			productReference = 37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */;
+			productType = "com.apple.product-type.app-extension";
+		};
 		DDCC3AD52DDE1790006F1C10 /* Tests */ = {
 			isa = PBXNativeTarget;
 			buildConfigurationList = DDCC3ADC2DDE1790006F1C10 /* Build configuration list for PBXNativeTarget "Tests" */;
@@ -1647,10 +1770,12 @@
 				FC9788122485969B00A7906C /* Resources */,
 				04DA71CCA0280FA5FA2DF7A6 /* [CP] Embed Pods Frameworks */,
 				DDB0AF532BB1AA0900AFA48B /* Capture Build Details */,
+				37A4BDED2F5B6B4C00EEB289 /* Embed Foundation Extensions */,
 			);
 			buildRules = (
 			);
 			dependencies = (
+				37A4BDE72F5B6B4C00EEB289 /* PBXTargetDependency */,
 			);
 			fileSystemSynchronizedGroups = (
 				65AC25F52ECFD5E800421360 /* Stats */,
@@ -1658,8 +1783,6 @@
 			);
 			name = LoopFollow;
 			packageProductDependencies = (
-				DD48781B2C7DAF140048F05C /* SwiftJWT */,
-				DD485F152E46631000CE8CBF /* CryptoSwift */,
 			);
 			productName = LoopFollow;
 			productReference = FC9788142485969B00A7906C /* Loop Follow.app */;
@@ -1671,10 +1794,13 @@
 		FC97880C2485969B00A7906C /* Project object */ = {
 			isa = PBXProject;
 			attributes = {
-				LastSwiftUpdateCheck = 1630;
+				LastSwiftUpdateCheck = 2620;
 				LastUpgradeCheck = 1140;
 				ORGANIZATIONNAME = "Jon Fawcett";
 				TargetAttributes = {
+					37A4BDD82F5B6B4A00EEB289 = {
+						CreatedOnToolsVersion = 26.2;
+					};
 					DDCC3AD52DDE1790006F1C10 = {
 						CreatedOnToolsVersion = 16.3;
 						TestTargetID = FC9788132485969B00A7906C;
@@ -1694,9 +1820,6 @@
 			);
 			mainGroup = FC97880B2485969B00A7906C;
 			packageReferences = (
-				DD48781A2C7DAF140048F05C /* XCRemoteSwiftPackageReference "Swift-JWT" */,
-				654132E82E19F0B800BDBE08 /* XCRemoteSwiftPackageReference "swift-crypto" */,
-				DD485F0B2E4547C800CE8CBF /* XCRemoteSwiftPackageReference "CryptoSwift" */,
 			);
 			productRefGroup = FC9788152485969B00A7906C /* Products */;
 			projectDirPath = "";
@@ -1704,11 +1827,19 @@
 			targets = (
 				FC9788132485969B00A7906C /* LoopFollow */,
 				DDCC3AD52DDE1790006F1C10 /* Tests */,
+				37A4BDD82F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension */,
 			);
 		};
 /* End PBXProject section */
 
 /* Begin PBXResourcesBuildPhase section */
+		37A4BDD72F5B6B4A00EEB289 /* Resources */ = {
+			isa = PBXResourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
 		DDCC3AD42DDE1790006F1C10 /* Resources */ = {
 			isa = PBXResourcesBuildPhase;
 			buildActionMask = 2147483647;
@@ -1932,6 +2063,20 @@
 /* End PBXShellScriptBuildPhase section */
 
 /* Begin PBXSourcesBuildPhase section */
+		37A4BDD52F5B6B4A00EEB289 /* Sources */ = {
+			isa = PBXSourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				DD91E4DE2BDEC3F8002D9E97 /* GlucoseConversion.swift in Sources */,
+				374A77AA2F5BE17000E96858 /* AppGroupID.swift in Sources */,
+				37E4DD0E2F7E097D000511C8 /* LALivenessStore.swift in Sources */,
+				374A77AB2F5BE17000E96858 /* GlucoseSnapshot.swift in Sources */,
+				37E4DD112F7E0D35000511C8 /* LALivenessMarker.swift in Sources */,
+				374A77AC2F5BE17000E96858 /* LAAppGroupSettings.swift in Sources */,
+				374A77AD2F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift in Sources */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
 		DDCC3AD22DDE1790006F1C10 /* Sources */ = {
 			isa = PBXSourcesBuildPhase;
 			buildActionMask = 2147483647;
@@ -1955,6 +2100,11 @@
 				DD4AFB492DB576C200BB593F /* AlarmSettingsView.swift in Sources */,
 				DD7F4C032DD4B92E00D449E9 /* NotLoopingAlarmEditor.swift in Sources */,
 				DD7F4C1B2DD6501D00D449E9 /* COBCondition.swift in Sources */,
+				374A77B42F5BE1AC00E96858 /* StorageCurrentGlucoseStateProvider.swift in Sources */,
+				374A77B52F5BE1AC00E96858 /* GlucoseSnapshotBuilder.swift in Sources */,
+				374A77B72F5BE1AC00E96858 /* LiveActivityManager.swift in Sources */,
+				374A77B82F5BE1AC00E96858 /* GlucoseSnapshotStore.swift in Sources */,
+				374A77B92F5BE1AC00E96858 /* PreferredGlucoseUnit.swift in Sources */,
 				DDE75D232DE5E505007C1FC1 /* Glyph.swift in Sources */,
 				DD4878202C7DAF890048F05C /* PushMessage.swift in Sources */,
 				DD0247712DB4337700FCADF6 /* BuildExpireCondition.swift in Sources */,
@@ -2038,6 +2188,7 @@
 				DDEF503A2D31615000999A5D /* LogManager.swift in Sources */,
 				DD4878172C7B75350048F05C /* BolusView.swift in Sources */,
 				DD026E592EA2C8A200A39CB5 /* InsulinPrecisionManager.swift in Sources */,
+				374A77992F5BD8B200E96858 /* APNSClient.swift in Sources */,
 				DD493AE72ACF23CF009A6922 /* DeviceStatus.swift in Sources */,
 				DDC7E5162DBCFA7F00EB1127 /* SnoozerView.swift in Sources */,
 				FCFEECA2248857A600402A7F /* SettingsViewController.swift in Sources */,
@@ -2080,7 +2231,9 @@
 				DD493AE12ACF22FE009A6922 /* Profile.swift in Sources */,
 				6589CC622E9E7D1600BB18FE /* ImportExportSettingsView.swift in Sources */,
 				6589CC632E9E7D1600BB18FE /* GeneralSettingsView.swift in Sources */,
+				5544D8C363FB5D3B9BF8CE4A /* APNSettingsView.swift in Sources */,
 				6589CC642E9E7D1600BB18FE /* ContactSettingsView.swift in Sources */,
+				37E4DD0D2F7E0967000511C8 /* LALivenessStore.swift in Sources */,
 				6589CC652E9E7D1600BB18FE /* DexcomSettingsViewModel.swift in Sources */,
 				6589CC662E9E7D1600BB18FE /* AdvancedSettingsView.swift in Sources */,
 				6589CC672E9E7D1600BB18FE /* ImportExportSettingsViewModel.swift in Sources */,
@@ -2132,6 +2285,8 @@
 				DDFF3D852D14279B00BF9D9E /* BackgroundRefreshSettingsView.swift in Sources */,
 				DDCF9A882D85FD33004DF4DD /* AlarmData.swift in Sources */,
 				DD608A0C2C27415C00F91132 /* BackgroundAlertManager.swift in Sources */,
+				66E3D12E66AA4534A144A54B /* BackgroundRefreshManager.swift in Sources */,
+				379BECB02F65DA4B0069DC62 /* LiveActivitySettingsView.swift in Sources */,
 				DD4878082C7B30BF0048F05C /* RemoteSettingsView.swift in Sources */,
 				DDE75D2B2DE5E613007C1FC1 /* NavigationRow.swift in Sources */,
 				DD4AFB3B2DB55CB600BB593F /* TimeOfDay.swift in Sources */,
@@ -2165,6 +2320,10 @@
 				DD7F4C212DD66BB200D449E9 /* FastRiseAlarmEditor.swift in Sources */,
 				FC97881A2485969B00A7906C /* SceneDelegate.swift in Sources */,
 				DD0C0C662C46E54C00DBADDF /* InfoDataSeparator.swift in Sources */,
+				374A77A52F5BE17000E96858 /* AppGroupID.swift in Sources */,
+				374A77A62F5BE17000E96858 /* GlucoseSnapshot.swift in Sources */,
+				374A77A72F5BE17000E96858 /* LAAppGroupSettings.swift in Sources */,
+				374A77A82F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift in Sources */,
 				DD58171C2D299F940041FB98 /* BluetoothDevice.swift in Sources */,
 				DD7E198A2ACDA62600DBD158 /* SensorStart.swift in Sources */,
 				DD026E5B2EA2C9C300A39CB5 /* InsulinFormatter.swift in Sources */,
@@ -2190,6 +2349,7 @@
 				654132EA2E19F24800BDBE08 /* TOTPGenerator.swift in Sources */,
 				6541341C2E1DC28000BDBE08 /* DateExtensions.swift in Sources */,
 				DD0650F52DCF303F004D3B41 /* AlarmStepperSection.swift in Sources */,
+				379BECAA2F6588300069DC62 /* RestartLiveActivityIntent.swift in Sources */,
 				DDCF9A802D85FD0B004DF4DD /* Alarm.swift in Sources */,
 				DD7F4C132DD51FD500D449E9 /* TempTargetEndCondition.swift in Sources */,
 				DDD10F012C510C6B00D76A8E /* ObservableUserDefaults.swift in Sources */,
@@ -2217,6 +2377,12 @@
 /* End PBXSourcesBuildPhase section */
 
 /* Begin PBXTargetDependency section */
+		37A4BDE72F5B6B4C00EEB289 /* PBXTargetDependency */ = {
+			isa = PBXTargetDependency;
+			platformFilter = ios;
+			target = 37A4BDD82F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension */;
+			targetProxy = 37A4BDE62F5B6B4C00EEB289 /* PBXContainerItemProxy */;
+		};
 		DDCC3ADB2DDE1790006F1C10 /* PBXTargetDependency */ = {
 			isa = PBXTargetDependency;
 			target = FC9788132485969B00A7906C /* LoopFollow */;
@@ -2244,6 +2410,109 @@
 /* End PBXVariantGroup section */
 
 /* Begin XCBuildConfiguration section */
+		37A4BDEB2F5B6B4C00EEB289 /* Debug */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+				ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
+				CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+				CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+				CODE_SIGN_ENTITLEMENTS = LoopFollowLAExtensionExtension.entitlements;
+				CODE_SIGN_STYLE = Automatic;
+				CURRENT_PROJECT_VERSION = 1;
+				DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)";
+				ENABLE_APP_SANDBOX = NO;
+				ENABLE_HARDENED_RUNTIME = NO;
+				ENABLE_USER_SCRIPT_SANDBOXING = YES;
+				GCC_C_LANGUAGE_STANDARD = gnu17;
+				GENERATE_INFOPLIST_FILE = NO;
+				INFOPLIST_FILE = LoopFollowLAExtension/ExtensionInfo.plist;
+				INFOPLIST_KEY_CFBundleDisplayName = LoopFollowLAExtension;
+				INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 Jon Fawcett. All rights reserved.";
+				IPHONEOS_DEPLOYMENT_TARGET = 16.6;
+				LD_RUNPATH_SEARCH_PATHS = (
+					"$(inherited)",
+					"@executable_path/Frameworks",
+					"@executable_path/../../Frameworks",
+				);
+				"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = (
+					"$(inherited)",
+					"@executable_path/../Frameworks",
+					"@executable_path/../../../../Frameworks",
+				);
+				LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+				MACOSX_DEPLOYMENT_TARGET = 26.2;
+				MARKETING_VERSION = "$(MARKETING_VERSION)";
+				PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix).LoopFollowLAExtension";
+				PRODUCT_MODULE_NAME = LoopFollowLAExtension;
+				PRODUCT_NAME = "$(TARGET_NAME)";
+				SDKROOT = auto;
+				SKIP_INSTALL = YES;
+				STRING_CATALOG_GENERATE_SYMBOLS = YES;
+				SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
+				SUPPORTS_MACCATALYST = NO;
+				SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
+				SWIFT_APPROACHABLE_CONCURRENCY = YES;
+				SWIFT_EMIT_LOC_STRINGS = YES;
+				SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+				SWIFT_VERSION = 5.0;
+				TARGETED_DEVICE_FAMILY = "1,2";
+				XROS_DEPLOYMENT_TARGET = 26.2;
+			};
+			name = Debug;
+		};
+		37A4BDEC2F5B6B4C00EEB289 /* Release */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+				ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
+				CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+				CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+				CODE_SIGN_ENTITLEMENTS = LoopFollowLAExtensionExtension.entitlements;
+				CODE_SIGN_STYLE = Automatic;
+				CURRENT_PROJECT_VERSION = 1;
+				DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)";
+				ENABLE_APP_SANDBOX = NO;
+				ENABLE_HARDENED_RUNTIME = NO;
+				ENABLE_USER_SCRIPT_SANDBOXING = YES;
+				GCC_C_LANGUAGE_STANDARD = gnu17;
+				GENERATE_INFOPLIST_FILE = NO;
+				INFOPLIST_FILE = LoopFollowLAExtension/ExtensionInfo.plist;
+				INFOPLIST_KEY_CFBundleDisplayName = LoopFollowLAExtension;
+				INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 Jon Fawcett. All rights reserved.";
+				IPHONEOS_DEPLOYMENT_TARGET = 16.6;
+				LD_RUNPATH_SEARCH_PATHS = (
+					"$(inherited)",
+					"@executable_path/Frameworks",
+					"@executable_path/../../Frameworks",
+				);
+				"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = (
+					"$(inherited)",
+					"@executable_path/../Frameworks",
+					"@executable_path/../../../../Frameworks",
+				);
+				LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+				MACOSX_DEPLOYMENT_TARGET = 26.2;
+				MARKETING_VERSION = "$(MARKETING_VERSION)";
+				PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix).LoopFollowLAExtension";
+				PRODUCT_MODULE_NAME = LoopFollowLAExtension;
+				PRODUCT_NAME = "$(TARGET_NAME)";
+				SDKROOT = auto;
+				SKIP_INSTALL = YES;
+				STRING_CATALOG_GENERATE_SYMBOLS = YES;
+				SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
+				SUPPORTS_MACCATALYST = NO;
+				SWIFT_APPROACHABLE_CONCURRENCY = YES;
+				SWIFT_EMIT_LOC_STRINGS = YES;
+				SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+				SWIFT_VERSION = 5.0;
+				TARGETED_DEVICE_FAMILY = "1,2";
+				XROS_DEPLOYMENT_TARGET = 26.2;
+			};
+			name = Release;
+		};
 		DDCC3ADD2DDE1790006F1C10 /* Debug */ = {
 			isa = XCBuildConfiguration;
 			buildSettings = {
@@ -2266,7 +2535,7 @@
 				IPHONEOS_DEPLOYMENT_TARGET = 18.4;
 				LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
 				MARKETING_VERSION = 1.0;
-				PRODUCT_BUNDLE_IDENTIFIER = "com.--unique-id-.LoopFollowTests--app-suffix-.Tests";
+				PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollowTests$(app_suffix).Tests";
 				PRODUCT_MODULE_NAME = Tests;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
@@ -2299,7 +2568,7 @@
 				IPHONEOS_DEPLOYMENT_TARGET = 18.4;
 				LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
 				MARKETING_VERSION = 1.0;
-				PRODUCT_BUNDLE_IDENTIFIER = "com.--unique-id-.LoopFollowTests--app-suffix-.Tests";
+				PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollowTests$(app_suffix).Tests";
 				PRODUCT_MODULE_NAME = Tests;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SWIFT_EMIT_LOC_STRINGS = NO;
@@ -2447,6 +2716,7 @@
 				);
 				PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix)";
 				PRODUCT_NAME = "Loop Follow";
+				SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
 				SUPPORTS_MACCATALYST = YES;
 				SWIFT_VERSION = 5.0;
 				TARGETED_DEVICE_FAMILY = "1,2";
@@ -2471,6 +2741,7 @@
 				);
 				PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix)";
 				PRODUCT_NAME = "Loop Follow";
+				SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
 				SUPPORTS_MACCATALYST = YES;
 				SWIFT_VERSION = 5.0;
 				TARGETED_DEVICE_FAMILY = "1,2";
@@ -2480,6 +2751,15 @@
 /* End XCBuildConfiguration section */
 
 /* Begin XCConfigurationList section */
+		37A4BDEA2F5B6B4C00EEB289 /* Build configuration list for PBXNativeTarget "LoopFollowLAExtensionExtension" */ = {
+			isa = XCConfigurationList;
+			buildConfigurations = (
+				37A4BDEB2F5B6B4C00EEB289 /* Debug */,
+				37A4BDEC2F5B6B4C00EEB289 /* Release */,
+			);
+			defaultConfigurationIsVisible = 0;
+			defaultConfigurationName = Release;
+		};
 		DDCC3ADC2DDE1790006F1C10 /* Build configuration list for PBXNativeTarget "Tests" */ = {
 			isa = XCConfigurationList;
 			buildConfigurations = (
@@ -2509,45 +2789,6 @@
 		};
 /* End XCConfigurationList section */
 
-/* Begin XCRemoteSwiftPackageReference section */
-		654132E82E19F0B800BDBE08 /* XCRemoteSwiftPackageReference "swift-crypto" */ = {
-			isa = XCRemoteSwiftPackageReference;
-			repositoryURL = "https://github.com/apple/swift-crypto.git";
-			requirement = {
-				kind = upToNextMajorVersion;
-				minimumVersion = 3.12.3;
-			};
-		};
-		DD485F0B2E4547C800CE8CBF /* XCRemoteSwiftPackageReference "CryptoSwift" */ = {
-			isa = XCRemoteSwiftPackageReference;
-			repositoryURL = "https://github.com/krzyzanowskim/CryptoSwift.git";
-			requirement = {
-				kind = upToNextMajorVersion;
-				minimumVersion = 1.9.0;
-			};
-		};
-		DD48781A2C7DAF140048F05C /* XCRemoteSwiftPackageReference "Swift-JWT" */ = {
-			isa = XCRemoteSwiftPackageReference;
-			repositoryURL = "https://github.com/Kitura/Swift-JWT.git";
-			requirement = {
-				kind = exactVersion;
-				version = 4.0.1;
-			};
-		};
-/* End XCRemoteSwiftPackageReference section */
-
-/* Begin XCSwiftPackageProductDependency section */
-		DD485F152E46631000CE8CBF /* CryptoSwift */ = {
-			isa = XCSwiftPackageProductDependency;
-			productName = CryptoSwift;
-		};
-		DD48781B2C7DAF140048F05C /* SwiftJWT */ = {
-			isa = XCSwiftPackageProductDependency;
-			package = DD48781A2C7DAF140048F05C /* XCRemoteSwiftPackageReference "Swift-JWT" */;
-			productName = SwiftJWT;
-		};
-/* End XCSwiftPackageProductDependency section */
-
 /* Begin XCVersionGroup section */
 		FC3AE7B3249E8E0E00AAE1E0 /* LoopFollow.xcdatamodeld */ = {
 			isa = XCVersionGroup;

+ 0 - 96
LoopFollow.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved

@@ -1,96 +0,0 @@
-{
-  "originHash" : "94a024be279d128a7e82f3c76785db1e4cf7c9380d0c4aa59dfdf54952403b8d",
-  "pins" : [
-    {
-      "identity" : "bluecryptor",
-      "kind" : "remoteSourceControl",
-      "location" : "https://github.com/Kitura/BlueCryptor.git",
-      "state" : {
-        "revision" : "cec97c24b111351e70e448972a7d3fe68a756d6d",
-        "version" : "2.0.2"
-      }
-    },
-    {
-      "identity" : "blueecc",
-      "kind" : "remoteSourceControl",
-      "location" : "https://github.com/Kitura/BlueECC.git",
-      "state" : {
-        "revision" : "1485268a54f8135435a825a855e733f026fa6cc8",
-        "version" : "1.2.201"
-      }
-    },
-    {
-      "identity" : "bluersa",
-      "kind" : "remoteSourceControl",
-      "location" : "https://github.com/Kitura/BlueRSA.git",
-      "state" : {
-        "revision" : "f40325520344a966523b214394aa350132a6af68",
-        "version" : "1.0.203"
-      }
-    },
-    {
-      "identity" : "cryptoswift",
-      "kind" : "remoteSourceControl",
-      "location" : "https://github.com/krzyzanowskim/CryptoSwift.git",
-      "state" : {
-        "revision" : "e45a26384239e028ec87fbcc788f513b67e10d8f",
-        "version" : "1.9.0"
-      }
-    },
-    {
-      "identity" : "kituracontracts",
-      "kind" : "remoteSourceControl",
-      "location" : "https://github.com/Kitura/KituraContracts.git",
-      "state" : {
-        "revision" : "6edf7ac3dd2b3a2c61284778d430bbad7d8a6f23",
-        "version" : "2.0.1"
-      }
-    },
-    {
-      "identity" : "loggerapi",
-      "kind" : "remoteSourceControl",
-      "location" : "https://github.com/Kitura/LoggerAPI.git",
-      "state" : {
-        "revision" : "4e6b45e850ffa275e8e26a24c6454fd709d5b6ac",
-        "version" : "2.0.0"
-      }
-    },
-    {
-      "identity" : "swift-asn1",
-      "kind" : "remoteSourceControl",
-      "location" : "https://github.com/apple/swift-asn1.git",
-      "state" : {
-        "revision" : "810496cf121e525d660cd0ea89a758740476b85f",
-        "version" : "1.5.1"
-      }
-    },
-    {
-      "identity" : "swift-crypto",
-      "kind" : "remoteSourceControl",
-      "location" : "https://github.com/apple/swift-crypto.git",
-      "state" : {
-        "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc",
-        "version" : "3.15.1"
-      }
-    },
-    {
-      "identity" : "swift-jwt",
-      "kind" : "remoteSourceControl",
-      "location" : "https://github.com/Kitura/Swift-JWT.git",
-      "state" : {
-        "revision" : "f68ec28fbd90a651597e9e825ea7f315f8d52a1f",
-        "version" : "4.0.1"
-      }
-    },
-    {
-      "identity" : "swift-log",
-      "kind" : "remoteSourceControl",
-      "location" : "https://github.com/apple/swift-log.git",
-      "state" : {
-        "revision" : "bbd81b6725ae874c69e9b8c8804d462356b55523",
-        "version" : "1.10.1"
-      }
-    }
-  ],
-  "version" : 3
-}

+ 34 - 14
LoopFollow/Application/AppDelegate.swift

@@ -6,7 +6,7 @@ import EventKit
 import UIKit
 import UserNotifications
 
-@UIApplicationMain
+@main
 class AppDelegate: UIResponder, UIApplicationDelegate {
     var window: UIWindow?
     let notificationCenter = UNUserNotificationCenter.current()
@@ -32,7 +32,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
         }
 
         let action = UNNotificationAction(identifier: "OPEN_APP_ACTION", title: "Open App", options: .foreground)
-        let category = UNNotificationCategory(identifier: "loopfollow.background.alert", actions: [action], intentIdentifiers: [], options: [])
+        let category = UNNotificationCategory(identifier: BackgroundAlertIdentifier.categoryIdentifier, actions: [action], intentIdentifiers: [], options: [])
         UNUserNotificationCenter.current().setNotificationCategories([category])
 
         UNUserNotificationCenter.current().delegate = self
@@ -45,30 +45,44 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
         DispatchQueue.main.async {
             UIApplication.shared.registerForRemoteNotifications()
         }
+
+        BackgroundRefreshManager.shared.register()
+
+        // Detect Before-First-Unlock launch. If protected data is unavailable here,
+        // StorageValues were cached from encrypted UserDefaults and need a reload
+        // on the first foreground after the user unlocks.
+        let bfu = !UIApplication.shared.isProtectedDataAvailable
+        Storage.shared.needsBFUReload = bfu
+        LogManager.shared.log(category: .general, message: "BFU check: isProtectedDataAvailable=\(!bfu), needsBFUReload=\(bfu)")
+
         return true
     }
 
-    func applicationWillTerminate(_: UIApplication) {}
+    func applicationWillTerminate(_: UIApplication) {
+        #if !targetEnvironment(macCatalyst)
+            LiveActivityManager.shared.endOnTerminate()
+        #endif
+    }
 
     // MARK: - Remote Notifications
 
-    // Called when successfully registered for remote notifications
+    /// Called when successfully registered for remote notifications
     func application(_: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
         let tokenString = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
 
         Observable.shared.loopFollowDeviceToken.value = tokenString
 
-        LogManager.shared.log(category: .general, message: "Successfully registered for remote notifications with token: \(tokenString)")
+        LogManager.shared.log(category: .apns, message: "Successfully registered for remote notifications with token: \(tokenString)")
     }
 
-    // Called when failed to register for remote notifications
+    /// Called when failed to register for remote notifications
     func application(_: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
-        LogManager.shared.log(category: .general, message: "Failed to register for remote notifications: \(error.localizedDescription)")
+        LogManager.shared.log(category: .apns, message: "Failed to register for remote notifications: \(error.localizedDescription)")
     }
 
-    // Called when a remote notification is received
+    /// Called when a remote notification is received
     func application(_: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
-        LogManager.shared.log(category: .general, message: "Received remote notification: \(userInfo)")
+        LogManager.shared.log(category: .apns, message: "Received remote notification: \(userInfo)")
 
         // Check if this is a response notification from Loop or Trio
         if let aps = userInfo["aps"] as? [String: Any] {
@@ -76,7 +90,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
             if let alert = aps["alert"] as? [String: Any] {
                 let title = alert["title"] as? String ?? ""
                 let body = alert["body"] as? String ?? ""
-                LogManager.shared.log(category: .general, message: "Notification - Title: \(title), Body: \(body)")
+                LogManager.shared.log(category: .apns, message: "Notification - Title: \(title), Body: \(body)")
             }
 
             // Handle silent notification (content-available)
@@ -84,11 +98,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
                 // This is a silent push, nothing implemented but logging for now
 
                 if let commandStatus = userInfo["command_status"] as? String {
-                    LogManager.shared.log(category: .general, message: "Command status: \(commandStatus)")
+                    LogManager.shared.log(category: .apns, message: "Command status: \(commandStatus)")
                 }
 
                 if let commandType = userInfo["command_type"] as? String {
-                    LogManager.shared.log(category: .general, message: "Command type: \(commandType)")
+                    LogManager.shared.log(category: .apns, message: "Command type: \(commandType)")
                 }
             }
         }
@@ -97,6 +111,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
         completionHandler(.newData)
     }
 
+    // MARK: - URL handling
+
+    // Note: with scene-based lifecycle (iOS 13+), URLs are delivered to
+    // SceneDelegate.scene(_:openURLContexts:) — not here. The scene delegate
+    // handles <urlScheme>://la-tap for Live Activity tap navigation.
+
     // MARK: UISceneSession Lifecycle
 
     func application(_: UIApplication, willFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
@@ -110,7 +130,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
     func application(_: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options _: UIScene.ConnectionOptions) -> UISceneConfiguration {
         // Called when a new scene session is being created.
         // Use this method to select a configuration to create the new scene with.
-        return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
+        UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
     }
 
     func application(_: UIApplication, didDiscardSceneSessions _: Set<UISceneSession>) {
@@ -166,7 +186,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
 
     func userNotificationCenter(_: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
         if response.actionIdentifier == "OPEN_APP_ACTION" {
-            if let window = window {
+            if let window {
                 window.rootViewController?.dismiss(animated: true, completion: nil)
                 window.rootViewController?.present(MainViewController(), animated: true, completion: nil)
             }

+ 12 - 2
LoopFollow/Application/SceneDelegate.swift

@@ -34,6 +34,16 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
         // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive.
     }
 
+    func scene(_: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
+        guard URLContexts.contains(where: { $0.url.scheme == AppGroupID.urlScheme && $0.url.host == "la-tap" }) else { return }
+        // scene(_:openURLContexts:) fires after sceneDidBecomeActive when the app
+        // foregrounds from background. Post on the next run loop so the view
+        // hierarchy (including any presented modals) is fully settled.
+        DispatchQueue.main.async {
+            NotificationCenter.default.post(name: .liveActivityDidForeground, object: nil)
+        }
+    }
+
     func sceneWillResignActive(_: UIScene) {
         // Called when the scene will move from an active state to an inactive state.
         // This may occur due to temporary interruptions (ex. an incoming phone call).
@@ -53,7 +63,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
         (UIApplication.shared.delegate as? AppDelegate)?.saveContext()
     }
 
-    // Handle the UIApplicationShortcutItem when the user taps on the Home Screen Quick Action. This function toggles the "Speak BG" setting in UserDefaultsRepository, speaks the current state (on/off) using AVSpeechSynthesizer, and updates the Quick Action appearance.
+    /// Handle the UIApplicationShortcutItem when the user taps on the Home Screen Quick Action. This function toggles the "Speak BG" setting in UserDefaultsRepository, speaks the current state (on/off) using AVSpeechSynthesizer, and updates the Quick Action appearance.
     func handleShortcutItem(_ shortcutItem: UIApplicationShortcutItem) {
         if let bundleIdentifier = Bundle.main.bundleIdentifier {
             let expectedType = bundleIdentifier + ".toggleSpeakBG"
@@ -66,7 +76,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
         }
     }
 
-    // The following method is called when the user taps on the Home Screen Quick Action
+    /// The following method is called when the user taps on the Home Screen Quick Action
     func windowScene(_: UIWindowScene, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler _: @escaping (Bool) -> Void) {
         handleShortcutItem(shortcutItem)
     }

+ 19 - 6
LoopFollow/Controllers/BackgroundAlertManager.swift

@@ -11,11 +11,24 @@ enum BackgroundAlertDuration: TimeInterval, CaseIterable {
     case eighteenMinutes = 1080 // 18 minutes in seconds
 }
 
-/// Enum representing unique identifiers for each background alert.
-enum BackgroundAlertIdentifier: String, CaseIterable {
-    case sixMin = "loopfollow.background.alert.6min"
-    case twelveMin = "loopfollow.background.alert.12min"
-    case eighteenMin = "loopfollow.background.alert.18min"
+/// Unique identifiers for each background alert, scoped to the current bundle
+/// so multiple LoopFollow instances don't interfere with each other's notifications.
+enum BackgroundAlertIdentifier: CaseIterable {
+    case sixMin
+    case twelveMin
+    case eighteenMin
+
+    private static let prefix = Bundle.main.bundleIdentifier ?? "loopfollow"
+
+    var rawValue: String {
+        switch self {
+        case .sixMin: "\(Self.prefix).background.alert.6min"
+        case .twelveMin: "\(Self.prefix).background.alert.12min"
+        case .eighteenMin: "\(Self.prefix).background.alert.18min"
+        }
+    }
+
+    static let categoryIdentifier = "\(prefix).background.alert"
 }
 
 class BackgroundAlertManager {
@@ -118,7 +131,7 @@ class BackgroundAlertManager {
         content.title = title
         content.body = body
         content.sound = .defaultCritical
-        content.categoryIdentifier = "loopfollow.background.alert"
+        content.categoryIdentifier = BackgroundAlertIdentifier.categoryIdentifier
         return content
     }
 

+ 10 - 0
LoopFollow/Controllers/Nightscout/BGData.swift

@@ -260,9 +260,19 @@ extension MainViewController {
                 Observable.shared.deltaText.value = "+" + Localizer.toDisplayUnits(String(deltaBG))
             }
 
+            // Live Activity storage
+            Storage.shared.lastBgReadingTimeSeconds.value = lastBGTime
+            Storage.shared.lastDeltaMgdl.value = Double(deltaBG)
+            Storage.shared.lastTrendCode.value = entries[latestEntryIndex].direction
+
             // Mark BG data as loaded for initial loading state
             self.markDataLoaded("bg")
 
+            // Live Activity update
+            #if !targetEnvironment(macCatalyst)
+                LiveActivityManager.shared.refreshFromCurrentState(reason: "bg")
+            #endif
+
             // Update contact
             if Storage.shared.contactEnabled.value {
                 self.contactImageUpdater

+ 11 - 0
LoopFollow/Controllers/Nightscout/DeviceStatus.swift

@@ -46,6 +46,7 @@ extension MainViewController {
 
         if IsNightscoutEnabled(), (now - lastLoopTime) >= nonLoopingTimeThreshold, lastLoopTime > 0 {
             IsNotLooping = true
+            Observable.shared.isNotLooping.value = true
             statusStackView.distribution = .fill
 
             PredictionLabel.isHidden = true
@@ -55,9 +56,13 @@ extension MainViewController {
             LoopStatusLabel.text = "⚠️ Not Looping!"
             LoopStatusLabel.textColor = UIColor.systemYellow
             LoopStatusLabel.font = UIFont.boldSystemFont(ofSize: 18)
+            #if !targetEnvironment(macCatalyst)
+                LiveActivityManager.shared.refreshFromCurrentState(reason: "notLooping")
+            #endif
 
         } else {
             IsNotLooping = false
+            Observable.shared.isNotLooping.value = false
             statusStackView.distribution = .fillEqually
             PredictionLabel.isHidden = false
 
@@ -72,6 +77,9 @@ extension MainViewController {
             case .system:
                 LoopStatusLabel.textColor = UIColor.label
             }
+            #if !targetEnvironment(macCatalyst)
+                LiveActivityManager.shared.refreshFromCurrentState(reason: "loopingResumed")
+            #endif
         }
     }
 
@@ -120,14 +128,17 @@ extension MainViewController {
                 let storedTime = Observable.shared.alertLastLoopTime.value ?? 0
                 if lastPumpTime > storedTime {
                     Observable.shared.alertLastLoopTime.value = lastPumpTime
+                    Storage.shared.lastLoopTime.value = lastPumpTime
                 }
 
                 if let reservoirData = lastPumpRecord["reservoir"] as? Double {
                     latestPumpVolume = reservoirData
                     infoManager.updateInfoData(type: .pump, value: String(format: "%.0f", reservoirData) + "U")
+                    Storage.shared.lastPumpReservoirU.value = reservoirData
                 } else {
                     latestPumpVolume = 50.0
                     infoManager.updateInfoData(type: .pump, value: "50+U")
+                    Storage.shared.lastPumpReservoirU.value = nil
                 }
             }
 

+ 18 - 1
LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift

@@ -30,12 +30,14 @@ extension MainViewController {
             let profileISF = profileManager.currentISF()
             if let profileISF = profileISF {
                 infoManager.updateInfoData(type: .isf, value: profileISF)
+                Storage.shared.lastIsfMgdlPerU.value = profileISF.doubleValue(for: .milligramsPerDeciliter)
             }
 
             // Carb Ratio (CR)
             let profileCR = profileManager.currentCarbRatio()
             if let profileCR = profileCR {
                 infoManager.updateInfoData(type: .carbRatio, value: profileCR)
+                Storage.shared.lastCarbRatio.value = profileCR
             }
 
             // Target
@@ -47,6 +49,8 @@ extension MainViewController {
             } else if let profileTargetLow = profileTargetLow {
                 infoManager.updateInfoData(type: .target, value: profileTargetLow)
             }
+            Storage.shared.lastTargetLowMgdl.value = profileTargetLow?.doubleValue(for: .milligramsPerDeciliter)
+            Storage.shared.lastTargetHighMgdl.value = profileTargetHigh?.doubleValue(for: .milligramsPerDeciliter)
 
             // IOB
             if let insulinMetric = InsulinMetric(from: lastLoopRecord["iob"], key: "iob") {
@@ -63,7 +67,7 @@ extension MainViewController {
 
             if let predictdata = lastLoopRecord["predicted"] as? [String: AnyObject] {
                 let prediction = predictdata["values"] as! [Double]
-                PredictionLabel.text = Localizer.toDisplayUnits(String(Int(prediction.last!)))
+                PredictionLabel.text = Localizer.toDisplayUnits(String(Int(round(prediction.last!))))
                 PredictionLabel.textColor = UIColor.systemPurple
                 if Storage.shared.downloadPrediction.value, previousLastLoopTime < lastLoopTime {
                     predictionData.removeAll()
@@ -88,6 +92,8 @@ extension MainViewController {
                         let formattedMax = Localizer.toDisplayUnits(String(predMax))
                         let value = "\(formattedMin)/\(formattedMax)"
                         infoManager.updateInfoData(type: .minMax, value: value)
+                        Storage.shared.lastMinBgMgdl.value = predMin
+                        Storage.shared.lastMaxBgMgdl.value = predMax
                     }
 
                     updatePredictionGraph()
@@ -120,6 +126,17 @@ extension MainViewController {
                 LoopStatusLabel.text = "↻"
                 latestLoopStatusString = "↻"
             }
+
+            // Live Activity storage
+            Storage.shared.lastIOB.value = latestIOB?.value
+            Storage.shared.lastCOB.value = latestCOB?.value
+            if let predictdata = lastLoopRecord["predicted"] as? [String: AnyObject],
+               let values = predictdata["values"] as? [Double]
+            {
+                Storage.shared.projectedBgMgdl.value = values.last
+            } else {
+                Storage.shared.projectedBgMgdl.value = nil
+            }
         }
     }
 }

+ 22 - 0
LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift

@@ -37,8 +37,10 @@ extension MainViewController {
             }
             if let profileISF = profileISF, let enactedISF = enactedISF, profileISF != enactedISF {
                 infoManager.updateInfoData(type: .isf, firstValue: profileISF, secondValue: enactedISF, separator: .arrow)
+                Storage.shared.lastIsfMgdlPerU.value = enactedISF.doubleValue(for: .milligramsPerDeciliter)
             } else if let profileISF = profileISF {
                 infoManager.updateInfoData(type: .isf, value: profileISF)
+                Storage.shared.lastIsfMgdlPerU.value = profileISF.doubleValue(for: .milligramsPerDeciliter)
             }
 
             // Carb Ratio (CR)
@@ -57,8 +59,10 @@ extension MainViewController {
 
             if let profileCR = profileCR, let enactedCR = enactedCR, profileCR != enactedCR {
                 infoManager.updateInfoData(type: .carbRatio, value: profileCR, enactedValue: enactedCR, separator: .arrow)
+                Storage.shared.lastCarbRatio.value = enactedCR
             } else if let profileCR = profileCR {
                 infoManager.updateInfoData(type: .carbRatio, value: profileCR)
+                Storage.shared.lastCarbRatio.value = profileCR
             }
 
             // IOB
@@ -99,6 +103,7 @@ extension MainViewController {
             if let sens = enactedOrSuggested["sensitivityRatio"] as? Double {
                 let formattedSens = String(format: "%.0f", sens * 100.0) + "%"
                 infoManager.updateInfoData(type: .autosens, value: formattedSens)
+                Storage.shared.lastAutosens.value = sens
             }
 
             // Recommended Bolus
@@ -113,6 +118,9 @@ extension MainViewController {
             if let eventualBGValue = enactedOrSuggested["eventualBG"] as? Double {
                 let eventualBGQuantity = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: eventualBGValue)
                 PredictionLabel.text = Localizer.formatQuantity(eventualBGQuantity)
+                Storage.shared.projectedBgMgdl.value = eventualBGValue
+            } else {
+                Storage.shared.projectedBgMgdl.value = nil
             }
 
             // Target
@@ -137,11 +145,19 @@ extension MainViewController {
                 } else {
                     infoManager.updateInfoData(type: .target, value: profileTargetHigh)
                 }
+                let effectiveMgdl = enactedTarget.doubleValue(for: .milligramsPerDeciliter)
+                Storage.shared.lastTargetLowMgdl.value = effectiveMgdl
+                Storage.shared.lastTargetHighMgdl.value = effectiveMgdl
+            } else if let profileTargetHigh = profileTargetHigh {
+                let profileMgdl = profileTargetHigh.doubleValue(for: .milligramsPerDeciliter)
+                Storage.shared.lastTargetLowMgdl.value = profileMgdl
+                Storage.shared.lastTargetHighMgdl.value = profileMgdl
             }
 
             // TDD
             if let tddMetric = InsulinMetric(from: enactedOrSuggested, key: "TDD") {
                 infoManager.updateInfoData(type: .tdd, value: tddMetric)
+                Storage.shared.lastTdd.value = tddMetric.value
             }
 
             let predBGsData: [String: AnyObject]? = {
@@ -202,6 +218,8 @@ extension MainViewController {
                 if minPredBG != Double.infinity, maxPredBG != -Double.infinity {
                     let value = "\(Localizer.toDisplayUnits(String(minPredBG)))/\(Localizer.toDisplayUnits(String(maxPredBG)))"
                     infoManager.updateInfoData(type: .minMax, value: value)
+                    Storage.shared.lastMinBgMgdl.value = minPredBG
+                    Storage.shared.lastMaxBgMgdl.value = maxPredBG
                 } else {
                     infoManager.updateInfoData(type: .minMax, value: "N/A")
                 }
@@ -225,6 +243,10 @@ extension MainViewController {
                 LoopStatusLabel.text = "↻"
                 latestLoopStatusString = "↻"
             }
+
+            // Live Activity storage
+            Storage.shared.lastIOB.value = latestIOB?.value
+            Storage.shared.lastCOB.value = latestCOB?.value
         }
     }
 }

+ 1 - 0
LoopFollow/Controllers/Nightscout/IAge.swift

@@ -45,6 +45,7 @@ extension MainViewController {
                                    .withColonSeparatorInTime]
 
         if let iageTime = formatter.date(from: (lastIageString as! String))?.timeIntervalSince1970 {
+            Storage.shared.iageInsertTime.value = iageTime
             let now = dateTimeUtils.getNowTimeIntervalUTC()
             let secondsAgo = now - iageTime
 

+ 1 - 0
LoopFollow/Controllers/Nightscout/Profile.swift

@@ -23,6 +23,7 @@ extension MainViewController {
         }
         profileManager.loadProfile(from: profileData)
         infoManager.updateInfoData(type: .profile, value: profileData.defaultProfile)
+        Storage.shared.lastProfileName.value = profileData.defaultProfile
 
         // Mark profile data as loaded for initial loading state
         markDataLoaded("profile")

+ 1 - 0
LoopFollow/Controllers/Nightscout/Treatments/Basals.swift

@@ -147,5 +147,6 @@ extension MainViewController {
             latestBasal = "\(profileBasal) → \(latestBasal)"
         }
         infoManager.updateInfoData(type: .basal, value: latestBasal)
+        Storage.shared.lastBasal.value = latestBasal
     }
 }

+ 1 - 0
LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift

@@ -82,5 +82,6 @@ extension MainViewController {
 
         let resultString = String(format: "%.0f", totalCarbs)
         infoManager.updateInfoData(type: .carbsToday, value: resultString)
+        Storage.shared.lastCarbsToday.value = totalCarbs
     }
 }

+ 96 - 0
LoopFollow/Helpers/BackgroundRefreshManager.swift

@@ -0,0 +1,96 @@
+// LoopFollow
+// BackgroundRefreshManager.swift
+
+import BackgroundTasks
+import UIKit
+
+class BackgroundRefreshManager {
+    static let shared = BackgroundRefreshManager()
+    private init() {}
+
+    private let taskIdentifier = "\(Bundle.main.bundleIdentifier ?? "com.loopfollow").audiorefresh"
+
+    func register() {
+        BGTaskScheduler.shared.register(forTaskWithIdentifier: taskIdentifier, using: nil) { task in
+            guard let refreshTask = task as? BGAppRefreshTask else { return }
+            self.handleRefreshTask(refreshTask)
+        }
+    }
+
+    private func handleRefreshTask(_ task: BGAppRefreshTask) {
+        LogManager.shared.log(category: .taskScheduler, message: "BGAppRefreshTask fired")
+
+        // Guard against double setTaskCompleted if expiration fires while the
+        // main-queue block is in-flight (Apple documents this as a programming error).
+        var completed = false
+
+        task.expirationHandler = {
+            guard !completed else { return }
+            completed = true
+            LogManager.shared.log(category: .taskScheduler, message: "BGAppRefreshTask expired")
+            task.setTaskCompleted(success: false)
+            self.scheduleRefresh()
+        }
+
+        DispatchQueue.main.async {
+            guard !completed else { return }
+            completed = true
+            if let mainVC = self.getMainViewController() {
+                if !mainVC.backgroundTask.player.isPlaying {
+                    LogManager.shared.log(category: .taskScheduler, message: "audio dead, attempting restart")
+                    mainVC.backgroundTask.stopBackgroundTask()
+                    mainVC.backgroundTask.startBackgroundTask()
+                    LogManager.shared.log(category: .taskScheduler, message: "audio restart initiated")
+                } else {
+                    LogManager.shared.log(category: .taskScheduler, message: "audio alive, no action needed", isDebug: true)
+                }
+            }
+            self.scheduleRefresh()
+            task.setTaskCompleted(success: true)
+        }
+    }
+
+    func scheduleRefresh() {
+        let request = BGAppRefreshTaskRequest(identifier: taskIdentifier)
+        request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)
+        do {
+            try BGTaskScheduler.shared.submit(request)
+        } catch {
+            LogManager.shared.log(category: .taskScheduler, message: "Failed to schedule BGAppRefreshTask: \(error)")
+        }
+    }
+
+    private func getMainViewController() -> MainViewController? {
+        guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
+              let window = windowScene.windows.first,
+              let rootVC = window.rootViewController
+        else {
+            return nil
+        }
+
+        if let mainVC = rootVC as? MainViewController {
+            return mainVC
+        }
+
+        if let navVC = rootVC as? UINavigationController,
+           let mainVC = navVC.viewControllers.first as? MainViewController
+        {
+            return mainVC
+        }
+
+        if let tabVC = rootVC as? UITabBarController {
+            for vc in tabVC.viewControllers ?? [] {
+                if let mainVC = vc as? MainViewController {
+                    return mainVC
+                }
+                if let navVC = vc as? UINavigationController,
+                   let mainVC = navVC.viewControllers.first as? MainViewController
+                {
+                    return mainVC
+                }
+            }
+        }
+
+        return nil
+    }
+}

+ 53 - 12
LoopFollow/Helpers/BackgroundTaskAudio.swift

@@ -7,12 +7,16 @@ class BackgroundTask {
     // MARK: - Vars
 
     var player = AVAudioPlayer()
-    var timer = Timer()
+
+    private var retryCount = 0
+    private let maxRetries = 3
 
     // MARK: - Methods
 
     func startBackgroundTask() {
+        NotificationCenter.default.removeObserver(self, name: AVAudioSession.interruptionNotification, object: nil)
         NotificationCenter.default.addObserver(self, selector: #selector(interruptedAudio), name: AVAudioSession.interruptionNotification, object: AVAudioSession.sharedInstance())
+        retryCount = 0
         playAudio()
     }
 
@@ -22,21 +26,43 @@ class BackgroundTask {
         LogManager.shared.log(category: .general, message: "Silent audio stopped", isDebug: true)
     }
 
-    @objc fileprivate func interruptedAudio(_ notification: Notification) {
-        LogManager.shared.log(category: .general, message: "Silent audio interrupted")
-        if notification.name == AVAudioSession.interruptionNotification, notification.userInfo != nil {
-            var info = notification.userInfo!
-            var intValue = 0
-            (info[AVAudioSessionInterruptionTypeKey]! as AnyObject).getValue(&intValue)
-            if intValue == 1 { playAudio() }
+    @objc private func interruptedAudio(_ notification: Notification) {
+        guard notification.name == AVAudioSession.interruptionNotification,
+              let userInfo = notification.userInfo,
+              let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
+              let type = AVAudioSession.InterruptionType(rawValue: typeValue)
+        else { return }
+
+        switch type {
+        case .began:
+            LogManager.shared.log(category: .general, message: "[LA] Silent audio session interrupted (began)")
+
+        case .ended:
+            if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt {
+                let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
+                if !options.contains(.shouldResume) {
+                    LogManager.shared.log(category: .general, message: "[LA] Silent audio interruption ended — shouldResume not set, attempting restart anyway")
+                }
+            }
+            LogManager.shared.log(category: .general, message: "[LA] Silent audio interruption ended — scheduling restart in 0.5s")
+            retryCount = 0
+            // Brief delay to let the interrupting app (e.g. Clock alarm) fully release the audio
+            // session before we attempt to reactivate. Without this, setActive(true) races with
+            // the alarm and fails with AVAudioSession.ErrorCode.cannotInterruptOthers (560557684).
+            DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
+                self?.playAudio()
+            }
+
+        @unknown default:
+            break
         }
     }
 
-    fileprivate func playAudio() {
+    private func playAudio() {
+        let attemptDesc = retryCount == 0 ? "initial attempt" : "retry \(retryCount)/\(maxRetries)"
         do {
             let bundle = Bundle.main.path(forResource: "blank", ofType: "wav")
             let alertSound = URL(fileURLWithPath: bundle!)
-            // try AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback)
             try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: .mixWithOthers)
             try AVAudioSession.sharedInstance().setActive(true)
             try player = AVAudioPlayer(contentsOf: alertSound)
@@ -45,9 +71,24 @@ class BackgroundTask {
             player.volume = 0.01
             player.prepareToPlay()
             player.play()
-            LogManager.shared.log(category: .general, message: "Silent audio playing", isDebug: true)
+            retryCount = 0
+            LogManager.shared.log(category: .general, message: "Silent audio playing (\(attemptDesc))", isDebug: true)
         } catch {
-            LogManager.shared.log(category: .general, message: "playAudio, error: \(error)")
+            LogManager.shared.log(category: .general, message: "playAudio failed (\(attemptDesc)), error: \(error)")
+            if retryCount < maxRetries {
+                retryCount += 1
+                LogManager.shared.log(category: .general, message: "playAudio scheduling retry \(retryCount)/\(maxRetries) in 2s")
+                DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in
+                    self?.playAudio()
+                }
+            } else {
+                LogManager.shared.log(category: .general, message: "playAudio failed after \(maxRetries) retries — posting BackgroundAudioFailed")
+                NotificationCenter.default.post(name: .backgroundAudioFailed, object: nil)
+            }
         }
     }
 }
+
+extension Notification.Name {
+    static let backgroundAudioFailed = Notification.Name("BackgroundAudioFailed")
+}

+ 5 - 1
LoopFollow/Helpers/GlucoseConversion.swift

@@ -4,6 +4,10 @@
 import Foundation
 
 enum GlucoseConversion {
-    static let mgDlToMmolL: Double = 0.0555
     static let mmolToMgDl: Double = 18.01559
+    static let mgDlToMmolL: Double = 1.0 / mmolToMgDl
+
+    static func toMmol(_ mgdl: Double) -> Double {
+        mgdl * mgDlToMmolL
+    }
 }

+ 90 - 26
LoopFollow/Helpers/JWTManager.swift

@@ -1,42 +1,51 @@
 // LoopFollow
 // JWTManager.swift
 
+import CryptoKit
 import Foundation
-import SwiftJWT
-
-struct JWTClaims: Claims {
-    let iss: String
-    let iat: Date
-}
 
 class JWTManager {
     static let shared = JWTManager()
 
+    private struct CachedToken {
+        let jwt: String
+        let expiresAt: Date
+    }
+
+    /// Cache keyed by "keyId:teamId", 55 min TTL
+    private var cache: [String: CachedToken] = [:]
+    private let ttl: TimeInterval = 55 * 60
+    private let lock = NSLock()
+
     private init() {}
 
     func getOrGenerateJWT(keyId: String, teamId: String, apnsKey: String) -> String? {
-        // 1. Check for a valid, non-expired JWT directly from Storage.shared
-        if let jwt = Storage.shared.cachedJWT.value,
-           let expiration = Storage.shared.jwtExpirationDate.value,
-           Date() < expiration
-        {
-            return jwt
-        }
+        let cacheKey = "\(keyId):\(teamId)"
 
-        // 2. If no valid JWT is found, generate a new one
-        let header = Header(kid: keyId)
-        let claims = JWTClaims(iss: teamId, iat: Date())
-        var jwt = JWT(header: header, claims: claims)
+        lock.lock()
+        defer { lock.unlock() }
+
+        if let cached = cache[cacheKey], Date() < cached.expiresAt {
+            return cached.jwt
+        }
 
         do {
-            let privateKey = Data(apnsKey.utf8)
-            let jwtSigner = JWTSigner.es256(privateKey: privateKey)
-            let signedJWT = try jwt.sign(using: jwtSigner)
+            let privateKey = try loadPrivateKey(from: apnsKey)
+            let header = try encodeHeader(keyId: keyId)
+            let payload = try encodePayload(teamId: teamId)
+            let signingInput = "\(header).\(payload)"
+
+            guard let signingData = signingInput.data(using: .utf8) else {
+                LogManager.shared.log(category: .apns, message: "Failed to encode JWT signing input")
+                return nil
+            }
 
-            // 3. Save the new JWT and its expiration date directly to Storage.shared
-            Storage.shared.cachedJWT.value = signedJWT
-            Storage.shared.jwtExpirationDate.value = Date().addingTimeInterval(3600) // Expires in 1 hour
+            let signature = try privateKey.signature(for: signingData)
+            let signatureBase64 = base64URLEncode(signature.rawRepresentation)
+            let signedJWT = "\(signingInput).\(signatureBase64)"
 
+            cache[cacheKey] = CachedToken(jwt: signedJWT, expiresAt: Date().addingTimeInterval(ttl))
+            LogManager.shared.log(category: .apns, message: "JWT generated for key \(keyId) (TTL 55 min)")
             return signedJWT
         } catch {
             LogManager.shared.log(category: .apns, message: "Failed to sign JWT: \(error.localizedDescription)")
@@ -44,9 +53,64 @@ class JWTManager {
         }
     }
 
-    // Invalidate the cache by clearing values in Storage.shared
     func invalidateCache() {
-        Storage.shared.cachedJWT.value = nil
-        Storage.shared.jwtExpirationDate.value = nil
+        lock.lock()
+        defer { lock.unlock() }
+        cache.removeAll()
+        LogManager.shared.log(category: .apns, message: "JWT cache invalidated")
+    }
+
+    // MARK: - Private Helpers
+
+    private func loadPrivateKey(from apnsKey: String) throws -> P256.Signing.PrivateKey {
+        let cleaned = apnsKey
+            .replacingOccurrences(of: "-----BEGIN PRIVATE KEY-----", with: "")
+            .replacingOccurrences(of: "-----END PRIVATE KEY-----", with: "")
+            .replacingOccurrences(of: "\n", with: "")
+            .replacingOccurrences(of: "\r", with: "")
+            .trimmingCharacters(in: .whitespaces)
+
+        guard let keyData = Data(base64Encoded: cleaned) else {
+            throw JWTError.keyDecodingFailed
+        }
+
+        return try P256.Signing.PrivateKey(derRepresentation: keyData)
+    }
+
+    private func encodeHeader(keyId: String) throws -> String {
+        let header: [String: String] = [
+            "alg": "ES256",
+            "kid": keyId,
+        ]
+        let data = try JSONSerialization.data(withJSONObject: header)
+        return base64URLEncode(data)
+    }
+
+    private func encodePayload(teamId: String) throws -> String {
+        let now = Int(Date().timeIntervalSince1970)
+        let payload: [String: Any] = [
+            "iss": teamId,
+            "iat": now,
+        ]
+        let data = try JSONSerialization.data(withJSONObject: payload)
+        return base64URLEncode(data)
+    }
+
+    private func base64URLEncode(_ data: Data) -> String {
+        data.base64EncodedString()
+            .replacingOccurrences(of: "+", with: "-")
+            .replacingOccurrences(of: "/", with: "_")
+            .replacingOccurrences(of: "=", with: "")
+    }
+
+    private enum JWTError: Error, LocalizedError {
+        case keyDecodingFailed
+
+        var errorDescription: String? {
+            switch self {
+            case .keyDecodingFailed:
+                return "Failed to decode APNs p8 key content. Ensure it is valid base64."
+            }
+        }
     }
 }

+ 7 - 1
LoopFollow/Info.plist

@@ -7,6 +7,7 @@
 	<key>BGTaskSchedulerPermittedIdentifiers</key>
 	<array>
 		<string>com.$(unique_id).LoopFollow$(app_suffix)</string>
+		<string>com.$(unique_id).LoopFollow$(app_suffix).audiorefresh</string>
 	</array>
 	<key>CFBundleDevelopmentRegion</key>
 	<string>$(DEVELOPMENT_LANGUAGE)</string>
@@ -16,6 +17,8 @@
 	<string>$(EXECUTABLE_NAME)</string>
 	<key>CFBundleGetInfoString</key>
 	<string></string>
+	<key>CFBundleIconFile</key>
+	<string> Activities</string>
 	<key>CFBundleIdentifier</key>
 	<string>com.$(unique_id).LoopFollow$(app_suffix)</string>
 	<key>CFBundleInfoDictionaryVersion</key>
@@ -31,7 +34,7 @@
 		<dict>
 			<key>CFBundleURLSchemes</key>
 			<array>
-				<string>loopfollow</string>
+				<string>loopfollow$(app_suffix)</string>
 			</array>
 		</dict>
 	</array>
@@ -61,6 +64,8 @@
 	<string>This app requires Face ID for secure authentication.</string>
 	<key>NSHumanReadableCopyright</key>
 	<string></string>
+	<key>NSSupportsLiveActivities</key>
+	<true/>
 	<key>UIApplicationSceneManifest</key>
 	<dict>
 		<key>UIApplicationSupportsMultipleScenes</key>
@@ -83,6 +88,7 @@
 	<key>UIBackgroundModes</key>
 	<array>
 		<string>audio</string>
+		<string>fetch</string>
 		<string>processing</string>
 		<string>bluetooth-central</string>
 		<string>remote-notification</string>

+ 161 - 0
LoopFollow/LiveActivity/APNSClient.swift

@@ -0,0 +1,161 @@
+// LoopFollow
+// APNSClient.swift
+
+// swiftformat:disable indent
+#if !targetEnvironment(macCatalyst)
+
+import Foundation
+
+class APNSClient {
+    static let shared = APNSClient()
+    private init() {}
+
+    // MARK: - Configuration
+
+    private let bundleID = Bundle.main.bundleIdentifier ?? "com.apple.unknown"
+
+    private var apnsHost: String {
+        let isProduction = BuildDetails.default.isTestFlightBuild()
+        return isProduction
+            ? "https://api.push.apple.com"
+            : "https://api.sandbox.push.apple.com"
+    }
+
+    private var lfKeyId: String {
+        Storage.shared.lfKeyId.value
+    }
+
+    private var lfTeamId: String {
+        BuildDetails.default.teamID ?? ""
+    }
+
+    private var lfApnsKey: String {
+        Storage.shared.lfApnsKey.value
+    }
+
+    // MARK: - Send Live Activity Update
+
+    func sendLiveActivityUpdate(
+        pushToken: String,
+        state: GlucoseLiveActivityAttributes.ContentState,
+    ) async {
+        guard let jwt = JWTManager.shared.getOrGenerateJWT(keyId: lfKeyId, teamId: lfTeamId, apnsKey: lfApnsKey) else {
+            LogManager.shared.log(category: .apns, message: "APNs failed to generate JWT for Live Activity push")
+            return
+        }
+
+        let payload = buildPayload(state: state)
+
+        guard let url = URL(string: "\(apnsHost)/3/device/\(pushToken)") else {
+            LogManager.shared.log(category: .apns, message: "APNs invalid URL", isDebug: true)
+            return
+        }
+
+        var request = URLRequest(url: url)
+        request.httpMethod = "POST"
+        request.setValue("bearer \(jwt)", forHTTPHeaderField: "authorization")
+        request.setValue("application/json", forHTTPHeaderField: "content-type")
+        request.setValue("\(bundleID).push-type.liveactivity", forHTTPHeaderField: "apns-topic")
+        request.setValue("liveactivity", forHTTPHeaderField: "apns-push-type")
+        request.setValue("10", forHTTPHeaderField: "apns-priority")
+        request.httpBody = payload
+
+        do {
+            let (data, response) = try await URLSession.shared.data(for: request)
+
+            if let httpResponse = response as? HTTPURLResponse {
+                switch httpResponse.statusCode {
+                case 200:
+                    LogManager.shared.log(category: .apns, message: "APNs push sent successfully", isDebug: true)
+
+                case 400:
+                    let responseBody = String(data: data, encoding: .utf8) ?? "empty"
+                    LogManager.shared.log(category: .apns, message: "APNs bad request (400) — malformed payload: \(responseBody)")
+
+                case 403:
+                    // JWT rejected — force regenerate on next push
+                    JWTManager.shared.invalidateCache()
+                    LogManager.shared.log(category: .apns, message: "APNs JWT rejected (403) — token cache cleared, will regenerate")
+
+                case 404, 410:
+                    // Activity token not found or expired — end and restart on next refresh
+                    let reason = httpResponse.statusCode == 410 ? "expired (410)" : "not found (404)"
+                    LogManager.shared.log(category: .apns, message: "APNs token \(reason) — restarting Live Activity")
+                    LiveActivityManager.shared.handleExpiredToken()
+
+                case 429:
+                    LogManager.shared.log(category: .apns, message: "APNs rate limited (429) — will retry on next refresh")
+
+                case 500 ... 599:
+                    let responseBody = String(data: data, encoding: .utf8) ?? "empty"
+                    LogManager.shared.log(category: .apns, message: "APNs server error (\(httpResponse.statusCode)) — will retry on next refresh: \(responseBody)")
+
+                default:
+                    let responseBody = String(data: data, encoding: .utf8) ?? "empty"
+                    LogManager.shared.log(category: .apns, message: "APNs push failed status=\(httpResponse.statusCode) body=\(responseBody)")
+                }
+            }
+
+        } catch {
+            LogManager.shared.log(category: .apns, message: "APNs error: \(error.localizedDescription)")
+        }
+    }
+
+    // MARK: - Payload Builder
+
+    private func buildPayload(state: GlucoseLiveActivityAttributes.ContentState) -> Data? {
+        let snapshot = state.snapshot
+
+        var snapshotDict: [String: Any] = [
+            "glucose": snapshot.glucose,
+            "delta": snapshot.delta,
+            "trend": snapshot.trend.rawValue,
+            "updatedAt": snapshot.updatedAt.timeIntervalSince1970,
+            "unit": snapshot.unit.rawValue,
+        ]
+
+        snapshotDict["isNotLooping"] = snapshot.isNotLooping
+        snapshotDict["showRenewalOverlay"] = snapshot.showRenewalOverlay
+        if let iob = snapshot.iob { snapshotDict["iob"] = iob }
+        if let cob = snapshot.cob { snapshotDict["cob"] = cob }
+        if let projected = snapshot.projected { snapshotDict["projected"] = projected }
+        if let override = snapshot.override { snapshotDict["override"] = override }
+        if let recBolus = snapshot.recBolus { snapshotDict["recBolus"] = recBolus }
+        if let battery = snapshot.battery { snapshotDict["battery"] = battery }
+        if let pumpBattery = snapshot.pumpBattery { snapshotDict["pumpBattery"] = pumpBattery }
+        if !snapshot.basalRate.isEmpty { snapshotDict["basalRate"] = snapshot.basalRate }
+        if let pumpReservoirU = snapshot.pumpReservoirU { snapshotDict["pumpReservoirU"] = pumpReservoirU }
+        if let autosens = snapshot.autosens { snapshotDict["autosens"] = autosens }
+        if let tdd = snapshot.tdd { snapshotDict["tdd"] = tdd }
+        if let targetLowMgdl = snapshot.targetLowMgdl { snapshotDict["targetLowMgdl"] = targetLowMgdl }
+        if let targetHighMgdl = snapshot.targetHighMgdl { snapshotDict["targetHighMgdl"] = targetHighMgdl }
+        if let isfMgdlPerU = snapshot.isfMgdlPerU { snapshotDict["isfMgdlPerU"] = isfMgdlPerU }
+        if let carbRatio = snapshot.carbRatio { snapshotDict["carbRatio"] = carbRatio }
+        if let carbsToday = snapshot.carbsToday { snapshotDict["carbsToday"] = carbsToday }
+        if let profileName = snapshot.profileName { snapshotDict["profileName"] = profileName }
+        if snapshot.sageInsertTime > 0 { snapshotDict["sageInsertTime"] = snapshot.sageInsertTime }
+        if snapshot.cageInsertTime > 0 { snapshotDict["cageInsertTime"] = snapshot.cageInsertTime }
+        if snapshot.iageInsertTime > 0 { snapshotDict["iageInsertTime"] = snapshot.iageInsertTime }
+        if let minBgMgdl = snapshot.minBgMgdl { snapshotDict["minBgMgdl"] = minBgMgdl }
+        if let maxBgMgdl = snapshot.maxBgMgdl { snapshotDict["maxBgMgdl"] = maxBgMgdl }
+
+        let contentState: [String: Any] = [
+            "snapshot": snapshotDict,
+            "seq": state.seq,
+            "reason": state.reason,
+            "producedAt": state.producedAt.timeIntervalSince1970,
+        ]
+
+        let payload: [String: Any] = [
+            "aps": [
+                "timestamp": Int(Date().timeIntervalSince1970),
+                "event": "update",
+                "content-state": contentState,
+            ],
+        ]
+
+        return try? JSONSerialization.data(withJSONObject: payload)
+    }
+}
+
+#endif

+ 77 - 0
LoopFollow/LiveActivity/AppGroupID.swift

@@ -0,0 +1,77 @@
+// LoopFollow
+// AppGroupID.swift
+
+import Foundation
+
+/// Resolves the App Group identifier in a PR-safe way.
+///
+/// Preferred contract:
+/// - App Group = "group.<baseBundleIdentifier>"
+/// - No team-specific hardcoding
+///
+/// Important nuance:
+/// - Extensions often have a *different* bundle identifier than the main app.
+/// - To keep app + extensions aligned, we:
+///   1) Prefer an explicit base bundle id if provided via Info.plist key.
+///   2) Otherwise, apply a conservative suffix-stripping heuristic.
+///   3) Fall back to the current bundle identifier.
+enum AppGroupID {
+    /// Optional Info.plist key you can set in *both* app + extension targets
+    /// to force a shared base bundle id (recommended for reliability).
+    private static let baseBundleIDPlistKey = "LFAppGroupBaseBundleID"
+
+    /// The base bundle identifier for the main app, with extension suffixes stripped.
+    /// Usable from both the main app and extensions.
+    static var baseBundleID: String {
+        if let base = Bundle.main.object(forInfoDictionaryKey: baseBundleIDPlistKey) as? String,
+           !base.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+        {
+            return base
+        }
+        let bundleID = Bundle.main.bundleIdentifier ?? "unknown"
+        return stripLikelyExtensionSuffixes(from: bundleID)
+    }
+
+    /// URL scheme derived from the bundle identifier. Works across app and extensions.
+    /// Default build: "loopfollow", second: "loopfollow2", third: "loopfollow3", etc.
+    static var urlScheme: String {
+        let base = baseBundleID
+        // Extract the suffix after "LoopFollow" in the bundle ID
+        // e.g. "com.TEAM.LoopFollow2" → "2", "com.TEAM.LoopFollow" → ""
+        if let range = base.range(of: "LoopFollow", options: .backwards) {
+            let suffix = base[range.upperBound...]
+            return "loopfollow\(suffix)"
+        }
+        return "loopfollow"
+    }
+
+    static func current() -> String {
+        "group.\(baseBundleID)"
+    }
+
+    private static func stripLikelyExtensionSuffixes(from bundleID: String) -> String {
+        let knownSuffixes = [
+            ".LiveActivity",
+            ".LiveActivityExtension",
+            ".LoopFollowLAExtension",
+            ".Widget",
+            ".WidgetExtension",
+            ".Widgets",
+            ".WidgetsExtension",
+            ".Watch",
+            ".WatchExtension",
+            ".CarPlay",
+            ".CarPlayExtension",
+            ".Intents",
+            ".IntentsExtension",
+        ]
+
+        for suffix in knownSuffixes {
+            if bundleID.hasSuffix(suffix) {
+                return String(bundleID.dropLast(suffix.count))
+            }
+        }
+
+        return bundleID
+    }
+}

+ 50 - 0
LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift

@@ -0,0 +1,50 @@
+// LoopFollow
+// GlucoseLiveActivityAttributes.swift
+
+// swiftformat:disable indent
+#if !targetEnvironment(macCatalyst)
+
+import ActivityKit
+import Foundation
+
+struct GlucoseLiveActivityAttributes: ActivityAttributes {
+    struct ContentState: Codable, Hashable {
+        let snapshot: GlucoseSnapshot
+        let seq: Int
+        let reason: String
+        let producedAt: Date
+
+        init(snapshot: GlucoseSnapshot, seq: Int, reason: String, producedAt: Date) {
+            self.snapshot = snapshot
+            self.seq = seq
+            self.reason = reason
+            self.producedAt = producedAt
+        }
+
+        init(from decoder: Decoder) throws {
+            let container = try decoder.container(keyedBy: CodingKeys.self)
+            snapshot = try container.decode(GlucoseSnapshot.self, forKey: .snapshot)
+            seq = try container.decode(Int.self, forKey: .seq)
+            reason = try container.decode(String.self, forKey: .reason)
+            let producedAtInterval = try container.decode(Double.self, forKey: .producedAt)
+            producedAt = Date(timeIntervalSince1970: producedAtInterval)
+        }
+
+        func encode(to encoder: Encoder) throws {
+            var container = encoder.container(keyedBy: CodingKeys.self)
+            try container.encode(snapshot, forKey: .snapshot)
+            try container.encode(seq, forKey: .seq)
+            try container.encode(reason, forKey: .reason)
+            try container.encode(producedAt.timeIntervalSince1970, forKey: .producedAt)
+        }
+
+        private enum CodingKeys: String, CodingKey {
+            case snapshot, seq, reason, producedAt
+        }
+    }
+
+    /// Reserved for future metadata. Keep minimal for stability.
+    let title: String
+}
+
+#endif

+ 331 - 0
LoopFollow/LiveActivity/GlucoseSnapshot.swift

@@ -0,0 +1,331 @@
+// LoopFollow
+// GlucoseSnapshot.swift
+
+import Foundation
+
+/// Canonical, source-agnostic glucose state used by
+/// Live Activity, future Watch complication, and CarPlay.
+///
+struct GlucoseSnapshot: Codable, Equatable, Hashable {
+    // MARK: - Units
+
+    enum Unit: String, Codable, Hashable {
+        case mgdl
+        case mmol
+
+        /// Human-readable display string for the unit (e.g. "mg/dL" or "mmol/L").
+        var displayName: String {
+            switch self {
+            case .mgdl: return "mg/dL"
+            case .mmol: return "mmol/L"
+            }
+        }
+    }
+
+    // MARK: - Core Glucose
+
+    /// Glucose value in mg/dL (canonical internal unit).
+    let glucose: Double
+
+    /// Delta in mg/dL. May be 0.0 if unchanged.
+    let delta: Double
+
+    /// Trend direction (mapped from LoopFollow state).
+    let trend: Trend
+
+    /// Timestamp of reading.
+    let updatedAt: Date
+
+    // MARK: - Secondary Metrics
+
+    /// Insulin On Board
+    let iob: Double?
+
+    /// Carbs On Board
+    let cob: Double?
+
+    /// Projected glucose in mg/dL (if available)
+    let projected: Double?
+
+    // MARK: - Extended InfoType Metrics
+
+    /// Active override name (nil if no active override)
+    let override: String?
+
+    /// Recommended bolus in units (nil if not available)
+    let recBolus: Double?
+
+    /// CGM/uploader device battery % (nil if not available)
+    let battery: Double?
+
+    /// Pump battery % (nil if not available)
+    let pumpBattery: Double?
+
+    /// Formatted current basal rate string (empty if not available)
+    let basalRate: String
+
+    /// Pump reservoir in units (nil if >50U or unknown)
+    let pumpReservoirU: Double?
+
+    /// Autosensitivity ratio, e.g. 0.9 = 90% (nil if not available)
+    let autosens: Double?
+
+    /// Total daily dose in units (nil if not available)
+    let tdd: Double?
+
+    /// BG target low in mg/dL (nil if not available)
+    let targetLowMgdl: Double?
+
+    /// BG target high in mg/dL (nil if not available)
+    let targetHighMgdl: Double?
+
+    /// Insulin Sensitivity Factor in mg/dL per unit (nil if not available)
+    let isfMgdlPerU: Double?
+
+    /// Carb ratio in g per unit (nil if not available)
+    let carbRatio: Double?
+
+    /// Total carbs entered today in grams (nil if not available)
+    let carbsToday: Double?
+
+    /// Active profile name (nil if not available)
+    let profileName: String?
+
+    /// Sensor insert time as Unix epoch seconds UTC (0 = not set)
+    let sageInsertTime: TimeInterval
+
+    /// Cannula insert time as Unix epoch seconds UTC (0 = not set)
+    let cageInsertTime: TimeInterval
+
+    /// Insulin/pod insert time as Unix epoch seconds UTC (0 = not set)
+    let iageInsertTime: TimeInterval
+
+    /// Min predicted BG in mg/dL (nil if not available)
+    let minBgMgdl: Double?
+
+    /// Max predicted BG in mg/dL (nil if not available)
+    let maxBgMgdl: Double?
+
+    // MARK: - Unit Context
+
+    /// User's preferred display unit. Values are always stored in mg/dL;
+    /// this tells the display layer which unit to render.
+    let unit: Unit
+
+    // MARK: - Loop Status
+
+    /// True when LoopFollow detects the loop has not reported in 15+ minutes (Nightscout only).
+    let isNotLooping: Bool
+
+    // MARK: - Renewal
+
+    /// True when the Live Activity is within renewalWarning seconds of its renewal deadline.
+    /// The extension renders a "Tap to update" overlay so the user knows renewal is imminent.
+    let showRenewalOverlay: Bool
+
+    // MARK: - Init
+
+    init(
+        glucose: Double,
+        delta: Double,
+        trend: Trend,
+        updatedAt: Date,
+        iob: Double?,
+        cob: Double?,
+        projected: Double?,
+        override: String? = nil,
+        recBolus: Double? = nil,
+        battery: Double? = nil,
+        pumpBattery: Double? = nil,
+        basalRate: String = "",
+        pumpReservoirU: Double? = nil,
+        autosens: Double? = nil,
+        tdd: Double? = nil,
+        targetLowMgdl: Double? = nil,
+        targetHighMgdl: Double? = nil,
+        isfMgdlPerU: Double? = nil,
+        carbRatio: Double? = nil,
+        carbsToday: Double? = nil,
+        profileName: String? = nil,
+        sageInsertTime: TimeInterval = 0,
+        cageInsertTime: TimeInterval = 0,
+        iageInsertTime: TimeInterval = 0,
+        minBgMgdl: Double? = nil,
+        maxBgMgdl: Double? = nil,
+        unit: Unit,
+        isNotLooping: Bool,
+        showRenewalOverlay: Bool = false,
+    ) {
+        self.glucose = glucose
+        self.delta = delta
+        self.trend = trend
+        self.updatedAt = updatedAt
+        self.iob = iob
+        self.cob = cob
+        self.projected = projected
+        self.override = override
+        self.recBolus = recBolus
+        self.battery = battery
+        self.pumpBattery = pumpBattery
+        self.basalRate = basalRate
+        self.pumpReservoirU = pumpReservoirU
+        self.autosens = autosens
+        self.tdd = tdd
+        self.targetLowMgdl = targetLowMgdl
+        self.targetHighMgdl = targetHighMgdl
+        self.isfMgdlPerU = isfMgdlPerU
+        self.carbRatio = carbRatio
+        self.carbsToday = carbsToday
+        self.profileName = profileName
+        self.sageInsertTime = sageInsertTime
+        self.cageInsertTime = cageInsertTime
+        self.iageInsertTime = iageInsertTime
+        self.minBgMgdl = minBgMgdl
+        self.maxBgMgdl = maxBgMgdl
+        self.unit = unit
+        self.isNotLooping = isNotLooping
+        self.showRenewalOverlay = showRenewalOverlay
+    }
+
+    // MARK: - Derived Convenience
+
+    /// Age of reading in seconds.
+    var age: TimeInterval {
+        Date().timeIntervalSince(updatedAt)
+    }
+
+    /// Returns a copy of this snapshot with `showRenewalOverlay` set to the given value.
+    /// All other fields are preserved exactly. Use this instead of manually copying
+    /// every field when only the overlay flag needs to change.
+    func withRenewalOverlay(_ value: Bool) -> GlucoseSnapshot {
+        GlucoseSnapshot(
+            glucose: glucose,
+            delta: delta,
+            trend: trend,
+            updatedAt: updatedAt,
+            iob: iob,
+            cob: cob,
+            projected: projected,
+            override: override,
+            recBolus: recBolus,
+            battery: battery,
+            pumpBattery: pumpBattery,
+            basalRate: basalRate,
+            pumpReservoirU: pumpReservoirU,
+            autosens: autosens,
+            tdd: tdd,
+            targetLowMgdl: targetLowMgdl,
+            targetHighMgdl: targetHighMgdl,
+            isfMgdlPerU: isfMgdlPerU,
+            carbRatio: carbRatio,
+            carbsToday: carbsToday,
+            profileName: profileName,
+            sageInsertTime: sageInsertTime,
+            cageInsertTime: cageInsertTime,
+            iageInsertTime: iageInsertTime,
+            minBgMgdl: minBgMgdl,
+            maxBgMgdl: maxBgMgdl,
+            unit: unit,
+            isNotLooping: isNotLooping,
+            showRenewalOverlay: value,
+        )
+    }
+
+    // MARK: - Codable
+
+    func encode(to encoder: Encoder) throws {
+        var container = encoder.container(keyedBy: CodingKeys.self)
+        try container.encode(glucose, forKey: .glucose)
+        try container.encode(delta, forKey: .delta)
+        try container.encode(trend, forKey: .trend)
+        try container.encode(updatedAt.timeIntervalSince1970, forKey: .updatedAt)
+        try container.encodeIfPresent(iob, forKey: .iob)
+        try container.encodeIfPresent(cob, forKey: .cob)
+        try container.encodeIfPresent(projected, forKey: .projected)
+        try container.encodeIfPresent(override, forKey: .override)
+        try container.encodeIfPresent(recBolus, forKey: .recBolus)
+        try container.encodeIfPresent(battery, forKey: .battery)
+        try container.encodeIfPresent(pumpBattery, forKey: .pumpBattery)
+        try container.encode(basalRate, forKey: .basalRate)
+        try container.encodeIfPresent(pumpReservoirU, forKey: .pumpReservoirU)
+        try container.encodeIfPresent(autosens, forKey: .autosens)
+        try container.encodeIfPresent(tdd, forKey: .tdd)
+        try container.encodeIfPresent(targetLowMgdl, forKey: .targetLowMgdl)
+        try container.encodeIfPresent(targetHighMgdl, forKey: .targetHighMgdl)
+        try container.encodeIfPresent(isfMgdlPerU, forKey: .isfMgdlPerU)
+        try container.encodeIfPresent(carbRatio, forKey: .carbRatio)
+        try container.encodeIfPresent(carbsToday, forKey: .carbsToday)
+        try container.encodeIfPresent(profileName, forKey: .profileName)
+        try container.encode(sageInsertTime, forKey: .sageInsertTime)
+        try container.encode(cageInsertTime, forKey: .cageInsertTime)
+        try container.encode(iageInsertTime, forKey: .iageInsertTime)
+        try container.encodeIfPresent(minBgMgdl, forKey: .minBgMgdl)
+        try container.encodeIfPresent(maxBgMgdl, forKey: .maxBgMgdl)
+        try container.encode(unit, forKey: .unit)
+        try container.encode(isNotLooping, forKey: .isNotLooping)
+        try container.encode(showRenewalOverlay, forKey: .showRenewalOverlay)
+    }
+
+    init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+        glucose = try container.decode(Double.self, forKey: .glucose)
+        delta = try container.decode(Double.self, forKey: .delta)
+        trend = try container.decode(Trend.self, forKey: .trend)
+        updatedAt = try Date(timeIntervalSince1970: container.decode(Double.self, forKey: .updatedAt))
+        iob = try container.decodeIfPresent(Double.self, forKey: .iob)
+        cob = try container.decodeIfPresent(Double.self, forKey: .cob)
+        projected = try container.decodeIfPresent(Double.self, forKey: .projected)
+        override = try container.decodeIfPresent(String.self, forKey: .override)
+        recBolus = try container.decodeIfPresent(Double.self, forKey: .recBolus)
+        battery = try container.decodeIfPresent(Double.self, forKey: .battery)
+        pumpBattery = try container.decodeIfPresent(Double.self, forKey: .pumpBattery)
+        basalRate = try container.decodeIfPresent(String.self, forKey: .basalRate) ?? ""
+        pumpReservoirU = try container.decodeIfPresent(Double.self, forKey: .pumpReservoirU)
+        autosens = try container.decodeIfPresent(Double.self, forKey: .autosens)
+        tdd = try container.decodeIfPresent(Double.self, forKey: .tdd)
+        targetLowMgdl = try container.decodeIfPresent(Double.self, forKey: .targetLowMgdl)
+        targetHighMgdl = try container.decodeIfPresent(Double.self, forKey: .targetHighMgdl)
+        isfMgdlPerU = try container.decodeIfPresent(Double.self, forKey: .isfMgdlPerU)
+        carbRatio = try container.decodeIfPresent(Double.self, forKey: .carbRatio)
+        carbsToday = try container.decodeIfPresent(Double.self, forKey: .carbsToday)
+        profileName = try container.decodeIfPresent(String.self, forKey: .profileName)
+        sageInsertTime = try container.decodeIfPresent(Double.self, forKey: .sageInsertTime) ?? 0
+        cageInsertTime = try container.decodeIfPresent(Double.self, forKey: .cageInsertTime) ?? 0
+        iageInsertTime = try container.decodeIfPresent(Double.self, forKey: .iageInsertTime) ?? 0
+        minBgMgdl = try container.decodeIfPresent(Double.self, forKey: .minBgMgdl)
+        maxBgMgdl = try container.decodeIfPresent(Double.self, forKey: .maxBgMgdl)
+        unit = try container.decode(Unit.self, forKey: .unit)
+        isNotLooping = try container.decodeIfPresent(Bool.self, forKey: .isNotLooping) ?? false
+        showRenewalOverlay = try container.decodeIfPresent(Bool.self, forKey: .showRenewalOverlay) ?? false
+    }
+
+    private enum CodingKeys: String, CodingKey {
+        case glucose, delta, trend, updatedAt
+        case iob, cob, projected
+        case override, recBolus, battery, pumpBattery, basalRate, pumpReservoirU
+        case autosens, tdd, targetLowMgdl, targetHighMgdl, isfMgdlPerU, carbRatio, carbsToday
+        case profileName, sageInsertTime, cageInsertTime, iageInsertTime, minBgMgdl, maxBgMgdl
+        case unit, isNotLooping, showRenewalOverlay
+    }
+}
+
+// MARK: - Trend
+
+extension GlucoseSnapshot {
+    enum Trend: String, Codable, Hashable {
+        case up
+        case upSlight
+        case upFast
+        case flat
+        case down
+        case downSlight
+        case downFast
+        case unknown
+
+        init(from decoder: Decoder) throws {
+            let container = try decoder.singleValueContainer()
+            let raw = try container.decode(String.self)
+            self = Trend(rawValue: raw) ?? .unknown
+        }
+    }
+}

+ 202 - 0
LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift

@@ -0,0 +1,202 @@
+// LoopFollow
+// GlucoseSnapshotBuilder.swift
+
+import Foundation
+
+/// Provides the latest glucose-relevant values from LoopFollow's single source of truth.
+/// Intentionally provider-agnostic (Nightscout vs Dexcom doesn't matter).
+protocol CurrentGlucoseStateProviding {
+    // MARK: - Core Glucose
+
+    /// Canonical glucose value in mg/dL.
+    var glucoseMgdl: Double? { get }
+
+    /// Canonical delta in mg/dL.
+    var deltaMgdl: Double? { get }
+
+    /// Canonical projected glucose in mg/dL.
+    var projectedMgdl: Double? { get }
+
+    /// Timestamp of the last reading.
+    var updatedAt: Date? { get }
+
+    /// Trend string from LoopFollow (mapped to GlucoseSnapshot.Trend by the builder).
+    var trendCode: String? { get }
+
+    // MARK: - Secondary Metrics
+
+    var iob: Double? { get }
+    var cob: Double? { get }
+
+    // MARK: - Extended Metrics
+
+    /// Active override name (nil if no active override).
+    var override: String? { get }
+
+    /// Recommended bolus in units.
+    var recBolus: Double? { get }
+
+    /// CGM/uploader device battery %.
+    var battery: Double? { get }
+
+    /// Pump battery %.
+    var pumpBattery: Double? { get }
+
+    /// Formatted current basal rate string (empty if not available).
+    var basalRate: String { get }
+
+    /// Pump reservoir in units (nil if >50U or unknown).
+    var pumpReservoirU: Double? { get }
+
+    /// Autosensitivity ratio, e.g. 0.9 = 90%.
+    var autosens: Double? { get }
+
+    /// Total daily dose in units.
+    var tdd: Double? { get }
+
+    /// BG target low in mg/dL.
+    var targetLowMgdl: Double? { get }
+
+    /// BG target high in mg/dL.
+    var targetHighMgdl: Double? { get }
+
+    /// Insulin Sensitivity Factor in mg/dL per unit.
+    var isfMgdlPerU: Double? { get }
+
+    /// Carb ratio in g per unit.
+    var carbRatio: Double? { get }
+
+    /// Total carbs entered today in grams.
+    var carbsToday: Double? { get }
+
+    /// Active profile name.
+    var profileName: String? { get }
+
+    /// Sensor insert time as Unix epoch seconds UTC (0 = not set).
+    var sageInsertTime: TimeInterval { get }
+
+    /// Cannula insert time as Unix epoch seconds UTC (0 = not set).
+    var cageInsertTime: TimeInterval { get }
+
+    /// Insulin/pod insert time as Unix epoch seconds UTC (0 = not set).
+    var iageInsertTime: TimeInterval { get }
+
+    /// Min predicted BG in mg/dL.
+    var minBgMgdl: Double? { get }
+
+    /// Max predicted BG in mg/dL.
+    var maxBgMgdl: Double? { get }
+
+    // MARK: - Loop Status
+
+    /// True when LoopFollow detects the loop has not reported in 15+ minutes.
+    var isNotLooping: Bool { get }
+
+    // MARK: - Renewal
+
+    /// True when the Live Activity is within renewalWarning seconds of its deadline.
+    var showRenewalOverlay: Bool { get }
+}
+
+// MARK: - Builder
+
+/// Pure transformation layer. Reads exclusively from the provider — no direct
+/// Storage.shared or Observable.shared access. This makes it testable and reusable
+/// across Live Activity, Watch, and CarPlay.
+enum GlucoseSnapshotBuilder {
+    static func build(from provider: CurrentGlucoseStateProviding) -> GlucoseSnapshot? {
+        guard
+            let glucoseMgdl = provider.glucoseMgdl,
+            glucoseMgdl > 0,
+            let updatedAt = provider.updatedAt
+        else {
+            LogManager.shared.log(
+                category: .general,
+                message: "GlucoseSnapshotBuilder: missing/invalid core values glucoseMgdl=\(provider.glucoseMgdl?.description ?? "nil") updatedAt=\(provider.updatedAt?.description ?? "nil")",
+                isDebug: true,
+            )
+            return nil
+        }
+
+        let preferredUnit = PreferredGlucoseUnit.snapshotUnit()
+        let deltaMgdl = provider.deltaMgdl ?? 0.0
+        let trend = mapTrend(provider.trendCode)
+
+        if provider.showRenewalOverlay {
+            LogManager.shared.log(category: .general, message: "[LA] renewal overlay ON")
+        }
+
+        LogManager.shared.log(
+            category: .general,
+            message: "LA snapshot built: updatedAt=\(updatedAt) interval=\(updatedAt.timeIntervalSince1970)",
+            isDebug: true,
+        )
+
+        return GlucoseSnapshot(
+            glucose: glucoseMgdl,
+            delta: deltaMgdl,
+            trend: trend,
+            updatedAt: updatedAt,
+            iob: provider.iob,
+            cob: provider.cob,
+            projected: provider.projectedMgdl,
+            override: provider.override,
+            recBolus: provider.recBolus,
+            battery: provider.battery,
+            pumpBattery: provider.pumpBattery,
+            basalRate: provider.basalRate,
+            pumpReservoirU: provider.pumpReservoirU,
+            autosens: provider.autosens,
+            tdd: provider.tdd,
+            targetLowMgdl: provider.targetLowMgdl,
+            targetHighMgdl: provider.targetHighMgdl,
+            isfMgdlPerU: provider.isfMgdlPerU,
+            carbRatio: provider.carbRatio,
+            carbsToday: provider.carbsToday,
+            profileName: provider.profileName,
+            sageInsertTime: provider.sageInsertTime,
+            cageInsertTime: provider.cageInsertTime,
+            iageInsertTime: provider.iageInsertTime,
+            minBgMgdl: provider.minBgMgdl,
+            maxBgMgdl: provider.maxBgMgdl,
+            unit: preferredUnit,
+            isNotLooping: provider.isNotLooping,
+            showRenewalOverlay: provider.showRenewalOverlay,
+        )
+    }
+
+    // MARK: - Trend Mapping
+
+    private static func mapTrend(_ code: String?) -> GlucoseSnapshot.Trend {
+        guard
+            let raw = code?
+            .trimmingCharacters(in: .whitespacesAndNewlines)
+            .lowercased(),
+            !raw.isEmpty
+        else { return .unknown }
+
+        if raw.contains("doubleup") || raw.contains("rapidrise") || raw == "up2" || raw == "upfast" {
+            return .upFast
+        }
+        if raw.contains("fortyfiveup") {
+            return .upSlight
+        }
+        if raw.contains("singleup") || raw == "up" || raw == "up1" || raw == "rising" {
+            return .up
+        }
+        if raw.contains("flat") || raw == "steady" || raw == "none" {
+            return .flat
+        }
+        if raw.contains("doubledown") || raw.contains("rapidfall") || raw == "down2" || raw == "downfast" {
+            return .downFast
+        }
+        if raw.contains("fortyfivedown") {
+            return .downSlight
+        }
+        if raw.contains("singledown") || raw == "down" || raw == "down1" || raw == "falling" {
+            return .down
+        }
+
+        return .unknown
+    }
+}

+ 75 - 0
LoopFollow/LiveActivity/GlucoseSnapshotStore.swift

@@ -0,0 +1,75 @@
+// LoopFollow
+// GlucoseSnapshotStore.swift
+
+import Foundation
+
+/// Persists the latest GlucoseSnapshot into the App Group container so that:
+/// - the Live Activity extension can read it
+/// - future Watch + CarPlay surfaces can reuse it
+///
+/// Uses an atomic JSON file write to avoid partial/corrupt reads across processes.
+final class GlucoseSnapshotStore {
+    static let shared = GlucoseSnapshotStore()
+    private init() {}
+
+    private let fileName = "glucose_snapshot.json"
+    private let queue = DispatchQueue(label: "com.loopfollow.glucoseSnapshotStore", qos: .utility)
+
+    // MARK: - Public API
+
+    func save(_ snapshot: GlucoseSnapshot) {
+        queue.async {
+            do {
+                let url = try self.fileURL()
+                let encoder = JSONEncoder()
+                encoder.dateEncodingStrategy = .iso8601
+                let data = try encoder.encode(snapshot)
+                try data.write(to: url, options: [.atomic])
+            } catch {
+                // Intentionally silent (extension-safe, no dependencies).
+            }
+        }
+    }
+
+    func load() -> GlucoseSnapshot? {
+        do {
+            let url = try fileURL()
+            guard FileManager.default.fileExists(atPath: url.path) else { return nil }
+
+            let data = try Data(contentsOf: url)
+            let decoder = JSONDecoder()
+            decoder.dateDecodingStrategy = .iso8601
+            return try decoder.decode(GlucoseSnapshot.self, from: data)
+        } catch {
+            // Intentionally silent (extension-safe, no dependencies).
+            return nil
+        }
+    }
+
+    func delete() {
+        queue.async {
+            do {
+                let url = try self.fileURL()
+                if FileManager.default.fileExists(atPath: url.path) {
+                    try FileManager.default.removeItem(at: url)
+                }
+            } catch {
+                // Intentionally silent (extension-safe, no dependencies).
+            }
+        }
+    }
+
+    // MARK: - Helpers
+
+    private func fileURL() throws -> URL {
+        let groupID = AppGroupID.current()
+        guard let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: groupID) else {
+            throw NSError(
+                domain: "GlucoseSnapshotStore",
+                code: 1,
+                userInfo: [NSLocalizedDescriptionKey: "App Group containerURL is nil for id=\(groupID)"],
+            )
+        }
+        return containerURL.appendingPathComponent(fileName, isDirectory: false)
+    }
+}

+ 223 - 0
LoopFollow/LiveActivity/LAAppGroupSettings.swift

@@ -0,0 +1,223 @@
+// LoopFollow
+// LAAppGroupSettings.swift
+
+import Foundation
+
+// MARK: - Slot option enum
+
+/// One displayable metric that can occupy a slot in the Live Activity 2×2 grid.
+///
+/// - `.none` is the empty/blank state — leaves the slot visually empty.
+/// - Optional cases (isOptional == true) may display "—" for Dexcom-only users
+///   whose setup does not provide that metric.
+/// - All values are read from GlucoseSnapshot at render time inside the widget
+///   extension; no additional App Group reads are required per slot.
+enum LiveActivitySlotOption: String, CaseIterable, Codable {
+    // Core glucose
+    case none
+    case delta
+    case projectedBG
+    case minMax
+    // Loop metrics
+    case iob
+    case cob
+    case recBolus
+    case autosens
+    case tdd
+    // Pump / device
+    case basal
+    case pump
+    case pumpBattery
+    case battery
+    case target
+    case isf
+    case carbRatio
+    // Ages
+    case sage
+    case cage
+    case iage
+    // Other
+    case carbsToday
+    case override
+    case profile
+
+    /// Human-readable label shown in the slot picker in Settings.
+    var displayName: String {
+        switch self {
+        case .none: "Empty"
+        case .delta: "Delta"
+        case .projectedBG: "Projected BG"
+        case .minMax: "Min/Max"
+        case .iob: "IOB"
+        case .cob: "COB"
+        case .recBolus: "Rec. Bolus"
+        case .autosens: "Autosens"
+        case .tdd: "TDD"
+        case .basal: "Basal"
+        case .pump: "Pump"
+        case .pumpBattery: "Pump Battery"
+        case .battery: "Battery"
+        case .target: "Target"
+        case .isf: "ISF"
+        case .carbRatio: "CR"
+        case .sage: "SAGE"
+        case .cage: "CAGE"
+        case .iage: "IAGE"
+        case .carbsToday: "Carbs today"
+        case .override: "Override"
+        case .profile: "Profile"
+        }
+    }
+
+    /// Short label used inside the MetricBlock on the Live Activity card.
+    var gridLabel: String {
+        switch self {
+        case .none: ""
+        case .delta: "Delta"
+        case .projectedBG: "Proj"
+        case .minMax: "Min/Max"
+        case .iob: "IOB"
+        case .cob: "COB"
+        case .recBolus: "Rec."
+        case .autosens: "Sens"
+        case .tdd: "TDD"
+        case .basal: "Basal"
+        case .pump: "Pump"
+        case .pumpBattery: "Pump%"
+        case .battery: "Bat."
+        case .target: "Target"
+        case .isf: "ISF"
+        case .carbRatio: "CR"
+        case .sage: "SAGE"
+        case .cage: "CAGE"
+        case .iage: "IAGE"
+        case .carbsToday: "Carbs"
+        case .override: "Ovrd"
+        case .profile: "Prof"
+        }
+    }
+
+    /// True when the value is a glucose measurement and should be followed by
+    /// the user's preferred unit label (mg/dL or mmol/L) in compact displays.
+    var isGlucoseUnit: Bool {
+        switch self {
+        case .projectedBG, .delta, .minMax, .target, .isf: return true
+        default: return false
+        }
+    }
+
+    /// True when the underlying value may be nil (e.g. Dexcom-only users who have
+    /// no Loop data). The widget renders "—" in those cases.
+    var isOptional: Bool {
+        switch self {
+        case .none, .delta: false
+        default: true
+        }
+    }
+}
+
+// MARK: - Default slot assignments
+
+enum LiveActivitySlotDefaults {
+    /// Top-left slot
+    static let slot1: LiveActivitySlotOption = .iob
+    /// Bottom-left slot
+    static let slot2: LiveActivitySlotOption = .cob
+    /// Top-right slot
+    static let slot3: LiveActivitySlotOption = .projectedBG
+    /// Bottom-right slot — intentionally empty until the user configures it
+    static let slot4: LiveActivitySlotOption = .none
+    /// Small widget (CarPlay / Watch Smart Stack) right slot
+    static let smallWidgetSlot: LiveActivitySlotOption = .projectedBG
+
+    static var all: [LiveActivitySlotOption] {
+        [slot1, slot2, slot3, slot4]
+    }
+}
+
+// MARK: - App Group settings
+
+/// Minimal App Group settings needed by the Live Activity UI.
+///
+/// We keep this separate from Storage.shared to avoid target-coupling and
+/// ensure the widget extension reads the same values as the app.
+enum LAAppGroupSettings {
+    private enum Keys {
+        static let lowLineMgdl = "la.lowLine.mgdl"
+        static let highLineMgdl = "la.highLine.mgdl"
+        static let slots = "la.slots"
+        static let smallWidgetSlot = "la.smallWidgetSlot"
+        static let displayName = "la.displayName"
+        static let showDisplayName = "la.showDisplayName"
+    }
+
+    private static var defaults: UserDefaults? {
+        UserDefaults(suiteName: AppGroupID.current())
+    }
+
+    // MARK: - Thresholds (Write)
+
+    static func setThresholds(lowMgdl: Double, highMgdl: Double) {
+        defaults?.set(lowMgdl, forKey: Keys.lowLineMgdl)
+        defaults?.set(highMgdl, forKey: Keys.highLineMgdl)
+    }
+
+    // MARK: - Thresholds (Read)
+
+    static func thresholdsMgdl(fallbackLow: Double = 70, fallbackHigh: Double = 180) -> (low: Double, high: Double) {
+        let low = defaults?.object(forKey: Keys.lowLineMgdl) as? Double ?? fallbackLow
+        let high = defaults?.object(forKey: Keys.highLineMgdl) as? Double ?? fallbackHigh
+        return (low, high)
+    }
+
+    // MARK: - Slot configuration (Write)
+
+    /// Persists a 4-slot configuration to the App Group container.
+    /// - Parameter slots: Array of exactly 4 `LiveActivitySlotOption` values;
+    ///   extra elements are ignored, missing elements are filled with `.none`.
+    static func setSlots(_ slots: [LiveActivitySlotOption]) {
+        let raw = slots.prefix(4).map(\.rawValue)
+        defaults?.set(raw, forKey: Keys.slots)
+    }
+
+    // MARK: - Slot configuration (Read)
+
+    /// Returns the current 4-slot configuration, falling back to defaults
+    /// if no configuration has been saved yet.
+    static func slots() -> [LiveActivitySlotOption] {
+        guard let raw = defaults?.stringArray(forKey: Keys.slots), raw.count == 4 else {
+            return LiveActivitySlotDefaults.all
+        }
+        return raw.map { LiveActivitySlotOption(rawValue: $0) ?? .none }
+    }
+
+    // MARK: - Small widget slot (Write)
+
+    static func setSmallWidgetSlot(_ slot: LiveActivitySlotOption) {
+        defaults?.set(slot.rawValue, forKey: Keys.smallWidgetSlot)
+    }
+
+    // MARK: - Small widget slot (Read)
+
+    static func smallWidgetSlot() -> LiveActivitySlotOption {
+        guard let raw = defaults?.string(forKey: Keys.smallWidgetSlot) else {
+            return LiveActivitySlotDefaults.smallWidgetSlot
+        }
+        return LiveActivitySlotOption(rawValue: raw) ?? LiveActivitySlotDefaults.smallWidgetSlot
+    }
+
+    // MARK: - Display Name
+
+    static func setDisplayName(_ name: String, show: Bool) {
+        defaults?.set(name, forKey: Keys.displayName)
+        defaults?.set(show, forKey: Keys.showDisplayName)
+    }
+
+    static func displayName() -> String {
+        defaults?.string(forKey: Keys.displayName) ?? "LoopFollow"
+    }
+
+    static func showDisplayName() -> Bool {
+        defaults?.bool(forKey: Keys.showDisplayName) ?? false
+    }
+}

+ 27 - 0
LoopFollow/LiveActivity/LALivenessMarker.swift

@@ -0,0 +1,27 @@
+//
+//  LALivenessMarker.swift
+//  LoopFollow
+//
+//  Created by Philippe Achkar on 2026-04-01.
+//  Copyright © 2026 Jon Fawcett. All rights reserved.
+//
+
+
+import SwiftUI
+
+struct LALivenessMarker: View {
+    let seq: Int
+    let producedAt: Date
+
+    var body: some View {
+        Color.clear
+            .frame(width: 0, height: 0)
+            .task(id: markerID) {
+                LALivenessStore.markExtensionRender(seq: seq, producedAt: producedAt)
+            }
+    }
+
+    private var markerID: String {
+        "\(seq)-\(producedAt.timeIntervalSince1970)"
+    }
+}

+ 44 - 0
LoopFollow/LiveActivity/LALivenessStore.swift

@@ -0,0 +1,44 @@
+//
+//  LALivenessStore.swift
+//  LoopFollow
+//
+//  Created by Philippe Achkar on 2026-04-01.
+//  Copyright © 2026 Jon Fawcett. All rights reserved.
+//
+
+
+import Foundation
+
+enum LALivenessStore {
+    private static let defaults = UserDefaults(suiteName: AppGroupID.baseBundleID)
+
+    private enum Key {
+        static let lastExtensionSeenAt = "la.liveness.lastExtensionSeenAt"
+        static let lastExtensionSeq = "la.liveness.lastExtensionSeq"
+        static let lastExtensionProducedAt = "la.liveness.lastExtensionProducedAt"
+    }
+
+    static func markExtensionRender(seq: Int, producedAt: Date) {
+        defaults?.set(Date().timeIntervalSince1970, forKey: Key.lastExtensionSeenAt)
+        defaults?.set(seq, forKey: Key.lastExtensionSeq)
+        defaults?.set(producedAt.timeIntervalSince1970, forKey: Key.lastExtensionProducedAt)
+    }
+
+    static var lastExtensionSeenAt: TimeInterval {
+        defaults?.double(forKey: Key.lastExtensionSeenAt) ?? 0
+    }
+
+    static var lastExtensionSeq: Int {
+        defaults?.integer(forKey: Key.lastExtensionSeq) ?? 0
+    }
+
+    static var lastExtensionProducedAt: TimeInterval {
+        defaults?.double(forKey: Key.lastExtensionProducedAt) ?? 0
+    }
+
+    static func clear() {
+        defaults?.removeObject(forKey: Key.lastExtensionSeenAt)
+        defaults?.removeObject(forKey: Key.lastExtensionSeq)
+        defaults?.removeObject(forKey: Key.lastExtensionProducedAt)
+    }
+}

+ 789 - 0
LoopFollow/LiveActivity/LiveActivityManager.swift

@@ -0,0 +1,789 @@
+// LoopFollow
+// LiveActivityManager.swift
+
+// swiftformat:disable indent
+#if !targetEnvironment(macCatalyst)
+
+@preconcurrency import ActivityKit
+import Foundation
+import os
+import UIKit
+import UserNotifications
+
+// Live Activity manager for LoopFollow.
+
+final class LiveActivityManager {
+    static let shared = LiveActivityManager()
+    private init() {
+        NotificationCenter.default.addObserver(
+            self,
+            selector: #selector(handleForeground),
+            name: UIApplication.willEnterForegroundNotification,
+            object: nil,
+        )
+        NotificationCenter.default.addObserver(
+            self,
+            selector: #selector(handleDidBecomeActive),
+            name: UIApplication.didBecomeActiveNotification,
+            object: nil,
+        )
+        NotificationCenter.default.addObserver(
+            self,
+            selector: #selector(handleWillResignActive),
+            name: UIApplication.willResignActiveNotification,
+            object: nil,
+        )
+        NotificationCenter.default.addObserver(
+            self,
+            selector: #selector(handleBackgroundAudioFailed),
+            name: .backgroundAudioFailed,
+            object: nil,
+        )
+    }
+
+    /// Fires before the app loses focus (lock screen, home button, etc.).
+    /// Cancels any pending debounced refresh and pushes the latest snapshot
+    /// directly to the Live Activity while the app is still foreground-active,
+    /// ensuring the LA is up to date the moment the lock screen appears.
+    @objc private func handleWillResignActive() {
+        guard Storage.shared.laEnabled.value, let activity = current else { return }
+
+        refreshWorkItem?.cancel()
+        refreshWorkItem = nil
+
+        let provider = StorageCurrentGlucoseStateProvider()
+        guard let snapshot = GlucoseSnapshotBuilder.build(from: provider) else { return }
+
+        LAAppGroupSettings.setThresholds(
+            lowMgdl: Storage.shared.lowLine.value,
+            highMgdl: Storage.shared.highLine.value,
+        )
+        GlucoseSnapshotStore.shared.save(snapshot)
+
+        seq += 1
+        let nextSeq = seq
+        let state = GlucoseLiveActivityAttributes.ContentState(
+            snapshot: snapshot,
+            seq: nextSeq,
+            reason: "resign-active",
+            producedAt: Date(),
+        )
+        let content = ActivityContent(
+            state: state,
+            staleDate: Date(timeIntervalSince1970: Storage.shared.laRenewBy.value),
+            relevanceScore: 100.0,
+        )
+
+        Task {
+            // Direct ActivityKit update — app is still active at this point.
+            await activity.update(content)
+            LogManager.shared.log(category: .general, message: "[LA] resign-active flush sent seq=\(nextSeq)", isDebug: true)
+            // Also send APNs so the extension receives the latest token-based update.
+            if let token = pushToken {
+                await APNSClient.shared.sendLiveActivityUpdate(pushToken: token, state: state)
+            }
+        }
+    }
+
+    @objc private func handleDidBecomeActive() {
+        guard Storage.shared.laEnabled.value else { return }
+        if skipNextDidBecomeActive {
+            LogManager.shared.log(category: .general, message: "[LA] didBecomeActive: skipped (handleForeground owns restart)", isDebug: true)
+            skipNextDidBecomeActive = false
+            return
+        }
+        LogManager.shared.log(category: .general, message: "[LA] didBecomeActive: calling startFromCurrentState, dismissedByUser=\(dismissedByUser)", isDebug: true)
+        Task { @MainActor in
+            self.startFromCurrentState()
+        }
+    }
+
+    @objc private func handleForeground() {
+        guard Storage.shared.laEnabled.value else { return }
+
+        let renewalFailed = Storage.shared.laRenewalFailed.value
+        let renewBy = Storage.shared.laRenewBy.value
+        let now = Date().timeIntervalSince1970
+        let overlayIsShowing = renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning
+
+        LogManager.shared.log(
+            category: .general,
+            message: "[LA] foreground: renewalFailed=\(renewalFailed), overlayShowing=\(overlayIsShowing), current=\(current?.id ?? "nil"), dismissedByUser=\(dismissedByUser), renewBy=\(renewBy), now=\(now)"
+        )
+
+        guard renewalFailed || overlayIsShowing else {
+            LogManager.shared.log(category: .general, message: "[LA] foreground: no action needed (not in renewal window)")
+            return
+        }
+
+        LogManager.shared.log(
+            category: .general,
+            message: "[LA] ending stale LA and restarting (renewalFailed=\(renewalFailed), overlayShowing=\(overlayIsShowing))"
+        )
+
+        skipNextDidBecomeActive = true
+
+        // Mark restart intent BEFORE clearing storage flags, so any late .dismissed
+        // from the old activity is never misclassified as a user swipe.
+        endingForRestart = true
+        dismissedByUser = false
+
+        // Stop any observers/tasks tied to the previous activity instance. In the
+        // current=nil branch below, the old observer can otherwise deliver a late
+        // .dismissed and poison dismissedByUser.
+        updateTask?.cancel()
+        updateTask = nil
+        tokenObservationTask?.cancel()
+        tokenObservationTask = nil
+        stateObserverTask?.cancel()
+        stateObserverTask = nil
+        pushToken = nil
+
+        // Clear renewal state so the new snapshot does not show the renewal overlay.
+        Storage.shared.laRenewBy.value = 0
+        Storage.shared.laRenewalFailed.value = false
+        cancelRenewalFailedNotification()
+
+        guard let activity = current else {
+            LogManager.shared.log(
+                category: .general,
+                message: "[LA] foreground restart: current=nil (old activity not bound locally), ending all existing LAs before restart"
+            )
+            current = nil
+        
+            Task {
+                for activity in Activity<GlucoseLiveActivityAttributes>.activities {
+                    await activity.end(nil, dismissalPolicy: .immediate)
+                }
+                await MainActor.run {
+                    self.dismissedByUser = false
+                    self.startFromCurrentState(cleanupOrphans: false)
+                    LogManager.shared.log(
+                        category: .general,
+                        message: "[LA] foreground restart: fresh LA started after ending unbound existing activity"
+                    )
+                }
+            }
+            return
+        }
+
+        current = nil
+
+        Task {
+            await activity.end(nil, dismissalPolicy: .immediate)
+            await MainActor.run {
+                self.dismissedByUser = false
+                self.startFromCurrentState(cleanupOrphans: false)
+                LogManager.shared.log(category: .general, message: "[LA] Live Activity restarted after foreground retry")
+            }
+        }
+    }
+    
+    @objc private func handleBackgroundAudioFailed() {
+        guard Storage.shared.laEnabled.value, current != nil else { return }
+        // The background audio session has permanently failed — the app will lose its
+        // background keep-alive. Immediately push the renewal overlay so the user sees
+        // "Tap to update" on the lock screen and knows to foreground the app.
+        LogManager.shared.log(category: .general, message: "[LA] background audio failed — forcing renewal overlay")
+        Storage.shared.laRenewBy.value = Date().timeIntervalSince1970
+        refreshFromCurrentState(reason: "audio-session-failed")
+    }
+    
+    private func shouldRestartBecauseExtensionLooksStuck() -> Bool {
+        guard Storage.shared.laEnabled.value else { return false }
+        guard !dismissedByUser else { return false }
+
+        guard let activity = current ?? Activity<GlucoseLiveActivityAttributes>.activities.first else {
+            return false
+        }
+
+        let now = Date().timeIntervalSince1970
+        let staleDatePassed = activity.content.staleDate.map { $0 <= Date() } ?? false
+        if staleDatePassed {
+            LogManager.shared.log(
+                category: .general,
+                message: "[LA] liveness check: staleDate already passed"
+            )
+            return true
+        }
+
+        let expectedSeq = activity.content.state.seq
+        let seenSeq = LALivenessStore.lastExtensionSeq
+        let lastSeenAt = LALivenessStore.lastExtensionSeenAt
+        let lastProducedAt = LALivenessStore.lastExtensionProducedAt
+
+        let extensionHasNeverCheckedIn = lastSeenAt <= 0
+        let extensionLooksBehind = seenSeq < expectedSeq
+        let noRecentExtensionTouch = extensionHasNeverCheckedIn || (now - lastSeenAt > LiveActivityManager.extensionLivenessGrace)
+
+        LogManager.shared.log(
+            category: .general,
+            message: "[LA] liveness check: expectedSeq=\(expectedSeq), seenSeq=\(seenSeq), lastSeenAt=\(lastSeenAt), lastProducedAt=\(lastProducedAt), behind=\(extensionLooksBehind), noRecentTouch=\(noRecentExtensionTouch)",
+            isDebug: true
+        )
+
+        // Conservative rule:
+        // only suspect "stuck" if the extension is both behind AND has not checked in recently.
+        return extensionLooksBehind && noRecentExtensionTouch
+    }
+
+    static let renewalThreshold: TimeInterval = 7.5 * 3600
+    static let renewalWarning: TimeInterval = 30 * 60
+    static let extensionLivenessGrace: TimeInterval = 15 * 60
+
+    private(set) var current: Activity<GlucoseLiveActivityAttributes>?
+    private var stateObserverTask: Task<Void, Never>?
+    private var updateTask: Task<Void, Never>?
+    private var seq: Int = 0
+    private var lastUpdateTime: Date?
+    private var pushToken: String?
+    private var tokenObservationTask: Task<Void, Never>?
+    private var refreshWorkItem: DispatchWorkItem?
+    /// Set when the user manually swipes away the LA. Blocks auto-restart until
+    /// an explicit user action (Restart button, App Intent) clears it.
+    /// In-memory only — resets to false on app relaunch, so a kill + relaunch
+    /// starts fresh as expected.
+    private var dismissedByUser = false
+    /// Set to true immediately before we call activity.end() as part of a planned restart.
+    /// Cleared after the restart completes. The state observer checks this flag so that
+    /// a .dismissed delivery triggered by our own end() call is never misclassified as a
+    /// user swipe — regardless of the order in which the MainActor executes the two writes.
+    private var endingForRestart = false
+    /// Set by handleForeground() when it takes ownership of the restart sequence.
+    /// Prevents handleDidBecomeActive() from racing with an in-flight end+restart.
+    private var skipNextDidBecomeActive = false
+
+    // MARK: - Public API
+
+    func startIfNeeded() {
+        guard ActivityAuthorizationInfo().areActivitiesEnabled else {
+            LogManager.shared.log(category: .general, message: "Live Activity not authorized")
+            return
+        }
+
+        if let existing = Activity<GlucoseLiveActivityAttributes>.activities.first {
+            // Before reusing, check whether this activity needs a restart. This covers cold
+            // starts (app was killed while the overlay was showing — willEnterForeground is
+            // never sent, so handleForeground never runs) and any other path that lands here
+            // without first going through handleForeground.
+            let renewBy = Storage.shared.laRenewBy.value
+            let now = Date().timeIntervalSince1970
+            let staleDatePassed = existing.content.staleDate.map { $0 <= Date() } ?? false
+            let inRenewalWindow = renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning
+            let needsRestart = Storage.shared.laRenewalFailed.value || inRenewalWindow || staleDatePassed
+
+            if needsRestart {
+                LogManager.shared.log(
+                    category: .general,
+                    message: "[LA] existing activity is stale on startIfNeeded — ending and restarting (staleDatePassed=\(staleDatePassed), inRenewalWindow=\(inRenewalWindow))"
+                )
+
+                endingForRestart = true
+                dismissedByUser = false
+
+                Storage.shared.laRenewBy.value = 0
+                Storage.shared.laRenewalFailed.value = false
+                cancelRenewalFailedNotification()
+
+                Task {
+                    await existing.end(nil, dismissalPolicy: .immediate)
+                    await MainActor.run { self.startIfNeeded() }
+                }
+                return
+            }
+
+            bind(to: existing, logReason: "reuse")
+            Storage.shared.laRenewalFailed.value = false
+            return
+        }
+
+        do {
+            let attributes = GlucoseLiveActivityAttributes(title: "LoopFollow")
+
+            // Prefer a freshly built snapshot so all extended fields are populated.
+            // Fall back to the persisted store (covers cold-start with real data),
+            // then to a zero seed (true first-ever launch with no data yet).
+            let provider = StorageCurrentGlucoseStateProvider()
+            let seedSnapshot = GlucoseSnapshotBuilder.build(from: provider)
+                ?? GlucoseSnapshotStore.shared.load()
+                ?? GlucoseSnapshot(
+                    glucose: 0,
+                    delta: 0,
+                    trend: .unknown,
+                    updatedAt: Date(),
+                    iob: nil,
+                    cob: nil,
+                    projected: nil,
+                    unit: .mgdl,
+                    isNotLooping: false,
+                )
+
+            let initialState = GlucoseLiveActivityAttributes.ContentState(
+                snapshot: seedSnapshot,
+                seq: 0,
+                reason: "start",
+                producedAt: Date(),
+            )
+
+            let renewDeadline = Date().addingTimeInterval(LiveActivityManager.renewalThreshold)
+            let content = ActivityContent(state: initialState, staleDate: renewDeadline)
+            LALivenessStore.clear()
+            let activity = try Activity.request(attributes: attributes, content: content, pushType: .token)
+
+            bind(to: activity, logReason: "start-new")
+            Storage.shared.laRenewBy.value = renewDeadline.timeIntervalSince1970
+            Storage.shared.laRenewalFailed.value = false
+            LogManager.shared.log(category: .general, message: "Live Activity started id=\(activity.id)")
+        } catch {
+            LogManager.shared.log(category: .general, message: "Live Activity failed to start: \(error)")
+        }
+    }
+
+    /// Called from applicationWillTerminate. Ends the LA synchronously (blocking
+    /// up to 3 s) so it clears from the lock screen before the process exits.
+    /// Does not clear laEnabled — the user's preference is preserved for relaunch.
+    func endOnTerminate() {
+        guard let activity = current else { return }
+        current = nil
+        Storage.shared.laRenewBy.value = 0
+        LALivenessStore.clear()
+        let semaphore = DispatchSemaphore(value: 0)
+        Task.detached {
+            await activity.end(nil, dismissalPolicy: .immediate)
+            semaphore.signal()
+        }
+        _ = semaphore.wait(timeout: .now() + 3)
+        LogManager.shared.log(category: .general, message: "[LA] ended on app terminate")
+    }
+
+    func end(dismissalPolicy: ActivityUIDismissalPolicy = .default) {
+        updateTask?.cancel()
+        updateTask = nil
+
+        guard let activity = current else { return }
+
+        Task {
+            let finalState = GlucoseLiveActivityAttributes.ContentState(
+                snapshot: GlucoseSnapshotStore.shared.load() ?? GlucoseSnapshot(
+                    glucose: 0,
+                    delta: 0,
+                    trend: .unknown,
+                    updatedAt: Date(),
+                    iob: nil,
+                    cob: nil,
+                    projected: nil,
+                    unit: .mgdl,
+                    isNotLooping: false,
+                ),
+                seq: seq,
+                reason: "end",
+                producedAt: Date(),
+            )
+
+            let content = ActivityContent(state: finalState, staleDate: nil)
+            await activity.end(content, dismissalPolicy: dismissalPolicy)
+
+            LogManager.shared.log(category: .general, message: "Live Activity ended id=\(activity.id)", isDebug: true)
+
+            if current?.id == activity.id {
+                current = nil
+                Storage.shared.laRenewBy.value = 0
+                LALivenessStore.clear()
+            }
+        }
+    }
+
+    /// Ends all running Live Activities and starts a fresh one from the current state.
+    /// Intended for the "Restart Live Activity" button and the AppIntent.
+    @MainActor
+    func forceRestart() {
+        guard Storage.shared.laEnabled.value else { return }
+        LogManager.shared.log(category: .general, message: "[LA] forceRestart called")
+        dismissedByUser = false
+        Storage.shared.laRenewBy.value = 0
+        Storage.shared.laRenewalFailed.value = false
+        LALivenessStore.clear()
+        cancelRenewalFailedNotification()
+        current = nil
+        updateTask?.cancel(); updateTask = nil
+        tokenObservationTask?.cancel(); tokenObservationTask = nil
+        stateObserverTask?.cancel(); stateObserverTask = nil
+        pushToken = nil
+        Task {
+            for activity in Activity<GlucoseLiveActivityAttributes>.activities {
+                await activity.end(nil, dismissalPolicy: .immediate)
+            }
+            await MainActor.run {
+                self.startFromCurrentState(cleanupOrphans: false)
+                LogManager.shared.log(category: .general, message: "[LA] forceRestart: Live Activity restarted")
+            }
+        }
+    }
+
+    func startFromCurrentState(cleanupOrphans: Bool = false) {
+        guard Storage.shared.laEnabled.value, !dismissedByUser else { return }
+
+        if cleanupOrphans {
+            endOrphanedActivities()
+        }
+
+        let provider = StorageCurrentGlucoseStateProvider()
+        if let snapshot = GlucoseSnapshotBuilder.build(from: provider) {
+            LAAppGroupSettings.setThresholds(
+                lowMgdl: Storage.shared.lowLine.value,
+                highMgdl: Storage.shared.highLine.value,
+            )
+            LAAppGroupSettings.setDisplayName(
+                Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? "LoopFollow",
+                show: Storage.shared.showDisplayName.value
+            )
+            GlucoseSnapshotStore.shared.save(snapshot)
+        }
+        startIfNeeded()
+    }
+
+    func refreshFromCurrentState(reason: String) {
+        // No LA guard here — Watch and store must update regardless of LA state.
+        // LA-specific gating (laEnabled, dismissedByUser) is applied inside performRefresh.
+        refreshWorkItem?.cancel()
+        let workItem = DispatchWorkItem { [weak self] in
+            self?.performRefresh(reason: reason)
+        }
+        refreshWorkItem = workItem
+        DispatchQueue.main.asyncAfter(deadline: .now() + 20.0, execute: workItem)
+    }
+
+    // MARK: - Renewal
+
+    /// Requests a fresh Live Activity to replace the current one when the renewal
+    /// deadline has passed, working around Apple's 8-hour maximum LA lifetime.
+    /// The new LA is requested FIRST — the old one is only ended if that succeeds,
+    /// so the user keeps live data if Activity.request() throws.
+    /// Returns true if renewal was performed (caller should return early).
+    private func renewIfNeeded(snapshot: GlucoseSnapshot) -> Bool {
+        guard let oldActivity = current else { return false }
+
+        let renewBy = Storage.shared.laRenewBy.value
+        guard renewBy > 0, Date().timeIntervalSince1970 >= renewBy else { return false }
+
+        let overdueBy = Date().timeIntervalSince1970 - renewBy
+        LogManager.shared.log(category: .general, message: "[LA] renewal deadline passed by \(Int(overdueBy))s, requesting new LA")
+
+        let renewDeadline = Date().addingTimeInterval(LiveActivityManager.renewalThreshold)
+        let attributes = GlucoseLiveActivityAttributes(title: "LoopFollow")
+
+        // Build the fresh snapshot with showRenewalOverlay: false — the new LA has a
+        // fresh deadline so no overlay is needed from the first frame. We pass the
+        // deadline as staleDate to ActivityContent below, not to Storage yet; Storage
+        // is only updated after Activity.request succeeds so a crash between the two
+        // can't leave the deadline permanently stuck in the future.
+        let freshSnapshot = snapshot.withRenewalOverlay(false)
+
+        let state = GlucoseLiveActivityAttributes.ContentState(
+            snapshot: freshSnapshot,
+            seq: seq,
+            reason: "renew",
+            producedAt: Date(),
+        )
+        let content = ActivityContent(state: state, staleDate: renewDeadline)
+
+        do {
+            let newActivity = try Activity.request(attributes: attributes, content: content, pushType: .token)
+
+            Task {
+                await oldActivity.end(nil, dismissalPolicy: .immediate)
+            }
+
+            updateTask?.cancel()
+            updateTask = nil
+            tokenObservationTask?.cancel()
+            tokenObservationTask = nil
+            stateObserverTask?.cancel()
+            stateObserverTask = nil
+            pushToken = nil
+
+            // Write deadline only on success — avoids a stuck future deadline if we crash
+            // between the write and the Activity.request call.
+            Storage.shared.laRenewBy.value = renewDeadline.timeIntervalSince1970
+            bind(to: newActivity, logReason: "renew")
+            Storage.shared.laRenewalFailed.value = false
+            cancelRenewalFailedNotification()
+            GlucoseSnapshotStore.shared.save(freshSnapshot)
+            LogManager.shared.log(category: .general, message: "[LA] Live Activity renewed successfully id=\(newActivity.id)")
+            return true
+        } catch {
+            // Renewal failed — deadline was never written, so no rollback needed.
+            let isFirstFailure = !Storage.shared.laRenewalFailed.value
+            Storage.shared.laRenewalFailed.value = true
+            LogManager.shared.log(category: .general, message: "[LA] renewal failed, keeping existing LA: \(error)")
+            if isFirstFailure {
+                scheduleRenewalFailedNotification()
+            }
+            return false
+        }
+    }
+
+    private func performRefresh(reason: String) {
+        let provider = StorageCurrentGlucoseStateProvider()
+        guard let snapshot = GlucoseSnapshotBuilder.build(from: provider) else {
+            return
+        }
+        LogManager.shared.log(category: .general, message: "[LA] refresh g=\(snapshot.glucose) reason=\(reason)", isDebug: true)
+        let fingerprint =
+            "g=\(snapshot.glucose) d=\(snapshot.delta) t=\(snapshot.trend.rawValue) " +
+            "at=\(snapshot.updatedAt.timeIntervalSince1970) iob=\(snapshot.iob?.description ?? "nil") " +
+            "cob=\(snapshot.cob?.description ?? "nil") proj=\(snapshot.projected?.description ?? "nil") u=\(snapshot.unit.rawValue)"
+        LogManager.shared.log(category: .general, message: "[LA] snapshot \(fingerprint) reason=\(reason)", isDebug: true)
+
+        // Check if the Live Activity is approaching Apple's 8-hour limit and renew if so.
+        if renewIfNeeded(snapshot: snapshot) { return }
+
+        if snapshot.showRenewalOverlay {
+            LogManager.shared.log(category: .general, message: "[LA] sending update with renewal overlay visible")
+        }
+
+        let now = Date()
+        let timeSinceLastUpdate = now.timeIntervalSince(lastUpdateTime ?? .distantPast)
+        let forceRefreshNeeded = timeSinceLastUpdate >= 5 * 60
+        // Capture dedup result BEFORE saving so the store comparison is valid.
+        let snapshotUnchanged = GlucoseSnapshotStore.shared.load() == snapshot
+
+        // Store + Watch: always update, independent of LA state.
+        LAAppGroupSettings.setThresholds(
+            lowMgdl: Storage.shared.lowLine.value,
+            highMgdl: Storage.shared.highLine.value,
+        )
+        GlucoseSnapshotStore.shared.save(snapshot)
+        //WatchConnectivityManager.shared.send(snapshot: snapshot)
+
+        // LA update: gated on LA being active, snapshot having changed, and activities enabled.
+        guard Storage.shared.laEnabled.value, !dismissedByUser else { return }
+        guard !snapshotUnchanged || forceRefreshNeeded else { return }
+        guard ActivityAuthorizationInfo().areActivitiesEnabled else {
+            return
+        }
+        if current == nil, let existing = Activity<GlucoseLiveActivityAttributes>.activities.first {
+            bind(to: existing, logReason: "bind-existing")
+        }
+        if let _ = current {
+            update(snapshot: snapshot, reason: reason)
+            return
+        }
+        if isAppVisibleForLiveActivityStart() {
+            startIfNeeded()
+            if current != nil {
+                update(snapshot: snapshot, reason: reason)
+            }
+        } else {
+            LogManager.shared.log(category: .general, message: "LA start suppressed (not visible) reason=\(reason)", isDebug: true)
+        }
+    }
+
+    private func isAppVisibleForLiveActivityStart() -> Bool {
+        let scenes = UIApplication.shared.connectedScenes
+        return scenes.contains { $0.activationState == .foregroundActive }
+    }
+
+    func update(snapshot: GlucoseSnapshot, reason: String) {
+        if current == nil, let existing = Activity<GlucoseLiveActivityAttributes>.activities.first {
+            bind(to: existing, logReason: "bind-existing")
+        }
+
+        guard let activity = current else { return }
+
+        updateTask?.cancel()
+
+        seq += 1
+        let nextSeq = seq
+        let activityID = activity.id
+
+        let state = GlucoseLiveActivityAttributes.ContentState(
+            snapshot: snapshot,
+            seq: nextSeq,
+            reason: reason,
+            producedAt: Date(),
+        )
+
+        updateTask = Task { [weak self] in
+            guard let self else { return }
+
+            if activity.activityState == .ended || activity.activityState == .dismissed {
+                if current?.id == activityID { current = nil }
+                return
+            }
+
+            let content = ActivityContent(
+                state: state,
+                staleDate: Date(timeIntervalSince1970: Storage.shared.laRenewBy.value),
+                relevanceScore: 100.0,
+            )
+
+            if Task.isCancelled { return }
+
+            // Dual-path update strategy:
+            // - Foreground: direct ActivityKit update works reliably.
+            // - Background: direct update silently fails due to the audio session
+            //   limitation. APNs self-push is the only reliable delivery path.
+            //   Both paths are attempted when applicable; APNs is the authoritative
+            //   background mechanism.
+            let isForeground = await MainActor.run {
+                UIApplication.shared.applicationState == .active
+            }
+
+            if isForeground {
+                await activity.update(content)
+            }
+
+            if Task.isCancelled { return }
+
+            guard current?.id == activityID else {
+                LogManager.shared.log(category: .general, message: "Live Activity update — activity ID mismatch, discarding")
+                return
+            }
+
+            lastUpdateTime = Date()
+            LogManager.shared.log(category: .general, message: "[LA] updated id=\(activityID) seq=\(nextSeq) reason=\(reason)", isDebug: true)
+
+            if let token = pushToken {
+                await APNSClient.shared.sendLiveActivityUpdate(pushToken: token, state: state)
+            }
+        }
+    }
+
+    // MARK: - Binding / Lifecycle
+
+    /// Ends any Live Activities of this type that are not the one currently tracked.
+    /// Called on app launch to clean up cards left behind by a previous crash.
+    private func endOrphanedActivities() {
+        for activity in Activity<GlucoseLiveActivityAttributes>.activities {
+            guard activity.id != current?.id else { continue }
+            let orphanID = activity.id
+            Task {
+                await activity.end(nil, dismissalPolicy: .immediate)
+                LogManager.shared.log(category: .general, message: "Ended orphaned Live Activity id=\(orphanID)")
+            }
+        }
+    }
+
+    private func bind(to activity: Activity<GlucoseLiveActivityAttributes>, logReason: String) {
+        if current?.id == activity.id { return }
+        current = activity
+        dismissedByUser = false
+        endingForRestart = false
+        attachStateObserver(to: activity)
+        LogManager.shared.log(category: .general, message: "Live Activity bound id=\(activity.id) (\(logReason))", isDebug: true)
+        observePushToken(for: activity)
+    }
+
+    private func observePushToken(for activity: Activity<GlucoseLiveActivityAttributes>) {
+        tokenObservationTask?.cancel()
+        tokenObservationTask = Task {
+            for await tokenData in activity.pushTokenUpdates {
+                let token = tokenData.map { String(format: "%02x", $0) }.joined()
+                self.pushToken = token
+                LogManager.shared.log(category: .general, message: "Live Activity push token received", isDebug: true)
+            }
+        }
+    }
+
+    func handleExpiredToken() {
+        end()
+        // Activity will restart on next BG refresh via refreshFromCurrentState()
+    }
+
+    // MARK: - Renewal Notifications
+
+    private static let renewalNotificationID = "\(Bundle.main.bundleIdentifier ?? "loopfollow").la.renewal.failed"
+
+    private func scheduleRenewalFailedNotification() {
+        let content = UNMutableNotificationContent()
+        content.title = "Live Activity Expiring"
+        content.body = "Live Activity will expire soon. Open LoopFollow to restart."
+        content.sound = .default
+        let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
+        let request = UNNotificationRequest(
+            identifier: LiveActivityManager.renewalNotificationID,
+            content: content,
+            trigger: trigger,
+        )
+        UNUserNotificationCenter.current().add(request) { error in
+            if let error {
+                LogManager.shared.log(category: .general, message: "[LA] failed to schedule renewal notification: \(error)")
+            }
+        }
+        LogManager.shared.log(category: .general, message: "[LA] renewal failed notification scheduled")
+    }
+
+    private func cancelRenewalFailedNotification() {
+        let id = LiveActivityManager.renewalNotificationID
+        UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [id])
+        UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [id])
+    }
+
+    private func attachStateObserver(to activity: Activity<GlucoseLiveActivityAttributes>) {
+        stateObserverTask?.cancel()
+        stateObserverTask = Task {
+            for await state in activity.activityStateUpdates {
+                LogManager.shared.log(category: .general, message: "Live Activity state id=\(activity.id) -> \(state)", isDebug: true)
+                if state == .ended || state == .dismissed {
+                    if current?.id == activity.id {
+                        current = nil
+                        // Do NOT clear laRenewBy here. Preserving it means handleForeground()
+                        // can detect the renewal window on the next foreground event and restart
+                        // automatically — whether the LA ended normally (.ended) or was
+                        // system-dismissed (.dismissed). laRenewBy is only set to 0 when:
+                        //   • the user explicitly swipes (below) — renewal intent cancelled
+                        //   • a new LA starts (startIfNeeded writes the new deadline)
+                        //   • handleForeground() clears it synchronously before restarting
+                        //   • the user disables LA or calls forceRestart
+                        LogManager.shared.log(category: .general, message: "[LA] activity cleared id=\(activity.id) state=\(state)", isDebug: true)
+                    }
+                    if state == .dismissed {
+                        // Three possible sources of .dismissed — only the third blocks restart:
+                        //
+                        // (a) endingForRestart: our own end() during a planned restart.
+                        //     Must be checked first: handleForeground() clears laRenewalFailed
+                        //     and laRenewBy synchronously before calling end(), so those flags
+                        //     would read as "no problem" even though we initiated the dismissal.
+                        //
+                        // (b) iOS system force-dismiss: either laRenewalFailed is set (our 8-hour
+                        //     renewal logic marked it) or the renewal deadline has already passed
+                        //     (laRenewBy > 0 && now >= laRenewBy). In both cases iOS acted, not
+                        //     the user. laRenewBy is preserved so handleForeground() restarts on
+                        //     the next foreground.
+                        //
+                        // (c) User decision: the user explicitly swiped the LA away. Block
+                        //     auto-restart until forceRestart() is called. Clear laRenewBy so
+                        //     handleForeground() does NOT re-enter the renewal path on the next
+                        //     foreground — the renewal intent is cancelled by the user's choice.
+                        let now = Date().timeIntervalSince1970
+                        let renewBy = Storage.shared.laRenewBy.value
+                        let renewalFailed = Storage.shared.laRenewalFailed.value
+                        let pastDeadline = renewBy > 0 && now >= renewBy
+                        LogManager.shared.log(category: .general, message: "[LA] .dismissed: endingForRestart=\(endingForRestart), renewalFailed=\(renewalFailed), pastDeadline=\(pastDeadline), renewBy=\(renewBy), now=\(now)")
+                        if endingForRestart {
+                            // (a) Our own restart — do nothing, Task handles the rest.
+                            LogManager.shared.log(category: .general, message: "[LA] dismissed by self (endingForRestart) — restart in-flight, no action")
+                        } else if renewalFailed || pastDeadline {
+                            // (b) iOS system force-dismiss — allow auto-restart on next foreground.
+                            LogManager.shared.log(category: .general, message: "[LA] dismissed by iOS (renewalFailed=\(renewalFailed), pastDeadline=\(pastDeadline)) — auto-restart on next foreground")
+                        } else {
+                            // (c) User decision — cancel renewal intent, block auto-restart.
+                            dismissedByUser = true
+                            Storage.shared.laRenewBy.value = 0
+                            LogManager.shared.log(category: .general, message: "[LA] dismissed by USER (renewBy=\(renewBy), now=\(now)) — laRenewBy cleared, auto-restart BLOCKED until forceRestart")
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
+
+#endif
+
+extension Notification.Name {
+    /// Posted when the user taps the Live Activity or Dynamic Island.
+    /// Observers navigate to the Home or Snoozer tab as appropriate.
+    static let liveActivityDidForeground = Notification.Name("liveActivityDidForeground")
+}

+ 22 - 0
LoopFollow/LiveActivity/PreferredGlucoseUnit.swift

@@ -0,0 +1,22 @@
+// LoopFollow
+// PreferredGlucoseUnit.swift
+
+import Foundation
+import HealthKit
+
+enum PreferredGlucoseUnit {
+    /// LoopFollow’s existing source of truth for unit selection.
+    static func hkUnit() -> HKUnit {
+        Localizer.getPreferredUnit()
+    }
+
+    /// Maps HKUnit -> GlucoseSnapshot.Unit (our cross-platform enum).
+    static func snapshotUnit() -> GlucoseSnapshot.Unit {
+        switch hkUnit() {
+        case .millimolesPerLiter:
+            .mmol
+        default:
+            .mgdl
+        }
+    }
+}

+ 39 - 0
LoopFollow/LiveActivity/RestartLiveActivityIntent.swift

@@ -0,0 +1,39 @@
+// LoopFollow
+// RestartLiveActivityIntent.swift
+
+import AppIntents
+import UIKit
+
+struct RestartLiveActivityIntent: AppIntent {
+    static var title: LocalizedStringResource = "Restart Live Activity"
+    static var description = IntentDescription("Starts or restarts the LoopFollow Live Activity.")
+
+    func perform() async throws -> some IntentResult & ProvidesDialog {
+        Storage.shared.laEnabled.value = true
+
+        let keyId = Storage.shared.lfKeyId.value
+        let apnsKey = Storage.shared.lfApnsKey.value
+
+        if keyId.isEmpty || apnsKey.isEmpty {
+            if let url = URL(string: "\(AppGroupID.urlScheme)://settings/live-activity") {
+                await MainActor.run { UIApplication.shared.open(url) }
+            }
+            return .result(dialog: "Please enter your APNs credentials in LoopFollow settings to use the Live Activity.")
+        }
+
+        await MainActor.run { LiveActivityManager.shared.forceRestart() }
+
+        return .result(dialog: "Live Activity restarted.")
+    }
+}
+
+struct LoopFollowAppShortcuts: AppShortcutsProvider {
+    static var appShortcuts: [AppShortcut] {
+        AppShortcut(
+            intent: RestartLiveActivityIntent(),
+            phrases: ["Restart Live Activity in \(.applicationName)"],
+            shortTitle: "Restart Live Activity",
+            systemImageName: "dot.radiowaves.left.and.right",
+        )
+    }
+}

+ 138 - 0
LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift

@@ -0,0 +1,138 @@
+// LoopFollow
+// StorageCurrentGlucoseStateProvider.swift
+
+import Foundation
+
+/// Reads the latest glucose state from LoopFollow's Storage and Observable layers.
+/// This is the only file in the pipeline that is allowed to touch Storage.shared
+/// or Observable.shared — all other layers read exclusively from this provider.
+struct StorageCurrentGlucoseStateProvider: CurrentGlucoseStateProviding {
+    // MARK: - Core Glucose
+
+    var glucoseMgdl: Double? {
+        guard let bg = Observable.shared.bg.value, bg > 0 else { return nil }
+        return Double(bg)
+    }
+
+    var deltaMgdl: Double? {
+        Storage.shared.lastDeltaMgdl.value
+    }
+
+    var projectedMgdl: Double? {
+        Storage.shared.projectedBgMgdl.value
+    }
+
+    var updatedAt: Date? {
+        guard let t = Storage.shared.lastBgReadingTimeSeconds.value else { return nil }
+        return Date(timeIntervalSince1970: t)
+    }
+
+    var trendCode: String? {
+        Storage.shared.lastTrendCode.value
+    }
+
+    // MARK: - Secondary Metrics
+
+    var iob: Double? {
+        Storage.shared.lastIOB.value
+    }
+
+    var cob: Double? {
+        Storage.shared.lastCOB.value
+    }
+
+    // MARK: - Extended Metrics
+
+    var override: String? {
+        Observable.shared.override.value
+    }
+
+    var recBolus: Double? {
+        Observable.shared.deviceRecBolus.value
+    }
+
+    var battery: Double? {
+        Observable.shared.deviceBatteryLevel.value
+    }
+
+    var pumpBattery: Double? {
+        Observable.shared.pumpBatteryLevel.value
+    }
+
+    var basalRate: String {
+        Storage.shared.lastBasal.value
+    }
+
+    var pumpReservoirU: Double? {
+        Storage.shared.lastPumpReservoirU.value
+    }
+
+    var autosens: Double? {
+        Storage.shared.lastAutosens.value
+    }
+
+    var tdd: Double? {
+        Storage.shared.lastTdd.value
+    }
+
+    var targetLowMgdl: Double? {
+        Storage.shared.lastTargetLowMgdl.value
+    }
+
+    var targetHighMgdl: Double? {
+        Storage.shared.lastTargetHighMgdl.value
+    }
+
+    var isfMgdlPerU: Double? {
+        Storage.shared.lastIsfMgdlPerU.value
+    }
+
+    var carbRatio: Double? {
+        Storage.shared.lastCarbRatio.value
+    }
+
+    var carbsToday: Double? {
+        Storage.shared.lastCarbsToday.value
+    }
+
+    var profileName: String? {
+        let raw = Storage.shared.lastProfileName.value
+        return raw.isEmpty ? nil : raw
+    }
+
+    var sageInsertTime: TimeInterval {
+        Storage.shared.sageInsertTime.value
+    }
+
+    var cageInsertTime: TimeInterval {
+        Storage.shared.cageInsertTime.value
+    }
+
+    var iageInsertTime: TimeInterval {
+        Storage.shared.iageInsertTime.value
+    }
+
+    var minBgMgdl: Double? {
+        Storage.shared.lastMinBgMgdl.value
+    }
+
+    var maxBgMgdl: Double? {
+        Storage.shared.lastMaxBgMgdl.value
+    }
+
+    // MARK: - Loop Status
+
+    var isNotLooping: Bool {
+        let lastLoopTime = Storage.shared.lastLoopTime.value
+        guard lastLoopTime > 0, !Storage.shared.url.value.isEmpty else { return false }
+        return Date().timeIntervalSince1970 - lastLoopTime >= 15 * 60
+    }
+
+    // MARK: - Renewal
+
+    var showRenewalOverlay: Bool {
+        let renewBy = Storage.shared.laRenewBy.value
+        let now = Date().timeIntervalSince1970
+        return renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning
+    }
+}

+ 89 - 0
LoopFollow/LiveActivitySettingsView.swift

@@ -0,0 +1,89 @@
+// LoopFollow
+// LiveActivitySettingsView.swift
+
+import SwiftUI
+
+struct LiveActivitySettingsView: View {
+    @State private var laEnabled: Bool = Storage.shared.laEnabled.value
+    @State private var restartConfirmed = false
+    @State private var slots: [LiveActivitySlotOption] = LAAppGroupSettings.slots()
+    @State private var smallWidgetSlot: LiveActivitySlotOption = LAAppGroupSettings.smallWidgetSlot()
+
+    private let slotLabels = ["Top left", "Top right", "Bottom left", "Bottom right"]
+
+    var body: some View {
+        Form {
+            Section(header: Text("Live Activity")) {
+                Toggle("Enable Live Activity", isOn: $laEnabled)
+            }
+
+            if laEnabled {
+                Section {
+                    Button(restartConfirmed ? "Live Activity Restarted" : "Restart Live Activity") {
+                        LiveActivityManager.shared.forceRestart()
+                        restartConfirmed = true
+                        DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
+                            restartConfirmed = false
+                        }
+                    }
+                    .disabled(restartConfirmed)
+                }
+            }
+
+            Section(header: Text("Grid Slots - Live Activity")) {
+                ForEach(0 ..< 4, id: \.self) { index in
+                    Picker(slotLabels[index], selection: Binding(
+                        get: { slots[index] },
+                        set: { selectSlot($0, at: index) }
+                    )) {
+                        ForEach(LiveActivitySlotOption.allCases, id: \.self) { option in
+                            Text(option.displayName).tag(option)
+                        }
+                    }
+                }
+            }
+
+            Section(header: Text("Grid Slot - CarPlay / Watch")) {
+                Picker("Right slot", selection: Binding(
+                    get: { smallWidgetSlot },
+                    set: { newValue in
+                        smallWidgetSlot = newValue
+                        LAAppGroupSettings.setSmallWidgetSlot(newValue)
+                        LiveActivityManager.shared.refreshFromCurrentState(reason: "small widget slot changed")
+                    }
+                )) {
+                    ForEach(LiveActivitySlotOption.allCases, id: \.self) { option in
+                        Text(option.displayName).tag(option)
+                    }
+                }
+            }
+        }
+        .onReceive(Storage.shared.laEnabled.$value) { newValue in
+            if newValue != laEnabled { laEnabled = newValue }
+        }
+        .onChange(of: laEnabled) { newValue in
+            Storage.shared.laEnabled.value = newValue
+            if newValue {
+                LiveActivityManager.shared.forceRestart()
+            } else {
+                LiveActivityManager.shared.end(dismissalPolicy: .immediate)
+            }
+        }
+        .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme)
+        .navigationTitle("Live Activity")
+        .navigationBarTitleDisplayMode(.inline)
+    }
+
+    /// Selects an option for the given slot index, enforcing uniqueness:
+    /// if the chosen option is already in another slot, that slot is cleared to `.none`.
+    private func selectSlot(_ option: LiveActivitySlotOption, at index: Int) {
+        if option != .none {
+            for i in 0 ..< slots.count where i != index && slots[i] == option {
+                slots[i] = .none
+            }
+        }
+        slots[index] = option
+        LAAppGroupSettings.setSlots(slots)
+        LiveActivityManager.shared.refreshFromCurrentState(reason: "slot config changed")
+    }
+}

+ 4 - 0
LoopFollow/Loop Follow.entitlements

@@ -8,6 +8,10 @@
 	<string>development</string>
 	<key>com.apple.security.app-sandbox</key>
 	<true/>
+	<key>com.apple.security.application-groups</key>
+	<array>
+		<string>group.com.$(unique_id).LoopFollow$(app_suffix)</string>
+	</array>
 	<key>com.apple.security.device.bluetooth</key>
 	<true/>
 	<key>com.apple.security.network.client</key>

+ 46 - 41
LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift

@@ -1,14 +1,26 @@
 // LoopFollow
 // LoopAPNSService.swift
 
-import CryptoKit
 import Foundation
 import HealthKit
-import SwiftJWT
 
 class LoopAPNSService {
     private let storage = Storage.shared
 
+    /// Returns the effective APNs credentials for sending commands to the remote app.
+    /// Same team → use LoopFollow's own key. Different team → use remote-specific key.
+    private func effectiveCredentials() -> (apnsKey: String, keyId: String, teamId: String) {
+        let lfTeamId = BuildDetails.default.teamID ?? ""
+        let remoteTeamId = storage.teamId.value ?? ""
+        let sameTeam = !lfTeamId.isEmpty && !remoteTeamId.isEmpty && lfTeamId == remoteTeamId
+
+        if sameTeam || remoteTeamId.isEmpty {
+            return (storage.lfApnsKey.value, storage.lfKeyId.value, lfTeamId)
+        } else {
+            return (storage.remoteApnsKey.value, storage.remoteKeyId.value, remoteTeamId)
+        }
+    }
+
     enum LoopAPNSError: Error, LocalizedError {
         case invalidConfiguration
         case jwtError
@@ -57,26 +69,11 @@ class LoopAPNSService {
             return nil
         }
 
-        // Get the target Loop app's Team ID from storage.
-        let targetTeamId = storage.teamId.value ?? ""
-        let teamIdsAreDifferent = loopFollowTeamID != targetTeamId
-
-        let keyIdForReturn: String
-        let apnsKeyForReturn: String
-
-        if teamIdsAreDifferent {
-            // Team IDs differ, use the separate return credentials.
-            keyIdForReturn = storage.returnKeyId.value
-            apnsKeyForReturn = storage.returnApnsKey.value
-        } else {
-            // Team IDs are the same, use the primary credentials.
-            keyIdForReturn = storage.keyId.value
-            apnsKeyForReturn = storage.apnsKey.value
-        }
+        let lfKeyId = storage.lfKeyId.value
+        let lfApnsKey = storage.lfApnsKey.value
 
-        // Ensure we have the necessary credentials.
-        guard !keyIdForReturn.isEmpty, !apnsKeyForReturn.isEmpty else {
-            LogManager.shared.log(category: .apns, message: "Missing required return APNS credentials. Check Remote Settings.")
+        guard !lfKeyId.isEmpty, !lfApnsKey.isEmpty else {
+            LogManager.shared.log(category: .apns, message: "Missing LoopFollow APNS credentials. Configure them in App Settings → APN.")
             return nil
         }
 
@@ -85,8 +82,8 @@ class LoopAPNSService {
             deviceToken: loopFollowDeviceToken,
             bundleId: Bundle.main.bundleIdentifier ?? "",
             teamId: loopFollowTeamID,
-            keyId: keyIdForReturn,
-            apnsKey: apnsKeyForReturn
+            keyId: lfKeyId,
+            apnsKey: lfApnsKey
         )
     }
 
@@ -108,8 +105,9 @@ class LoopAPNSService {
     /// Validates the Loop APNS setup by checking all required fields
     /// - Returns: True if setup is valid, false otherwise
     func validateSetup() -> Bool {
-        let hasKeyId = !storage.keyId.value.isEmpty
-        let hasAPNSKey = !storage.apnsKey.value.isEmpty
+        let creds = effectiveCredentials()
+        let hasKeyId = !creds.keyId.isEmpty
+        let hasAPNSKey = !creds.apnsKey.isEmpty
         let hasQrCode = !storage.loopAPNSQrCodeURL.value.isEmpty
         let hasDeviceToken = !Storage.shared.deviceToken.value.isEmpty
         let hasBundleIdentifier = !Storage.shared.bundleId.value.isEmpty
@@ -138,8 +136,7 @@ class LoopAPNSService {
 
         let deviceToken = Storage.shared.deviceToken.value
         let bundleIdentifier = Storage.shared.bundleId.value
-        let keyId = storage.keyId.value
-        let apnsKey = storage.apnsKey.value
+        let creds = effectiveCredentials()
 
         // Create APNS notification payload (matching Loop's expected format)
         let now = Date()
@@ -186,8 +183,9 @@ class LoopAPNSService {
         sendAPNSNotification(
             deviceToken: deviceToken,
             bundleIdentifier: bundleIdentifier,
-            keyId: keyId,
-            apnsKey: apnsKey,
+            keyId: creds.keyId,
+            apnsKey: creds.apnsKey,
+            teamId: creds.teamId,
             payload: finalPayload,
             completion: completion
         )
@@ -207,8 +205,7 @@ class LoopAPNSService {
 
         let deviceToken = Storage.shared.deviceToken.value
         let bundleIdentifier = Storage.shared.bundleId.value
-        let keyId = storage.keyId.value
-        let apnsKey = storage.apnsKey.value
+        let creds = effectiveCredentials()
 
         // Create APNS notification payload (matching Loop's expected format)
         let now = Date()
@@ -250,8 +247,9 @@ class LoopAPNSService {
         sendAPNSNotification(
             deviceToken: deviceToken,
             bundleIdentifier: bundleIdentifier,
-            keyId: keyId,
-            apnsKey: apnsKey,
+            keyId: creds.keyId,
+            apnsKey: creds.apnsKey,
+            teamId: creds.teamId,
             payload: finalPayload,
             completion: completion
         )
@@ -262,9 +260,10 @@ class LoopAPNSService {
     private func validateCredentials() -> [String]? {
         var errors = [String]()
 
-        let keyId = storage.keyId.value
-        let teamId = Storage.shared.teamId.value ?? ""
-        let apnsKey = storage.apnsKey.value
+        let creds = effectiveCredentials()
+        let keyId = creds.keyId
+        let teamId = creds.teamId
+        let apnsKey = creds.apnsKey
 
         // Validate keyId (should be 10 alphanumeric characters)
         let keyIdPattern = "^[A-Z0-9]{10}$"
@@ -328,6 +327,7 @@ class LoopAPNSService {
         bundleIdentifier: String,
         keyId: String,
         apnsKey: String,
+        teamId: String,
         payload: [String: Any],
         completion: @escaping (Bool, String?) -> Void
     ) {
@@ -340,7 +340,7 @@ class LoopAPNSService {
         }
 
         // Create JWT token for APNS authentication
-        guard let jwt = JWTManager.shared.getOrGenerateJWT(keyId: keyId, teamId: Storage.shared.teamId.value ?? "", apnsKey: apnsKey) else {
+        guard let jwt = JWTManager.shared.getOrGenerateJWT(keyId: keyId, teamId: teamId, apnsKey: apnsKey) else {
             let errorMessage = "Failed to generate JWT, please check that the APNS Key ID, APNS Key and Team ID are correct."
             LogManager.shared.log(category: .apns, message: errorMessage)
             completion(false, errorMessage)
@@ -429,6 +429,7 @@ class LoopAPNSService {
                         LogManager.shared.log(category: .apns, message: "APNS error 400: \(responseBodyMessage) - Check device token and environment settings")
                         completion(false, errorMessage)
                     case 403:
+                        JWTManager.shared.invalidateCache()
                         let errorMessage = "Authentication error. Check your certificate or authentication token. \(responseBodyMessage)"
                         LogManager.shared.log(category: .apns, message: "APNS error 403: \(responseBodyMessage) - Check APNS key permissions for bundle ID")
                         completion(false, errorMessage)
@@ -699,11 +700,13 @@ class LoopAPNSService {
         }
 
         // Send the notification using the existing APNS infrastructure
+        let creds = effectiveCredentials()
         sendAPNSNotification(
             deviceToken: deviceToken,
             bundleIdentifier: bundleIdentifier,
-            keyId: storage.keyId.value,
-            apnsKey: storage.apnsKey.value,
+            keyId: creds.keyId,
+            apnsKey: creds.apnsKey,
+            teamId: creds.teamId,
             payload: payload,
             completion: completion
         )
@@ -753,11 +756,13 @@ class LoopAPNSService {
         }
 
         // Send the notification using the existing APNS infrastructure
+        let creds = effectiveCredentials()
         sendAPNSNotification(
             deviceToken: deviceToken,
             bundleIdentifier: bundleIdentifier,
-            keyId: storage.keyId.value,
-            apnsKey: storage.apnsKey.value,
+            keyId: creds.keyId,
+            apnsKey: creds.apnsKey,
+            teamId: creds.teamId,
             payload: payload,
             completion: completion
         )

+ 12 - 12
LoopFollow/Remote/Settings/RemoteCommandSettings.swift

@@ -8,8 +8,8 @@ struct RemoteCommandSettings: Codable {
     let remoteType: RemoteType
     let user: String
     let sharedSecret: String
-    let apnsKey: String
-    let keyId: String
+    let remoteApnsKey: String
+    let remoteKeyId: String
     let teamId: String?
     let maxBolus: Double
     let maxCarbs: Double
@@ -27,8 +27,8 @@ struct RemoteCommandSettings: Codable {
         remoteType: RemoteType,
         user: String,
         sharedSecret: String,
-        apnsKey: String,
-        keyId: String,
+        remoteApnsKey: String,
+        remoteKeyId: String,
         teamId: String?,
         maxBolus: Double,
         maxCarbs: Double,
@@ -44,8 +44,8 @@ struct RemoteCommandSettings: Codable {
         self.remoteType = remoteType
         self.user = user
         self.sharedSecret = sharedSecret
-        self.apnsKey = apnsKey
-        self.keyId = keyId
+        self.remoteApnsKey = remoteApnsKey
+        self.remoteKeyId = remoteKeyId
         self.teamId = teamId
         self.maxBolus = maxBolus
         self.maxCarbs = maxCarbs
@@ -68,8 +68,8 @@ struct RemoteCommandSettings: Codable {
             remoteType: storage.remoteType.value,
             user: storage.user.value,
             sharedSecret: storage.sharedSecret.value,
-            apnsKey: storage.apnsKey.value,
-            keyId: storage.keyId.value,
+            remoteApnsKey: storage.remoteApnsKey.value,
+            remoteKeyId: storage.remoteKeyId.value,
             teamId: storage.teamId.value,
             maxBolus: storage.maxBolus.value.doubleValue(for: .internationalUnit()),
             maxCarbs: storage.maxCarbs.value.doubleValue(for: .gram()),
@@ -91,8 +91,8 @@ struct RemoteCommandSettings: Codable {
         storage.remoteType.value = remoteType
         storage.user.value = user
         storage.sharedSecret.value = sharedSecret
-        storage.apnsKey.value = apnsKey
-        storage.keyId.value = keyId
+        storage.remoteApnsKey.value = remoteApnsKey
+        storage.remoteKeyId.value = remoteKeyId
         storage.teamId.value = teamId
         storage.maxBolus.value = HKQuantity(unit: .internationalUnit(), doubleValue: maxBolus)
         storage.maxCarbs.value = HKQuantity(unit: .gram(), doubleValue: maxCarbs)
@@ -152,9 +152,9 @@ struct RemoteCommandSettings: Codable {
         case .nightscout:
             return !user.isEmpty
         case .trc:
-            return !user.isEmpty && !sharedSecret.isEmpty && !apnsKey.isEmpty && !keyId.isEmpty
+            return !user.isEmpty && !sharedSecret.isEmpty && !remoteApnsKey.isEmpty && !remoteKeyId.isEmpty
         case .loopAPNS:
-            return !keyId.isEmpty && !apnsKey.isEmpty && teamId != nil && !loopAPNSQrCodeURL.isEmpty
+            return !remoteKeyId.isEmpty && !remoteApnsKey.isEmpty && teamId != nil && !loopAPNSQrCodeURL.isEmpty
         }
     }
 

+ 36 - 55
LoopFollow/Remote/Settings/RemoteSettingsView.swift

@@ -148,23 +148,25 @@ struct RemoteSettingsView: View {
                         )
                     }
 
-                    HStack {
-                        Text("APNS Key ID")
-                        TogglableSecureInput(
-                            placeholder: "Enter APNS Key ID",
-                            text: $viewModel.keyId,
-                            style: .singleLine
-                        )
-                    }
+                    if viewModel.areTeamIdsDifferent {
+                        HStack {
+                            Text("APNS Key ID")
+                            TogglableSecureInput(
+                                placeholder: "Enter APNS Key ID",
+                                text: $viewModel.remoteKeyId,
+                                style: .singleLine
+                            )
+                        }
 
-                    VStack(alignment: .leading) {
-                        Text("APNS Key")
-                        TogglableSecureInput(
-                            placeholder: "Paste APNS Key",
-                            text: $viewModel.apnsKey,
-                            style: .multiLine
-                        )
-                        .frame(minHeight: 110)
+                        VStack(alignment: .leading) {
+                            Text("APNS Key")
+                            TogglableSecureInput(
+                                placeholder: "Paste APNS Key",
+                                text: $viewModel.remoteApnsKey,
+                                style: .multiLine
+                            )
+                            .frame(minHeight: 110)
+                        }
                     }
                 }
 
@@ -194,23 +196,25 @@ struct RemoteSettingsView: View {
                         )
                     }
 
-                    HStack {
-                        Text("APNS Key ID")
-                        TogglableSecureInput(
-                            placeholder: "Enter APNS Key ID",
-                            text: $viewModel.keyId,
-                            style: .singleLine
-                        )
-                    }
+                    if viewModel.areTeamIdsDifferent {
+                        HStack {
+                            Text("APNS Key ID")
+                            TogglableSecureInput(
+                                placeholder: "Enter APNS Key ID",
+                                text: $viewModel.remoteKeyId,
+                                style: .singleLine
+                            )
+                        }
 
-                    VStack(alignment: .leading) {
-                        Text("APNS Key")
-                        TogglableSecureInput(
-                            placeholder: "Paste APNS Key",
-                            text: $viewModel.apnsKey,
-                            style: .multiLine
-                        )
-                        .frame(minHeight: 110)
+                        VStack(alignment: .leading) {
+                            Text("APNS Key")
+                            TogglableSecureInput(
+                                placeholder: "Paste APNS Key",
+                                text: $viewModel.remoteApnsKey,
+                                style: .multiLine
+                            )
+                            .frame(minHeight: 110)
+                        }
                     }
 
                     HStack {
@@ -279,29 +283,6 @@ struct RemoteSettingsView: View {
                         Text("Bolus Increment: \(Storage.shared.bolusIncrement.value.doubleValue(for: .internationalUnit()), specifier: "%.3f") U")
                     }
                 }
-
-                if viewModel.areTeamIdsDifferent {
-                    Section(header: Text("Return Notification Settings"), footer: Text("Because LoopFollow and the target app were built with different Team IDs, you must provide the APNS credentials for LoopFollow below.").font(.caption)) {
-                        HStack {
-                            Text("Return APNS Key ID")
-                            TogglableSecureInput(
-                                placeholder: "Enter Key ID for LoopFollow",
-                                text: $viewModel.returnKeyId,
-                                style: .singleLine
-                            )
-                        }
-
-                        VStack(alignment: .leading) {
-                            Text("Return APNS Key")
-                            TogglableSecureInput(
-                                placeholder: "Paste APNS Key for LoopFollow",
-                                text: $viewModel.returnApnsKey,
-                                style: .multiLine
-                            )
-                            .frame(minHeight: 110)
-                        }
-                    }
-                }
             }
         }
         .alert(isPresented: $showAlert) {

+ 19 - 37
LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift

@@ -9,8 +9,8 @@ class RemoteSettingsViewModel: ObservableObject {
     @Published var remoteType: RemoteType
     @Published var user: String
     @Published var sharedSecret: String
-    @Published var apnsKey: String
-    @Published var keyId: String
+    @Published var remoteApnsKey: String
+    @Published var remoteKeyId: String
 
     @Published var maxBolus: HKQuantity
     @Published var maxCarbs: HKQuantity
@@ -21,11 +21,6 @@ class RemoteSettingsViewModel: ObservableObject {
     @Published var isTrioDevice: Bool = (Storage.shared.device.value == "Trio")
     @Published var isLoopDevice: Bool = (Storage.shared.device.value == "Loop")
 
-    // MARK: - Return Notification Properties
-
-    @Published var returnApnsKey: String
-    @Published var returnKeyId: String
-
     // MARK: - Loop APNS Setup Properties
 
     @Published var loopDeveloperTeamId: String
@@ -56,16 +51,13 @@ class RemoteSettingsViewModel: ObservableObject {
 
         // Determine if a comparison is needed and perform it.
         switch remoteType {
-        case .trc:
-            // If the target ID is empty, there's nothing to compare.
+        case .trc, .loopAPNS:
             guard !targetTeamId.isEmpty else {
                 return false
             }
-            // Return true if the IDs are different.
             return loopFollowTeamID != targetTeamId
 
-        case .loopAPNS, .none, .nightscout:
-            // For other remote types, this check is not applicable.
+        case .none, .nightscout:
             return false
         }
     }
@@ -73,8 +65,13 @@ class RemoteSettingsViewModel: ObservableObject {
     // MARK: - Computed property for Loop APNS Setup validation
 
     var loopAPNSSetup: Bool {
-        !keyId.isEmpty &&
-            !apnsKey.isEmpty &&
+        let hasCredentials: Bool
+        if areTeamIdsDifferent {
+            hasCredentials = !remoteKeyId.isEmpty && !remoteApnsKey.isEmpty
+        } else {
+            hasCredentials = !Storage.shared.lfKeyId.value.isEmpty && !Storage.shared.lfApnsKey.value.isEmpty
+        }
+        return hasCredentials &&
             !loopDeveloperTeamId.isEmpty &&
             !loopAPNSQrCodeURL.isEmpty &&
             !Storage.shared.deviceToken.value.isEmpty &&
@@ -89,8 +86,8 @@ class RemoteSettingsViewModel: ObservableObject {
         remoteType = storage.remoteType.value
         user = storage.user.value
         sharedSecret = storage.sharedSecret.value
-        apnsKey = storage.apnsKey.value
-        keyId = storage.keyId.value
+        remoteApnsKey = storage.remoteApnsKey.value
+        remoteKeyId = storage.remoteKeyId.value
         maxBolus = storage.maxBolus.value
         maxCarbs = storage.maxCarbs.value
         maxProtein = storage.maxProtein.value
@@ -102,9 +99,6 @@ class RemoteSettingsViewModel: ObservableObject {
         loopAPNSQrCodeURL = storage.loopAPNSQrCodeURL.value
         productionEnvironment = storage.productionEnvironment.value
 
-        returnApnsKey = storage.returnApnsKey.value
-        returnKeyId = storage.returnKeyId.value
-
         setupBindings()
     }
 
@@ -125,19 +119,18 @@ class RemoteSettingsViewModel: ObservableObject {
             .sink { [weak self] in self?.storage.sharedSecret.value = $0 }
             .store(in: &cancellables)
 
-        $apnsKey
+        $remoteApnsKey
             .dropFirst()
             .sink { [weak self] newValue in
-                // Validate and fix the APNS key format using the service
                 let apnsService = LoopAPNSService()
                 let fixedKey = apnsService.validateAndFixAPNSKey(newValue)
-                self?.storage.apnsKey.value = fixedKey
+                self?.storage.remoteApnsKey.value = fixedKey
             }
             .store(in: &cancellables)
 
-        $keyId
+        $remoteKeyId
             .dropFirst()
-            .sink { [weak self] in self?.storage.keyId.value = $0 }
+            .sink { [weak self] in self?.storage.remoteKeyId.value = $0 }
             .store(in: &cancellables)
 
         $maxBolus
@@ -194,17 +187,6 @@ class RemoteSettingsViewModel: ObservableObject {
             .dropFirst()
             .sink { [weak self] in self?.storage.productionEnvironment.value = $0 }
             .store(in: &cancellables)
-
-        // Return notification bindings
-        $returnApnsKey
-            .dropFirst()
-            .sink { [weak self] in self?.storage.returnApnsKey.value = $0 }
-            .store(in: &cancellables)
-
-        $returnKeyId
-            .dropFirst()
-            .sink { [weak self] in self?.storage.returnKeyId.value = $0 }
-            .store(in: &cancellables)
     }
 
     func handleLoopAPNSQRCodeScanResult(_ result: Result<String, Error>) {
@@ -235,8 +217,8 @@ class RemoteSettingsViewModel: ObservableObject {
         remoteType = storage.remoteType.value
         user = storage.user.value
         sharedSecret = storage.sharedSecret.value
-        apnsKey = storage.apnsKey.value
-        keyId = storage.keyId.value
+        remoteApnsKey = storage.remoteApnsKey.value
+        remoteKeyId = storage.remoteKeyId.value
         maxBolus = storage.maxBolus.value
         maxCarbs = storage.maxCarbs.value
         maxProtein = storage.maxProtein.value

+ 21 - 19
LoopFollow/Remote/TRC/PushNotificationManager.swift

@@ -3,7 +3,6 @@
 
 import Foundation
 import HealthKit
-import SwiftJWT
 
 class PushNotificationManager {
     private var deviceToken: String
@@ -19,11 +18,22 @@ class PushNotificationManager {
         deviceToken = Storage.shared.deviceToken.value
         sharedSecret = Storage.shared.sharedSecret.value
         productionEnvironment = Storage.shared.productionEnvironment.value
-        apnsKey = Storage.shared.apnsKey.value
-        teamId = Storage.shared.teamId.value ?? ""
-        keyId = Storage.shared.keyId.value
         user = Storage.shared.user.value
         bundleId = Storage.shared.bundleId.value
+
+        let lfTeamId = BuildDetails.default.teamID ?? ""
+        let remoteTeamId = Storage.shared.teamId.value ?? ""
+        let sameTeam = !lfTeamId.isEmpty && !remoteTeamId.isEmpty && lfTeamId == remoteTeamId
+
+        if sameTeam || remoteTeamId.isEmpty {
+            apnsKey = Storage.shared.lfApnsKey.value
+            keyId = Storage.shared.lfKeyId.value
+            teamId = lfTeamId
+        } else {
+            apnsKey = Storage.shared.remoteApnsKey.value
+            keyId = Storage.shared.remoteKeyId.value
+            teamId = remoteTeamId
+        }
     }
 
     private func createReturnNotificationInfo() -> CommandPayload.ReturnNotificationInfo? {
@@ -38,20 +48,11 @@ class PushNotificationManager {
             return nil
         }
 
-        let teamIdsAreDifferent = loopFollowTeamID != teamId
-        let keyIdForReturn: String
-        let apnsKeyForReturn: String
-
-        if teamIdsAreDifferent {
-            keyIdForReturn = Storage.shared.returnKeyId.value
-            apnsKeyForReturn = Storage.shared.returnApnsKey.value
-        } else {
-            keyIdForReturn = keyId
-            apnsKeyForReturn = apnsKey
-        }
+        let lfKeyId = Storage.shared.lfKeyId.value
+        let lfApnsKey = Storage.shared.lfApnsKey.value
 
-        guard !keyIdForReturn.isEmpty, !apnsKeyForReturn.isEmpty else {
-            LogManager.shared.log(category: .apns, message: "Missing required return APNS credentials. Check Remote Settings.")
+        guard !lfKeyId.isEmpty, !lfApnsKey.isEmpty else {
+            LogManager.shared.log(category: .apns, message: "Missing LoopFollow APNS credentials. Configure them in App Settings → APN.")
             return nil
         }
 
@@ -60,8 +61,8 @@ class PushNotificationManager {
             deviceToken: loopFollowDeviceToken,
             bundleId: Bundle.main.bundleIdentifier ?? "",
             teamId: loopFollowTeamID,
-            keyId: keyIdForReturn,
-            apnsKey: apnsKeyForReturn
+            keyId: lfKeyId,
+            apnsKey: lfApnsKey
         )
     }
 
@@ -294,6 +295,7 @@ class PushNotificationManager {
                     case 400:
                         completion(false, "Bad request. The request was invalid or malformed. \(responseBodyMessage)")
                     case 403:
+                        JWTManager.shared.invalidateCache()
                         completion(false, "Authentication error. Check your certificate or authentication token. \(responseBodyMessage)")
                     case 404:
                         completion(false, "Invalid request: The :path value was incorrect. \(responseBodyMessage)")

+ 8 - 18
LoopFollow/Remote/TRC/SecureMessenger.swift

@@ -1,38 +1,28 @@
 // LoopFollow
 // SecureMessenger.swift
 
-import CryptoSwift
+import CryptoKit
 import Foundation
-import Security
 
 struct SecureMessenger {
-    private let sharedKey: [UInt8]
+    private let encryptionKey: SymmetricKey
 
     init?(sharedSecret: String) {
         guard let secretData = sharedSecret.data(using: .utf8) else {
             return nil
         }
-        sharedKey = Array(secretData.sha256())
-    }
-
-    private func generateSecureRandomBytes(count: Int) -> [UInt8]? {
-        var bytes = [UInt8](repeating: 0, count: count)
-        let status = SecRandomCopyBytes(kSecRandomDefault, count, &bytes)
-        return status == errSecSuccess ? bytes : nil
+        let hashed = SHA256.hash(data: secretData)
+        encryptionKey = SymmetricKey(data: hashed)
     }
 
     func encrypt<T: Encodable>(_ object: T) throws -> String {
         let dataToEncrypt = try JSONEncoder().encode(object)
 
-        guard let nonce = generateSecureRandomBytes(count: 12) else {
-            throw NSError(domain: "SecureMessenger", code: 500, userInfo: [NSLocalizedDescriptionKey: "Failed to generate secure random nonce."])
-        }
+        let nonce = AES.GCM.Nonce()
+        let sealedBox = try AES.GCM.seal(dataToEncrypt, using: encryptionKey, nonce: nonce)
 
-        let gcm = GCM(iv: nonce, mode: .combined)
-        let aes = try AES(key: sharedKey, blockMode: gcm, padding: .noPadding)
-        let encryptedBytes = try aes.encrypt(Array(dataToEncrypt))
-        let finalData = Data(nonce + encryptedBytes)
+        let combinedData = Data(nonce) + sealedBox.ciphertext + sealedBox.tag
 
-        return finalData.base64EncodedString()
+        return combinedData.base64EncodedString()
     }
 }

+ 44 - 0
LoopFollow/Settings/APNSettingsView.swift

@@ -0,0 +1,44 @@
+// LoopFollow
+// APNSettingsView.swift
+
+import SwiftUI
+
+struct APNSettingsView: View {
+    @State private var keyId: String = Storage.shared.lfKeyId.value
+    @State private var apnsKey: String = Storage.shared.lfApnsKey.value
+
+    var body: some View {
+        Form {
+            Section(header: Text("LoopFollow APNs Credentials")) {
+                HStack {
+                    Text("APNS Key ID")
+                    TogglableSecureInput(
+                        placeholder: "Enter APNS Key ID",
+                        text: $keyId,
+                        style: .singleLine
+                    )
+                }
+
+                VStack(alignment: .leading) {
+                    Text("APNS Key")
+                    TogglableSecureInput(
+                        placeholder: "Paste APNS Key",
+                        text: $apnsKey,
+                        style: .multiLine
+                    )
+                    .frame(minHeight: 110)
+                }
+            }
+        }
+        .onChange(of: keyId) { newValue in
+            Storage.shared.lfKeyId.value = newValue
+        }
+        .onChange(of: apnsKey) { newValue in
+            let apnsService = LoopAPNSService()
+            Storage.shared.lfApnsKey.value = apnsService.validateAndFixAPNSKey(newValue)
+        }
+        .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme)
+        .navigationTitle("APN")
+        .navigationBarTitleDisplayMode(.inline)
+    }
+}

+ 12 - 12
LoopFollow/Settings/ImportExport/ExportableSettings.swift

@@ -148,8 +148,8 @@ struct RemoteSettingsExport: Codable {
     let remoteType: RemoteType
     let user: String
     let sharedSecret: String
-    let apnsKey: String
-    let keyId: String
+    let remoteApnsKey: String
+    let remoteKeyId: String
     let teamId: String?
     let maxBolus: Double
     let maxCarbs: Double
@@ -168,8 +168,8 @@ struct RemoteSettingsExport: Codable {
             remoteType: storage.remoteType.value,
             user: storage.user.value,
             sharedSecret: storage.sharedSecret.value,
-            apnsKey: storage.apnsKey.value,
-            keyId: storage.keyId.value,
+            remoteApnsKey: storage.remoteApnsKey.value,
+            remoteKeyId: storage.remoteKeyId.value,
             teamId: storage.teamId.value,
             maxBolus: storage.maxBolus.value.doubleValue(for: .internationalUnit()),
             maxCarbs: storage.maxCarbs.value.doubleValue(for: .gram()),
@@ -189,8 +189,8 @@ struct RemoteSettingsExport: Codable {
         storage.remoteType.value = remoteType
         storage.user.value = user
         storage.sharedSecret.value = sharedSecret
-        storage.apnsKey.value = apnsKey
-        storage.keyId.value = keyId
+        storage.remoteApnsKey.value = remoteApnsKey
+        storage.remoteKeyId.value = remoteKeyId
         storage.teamId.value = teamId
         storage.maxBolus.value = HKQuantity(unit: .internationalUnit(), doubleValue: maxBolus)
         storage.maxCarbs.value = HKQuantity(unit: .gram(), doubleValue: maxCarbs)
@@ -237,9 +237,9 @@ struct RemoteSettingsExport: Codable {
         case .nightscout:
             return !user.isEmpty
         case .trc:
-            return !user.isEmpty && !sharedSecret.isEmpty && !apnsKey.isEmpty && !keyId.isEmpty
+            return !user.isEmpty && !sharedSecret.isEmpty && !remoteApnsKey.isEmpty && !remoteKeyId.isEmpty
         case .loopAPNS:
-            return !keyId.isEmpty && !apnsKey.isEmpty && teamId != nil && !loopAPNSQrCodeURL.isEmpty
+            return !remoteKeyId.isEmpty && !remoteApnsKey.isEmpty && teamId != nil && !loopAPNSQrCodeURL.isEmpty
         }
     }
 
@@ -266,14 +266,14 @@ struct RemoteSettingsExport: Codable {
 
         // For TRC and LoopAPNS, check if key details are changing
         if remoteType == .trc || remoteType == .loopAPNS {
-            let currentKeyId = storage.keyId.value
-            let currentApnsKey = storage.apnsKey.value
+            let currentKeyId = storage.remoteKeyId.value
+            let currentApnsKey = storage.remoteApnsKey.value
 
-            if !currentKeyId.isEmpty, currentKeyId != keyId {
+            if !currentKeyId.isEmpty, currentKeyId != remoteKeyId {
                 message += "APNS Key ID is changing. This may affect your remote commands.\n"
             }
 
-            if !currentApnsKey.isEmpty, currentApnsKey != apnsKey {
+            if !currentApnsKey.isEmpty, currentApnsKey != remoteApnsKey {
                 message += "APNS Key is changing. This may affect your remote commands.\n"
             }
         }

+ 42 - 0
LoopFollow/Settings/LiveActivitySettingsView.swift

@@ -0,0 +1,42 @@
+// LoopFollow
+// LiveActivitySettingsView.swift
+
+import SwiftUI
+
+struct LiveActivitySettingsView: View {
+    @State private var laEnabled: Bool = Storage.shared.laEnabled.value
+    @State private var restartConfirmed = false
+
+    var body: some View {
+        Form {
+            Section(header: Text("Live Activity")) {
+                Toggle("Enable Live Activity", isOn: $laEnabled)
+            }
+
+            if laEnabled {
+                Section {
+                    Button(restartConfirmed ? "Live Activity Restarted" : "Restart Live Activity") {
+                        LiveActivityManager.shared.forceRestart()
+                        restartConfirmed = true
+                        DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
+                            restartConfirmed = false
+                        }
+                    }
+                    .disabled(restartConfirmed)
+                }
+            }
+        }
+        .onReceive(Storage.shared.laEnabled.$value) { newValue in
+            if newValue != laEnabled { laEnabled = newValue }
+        }
+        .onChange(of: laEnabled) { newValue in
+            Storage.shared.laEnabled.value = newValue
+            if !newValue {
+                LiveActivityManager.shared.end(dismissalPolicy: .immediate)
+            }
+        }
+        .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme)
+        .navigationTitle("Live Activity")
+        .navigationBarTitleDisplayMode(.inline)
+    }
+}

+ 16 - 0
LoopFollow/Settings/SettingsMenuView.swift

@@ -65,6 +65,18 @@ struct SettingsMenuView: View {
                         settingsPath.value.append(Sheet.importExport)
                     }
 
+                    NavigationRow(title: "APN",
+                                  icon: "bell.and.waves.left.and.right")
+                    {
+                        settingsPath.value.append(Sheet.apn)
+                    }
+
+                    NavigationRow(title: "Live Activity",
+                                  icon: "dot.radiowaves.left.and.right")
+                    {
+                        settingsPath.value.append(Sheet.liveActivity)
+                    }
+
                     if !nightscoutURL.value.isEmpty {
                         NavigationRow(title: "Remote",
                                       icon: "antenna.radiowaves.left.and.right")
@@ -158,6 +170,8 @@ private enum Sheet: Hashable, Identifiable {
     case tabSettings
     case infoDisplay
     case alarmSettings
+    case apn
+    case liveActivity
     case remote
     case importExport
     case calendar, contact
@@ -177,6 +191,8 @@ private enum Sheet: Hashable, Identifiable {
         case .tabSettings: TabCustomizationModal()
         case .infoDisplay: InfoDisplaySettingsView(viewModel: .init())
         case .alarmSettings: AlarmSettingsView()
+        case .apn: APNSettingsView()
+        case .liveActivity: LiveActivitySettingsView()
         case .remote: RemoteSettingsView(viewModel: .init())
         case .importExport: ImportExportSettingsView()
         case .calendar: CalendarSettingsView()

+ 12 - 0
LoopFollow/Storage/Framework/StorageValue.swift

@@ -40,4 +40,16 @@ class StorageValue<T: Codable & Equatable>: ObservableObject {
     func remove() {
         StorageValue.defaults.removeObject(forKey: key)
     }
+
+    /// Re-reads the value from UserDefaults, updating the @Published cache.
+    /// Call this when the app foregrounds after a Before-First-Unlock background launch,
+    /// where StorageValue was initialized while UserDefaults was locked (returning defaults).
+    func reload() {
+        if let data = StorageValue.defaults.data(forKey: key),
+           let decodedValue = try? JSONDecoder().decode(T.self, from: data),
+           decodedValue != value
+        {
+            value = decodedValue
+        }
+    }
 }

+ 2 - 0
LoopFollow/Storage/Observable.swift

@@ -44,5 +44,7 @@ class Observable {
 
     var loopFollowDeviceToken = ObservableValue<String>(default: "")
 
+    var isNotLooping = ObservableValue<Bool>(default: false)
+
     private init() {}
 }

+ 71 - 4
LoopFollow/Storage/Storage+Migrate.swift

@@ -2,6 +2,7 @@
 // Storage+Migrate.swift
 
 import Foundation
+import UserNotifications
 
 extension Storage {
     func migrateStep5() {
@@ -32,6 +33,61 @@ extension Storage {
         }
     }
 
+    func migrateStep7() {
+        // Cancel notifications scheduled with old hardcoded identifiers.
+        // Replaced with bundle-ID-scoped identifiers for multi-instance support.
+        LogManager.shared.log(category: .general, message: "Running migrateStep7 — cancel legacy notification identifiers")
+
+        let legacyNotificationIDs = [
+            "loopfollow.background.alert.6min",
+            "loopfollow.background.alert.12min",
+            "loopfollow.background.alert.18min",
+            "loopfollow.la.renewal.failed",
+        ]
+        UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: legacyNotificationIDs)
+        UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: legacyNotificationIDs)
+    }
+
+    func migrateStep6() {
+        // APNs credential separation
+        LogManager.shared.log(category: .general, message: "Running migrateStep6 — APNs credential separation")
+
+        let legacyReturnApnsKey = StorageValue<String>(key: "returnApnsKey", defaultValue: "")
+        let legacyReturnKeyId = StorageValue<String>(key: "returnKeyId", defaultValue: "")
+        let legacyApnsKey = StorageValue<String>(key: "apnsKey", defaultValue: "")
+        let legacyKeyId = StorageValue<String>(key: "keyId", defaultValue: "")
+
+        // 1. If returnApnsKey had a value, that was LoopFollow's own key (different team scenario)
+        if legacyReturnApnsKey.exists, !legacyReturnApnsKey.value.isEmpty {
+            lfApnsKey.value = legacyReturnApnsKey.value
+            lfKeyId.value = legacyReturnKeyId.value
+        }
+
+        // 2. If lfApnsKey is still empty and the old primary key exists,
+        //    check if same team — if so, the primary key was used for everything
+        if lfApnsKey.value.isEmpty, legacyApnsKey.exists, !legacyApnsKey.value.isEmpty {
+            let lfTeamId = BuildDetails.default.teamID ?? ""
+            let remoteTeamId = teamId.value ?? ""
+            let sameTeam = !lfTeamId.isEmpty && (remoteTeamId.isEmpty || lfTeamId == remoteTeamId)
+            if sameTeam {
+                lfApnsKey.value = legacyApnsKey.value
+                lfKeyId.value = legacyKeyId.value
+            }
+        }
+
+        // 3. Move old primary credentials to remoteApnsKey/remoteKeyId
+        if legacyApnsKey.exists, !legacyApnsKey.value.isEmpty {
+            remoteApnsKey.value = legacyApnsKey.value
+            remoteKeyId.value = legacyKeyId.value
+        }
+
+        // 4. Clean up old keys
+        legacyReturnApnsKey.remove()
+        legacyReturnKeyId.remove()
+        legacyApnsKey.remove()
+        legacyKeyId.remove()
+    }
+
     func migrateStep3() {
         LogManager.shared.log(category: .general, message: "Running migrateStep3 - this should only happen once!")
         let legacyForceDarkMode = StorageValue<Bool>(key: "forceDarkMode", defaultValue: true)
@@ -85,10 +141,21 @@ extension Storage {
     }
 
     func migrateStep1() {
-        Storage.shared.url.value = ObservableUserDefaults.shared.old_url.value
-        Storage.shared.device.value = ObservableUserDefaults.shared.old_device.value
-        Storage.shared.nsWriteAuth.value = ObservableUserDefaults.shared.old_nsWriteAuth.value
-        Storage.shared.nsAdminAuth.value = ObservableUserDefaults.shared.old_nsAdminAuth.value
+        // Guard each field with .exists so that if the App Group suite is unreadable
+        // (e.g. Before-First-Unlock state after a reboot), we skip the write rather
+        // than overwriting the already-migrated Standard value with an empty default.
+        if ObservableUserDefaults.shared.old_url.exists {
+            Storage.shared.url.value = ObservableUserDefaults.shared.old_url.value
+        }
+        if ObservableUserDefaults.shared.old_device.exists {
+            Storage.shared.device.value = ObservableUserDefaults.shared.old_device.value
+        }
+        if ObservableUserDefaults.shared.old_nsWriteAuth.exists {
+            Storage.shared.nsWriteAuth.value = ObservableUserDefaults.shared.old_nsWriteAuth.value
+        }
+        if ObservableUserDefaults.shared.old_nsAdminAuth.exists {
+            Storage.shared.nsAdminAuth.value = ObservableUserDefaults.shared.old_nsAdminAuth.value
+        }
 
         // Helper: 1-to-1 type -----------------------------------------------------------------
         func move<T: AnyConvertible & Equatable>(

+ 229 - 17
LoopFollow/Storage/Storage.swift

@@ -15,9 +15,12 @@ class Storage {
     var expirationDate = StorageValue<Date?>(key: "expirationDate", defaultValue: nil)
     var sharedSecret = StorageValue<String>(key: "sharedSecret", defaultValue: "")
     var productionEnvironment = StorageValue<Bool>(key: "productionEnvironment", defaultValue: false)
-    var apnsKey = StorageValue<String>(key: "apnsKey", defaultValue: "")
+    var remoteApnsKey = StorageValue<String>(key: "remoteApnsKey", defaultValue: "")
     var teamId = StorageValue<String?>(key: "teamId", defaultValue: nil)
-    var keyId = StorageValue<String>(key: "keyId", defaultValue: "")
+    var remoteKeyId = StorageValue<String>(key: "remoteKeyId", defaultValue: "")
+
+    var lfApnsKey = StorageValue<String>(key: "lfApnsKey", defaultValue: "")
+    var lfKeyId = StorageValue<String>(key: "lfKeyId", defaultValue: "")
     var bundleId = StorageValue<String>(key: "bundleId", defaultValue: "")
     var user = StorageValue<String>(key: "user", defaultValue: "")
 
@@ -32,9 +35,6 @@ class Storage {
     // TODO: This flag can be deleted in March 2027. Check the commit for other places to cleanup.
     var hasSeenFatProteinOrderChange = StorageValue<Bool>(key: "hasSeenFatProteinOrderChange", defaultValue: false)
 
-    var cachedJWT = StorageValue<String?>(key: "cachedJWT", defaultValue: nil)
-    var jwtExpirationDate = StorageValue<Date?>(key: "jwtExpirationDate", defaultValue: nil)
-
     var backgroundRefreshType = StorageValue<BackgroundRefreshType>(key: "backgroundRefreshType", defaultValue: .silentTune)
 
     var selectedBLEDevice = StorageValue<BLEDevice?>(key: "selectedBLEDevice", defaultValue: nil)
@@ -89,6 +89,34 @@ class Storage {
     var speakLanguage = StorageValue<String>(key: "speakLanguage", defaultValue: "en")
     // General Settings [END]
 
+    // Live Activity glucose state
+    var lastBgReadingTimeSeconds = StorageValue<TimeInterval?>(key: "lastBgReadingTimeSeconds", defaultValue: nil)
+    var lastDeltaMgdl = StorageValue<Double?>(key: "lastDeltaMgdl", defaultValue: nil)
+    var lastTrendCode = StorageValue<String?>(key: "lastTrendCode", defaultValue: nil)
+    var lastIOB = StorageValue<Double?>(key: "lastIOB", defaultValue: nil)
+    var lastCOB = StorageValue<Double?>(key: "lastCOB", defaultValue: nil)
+    var projectedBgMgdl = StorageValue<Double?>(key: "projectedBgMgdl", defaultValue: nil)
+
+    // Live Activity extended InfoType data
+    var lastBasal = StorageValue<String>(key: "lastBasal", defaultValue: "")
+    var lastPumpReservoirU = StorageValue<Double?>(key: "lastPumpReservoirU", defaultValue: nil)
+    var lastAutosens = StorageValue<Double?>(key: "lastAutosens", defaultValue: nil)
+    var lastTdd = StorageValue<Double?>(key: "lastTdd", defaultValue: nil)
+    var lastTargetLowMgdl = StorageValue<Double?>(key: "lastTargetLowMgdl", defaultValue: nil)
+    var lastTargetHighMgdl = StorageValue<Double?>(key: "lastTargetHighMgdl", defaultValue: nil)
+    var lastIsfMgdlPerU = StorageValue<Double?>(key: "lastIsfMgdlPerU", defaultValue: nil)
+    var lastCarbRatio = StorageValue<Double?>(key: "lastCarbRatio", defaultValue: nil)
+    var lastCarbsToday = StorageValue<Double?>(key: "lastCarbsToday", defaultValue: nil)
+    var lastProfileName = StorageValue<String>(key: "lastProfileName", defaultValue: "")
+    var iageInsertTime = StorageValue<TimeInterval>(key: "iageInsertTime", defaultValue: 0)
+    var lastMinBgMgdl = StorageValue<Double?>(key: "lastMinBgMgdl", defaultValue: nil)
+    var lastMaxBgMgdl = StorageValue<Double?>(key: "lastMaxBgMgdl", defaultValue: nil)
+
+    // Live Activity
+    var laEnabled = StorageValue<Bool>(key: "laEnabled", defaultValue: false)
+    var laRenewBy = StorageValue<TimeInterval>(key: "laRenewBy", defaultValue: 0)
+    var laRenewalFailed = StorageValue<Bool>(key: "laRenewalFailed", defaultValue: false)
+
     // Graph Settings [BEGIN]
     var showDots = StorageValue<Bool>(key: "showDots", defaultValue: true)
     var showLines = StorageValue<Bool>(key: "showLines", defaultValue: true)
@@ -157,8 +185,8 @@ class Storage {
     var token = StorageValue<String>(key: "token", defaultValue: "")
     var units = StorageValue<String>(key: "units", defaultValue: "mg/dL")
 
-    var infoSort = StorageValue<[Int]>(key: "infoSort", defaultValue: InfoType.allCases.map { $0.sortOrder })
-    var infoVisible = StorageValue<[Bool]>(key: "infoVisible", defaultValue: InfoType.allCases.map { $0.defaultVisible })
+    var infoSort = StorageValue<[Int]>(key: "infoSort", defaultValue: InfoType.allCases.map(\.sortOrder))
+    var infoVisible = StorageValue<[Bool]>(key: "infoVisible", defaultValue: InfoType.allCases.map(\.defaultVisible))
 
     var url = StorageValue<String>(key: "url", defaultValue: "")
     var device = StorageValue<String>(key: "device", defaultValue: "")
@@ -172,6 +200,7 @@ class Storage {
 
     var lastLoopingChecked = StorageValue<Date?>(key: "lastLoopingChecked", defaultValue: nil)
     var lastBGChecked = StorageValue<Date?>(key: "lastBGChecked", defaultValue: nil)
+    var lastLoopTime = StorageValue<TimeInterval>(key: "lastLoopTime", defaultValue: 0)
 
     // Tab positions - which position each item is in (positions 1-4 are customizable, 5 is always Menu)
     var homePosition = StorageValue<TabPosition>(key: "homePosition", defaultValue: .position1)
@@ -184,9 +213,6 @@ class Storage {
 
     var loopAPNSQrCodeURL = StorageValue<String>(key: "loopAPNSQrCodeURL", defaultValue: "")
 
-    var returnApnsKey = StorageValue<String>(key: "returnApnsKey", defaultValue: "")
-    var returnKeyId = StorageValue<String>(key: "returnKeyId", defaultValue: "")
-
     var bolusIncrement = SecureStorageValue<HKQuantity>(key: "bolusIncrement", defaultValue: HKQuantity(unit: .internationalUnit(), doubleValue: 0.05))
     var bolusIncrementDetected = StorageValue<Bool>(key: "bolusIncrementDetected", defaultValue: false)
     // Statistics display preferences
@@ -197,18 +223,204 @@ class Storage {
     static let shared = Storage()
     private init() {}
 
+    /// Set to true at launch if isProtectedDataAvailable was false (BFU state).
+    /// Consumed and cleared on the first foreground after that launch.
+    var needsBFUReload = false
+
+    /// Re-reads every StorageValue from UserDefaults, firing @Published only where the value
+    /// actually changed. Call this when foregrounding after a Before-First-Unlock (BFU) background
+    /// launch, where Storage was initialized while UserDefaults was encrypted and all values were
+    /// cached as their defaults.
+    ///
+    /// `migrationStep` is intentionally excluded: viewDidLoad writes it to 6 during the BFU
+    /// launch; if we reloaded it and the flush had somehow not landed yet, migrations would re-run.
+    ///
+    /// SecureStorageValue properties (maxBolus, maxCarbs, maxProtein, maxFat, bolusIncrement) are
+    /// not covered here — SecureStorageValue does not implement reload() and Keychain has the same
+    /// BFU inaccessibility; that is a separate problem.
+    func reloadAll() {
+        remoteType.reload()
+        deviceToken.reload()
+        expirationDate.reload()
+        sharedSecret.reload()
+        productionEnvironment.reload()
+        remoteApnsKey.reload()
+        teamId.reload()
+        remoteKeyId.reload()
+
+        lfApnsKey.reload()
+        lfKeyId.reload()
+        bundleId.reload()
+        user.reload()
+
+        mealWithBolus.reload()
+        mealWithFatProtein.reload()
+        hasSeenFatProteinOrderChange.reload()
+
+        backgroundRefreshType.reload()
+        selectedBLEDevice.reload()
+        debugLogLevel.reload()
+
+        contactTrend.reload()
+        contactDelta.reload()
+        contactEnabled.reload()
+        contactBackgroundColor.reload()
+        contactTextColor.reload()
+
+        sensorScheduleOffset.reload()
+        alarms.reload()
+        alarmConfiguration.reload()
+
+        lastOverrideStartNotified.reload()
+        lastOverrideEndNotified.reload()
+        lastTempTargetStartNotified.reload()
+        lastTempTargetEndNotified.reload()
+        lastRecBolusNotified.reload()
+        lastCOBNotified.reload()
+        lastMissedBolusNotified.reload()
+
+        appBadge.reload()
+        colorBGText.reload()
+        appearanceMode.reload()
+        showStats.reload()
+        useIFCC.reload()
+        showSmallGraph.reload()
+        screenlockSwitchState.reload()
+        showDisplayName.reload()
+        snoozerEmoji.reload()
+        forcePortraitMode.reload()
+
+        speakBG.reload()
+        speakBGAlways.reload()
+        speakLowBG.reload()
+        speakProactiveLowBG.reload()
+        speakFastDropDelta.reload()
+        speakLowBGLimit.reload()
+        speakHighBGLimit.reload()
+        speakHighBG.reload()
+        speakLanguage.reload()
+
+        lastBgReadingTimeSeconds.reload()
+        lastDeltaMgdl.reload()
+        lastTrendCode.reload()
+        lastIOB.reload()
+        lastCOB.reload()
+        projectedBgMgdl.reload()
+
+        lastBasal.reload()
+        lastPumpReservoirU.reload()
+        lastAutosens.reload()
+        lastTdd.reload()
+        lastTargetLowMgdl.reload()
+        lastTargetHighMgdl.reload()
+        lastIsfMgdlPerU.reload()
+        lastCarbRatio.reload()
+        lastCarbsToday.reload()
+        lastProfileName.reload()
+        iageInsertTime.reload()
+        lastMinBgMgdl.reload()
+        lastMaxBgMgdl.reload()
+
+        laEnabled.reload()
+        laRenewBy.reload()
+        laRenewalFailed.reload()
+
+        showDots.reload()
+        showLines.reload()
+        showValues.reload()
+        showAbsorption.reload()
+        showDIALines.reload()
+        show30MinLine.reload()
+        show90MinLine.reload()
+        showMidnightLines.reload()
+        smallGraphTreatments.reload()
+        smallGraphHeight.reload()
+        predictionToLoad.reload()
+        minBasalScale.reload()
+        minBGScale.reload()
+        lowLine.reload()
+        highLine.reload()
+        downloadDays.reload()
+        graphTimeZoneEnabled.reload()
+        graphTimeZoneIdentifier.reload()
+
+        writeCalendarEvent.reload()
+        calendarIdentifier.reload()
+        watchLine1.reload()
+        watchLine2.reload()
+
+        shareUserName.reload()
+        sharePassword.reload()
+        shareServer.reload()
+
+        chartScaleX.reload()
+
+        downloadTreatments.reload()
+        downloadPrediction.reload()
+        graphOtherTreatments.reload()
+        graphBasal.reload()
+        graphBolus.reload()
+        graphCarbs.reload()
+        bgUpdateDelay.reload()
+
+        cageInsertTime.reload()
+        sageInsertTime.reload()
+
+        cachedForVersion.reload()
+        latestVersion.reload()
+        latestVersionChecked.reload()
+        currentVersionBlackListed.reload()
+        lastBlacklistNotificationShown.reload()
+        lastVersionUpdateNotificationShown.reload()
+        lastExpirationNotificationShown.reload()
+
+        hideInfoTable.reload()
+        token.reload()
+        units.reload()
+        infoSort.reload()
+        infoVisible.reload()
+
+        url.reload()
+        device.reload()
+        nsWriteAuth.reload()
+        nsAdminAuth.reload()
+
+        // migrationStep intentionally excluded — see method comment above.
+
+        persistentNotification.reload()
+        persistentNotificationLastBGTime.reload()
+
+        lastLoopingChecked.reload()
+        lastBGChecked.reload()
+        lastLoopTime.reload()
+
+        homePosition.reload()
+        alarmsPosition.reload()
+        snoozerPosition.reload()
+        nightscoutPosition.reload()
+        remotePosition.reload()
+        statisticsPosition.reload()
+        treatmentsPosition.reload()
+
+        loopAPNSQrCodeURL.reload()
+        bolusIncrementDetected.reload()
+        showGMI.reload()
+        showStdDev.reload()
+        showTITR.reload()
+    }
+
     // MARK: - Tab Position Helpers
 
     /// Get the position for a given tab item
     func position(for item: TabItem) -> TabPosition {
         switch item {
-        case .home: return homePosition.value
-        case .alarms: return alarmsPosition.value
-        case .remote: return remotePosition.value
-        case .nightscout: return nightscoutPosition.value
-        case .snoozer: return snoozerPosition.value
-        case .stats: return statisticsPosition.value
-        case .treatments: return treatmentsPosition.value
+        case .home: homePosition.value
+        case .alarms: alarmsPosition.value
+        case .remote: remotePosition.value
+        case .nightscout: nightscoutPosition.value
+        case .snoozer: snoozerPosition.value
+        case .stats: statisticsPosition.value
+        case .treatments: treatmentsPosition.value
         }
     }
 

+ 148 - 57
LoopFollow/ViewControllers/MainViewController.swift

@@ -15,6 +15,14 @@ func IsNightscoutEnabled() -> Bool {
     return !Storage.shared.url.value.isEmpty
 }
 
+private struct APNSCredentialSnapshot: Equatable {
+    let remoteApnsKey: String
+    let teamId: String?
+    let remoteKeyId: String
+    let lfApnsKey: String
+    let lfKeyId: String
+}
+
 class MainViewController: UIViewController, UITableViewDataSource, ChartViewDelegate, UNUserNotificationCenterDelegate, UIScrollViewDelegate {
     var isPresentedAsModal: Bool = false
 
@@ -142,40 +150,8 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
 
         loadDebugData()
 
-        // Capture before migrations run: true for existing users, false for fresh installs.
-        let isExistingUser = Storage.shared.migrationStep.exists
-
-        // Step 1: Released in v3.0.0 (2025-07-07). Can be removed after 2026-07-07.
-        if Storage.shared.migrationStep.value < 1 {
-            Storage.shared.migrateStep1()
-            Storage.shared.migrationStep.value = 1
-        }
-
-        // Step 2: Released in v3.1.0 (2025-07-21). Can be removed after 2026-07-21.
-        if Storage.shared.migrationStep.value < 2 {
-            Storage.shared.migrateStep2()
-            Storage.shared.migrationStep.value = 2
-        }
-
-        // Step 3: Released in v4.5.0 (2026-02-01). Can be removed after 2027-02-01.
-        if Storage.shared.migrationStep.value < 3 {
-            Storage.shared.migrateStep3()
-            Storage.shared.migrationStep.value = 3
-        }
-
-        // Step 4: Released in v5.0.0 (2026-03-20). Can be removed after 2027-03-20.
-        if Storage.shared.migrationStep.value < 4 {
-            // Existing users need to see the fat/protein order change banner.
-            // New users never saw the old order, so mark it as already seen.
-            Storage.shared.hasSeenFatProteinOrderChange.value = !isExistingUser
-            Storage.shared.migrationStep.value = 4
-        }
-
-        // Step 5: Released in v5.0.0 (2026-03-20). Can be removed after 2027-03-20.
-        if Storage.shared.migrationStep.value < 5 {
-            Storage.shared.migrateStep5()
-            Storage.shared.migrationStep.value = 5
-        }
+        // Migrations run in foreground only — see runMigrationsIfNeeded() for details.
+        runMigrationsIfNeeded()
 
         // Synchronize info types to ensure arrays are the correct size
         synchronizeInfoTypes()
@@ -214,6 +190,11 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
         let notificationCenter = NotificationCenter.default
         notificationCenter.addObserver(self, selector: #selector(appMovedToBackground), name: UIApplication.didEnterBackgroundNotification, object: nil)
         notificationCenter.addObserver(self, selector: #selector(appCameToForeground), name: UIApplication.willEnterForegroundNotification, object: nil)
+        // didBecomeActive is used (not willEnterForeground) to ensure applicationState == .active
+        // when runMigrationsIfNeeded() is called. This catches migrations deferred by a
+        // background BGAppRefreshTask launch in Before-First-Unlock state.
+        notificationCenter.addObserver(self, selector: #selector(appDidBecomeActive), name: UIApplication.didBecomeActiveNotification, object: nil)
+        notificationCenter.addObserver(self, selector: #selector(navigateOnLAForeground), name: .liveActivityDidForeground, object: nil)
 
         // Setup the Graph
         if firstGraphLoad {
@@ -398,29 +379,27 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
             }
             .store(in: &cancellables)
 
-        Storage.shared.apnsKey.$value
-            .receive(on: DispatchQueue.main)
-            .removeDuplicates()
-            .sink { _ in
-                JWTManager.shared.invalidateCache()
-            }
-            .store(in: &cancellables)
-
-        Storage.shared.teamId.$value
-            .receive(on: DispatchQueue.main)
-            .removeDuplicates()
-            .sink { _ in
-                JWTManager.shared.invalidateCache()
-            }
-            .store(in: &cancellables)
-
-        Storage.shared.keyId.$value
-            .receive(on: DispatchQueue.main)
-            .removeDuplicates()
-            .sink { _ in
-                JWTManager.shared.invalidateCache()
-            }
-            .store(in: &cancellables)
+        Publishers.CombineLatest4(
+            Storage.shared.remoteApnsKey.$value,
+            Storage.shared.teamId.$value,
+            Storage.shared.remoteKeyId.$value,
+            Storage.shared.lfApnsKey.$value
+        )
+        .combineLatest(Storage.shared.lfKeyId.$value)
+        .map { values, lfKeyId in
+            APNSCredentialSnapshot(
+                remoteApnsKey: values.0,
+                teamId: values.1,
+                remoteKeyId: values.2,
+                lfApnsKey: values.3,
+                lfKeyId: lfKeyId
+            )
+        }
+        .removeDuplicates()
+        .dropFirst()
+        .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
+        .sink { _ in JWTManager.shared.invalidateCache() }
+        .store(in: &cancellables)
 
         Storage.shared.device.$value
             .receive(on: DispatchQueue.main)
@@ -717,6 +696,28 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
         updateNightscoutTabState()
     }
 
+    @objc private func navigateOnLAForeground() {
+        guard let tabBarController = tabBarController,
+              let vcs = tabBarController.viewControllers, !vcs.isEmpty else { return }
+
+        let targetIndex: Int
+        if Observable.shared.currentAlarm.value != nil,
+           let snoozerIndex = getSnoozerTabIndex(), snoozerIndex < vcs.count
+        {
+            targetIndex = snoozerIndex
+        } else {
+            targetIndex = 0
+        }
+
+        if let presented = tabBarController.presentedViewController {
+            presented.dismiss(animated: false) {
+                tabBarController.selectedIndex = targetIndex
+            }
+        } else {
+            tabBarController.selectedIndex = targetIndex
+        }
+    }
+
     private func getSnoozerTabIndex() -> Int? {
         guard let tabBarController = tabBarController,
               let viewControllers = tabBarController.viewControllers else { return nil }
@@ -973,6 +974,7 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
 
         if Storage.shared.backgroundRefreshType.value == .silentTune {
             backgroundTask.startBackgroundTask()
+            BackgroundRefreshManager.shared.scheduleRefresh()
         }
 
         if Storage.shared.backgroundRefreshType.value != .none {
@@ -980,7 +982,93 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
         }
     }
 
+    // Migrations must only run when UserDefaults is accessible (i.e. after first unlock).
+    // When the app is launched in the background by BGAppRefreshTask immediately after a
+    // reboot, the device may be in Before-First-Unlock (BFU) state: UserDefaults files are
+    // still encrypted, so every read returns the default value (0 / ""). Running migrations
+    // in that state would overwrite real settings with empty strings.
+    //
+    // Strategy: skip migrations if applicationState == .background; call this method again
+    // from appCameToForeground() so they run on the first foreground after a BFU launch.
+    func runMigrationsIfNeeded() {
+        guard UIApplication.shared.applicationState != .background else { return }
+
+        // Capture before migrations run: true for existing users, false for fresh installs.
+        let isExistingUser = Storage.shared.migrationStep.exists
+
+        // Step 1: Released in v3.0.0 (2025-07-07). Can be removed after 2026-07-07.
+        if Storage.shared.migrationStep.value < 1 {
+            Storage.shared.migrateStep1()
+            Storage.shared.migrationStep.value = 1
+        }
+
+        // Step 2: Released in v3.1.0 (2025-07-21). Can be removed after 2026-07-21.
+        if Storage.shared.migrationStep.value < 2 {
+            Storage.shared.migrateStep2()
+            Storage.shared.migrationStep.value = 2
+        }
+
+        // Step 3: Released in v4.5.0 (2026-02-01). Can be removed after 2027-02-01.
+        if Storage.shared.migrationStep.value < 3 {
+            Storage.shared.migrateStep3()
+            Storage.shared.migrationStep.value = 3
+        }
+
+        // Step 4: Released in v5.0.0 (2026-03-20). Can be removed after 2027-03-20.
+        if Storage.shared.migrationStep.value < 4 {
+            // Existing users need to see the fat/protein order change banner.
+            // New users never saw the old order, so mark it as already seen.
+            Storage.shared.hasSeenFatProteinOrderChange.value = !isExistingUser
+            Storage.shared.migrationStep.value = 4
+        }
+
+        // Step 5: Released in v5.0.0 (2026-03-20). Can be removed after 2027-03-20.
+        if Storage.shared.migrationStep.value < 5 {
+            Storage.shared.migrateStep5()
+            Storage.shared.migrationStep.value = 5
+        }
+
+        if Storage.shared.migrationStep.value < 6 {
+            Storage.shared.migrateStep6()
+            Storage.shared.migrationStep.value = 6
+        }
+
+        if Storage.shared.migrationStep.value < 7 {
+            Storage.shared.migrateStep7()
+            Storage.shared.migrationStep.value = 7
+        }
+    }
+
+    @objc func appDidBecomeActive() {
+        // applicationState == .active is guaranteed here, so the BFU guard in
+        // runMigrationsIfNeeded() will always pass. Catches the case where viewDidLoad
+        // ran during a BGAppRefreshTask background launch and deferred migrations.
+        runMigrationsIfNeeded()
+    }
+
     @objc func appCameToForeground() {
+        // If the app was cold-launched in Before-First-Unlock state (e.g. by BGAppRefreshTask
+        // after a reboot), all StorageValues were cached from encrypted UserDefaults and hold
+        // their defaults. Reload everything from disk now that the device is unlocked, firing
+        // Combine observers only for values that actually changed.
+        LogManager.shared.log(category: .general, message: "appCameToForeground: needsBFUReload=\(Storage.shared.needsBFUReload), url='\(Storage.shared.url.value)'")
+        if Storage.shared.needsBFUReload {
+            Storage.shared.needsBFUReload = false
+            LogManager.shared.log(category: .general, message: "BFU reload triggered — reloading all StorageValues")
+            Storage.shared.reloadAll()
+            LogManager.shared.log(category: .general, message: "BFU reload complete: url='\(Storage.shared.url.value)'")
+            // Show the loading overlay so the user sees feedback during the 2-5s
+            // while tasks re-run with the now-correct credentials.
+            loadingStates = ["bg": false, "profile": false, "deviceStatus": false]
+            isInitialLoad = true
+            setupLoadingState()
+            showLoadingOverlay()
+            // Tasks were scheduled during BFU viewDidLoad with url="" — they fired, found no
+            // data source, and rescheduled themselves 60s out. Reset them now so they run
+            // within their normal 2-5s initial delay using the now-correct credentials.
+            scheduleAllTasks()
+        }
+
         // reset screenlock state if needed
         UIApplication.shared.isIdleTimerDisabled = Storage.shared.screenlockSwitchState.value
 
@@ -1053,6 +1141,9 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
 
     @objc override func viewDidAppear(_: Bool) {
         showHideNSDetails()
+        #if !targetEnvironment(macCatalyst)
+            LiveActivityManager.shared.startFromCurrentState()
+        #endif
     }
 
     func stringFromTimeInterval(interval: TimeInterval) -> String {

+ 27 - 0
LoopFollowLAExtension/ExtensionInfo.plist

@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>CFBundleDisplayName</key>
+	<string>LoopFollowLAExtension</string>
+	<key>CFBundleExecutable</key>
+	<string>$(EXECUTABLE_NAME)</string>
+	<key>CFBundleIdentifier</key>
+	<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
+	<key>CFBundleInfoDictionaryVersion</key>
+	<string>6.0</string>
+	<key>CFBundleName</key>
+	<string>$(PRODUCT_NAME)</string>
+	<key>CFBundlePackageType</key>
+	<string>XPC!</string>
+	<key>CFBundleShortVersionString</key>
+	<string>$(MARKETING_VERSION)</string>
+	<key>CFBundleVersion</key>
+	<string>$(CURRENT_PROJECT_VERSION)</string>
+	<key>NSExtension</key>
+	<dict>
+		<key>NSExtensionPointIdentifier</key>
+		<string>com.apple.widgetkit-extension</string>
+	</dict>
+</dict>
+</plist>

+ 12 - 0
LoopFollowLAExtension/LoopFollowLABundle.swift

@@ -0,0 +1,12 @@
+// LoopFollow
+// LoopFollowLABundle.swift
+
+import SwiftUI
+import WidgetKit
+
+@main
+struct LoopFollowLABundle: WidgetBundle {
+    var body: some Widget {
+        LoopFollowLiveActivityWidget()
+    }
+}

+ 752 - 0
LoopFollowLAExtension/LoopFollowLiveActivity.swift

@@ -0,0 +1,752 @@
+// LoopFollow
+// LoopFollowLiveActivity.swift
+
+import ActivityKit
+import SwiftUI
+import WidgetKit
+
+/// Builds the shared Dynamic Island content used by the Live Activity widget.
+private func makeDynamicIsland(context: ActivityViewContext<GlucoseLiveActivityAttributes>) -> DynamicIsland {
+    DynamicIsland {
+        DynamicIslandExpandedRegion(.leading) {
+            Link(destination: URL(string: "\(AppGroupID.urlScheme)://la-tap")!) {
+                DynamicIslandLeadingView(snapshot: context.state.snapshot)
+                    .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay))
+            }
+            .id(context.state.seq)
+        }
+        DynamicIslandExpandedRegion(.trailing) {
+            Link(destination: URL(string: "\(AppGroupID.urlScheme)://la-tap")!) {
+                DynamicIslandTrailingView(snapshot: context.state.snapshot)
+                    .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay))
+            }
+            .id(context.state.seq)
+        }
+        DynamicIslandExpandedRegion(.bottom) {
+            Link(destination: URL(string: "\(AppGroupID.urlScheme)://la-tap")!) {
+                DynamicIslandBottomView(snapshot: context.state.snapshot)
+                    .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay, showText: true))
+            }
+            .id(context.state.seq)
+        }
+    } compactLeading: {
+        DynamicIslandCompactLeadingView(snapshot: context.state.snapshot)
+            .id(context.state.seq)
+    } compactTrailing: {
+        DynamicIslandCompactTrailingView(snapshot: context.state.snapshot)
+            .id(context.state.seq)
+    } minimal: {
+        DynamicIslandMinimalView(snapshot: context.state.snapshot)
+            .id(context.state.seq)
+    }
+    .keylineTint(LAColors.keyline(for: context.state.snapshot).opacity(0.75))
+}
+
+// MARK: - Live Activity widget
+
+/// Single widget for all supported OS versions.
+/// - iOS 18+: enables supplemental `.small` family and routes via `LockScreenFamilyAdaptiveView`.
+/// - iOS 16.1–17.x: uses the regular lock screen view.
+@available(iOSApplicationExtension 16.1, *)
+struct LoopFollowLiveActivityWidget: Widget {
+    var body: some WidgetConfiguration {
+        if #available(iOSApplicationExtension 18.0, *) {
+            return ActivityConfiguration(for: GlucoseLiveActivityAttributes.self) { context in
+                LockScreenFamilyAdaptiveView(state: context.state)
+                    .id(context.state.seq)
+                    .background(
+                        LALivenessMarker(
+                            seq: context.state.seq,
+                            producedAt: context.state.producedAt
+                        )
+                    )
+                    .activitySystemActionForegroundColor(.white)
+                    .applyActivityContentMarginsFixIfAvailable()
+                    .widgetURL(URL(string: "\(AppGroupID.urlScheme)://la-tap")!)
+            } dynamicIsland: { context in
+                makeDynamicIsland(context: context)
+            }
+            .supplementalActivityFamilies([.small])
+        } else {
+            return ActivityConfiguration(for: GlucoseLiveActivityAttributes.self) { context in
+                LockScreenLiveActivityView(state: context.state)
+                    .id(context.state.seq)
+                    .background(
+                        LALivenessMarker(
+                            seq: context.state.seq,
+                            producedAt: context.state.producedAt
+                        )
+                    )
+                    .activitySystemActionForegroundColor(.white)
+                    .activityBackgroundTint(LAColors.backgroundTint(for: context.state.snapshot))
+                    .applyActivityContentMarginsFixIfAvailable()
+                    .widgetURL(URL(string: "\(AppGroupID.urlScheme)://la-tap")!)
+            } dynamicIsland: { context in
+                makeDynamicIsland(context: context)
+            }
+        }
+    }
+}
+
+// MARK: - Live Activity content margins helper
+
+private extension View {
+    @ViewBuilder
+    func applyActivityContentMarginsFixIfAvailable() -> some View {
+        if #available(iOS 17.0, *) {
+            contentMargins(Edge.Set.all, 0)
+        } else {
+            self
+        }
+    }
+}
+
+// MARK: - Family-adaptive wrapper (Lock Screen / CarPlay / Watch Smart Stack)
+
+/// Reads the activityFamily environment value and routes to the appropriate layout.
+/// - `.small` → CarPlay Dashboard & Watch Smart Stack
+/// - everything else → full lock screen layout
+@available(iOS 18.0, *)
+private struct LockScreenFamilyAdaptiveView: View {
+    let state: GlucoseLiveActivityAttributes.ContentState
+
+    @Environment(\.activityFamily) private var activityFamily
+
+    var body: some View {
+        if activityFamily == .small {
+            SmallFamilyView(snapshot: state.snapshot)
+                .activityBackgroundTint(Color.black.opacity(0.25))
+        } else {
+            LockScreenLiveActivityView(state: state)
+                .activityBackgroundTint(LAColors.backgroundTint(for: state.snapshot))
+        }
+    }
+}
+
+// MARK: - Small family view (CarPlay Dashboard + Watch Smart Stack)
+
+@available(iOS 18.0, *)
+private struct SmallFamilyView: View {
+    let snapshot: GlucoseSnapshot
+
+    /// Unit label for the right slot — ISF appends "/U", other glucose slots
+    /// use the plain glucose unit, non-glucose slots return nil.
+    private func rightSlotUnitLabel(for slot: LiveActivitySlotOption) -> String? {
+        guard slot.isGlucoseUnit else { return nil }
+        if slot == .isf { return snapshot.unit.displayName + "/U" }
+        return snapshot.unit.displayName
+    }
+
+    var body: some View {
+        let rightSlot = LAAppGroupSettings.smallWidgetSlot()
+
+        HStack(alignment: .center, spacing: 0) {
+            VStack(alignment: .leading, spacing: 2) {
+                HStack(alignment: .firstTextBaseline, spacing: 4) {
+                    Text(LAFormat.glucose(snapshot))
+                        .font(.system(size: 28, weight: .bold, design: .rounded))
+                        .monospacedDigit()
+                        .foregroundStyle(LAColors.keyline(for: snapshot))
+
+                    Text(LAFormat.trendArrow(snapshot))
+                        .font(.system(size: 22, weight: .semibold, design: .rounded))
+                        .foregroundStyle(LAColors.keyline(for: snapshot))
+                }
+
+                Text("\(LAFormat.delta(snapshot)) \(snapshot.unit.displayName)")
+                    .font(.system(size: 14, weight: .semibold, design: .rounded))
+                    .monospacedDigit()
+                    .foregroundStyle(.white.opacity(0.85))
+            }
+            .layoutPriority(1)
+
+            Spacer()
+
+            if rightSlot != .none {
+                if let unitLabel = rightSlotUnitLabel(for: rightSlot) {
+                    // Use ViewThatFits so the unit label appears on surfaces with
+                    // enough vertical space (CarPlay) and is omitted where it doesn't
+                    // fit (Watch Smart Stack).
+                    ViewThatFits(in: .vertical) {
+                        VStack(alignment: .trailing, spacing: 2) {
+                            Text(rightSlot.gridLabel)
+                                .font(.system(size: 12, weight: .semibold, design: .rounded))
+                                .foregroundStyle(.white.opacity(0.65))
+                            Text(slotFormattedValue(option: rightSlot, snapshot: snapshot))
+                                .font(.system(size: 20, weight: .bold, design: .rounded))
+                                .monospacedDigit()
+                                .foregroundStyle(.white)
+                                .lineLimit(1)
+                                .minimumScaleFactor(0.8)
+                            Text(unitLabel)
+                                .font(.system(size: 11, weight: .regular, design: .rounded))
+                                .foregroundStyle(.white.opacity(0.55))
+                        }
+                        VStack(alignment: .trailing, spacing: 2) {
+                            Text(rightSlot.gridLabel)
+                                .font(.system(size: 12, weight: .semibold, design: .rounded))
+                                .foregroundStyle(.white.opacity(0.65))
+                            Text(slotFormattedValue(option: rightSlot, snapshot: snapshot))
+                                .font(.system(size: 20, weight: .bold, design: .rounded))
+                                .monospacedDigit()
+                                .foregroundStyle(.white)
+                                .lineLimit(1)
+                                .minimumScaleFactor(0.8)
+                        }
+                    }
+                } else {
+                    VStack(alignment: .trailing, spacing: 2) {
+                        Text(rightSlot.gridLabel)
+                            .font(.system(size: 12, weight: .semibold, design: .rounded))
+                            .foregroundStyle(.white.opacity(0.65))
+                        Text(slotFormattedValue(option: rightSlot, snapshot: snapshot))
+                            .font(.system(size: 20, weight: .bold, design: .rounded))
+                            .monospacedDigit()
+                            .foregroundStyle(.white)
+                            .lineLimit(1)
+                            .minimumScaleFactor(0.8)
+                    }
+                }
+            }
+        }
+        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
+        .padding(10)
+    }
+}
+
+// MARK: - Lock Screen Contract View
+
+private struct LockScreenLiveActivityView: View {
+    let state: GlucoseLiveActivityAttributes.ContentState
+
+    var body: some View {
+        let s = state.snapshot
+        let slotConfig = LAAppGroupSettings.slots()
+
+        VStack(spacing: 6) {
+            HStack(spacing: 12) {
+                VStack(alignment: .leading, spacing: 4) {
+                    HStack(alignment: .firstTextBaseline, spacing: 4) {
+                        Text(LAFormat.glucose(s))
+                            .font(.system(size: 46, weight: .bold, design: .rounded))
+                            .monospacedDigit()
+                            .foregroundStyle(.white)
+                            .lineLimit(1)
+                            .minimumScaleFactor(0.78)
+                            .allowsTightening(true)
+                            .layoutPriority(3)
+
+                        Text(LAFormat.trendArrow(s))
+                            .font(.system(size: 32, weight: .bold, design: .rounded))
+                            .foregroundStyle(.white.opacity(0.95))
+                            .lineLimit(1)
+                            .fixedSize(horizontal: true, vertical: false)
+                    }
+
+                    Text("Delta: \(LAFormat.delta(s))")
+                        .font(.system(size: 15, weight: .semibold, design: .rounded))
+                        .monospacedDigit()
+                        .foregroundStyle(.white.opacity(0.80))
+                        .lineLimit(1)
+                }
+                .frame(minWidth: 168, maxWidth: 190, alignment: .leading)
+                .layoutPriority(2)
+
+                Rectangle()
+                    .fill(Color.white.opacity(0.20))
+                    .frame(width: 1)
+                    .padding(.vertical, 8)
+
+                VStack(spacing: 8) {
+                    HStack(spacing: 12) {
+                        SlotView(option: slotConfig[0], snapshot: s)
+                        SlotView(option: slotConfig[1], snapshot: s)
+                    }
+                    HStack(spacing: 12) {
+                        SlotView(option: slotConfig[2], snapshot: s)
+                        SlotView(option: slotConfig[3], snapshot: s)
+                    }
+                }
+                .frame(maxWidth: .infinity, alignment: .trailing)
+            }
+
+            Text(LAAppGroupSettings.showDisplayName()
+                ? "\(LAAppGroupSettings.displayName()) — \(LAFormat.updated(s))"
+                : "Last Update: \(LAFormat.updated(s))")
+                .font(.system(size: 11, weight: .regular, design: .rounded))
+                .monospacedDigit()
+                .foregroundStyle(.white.opacity(0.65))
+                .frame(maxWidth: .infinity, alignment: .center)
+        }
+        .frame(maxWidth: .infinity, alignment: .leading)
+        .padding(.horizontal, 14)
+        .padding(.top, 12)
+        .padding(.bottom, 8)
+        .overlay(
+            RoundedRectangle(cornerRadius: 16, style: .continuous)
+                .stroke(Color.white.opacity(0.20), lineWidth: 1)
+        )
+        .overlay(
+            Group {
+                if state.snapshot.isNotLooping {
+                    ZStack {
+                        RoundedRectangle(cornerRadius: 16, style: .continuous)
+                            .fill(Color(uiColor: UIColor.systemRed).opacity(0.85))
+
+                        Text("Not Looping")
+                            .font(.system(size: 20, weight: .heavy, design: .rounded))
+                            .foregroundStyle(.white)
+                            .tracking(1.5)
+                    }
+                }
+            }
+        )
+        .overlay(
+            ZStack {
+                RoundedRectangle(cornerRadius: 16, style: .continuous)
+                    .fill(Color.gray.opacity(0.9))
+
+                Text("Tap to update")
+                    .font(.system(size: 20, weight: .semibold))
+                    .foregroundStyle(.white)
+            }
+            .opacity(state.snapshot.showRenewalOverlay ? 1 : 0)
+        )
+    }
+}
+
+/// Full-size gray overlay shown 30 minutes before the LA renewal deadline.
+/// Applied to both the lock screen view and each expanded Dynamic Island region.
+private struct RenewalOverlayView: View {
+    let show: Bool
+    var showText: Bool = false
+
+    var body: some View {
+        ZStack {
+            Color.gray.opacity(0.9)
+            if showText {
+                Text("Tap to update")
+                    .font(.system(size: 14, weight: .semibold))
+                    .foregroundStyle(.white)
+            }
+        }
+        .opacity(show ? 1 : 0)
+    }
+}
+
+private struct MetricBlock: View {
+    let label: String
+    let value: String
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: 2) {
+            Text(label)
+                .font(.system(size: 12, weight: .semibold, design: .rounded))
+                .foregroundStyle(.white.opacity(0.78))
+
+            Text(value)
+                .font(.system(size: 16, weight: .bold, design: .rounded))
+                .monospacedDigit()
+                .foregroundStyle(.white)
+                .lineLimit(1)
+                .minimumScaleFactor(0.85)
+        }
+        .frame(width: 60, alignment: .leading)
+    }
+}
+
+private func slotFormattedValue(option: LiveActivitySlotOption, snapshot: GlucoseSnapshot) -> String {
+    switch option {
+    case .none: ""
+    case .delta: LAFormat.delta(snapshot)
+    case .projectedBG: LAFormat.projected(snapshot)
+    case .minMax: LAFormat.minMax(snapshot)
+    case .iob: LAFormat.iob(snapshot)
+    case .cob: LAFormat.cob(snapshot)
+    case .recBolus: LAFormat.recBolus(snapshot)
+    case .autosens: LAFormat.autosens(snapshot)
+    case .tdd: LAFormat.tdd(snapshot)
+    case .basal: LAFormat.basal(snapshot)
+    case .pump: LAFormat.pump(snapshot)
+    case .pumpBattery: LAFormat.pumpBattery(snapshot)
+    case .battery: LAFormat.battery(snapshot)
+    case .target: LAFormat.target(snapshot)
+    case .isf: LAFormat.isf(snapshot)
+    case .carbRatio: LAFormat.carbRatio(snapshot)
+    case .sage: LAFormat.age(insertTime: snapshot.sageInsertTime)
+    case .cage: LAFormat.age(insertTime: snapshot.cageInsertTime)
+    case .iage: LAFormat.age(insertTime: snapshot.iageInsertTime)
+    case .carbsToday: LAFormat.carbsToday(snapshot)
+    case .override: LAFormat.override(snapshot)
+    case .profile: LAFormat.profileName(snapshot)
+    }
+}
+
+private struct SlotView: View {
+    let option: LiveActivitySlotOption
+    let snapshot: GlucoseSnapshot
+
+    var body: some View {
+        if option == .none {
+            Color.clear
+                .frame(width: 60, height: 36)
+        } else {
+            MetricBlock(label: option.gridLabel, value: slotFormattedValue(option: option, snapshot: snapshot))
+        }
+    }
+}
+
+// MARK: - Dynamic Island
+
+private struct DynamicIslandLeadingView: View {
+    let snapshot: GlucoseSnapshot
+
+    var body: some View {
+        if snapshot.isNotLooping {
+            Text("⚠️ Not Looping")
+                .font(.system(size: 20, weight: .heavy, design: .rounded))
+                .foregroundStyle(.white)
+                .tracking(1.0)
+                .lineLimit(1)
+                .minimumScaleFactor(0.7)
+        } else {
+            VStack(alignment: .leading, spacing: 2) {
+                HStack(alignment: .firstTextBaseline, spacing: 4) {
+                    Text(LAFormat.glucose(snapshot))
+                        .font(.system(size: 28, weight: .bold, design: .rounded))
+                        .monospacedDigit()
+                        .foregroundStyle(LAColors.keyline(for: snapshot))
+                    Text(LAFormat.trendArrow(snapshot))
+                        .font(.system(size: 22, weight: .semibold, design: .rounded))
+                        .foregroundStyle(LAColors.keyline(for: snapshot))
+                }
+                Text("\(LAFormat.delta(snapshot)) \(snapshot.unit.displayName)")
+                    .font(.system(size: 13, weight: .semibold, design: .rounded))
+                    .monospacedDigit()
+                    .foregroundStyle(.white.opacity(0.85))
+            }
+        }
+    }
+}
+
+private struct DynamicIslandTrailingView: View {
+    let snapshot: GlucoseSnapshot
+
+    var body: some View {
+        if snapshot.isNotLooping {
+            EmptyView()
+        } else {
+            let slot = LAAppGroupSettings.smallWidgetSlot()
+            if slot != .none {
+                VStack(alignment: .trailing, spacing: 2) {
+                    Text(slot.gridLabel)
+                        .font(.system(size: 11, weight: .semibold, design: .rounded))
+                        .foregroundStyle(.white.opacity(0.65))
+                    Text(slotFormattedValue(option: slot, snapshot: snapshot))
+                        .font(.system(size: 18, weight: .bold, design: .rounded))
+                        .monospacedDigit()
+                        .foregroundStyle(.white)
+                        .lineLimit(1)
+                        .minimumScaleFactor(0.8)
+                }
+                .padding(.trailing, 6)
+            }
+        }
+    }
+}
+
+private struct DynamicIslandBottomView: View {
+    let snapshot: GlucoseSnapshot
+
+    var body: some View {
+        if snapshot.isNotLooping {
+            Text("Loop has not reported in 15+ minutes")
+                .font(.system(size: 12, weight: .semibold, design: .rounded))
+                .foregroundStyle(.white.opacity(0.92))
+                .lineLimit(1)
+                .minimumScaleFactor(0.75)
+        } else {
+            Text("Updated at: \(LAFormat.updated(snapshot))")
+                .font(.system(size: 13, weight: .semibold, design: .rounded))
+                .foregroundStyle(.white.opacity(0.92))
+                .lineLimit(1)
+                .minimumScaleFactor(0.85)
+        }
+    }
+}
+
+private struct DynamicIslandCompactTrailingView: View {
+    let snapshot: GlucoseSnapshot
+
+    var body: some View {
+        if snapshot.isNotLooping {
+            Text("Not Looping")
+                .font(.system(size: 11, weight: .heavy, design: .rounded))
+                .foregroundStyle(.white)
+                .lineLimit(1)
+                .minimumScaleFactor(0.7)
+        } else {
+            Text(LAFormat.delta(snapshot))
+                .font(.system(size: 14, weight: .semibold, design: .rounded))
+                .monospacedDigit()
+                .foregroundStyle(.white.opacity(0.95))
+        }
+    }
+}
+
+private struct DynamicIslandCompactLeadingView: View {
+    let snapshot: GlucoseSnapshot
+
+    var body: some View {
+        if snapshot.isNotLooping {
+            Text("⚠️")
+                .font(.system(size: 14))
+        } else {
+            Text(LAFormat.glucose(snapshot))
+                .font(.system(size: 16, weight: .bold, design: .rounded))
+                .monospacedDigit()
+                .foregroundStyle(.white)
+        }
+    }
+}
+
+private struct DynamicIslandMinimalView: View {
+    let snapshot: GlucoseSnapshot
+
+    var body: some View {
+        if snapshot.isNotLooping {
+            Text("⚠️")
+                .font(.system(size: 12))
+        } else {
+            Text(LAFormat.glucose(snapshot))
+                .font(.system(size: 14, weight: .bold, design: .rounded))
+                .monospacedDigit()
+                .foregroundStyle(.white)
+        }
+    }
+}
+
+// MARK: - Formatting
+
+private enum LAFormat {
+    private static let mgdlFormatter: NumberFormatter = {
+        let nf = NumberFormatter()
+        nf.numberStyle = .decimal
+        nf.maximumFractionDigits = 0
+        nf.locale = .current
+        return nf
+    }()
+
+    private static let mmolFormatter: NumberFormatter = {
+        let nf = NumberFormatter()
+        nf.numberStyle = .decimal
+        nf.minimumFractionDigits = 1
+        nf.maximumFractionDigits = 1
+        nf.locale = .current
+        return nf
+    }()
+
+    private static func formatGlucoseValue(_ mgdl: Double, unit: GlucoseSnapshot.Unit) -> String {
+        switch unit {
+        case .mgdl:
+            return mgdlFormatter.string(from: NSNumber(value: round(mgdl))) ?? "\(Int(round(mgdl)))"
+        case .mmol:
+            let mmol = GlucoseConversion.toMmol(mgdl)
+            return mmolFormatter.string(from: NSNumber(value: mmol)) ?? String(format: "%.1f", mmol)
+        }
+    }
+
+    static func glucose(_ s: GlucoseSnapshot) -> String {
+        formatGlucoseValue(s.glucose, unit: s.unit)
+    }
+
+    static func delta(_ s: GlucoseSnapshot) -> String {
+        switch s.unit {
+        case .mgdl:
+            let v = Int(round(s.delta))
+            if v == 0 { return "0" }
+            return v > 0 ? "+\(v)" : "\(v)"
+        case .mmol:
+            let mmol = GlucoseConversion.toMmol(s.delta)
+            let d = (abs(mmol) < 0.05) ? 0.0 : mmol
+            if d == 0 { return mmolFormatter.string(from: 0) ?? "0.0" }
+            let formatted = mmolFormatter.string(from: NSNumber(value: abs(d))) ?? String(format: "%.1f", abs(d))
+            return d > 0 ? "+\(formatted)" : "-\(formatted)"
+        }
+    }
+
+    static func trendArrow(_ s: GlucoseSnapshot) -> String {
+        switch s.trend {
+        case .upFast: "↑↑"
+        case .up: "↑"
+        case .upSlight: "↗"
+        case .flat: "→"
+        case .downSlight: "↘︎"
+        case .down: "↓"
+        case .downFast: "↓↓"
+        case .unknown: "–"
+        }
+    }
+
+    static func iob(_ s: GlucoseSnapshot) -> String {
+        guard let v = s.iob else { return "—" }
+        return String(format: "%.1f", v)
+    }
+
+    static func cob(_ s: GlucoseSnapshot) -> String {
+        guard let v = s.cob else { return "—" }
+        return String(Int(round(v)))
+    }
+
+    static func projected(_ s: GlucoseSnapshot) -> String {
+        guard let v = s.projected else { return "—" }
+        return formatGlucoseValue(v, unit: s.unit)
+    }
+
+    private static let ageFormatter: DateComponentsFormatter = {
+        let f = DateComponentsFormatter()
+        f.unitsStyle = .positional
+        f.allowedUnits = [.day, .hour]
+        f.zeroFormattingBehavior = [.pad]
+        return f
+    }()
+
+    static func age(insertTime: TimeInterval) -> String {
+        guard insertTime > 0 else { return "—" }
+        let secondsAgo = Date().timeIntervalSince1970 - insertTime
+        return ageFormatter.string(from: secondsAgo) ?? "—"
+    }
+
+    static func recBolus(_ s: GlucoseSnapshot) -> String {
+        guard let v = s.recBolus else { return "—" }
+        return String(format: "%.2fU", v)
+    }
+
+    static func autosens(_ s: GlucoseSnapshot) -> String {
+        guard let v = s.autosens else { return "—" }
+        return String(format: "%.0f%%", v * 100)
+    }
+
+    static func tdd(_ s: GlucoseSnapshot) -> String {
+        guard let v = s.tdd else { return "—" }
+        return String(format: "%.1fU", v)
+    }
+
+    static func basal(_ s: GlucoseSnapshot) -> String {
+        s.basalRate.isEmpty ? "—" : s.basalRate
+    }
+
+    static func pump(_ s: GlucoseSnapshot) -> String {
+        guard let v = s.pumpReservoirU else { return "50+U" }
+        return "\(Int(round(v)))U"
+    }
+
+    static func pumpBattery(_ s: GlucoseSnapshot) -> String {
+        guard let v = s.pumpBattery else { return "—" }
+        return String(format: "%.0f%%", v)
+    }
+
+    static func battery(_ s: GlucoseSnapshot) -> String {
+        guard let v = s.battery else { return "—" }
+        return String(format: "%.0f%%", v)
+    }
+
+    static func target(_ s: GlucoseSnapshot) -> String {
+        guard let low = s.targetLowMgdl, low > 0 else { return "—" }
+        let lowStr = formatGlucoseValue(low, unit: s.unit)
+        if let high = s.targetHighMgdl, high > 0, abs(high - low) > 0.5 {
+            return "\(lowStr)-\(formatGlucoseValue(high, unit: s.unit))"
+        }
+        return lowStr
+    }
+
+    static func isf(_ s: GlucoseSnapshot) -> String {
+        guard let v = s.isfMgdlPerU, v > 0 else { return "—" }
+        return formatGlucoseValue(v, unit: s.unit)
+    }
+
+    static func carbRatio(_ s: GlucoseSnapshot) -> String {
+        guard let v = s.carbRatio, v > 0 else { return "—" }
+        return String(format: "%.0fg", v)
+    }
+
+    static func carbsToday(_ s: GlucoseSnapshot) -> String {
+        guard let v = s.carbsToday else { return "—" }
+        return "\(Int(round(v)))g"
+    }
+
+    static func minMax(_ s: GlucoseSnapshot) -> String {
+        guard let mn = s.minBgMgdl, let mx = s.maxBgMgdl else { return "—" }
+        return "\(formatGlucoseValue(mn, unit: s.unit))/\(formatGlucoseValue(mx, unit: s.unit))"
+    }
+
+    static func override(_ s: GlucoseSnapshot) -> String {
+        s.override ?? "—"
+    }
+
+    static func profileName(_ s: GlucoseSnapshot) -> String {
+        s.profileName ?? "—"
+    }
+
+    private static let hhmmFormatter: DateFormatter = {
+        let df = DateFormatter()
+        df.locale = .current
+        df.timeZone = .current
+        df.dateFormat = "HH:mm"
+        return df
+    }()
+
+    private static let hhmmssFormatter: DateFormatter = {
+        let df = DateFormatter()
+        df.locale = .current
+        df.timeZone = .current
+        df.dateFormat = "HH:mm:ss"
+        return df
+    }()
+
+    static func hhmmss(_ date: Date) -> String {
+        hhmmssFormatter.string(from: date)
+    }
+
+    static func updated(_ s: GlucoseSnapshot) -> String {
+        hhmmFormatter.string(from: s.updatedAt)
+    }
+}
+
+// MARK: - Threshold-driven colors
+
+private enum LAColors {
+    static func backgroundTint(for snapshot: GlucoseSnapshot) -> Color {
+        let mgdl = snapshot.glucose
+        let t = LAAppGroupSettings.thresholdsMgdl()
+        let low = t.low
+        let high = t.high
+
+        if mgdl < low {
+            let raw = 0.48 + (0.85 - 0.48) * ((low - mgdl) / (low - 54.0))
+            let opacity = min(max(raw, 0.48), 0.85)
+            return Color(uiColor: UIColor.systemRed).opacity(opacity)
+        } else if mgdl > high {
+            let raw = 0.44 + (0.85 - 0.44) * ((mgdl - high) / (324.0 - high))
+            let opacity = min(max(raw, 0.44), 0.85)
+            return Color(uiColor: UIColor.systemOrange).opacity(opacity)
+        } else {
+            return Color(uiColor: UIColor.systemGreen).opacity(0.36)
+        }
+    }
+
+    static func keyline(for snapshot: GlucoseSnapshot) -> Color {
+        let mgdl = snapshot.glucose
+        let t = LAAppGroupSettings.thresholdsMgdl()
+        let low = t.low
+        let high = t.high
+
+        if mgdl < low {
+            return Color(uiColor: UIColor.systemRed)
+        } else if mgdl > high {
+            return Color(uiColor: UIColor.systemOrange)
+        } else {
+            return Color(uiColor: UIColor.systemGreen)
+        }
+    }
+}

+ 10 - 0
LoopFollowLAExtensionExtension.entitlements

@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>com.apple.security.application-groups</key>
+	<array>
+		<string>group.com.$(unique_id).LoopFollow$(app_suffix)</string>
+	</array>
+</dict>
+</plist>

+ 9 - 0
Podfile

@@ -7,6 +7,15 @@ target 'LoopFollow' do
 end
 
 post_install do |installer|
+  # Set minimum deployment target for all pods to match the app (suppresses deprecation warnings)
+  installer.pods_project.targets.each do |target|
+    target.build_configurations.each do |config|
+      if config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'].to_f < 16.6
+        config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '16.6'
+      end
+    end
+  end
+
   # Patch Charts Transformer to avoid "CGAffineTransformInvert: singular matrix"
   # warnings when chart views have zero dimensions (before layout).
   transformer = 'Pods/Charts/Source/Charts/Utils/Transformer.swift'

+ 45 - 0
RestartLiveActivityIntent.swift

@@ -0,0 +1,45 @@
+// LoopFollow
+// RestartLiveActivityIntent.swift
+
+import AppIntents
+import UIKit
+
+@available(iOS 16.4, *)
+struct RestartLiveActivityIntent: AppIntent, ForegroundContinuableIntent {
+    static var title: LocalizedStringResource = "Restart Live Activity"
+    static var description = IntentDescription("Starts or restarts the LoopFollow Live Activity.")
+
+    func perform() async throws -> some IntentResult & ProvidesDialog {
+        Storage.shared.laEnabled.value = true
+
+        let keyId = Storage.shared.lfKeyId.value
+        let apnsKey = Storage.shared.lfApnsKey.value
+
+        if keyId.isEmpty || apnsKey.isEmpty {
+            if let url = URL(string: "loopfollow://settings/live-activity") {
+                await MainActor.run { UIApplication.shared.open(url) }
+            }
+            return .result(dialog: "Please enter your APNs credentials in LoopFollow settings to use the Live Activity.")
+        }
+
+        if #available(iOS 26.0, *) {
+            try await continueInForeground()
+        }
+
+        await MainActor.run { LiveActivityManager.shared.forceRestart() }
+
+        return .result(dialog: "Live Activity restarted.")
+    }
+}
+
+@available(iOS 16.4, *)
+struct LoopFollowAppShortcuts: AppShortcutsProvider {
+    static var appShortcuts: [AppShortcut] {
+        AppShortcut(
+            intent: RestartLiveActivityIntent(),
+            phrases: ["Restart Live Activity in \(.applicationName)"],
+            shortTitle: "Restart Live Activity",
+            systemImageName: "dot.radiowaves.left.and.right"
+        )
+    }
+}

+ 19 - 2
fastlane/Fastfile

@@ -55,7 +55,8 @@ platform :ios do
       type: "appstore",
       git_basic_authorization: Base64.strict_encode64("#{GITHUB_REPOSITORY_OWNER}:#{GH_PAT}"),
       app_identifier: [
-        "com.#{TEAMID}.LoopFollow"
+        "com.#{TEAMID}.LoopFollow",
+	"com.#{TEAMID}.LoopFollow.LoopFollowLAExtension"
       ]
     )
 
@@ -70,13 +71,26 @@ platform :ios do
       targets: ["LoopFollow"]
     )
 
+    update_code_signing_settings(
+      path: "#{GITHUB_WORKSPACE}/LoopFollow.xcodeproj",    
+      profile_name: mapping["com.#{TEAMID}.LoopFollow.LoopFollowLAExtension"],
+      code_sign_identity: "iPhone Distribution",
+      targets: ["LoopFollowLAExtensionExtension"]
+    )
+
     gym(
       export_method: "app-store",
       scheme: "LoopFollow",
       output_name: "LoopFollow.ipa",
       configuration: "Release",
       destination: 'generic/platform=iOS',
-      buildlog_path: 'buildlog'
+      buildlog_path: 'buildlog',
+      export_options: {
+        provisioningProfiles: {
+          "com.#{TEAMID}.LoopFollow" => mapping["com.#{TEAMID}.LoopFollow"],
+          "com.#{TEAMID}.LoopFollow.LoopFollowLAExtension" => mapping["com.#{TEAMID}.LoopFollow.LoopFollowLAExtension"]
+        }
+      }	
     )
 
     copy_artifacts(
@@ -128,6 +142,8 @@ platform :ios do
       Spaceship::ConnectAPI::BundleIdCapability::Type::PUSH_NOTIFICATIONS
     ])
     
+    configure_bundle_id("LoopFollow Live Activity Extension", "com.#{TEAMID}.LoopFollow.LoopFollowLAExtension", [])
+
   end
 
   desc "Provision Certificates"
@@ -148,6 +164,7 @@ platform :ios do
       git_basic_authorization: Base64.strict_encode64("#{GITHUB_REPOSITORY_OWNER}:#{GH_PAT}"),
       app_identifier: [
         "com.#{TEAMID}.LoopFollow",
+	"com.#{TEAMID}.LoopFollow.LoopFollowLAExtension"
       ]
     )
   end