From cf27620552c5380c262d2c62cf9945494372daa2 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 7 Mar 2026 17:05:10 -0500 Subject: [PATCH 01/86] =?UTF-8?q?feat:=20Live=20Activity=20=E2=80=94=20Pha?= =?UTF-8?q?se=201=20(lock=20screen=20+=20Dynamic=20Island,=20APNs=20self-p?= =?UTF-8?q?ush)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements a lock screen and Dynamic Island Live Activity for LoopFollow displaying real-time glucose data updated via APNs self-push. ## What's included - Lock screen card: glucose + trend arrow, delta, IOB, COB, projected, last update time, threshold-driven background color (green/orange/red) - Dynamic Island: compact, expanded, and minimal presentations - Not Looping overlay: red banner when Loop hasn't reported in 15+ min - APNs self-push: app sends push to itself for reliable background updates without interference from background audio session - Single source of truth: all data flows from Storage/Observable - Source-agnostic: IOB/COB/projected are optional, safe for Dexcom-only users - Dynamic App Group ID: derived from bundle identifier, no hardcoded team IDs - APNs key injected via xcconfig/Info.plist — never bundled, never committed ## Files added - LoopFollow/LiveActivity/: APNSClient, APNSJWTGenerator, AppGroupID, GlucoseLiveActivityAttributes, GlucoseSnapshot, GlucoseSnapshotBuilder, GlucoseSnapshotStore, GlucoseUnitConversion, LAAppGroupSettings, LAThresholdSync, LiveActivityManager, PreferredGlucoseUnit, StorageCurrentGlucoseStateProvider - LoopFollowLAExtension/: LoopFollowLiveActivity, LoopFollowLABundle - docs/LiveActivity.md (architecture + APNs setup guide) ## Files modified - Storage: added lastBgReadingTimeSeconds, lastDeltaMgdl, lastTrendCode, lastIOB, lastCOB, projectedBgMgdl - Observable: added isNotLooping - BGData, DeviceStatusLoop, DeviceStatusOpenAPS: write canonical values to Storage - DeviceStatus: write isNotLooping to Observable - BackgroundTaskAudio: cleanup - MainViewController: wired LiveActivityManager.refreshFromCurrentState() - Info.plist: added APNSKeyID, APNSTeamID, APNSKeyContent build settings - fastlane/Fastfile: added extension App ID and provisioning profile - build_LoopFollow.yml: inject APNs key from GitHub secret --- .github/workflows/build_LoopFollow.yml | 10 + LoopFollow.xcodeproj/project.pbxproj | 308 +++++++++++- .../Controllers/Nightscout/BGData.swift | 12 + .../Controllers/Nightscout/DeviceStatus.swift | 2 + .../Nightscout/DeviceStatusLoop.swift | 10 + .../Nightscout/DeviceStatusOpenAPS.swift | 5 + LoopFollow/Helpers/BackgroundTaskAudio.swift | 21 +- LoopFollow/Info.plist | 12 +- LoopFollow/LiveActivity/APNSClient.swift | 111 +++++ .../LiveActivity/APNSJWTGenerator.swift | 116 +++++ LoopFollow/LiveActivity/AppGroupID.swift | 66 +++ .../GlucoseLiveActivityAttributes.swift | 29 ++ LoopFollow/LiveActivity/GlucoseSnapshot.swift | 113 +++++ .../LiveActivity/GlucoseSnapshotBuilder.swift | 117 +++++ .../LiveActivity/GlucoseSnapshotStore.swift | 80 ++++ .../LiveActivity/GlucoseUnitConversion.swift | 28 ++ .../LiveActivity/LAAppGroupSettings.swift | 39 ++ LoopFollow/LiveActivity/LAThresholdSync.swift | 22 + .../LiveActivity/LiveActivityManager.swift | 254 ++++++++++ .../LiveActivity/PreferredGlucoseUnit.swift | 34 ++ .../StorageCurrentGlucoseStateProvider.swift | 49 ++ LoopFollow/Loop Follow.entitlements | 4 + LoopFollow/Storage/Observable.swift | 2 + LoopFollow/Storage/Storage.swift | 8 + .../ViewControllers/MainViewController.swift | 2 +- LoopFollowLAExtension/ExtensionInfo.plist | 27 ++ .../LoopFollowLABundle.swift | 24 + .../LoopFollowLiveActivity.swift | 440 ++++++++++++++++++ LoopFollowLAExtensionExtension.entitlements | 10 + docs/LiveActivity.md | 166 +++++++ fastlane/Fastfile | 21 +- 31 files changed, 2115 insertions(+), 27 deletions(-) create mode 100644 LoopFollow/LiveActivity/APNSClient.swift create mode 100644 LoopFollow/LiveActivity/APNSJWTGenerator.swift create mode 100644 LoopFollow/LiveActivity/AppGroupID.swift create mode 100644 LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift create mode 100644 LoopFollow/LiveActivity/GlucoseSnapshot.swift create mode 100644 LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift create mode 100644 LoopFollow/LiveActivity/GlucoseSnapshotStore.swift create mode 100644 LoopFollow/LiveActivity/GlucoseUnitConversion.swift create mode 100644 LoopFollow/LiveActivity/LAAppGroupSettings.swift create mode 100644 LoopFollow/LiveActivity/LAThresholdSync.swift create mode 100644 LoopFollow/LiveActivity/LiveActivityManager.swift create mode 100644 LoopFollow/LiveActivity/PreferredGlucoseUnit.swift create mode 100644 LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift create mode 100644 LoopFollowLAExtension/ExtensionInfo.plist create mode 100644 LoopFollowLAExtension/LoopFollowLABundle.swift create mode 100644 LoopFollowLAExtension/LoopFollowLiveActivity.swift create mode 100644 LoopFollowLAExtensionExtension.entitlements create mode 100644 docs/LiveActivity.md diff --git a/.github/workflows/build_LoopFollow.yml b/.github/workflows/build_LoopFollow.yml index 0ad0e814a..361dcacdc 100644 --- a/.github/workflows/build_LoopFollow.yml +++ b/.github/workflows/build_LoopFollow.yml @@ -203,6 +203,16 @@ jobs: - name: Sync clock run: sudo sntp -sS time.windows.com + - name: Inject APNs Key Content + env: + APNS_KEY: ${{ secrets.APNS_KEY }} + APNS_KEY_ID: ${{ secrets.APNS_KEY_ID }} + run: | + # Strip PEM headers, footers, and newlines — xcconfig requires single line + APNS_KEY_CONTENT=$(echo "$APNS_KEY" | grep -v "BEGIN\|END" | tr -d '\n\r ') + echo "APNS_KEY_ID = ${APNS_KEY_ID}" >> "${GITHUB_WORKSPACE}/LoopFollowConfigOverride.xcconfig" + echo "APNS_KEY_CONTENT = ${APNS_KEY_CONTENT}" >> "${GITHUB_WORKSPACE}/LoopFollowConfigOverride.xcconfig" + # Build signed LoopFollow IPA file - name: Fastlane Build & Archive run: bundle exec fastlane build_LoopFollow diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index e5cee0dc2..f85491fc1 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -7,6 +7,27 @@ objects = { /* Begin PBXBuildFile section */ + 374A77972F5BD78700E96858 /* APNSJWTGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77962F5BD77500E96858 /* APNSJWTGenerator.swift */; }; + 374A77992F5BD8B200E96858 /* APNSClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77982F5BD8AB00E96858 /* APNSClient.swift */; }; + 374A77A42F5BE17000E96858 /* GlucoseUnitConversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A22F5BE17000E96858 /* GlucoseUnitConversion.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 */; }; + 374A77A92F5BE17000E96858 /* GlucoseUnitConversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A22F5BE17000E96858 /* GlucoseUnitConversion.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 */; }; + 374A77B62F5BE1AC00E96858 /* LAThresholdSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77B02F5BE1AC00E96858 /* LAThresholdSync.swift */; }; + 374A77B72F5BE1AC00E96858 /* LiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77B12F5BE1AC00E96858 /* LiveActivityManager.swift */; }; + 374A77B82F5BE1AC00E96858 /* GlucoseSnapshotStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77AF2F5BE1AC00E96858 /* GlucoseSnapshotStore.swift */; }; + 374A77B92F5BE1AC00E96858 /* PreferredGlucoseUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77B22F5BE1AC00E96858 /* PreferredGlucoseUnit.swift */; }; + 37A4BDDB2F5B6B4A00EEB289 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 37A4BDDA2F5B6B4A00EEB289 /* WidgetKit.framework */; }; + 37A4BDDD2F5B6B4A00EEB289 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 37A4BDDC2F5B6B4A00EEB289 /* SwiftUI.framework */; }; + 37A4BDE82F5B6B4C00EEB289 /* LoopFollowLAExtensionExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 3F1335F351590E573D8E6962 /* Pods_LoopFollow.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */; }; 654132E72E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654132E62E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift */; }; 654132EA2E19F24800BDBE08 /* TOTPGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654132E92E19F24800BDBE08 /* TOTPGenerator.swift */; }; @@ -198,14 +219,14 @@ DDC7E5472DBD8A1600EB1127 /* AlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC7E5402DBD8A1600EB1127 /* AlarmEditor.swift */; }; DDC7E5CF2DC77C2000EB1127 /* SnoozerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC7E5CE2DC77C2000EB1127 /* SnoozerViewModel.swift */; }; DDCC3A4B2DDBB5E4006F1C10 /* BatteryCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A4A2DDBB5E4006F1C10 /* BatteryCondition.swift */; }; - DDCC3A502DDED000006F1C10 /* PumpBatteryCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A512DDED000006F1C10 /* PumpBatteryCondition.swift */; }; DDCC3A4D2DDBB77C006F1C10 /* PhoneBatteryAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A4C2DDBB77C006F1C10 /* PhoneBatteryAlarmEditor.swift */; }; - DDCC3A5B2DDE2000006F1C10 /* PumpBatteryAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A5C2DDE2000006F1C10 /* PumpBatteryAlarmEditor.swift */; }; DDCC3A4F2DDC5B54006F1C10 /* BatteryDropCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A4E2DDC5B54006F1C10 /* BatteryDropCondition.swift */; }; + DDCC3A502DDED000006F1C10 /* PumpBatteryCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A512DDED000006F1C10 /* PumpBatteryCondition.swift */; }; DDCC3A542DDC5D62006F1C10 /* BatteryDropAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A532DDC5D62006F1C10 /* BatteryDropAlarmEditor.swift */; }; DDCC3A562DDC9617006F1C10 /* MissedBolusCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A552DDC9617006F1C10 /* MissedBolusCondition.swift */; }; DDCC3A582DDC9655006F1C10 /* MissedBolusAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A572DDC9655006F1C10 /* MissedBolusAlarmEditor.swift */; }; DDCC3A5A2DDC988F006F1C10 /* CarbSample.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A592DDC988F006F1C10 /* CarbSample.swift */; }; + DDCC3A5B2DDE2000006F1C10 /* PumpBatteryAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A5C2DDE2000006F1C10 /* PumpBatteryAlarmEditor.swift */; }; DDCF979424C0D380002C9752 /* UIViewExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF979324C0D380002C9752 /* UIViewExtension.swift */; }; DDCF9A802D85FD0B004DF4DD /* Alarm.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF9A7F2D85FD09004DF4DD /* Alarm.swift */; }; DDCF9A822D85FD15004DF4DD /* AlarmType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF9A812D85FD14004DF4DD /* AlarmType.swift */; }; @@ -400,6 +421,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 */; @@ -409,8 +437,39 @@ }; /* 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 = ""; }; + 374A77962F5BD77500E96858 /* APNSJWTGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNSJWTGenerator.swift; sourceTree = ""; }; + 374A77982F5BD8AB00E96858 /* APNSClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNSClient.swift; sourceTree = ""; }; + 374A779F2F5BE17000E96858 /* AppGroupID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppGroupID.swift; sourceTree = ""; }; + 374A77A02F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseLiveActivityAttributes.swift; sourceTree = ""; }; + 374A77A12F5BE17000E96858 /* GlucoseSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseSnapshot.swift; sourceTree = ""; }; + 374A77A22F5BE17000E96858 /* GlucoseUnitConversion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseUnitConversion.swift; sourceTree = ""; }; + 374A77A32F5BE17000E96858 /* LAAppGroupSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LAAppGroupSettings.swift; sourceTree = ""; }; + 374A77AE2F5BE1AC00E96858 /* GlucoseSnapshotBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseSnapshotBuilder.swift; sourceTree = ""; }; + 374A77AF2F5BE1AC00E96858 /* GlucoseSnapshotStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseSnapshotStore.swift; sourceTree = ""; }; + 374A77B02F5BE1AC00E96858 /* LAThresholdSync.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LAThresholdSync.swift; sourceTree = ""; }; + 374A77B12F5BE1AC00E96858 /* LiveActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = ""; }; + 374A77B22F5BE1AC00E96858 /* PreferredGlucoseUnit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferredGlucoseUnit.swift; sourceTree = ""; }; + 374A77B32F5BE1AC00E96858 /* StorageCurrentGlucoseStateProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageCurrentGlucoseStateProvider.swift; sourceTree = ""; }; + 374DAACA2F5B924B00BB663B /* LoopFollowLAExtensionExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = LoopFollowLAExtensionExtension.entitlements; sourceTree = ""; }; + 37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = LoopFollowLAExtensionExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 37A4BDDA2F5B6B4A00EEB289 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = /System/Library/Frameworks/WidgetKit.framework; sourceTree = ""; }; + 37A4BDDC2F5B6B4A00EEB289 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = /System/Library/Frameworks/SwiftUI.framework; sourceTree = ""; }; 654132E62E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleQRCodeScannerView.swift; sourceTree = ""; }; 654132E92E19F24800BDBE08 /* TOTPGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOTPGenerator.swift; sourceTree = ""; }; 654134172E1DC09700BDBE08 /* OverridePresetsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverridePresetsView.swift; sourceTree = ""; }; @@ -601,14 +660,14 @@ DDC7E5402DBD8A1600EB1127 /* AlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmEditor.swift; sourceTree = ""; }; DDC7E5CE2DC77C2000EB1127 /* SnoozerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnoozerViewModel.swift; sourceTree = ""; }; DDCC3A4A2DDBB5E4006F1C10 /* BatteryCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryCondition.swift; sourceTree = ""; }; - DDCC3A512DDED000006F1C10 /* PumpBatteryCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpBatteryCondition.swift; sourceTree = ""; }; DDCC3A4C2DDBB77C006F1C10 /* PhoneBatteryAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneBatteryAlarmEditor.swift; sourceTree = ""; }; - DDCC3A5C2DDE2000006F1C10 /* PumpBatteryAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpBatteryAlarmEditor.swift; sourceTree = ""; }; DDCC3A4E2DDC5B54006F1C10 /* BatteryDropCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryDropCondition.swift; sourceTree = ""; }; + DDCC3A512DDED000006F1C10 /* PumpBatteryCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpBatteryCondition.swift; sourceTree = ""; }; DDCC3A532DDC5D62006F1C10 /* BatteryDropAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryDropAlarmEditor.swift; sourceTree = ""; }; DDCC3A552DDC9617006F1C10 /* MissedBolusCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MissedBolusCondition.swift; sourceTree = ""; }; DDCC3A572DDC9655006F1C10 /* MissedBolusAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MissedBolusAlarmEditor.swift; sourceTree = ""; }; DDCC3A592DDC988F006F1C10 /* CarbSample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbSample.swift; sourceTree = ""; }; + DDCC3A5C2DDE2000006F1C10 /* PumpBatteryAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpBatteryAlarmEditor.swift; sourceTree = ""; }; DDCC3ABF2DDE10B0006F1C10 /* Testing.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Testing.framework; path = Platforms/iPhoneOS.platform/Developer/Library/Frameworks/Testing.framework; sourceTree = DEVELOPER_DIR; }; DDCC3AD62DDE1790006F1C10 /* Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = Tests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; DDCF979324C0D380002C9752 /* UIViewExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIViewExtension.swift; sourceTree = ""; }; @@ -810,10 +869,20 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + 37A4BDDE2F5B6B4A00EEB289 /* LoopFollowLAExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = LoopFollowLAExtension; sourceTree = ""; }; DDCC3AD72DDE1790006F1C10 /* Tests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Tests; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ + 37A4BDD62F5B6B4A00EEB289 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 37A4BDDD2F5B6B4A00EEB289 /* SwiftUI.framework in Frameworks */, + 37A4BDDB2F5B6B4A00EEB289 /* WidgetKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; DDCC3AD32DDE1790006F1C10 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -835,6 +904,26 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 376310762F5CD65100656488 /* LiveActivity */ = { + isa = PBXGroup; + children = ( + 374A77AE2F5BE1AC00E96858 /* GlucoseSnapshotBuilder.swift */, + 374A77AF2F5BE1AC00E96858 /* GlucoseSnapshotStore.swift */, + 374A77B02F5BE1AC00E96858 /* LAThresholdSync.swift */, + 374A77B12F5BE1AC00E96858 /* LiveActivityManager.swift */, + 374A77B22F5BE1AC00E96858 /* PreferredGlucoseUnit.swift */, + 374A77B32F5BE1AC00E96858 /* StorageCurrentGlucoseStateProvider.swift */, + 374A779F2F5BE17000E96858 /* AppGroupID.swift */, + 374A77A02F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift */, + 374A77A12F5BE17000E96858 /* GlucoseSnapshot.swift */, + 374A77A22F5BE17000E96858 /* GlucoseUnitConversion.swift */, + 374A77A32F5BE17000E96858 /* LAAppGroupSettings.swift */, + 374A77982F5BD8AB00E96858 /* APNSClient.swift */, + 374A77962F5BD77500E96858 /* APNSJWTGenerator.swift */, + ); + path = LiveActivity; + sourceTree = ""; + }; 6589CC552E9E7D1600BB18FE /* ImportExport */ = { isa = PBXGroup; children = ( @@ -873,6 +962,8 @@ FCFEEC9D2486E68E00402A7F /* WebKit.framework */, A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */, FCE537C2249AAB2600F80BF8 /* NotificationCenter.framework */, + 37A4BDDA2F5B6B4A00EEB289 /* WidgetKit.framework */, + 37A4BDDC2F5B6B4A00EEB289 /* SwiftUI.framework */, ); name = Frameworks; sourceTree = ""; @@ -1476,6 +1567,7 @@ FC8DEEE32485D1680075863F /* LoopFollow */ = { isa = PBXGroup; children = ( + 376310762F5CD65100656488 /* LiveActivity */, 6589CC612E9E7D1600BB18FE /* Settings */, DDCF9A7E2D85FCE6004DF4DD /* Alarm */, FC16A97624995FEE003D6245 /* Application */, @@ -1504,6 +1596,7 @@ FC97880B2485969B00A7906C = { isa = PBXGroup; children = ( + 374DAACA2F5B924B00BB663B /* LoopFollowLAExtensionExtension.entitlements */, DDF2C0132BEFD468007A20E6 /* blacklisted-versions.json */, DDB0AF542BB1B24A00AFA48B /* BuildDetails.plist */, DDB0AF4F2BB1A81F00AFA48B /* Scripts */, @@ -1512,6 +1605,7 @@ FC5A5C3C2497B229009C550E /* Config.xcconfig */, FC8DEEE32485D1680075863F /* LoopFollow */, DDCC3AD72DDE1790006F1C10 /* Tests */, + 37A4BDDE2F5B6B4A00EEB289 /* LoopFollowLAExtension */, FC9788152485969B00A7906C /* Products */, 8E32230C453C93FDCE59C2B9 /* Pods */, 6A5880E0B811AF443B05AB02 /* Frameworks */, @@ -1523,6 +1617,7 @@ children = ( FC9788142485969B00A7906C /* Loop Follow.app */, DDCC3AD62DDE1790006F1C10 /* Tests.xctest */, + 37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */, ); name = Products; sourceTree = ""; @@ -1594,6 +1689,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" */; @@ -1628,10 +1745,12 @@ FC9788122485969B00A7906C /* Resources */, 04DA71CCA0280FA5FA2DF7A6 /* [CP] Embed Pods Frameworks */, DDB0AF532BB1AA0900AFA48B /* Capture Build Details */, + 37A4BDED2F5B6B4C00EEB289 /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( + 37A4BDE72F5B6B4C00EEB289 /* PBXTargetDependency */, ); name = LoopFollow; packageProductDependencies = ( @@ -1648,10 +1767,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; @@ -1681,11 +1803,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; @@ -1909,6 +2039,18 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 37A4BDD52F5B6B4A00EEB289 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 374A77A92F5BE17000E96858 /* GlucoseUnitConversion.swift in Sources */, + 374A77AA2F5BE17000E96858 /* AppGroupID.swift in Sources */, + 374A77AB2F5BE17000E96858 /* GlucoseSnapshot.swift in Sources */, + 374A77AC2F5BE17000E96858 /* LAAppGroupSettings.swift in Sources */, + 374A77AD2F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; DDCC3AD22DDE1790006F1C10 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -1927,11 +2069,18 @@ DD7F4B9F2DD1F92700D449E9 /* AlarmActiveSection.swift in Sources */, DD4AFB672DB68C5500BB593F /* UUID+Identifiable.swift in Sources */, DD9ED0CA2D355257000D2A63 /* LogView.swift in Sources */, + 374A77972F5BD78700E96858 /* APNSJWTGenerator.swift in Sources */, DD9ACA082D32F68B00415D8A /* BGTask.swift in Sources */, DD9ACA102D34129200415D8A /* Task.swift in Sources */, 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 */, + 374A77B62F5BE1AC00E96858 /* LAThresholdSync.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 */, @@ -2012,6 +2161,7 @@ DDEF503A2D31615000999A5D /* LogManager.swift in Sources */, DD4878172C7B75350048F05C /* BolusView.swift in Sources */, DD026E592EA2C8A200A39CB5 /* InsulinPrecisionManager.swift in Sources */, + 374A77992F5BD8B200E96858 /* APNSClient.swift in Sources */, DD493AE72ACF23CF009A6922 /* DeviceStatus.swift in Sources */, DDC7E5162DBCFA7F00EB1127 /* SnoozerView.swift in Sources */, FCFEECA2248857A600402A7F /* SettingsViewController.swift in Sources */, @@ -2137,6 +2287,11 @@ DD7F4C212DD66BB200D449E9 /* FastRiseAlarmEditor.swift in Sources */, FC97881A2485969B00A7906C /* SceneDelegate.swift in Sources */, DD0C0C662C46E54C00DBADDF /* InfoDataSeparator.swift in Sources */, + 374A77A42F5BE17000E96858 /* GlucoseUnitConversion.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 */, @@ -2189,6 +2344,11 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 37A4BDE72F5B6B4C00EEB289 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 37A4BDD82F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension */; + targetProxy = 37A4BDE62F5B6B4C00EEB289 /* PBXContainerItemProxy */; + }; DDCC3ADB2DDE1790006F1C10 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = FC9788132485969B00A7906C /* LoopFollow */; @@ -2216,6 +2376,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 = 2HEY366Q6J; + ENABLE_APP_SANDBOX = NO; + ENABLE_HARDENED_RUNTIME = NO; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = LoopFollowLAExtension/ExtensionInfo.plist; + INFOPLIST_KEY_CFBundleDisplayName = LoopFollowLAExtension; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 Jon Fawcett. All rights reserved."; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 26.2; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.2HEY366Q6J.LoopFollow.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; + 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 = 2HEY366Q6J; + ENABLE_APP_SANDBOX = NO; + ENABLE_HARDENED_RUNTIME = NO; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = LoopFollowLAExtension/ExtensionInfo.plist; + INFOPLIST_KEY_CFBundleDisplayName = LoopFollowLAExtension; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 Jon Fawcett. All rights reserved."; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 26.2; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.2HEY366Q6J.LoopFollow.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; + XROS_DEPLOYMENT_TARGET = 26.2; + }; + name = Release; + }; DDCC3ADD2DDE1790006F1C10 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -2225,7 +2488,7 @@ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; @@ -2252,7 +2515,7 @@ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; @@ -2397,18 +2660,21 @@ CODE_SIGN_ENTITLEMENTS = "LoopFollow/Loop Follow.entitlements"; CODE_SIGN_STYLE = Automatic; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = 2HEY366Q6J; INFOPLIST_FILE = LoopFollow/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix)"; + PRODUCT_BUNDLE_IDENTIFIER = com.2HEY366Q6J.LoopFollow; PRODUCT_NAME = "Loop Follow"; - SUPPORTS_MACCATALYST = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = 1; }; name = Debug; }; @@ -2420,24 +2686,36 @@ CODE_SIGN_ENTITLEMENTS = "LoopFollow/Loop Follow.entitlements"; CODE_SIGN_STYLE = Automatic; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = 2HEY366Q6J; INFOPLIST_FILE = LoopFollow/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix)"; + PRODUCT_BUNDLE_IDENTIFIER = com.2HEY366Q6J.LoopFollow; PRODUCT_NAME = "Loop Follow"; - SUPPORTS_MACCATALYST = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = 1; }; name = Release; }; /* 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 = ( diff --git a/LoopFollow/Controllers/Nightscout/BGData.swift b/LoopFollow/Controllers/Nightscout/BGData.swift index c07da66d5..2f0311053 100644 --- a/LoopFollow/Controllers/Nightscout/BGData.swift +++ b/LoopFollow/Controllers/Nightscout/BGData.swift @@ -260,8 +260,19 @@ extension MainViewController { Observable.shared.deltaText.value = "+" + Localizer.toDisplayUnits(String(deltaBG)) } + // Live Activity storage + Storage.shared.lastBgReadingTimeSeconds.value = lastBGTime + Storage.shared.lastDeltaMgdl.value = Double(deltaBG) + Storage.shared.lastTrendCode.value = entries[latestEntryIndex].direction + // Mark BG data as loaded for initial loading state self.markDataLoaded("bg") + + // Live Activity update + if #available(iOS 16.1, *) { + LiveActivityManager.shared.refreshFromCurrentState(reason: "bg") + } + // Update contact if Storage.shared.contactEnabled.value { @@ -274,6 +285,7 @@ extension MainViewController { ) } Storage.shared.lastBGChecked.value = Date() + } } } diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift index f8bc8f867..a287548c6 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift @@ -46,6 +46,7 @@ extension MainViewController { if IsNightscoutEnabled(), (now - lastLoopTime) >= nonLoopingTimeThreshold, lastLoopTime > 0 { IsNotLooping = true + Observable.shared.isNotLooping.value = true statusStackView.distribution = .fill PredictionLabel.isHidden = true @@ -58,6 +59,7 @@ extension MainViewController { } else { IsNotLooping = false + Observable.shared.isNotLooping.value = false statusStackView.distribution = .fillEqually PredictionLabel.isHidden = false diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift index fe10b62b9..d4f851ba5 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift @@ -119,6 +119,16 @@ extension MainViewController { LoopStatusLabel.text = "↻" latestLoopStatusString = "↻" } + + // Live Activity storage + Storage.shared.lastIOB.value = latestIOB?.value + Storage.shared.lastCOB.value = latestCOB?.value + if let predictdata = lastLoopRecord["predicted"] as? [String: AnyObject], + let values = predictdata["values"] as? [Double] { + Storage.shared.projectedBgMgdl.value = values.last + } else { + Storage.shared.projectedBgMgdl.value = nil + } } } } diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift index 57a940695..fc3b3c5b5 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift @@ -224,6 +224,11 @@ extension MainViewController { LoopStatusLabel.text = "↻" latestLoopStatusString = "↻" } + + // Live Activity storage + Storage.shared.lastIOB.value = latestIOB?.value + Storage.shared.lastCOB.value = latestCOB?.value + Storage.shared.projectedBgMgdl.value = nil } } } diff --git a/LoopFollow/Helpers/BackgroundTaskAudio.swift b/LoopFollow/Helpers/BackgroundTaskAudio.swift index 91504ab5d..b7bbd26a8 100755 --- a/LoopFollow/Helpers/BackgroundTaskAudio.swift +++ b/LoopFollow/Helpers/BackgroundTaskAudio.swift @@ -1,16 +1,18 @@ -// LoopFollow // BackgroundTaskAudio.swift +// LoopFollow +// Philippe Achkar +// 2026-03-07 import AVFoundation class BackgroundTask { - // MARK: - Vars + // MARK: - Vars + static let shared = BackgroundTask() var player = AVAudioPlayer() var timer = Timer() // MARK: - Methods - func startBackgroundTask() { NotificationCenter.default.addObserver(self, selector: #selector(interruptedAudio), name: AVAudioSession.interruptionNotification, object: AVAudioSession.sharedInstance()) playAudio() @@ -19,10 +21,15 @@ class BackgroundTask { func stopBackgroundTask() { NotificationCenter.default.removeObserver(self, name: AVAudioSession.interruptionNotification, object: nil) player.stop() - LogManager.shared.log(category: .general, message: "Silent audio stopped", isDebug: true) + do { + try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + LogManager.shared.log(category: .general, message: "Silent audio stopped", isDebug: true) + } catch { + LogManager.shared.log(category: .general, message: "Silent audio stop failed: \(error)", isDebug: true) + } } - @objc fileprivate func interruptedAudio(_ notification: Notification) { + @objc private 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! @@ -32,15 +39,13 @@ class BackgroundTask { } } - fileprivate func playAudio() { + private func playAudio() { 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) - // Play audio forever by setting num of loops to -1 player.numberOfLoops = -1 player.volume = 0.01 player.prepareToPlay() diff --git a/LoopFollow/Info.plist b/LoopFollow/Info.plist index e76068f9a..94351928e 100644 --- a/LoopFollow/Info.plist +++ b/LoopFollow/Info.plist @@ -2,6 +2,12 @@ + APNSKeyContent + $(APNS_KEY_CONTENT) + APNSKeyID + $(APNS_KEY_ID) + APNSTeamID + $(DEVELOPMENT_TEAM) AppGroupIdentifier group.com.$(unique_id).LoopFollow$(app_suffix) BGTaskSchedulerPermittedIdentifiers @@ -16,8 +22,10 @@ $(EXECUTABLE_NAME) CFBundleGetInfoString + CFBundleIconFile + Activities CFBundleIdentifier - com.$(unique_id).LoopFollow$(app_suffix) + $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion 6.0 CFBundleName @@ -61,6 +69,8 @@ This app requires Face ID for secure authentication. NSHumanReadableCopyright + NSSupportsLiveActivities + UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/LoopFollow/LiveActivity/APNSClient.swift b/LoopFollow/LiveActivity/APNSClient.swift new file mode 100644 index 000000000..8a46babef --- /dev/null +++ b/LoopFollow/LiveActivity/APNSClient.swift @@ -0,0 +1,111 @@ +// APNSClient.swift +// Philippe Achkar +// 2026-03-07 + +import Foundation + +class APNSClient { + + static let shared = APNSClient() + private init() {} + + // MARK: - Configuration + + private let bundleID = Bundle.main.bundleIdentifier ?? "com.apple.unknown" + private let apnsHost = "https://api.push.apple.com" + + // MARK: - JWT Cache + + private var cachedToken: String? + private var tokenGeneratedAt: Date? + private let tokenTTL: TimeInterval = 55 * 60 + + private func validToken() throws -> String { + let now = Date() + if let token = cachedToken, + let generatedAt = tokenGeneratedAt, + now.timeIntervalSince(generatedAt) < tokenTTL { + return token + } + let newToken = try APNSJWTGenerator.generateToken() + cachedToken = newToken + tokenGeneratedAt = now + LogManager.shared.log(category: .general, message: "APNs JWT refreshed", isDebug: true) + return newToken + } + + // MARK: - Send Live Activity Update + + func sendLiveActivityUpdate( + pushToken: String, + state: GlucoseLiveActivityAttributes.ContentState + ) async { + do { + let jwt = try validToken() + let payload = buildPayload(state: state) + + guard let url = URL(string: "\(apnsHost)/3/device/\(pushToken)") else { + LogManager.shared.log(category: .general, message: "APNs invalid URL", isDebug: true) + return + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("bearer \(jwt)", forHTTPHeaderField: "authorization") + request.setValue("application/json", forHTTPHeaderField: "content-type") + request.setValue("\(bundleID).push-type.liveactivity", forHTTPHeaderField: "apns-topic") + request.setValue("liveactivity", forHTTPHeaderField: "apns-push-type") + request.setValue("10", forHTTPHeaderField: "apns-priority") + request.httpBody = payload + + let (data, response) = try await URLSession.shared.data(for: request) + + if let httpResponse = response as? HTTPURLResponse { + if httpResponse.statusCode == 200 { + LogManager.shared.log(category: .general, message: "APNs push sent successfully", isDebug: true) + } else { + let responseBody = String(data: data, encoding: .utf8) ?? "empty" + LogManager.shared.log(category: .general, message: "APNs push failed status=\(httpResponse.statusCode) body=\(responseBody)") + } + } + + } catch { + LogManager.shared.log(category: .general, message: "APNs error: \(error.localizedDescription)") + } + } + + // MARK: - Payload Builder + + private func buildPayload(state: GlucoseLiveActivityAttributes.ContentState) -> Data? { + let snapshot = state.snapshot + + var snapshotDict: [String: Any] = [ + "glucose": snapshot.glucose, + "delta": snapshot.delta, + "trend": snapshot.trend.rawValue, + "updatedAt": snapshot.updatedAt.timeIntervalSince1970, + "unit": snapshot.unit.rawValue + ] + + if let iob = snapshot.iob { snapshotDict["iob"] = iob } + if let cob = snapshot.cob { snapshotDict["cob"] = cob } + if let projected = snapshot.projected { snapshotDict["projected"] = projected } + + let contentState: [String: Any] = [ + "snapshot": snapshotDict, + "seq": state.seq, + "reason": state.reason, + "producedAt": state.producedAt.timeIntervalSince1970 + ] + + let payload: [String: Any] = [ + "aps": [ + "timestamp": Int(Date().timeIntervalSince1970), + "event": "update", + "content-state": contentState + ] + ] + + return try? JSONSerialization.data(withJSONObject: payload) + } +} diff --git a/LoopFollow/LiveActivity/APNSJWTGenerator.swift b/LoopFollow/LiveActivity/APNSJWTGenerator.swift new file mode 100644 index 000000000..381000ed1 --- /dev/null +++ b/LoopFollow/LiveActivity/APNSJWTGenerator.swift @@ -0,0 +1,116 @@ +// APNSJWTGenerator.swift +// Philippe Achkar +// 2026-03-07 + +import Foundation +import CryptoKit + +struct APNSJWTGenerator { + + // MARK: - Configuration (read from Info.plist — never hardcoded) + + static var keyID: String { + Bundle.main.infoDictionary?["APNSKeyID"] as? String ?? "" + } + + static var teamID: String { + Bundle.main.infoDictionary?["APNSTeamID"] as? String ?? "" + } + + static var keyContent: String { + Bundle.main.infoDictionary?["APNSKeyContent"] as? String ?? "" + } + + // MARK: - JWT Generation + + /// Generates a signed ES256 JWT for APNs authentication. + /// Valid for 60 minutes per Apple's requirements. + static func generateToken() throws -> String { + let privateKey = try loadPrivateKey() + let header = try encodeHeader() + let payload = try encodePayload() + let signingInput = "\(header).\(payload)" + + guard let signingData = signingInput.data(using: .utf8) else { + throw APNSJWTError.encodingFailed + } + + let signature = try privateKey.signature(for: signingData) + let signatureBase64 = base64URLEncode(signature.rawRepresentation) + return "\(signingInput).\(signatureBase64)" + } + + // MARK: - Private Helpers + + private static func loadPrivateKey() throws -> P256.Signing.PrivateKey { + guard !keyID.isEmpty else { + throw APNSJWTError.keyIDNotConfigured + } + + guard !keyContent.isEmpty else { + throw APNSJWTError.keyContentNotConfigured + } + + // Strip PEM headers/footers and whitespace if present + let cleaned = keyContent + .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 APNSJWTError.keyDecodingFailed + } + + return try P256.Signing.PrivateKey(derRepresentation: keyData) + } + + private static func encodeHeader() throws -> String { + let header: [String: String] = [ + "alg": "ES256", + "kid": keyID + ] + let data = try JSONSerialization.data(withJSONObject: header) + return base64URLEncode(data) + } + + private static func encodePayload() 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 static func base64URLEncode(_ data: Data) -> String { + return data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} + +// MARK: - Errors + +enum APNSJWTError: Error, LocalizedError { + case keyIDNotConfigured + case keyContentNotConfigured + case keyDecodingFailed + case encodingFailed + + var errorDescription: String? { + switch self { + case .keyIDNotConfigured: + return "APNSKeyID not set in Info.plist or LoopFollowConfigOverride.xcconfig." + case .keyContentNotConfigured: + return "APNSKeyContent not set. Add APNS_KEY_CONTENT to LoopFollowConfigOverride.xcconfig or GitHub Secrets." + case .keyDecodingFailed: + return "Failed to decode APNs p8 key content. Ensure it is valid base64 with no line breaks." + case .encodingFailed: + return "Failed to encode JWT signing input." + } + } +} diff --git a/LoopFollow/LiveActivity/AppGroupID.swift b/LoopFollow/LiveActivity/AppGroupID.swift new file mode 100644 index 000000000..7b02acb94 --- /dev/null +++ b/LoopFollow/LiveActivity/AppGroupID.swift @@ -0,0 +1,66 @@ +// +// AppGroupID.swift +// LoopFollow +// +// Created by Philippe Achkar on 2026-02-24. +// + +import Foundation + +/// Resolves the App Group identifier in a PR-safe way. +/// +/// Preferred contract: +/// - App Group = "group." +/// - No team-specific hardcoding +/// +/// Important nuance: +/// - Extensions often have a *different* bundle identifier than the main app. +/// - To keep app + extensions aligned, we: +/// 1) Prefer an explicit base bundle id if provided via Info.plist key. +/// 2) Otherwise, apply a conservative suffix-stripping heuristic. +/// 3) Fall back to the current bundle identifier. +enum AppGroupID { + + /// Optional Info.plist key you can set in *both* app + extension targets + /// to force a shared base bundle id (recommended for reliability). + private static let baseBundleIDPlistKey = "LFAppGroupBaseBundleID" + + static func current() -> String { + if let base = Bundle.main.object(forInfoDictionaryKey: baseBundleIDPlistKey) as? String, + !base.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return "group.\(base)" + } + + let bundleID = Bundle.main.bundleIdentifier ?? "unknown" + + // Heuristic: strip common extension suffixes so the extension can land on the main app’s group id. + let base = stripLikelyExtensionSuffixes(from: bundleID) + + return "group.\(base)" + } + + private static func stripLikelyExtensionSuffixes(from bundleID: String) -> String { + let knownSuffixes = [ + ".LiveActivity", + ".LiveActivityExtension", + ".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 + } +} \ No newline at end of file diff --git a/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift b/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift new file mode 100644 index 000000000..1052ee0ec --- /dev/null +++ b/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift @@ -0,0 +1,29 @@ +// +// GlucoseLiveActivityAttributes.swift +// LoopFollow +// +// Created by Philippe Achkar on 2026-02-24. +// + +import ActivityKit +import Foundation + +struct GlucoseLiveActivityAttributes: ActivityAttributes { + + public struct ContentState: Codable, Hashable { + /// The latest snapshot, already converted into the user’s preferred unit. + let snapshot: GlucoseSnapshot + + /// Monotonic sequence for “did we update?” debugging and hung detection. + let seq: Int + + /// Reason the app refreshed (e.g., "bg", "deviceStatus"). + let reason: String + + /// When the activity state was produced. + let producedAt: Date + } + + /// Reserved for future metadata. Keep minimal for stability. + let title: String +} \ No newline at end of file diff --git a/LoopFollow/LiveActivity/GlucoseSnapshot.swift b/LoopFollow/LiveActivity/GlucoseSnapshot.swift new file mode 100644 index 000000000..563be34d5 --- /dev/null +++ b/LoopFollow/LiveActivity/GlucoseSnapshot.swift @@ -0,0 +1,113 @@ +// +// GlucoseSnapshot.swift +// LoopFollow +// +// Created by Philippe Achkar on 2026-02-24. +// + +import Foundation + +/// Canonical, source-agnostic glucose state used by +/// Live Activity, future Watch complication, and CarPlay. +/// +struct GlucoseSnapshot: Codable, Equatable, Hashable { + + // MARK: - Units + + enum Unit: String, Codable, Hashable { + case mgdl + case mmol + } + + // MARK: - Core Glucose + + /// Raw glucose value in the user-selected unit. + let glucose: Double + + /// Raw delta in the user-selected unit. 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 (if available) + let projected: Double? + + // MARK: - Unit Context + + /// Unit selected by the user in LoopFollow settings. + let unit: Unit + + // MARK: - Loop Status + /// True when LoopFollow detects the loop has not reported in 15+ minutes (Nightscout only). + let isNotLooping: Bool + + init( + glucose: Double, + delta: Double, + trend: Trend, + updatedAt: Date, + iob: Double?, + cob: Double?, + projected: Double?, + unit: Unit, + isNotLooping: Bool + ) { + self.glucose = glucose + self.delta = delta + self.trend = trend + self.updatedAt = updatedAt + self.iob = iob + self.cob = cob + self.projected = projected + self.unit = unit + self.isNotLooping = isNotLooping + } + + // MARK: - Codable + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + glucose = try container.decode(Double.self, forKey: .glucose) + delta = try container.decode(Double.self, forKey: .delta) + trend = try container.decode(Trend.self, forKey: .trend) + updatedAt = try container.decode(Date.self, forKey: .updatedAt) + iob = try container.decodeIfPresent(Double.self, forKey: .iob) + cob = try container.decodeIfPresent(Double.self, forKey: .cob) + projected = try container.decodeIfPresent(Double.self, forKey: .projected) + unit = try container.decode(Unit.self, forKey: .unit) + isNotLooping = try container.decodeIfPresent(Bool.self, forKey: .isNotLooping) ?? false + } + + // MARK: - Derived Convenience + + /// Age of reading in seconds. + var age: TimeInterval { + Date().timeIntervalSince(updatedAt) + } +} + + +// MARK: - Trend + +extension GlucoseSnapshot { + + enum Trend: String, Codable, Hashable { + case up + case upFast + case flat + case down + case downFast + case unknown + } +} diff --git a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift new file mode 100644 index 000000000..a61774ead --- /dev/null +++ b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift @@ -0,0 +1,117 @@ +// +// GlucoseSnapshotBuilder.swift +// LoopFollow +// +// Created by Philippe Achkar on 2026-02-28. +// + +import Foundation + +/// Provides the *latest* glucose-relevant values from LoopFollow’s single source of truth. +/// This is intentionally provider-agnostic (Nightscout vs Dexcom doesn’t matter). +protocol CurrentGlucoseStateProviding { + /// Canonical glucose value in mg/dL (recommended internal canonical form). + var glucoseMgdl: Double? { get } + + /// Canonical delta in mg/dL. + var deltaMgdl: Double? { get } + + /// Canonical projected glucose in mg/dL. + var projectedMgdl: Double? { get } + + /// Timestamp of the last reading/update. + var updatedAt: Date? { get } + + /// Trend string / code from LoopFollow (we map to GlucoseSnapshot.Trend). + var trendCode: String? { get } + + /// Secondary metrics (typically already unitless) + var iob: Double? { get } + var cob: Double? { get } +} + +/// Builds a GlucoseSnapshot in the user’s preferred unit, without embedding provider logic. +enum GlucoseSnapshotBuilder { + + static func build(from provider: CurrentGlucoseStateProviding) -> GlucoseSnapshot? { + guard + let glucoseMgdl = provider.glucoseMgdl, + glucoseMgdl > 0, + let updatedAt = provider.updatedAt + else { + // Debug-only signal: we’re missing core state. + // (If you prefer no logs here, remove this line.) + LogManager.shared.log( + category: .general, + message: "GlucoseSnapshotBuilder: missing/invalid core values glucoseMgdl=\(provider.glucoseMgdl?.description ?? "nil") updatedAt=\(provider.updatedAt?.description ?? "nil")", + isDebug: true + ) + return nil + } + + let preferredUnit = PreferredGlucoseUnit.snapshotUnit() + + let glucose = GlucoseUnitConversion.convertGlucose(glucoseMgdl, from: .mgdl, to: preferredUnit) + + let deltaMgdl = provider.deltaMgdl ?? 0.0 + let delta = GlucoseUnitConversion.convertGlucose(deltaMgdl, from: .mgdl, to: preferredUnit) + + let projected: Double? + if let projMgdl = provider.projectedMgdl { + projected = GlucoseUnitConversion.convertGlucose(projMgdl, from: .mgdl, to: preferredUnit) + } else { + projected = nil + } + + let trend = mapTrend(provider.trendCode) + + // Not Looping — read from Observable, set by evaluateNotLooping() in DeviceStatus.swift + let isNotLooping = Observable.shared.isNotLooping.value + + return GlucoseSnapshot( + glucose: glucose, + delta: delta, + trend: trend, + updatedAt: updatedAt, + iob: provider.iob, + cob: provider.cob, + projected: projected, + unit: preferredUnit, + isNotLooping: isNotLooping + ) + } + + private static func mapTrend(_ code: String?) -> GlucoseSnapshot.Trend { + guard + let raw = code? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased(), + !raw.isEmpty + else { return .unknown } + + // Common Nightscout strings: + // "Flat", "FortyFiveUp", "SingleUp", "DoubleUp", "FortyFiveDown", "SingleDown", "DoubleDown" + // Common variants: + // "rising", "falling", "rapidRise", "rapidFall" + + if raw.contains("doubleup") || raw.contains("rapidrise") || raw == "up2" || raw == "upfast" { + return .upFast + } + if raw.contains("singleup") || raw.contains("fortyfiveup") || 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("singledown") || raw.contains("fortyfivedown") || raw == "down" || raw == "down1" || raw == "falling" { + return .down + } + + return .unknown + } +} diff --git a/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift b/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift new file mode 100644 index 000000000..b906742ce --- /dev/null +++ b/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift @@ -0,0 +1,80 @@ +// +// GlucoseSnapshotStore.swift +// LoopFollow +// +// Created by Philippe Achkar on 2026-02-24. +// + +import Foundation + +/// Persists the latest GlucoseSnapshot into the App Group container so that: +/// - the Live Activity extension can read it +/// - future Watch + CarPlay surfaces can reuse it +/// +/// Uses an atomic JSON file write to avoid partial/corrupt reads across processes. +final class GlucoseSnapshotStore { + + static let shared = GlucoseSnapshotStore() + private init() {} + + private let fileName = "glucose_snapshot.json" + private let queue = DispatchQueue(label: "com.loopfollow.glucoseSnapshotStore", qos: .utility) + + // MARK: - Public API + + func save(_ snapshot: GlucoseSnapshot) { + queue.async { + do { + let url = try self.fileURL() + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode(snapshot) + try data.write(to: url, options: [.atomic]) + } catch { + // Intentionally silent (extension-safe, no dependencies). + } + } + } + + func load() -> GlucoseSnapshot? { + do { + let url = try fileURL() + guard FileManager.default.fileExists(atPath: url.path) else { return nil } + + let data = try Data(contentsOf: url) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode(GlucoseSnapshot.self, from: data) + } catch { + // Intentionally silent (extension-safe, no dependencies). + return nil + } + } + + func delete() { + queue.async { + do { + let url = try self.fileURL() + if FileManager.default.fileExists(atPath: url.path) { + try FileManager.default.removeItem(at: url) + } + } catch { + // Intentionally silent (extension-safe, no dependencies). + } + } + } + + // MARK: - Helpers + + private func fileURL() throws -> URL { + let groupID = AppGroupID.current() + guard let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: groupID) else { + throw NSError( + domain: "GlucoseSnapshotStore", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "App Group containerURL is nil for id=\(groupID)"] + ) + } + return containerURL.appendingPathComponent(fileName, isDirectory: false) + } +} diff --git a/LoopFollow/LiveActivity/GlucoseUnitConversion.swift b/LoopFollow/LiveActivity/GlucoseUnitConversion.swift new file mode 100644 index 000000000..3d81620b5 --- /dev/null +++ b/LoopFollow/LiveActivity/GlucoseUnitConversion.swift @@ -0,0 +1,28 @@ +// +// GlucoseUnitConversion.swift +// LoopFollow +// +// Created by Philippe Achkar on 2026-02-24. +// + +import Foundation + +enum GlucoseUnitConversion { + + // 1 mmol/L glucose ≈ 18.0182 mg/dL (commonly rounded to 18) + // Using 18.0182 is standard for glucose conversions. + private static let mgdlPerMmol: Double = 18.0182 + + static func convertGlucose(_ value: Double, from: GlucoseSnapshot.Unit, to: GlucoseSnapshot.Unit) -> Double { + guard from != to else { return value } + + switch (from, to) { + case (.mgdl, .mmol): + return value / mgdlPerMmol + case (.mmol, .mgdl): + return value * mgdlPerMmol + default: + return value + } + } +} \ No newline at end of file diff --git a/LoopFollow/LiveActivity/LAAppGroupSettings.swift b/LoopFollow/LiveActivity/LAAppGroupSettings.swift new file mode 100644 index 000000000..091497f1e --- /dev/null +++ b/LoopFollow/LiveActivity/LAAppGroupSettings.swift @@ -0,0 +1,39 @@ +// +// LAAppGroupSettings.swift +// LoopFollow +// +// Created by Philippe Achkar on 2026-02-24. +// + +import Foundation + +/// Minimal App Group settings needed by the Live Activity UI. +/// +/// We keep this separate from Storage.shared to avoid target-coupling and +/// ensure the widget extension reads the same values as the app. +enum LAAppGroupSettings { + + private enum Keys { + static let lowLineMgdl = "la.lowLine.mgdl" + static let highLineMgdl = "la.highLine.mgdl" + } + + private static var defaults: UserDefaults? { + UserDefaults(suiteName: AppGroupID.current()) + } + + // MARK: - Write (App) + + static func setThresholds(lowMgdl: Double, highMgdl: Double) { + defaults?.set(lowMgdl, forKey: Keys.lowLineMgdl) + defaults?.set(highMgdl, forKey: Keys.highLineMgdl) + } + + // MARK: - Read (Extension) + + static func thresholdsMgdl(fallbackLow: Double = 70, fallbackHigh: Double = 180) -> (low: Double, high: Double) { + let low = defaults?.object(forKey: Keys.lowLineMgdl) as? Double ?? fallbackLow + let high = defaults?.object(forKey: Keys.highLineMgdl) as? Double ?? fallbackHigh + return (low, high) + } +} \ No newline at end of file diff --git a/LoopFollow/LiveActivity/LAThresholdSync.swift b/LoopFollow/LiveActivity/LAThresholdSync.swift new file mode 100644 index 000000000..03a5a95a4 --- /dev/null +++ b/LoopFollow/LiveActivity/LAThresholdSync.swift @@ -0,0 +1,22 @@ +// +// LAThresholdSync.swift +// LoopFollow +// +// Created by Philippe Achkar on 2026-02-25. +// + +import Foundation + +/// Bridges LoopFollow's internal threshold settings +/// into the App Group for extension consumption. +/// +/// This file belongs ONLY to the main app target. +enum LAThresholdSync { + + static func syncToAppGroup() { + LAAppGroupSettings.setThresholds( + lowMgdl: Storage.shared.lowLine.value, + highMgdl: Storage.shared.highLine.value + ) + } +} \ No newline at end of file diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift new file mode 100644 index 000000000..e41ce5b39 --- /dev/null +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -0,0 +1,254 @@ +// LiveActivityManager.swift +// Philippe Achkar +// 2026-03-07 + +import Foundation +@preconcurrency import ActivityKit +import UIKit +import os + +/// Live Activity manager for LoopFollow. + +@available(iOS 16.1, *) +final class LiveActivityManager { + + static let shared = LiveActivityManager() + private init() {} + + private(set) var current: Activity? + private var stateObserverTask: Task? + private var updateTask: Task? + private var seq: Int = 0 + private var lastUpdateTime: Date? + private var pushToken: String? + private var tokenObservationTask: Task? + + // MARK: - Public API + + func startIfNeeded() { + guard ActivityAuthorizationInfo().areActivitiesEnabled else { + LogManager.shared.log(category: .general, message: "Live Activity not authorized") + return + } + + if let existing = Activity.activities.first { + bind(to: existing, logReason: "reuse") + return + } + + do { + let attributes = GlucoseLiveActivityAttributes(title: "LoopFollow") + + let seedSnapshot = GlucoseSnapshotStore.shared.load() ?? GlucoseSnapshot( + glucose: 0, + delta: 0, + trend: .unknown, + updatedAt: Date(), + iob: nil, + cob: nil, + projected: nil, + unit: .mgdl, + isNotLooping: false + ) + + let initialState = GlucoseLiveActivityAttributes.ContentState( + snapshot: seedSnapshot, + seq: 0, + reason: "start", + producedAt: Date() + ) + + let content = ActivityContent(state: initialState, staleDate: Date().addingTimeInterval(15 * 60)) + let activity = try Activity.request(attributes: attributes, content: content, pushType: .token) + + bind(to: activity, logReason: "start-new") + LogManager.shared.log(category: .general, message: "Live Activity started id=\(activity.id)") + } catch { + LogManager.shared.log(category: .general, message: "Live Activity failed to start: \(error)") + } + } + + func end(dismissalPolicy: ActivityUIDismissalPolicy = .default) { + updateTask?.cancel() + updateTask = nil + + guard let activity = current else { return } + + Task { + let finalState = GlucoseLiveActivityAttributes.ContentState( + snapshot: (GlucoseSnapshotStore.shared.load() ?? GlucoseSnapshot( + glucose: 0, + delta: 0, + trend: .unknown, + updatedAt: Date(), + iob: nil, + cob: nil, + projected: nil, + unit: .mgdl, + isNotLooping: false + )), + seq: seq, + reason: "end", + producedAt: Date() + ) + + let content = ActivityContent(state: finalState, staleDate: nil) + await activity.end(content, dismissalPolicy: dismissalPolicy) + + LogManager.shared.log(category: .general, message: "Live Activity ended id=\(activity.id)", isDebug: true) + + if current?.id == activity.id { + current = nil + } + } + } + + func refreshFromCurrentState(reason: String) { + let provider = StorageCurrentGlucoseStateProvider() + + guard let snapshot = GlucoseSnapshotBuilder.build(from: provider) else { + return + } + + LogManager.shared.log(category: .general, message: "[LA] refresh g=\(snapshot.glucose) reason=\(reason)", isDebug: true) + + let fingerprint = + "g=\(snapshot.glucose) d=\(snapshot.delta) t=\(snapshot.trend.rawValue) " + + "at=\(snapshot.updatedAt.timeIntervalSince1970) iob=\(snapshot.iob?.description ?? "nil") " + + "cob=\(snapshot.cob?.description ?? "nil") proj=\(snapshot.projected?.description ?? "nil") u=\(snapshot.unit.rawValue)" + + LogManager.shared.log(category: .general, message: "[LA] snapshot \(fingerprint) reason=\(reason)", isDebug: true) + + let now = Date() + let timeSinceLastUpdate = now.timeIntervalSince(lastUpdateTime ?? .distantPast) + let forceRefreshNeeded = timeSinceLastUpdate >= 5 * 60 + + if let previous = GlucoseSnapshotStore.shared.load(), previous == snapshot, !forceRefreshNeeded { + return + } + + LAAppGroupSettings.setThresholds( + lowMgdl: Storage.shared.lowLine.value, + highMgdl: Storage.shared.highLine.value + ) + + GlucoseSnapshotStore.shared.save(snapshot) + + guard ActivityAuthorizationInfo().areActivitiesEnabled else { + return + } + + if current == nil, let existing = Activity.activities.first { + bind(to: existing, logReason: "bind-existing") + } + + if let _ = current { + update(snapshot: snapshot, reason: reason) + return + } + + if isAppVisibleForLiveActivityStart() { + startIfNeeded() + if current != nil { + update(snapshot: snapshot, reason: reason) + } + } else { + LogManager.shared.log(category: .general, message: "LA start suppressed (not visible) reason=\(reason)", isDebug: true) + } + } + + private func isAppVisibleForLiveActivityStart() -> Bool { + let scenes = UIApplication.shared.connectedScenes + return scenes.contains { $0.activationState == .foregroundActive } + } + + func update(snapshot: GlucoseSnapshot, reason: String) { + if current == nil, let existing = Activity.activities.first { + bind(to: existing, logReason: "bind-existing") + } + + guard let activity = current else { return } + + updateTask?.cancel() + + seq += 1 + let nextSeq = seq + let activityID = activity.id + + let state = GlucoseLiveActivityAttributes.ContentState( + snapshot: snapshot, + seq: nextSeq, + reason: reason, + producedAt: Date() + ) + + updateTask = Task { [weak self] in + guard let self else { return } + + if activity.activityState == .ended || activity.activityState == .dismissed { + if self.current?.id == activityID { self.current = nil } + return + } + + let content = ActivityContent( + state: state, + staleDate: Date().addingTimeInterval(15 * 60), + relevanceScore: 100.0 + ) + + if Task.isCancelled { return } + + await activity.update(content) + + if Task.isCancelled { return } + + guard self.current?.id == activityID else { + LogManager.shared.log(category: .general, message: "Live Activity update — activity ID mismatch, discarding") + return + } + + self.lastUpdateTime = Date() + LogManager.shared.log(category: .general, message: "[LA] updated id=\(activityID) seq=\(nextSeq) reason=\(reason)", isDebug: true) + + if let token = self.pushToken { + await APNSClient.shared.sendLiveActivityUpdate(pushToken: token, state: state) + } + } + } + + // MARK: - Binding / Lifecycle + + private func bind(to activity: Activity, logReason: String) { + if current?.id == activity.id { return } + current = activity + attachStateObserver(to: activity) + LogManager.shared.log(category: .general, message: "Live Activity bound id=\(activity.id) (\(logReason))", isDebug: true) + observePushToken(for: activity) + } + + private func observePushToken(for activity: Activity) { + tokenObservationTask?.cancel() + tokenObservationTask = Task { + for await tokenData in activity.pushTokenUpdates { + let token = tokenData.map { String(format: "%02x", $0) }.joined() + self.pushToken = token + LogManager.shared.log(category: .general, message: "Live Activity push token received", isDebug: true) + } + } + } + + private func attachStateObserver(to activity: Activity) { + stateObserverTask?.cancel() + stateObserverTask = Task { + for await state in activity.activityStateUpdates { + LogManager.shared.log(category: .general, message: "Live Activity state id=\(activity.id) -> \(state)", isDebug: true) + if state == .ended || state == .dismissed { + if current?.id == activity.id { + current = nil + LogManager.shared.log(category: .general, message: "Live Activity cleared id=\(activity.id)", isDebug: true) + } + } + } + } + } +} diff --git a/LoopFollow/LiveActivity/PreferredGlucoseUnit.swift b/LoopFollow/LiveActivity/PreferredGlucoseUnit.swift new file mode 100644 index 000000000..b4c5dadfb --- /dev/null +++ b/LoopFollow/LiveActivity/PreferredGlucoseUnit.swift @@ -0,0 +1,34 @@ +// +// PreferredGlucoseUnit.swift +// LoopFollow +// +// Created by Philippe Achkar on 2026-02-24. +// + +import Foundation +import HealthKit + +enum PreferredGlucoseUnit { + + /// LoopFollow’s existing source of truth for unit selection. + /// NOTE: Do not duplicate the string constant elsewhere—keep it here. + static func hkUnit() -> HKUnit { + let unitString = Storage.shared.units.value + switch unitString { + case "mmol/L": + return .millimolesPerLiter + default: + return .milligramsPerDeciliter + } + } + + /// Maps HKUnit -> GlucoseSnapshot.Unit (our cross-platform enum). + static func snapshotUnit() -> GlucoseSnapshot.Unit { + switch hkUnit() { + case .millimolesPerLiter: + return .mmol + default: + return .mgdl + } + } +} \ No newline at end of file diff --git a/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift b/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift new file mode 100644 index 000000000..5e50a3e0d --- /dev/null +++ b/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift @@ -0,0 +1,49 @@ +// +// StorageCurrentGlucoseStateProvider.swift +// LoopFollow +// +// Created by Philippe Achkar on 2026-02-24. +// + +import Foundation + +/// Reads the latest glucose state from LoopFollow’s existing single source of truth. +/// Provider remains source-agnostic (Nightscout vs Dexcom). +struct StorageCurrentGlucoseStateProvider: CurrentGlucoseStateProviding { + + var glucoseMgdl: Double? { + guard + let bg = Observable.shared.bg.value, + bg > 0 + else { + return nil + } + + return Double(bg) + } + + var deltaMgdl: Double? { + Storage.shared.lastDeltaMgdl.value + } + + var projectedMgdl: Double? { + Storage.shared.projectedBgMgdl.value + } + + var updatedAt: Date? { + guard let t = Storage.shared.lastBgReadingTimeSeconds.value else { return nil } + return Date(timeIntervalSince1970: t) + } + + var trendCode: String? { + Storage.shared.lastTrendCode.value + } + + var iob: Double? { + Storage.shared.lastIOB.value + } + + var cob: Double? { + Storage.shared.lastCOB.value + } +} diff --git a/LoopFollow/Loop Follow.entitlements b/LoopFollow/Loop Follow.entitlements index ec1156a01..4ec33220c 100644 --- a/LoopFollow/Loop Follow.entitlements +++ b/LoopFollow/Loop Follow.entitlements @@ -8,6 +8,10 @@ development com.apple.security.app-sandbox + com.apple.security.application-groups + + group.com.2HEY366Q6J.LoopFollow + com.apple.security.device.bluetooth com.apple.security.network.client diff --git a/LoopFollow/Storage/Observable.swift b/LoopFollow/Storage/Observable.swift index fd4494342..4caef7b94 100644 --- a/LoopFollow/Storage/Observable.swift +++ b/LoopFollow/Storage/Observable.swift @@ -42,6 +42,8 @@ class Observable { var lastSentTOTP = ObservableValue(default: nil) var loopFollowDeviceToken = ObservableValue(default: "") + + var isNotLooping = ObservableValue(default: false) private init() {} } diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index e5e2a7ffc..0cb7d32f3 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -80,6 +80,14 @@ class Storage { var speakLanguage = StorageValue(key: "speakLanguage", defaultValue: "en") // General Settings [END] + // Live Activity glucose state + var lastBgReadingTimeSeconds = StorageValue(key: "lastBgReadingTimeSeconds", defaultValue: nil) + var lastDeltaMgdl = StorageValue(key: "lastDeltaMgdl", defaultValue: nil) + var lastTrendCode = StorageValue(key: "lastTrendCode", defaultValue: nil) + var lastIOB = StorageValue(key: "lastIOB", defaultValue: nil) + var lastCOB = StorageValue(key: "lastCOB", defaultValue: nil) + var projectedBgMgdl = StorageValue(key: "projectedBgMgdl", defaultValue: nil) + // Graph Settings [BEGIN] var showDots = StorageValue(key: "showDots", defaultValue: true) var showLines = StorageValue(key: "showLines", defaultValue: true) diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index ab1df5766..453f67097 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -59,7 +59,7 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele var currentCage: cageData? var currentIage: iageData? - var backgroundTask = BackgroundTask() + var backgroundTask = BackgroundTask.shared var graphNowTimer = Timer() diff --git a/LoopFollowLAExtension/ExtensionInfo.plist b/LoopFollowLAExtension/ExtensionInfo.plist new file mode 100644 index 000000000..cf08ba141 --- /dev/null +++ b/LoopFollowLAExtension/ExtensionInfo.plist @@ -0,0 +1,27 @@ + + + + + CFBundleDisplayName + LoopFollowLAExtension + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/LoopFollowLAExtension/LoopFollowLABundle.swift b/LoopFollowLAExtension/LoopFollowLABundle.swift new file mode 100644 index 000000000..13b01574b --- /dev/null +++ b/LoopFollowLAExtension/LoopFollowLABundle.swift @@ -0,0 +1,24 @@ +// +// LoopFollowLABundle.swift +// LoopFollow +// +// Created by Philippe Achkar on 2026-03-07. +// Copyright © 2026 Jon Fawcett. All rights reserved. +// + + +// LoopFollowLABundle.swift +// Philippe Achkar +// 2026-03-07 + +import WidgetKit +import SwiftUI + +@main +struct LoopFollowLABundle: WidgetBundle { + var body: some Widget { + if #available(iOS 16.1, *) { + LoopFollowLiveActivityWidget() + } + } +} \ No newline at end of file diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift new file mode 100644 index 000000000..2b0948679 --- /dev/null +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -0,0 +1,440 @@ +// +// LoopFollowLiveActivity.swift +// LoopFollow +// +// Created by Philippe Achkar on 2026-02-24. +// + +import ActivityKit +import SwiftUI +import WidgetKit + +@available(iOS 16.1, *) +struct LoopFollowLiveActivityWidget: Widget { + + var body: some WidgetConfiguration { + ActivityConfiguration(for: GlucoseLiveActivityAttributes.self) { context in + // LOCK SCREEN / BANNER UI + LockScreenLiveActivityView(state: context.state/*, activityID: context.activityID*/) + .id(context.state.seq) // force SwiftUI to re-render on every update + .activitySystemActionForegroundColor(.white) + .activityBackgroundTint(LAColors.backgroundTint(for: context.state.snapshot)) + .applyActivityContentMarginsFixIfAvailable() + } dynamicIsland: { context in + // DYNAMIC ISLAND UI + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + DynamicIslandLeadingView(snapshot: context.state.snapshot) + .id(context.state.seq) + } + DynamicIslandExpandedRegion(.trailing) { + DynamicIslandTrailingView(snapshot: context.state.snapshot) + .id(context.state.seq) + } + DynamicIslandExpandedRegion(.bottom) { + DynamicIslandBottomView(snapshot: context.state.snapshot) + .id(context.state.seq) + } + } compactLeading: { + DynamicIslandCompactLeadingView(snapshot: context.state.snapshot) + .id(context.state.seq) + } compactTrailing: { + DynamicIslandCompactTrailingView(snapshot: context.state.snapshot) + .id(context.state.seq) + } minimal: { + DynamicIslandMinimalView(snapshot: context.state.snapshot) + .id(context.state.seq) + } + .keylineTint(LAColors.keyline(for: context.state.snapshot).opacity(0.75)) + } + } +} + +// MARK: - Live Activity content margins helper + +private extension View { + @ViewBuilder + func applyActivityContentMarginsFixIfAvailable() -> some View { + if #available(iOS 17.0, *) { + // Use the generic SwiftUI API available in iOS 17+ (no placement enum) + self.contentMargins(Edge.Set.all, 0) + } else { + self + } + } +} + +// MARK: - Lock Screen Contract View +@available(iOS 16.1, *) +private struct LockScreenLiveActivityView: View { + let state: GlucoseLiveActivityAttributes.ContentState + /*let activityID: String*/ + + var body: some View { + let s = state.snapshot + + HStack(spacing: 12) { + + // LEFT: Glucose + trend, update time below + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(LAFormat.glucose(s)) + .font(.system(size: 46, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white) + + Text(LAFormat.trendArrow(s)) + .font(.system(size: 46, weight: .bold, design: .rounded)) + .foregroundStyle(.white.opacity(0.95)) + } + + Text("Last Update: \(LAFormat.updated(s))") + .font(.system(size: 13, weight: .regular, design: .rounded)) + .foregroundStyle(.white.opacity(0.75)) + } + .frame(width: 168, alignment: .leading) + .layoutPriority(2) + + // Divider + Rectangle() + .fill(Color.white.opacity(0.20)) + .frame(width: 1) + .padding(.vertical, 8) + + // RIGHT: 2x2 grid — delta/proj | iob/cob + VStack(spacing: 10) { + HStack(spacing: 16) { + MetricBlock(label: "Delta", value: LAFormat.delta(s)) + MetricBlock(label: "IOB", value: LAFormat.iob(s)) + } + HStack(spacing: 16) { + MetricBlock(label: "Proj", value: LAFormat.projected(s)) + MetricBlock(label: "COB", value: LAFormat.cob(s)) + } + } + .frame(maxWidth: .infinity, alignment: .trailing) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 14) + .padding(.vertical, 12) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(Color.white.opacity(0.20), lineWidth: 1) + ) + .overlay( + Group { + if state.snapshot.isNotLooping { + ZStack { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color(uiColor: UIColor.systemRed).opacity(0.85)) + Text("Not Looping") + .font(.system(size: 20, weight: .heavy, design: .rounded)) + .foregroundStyle(.white) + .tracking(1.5) + } + } + } + ) + } +} + +private struct MetricBlock: View { + let label: String + let value: String + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.78)) + + Text(value) + .font(.system(size: 16, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white) + .lineLimit(1) + .minimumScaleFactor(0.85) + } + .frame(width: 64, alignment: .leading) // consistent 2×2 columns + } +} + +// MARK: - Dynamic Island + +@available(iOS 16.1, *) +private struct DynamicIslandLeadingView: View { + let snapshot: GlucoseSnapshot + var body: some View { + if snapshot.isNotLooping { + VStack(alignment: .leading, spacing: 2) { + Text("⚠️ Not Looping") + .font(.system(size: 20, weight: .heavy, design: .rounded)) + .foregroundStyle(.white) + .tracking(1.0) + .lineLimit(1) + .minimumScaleFactor(0.7) + } + } else { + VStack(alignment: .leading, spacing: 2) { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(LAFormat.glucose(snapshot)) + .font(.system(size: 28, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white) + Text(LAFormat.trendArrow(snapshot)) + .font(.system(size: 16, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.95)) + .padding(.top, 2) + } + Text(LAFormat.delta(snapshot)) + .font(.system(size: 14, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.9)) + } + } + } +} + +@available(iOS 16.1, *) +private struct DynamicIslandTrailingView: View { + let snapshot: GlucoseSnapshot + var body: some View { + if snapshot.isNotLooping { + EmptyView() + } else { + VStack(alignment: .trailing, spacing: 3) { + Text("Upd \(LAFormat.updated(snapshot))") + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.85)) + Text("Proj \(LAFormat.projected(snapshot))") + .font(.system(size: 13, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.95)) + } + } + } +} + +@available(iOS 16.1, *) +private struct DynamicIslandBottomView: View { + let snapshot: GlucoseSnapshot + var body: some View { + if snapshot.isNotLooping { + Text("Loop has not reported in 15+ minutes") + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.92)) + .lineLimit(1) + .minimumScaleFactor(0.75) + } else { + HStack(spacing: 14) { + Text("IOB \(LAFormat.iob(snapshot))") + Text("COB \(LAFormat.cob(snapshot))") + } + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.92)) + .lineLimit(1) + .minimumScaleFactor(0.85) + } + } +} + +@available(iOS 16.1, *) +private struct DynamicIslandCompactTrailingView: View { + let snapshot: GlucoseSnapshot + var body: some View { + if snapshot.isNotLooping { + Text("Not Looping") + .font(.system(size: 11, weight: .heavy, design: .rounded)) + .foregroundStyle(.white) + .lineLimit(1) + .minimumScaleFactor(0.7) + } else { + Text(LAFormat.trendArrow(snapshot)) + .font(.system(size: 14, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.95)) + } + } +} + +@available(iOS 16.1, *) +private struct DynamicIslandCompactLeadingView: View { + let snapshot: GlucoseSnapshot + var body: some View { + if snapshot.isNotLooping { + Text("⚠️") + .font(.system(size: 14)) + } else { + Text(LAFormat.glucose(snapshot)) + .font(.system(size: 16, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white) + } + } +} + +@available(iOS 16.1, *) +private struct DynamicIslandMinimalView: View { + let snapshot: GlucoseSnapshot + var body: some View { + if snapshot.isNotLooping { + Text("⚠️") + .font(.system(size: 12)) + } else { + Text(LAFormat.glucose(snapshot)) + .font(.system(size: 14, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white) + } + } +} + +// MARK: - Formatting + +private enum LAFormat { + + // MARK: Glucose + + static func glucose(_ s: GlucoseSnapshot) -> String { + switch s.unit { + case .mgdl: + return String(Int(round(s.glucose))) + case .mmol: + // 1 decimal always (contract: clinical, consistent) + return String(format: "%.1f", s.glucose) + } + } + + 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: + // Treat tiny fluctuations as 0.0 to avoid “+0.0” noise + let d = (abs(s.delta) < 0.05) ? 0.0 : s.delta + if d == 0 { return "0.0" } + return d > 0 ? String(format: "+%.1f", d) : String(format: "%.1f", d) + } + } + + // MARK: Trend + + static func trendArrow(_ s: GlucoseSnapshot) -> String { + // Map to the common clinical arrows; keep unknown as a neutral dash. + switch s.trend { + case .upFast: return "↑↑" + case .up: return "↑" + case .flat: return "→" + case .down: return "↓" + case .downFast: return "↓↓" + case .unknown: return "–" + } + } + + // MARK: Secondary + + static func iob(_ s: GlucoseSnapshot) -> String { + guard let v = s.iob else { return "—" } + // Contract-friendly: one decimal, no unit suffix + return String(format: "%.1f", v) + } + + static func cob(_ s: GlucoseSnapshot) -> String { + guard let v = s.cob else { return "—" } + // Contract-friendly: whole grams + return String(Int(round(v))) + } + + static func projected(_ s: GlucoseSnapshot) -> String { + guard let v = s.projected else { return "—" } + switch s.unit { + case .mgdl: + return String(Int(round(v))) + case .mmol: + return String(format: "%.1f", v) + } + } + + // MARK: Update time + + private static let hhmmFormatter: DateFormatter = { + let df = DateFormatter() + df.locale = .current + df.timeZone = .current + df.dateFormat = "HH:mm" // 24h format + return df + }() + + private static let hhmmssFormatter: DateFormatter = { + let df = DateFormatter() + df.locale = .current + df.timeZone = .current + df.dateFormat = "HH:mm:ss" + return df + }() + + static func hhmmss(_ date: Date) -> String { + hhmmssFormatter.string(from: date) + } + + static func updated(_ s: GlucoseSnapshot) -> String { + hhmmFormatter.string(from: s.updatedAt) + } +} + +// MARK: - Threshold-driven colors (Option A, App Group-backed) + +private enum LAColors { + + static func backgroundTint(for snapshot: GlucoseSnapshot) -> Color { + let mgdl = toMgdl(snapshot) + + 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 { + // In range: fixed at your existing value + return Color(uiColor: UIColor.systemGreen).opacity(0.36) + } + } + + + static func keyline(for snapshot: GlucoseSnapshot) -> Color { + let mgdl = toMgdl(snapshot) + + 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) + } + } + + private static func toMgdl(_ snapshot: GlucoseSnapshot) -> Double { + switch snapshot.unit { + case .mgdl: + return snapshot.glucose + case .mmol: + // Convert mmol/L → mg/dL for threshold comparison + return GlucoseUnitConversion.convertGlucose(snapshot.glucose, from: .mmol, to: .mgdl) + } + } +} diff --git a/LoopFollowLAExtensionExtension.entitlements b/LoopFollowLAExtensionExtension.entitlements new file mode 100644 index 000000000..c2a1aa523 --- /dev/null +++ b/LoopFollowLAExtensionExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.2HEY366Q6J.LoopFollow + + + diff --git a/docs/LiveActivity.md b/docs/LiveActivity.md new file mode 100644 index 000000000..651b80086 --- /dev/null +++ b/docs/LiveActivity.md @@ -0,0 +1,166 @@ +# LoopFollow Live Activity — Architecture & Design Decisions + +**Author:** Philippe Achkar (supported by Claude) +**Date:** 2026-03-07 + +--- + +## What Is the Live Activity? + +The Live Activity displays real-time glucose data on the iPhone lock screen and in the Dynamic Island. It shows: + +- Current glucose value (mg/dL or mmol/L) +- Trend arrow and delta +- IOB, COB, and projected glucose (when available) +- Threshold-driven background color (red (low) / green (in-range) / orange (high)) with user-set thresholds +- A "Not Looping" overlay when Loop has not reported in 15+ minutes + +It updates every 5 minutes, driven by LoopFollow's existing refresh engine. No separate data pipeline exists — the Live Activity is a rendering surface only. + +--- + +## Core Principles + +### 1. Single Source of Truth + +The Live Activity never fetches data directly from Nightscout or Dexcom. It reads exclusively from LoopFollow's internal storage layer (`Storage.shared`, `Observable.shared`). All glucose values, thresholds, IOB, COB, and loop status flow through the same path as the rest of the app. + +This means: +- No duplicated business logic +- No risk of the Live Activity showing different data than the app +- The architecture is reusable for Apple Watch and CarPlay in future phases + +### 2. Source-Agnostic Design + +LoopFollow supports both Nightscout and Dexcom. IOB, COB, or predicted glucose are modeled as optional (`Double?`) in `GlucoseSnapshot` and the UI renders a dash (—) when they are absent. The Live Activity never assumes these values exist. + +### 3. No Hardcoded Identifiers + +The App Group ID is derived dynamically at runtime: group.. No team-specific bundle IDs or App Group IDs are hardcoded anywhere. This ensures the project is safe to fork, clone, and submit as a pull request by any contributor. + +--- + +## Update Architecture — Why APNs Self-Push? + +This is the most important architectural decision in Phase 1. Understanding it will help you maintain and extend this feature correctly. + +### What We Tried First — Direct ``activity.update()`` + +The obvious approach to updating a Live Activity is to call ``activity.update()`` directly from the app. This works reliably when the app is in the foreground. + +The problem appears when the app is in the background. LoopFollow uses a background audio session (`.playback` category, silent WAV file) to stay alive in the background and continue fetching glucose data. We discovered that _liveactivitiesd_ (the iOS system daemon responsible for rendering Live Activities) refuses to process ``activity.update()`` calls from processes that hold an active background audio session. The update call either hangs indefinitely or is silently dropped. The Live Activity freezes on the lock screen while the app continues running normally. + +We attempted several workarounds; none of these approaches were reliable or production-safe: +- Call ``activity.update()`` while audio is playing | Updates hang or are dropped +- Pause the audio player before updating | Insufficient — iOS checks the process-level audio assertion, not just the player state +- Call `AVAudioSession.setActive(false)` before updating | Intermittently worked, but introduced a race condition and broke the audio session unpredictably +- Add a fixed 3-second wait after deactivation | Fragile, caused background task timeout warnings, and still failed intermittently + +### The Solution — APNs Self-Push + +Our solution is for LoopFollow to send an APNs (Apple Push Notification service) push notification to itself. + +Here is how it works: + +1. When a Live Activity is started, ActivityKit provides a **push token** — a unique identifier for that specific Live Activity instance. +2. LoopFollow captures this token via `activity.pushTokenUpdates`. +3. After each BG refresh, LoopFollow generates a signed JWT using its APNs authentication key and posts an HTTP/2 request directly to Apple's APNs servers. +4. Apple's APNs infrastructure delivers the push to `liveactivitiesd` on the device. +5. `liveactivitiesd` updates the Live Activity directly — the app process is **never involved in the rendering path**. + +Because `liveactivitiesd` receives the update via APNs rather than via an inter-process call from LoopFollow, it does not care that LoopFollow holds a background audio session. The update is processed reliably every time. + +### Why This Is Safe and Appropriate + +- This is an officially supported ActivityKit feature. Apple documents push-token-based Live Activity updates as the **recommended** update mechanism. +- The push is sent from the app itself, to itself. No external server or provider infrastructure is required. +- The APNs authentication key is injected at build time via xcconfig and Info.plist. It is never stored in the repository. +- The JWT is generated on-device using CryptoKit (`P256.Signing`) and cached for 55 minutes (APNs tokens are valid for 60 minutes). + +--- + +## File Map + +### Main App Target + +| File | Responsibility | +|---|---| +| `LiveActivityManager.swift` | Orchestration — start, update, end, bind, observe lifecycle | +| `GlucoseSnapshotBuilder.swift` | Pure data transformation — builds `GlucoseSnapshot` from storage | +| `StorageCurrentGlucoseStateProvider.swift` | Thin abstraction over `Storage.shared` and `Observable.shared` | +| `GlucoseSnapshotStore.swift` | App Group persistence — saves/loads latest snapshot | +| `LAThresholdSync.swift` | Reads threshold config from Storage for widget color | +| `PreferredGlucoseUnit.swift` | Reads user unit preference, converts mg/dL ↔ mmol/L | +| `APNSClient.swift` | Sends APNs self-push with Live Activity content state | +| `APNSJWTGenerator.swift` | Generates ES256-signed JWT for APNs authentication | + +### Shared (App + Extension) + +| File | Responsibility | +|---|---| +| `GlucoseLiveActivityAttributes.swift` | ActivityKit attributes and content state definition | +| `GlucoseSnapshot.swift` | Canonical cross-platform glucose data struct | +| `GlucoseUnitConversion.swift` | Unit conversion helpers | +| `LAAppGroupSettings.swift` | App Group UserDefaults access | +| `AppGroupID.swift` | Derives App Group ID dynamically from bundle identifier | + +### Extension Target + +| File | Responsibility | +|---|---| +| `LoopFollowLiveActivity.swift` | SwiftUI rendering — lock screen card and Dynamic Island | +| `LoopFollowLABundle.swift` | WidgetBundle entry point | + +--- + +## Update Flow + +``` +LoopFollow BG refresh completes + → Storage.shared updated (glucose, delta, trend, IOB, COB, projected) + → Observable.shared updated (isNotLooping) + → BGData calls LiveActivityManager.refreshFromCurrentState(reason: "bg") + → GlucoseSnapshotBuilder.build() reads from StorageCurrentGlucoseStateProvider + → GlucoseSnapshot constructed (unit-converted, threshold-classified) + → GlucoseSnapshotStore persists snapshot to App Group + → activity.update(content) called (direct update for foreground reliability) + → APNSClient.sendLiveActivityUpdate() sends self-push via APNs + → liveactivitiesd receives push + → Lock screen re-renders +``` + +--- + +## APNs Setup — Required for Contributors + +To build and run the Live Activity locally or via CI, you need an APNs authentication key. The key content is injected at build time via `LoopFollowConfigOverride.xcconfig` and is **never stored in the repository**. + +### What you need + +- An Apple Developer account +- An APNs Auth Key (`.p8` file) with the **Apple Push Notifications service (APNs)** capability enabled +- The 10-character Key ID associated with that key + +### Local Build Setup + +1. Generate or download your `.p8` key from [developer.apple.com](https://developer.apple.com) → Certificates, Identifiers & Profiles → Keys. +2. Open the key file in a text editor. Copy the base64 content between the header and footer lines — **exclude** `-----BEGIN PRIVATE KEY-----` and `-----END PRIVATE KEY-----`. Join all lines into a single unbroken string with no spaces or line breaks. +3. Create or edit `LoopFollowConfigOverride.xcconfig` in the project root (this file is gitignored): + +``` +APNS_KEY_ID = +APNS_KEY_CONTENT = +``` + +4. Build and run. The key is read at runtime from `Info.plist` which resolves `$(APNS_KEY_CONTENT)` from the xcconfig. + +### CI / GitHub Actions Setup + +Add two repository secrets under **Settings → Secrets and variables → Actions**: + +| Secret Name | Value | +|---|---| +| `APNS_KEY_ID` | Your 10-character key ID | +| `APNS_KEY` | Full contents of your `.p8` file including PEM headers | + +The build workflow strips the PEM headers automatically and injects the content into `LoopFollowConfigOverride.xcconfig` before building. diff --git a/fastlane/Fastfile b/fastlane/Fastfile index d81e60e5d..0871e7414 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -55,7 +55,8 @@ platform :ios do type: "appstore", git_basic_authorization: Base64.strict_encode64("#{GITHUB_REPOSITORY_OWNER}:#{GH_PAT}"), app_identifier: [ - "com.#{TEAMID}.LoopFollow" + "com.#{TEAMID}.LoopFollow", + "com.#{TEAMID}.LoopFollow.LoopFollowLAExtension" ] ) @@ -70,13 +71,26 @@ platform :ios do targets: ["LoopFollow"] ) + update_code_signing_settings( + path: "#{GITHUB_WORKSPACE}/LoopFollow.xcodeproj", + profile_name: mapping["com.#{TEAMID}.LoopFollow.LoopFollowLAExtension"], + code_sign_identity: "iPhone Distribution", + targets: ["LoopFollowLAExtensionExtension"] + ) + gym( export_method: "app-store", scheme: "LoopFollow", output_name: "LoopFollow.ipa", configuration: "Release", destination: 'generic/platform=iOS', - buildlog_path: 'buildlog' + buildlog_path: 'buildlog', + export_options: { + provisioningProfiles: { + "com.#{TEAMID}.LoopFollow" => mapping["com.#{TEAMID}.LoopFollow"], + "com.#{TEAMID}.LoopFollow.LoopFollowLAExtension" => mapping["com.#{TEAMID}.LoopFollow.LoopFollowLAExtension"] + } + } ) copy_artifacts( @@ -128,6 +142,8 @@ platform :ios do Spaceship::ConnectAPI::BundleIdCapability::Type::PUSH_NOTIFICATIONS ]) + configure_bundle_id("LoopFollow Live Activity Extension", "com.#{TEAMID}.LoopFollow.LoopFollowLAExtension", []) + end desc "Provision Certificates" @@ -148,6 +164,7 @@ platform :ios do git_basic_authorization: Base64.strict_encode64("#{GITHUB_REPOSITORY_OWNER}:#{GH_PAT}"), app_identifier: [ "com.#{TEAMID}.LoopFollow", + "com.#{TEAMID}.LoopFollow.LoopFollowLAExtension" ] ) end From 59f5d2b2a6d5bb84eef7843f9df35b8dafb7e4dd Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sun, 8 Mar 2026 09:43:48 -0400 Subject: [PATCH 02/86] fix: trigger Live Activity refresh on not-looping state change; handle APNs error codes; fix DST timezone --- LoopFollow/Controllers/Nightscout/DeviceStatus.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift index a287548c6..fc1739c4c 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift @@ -56,6 +56,9 @@ extension MainViewController { LoopStatusLabel.text = "⚠️ Not Looping!" LoopStatusLabel.textColor = UIColor.systemYellow LoopStatusLabel.font = UIFont.boldSystemFont(ofSize: 18) + if #available(iOS 16.1, *) { + LiveActivityManager.shared.refreshFromCurrentState(reason: "notLooping") + } } else { IsNotLooping = false @@ -74,6 +77,9 @@ extension MainViewController { case .system: LoopStatusLabel.textColor = UIColor.label } + if #available(iOS 16.1, *) { + LiveActivityManager.shared.refreshFromCurrentState(reason: "loopingResumed") + } } } From c53e17f2438bc74a5782e8e1ae65d499db86dc1b Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sun, 8 Mar 2026 20:36:01 -0400 Subject: [PATCH 03/86] Fix PR issues + DST fix and better APNs error checking --- LoopFollow.xcodeproj/project.pbxproj | 24 ++++---- LoopFollow/Helpers/BackgroundTaskAudio.swift | 21 +++---- LoopFollow/Info.plist | 2 +- LoopFollow/LiveActivity/APNSClient.swift | 29 +++++++++- LoopFollow/LiveActivity/AppGroupID.swift | 3 +- .../GlucoseLiveActivityAttributes.swift | 29 +++++++--- LoopFollow/LiveActivity/GlucoseSnapshot.swift | 19 ++++++- .../LiveActivity/GlucoseSnapshotBuilder.swift | 33 ++++++----- .../LiveActivity/LiveActivityManager.swift | 55 ++++++++++++++----- LoopFollow/Loop Follow.entitlements | 2 +- .../ViewControllers/MainViewController.swift | 5 +- LoopFollowLAExtensionExtension.entitlements | 2 +- 12 files changed, 154 insertions(+), 70 deletions(-) diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index f85491fc1..bacc966b5 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -2410,7 +2410,7 @@ LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MACOSX_DEPLOYMENT_TARGET = 26.2; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.2HEY366Q6J.LoopFollow.LoopFollowLAExtension; + PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix).LoopFollowLAExtension"; PRODUCT_MODULE_NAME = LoopFollowLAExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; @@ -2423,7 +2423,7 @@ SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 1; + TARGETED_DEVICE_FAMILY = "1,2"; XROS_DEPLOYMENT_TARGET = 26.2; }; name = Debug; @@ -2462,7 +2462,7 @@ LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MACOSX_DEPLOYMENT_TARGET = 26.2; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.2HEY366Q6J.LoopFollow.LoopFollowLAExtension; + PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix).LoopFollowLAExtension"; PRODUCT_MODULE_NAME = LoopFollowLAExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; @@ -2474,7 +2474,7 @@ SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 1; + TARGETED_DEVICE_FAMILY = "1,2"; XROS_DEPLOYMENT_TARGET = 26.2; }; name = Release; @@ -2667,14 +2667,12 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.2HEY366Q6J.LoopFollow; + PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix)"; PRODUCT_NAME = "Loop Follow"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; - SUPPORTS_MACCATALYST = NO; - SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; - SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_MACCATALYST = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 1; + TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; @@ -2693,14 +2691,12 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.2HEY366Q6J.LoopFollow; + PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix)"; PRODUCT_NAME = "Loop Follow"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; - SUPPORTS_MACCATALYST = NO; - SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; - SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_MACCATALYST = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 1; + TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; }; diff --git a/LoopFollow/Helpers/BackgroundTaskAudio.swift b/LoopFollow/Helpers/BackgroundTaskAudio.swift index b7bbd26a8..91504ab5d 100755 --- a/LoopFollow/Helpers/BackgroundTaskAudio.swift +++ b/LoopFollow/Helpers/BackgroundTaskAudio.swift @@ -1,18 +1,16 @@ -// BackgroundTaskAudio.swift // LoopFollow -// Philippe Achkar -// 2026-03-07 +// BackgroundTaskAudio.swift import AVFoundation class BackgroundTask { - // MARK: - Vars - static let shared = BackgroundTask() + var player = AVAudioPlayer() var timer = Timer() // MARK: - Methods + func startBackgroundTask() { NotificationCenter.default.addObserver(self, selector: #selector(interruptedAudio), name: AVAudioSession.interruptionNotification, object: AVAudioSession.sharedInstance()) playAudio() @@ -21,15 +19,10 @@ class BackgroundTask { func stopBackgroundTask() { NotificationCenter.default.removeObserver(self, name: AVAudioSession.interruptionNotification, object: nil) player.stop() - do { - try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) - LogManager.shared.log(category: .general, message: "Silent audio stopped", isDebug: true) - } catch { - LogManager.shared.log(category: .general, message: "Silent audio stop failed: \(error)", isDebug: true) - } + LogManager.shared.log(category: .general, message: "Silent audio stopped", isDebug: true) } - @objc private func interruptedAudio(_ notification: Notification) { + @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! @@ -39,13 +32,15 @@ class BackgroundTask { } } - private func playAudio() { + fileprivate func playAudio() { 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) + // Play audio forever by setting num of loops to -1 player.numberOfLoops = -1 player.volume = 0.01 player.prepareToPlay() diff --git a/LoopFollow/Info.plist b/LoopFollow/Info.plist index 94351928e..6f329c1a6 100644 --- a/LoopFollow/Info.plist +++ b/LoopFollow/Info.plist @@ -25,7 +25,7 @@ CFBundleIconFile Activities CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) + com.$(unique_id).LoopFollow$(app_suffix) CFBundleInfoDictionaryVersion 6.0 CFBundleName diff --git a/LoopFollow/LiveActivity/APNSClient.swift b/LoopFollow/LiveActivity/APNSClient.swift index 8a46babef..7e98103dd 100644 --- a/LoopFollow/LiveActivity/APNSClient.swift +++ b/LoopFollow/LiveActivity/APNSClient.swift @@ -61,9 +61,34 @@ class APNSClient { let (data, response) = try await URLSession.shared.data(for: request) if let httpResponse = response as? HTTPURLResponse { - if httpResponse.statusCode == 200 { + switch httpResponse.statusCode { + case 200: LogManager.shared.log(category: .general, message: "APNs push sent successfully", isDebug: true) - } else { + + case 400: + let responseBody = String(data: data, encoding: .utf8) ?? "empty" + LogManager.shared.log(category: .general, message: "APNs bad request (400) — malformed payload: \(responseBody)") + + case 403: + // JWT rejected — force regenerate on next push + cachedToken = nil + tokenGeneratedAt = nil + LogManager.shared.log(category: .general, message: "APNs JWT rejected (403) — token cache cleared, will regenerate") + + case 404, 410: + // Activity token not found or expired — end and restart on next refresh + let reason = httpResponse.statusCode == 410 ? "expired (410)" : "not found (404)" + LogManager.shared.log(category: .general, message: "APNs token \(reason) — restarting Live Activity") + LiveActivityManager.shared.handleExpiredToken() + + case 429: + LogManager.shared.log(category: .general, message: "APNs rate limited (429) — will retry on next refresh") + + case 500...599: + let responseBody = String(data: data, encoding: .utf8) ?? "empty" + LogManager.shared.log(category: .general, message: "APNs server error (\(httpResponse.statusCode)) — will retry on next refresh: \(responseBody)") + + default: let responseBody = String(data: data, encoding: .utf8) ?? "empty" LogManager.shared.log(category: .general, message: "APNs push failed status=\(httpResponse.statusCode) body=\(responseBody)") } diff --git a/LoopFollow/LiveActivity/AppGroupID.swift b/LoopFollow/LiveActivity/AppGroupID.swift index 7b02acb94..c0887c2be 100644 --- a/LoopFollow/LiveActivity/AppGroupID.swift +++ b/LoopFollow/LiveActivity/AppGroupID.swift @@ -43,6 +43,7 @@ enum AppGroupID { let knownSuffixes = [ ".LiveActivity", ".LiveActivityExtension", + ".LoopFollowLAExtension", ".Widget", ".WidgetExtension", ".Widgets", @@ -63,4 +64,4 @@ enum AppGroupID { return bundleID } -} \ No newline at end of file +} diff --git a/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift b/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift index 1052ee0ec..981be7a05 100644 --- a/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift +++ b/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift @@ -11,19 +11,32 @@ import Foundation struct GlucoseLiveActivityAttributes: ActivityAttributes { public struct ContentState: Codable, Hashable { - /// The latest snapshot, already converted into the user’s preferred unit. let snapshot: GlucoseSnapshot - - /// Monotonic sequence for “did we update?” debugging and hung detection. let seq: Int - - /// Reason the app refreshed (e.g., "bg", "deviceStatus"). let reason: String - - /// When the activity state was produced. let producedAt: Date + + init(snapshot: GlucoseSnapshot, seq: Int, reason: String, producedAt: Date) { + self.snapshot = snapshot + self.seq = seq + self.reason = reason + self.producedAt = producedAt + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + snapshot = try container.decode(GlucoseSnapshot.self, forKey: .snapshot) + seq = try container.decode(Int.self, forKey: .seq) + reason = try container.decode(String.self, forKey: .reason) + let producedAtInterval = try container.decode(Double.self, forKey: .producedAt) + producedAt = Date(timeIntervalSince1970: producedAtInterval) + } + + private enum CodingKeys: String, CodingKey { + case snapshot, seq, reason, producedAt + } } /// Reserved for future metadata. Keep minimal for stability. let title: String -} \ No newline at end of file +} diff --git a/LoopFollow/LiveActivity/GlucoseSnapshot.swift b/LoopFollow/LiveActivity/GlucoseSnapshot.swift index 563be34d5..db3f50ef3 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshot.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshot.swift @@ -75,13 +75,30 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { self.isNotLooping = isNotLooping } + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(glucose, forKey: .glucose) + try container.encode(delta, forKey: .delta) + try container.encode(trend, forKey: .trend) + try container.encode(updatedAt.timeIntervalSince1970, forKey: .updatedAt) + try container.encodeIfPresent(iob, forKey: .iob) + try container.encodeIfPresent(cob, forKey: .cob) + try container.encodeIfPresent(projected, forKey: .projected) + try container.encode(unit, forKey: .unit) + try container.encode(isNotLooping, forKey: .isNotLooping) + } + + private enum CodingKeys: String, CodingKey { + case glucose, delta, trend, updatedAt, iob, cob, projected, unit, isNotLooping + } + // MARK: - Codable init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) glucose = try container.decode(Double.self, forKey: .glucose) delta = try container.decode(Double.self, forKey: .delta) trend = try container.decode(Trend.self, forKey: .trend) - updatedAt = try container.decode(Date.self, forKey: .updatedAt) + updatedAt = Date(timeIntervalSince1970: try 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) diff --git a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift index a61774ead..31628fedf 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift @@ -66,19 +66,26 @@ enum GlucoseSnapshotBuilder { let trend = mapTrend(provider.trendCode) // Not Looping — read from Observable, set by evaluateNotLooping() in DeviceStatus.swift - let isNotLooping = Observable.shared.isNotLooping.value - - return GlucoseSnapshot( - glucose: glucose, - delta: delta, - trend: trend, - updatedAt: updatedAt, - iob: provider.iob, - cob: provider.cob, - projected: projected, - unit: preferredUnit, - isNotLooping: isNotLooping - ) + let isNotLooping = Observable.shared.isNotLooping.value + + + LogManager.shared.log( + category: .general, + message: "LA snapshot built: updatedAt=\(updatedAt) interval=\(updatedAt.timeIntervalSince1970)", + isDebug: true + ) + + return GlucoseSnapshot( + glucose: glucose, + delta: delta, + trend: trend, + updatedAt: updatedAt, + iob: provider.iob, + cob: provider.cob, + projected: projected, + unit: preferredUnit, + isNotLooping: isNotLooping + ) } private static func mapTrend(_ code: String?) -> GlucoseSnapshot.Trend { diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index e41ce5b39..0c2e20537 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -22,6 +22,7 @@ final class LiveActivityManager { private var lastUpdateTime: Date? private var pushToken: String? private var tokenObservationTask: Task? + private var refreshWorkItem: DispatchWorkItem? // MARK: - Public API @@ -103,50 +104,59 @@ final class LiveActivityManager { } } + func startFromCurrentState() { + let provider = StorageCurrentGlucoseStateProvider() + if let snapshot = GlucoseSnapshotBuilder.build(from: provider) { + LAAppGroupSettings.setThresholds( + lowMgdl: Storage.shared.lowLine.value, + highMgdl: Storage.shared.highLine.value + ) + GlucoseSnapshotStore.shared.save(snapshot) + } + startIfNeeded() + } + func refreshFromCurrentState(reason: String) { + refreshWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + self?.performRefresh(reason: reason) + } + refreshWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 5.0, execute: workItem) + } + + private func performRefresh(reason: String) { let provider = StorageCurrentGlucoseStateProvider() - guard let snapshot = GlucoseSnapshotBuilder.build(from: provider) else { return } - LogManager.shared.log(category: .general, message: "[LA] refresh g=\(snapshot.glucose) reason=\(reason)", isDebug: true) - let fingerprint = "g=\(snapshot.glucose) d=\(snapshot.delta) t=\(snapshot.trend.rawValue) " + "at=\(snapshot.updatedAt.timeIntervalSince1970) iob=\(snapshot.iob?.description ?? "nil") " + "cob=\(snapshot.cob?.description ?? "nil") proj=\(snapshot.projected?.description ?? "nil") u=\(snapshot.unit.rawValue)" - LogManager.shared.log(category: .general, message: "[LA] snapshot \(fingerprint) reason=\(reason)", isDebug: true) - let now = Date() let timeSinceLastUpdate = now.timeIntervalSince(lastUpdateTime ?? .distantPast) let forceRefreshNeeded = timeSinceLastUpdate >= 5 * 60 - if let previous = GlucoseSnapshotStore.shared.load(), previous == snapshot, !forceRefreshNeeded { return } - LAAppGroupSettings.setThresholds( lowMgdl: Storage.shared.lowLine.value, highMgdl: Storage.shared.highLine.value ) - GlucoseSnapshotStore.shared.save(snapshot) - guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } - if current == nil, let existing = Activity.activities.first { bind(to: existing, logReason: "bind-existing") } - if let _ = current { update(snapshot: snapshot, reason: reason) return } - if isAppVisibleForLiveActivityStart() { startIfNeeded() if current != nil { @@ -156,7 +166,7 @@ final class LiveActivityManager { 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 } @@ -198,7 +208,19 @@ final class LiveActivityManager { if Task.isCancelled { return } - await activity.update(content) + // 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 } @@ -237,6 +259,11 @@ final class LiveActivityManager { } } + func handleExpiredToken() { + end() + // Activity will restart on next BG refresh via refreshFromCurrentState() + } + private func attachStateObserver(to activity: Activity) { stateObserverTask?.cancel() stateObserverTask = Task { diff --git a/LoopFollow/Loop Follow.entitlements b/LoopFollow/Loop Follow.entitlements index 4ec33220c..69ade1013 100644 --- a/LoopFollow/Loop Follow.entitlements +++ b/LoopFollow/Loop Follow.entitlements @@ -10,7 +10,7 @@ com.apple.security.application-groups - group.com.2HEY366Q6J.LoopFollow + group.com.$(unique_id).LoopFollow$(app_suffix) com.apple.security.device.bluetooth diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 453f67097..b5e37222a 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -59,7 +59,7 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele var currentCage: cageData? var currentIage: iageData? - var backgroundTask = BackgroundTask.shared + var backgroundTask = BackgroundTask() var graphNowTimer = Timer() @@ -827,6 +827,9 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele @objc override func viewDidAppear(_: Bool) { showHideNSDetails() + if #available(iOS 16.1, *) { + LiveActivityManager.shared.startFromCurrentState() + } } func stringFromTimeInterval(interval: TimeInterval) -> String { diff --git a/LoopFollowLAExtensionExtension.entitlements b/LoopFollowLAExtensionExtension.entitlements index c2a1aa523..5b963cc90 100644 --- a/LoopFollowLAExtensionExtension.entitlements +++ b/LoopFollowLAExtensionExtension.entitlements @@ -4,7 +4,7 @@ com.apple.security.application-groups - group.com.2HEY366Q6J.LoopFollow + group.com.$(unique_id).LoopFollow$(app_suffix) From b833ad972dd214d84fad1a02a0e0e369630bd615 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Mon, 9 Mar 2026 23:30:35 -0400 Subject: [PATCH 04/86] fix: address remaining hardcoded bundleID --- .gitignore | 2 +- LoopFollow.xcodeproj/project.pbxproj | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index f176e2f72..178842387 100644 --- a/.gitignore +++ b/.gitignore @@ -80,4 +80,4 @@ fastlane/test_output fastlane/FastlaneRunner LoopFollowConfigOverride.xcconfig -.history \ No newline at end of file +.history*.xcuserstate diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 1e68023a2..5fe9e6dd3 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -2401,7 +2401,7 @@ CODE_SIGN_ENTITLEMENTS = LoopFollowLAExtensionExtension.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 2HEY366Q6J; + DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = NO; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -2453,7 +2453,7 @@ CODE_SIGN_ENTITLEMENTS = LoopFollowLAExtensionExtension.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 2HEY366Q6J; + DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = NO; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -2675,7 +2675,7 @@ CODE_SIGN_ENTITLEMENTS = "LoopFollow/Loop Follow.entitlements"; CODE_SIGN_STYLE = Automatic; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = 2HEY366Q6J; + DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; INFOPLIST_FILE = LoopFollow/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.6; LD_RUNPATH_SEARCH_PATHS = ( @@ -2700,7 +2700,7 @@ CODE_SIGN_ENTITLEMENTS = "LoopFollow/Loop Follow.entitlements"; CODE_SIGN_STYLE = Automatic; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = 2HEY366Q6J; + DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; INFOPLIST_FILE = LoopFollow/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.6; LD_RUNPATH_SEARCH_PATHS = ( From 524b3bb86959b662c436cf37c60b5e0a51b3bdef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Wed, 11 Mar 2026 19:41:30 +0100 Subject: [PATCH 05/86] Replace SwiftJWT with CryptoKit and separate APNs credentials - Consolidate JWT generation into JWTManager using CryptoKit with multi-slot in-memory cache, removing SwiftJWT and swift-crypto SPM dependencies - Separate APNs keys for LoopFollow (lf) vs remote commands, with automatic team-ID routing and a migration step for legacy keys - Add dedicated APN settings page for LoopFollow's own APNs keys - Remove hardcoded APNs credentials from CI workflow and Info.plist in favor of user-configured keys - Apply swiftformat to Live Activity files --- .github/workflows/build_LoopFollow.yml | 14 +-- LoopFollow.xcodeproj/project.pbxproj | 38 +----- .../xcshareddata/swiftpm/Package.resolved | 16 --- .../Controllers/Nightscout/BGData.swift | 6 +- .../Nightscout/DeviceStatusLoop.swift | 3 +- LoopFollow/Helpers/JWTManager.swift | 108 ++++++++++++---- LoopFollow/Info.plist | 6 - LoopFollow/LiveActivity/APNSClient.swift | 79 ++++++------ .../LiveActivity/APNSJWTGenerator.swift | 116 ------------------ LoopFollow/LiveActivity/AppGroupID.swift | 14 +-- .../GlucoseLiveActivityAttributes.swift | 11 +- LoopFollow/LiveActivity/GlucoseSnapshot.swift | 21 ++-- .../LiveActivity/GlucoseSnapshotBuilder.swift | 16 +-- .../LiveActivity/GlucoseSnapshotStore.swift | 9 +- .../LiveActivity/GlucoseUnitConversion.swift | 11 +- .../LiveActivity/LAAppGroupSettings.swift | 11 +- LoopFollow/LiveActivity/LAThresholdSync.swift | 11 +- .../LiveActivity/LiveActivityManager.swift | 20 ++- .../LiveActivity/PreferredGlucoseUnit.swift | 11 +- .../StorageCurrentGlucoseStateProvider.swift | 9 +- .../Remote/LoopAPNS/LoopAPNSService.swift | 86 ++++++------- .../Settings/RemoteCommandSettings.swift | 24 ++-- .../Remote/Settings/RemoteSettingsView.swift | 91 ++++++-------- .../Settings/RemoteSettingsViewModel.swift | 56 +++------ .../Remote/TRC/PushNotificationManager.swift | 39 +++--- LoopFollow/Settings/APNSettingsView.swift | 44 +++++++ .../ImportExport/ExportableSettings.swift | 24 ++-- LoopFollow/Settings/SettingsMenuView.swift | 8 ++ LoopFollow/Storage/Observable.swift | 2 +- LoopFollow/Storage/Storage+Migrate.swift | 39 ++++++ LoopFollow/Storage/Storage.swift | 15 +-- .../ViewControllers/MainViewController.swift | 38 +++--- .../LoopFollowLABundle.swift | 14 +-- .../LoopFollowLiveActivity.swift | 30 ++--- 34 files changed, 447 insertions(+), 593 deletions(-) delete mode 100644 LoopFollow.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved delete mode 100644 LoopFollow/LiveActivity/APNSJWTGenerator.swift create mode 100644 LoopFollow/Settings/APNSettingsView.swift diff --git a/.github/workflows/build_LoopFollow.yml b/.github/workflows/build_LoopFollow.yml index 361dcacdc..2e8c0be54 100644 --- a/.github/workflows/build_LoopFollow.yml +++ b/.github/workflows/build_LoopFollow.yml @@ -165,7 +165,7 @@ jobs: build: name: Build needs: [check_certs, check_status] - runs-on: macos-15 + runs-on: macos-26 permissions: contents: write if: @@ -175,7 +175,7 @@ jobs: (vars.SCHEDULED_SYNC != 'false' && needs.check_status.outputs.NEW_COMMITS == 'true' ) steps: - name: Select Xcode version - run: "sudo xcode-select --switch /Applications/Xcode_16.4.app/Contents/Developer" + run: "sudo xcode-select --switch /Applications/Xcode_26.2.app/Contents/Developer" - name: Checkout Repo for building uses: actions/checkout@v4 @@ -203,16 +203,6 @@ jobs: - name: Sync clock run: sudo sntp -sS time.windows.com - - name: Inject APNs Key Content - env: - APNS_KEY: ${{ secrets.APNS_KEY }} - APNS_KEY_ID: ${{ secrets.APNS_KEY_ID }} - run: | - # Strip PEM headers, footers, and newlines — xcconfig requires single line - APNS_KEY_CONTENT=$(echo "$APNS_KEY" | grep -v "BEGIN\|END" | tr -d '\n\r ') - echo "APNS_KEY_ID = ${APNS_KEY_ID}" >> "${GITHUB_WORKSPACE}/LoopFollowConfigOverride.xcconfig" - echo "APNS_KEY_CONTENT = ${APNS_KEY_CONTENT}" >> "${GITHUB_WORKSPACE}/LoopFollowConfigOverride.xcconfig" - # Build signed LoopFollow IPA file - name: Fastlane Build & Archive run: bundle exec fastlane build_LoopFollow diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 5fe9e6dd3..83592c9ad 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -7,7 +7,6 @@ objects = { /* Begin PBXBuildFile section */ - 374A77972F5BD78700E96858 /* APNSJWTGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77962F5BD77500E96858 /* APNSJWTGenerator.swift */; }; 374A77992F5BD8B200E96858 /* APNSClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77982F5BD8AB00E96858 /* APNSClient.swift */; }; 374A77A42F5BE17000E96858 /* GlucoseUnitConversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A22F5BE17000E96858 /* GlucoseUnitConversion.swift */; }; 374A77A52F5BE17000E96858 /* AppGroupID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A779F2F5BE17000E96858 /* AppGroupID.swift */; }; @@ -42,6 +41,7 @@ 6584B1012E4A263900135D4D /* TOTPService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6584B1002E4A263900135D4D /* TOTPService.swift */; }; 6589CC622E9E7D1600BB18FE /* ImportExportSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC532E9E7D1600BB18FE /* ImportExportSettingsView.swift */; }; 6589CC632E9E7D1600BB18FE /* GeneralSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC5D2E9E7D1600BB18FE /* GeneralSettingsView.swift */; }; + 5544D8C363FB5D3B9BF8CE4A /* APNSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7C2676561D686C6459CAA2D /* APNSettingsView.swift */; }; 6589CC642E9E7D1600BB18FE /* ContactSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC592E9E7D1600BB18FE /* ContactSettingsView.swift */; }; 6589CC652E9E7D1600BB18FE /* DexcomSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC5C2E9E7D1600BB18FE /* DexcomSettingsViewModel.swift */; }; 6589CC662E9E7D1600BB18FE /* AdvancedSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC562E9E7D1600BB18FE /* AdvancedSettingsView.swift */; }; @@ -109,7 +109,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 */; }; @@ -455,7 +454,6 @@ /* 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 = ""; }; - 374A77962F5BD77500E96858 /* APNSJWTGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNSJWTGenerator.swift; sourceTree = ""; }; 374A77982F5BD8AB00E96858 /* APNSClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNSClient.swift; sourceTree = ""; }; 374A779F2F5BE17000E96858 /* AppGroupID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppGroupID.swift; sourceTree = ""; }; 374A77A02F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseLiveActivityAttributes.swift; sourceTree = ""; }; @@ -494,6 +492,7 @@ 6589CC5B2E9E7D1600BB18FE /* DexcomSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DexcomSettingsView.swift; sourceTree = ""; }; 6589CC5C2E9E7D1600BB18FE /* DexcomSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DexcomSettingsViewModel.swift; sourceTree = ""; }; 6589CC5D2E9E7D1600BB18FE /* GeneralSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingsView.swift; sourceTree = ""; }; + E7C2676561D686C6459CAA2D /* APNSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNSettingsView.swift; sourceTree = ""; }; 6589CC5E2E9E7D1600BB18FE /* GraphSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraphSettingsView.swift; sourceTree = ""; }; 6589CC5F2E9E7D1600BB18FE /* SettingsMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsMenuView.swift; sourceTree = ""; }; 6589CC602E9E7D1600BB18FE /* TabCustomizationModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabCustomizationModal.swift; sourceTree = ""; }; @@ -903,7 +902,6 @@ FCFEEC9E2486E68E00402A7F /* WebKit.framework in Frameworks */, DD485F162E46631000CE8CBF /* CryptoSwift in Frameworks */, 3F1335F351590E573D8E6962 /* Pods_LoopFollow.framework in Frameworks */, - DD48781C2C7DAF140048F05C /* SwiftJWT in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -925,7 +923,6 @@ 374A77A22F5BE17000E96858 /* GlucoseUnitConversion.swift */, 374A77A32F5BE17000E96858 /* LAAppGroupSettings.swift */, 374A77982F5BD8AB00E96858 /* APNSClient.swift */, - 374A77962F5BD77500E96858 /* APNSJWTGenerator.swift */, ); path = LiveActivity; sourceTree = ""; @@ -947,6 +944,7 @@ children = ( 6589CC552E9E7D1600BB18FE /* ImportExport */, 6589CC562E9E7D1600BB18FE /* AdvancedSettingsView.swift */, + E7C2676561D686C6459CAA2D /* APNSettingsView.swift */, 6589CC572E9E7D1600BB18FE /* AdvancedSettingsViewModel.swift */, 6589CC582E9E7D1600BB18FE /* CalendarSettingsView.swift */, 6589CC592E9E7D1600BB18FE /* ContactSettingsView.swift */, @@ -1767,7 +1765,6 @@ ); name = LoopFollow; packageProductDependencies = ( - DD48781B2C7DAF140048F05C /* SwiftJWT */, DD485F152E46631000CE8CBF /* CryptoSwift */, ); productName = LoopFollow; @@ -1806,8 +1803,6 @@ ); mainGroup = FC97880B2485969B00A7906C; packageReferences = ( - DD48781A2C7DAF140048F05C /* XCRemoteSwiftPackageReference "Swift-JWT" */, - 654132E82E19F0B800BDBE08 /* XCRemoteSwiftPackageReference "swift-crypto" */, DD485F0B2E4547C800CE8CBF /* XCRemoteSwiftPackageReference "CryptoSwift" */, ); productRefGroup = FC9788152485969B00A7906C /* Products */; @@ -2082,7 +2077,6 @@ DD7F4B9F2DD1F92700D449E9 /* AlarmActiveSection.swift in Sources */, DD4AFB672DB68C5500BB593F /* UUID+Identifiable.swift in Sources */, DD9ED0CA2D355257000D2A63 /* LogView.swift in Sources */, - 374A77972F5BD78700E96858 /* APNSJWTGenerator.swift in Sources */, DD9ACA082D32F68B00415D8A /* BGTask.swift in Sources */, DD9ACA102D34129200415D8A /* Task.swift in Sources */, DD4AFB492DB576C200BB593F /* AlarmSettingsView.swift in Sources */, @@ -2216,6 +2210,7 @@ DD493AE12ACF22FE009A6922 /* Profile.swift in Sources */, 6589CC622E9E7D1600BB18FE /* ImportExportSettingsView.swift in Sources */, 6589CC632E9E7D1600BB18FE /* GeneralSettingsView.swift in Sources */, + 5544D8C363FB5D3B9BF8CE4A /* APNSettingsView.swift in Sources */, 6589CC642E9E7D1600BB18FE /* ContactSettingsView.swift in Sources */, 6589CC652E9E7D1600BB18FE /* DexcomSettingsViewModel.swift in Sources */, 6589CC662E9E7D1600BB18FE /* AdvancedSettingsView.swift in Sources */, @@ -2758,14 +2753,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"; @@ -2774,27 +2761,14 @@ minimumVersion = 1.9.0; }; }; - DD48781A2C7DAF140048F05C /* XCRemoteSwiftPackageReference "Swift-JWT" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/Kitura/Swift-JWT.git"; - requirement = { - kind = exactVersion; - version = 4.0.1; - }; - }; -/* End XCRemoteSwiftPackageReference section */ + /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ DD485F152E46631000CE8CBF /* CryptoSwift */ = { isa = XCSwiftPackageProductDependency; productName = CryptoSwift; }; - DD48781B2C7DAF140048F05C /* SwiftJWT */ = { - isa = XCSwiftPackageProductDependency; - package = DD48781A2C7DAF140048F05C /* XCRemoteSwiftPackageReference "Swift-JWT" */; - productName = SwiftJWT; - }; -/* End XCSwiftPackageProductDependency section */ + /* End XCSwiftPackageProductDependency section */ /* Begin XCVersionGroup section */ FC3AE7B3249E8E0E00AAE1E0 /* LoopFollow.xcdatamodeld */ = { diff --git a/LoopFollow.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/LoopFollow.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index f2d75025c..000000000 --- a/LoopFollow.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,16 +0,0 @@ -{ - "object": { - "pins": [ - { - "package": "Oxygen", - "repositoryURL": "https://github.com/mpangburn/Oxygen.git", - "state": { - "branch": "master", - "revision": "b3c7a6ead1400e4799b16755d23c9905040d4acc", - "version": null - } - } - ] - }, - "version": 1 -} diff --git a/LoopFollow/Controllers/Nightscout/BGData.swift b/LoopFollow/Controllers/Nightscout/BGData.swift index 2f0311053..c870abe57 100644 --- a/LoopFollow/Controllers/Nightscout/BGData.swift +++ b/LoopFollow/Controllers/Nightscout/BGData.swift @@ -264,16 +264,15 @@ extension MainViewController { Storage.shared.lastBgReadingTimeSeconds.value = lastBGTime Storage.shared.lastDeltaMgdl.value = Double(deltaBG) Storage.shared.lastTrendCode.value = entries[latestEntryIndex].direction - + // Mark BG data as loaded for initial loading state self.markDataLoaded("bg") - + // Live Activity update if #available(iOS 16.1, *) { LiveActivityManager.shared.refreshFromCurrentState(reason: "bg") } - // Update contact if Storage.shared.contactEnabled.value { self.contactImageUpdater @@ -285,7 +284,6 @@ extension MainViewController { ) } Storage.shared.lastBGChecked.value = Date() - } } } diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift index d4f851ba5..650092237 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift @@ -124,7 +124,8 @@ extension MainViewController { 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] { + let values = predictdata["values"] as? [Double] + { Storage.shared.projectedBgMgdl.value = values.last } else { Storage.shared.projectedBgMgdl.value = nil diff --git a/LoopFollow/Helpers/JWTManager.swift b/LoopFollow/Helpers/JWTManager.swift index 621b2186d..06f4a5583 100644 --- a/LoopFollow/Helpers/JWTManager.swift +++ b/LoopFollow/Helpers/JWTManager.swift @@ -1,42 +1,46 @@ // LoopFollow // JWTManager.swift +import CryptoKit import Foundation -import SwiftJWT - -struct JWTClaims: Claims { - let iss: String - let iat: Date -} class JWTManager { static let shared = JWTManager() + private struct CachedToken { + let jwt: String + let expiresAt: Date + } + + /// Cache keyed by "keyId:teamId", 55 min TTL + private var cache: [String: CachedToken] = [:] + private let ttl: TimeInterval = 55 * 60 + private init() {} func getOrGenerateJWT(keyId: String, teamId: String, apnsKey: String) -> String? { - // 1. Check for a valid, non-expired JWT directly from Storage.shared - if let jwt = Storage.shared.cachedJWT.value, - let expiration = Storage.shared.jwtExpirationDate.value, - Date() < expiration - { - return jwt - } + let cacheKey = "\(keyId):\(teamId)" - // 2. If no valid JWT is found, generate a new one - let header = Header(kid: keyId) - let claims = JWTClaims(iss: teamId, iat: Date()) - var jwt = JWT(header: header, claims: claims) + if let cached = cache[cacheKey], Date() < cached.expiresAt { + return cached.jwt + } do { - let privateKey = Data(apnsKey.utf8) - let jwtSigner = JWTSigner.es256(privateKey: privateKey) - let signedJWT = try jwt.sign(using: jwtSigner) + let privateKey = try loadPrivateKey(from: apnsKey) + let header = try encodeHeader(keyId: keyId) + let payload = try encodePayload(teamId: teamId) + let signingInput = "\(header).\(payload)" + + guard let signingData = signingInput.data(using: .utf8) else { + LogManager.shared.log(category: .apns, message: "Failed to encode JWT signing input") + return nil + } - // 3. Save the new JWT and its expiration date directly to Storage.shared - Storage.shared.cachedJWT.value = signedJWT - Storage.shared.jwtExpirationDate.value = Date().addingTimeInterval(3600) // Expires in 1 hour + let signature = try privateKey.signature(for: signingData) + let signatureBase64 = base64URLEncode(signature.rawRepresentation) + let signedJWT = "\(signingInput).\(signatureBase64)" + cache[cacheKey] = CachedToken(jwt: signedJWT, expiresAt: Date().addingTimeInterval(ttl)) return signedJWT } catch { LogManager.shared.log(category: .apns, message: "Failed to sign JWT: \(error.localizedDescription)") @@ -44,9 +48,61 @@ class JWTManager { } } - // Invalidate the cache by clearing values in Storage.shared func invalidateCache() { - Storage.shared.cachedJWT.value = nil - Storage.shared.jwtExpirationDate.value = nil + cache.removeAll() + } + + // MARK: - Private Helpers + + private func loadPrivateKey(from apnsKey: String) throws -> P256.Signing.PrivateKey { + let cleaned = apnsKey + .replacingOccurrences(of: "-----BEGIN PRIVATE KEY-----", with: "") + .replacingOccurrences(of: "-----END PRIVATE KEY-----", with: "") + .replacingOccurrences(of: "\n", with: "") + .replacingOccurrences(of: "\r", with: "") + .trimmingCharacters(in: .whitespaces) + + guard let keyData = Data(base64Encoded: cleaned) else { + throw JWTError.keyDecodingFailed + } + + return try P256.Signing.PrivateKey(derRepresentation: keyData) + } + + private func encodeHeader(keyId: String) throws -> String { + let header: [String: String] = [ + "alg": "ES256", + "kid": keyId, + ] + let data = try JSONSerialization.data(withJSONObject: header) + return base64URLEncode(data) + } + + private func encodePayload(teamId: String) throws -> String { + let now = Int(Date().timeIntervalSince1970) + let payload: [String: Any] = [ + "iss": teamId, + "iat": now, + ] + let data = try JSONSerialization.data(withJSONObject: payload) + return base64URLEncode(data) + } + + private func base64URLEncode(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + private enum JWTError: Error, LocalizedError { + case keyDecodingFailed + + var errorDescription: String? { + switch self { + case .keyDecodingFailed: + return "Failed to decode APNs p8 key content. Ensure it is valid base64." + } + } } } diff --git a/LoopFollow/Info.plist b/LoopFollow/Info.plist index 6f329c1a6..28385ac6e 100644 --- a/LoopFollow/Info.plist +++ b/LoopFollow/Info.plist @@ -2,12 +2,6 @@ - APNSKeyContent - $(APNS_KEY_CONTENT) - APNSKeyID - $(APNS_KEY_ID) - APNSTeamID - $(DEVELOPMENT_TEAM) AppGroupIdentifier group.com.$(unique_id).LoopFollow$(app_suffix) BGTaskSchedulerPermittedIdentifiers diff --git a/LoopFollow/LiveActivity/APNSClient.swift b/LoopFollow/LiveActivity/APNSClient.swift index 7e98103dd..a8f079d05 100644 --- a/LoopFollow/LiveActivity/APNSClient.swift +++ b/LoopFollow/LiveActivity/APNSClient.swift @@ -1,63 +1,55 @@ +// LoopFollow // APNSClient.swift -// Philippe Achkar -// 2026-03-07 import Foundation class APNSClient { - static let shared = APNSClient() private init() {} // MARK: - Configuration private let bundleID = Bundle.main.bundleIdentifier ?? "com.apple.unknown" - private let apnsHost = "https://api.push.apple.com" - - // MARK: - JWT Cache - - private var cachedToken: String? - private var tokenGeneratedAt: Date? - private let tokenTTL: TimeInterval = 55 * 60 - private func validToken() throws -> String { - let now = Date() - if let token = cachedToken, - let generatedAt = tokenGeneratedAt, - now.timeIntervalSince(generatedAt) < tokenTTL { - return token - } - let newToken = try APNSJWTGenerator.generateToken() - cachedToken = newToken - tokenGeneratedAt = now - LogManager.shared.log(category: .general, message: "APNs JWT refreshed", isDebug: true) - return newToken + 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 { - do { - let jwt = try validToken() - let payload = buildPayload(state: state) + guard let jwt = JWTManager.shared.getOrGenerateJWT(keyId: lfKeyId, teamId: lfTeamId, apnsKey: lfApnsKey) else { + LogManager.shared.log(category: .general, message: "APNs failed to generate JWT for Live Activity push") + return + } - guard let url = URL(string: "\(apnsHost)/3/device/\(pushToken)") else { - LogManager.shared.log(category: .general, message: "APNs invalid URL", isDebug: true) - return - } + let payload = buildPayload(state: state) - 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 + guard let url = URL(string: "\(apnsHost)/3/device/\(pushToken)") else { + LogManager.shared.log(category: .general, message: "APNs invalid URL", isDebug: true) + return + } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("bearer \(jwt)", forHTTPHeaderField: "authorization") + request.setValue("application/json", forHTTPHeaderField: "content-type") + request.setValue("\(bundleID).push-type.liveactivity", forHTTPHeaderField: "apns-topic") + request.setValue("liveactivity", forHTTPHeaderField: "apns-push-type") + request.setValue("10", forHTTPHeaderField: "apns-priority") + request.httpBody = payload + + do { let (data, response) = try await URLSession.shared.data(for: request) if let httpResponse = response as? HTTPURLResponse { @@ -71,8 +63,7 @@ class APNSClient { case 403: // JWT rejected — force regenerate on next push - cachedToken = nil - tokenGeneratedAt = nil + JWTManager.shared.invalidateCache() LogManager.shared.log(category: .general, message: "APNs JWT rejected (403) — token cache cleared, will regenerate") case 404, 410: @@ -84,7 +75,7 @@ class APNSClient { case 429: LogManager.shared.log(category: .general, message: "APNs rate limited (429) — will retry on next refresh") - case 500...599: + case 500 ... 599: let responseBody = String(data: data, encoding: .utf8) ?? "empty" LogManager.shared.log(category: .general, message: "APNs server error (\(httpResponse.statusCode)) — will retry on next refresh: \(responseBody)") @@ -109,7 +100,7 @@ class APNSClient { "delta": snapshot.delta, "trend": snapshot.trend.rawValue, "updatedAt": snapshot.updatedAt.timeIntervalSince1970, - "unit": snapshot.unit.rawValue + "unit": snapshot.unit.rawValue, ] if let iob = snapshot.iob { snapshotDict["iob"] = iob } @@ -120,15 +111,15 @@ class APNSClient { "snapshot": snapshotDict, "seq": state.seq, "reason": state.reason, - "producedAt": state.producedAt.timeIntervalSince1970 + "producedAt": state.producedAt.timeIntervalSince1970, ] let payload: [String: Any] = [ "aps": [ "timestamp": Int(Date().timeIntervalSince1970), "event": "update", - "content-state": contentState - ] + "content-state": contentState, + ], ] return try? JSONSerialization.data(withJSONObject: payload) diff --git a/LoopFollow/LiveActivity/APNSJWTGenerator.swift b/LoopFollow/LiveActivity/APNSJWTGenerator.swift deleted file mode 100644 index 381000ed1..000000000 --- a/LoopFollow/LiveActivity/APNSJWTGenerator.swift +++ /dev/null @@ -1,116 +0,0 @@ -// APNSJWTGenerator.swift -// Philippe Achkar -// 2026-03-07 - -import Foundation -import CryptoKit - -struct APNSJWTGenerator { - - // MARK: - Configuration (read from Info.plist — never hardcoded) - - static var keyID: String { - Bundle.main.infoDictionary?["APNSKeyID"] as? String ?? "" - } - - static var teamID: String { - Bundle.main.infoDictionary?["APNSTeamID"] as? String ?? "" - } - - static var keyContent: String { - Bundle.main.infoDictionary?["APNSKeyContent"] as? String ?? "" - } - - // MARK: - JWT Generation - - /// Generates a signed ES256 JWT for APNs authentication. - /// Valid for 60 minutes per Apple's requirements. - static func generateToken() throws -> String { - let privateKey = try loadPrivateKey() - let header = try encodeHeader() - let payload = try encodePayload() - let signingInput = "\(header).\(payload)" - - guard let signingData = signingInput.data(using: .utf8) else { - throw APNSJWTError.encodingFailed - } - - let signature = try privateKey.signature(for: signingData) - let signatureBase64 = base64URLEncode(signature.rawRepresentation) - return "\(signingInput).\(signatureBase64)" - } - - // MARK: - Private Helpers - - private static func loadPrivateKey() throws -> P256.Signing.PrivateKey { - guard !keyID.isEmpty else { - throw APNSJWTError.keyIDNotConfigured - } - - guard !keyContent.isEmpty else { - throw APNSJWTError.keyContentNotConfigured - } - - // Strip PEM headers/footers and whitespace if present - let cleaned = keyContent - .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 APNSJWTError.keyDecodingFailed - } - - return try P256.Signing.PrivateKey(derRepresentation: keyData) - } - - private static func encodeHeader() throws -> String { - let header: [String: String] = [ - "alg": "ES256", - "kid": keyID - ] - let data = try JSONSerialization.data(withJSONObject: header) - return base64URLEncode(data) - } - - private static func encodePayload() 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 static func base64URLEncode(_ data: Data) -> String { - return data.base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - } -} - -// MARK: - Errors - -enum APNSJWTError: Error, LocalizedError { - case keyIDNotConfigured - case keyContentNotConfigured - case keyDecodingFailed - case encodingFailed - - var errorDescription: String? { - switch self { - case .keyIDNotConfigured: - return "APNSKeyID not set in Info.plist or LoopFollowConfigOverride.xcconfig." - case .keyContentNotConfigured: - return "APNSKeyContent not set. Add APNS_KEY_CONTENT to LoopFollowConfigOverride.xcconfig or GitHub Secrets." - case .keyDecodingFailed: - return "Failed to decode APNs p8 key content. Ensure it is valid base64 with no line breaks." - case .encodingFailed: - return "Failed to encode JWT signing input." - } - } -} diff --git a/LoopFollow/LiveActivity/AppGroupID.swift b/LoopFollow/LiveActivity/AppGroupID.swift index c0887c2be..6fc2bb9a6 100644 --- a/LoopFollow/LiveActivity/AppGroupID.swift +++ b/LoopFollow/LiveActivity/AppGroupID.swift @@ -1,9 +1,5 @@ -// -// AppGroupID.swift -// LoopFollow -// -// Created by Philippe Achkar on 2026-02-24. -// +// LoopFollow +// AppGroupID.swift import Foundation @@ -20,14 +16,14 @@ import Foundation /// 2) Otherwise, apply a conservative suffix-stripping heuristic. /// 3) Fall back to the current bundle identifier. enum AppGroupID { - /// Optional Info.plist key you can set in *both* app + extension targets /// to force a shared base bundle id (recommended for reliability). private static let baseBundleIDPlistKey = "LFAppGroupBaseBundleID" static func current() -> String { if let base = Bundle.main.object(forInfoDictionaryKey: baseBundleIDPlistKey) as? String, - !base.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + !base.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { return "group.\(base)" } @@ -53,7 +49,7 @@ enum AppGroupID { ".CarPlay", ".CarPlayExtension", ".Intents", - ".IntentsExtension" + ".IntentsExtension", ] for suffix in knownSuffixes { diff --git a/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift b/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift index 981be7a05..9d6811e56 100644 --- a/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift +++ b/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift @@ -1,15 +1,10 @@ -// -// GlucoseLiveActivityAttributes.swift -// LoopFollow -// -// Created by Philippe Achkar on 2026-02-24. -// +// LoopFollow +// GlucoseLiveActivityAttributes.swift import ActivityKit import Foundation struct GlucoseLiveActivityAttributes: ActivityAttributes { - public struct ContentState: Codable, Hashable { let snapshot: GlucoseSnapshot let seq: Int @@ -31,7 +26,7 @@ struct GlucoseLiveActivityAttributes: ActivityAttributes { let producedAtInterval = try container.decode(Double.self, forKey: .producedAt) producedAt = Date(timeIntervalSince1970: producedAtInterval) } - + private enum CodingKeys: String, CodingKey { case snapshot, seq, reason, producedAt } diff --git a/LoopFollow/LiveActivity/GlucoseSnapshot.swift b/LoopFollow/LiveActivity/GlucoseSnapshot.swift index db3f50ef3..2f0aac9dc 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshot.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshot.swift @@ -1,9 +1,5 @@ -// -// GlucoseSnapshot.swift -// LoopFollow -// -// Created by Philippe Achkar on 2026-02-24. -// +// LoopFollow +// GlucoseSnapshot.swift import Foundation @@ -11,7 +7,6 @@ import Foundation /// Live Activity, future Watch complication, and CarPlay. /// struct GlucoseSnapshot: Codable, Equatable, Hashable { - // MARK: - Units enum Unit: String, Codable, Hashable { @@ -50,6 +45,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { let unit: Unit // MARK: - Loop Status + /// True when LoopFollow detects the loop has not reported in 15+ minutes (Nightscout only). let isNotLooping: Bool @@ -74,7 +70,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { self.unit = unit self.isNotLooping = isNotLooping } - + func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(glucose, forKey: .glucose) @@ -91,21 +87,22 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { private enum CodingKeys: String, CodingKey { case glucose, delta, trend, updatedAt, iob, cob, projected, unit, isNotLooping } - + // MARK: - Codable + init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) glucose = try container.decode(Double.self, forKey: .glucose) delta = try container.decode(Double.self, forKey: .delta) trend = try container.decode(Trend.self, forKey: .trend) - updatedAt = Date(timeIntervalSince1970: try container.decode(Double.self, forKey: .updatedAt)) + updatedAt = try Date(timeIntervalSince1970: container.decode(Double.self, forKey: .updatedAt)) iob = try container.decodeIfPresent(Double.self, forKey: .iob) cob = try container.decodeIfPresent(Double.self, forKey: .cob) projected = try container.decodeIfPresent(Double.self, forKey: .projected) unit = try container.decode(Unit.self, forKey: .unit) isNotLooping = try container.decodeIfPresent(Bool.self, forKey: .isNotLooping) ?? false } - + // MARK: - Derived Convenience /// Age of reading in seconds. @@ -114,11 +111,9 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { } } - // MARK: - Trend extension GlucoseSnapshot { - enum Trend: String, Codable, Hashable { case up case upFast diff --git a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift index 31628fedf..2f0fcaa13 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift @@ -1,9 +1,5 @@ -// -// GlucoseSnapshotBuilder.swift -// LoopFollow -// -// Created by Philippe Achkar on 2026-02-28. -// +// LoopFollow +// GlucoseSnapshotBuilder.swift import Foundation @@ -32,7 +28,6 @@ protocol CurrentGlucoseStateProviding { /// Builds a GlucoseSnapshot in the user’s preferred unit, without embedding provider logic. enum GlucoseSnapshotBuilder { - static func build(from provider: CurrentGlucoseStateProviding) -> GlucoseSnapshot? { guard let glucoseMgdl = provider.glucoseMgdl, @@ -68,13 +63,12 @@ enum GlucoseSnapshotBuilder { // Not Looping — read from Observable, set by evaluateNotLooping() in DeviceStatus.swift let isNotLooping = Observable.shared.isNotLooping.value - LogManager.shared.log( category: .general, message: "LA snapshot built: updatedAt=\(updatedAt) interval=\(updatedAt.timeIntervalSince1970)", isDebug: true ) - + return GlucoseSnapshot( glucose: glucose, delta: delta, @@ -91,8 +85,8 @@ enum GlucoseSnapshotBuilder { private static func mapTrend(_ code: String?) -> GlucoseSnapshot.Trend { guard let raw = code? - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased(), + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased(), !raw.isEmpty else { return .unknown } diff --git a/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift b/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift index b906742ce..b45a7a0b9 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift @@ -1,9 +1,5 @@ -// -// GlucoseSnapshotStore.swift -// LoopFollow -// -// Created by Philippe Achkar on 2026-02-24. -// +// LoopFollow +// GlucoseSnapshotStore.swift import Foundation @@ -13,7 +9,6 @@ import Foundation /// /// Uses an atomic JSON file write to avoid partial/corrupt reads across processes. final class GlucoseSnapshotStore { - static let shared = GlucoseSnapshotStore() private init() {} diff --git a/LoopFollow/LiveActivity/GlucoseUnitConversion.swift b/LoopFollow/LiveActivity/GlucoseUnitConversion.swift index 3d81620b5..cf39988d2 100644 --- a/LoopFollow/LiveActivity/GlucoseUnitConversion.swift +++ b/LoopFollow/LiveActivity/GlucoseUnitConversion.swift @@ -1,14 +1,9 @@ -// -// GlucoseUnitConversion.swift -// LoopFollow -// -// Created by Philippe Achkar on 2026-02-24. -// +// LoopFollow +// GlucoseUnitConversion.swift import Foundation enum GlucoseUnitConversion { - // 1 mmol/L glucose ≈ 18.0182 mg/dL (commonly rounded to 18) // Using 18.0182 is standard for glucose conversions. private static let mgdlPerMmol: Double = 18.0182 @@ -25,4 +20,4 @@ enum GlucoseUnitConversion { return value } } -} \ No newline at end of file +} diff --git a/LoopFollow/LiveActivity/LAAppGroupSettings.swift b/LoopFollow/LiveActivity/LAAppGroupSettings.swift index 091497f1e..7615b2cf7 100644 --- a/LoopFollow/LiveActivity/LAAppGroupSettings.swift +++ b/LoopFollow/LiveActivity/LAAppGroupSettings.swift @@ -1,9 +1,5 @@ -// -// LAAppGroupSettings.swift -// LoopFollow -// -// Created by Philippe Achkar on 2026-02-24. -// +// LoopFollow +// LAAppGroupSettings.swift import Foundation @@ -12,7 +8,6 @@ import Foundation /// 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" @@ -36,4 +31,4 @@ enum LAAppGroupSettings { let high = defaults?.object(forKey: Keys.highLineMgdl) as? Double ?? fallbackHigh return (low, high) } -} \ No newline at end of file +} diff --git a/LoopFollow/LiveActivity/LAThresholdSync.swift b/LoopFollow/LiveActivity/LAThresholdSync.swift index 03a5a95a4..0c6c48e51 100644 --- a/LoopFollow/LiveActivity/LAThresholdSync.swift +++ b/LoopFollow/LiveActivity/LAThresholdSync.swift @@ -1,9 +1,5 @@ -// -// LAThresholdSync.swift -// LoopFollow -// -// Created by Philippe Achkar on 2026-02-25. -// +// LoopFollow +// LAThresholdSync.swift import Foundation @@ -12,11 +8,10 @@ import Foundation /// /// This file belongs ONLY to the main app target. enum LAThresholdSync { - static func syncToAppGroup() { LAAppGroupSettings.setThresholds( lowMgdl: Storage.shared.lowLine.value, highMgdl: Storage.shared.highLine.value ) } -} \ No newline at end of file +} diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 0c2e20537..b342711a7 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -1,17 +1,15 @@ +// LoopFollow // LiveActivityManager.swift -// Philippe Achkar -// 2026-03-07 -import Foundation @preconcurrency import ActivityKit -import UIKit +import Foundation import os +import UIKit /// Live Activity manager for LoopFollow. @available(iOS 16.1, *) final class LiveActivityManager { - static let shared = LiveActivityManager() private init() {} @@ -77,7 +75,7 @@ final class LiveActivityManager { Task { let finalState = GlucoseLiveActivityAttributes.ContentState( - snapshot: (GlucoseSnapshotStore.shared.load() ?? GlucoseSnapshot( + snapshot: GlucoseSnapshotStore.shared.load() ?? GlucoseSnapshot( glucose: 0, delta: 0, trend: .unknown, @@ -87,7 +85,7 @@ final class LiveActivityManager { projected: nil, unit: .mgdl, isNotLooping: false - )), + ), seq: seq, reason: "end", producedAt: Date() @@ -115,7 +113,7 @@ final class LiveActivityManager { } startIfNeeded() } - + func refreshFromCurrentState(reason: String) { refreshWorkItem?.cancel() let workItem = DispatchWorkItem { [weak self] in @@ -124,7 +122,7 @@ final class LiveActivityManager { refreshWorkItem = workItem DispatchQueue.main.asyncAfter(deadline: .now() + 5.0, execute: workItem) } - + private func performRefresh(reason: String) { let provider = StorageCurrentGlucoseStateProvider() guard let snapshot = GlucoseSnapshotBuilder.build(from: provider) else { @@ -166,7 +164,7 @@ final class LiveActivityManager { 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 } @@ -263,7 +261,7 @@ final class LiveActivityManager { end() // Activity will restart on next BG refresh via refreshFromCurrentState() } - + private func attachStateObserver(to activity: Activity) { stateObserverTask?.cancel() stateObserverTask = Task { diff --git a/LoopFollow/LiveActivity/PreferredGlucoseUnit.swift b/LoopFollow/LiveActivity/PreferredGlucoseUnit.swift index b4c5dadfb..f9e40468e 100644 --- a/LoopFollow/LiveActivity/PreferredGlucoseUnit.swift +++ b/LoopFollow/LiveActivity/PreferredGlucoseUnit.swift @@ -1,15 +1,10 @@ -// -// PreferredGlucoseUnit.swift -// LoopFollow -// -// Created by Philippe Achkar on 2026-02-24. -// +// LoopFollow +// PreferredGlucoseUnit.swift import Foundation import HealthKit enum PreferredGlucoseUnit { - /// LoopFollow’s existing source of truth for unit selection. /// NOTE: Do not duplicate the string constant elsewhere—keep it here. static func hkUnit() -> HKUnit { @@ -31,4 +26,4 @@ enum PreferredGlucoseUnit { return .mgdl } } -} \ No newline at end of file +} diff --git a/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift b/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift index 5e50a3e0d..b5a5cf7ea 100644 --- a/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift +++ b/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift @@ -1,16 +1,11 @@ -// -// StorageCurrentGlucoseStateProvider.swift -// LoopFollow -// -// Created by Philippe Achkar on 2026-02-24. -// +// LoopFollow +// StorageCurrentGlucoseStateProvider.swift import Foundation /// Reads the latest glucose state from LoopFollow’s existing single source of truth. /// Provider remains source-agnostic (Nightscout vs Dexcom). struct StorageCurrentGlucoseStateProvider: CurrentGlucoseStateProviding { - var glucoseMgdl: Double? { guard let bg = Observable.shared.bg.value, diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift index efcb15862..61aaa6ef4 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift @@ -1,14 +1,26 @@ // LoopFollow // LoopAPNSService.swift -import CryptoKit import Foundation import HealthKit -import SwiftJWT class LoopAPNSService { private let storage = Storage.shared + /// Returns the effective APNs credentials for sending commands to the remote app. + /// Same team → use LoopFollow's own key. Different team → use remote-specific key. + private func effectiveCredentials() -> (apnsKey: String, keyId: String, teamId: String) { + let lfTeamId = BuildDetails.default.teamID ?? "" + let remoteTeamId = storage.teamId.value ?? "" + let sameTeam = !lfTeamId.isEmpty && !remoteTeamId.isEmpty && lfTeamId == remoteTeamId + + if sameTeam || remoteTeamId.isEmpty { + return (storage.lfApnsKey.value, storage.lfKeyId.value, lfTeamId) + } else { + return (storage.remoteApnsKey.value, storage.remoteKeyId.value, remoteTeamId) + } + } + enum LoopAPNSError: Error, LocalizedError { case invalidConfiguration case jwtError @@ -57,26 +69,11 @@ class LoopAPNSService { return nil } - // Get the target Loop app's Team ID from storage. - let targetTeamId = storage.teamId.value ?? "" - let teamIdsAreDifferent = loopFollowTeamID != targetTeamId - - let keyIdForReturn: String - let apnsKeyForReturn: String - - if teamIdsAreDifferent { - // Team IDs differ, use the separate return credentials. - keyIdForReturn = storage.returnKeyId.value - apnsKeyForReturn = storage.returnApnsKey.value - } else { - // Team IDs are the same, use the primary credentials. - keyIdForReturn = storage.keyId.value - apnsKeyForReturn = storage.apnsKey.value - } + let lfKeyId = storage.lfKeyId.value + let lfApnsKey = storage.lfApnsKey.value - // Ensure we have the necessary credentials. - guard !keyIdForReturn.isEmpty, !apnsKeyForReturn.isEmpty else { - LogManager.shared.log(category: .apns, message: "Missing required return APNS credentials. Check Remote Settings.") + guard !lfKeyId.isEmpty, !lfApnsKey.isEmpty else { + LogManager.shared.log(category: .apns, message: "Missing LoopFollow APNS credentials. Configure them in App Settings → APN.") return nil } @@ -85,8 +82,8 @@ class LoopAPNSService { deviceToken: loopFollowDeviceToken, bundleId: Bundle.main.bundleIdentifier ?? "", teamId: loopFollowTeamID, - keyId: keyIdForReturn, - apnsKey: apnsKeyForReturn + keyId: lfKeyId, + apnsKey: lfApnsKey ) } @@ -108,8 +105,9 @@ class LoopAPNSService { /// Validates the Loop APNS setup by checking all required fields /// - Returns: True if setup is valid, false otherwise func validateSetup() -> Bool { - let hasKeyId = !storage.keyId.value.isEmpty - let hasAPNSKey = !storage.apnsKey.value.isEmpty + let creds = effectiveCredentials() + let hasKeyId = !creds.keyId.isEmpty + let hasAPNSKey = !creds.apnsKey.isEmpty let hasQrCode = !storage.loopAPNSQrCodeURL.value.isEmpty let hasDeviceToken = !Storage.shared.deviceToken.value.isEmpty let hasBundleIdentifier = !Storage.shared.bundleId.value.isEmpty @@ -138,8 +136,7 @@ class LoopAPNSService { let deviceToken = Storage.shared.deviceToken.value let bundleIdentifier = Storage.shared.bundleId.value - let keyId = storage.keyId.value - let apnsKey = storage.apnsKey.value + let creds = effectiveCredentials() // Create APNS notification payload (matching Loop's expected format) let now = Date() @@ -186,8 +183,9 @@ class LoopAPNSService { sendAPNSNotification( deviceToken: deviceToken, bundleIdentifier: bundleIdentifier, - keyId: keyId, - apnsKey: apnsKey, + keyId: creds.keyId, + apnsKey: creds.apnsKey, + teamId: creds.teamId, payload: finalPayload, completion: completion ) @@ -207,8 +205,7 @@ class LoopAPNSService { let deviceToken = Storage.shared.deviceToken.value let bundleIdentifier = Storage.shared.bundleId.value - let keyId = storage.keyId.value - let apnsKey = storage.apnsKey.value + let creds = effectiveCredentials() // Create APNS notification payload (matching Loop's expected format) let now = Date() @@ -250,8 +247,9 @@ class LoopAPNSService { sendAPNSNotification( deviceToken: deviceToken, bundleIdentifier: bundleIdentifier, - keyId: keyId, - apnsKey: apnsKey, + keyId: creds.keyId, + apnsKey: creds.apnsKey, + teamId: creds.teamId, payload: finalPayload, completion: completion ) @@ -262,9 +260,10 @@ class LoopAPNSService { private func validateCredentials() -> [String]? { var errors = [String]() - let keyId = storage.keyId.value - let teamId = Storage.shared.teamId.value ?? "" - let apnsKey = storage.apnsKey.value + let creds = effectiveCredentials() + let keyId = creds.keyId + let teamId = creds.teamId + let apnsKey = creds.apnsKey // Validate keyId (should be 10 alphanumeric characters) let keyIdPattern = "^[A-Z0-9]{10}$" @@ -328,6 +327,7 @@ class LoopAPNSService { bundleIdentifier: String, keyId: String, apnsKey: String, + teamId: String, payload: [String: Any], completion: @escaping (Bool, String?) -> Void ) { @@ -340,7 +340,7 @@ class LoopAPNSService { } // Create JWT token for APNS authentication - guard let jwt = JWTManager.shared.getOrGenerateJWT(keyId: keyId, teamId: Storage.shared.teamId.value ?? "", apnsKey: apnsKey) else { + guard let jwt = JWTManager.shared.getOrGenerateJWT(keyId: keyId, teamId: teamId, apnsKey: apnsKey) else { let errorMessage = "Failed to generate JWT, please check that the APNS Key ID, APNS Key and Team ID are correct." LogManager.shared.log(category: .apns, message: errorMessage) completion(false, errorMessage) @@ -699,11 +699,13 @@ class LoopAPNSService { } // Send the notification using the existing APNS infrastructure + let creds = effectiveCredentials() sendAPNSNotification( deviceToken: deviceToken, bundleIdentifier: bundleIdentifier, - keyId: storage.keyId.value, - apnsKey: storage.apnsKey.value, + keyId: creds.keyId, + apnsKey: creds.apnsKey, + teamId: creds.teamId, payload: payload, completion: completion ) @@ -753,11 +755,13 @@ class LoopAPNSService { } // Send the notification using the existing APNS infrastructure + let creds = effectiveCredentials() sendAPNSNotification( deviceToken: deviceToken, bundleIdentifier: bundleIdentifier, - keyId: storage.keyId.value, - apnsKey: storage.apnsKey.value, + keyId: creds.keyId, + apnsKey: creds.apnsKey, + teamId: creds.teamId, payload: payload, completion: completion ) diff --git a/LoopFollow/Remote/Settings/RemoteCommandSettings.swift b/LoopFollow/Remote/Settings/RemoteCommandSettings.swift index bdc270dc6..56c5686fb 100644 --- a/LoopFollow/Remote/Settings/RemoteCommandSettings.swift +++ b/LoopFollow/Remote/Settings/RemoteCommandSettings.swift @@ -8,8 +8,8 @@ struct RemoteCommandSettings: Codable { let remoteType: RemoteType let user: String let sharedSecret: String - let apnsKey: String - let keyId: String + let remoteApnsKey: String + let remoteKeyId: String let teamId: String? let maxBolus: Double let maxCarbs: Double @@ -27,8 +27,8 @@ struct RemoteCommandSettings: Codable { remoteType: RemoteType, user: String, sharedSecret: String, - apnsKey: String, - keyId: String, + remoteApnsKey: String, + remoteKeyId: String, teamId: String?, maxBolus: Double, maxCarbs: Double, @@ -44,8 +44,8 @@ struct RemoteCommandSettings: Codable { self.remoteType = remoteType self.user = user self.sharedSecret = sharedSecret - self.apnsKey = apnsKey - self.keyId = keyId + self.remoteApnsKey = remoteApnsKey + self.remoteKeyId = remoteKeyId self.teamId = teamId self.maxBolus = maxBolus self.maxCarbs = maxCarbs @@ -68,8 +68,8 @@ struct RemoteCommandSettings: Codable { remoteType: storage.remoteType.value, user: storage.user.value, sharedSecret: storage.sharedSecret.value, - apnsKey: storage.apnsKey.value, - keyId: storage.keyId.value, + remoteApnsKey: storage.remoteApnsKey.value, + remoteKeyId: storage.remoteKeyId.value, teamId: storage.teamId.value, maxBolus: storage.maxBolus.value.doubleValue(for: .internationalUnit()), maxCarbs: storage.maxCarbs.value.doubleValue(for: .gram()), @@ -91,8 +91,8 @@ struct RemoteCommandSettings: Codable { storage.remoteType.value = remoteType storage.user.value = user storage.sharedSecret.value = sharedSecret - storage.apnsKey.value = apnsKey - storage.keyId.value = keyId + storage.remoteApnsKey.value = remoteApnsKey + storage.remoteKeyId.value = remoteKeyId storage.teamId.value = teamId storage.maxBolus.value = HKQuantity(unit: .internationalUnit(), doubleValue: maxBolus) storage.maxCarbs.value = HKQuantity(unit: .gram(), doubleValue: maxCarbs) @@ -152,9 +152,9 @@ struct RemoteCommandSettings: Codable { case .nightscout: return !user.isEmpty case .trc: - return !user.isEmpty && !sharedSecret.isEmpty && !apnsKey.isEmpty && !keyId.isEmpty + return !user.isEmpty && !sharedSecret.isEmpty && !remoteApnsKey.isEmpty && !remoteKeyId.isEmpty case .loopAPNS: - return !keyId.isEmpty && !apnsKey.isEmpty && teamId != nil && !loopAPNSQrCodeURL.isEmpty + return !remoteKeyId.isEmpty && !remoteApnsKey.isEmpty && teamId != nil && !loopAPNSQrCodeURL.isEmpty } } diff --git a/LoopFollow/Remote/Settings/RemoteSettingsView.swift b/LoopFollow/Remote/Settings/RemoteSettingsView.swift index 3df7acf2d..c9d1878ba 100644 --- a/LoopFollow/Remote/Settings/RemoteSettingsView.swift +++ b/LoopFollow/Remote/Settings/RemoteSettingsView.swift @@ -148,23 +148,25 @@ struct RemoteSettingsView: View { ) } - HStack { - Text("APNS Key ID") - TogglableSecureInput( - placeholder: "Enter APNS Key ID", - text: $viewModel.keyId, - style: .singleLine - ) - } + if viewModel.areTeamIdsDifferent { + HStack { + Text("APNS Key ID") + TogglableSecureInput( + placeholder: "Enter APNS Key ID", + text: $viewModel.remoteKeyId, + style: .singleLine + ) + } - VStack(alignment: .leading) { - Text("APNS Key") - TogglableSecureInput( - placeholder: "Paste APNS Key", - text: $viewModel.apnsKey, - style: .multiLine - ) - .frame(minHeight: 110) + VStack(alignment: .leading) { + Text("APNS Key") + TogglableSecureInput( + placeholder: "Paste APNS Key", + text: $viewModel.remoteApnsKey, + style: .multiLine + ) + .frame(minHeight: 110) + } } } @@ -194,23 +196,25 @@ struct RemoteSettingsView: View { ) } - HStack { - Text("APNS Key ID") - TogglableSecureInput( - placeholder: "Enter APNS Key ID", - text: $viewModel.keyId, - style: .singleLine - ) - } + if viewModel.areTeamIdsDifferent { + HStack { + Text("APNS Key ID") + TogglableSecureInput( + placeholder: "Enter APNS Key ID", + text: $viewModel.remoteKeyId, + style: .singleLine + ) + } - VStack(alignment: .leading) { - Text("APNS Key") - TogglableSecureInput( - placeholder: "Paste APNS Key", - text: $viewModel.apnsKey, - style: .multiLine - ) - .frame(minHeight: 110) + VStack(alignment: .leading) { + Text("APNS Key") + TogglableSecureInput( + placeholder: "Paste APNS Key", + text: $viewModel.remoteApnsKey, + style: .multiLine + ) + .frame(minHeight: 110) + } } HStack { @@ -279,29 +283,6 @@ struct RemoteSettingsView: View { Text("Bolus Increment: \(Storage.shared.bolusIncrement.value.doubleValue(for: .internationalUnit()), specifier: "%.3f") U") } } - - if viewModel.areTeamIdsDifferent { - Section(header: Text("Return Notification Settings"), footer: Text("Because LoopFollow and the target app were built with different Team IDs, you must provide the APNS credentials for LoopFollow below.").font(.caption)) { - HStack { - Text("Return APNS Key ID") - TogglableSecureInput( - placeholder: "Enter Key ID for LoopFollow", - text: $viewModel.returnKeyId, - style: .singleLine - ) - } - - VStack(alignment: .leading) { - Text("Return APNS Key") - TogglableSecureInput( - placeholder: "Paste APNS Key for LoopFollow", - text: $viewModel.returnApnsKey, - style: .multiLine - ) - .frame(minHeight: 110) - } - } - } } } .alert(isPresented: $showAlert) { diff --git a/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift b/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift index bcf5a9952..d5ea2fc4e 100644 --- a/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift +++ b/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift @@ -9,8 +9,8 @@ class RemoteSettingsViewModel: ObservableObject { @Published var remoteType: RemoteType @Published var user: String @Published var sharedSecret: String - @Published var apnsKey: String - @Published var keyId: String + @Published var remoteApnsKey: String + @Published var remoteKeyId: String @Published var maxBolus: HKQuantity @Published var maxCarbs: HKQuantity @@ -21,11 +21,6 @@ class RemoteSettingsViewModel: ObservableObject { @Published var isTrioDevice: Bool = (Storage.shared.device.value == "Trio") @Published var isLoopDevice: Bool = (Storage.shared.device.value == "Loop") - // MARK: - Return Notification Properties - - @Published var returnApnsKey: String - @Published var returnKeyId: String - // MARK: - Loop APNS Setup Properties @Published var loopDeveloperTeamId: String @@ -56,16 +51,13 @@ class RemoteSettingsViewModel: ObservableObject { // Determine if a comparison is needed and perform it. switch remoteType { - case .trc: - // If the target ID is empty, there's nothing to compare. + case .trc, .loopAPNS: guard !targetTeamId.isEmpty else { return false } - // Return true if the IDs are different. return loopFollowTeamID != targetTeamId - case .loopAPNS, .none, .nightscout: - // For other remote types, this check is not applicable. + case .none, .nightscout: return false } } @@ -73,8 +65,13 @@ class RemoteSettingsViewModel: ObservableObject { // MARK: - Computed property for Loop APNS Setup validation var loopAPNSSetup: Bool { - !keyId.isEmpty && - !apnsKey.isEmpty && + let hasCredentials: Bool + if areTeamIdsDifferent { + hasCredentials = !remoteKeyId.isEmpty && !remoteApnsKey.isEmpty + } else { + hasCredentials = !Storage.shared.lfKeyId.value.isEmpty && !Storage.shared.lfApnsKey.value.isEmpty + } + return hasCredentials && !loopDeveloperTeamId.isEmpty && !loopAPNSQrCodeURL.isEmpty && !Storage.shared.deviceToken.value.isEmpty && @@ -89,8 +86,8 @@ class RemoteSettingsViewModel: ObservableObject { remoteType = storage.remoteType.value user = storage.user.value sharedSecret = storage.sharedSecret.value - apnsKey = storage.apnsKey.value - keyId = storage.keyId.value + remoteApnsKey = storage.remoteApnsKey.value + remoteKeyId = storage.remoteKeyId.value maxBolus = storage.maxBolus.value maxCarbs = storage.maxCarbs.value maxProtein = storage.maxProtein.value @@ -102,9 +99,6 @@ class RemoteSettingsViewModel: ObservableObject { loopAPNSQrCodeURL = storage.loopAPNSQrCodeURL.value productionEnvironment = storage.productionEnvironment.value - returnApnsKey = storage.returnApnsKey.value - returnKeyId = storage.returnKeyId.value - setupBindings() } @@ -125,19 +119,18 @@ class RemoteSettingsViewModel: ObservableObject { .sink { [weak self] in self?.storage.sharedSecret.value = $0 } .store(in: &cancellables) - $apnsKey + $remoteApnsKey .dropFirst() .sink { [weak self] newValue in - // Validate and fix the APNS key format using the service let apnsService = LoopAPNSService() let fixedKey = apnsService.validateAndFixAPNSKey(newValue) - self?.storage.apnsKey.value = fixedKey + self?.storage.remoteApnsKey.value = fixedKey } .store(in: &cancellables) - $keyId + $remoteKeyId .dropFirst() - .sink { [weak self] in self?.storage.keyId.value = $0 } + .sink { [weak self] in self?.storage.remoteKeyId.value = $0 } .store(in: &cancellables) $maxBolus @@ -194,17 +187,6 @@ class RemoteSettingsViewModel: ObservableObject { .dropFirst() .sink { [weak self] in self?.storage.productionEnvironment.value = $0 } .store(in: &cancellables) - - // Return notification bindings - $returnApnsKey - .dropFirst() - .sink { [weak self] in self?.storage.returnApnsKey.value = $0 } - .store(in: &cancellables) - - $returnKeyId - .dropFirst() - .sink { [weak self] in self?.storage.returnKeyId.value = $0 } - .store(in: &cancellables) } func handleLoopAPNSQRCodeScanResult(_ result: Result) { @@ -235,8 +217,8 @@ class RemoteSettingsViewModel: ObservableObject { remoteType = storage.remoteType.value user = storage.user.value sharedSecret = storage.sharedSecret.value - apnsKey = storage.apnsKey.value - keyId = storage.keyId.value + remoteApnsKey = storage.remoteApnsKey.value + remoteKeyId = storage.remoteKeyId.value maxBolus = storage.maxBolus.value maxCarbs = storage.maxCarbs.value maxProtein = storage.maxProtein.value diff --git a/LoopFollow/Remote/TRC/PushNotificationManager.swift b/LoopFollow/Remote/TRC/PushNotificationManager.swift index 1cef2ff1a..e0c70d746 100644 --- a/LoopFollow/Remote/TRC/PushNotificationManager.swift +++ b/LoopFollow/Remote/TRC/PushNotificationManager.swift @@ -3,7 +3,6 @@ import Foundation import HealthKit -import SwiftJWT class PushNotificationManager { private var deviceToken: String @@ -19,11 +18,22 @@ class PushNotificationManager { deviceToken = Storage.shared.deviceToken.value sharedSecret = Storage.shared.sharedSecret.value productionEnvironment = Storage.shared.productionEnvironment.value - apnsKey = Storage.shared.apnsKey.value - teamId = Storage.shared.teamId.value ?? "" - keyId = Storage.shared.keyId.value user = Storage.shared.user.value bundleId = Storage.shared.bundleId.value + + let lfTeamId = BuildDetails.default.teamID ?? "" + let remoteTeamId = Storage.shared.teamId.value ?? "" + let sameTeam = !lfTeamId.isEmpty && !remoteTeamId.isEmpty && lfTeamId == remoteTeamId + + if sameTeam || remoteTeamId.isEmpty { + apnsKey = Storage.shared.lfApnsKey.value + keyId = Storage.shared.lfKeyId.value + teamId = lfTeamId + } else { + apnsKey = Storage.shared.remoteApnsKey.value + keyId = Storage.shared.remoteKeyId.value + teamId = remoteTeamId + } } private func createReturnNotificationInfo() -> CommandPayload.ReturnNotificationInfo? { @@ -38,20 +48,11 @@ class PushNotificationManager { return nil } - let teamIdsAreDifferent = loopFollowTeamID != teamId - let keyIdForReturn: String - let apnsKeyForReturn: String - - if teamIdsAreDifferent { - keyIdForReturn = Storage.shared.returnKeyId.value - apnsKeyForReturn = Storage.shared.returnApnsKey.value - } else { - keyIdForReturn = keyId - apnsKeyForReturn = apnsKey - } + let lfKeyId = Storage.shared.lfKeyId.value + let lfApnsKey = Storage.shared.lfApnsKey.value - guard !keyIdForReturn.isEmpty, !apnsKeyForReturn.isEmpty else { - LogManager.shared.log(category: .apns, message: "Missing required return APNS credentials. Check Remote Settings.") + guard !lfKeyId.isEmpty, !lfApnsKey.isEmpty else { + LogManager.shared.log(category: .apns, message: "Missing LoopFollow APNS credentials. Configure them in App Settings → APN.") return nil } @@ -60,8 +61,8 @@ class PushNotificationManager { deviceToken: loopFollowDeviceToken, bundleId: Bundle.main.bundleIdentifier ?? "", teamId: loopFollowTeamID, - keyId: keyIdForReturn, - apnsKey: apnsKeyForReturn + keyId: lfKeyId, + apnsKey: lfApnsKey ) } diff --git a/LoopFollow/Settings/APNSettingsView.swift b/LoopFollow/Settings/APNSettingsView.swift new file mode 100644 index 000000000..79b07e7cd --- /dev/null +++ b/LoopFollow/Settings/APNSettingsView.swift @@ -0,0 +1,44 @@ +// LoopFollow +// APNSettingsView.swift + +import SwiftUI + +struct APNSettingsView: View { + @State private var keyId: String = Storage.shared.lfKeyId.value + @State private var apnsKey: String = Storage.shared.lfApnsKey.value + + var body: some View { + Form { + Section(header: Text("LoopFollow APNs Credentials")) { + HStack { + Text("APNS Key ID") + TogglableSecureInput( + placeholder: "Enter APNS Key ID", + text: $keyId, + style: .singleLine + ) + } + + VStack(alignment: .leading) { + Text("APNS Key") + TogglableSecureInput( + placeholder: "Paste APNS Key", + text: $apnsKey, + style: .multiLine + ) + .frame(minHeight: 110) + } + } + } + .onChange(of: keyId) { newValue in + Storage.shared.lfKeyId.value = newValue + } + .onChange(of: apnsKey) { newValue in + let apnsService = LoopAPNSService() + Storage.shared.lfApnsKey.value = apnsService.validateAndFixAPNSKey(newValue) + } + .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) + .navigationTitle("APN") + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/LoopFollow/Settings/ImportExport/ExportableSettings.swift b/LoopFollow/Settings/ImportExport/ExportableSettings.swift index 77c905806..0425528c9 100644 --- a/LoopFollow/Settings/ImportExport/ExportableSettings.swift +++ b/LoopFollow/Settings/ImportExport/ExportableSettings.swift @@ -148,8 +148,8 @@ struct RemoteSettingsExport: Codable { let remoteType: RemoteType let user: String let sharedSecret: String - let apnsKey: String - let keyId: String + let remoteApnsKey: String + let remoteKeyId: String let teamId: String? let maxBolus: Double let maxCarbs: Double @@ -168,8 +168,8 @@ struct RemoteSettingsExport: Codable { remoteType: storage.remoteType.value, user: storage.user.value, sharedSecret: storage.sharedSecret.value, - apnsKey: storage.apnsKey.value, - keyId: storage.keyId.value, + remoteApnsKey: storage.remoteApnsKey.value, + remoteKeyId: storage.remoteKeyId.value, teamId: storage.teamId.value, maxBolus: storage.maxBolus.value.doubleValue(for: .internationalUnit()), maxCarbs: storage.maxCarbs.value.doubleValue(for: .gram()), @@ -189,8 +189,8 @@ struct RemoteSettingsExport: Codable { storage.remoteType.value = remoteType storage.user.value = user storage.sharedSecret.value = sharedSecret - storage.apnsKey.value = apnsKey - storage.keyId.value = keyId + storage.remoteApnsKey.value = remoteApnsKey + storage.remoteKeyId.value = remoteKeyId storage.teamId.value = teamId storage.maxBolus.value = HKQuantity(unit: .internationalUnit(), doubleValue: maxBolus) storage.maxCarbs.value = HKQuantity(unit: .gram(), doubleValue: maxCarbs) @@ -237,9 +237,9 @@ struct RemoteSettingsExport: Codable { case .nightscout: return !user.isEmpty case .trc: - return !user.isEmpty && !sharedSecret.isEmpty && !apnsKey.isEmpty && !keyId.isEmpty + return !user.isEmpty && !sharedSecret.isEmpty && !remoteApnsKey.isEmpty && !remoteKeyId.isEmpty case .loopAPNS: - return !keyId.isEmpty && !apnsKey.isEmpty && teamId != nil && !loopAPNSQrCodeURL.isEmpty + return !remoteKeyId.isEmpty && !remoteApnsKey.isEmpty && teamId != nil && !loopAPNSQrCodeURL.isEmpty } } @@ -266,14 +266,14 @@ struct RemoteSettingsExport: Codable { // For TRC and LoopAPNS, check if key details are changing if remoteType == .trc || remoteType == .loopAPNS { - let currentKeyId = storage.keyId.value - let currentApnsKey = storage.apnsKey.value + let currentKeyId = storage.remoteKeyId.value + let currentApnsKey = storage.remoteApnsKey.value - if !currentKeyId.isEmpty, currentKeyId != keyId { + if !currentKeyId.isEmpty, currentKeyId != remoteKeyId { message += "APNS Key ID is changing. This may affect your remote commands.\n" } - if !currentApnsKey.isEmpty, currentApnsKey != apnsKey { + if !currentApnsKey.isEmpty, currentApnsKey != remoteApnsKey { message += "APNS Key is changing. This may affect your remote commands.\n" } } diff --git a/LoopFollow/Settings/SettingsMenuView.swift b/LoopFollow/Settings/SettingsMenuView.swift index 4ec770943..1ddcffc77 100644 --- a/LoopFollow/Settings/SettingsMenuView.swift +++ b/LoopFollow/Settings/SettingsMenuView.swift @@ -60,6 +60,12 @@ struct SettingsMenuView: View { settingsPath.value.append(Sheet.importExport) } + NavigationRow(title: "APN", + icon: "bell.and.waves.left.and.right") + { + settingsPath.value.append(Sheet.apn) + } + if !nightscoutURL.value.isEmpty { NavigationRow(title: "Information Display Settings", icon: "info.circle") @@ -238,6 +244,7 @@ private enum Sheet: Hashable, Identifiable { case general, graph case infoDisplay case alarmSettings + case apn case remote case importExport case calendar, contact @@ -257,6 +264,7 @@ private enum Sheet: Hashable, Identifiable { case .graph: GraphSettingsView() case .infoDisplay: InfoDisplaySettingsView(viewModel: .init()) case .alarmSettings: AlarmSettingsView() + case .apn: APNSettingsView() case .remote: RemoteSettingsView(viewModel: .init()) case .importExport: ImportExportSettingsView() case .calendar: CalendarSettingsView() diff --git a/LoopFollow/Storage/Observable.swift b/LoopFollow/Storage/Observable.swift index 4caef7b94..f5e9b1606 100644 --- a/LoopFollow/Storage/Observable.swift +++ b/LoopFollow/Storage/Observable.swift @@ -42,7 +42,7 @@ class Observable { var lastSentTOTP = ObservableValue(default: nil) var loopFollowDeviceToken = ObservableValue(default: "") - + var isNotLooping = ObservableValue(default: false) private init() {} diff --git a/LoopFollow/Storage/Storage+Migrate.swift b/LoopFollow/Storage/Storage+Migrate.swift index b913d9b42..d3efbe16e 100644 --- a/LoopFollow/Storage/Storage+Migrate.swift +++ b/LoopFollow/Storage/Storage+Migrate.swift @@ -4,6 +4,45 @@ import Foundation extension Storage { + func migrateStep5() { + LogManager.shared.log(category: .general, message: "Running migrateStep5 — APNs credential separation") + + let legacyReturnApnsKey = StorageValue(key: "returnApnsKey", defaultValue: "") + let legacyReturnKeyId = StorageValue(key: "returnKeyId", defaultValue: "") + let legacyApnsKey = StorageValue(key: "apnsKey", defaultValue: "") + let legacyKeyId = StorageValue(key: "keyId", defaultValue: "") + + // 1. If returnApnsKey had a value, that was LoopFollow's own key (different team scenario) + if legacyReturnApnsKey.exists, !legacyReturnApnsKey.value.isEmpty { + lfApnsKey.value = legacyReturnApnsKey.value + lfKeyId.value = legacyReturnKeyId.value + } + + // 2. If lfApnsKey is still empty and the old primary key exists, + // check if same team — if so, the primary key was used for everything + if lfApnsKey.value.isEmpty, legacyApnsKey.exists, !legacyApnsKey.value.isEmpty { + let lfTeamId = BuildDetails.default.teamID ?? "" + let remoteTeamId = teamId.value ?? "" + let sameTeam = !lfTeamId.isEmpty && (remoteTeamId.isEmpty || lfTeamId == remoteTeamId) + if sameTeam { + lfApnsKey.value = legacyApnsKey.value + lfKeyId.value = legacyKeyId.value + } + } + + // 3. Move old primary credentials to remoteApnsKey/remoteKeyId + if legacyApnsKey.exists, !legacyApnsKey.value.isEmpty { + remoteApnsKey.value = legacyApnsKey.value + remoteKeyId.value = legacyKeyId.value + } + + // 4. Clean up old keys + legacyReturnApnsKey.remove() + legacyReturnKeyId.remove() + legacyApnsKey.remove() + legacyKeyId.remove() + } + func migrateStep3() { LogManager.shared.log(category: .general, message: "Running migrateStep3 - this should only happen once!") let legacyForceDarkMode = StorageValue(key: "forceDarkMode", defaultValue: true) diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 093599bcb..dc0c8a282 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -15,9 +15,12 @@ class Storage { var expirationDate = StorageValue(key: "expirationDate", defaultValue: nil) var sharedSecret = StorageValue(key: "sharedSecret", defaultValue: "") var productionEnvironment = StorageValue(key: "productionEnvironment", defaultValue: false) - var apnsKey = StorageValue(key: "apnsKey", defaultValue: "") + var remoteApnsKey = StorageValue(key: "remoteApnsKey", defaultValue: "") var teamId = StorageValue(key: "teamId", defaultValue: nil) - var keyId = StorageValue(key: "keyId", defaultValue: "") + var remoteKeyId = StorageValue(key: "remoteKeyId", defaultValue: "") + + var lfApnsKey = StorageValue(key: "lfApnsKey", defaultValue: "") + var lfKeyId = StorageValue(key: "lfKeyId", defaultValue: "") var bundleId = StorageValue(key: "bundleId", defaultValue: "") var user = StorageValue(key: "user", defaultValue: "") @@ -32,9 +35,6 @@ class Storage { // TODO: This flag can be deleted in March 2027. Check the commit for other places to cleanup. var hasSeenFatProteinOrderChange = StorageValue(key: "hasSeenFatProteinOrderChange", defaultValue: false) - var cachedJWT = StorageValue(key: "cachedJWT", defaultValue: nil) - var jwtExpirationDate = StorageValue(key: "jwtExpirationDate", defaultValue: nil) - var backgroundRefreshType = StorageValue(key: "backgroundRefreshType", defaultValue: .silentTune) var selectedBLEDevice = StorageValue(key: "selectedBLEDevice", defaultValue: nil) @@ -90,7 +90,7 @@ class Storage { var lastIOB = StorageValue(key: "lastIOB", defaultValue: nil) var lastCOB = StorageValue(key: "lastCOB", defaultValue: nil) var projectedBgMgdl = StorageValue(key: "projectedBgMgdl", defaultValue: nil) - + // Graph Settings [BEGIN] var showDots = StorageValue(key: "showDots", defaultValue: true) var showLines = StorageValue(key: "showLines", defaultValue: true) @@ -186,9 +186,6 @@ class Storage { var loopAPNSQrCodeURL = StorageValue(key: "loopAPNSQrCodeURL", defaultValue: "") - var returnApnsKey = StorageValue(key: "returnApnsKey", defaultValue: "") - var returnKeyId = StorageValue(key: "returnKeyId", defaultValue: "") - var bolusIncrement = SecureStorageValue(key: "bolusIncrement", defaultValue: HKQuantity(unit: .internationalUnit(), doubleValue: 0.05)) var bolusIncrementDetected = StorageValue(key: "bolusIncrementDetected", defaultValue: false) // Statistics display preferences diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index eb87fc800..9173bec9f 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -168,6 +168,11 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele Storage.shared.migrationStep.value = 4 } + if Storage.shared.migrationStep.value < 5 { + Storage.shared.migrateStep5() + Storage.shared.migrationStep.value = 5 + } + // Synchronize info types to ensure arrays are the correct size synchronizeInfoTypes() @@ -385,29 +390,16 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele } .store(in: &cancellables) - Storage.shared.apnsKey.$value - .receive(on: DispatchQueue.main) - .removeDuplicates() - .sink { _ in - JWTManager.shared.invalidateCache() - } - .store(in: &cancellables) - - Storage.shared.teamId.$value - .receive(on: DispatchQueue.main) - .removeDuplicates() - .sink { _ in - JWTManager.shared.invalidateCache() - } - .store(in: &cancellables) - - Storage.shared.keyId.$value - .receive(on: DispatchQueue.main) - .removeDuplicates() - .sink { _ in - JWTManager.shared.invalidateCache() - } - .store(in: &cancellables) + Publishers.MergeMany( + Storage.shared.remoteApnsKey.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.teamId.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.remoteKeyId.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.lfApnsKey.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.lfKeyId.$value.map { _ in () }.eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { _ in JWTManager.shared.invalidateCache() } + .store(in: &cancellables) Storage.shared.device.$value .receive(on: DispatchQueue.main) diff --git a/LoopFollowLAExtension/LoopFollowLABundle.swift b/LoopFollowLAExtension/LoopFollowLABundle.swift index 13b01574b..e3a043783 100644 --- a/LoopFollowLAExtension/LoopFollowLABundle.swift +++ b/LoopFollowLAExtension/LoopFollowLABundle.swift @@ -1,18 +1,12 @@ -// -// LoopFollowLABundle.swift -// LoopFollow -// -// Created by Philippe Achkar on 2026-03-07. -// Copyright © 2026 Jon Fawcett. All rights reserved. -// - +// LoopFollow +// LoopFollowLABundle.swift // LoopFollowLABundle.swift // Philippe Achkar // 2026-03-07 -import WidgetKit import SwiftUI +import WidgetKit @main struct LoopFollowLABundle: WidgetBundle { @@ -21,4 +15,4 @@ struct LoopFollowLABundle: WidgetBundle { LoopFollowLiveActivityWidget() } } -} \ No newline at end of file +} diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 2b0948679..55342bc22 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -1,9 +1,5 @@ -// -// LoopFollowLiveActivity.swift -// LoopFollow -// -// Created by Philippe Achkar on 2026-02-24. -// +// LoopFollow +// LoopFollowLiveActivity.swift import ActivityKit import SwiftUI @@ -11,16 +7,15 @@ import WidgetKit @available(iOS 16.1, *) struct LoopFollowLiveActivityWidget: Widget { - var body: some WidgetConfiguration { ActivityConfiguration(for: GlucoseLiveActivityAttributes.self) { context in // LOCK SCREEN / BANNER UI - LockScreenLiveActivityView(state: context.state/*, activityID: context.activityID*/) + LockScreenLiveActivityView(state: context.state /* , activityID: context.activityID */ ) .id(context.state.seq) // force SwiftUI to re-render on every update .activitySystemActionForegroundColor(.white) .activityBackgroundTint(LAColors.backgroundTint(for: context.state.snapshot)) .applyActivityContentMarginsFixIfAvailable() - } dynamicIsland: { context in + } dynamicIsland: { context in // DYNAMIC ISLAND UI DynamicIsland { DynamicIslandExpandedRegion(.leading) { @@ -65,16 +60,16 @@ private extension View { } // MARK: - Lock Screen Contract View + @available(iOS 16.1, *) private struct LockScreenLiveActivityView: View { let state: GlucoseLiveActivityAttributes.ContentState - /*let activityID: String*/ - + /* let activityID: String */ + var body: some View { let s = state.snapshot HStack(spacing: 12) { - // LEFT: Glucose + trend, update time below VStack(alignment: .leading, spacing: 4) { HStack(alignment: .firstTextBaseline, spacing: 8) { @@ -291,7 +286,6 @@ private struct DynamicIslandMinimalView: View { // MARK: - Formatting private enum LAFormat { - // MARK: Glucose static func glucose(_ s: GlucoseSnapshot) -> String { @@ -378,7 +372,7 @@ private enum LAFormat { static func hhmmss(_ date: Date) -> String { hhmmssFormatter.string(from: date) } - + static func updated(_ s: GlucoseSnapshot) -> String { hhmmFormatter.string(from: s.updatedAt) } @@ -387,14 +381,13 @@ private enum LAFormat { // MARK: - Threshold-driven colors (Option A, App Group-backed) private enum LAColors { - static func backgroundTint(for snapshot: GlucoseSnapshot) -> Color { let mgdl = toMgdl(snapshot) - + 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) @@ -404,14 +397,13 @@ private enum LAColors { 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 { // In range: fixed at your existing value return Color(uiColor: UIColor.systemGreen).opacity(0.36) } } - static func keyline(for snapshot: GlucoseSnapshot) -> Color { let mgdl = toMgdl(snapshot) From 4dcbd6982791ec204f6d502e36af47244f472ef2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Thu, 12 Mar 2026 22:16:51 +0100 Subject: [PATCH 06/86] Localization refactoring --- LoopFollow.xcodeproj/project.pbxproj | 12 +--- LoopFollow/Helpers/GlucoseConversion.swift | 6 +- LoopFollow/LiveActivity/APNSClient.swift | 1 + LoopFollow/LiveActivity/GlucoseSnapshot.swift | 17 +++-- .../LiveActivity/GlucoseSnapshotBuilder.swift | 26 +++---- .../LiveActivity/GlucoseUnitConversion.swift | 23 ------ LoopFollow/LiveActivity/LAThresholdSync.swift | 17 ----- .../LiveActivity/PreferredGlucoseUnit.swift | 9 +-- .../LoopFollowLiveActivity.swift | 71 ++++++++++--------- docs/LiveActivity.md | 3 +- 10 files changed, 73 insertions(+), 112 deletions(-) delete mode 100644 LoopFollow/LiveActivity/GlucoseUnitConversion.swift delete mode 100644 LoopFollow/LiveActivity/LAThresholdSync.swift diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 83592c9ad..d7ed09428 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -8,19 +8,17 @@ /* Begin PBXBuildFile section */ 374A77992F5BD8B200E96858 /* APNSClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77982F5BD8AB00E96858 /* APNSClient.swift */; }; - 374A77A42F5BE17000E96858 /* GlucoseUnitConversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A22F5BE17000E96858 /* GlucoseUnitConversion.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 */; }; - 374A77A92F5BE17000E96858 /* GlucoseUnitConversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A22F5BE17000E96858 /* GlucoseUnitConversion.swift */; }; + DD91E4DE2BDEC3F8002D9E97 /* GlucoseConversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD91E4DC2BDEC3F8002D9E97 /* GlucoseConversion.swift */; }; 374A77AA2F5BE17000E96858 /* AppGroupID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A779F2F5BE17000E96858 /* AppGroupID.swift */; }; 374A77AB2F5BE17000E96858 /* GlucoseSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A12F5BE17000E96858 /* GlucoseSnapshot.swift */; }; 374A77AC2F5BE17000E96858 /* LAAppGroupSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A32F5BE17000E96858 /* LAAppGroupSettings.swift */; }; 374A77AD2F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A02F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift */; }; 374A77B42F5BE1AC00E96858 /* StorageCurrentGlucoseStateProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77B32F5BE1AC00E96858 /* StorageCurrentGlucoseStateProvider.swift */; }; 374A77B52F5BE1AC00E96858 /* GlucoseSnapshotBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77AE2F5BE1AC00E96858 /* GlucoseSnapshotBuilder.swift */; }; - 374A77B62F5BE1AC00E96858 /* LAThresholdSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77B02F5BE1AC00E96858 /* LAThresholdSync.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 */; }; @@ -458,11 +456,9 @@ 374A779F2F5BE17000E96858 /* AppGroupID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppGroupID.swift; sourceTree = ""; }; 374A77A02F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseLiveActivityAttributes.swift; sourceTree = ""; }; 374A77A12F5BE17000E96858 /* GlucoseSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseSnapshot.swift; sourceTree = ""; }; - 374A77A22F5BE17000E96858 /* GlucoseUnitConversion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseUnitConversion.swift; sourceTree = ""; }; 374A77A32F5BE17000E96858 /* LAAppGroupSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LAAppGroupSettings.swift; sourceTree = ""; }; 374A77AE2F5BE1AC00E96858 /* GlucoseSnapshotBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseSnapshotBuilder.swift; sourceTree = ""; }; 374A77AF2F5BE1AC00E96858 /* GlucoseSnapshotStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseSnapshotStore.swift; sourceTree = ""; }; - 374A77B02F5BE1AC00E96858 /* LAThresholdSync.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LAThresholdSync.swift; sourceTree = ""; }; 374A77B12F5BE1AC00E96858 /* LiveActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = ""; }; 374A77B22F5BE1AC00E96858 /* PreferredGlucoseUnit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferredGlucoseUnit.swift; sourceTree = ""; }; 374A77B32F5BE1AC00E96858 /* StorageCurrentGlucoseStateProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageCurrentGlucoseStateProvider.swift; sourceTree = ""; }; @@ -913,14 +909,12 @@ children = ( 374A77AE2F5BE1AC00E96858 /* GlucoseSnapshotBuilder.swift */, 374A77AF2F5BE1AC00E96858 /* GlucoseSnapshotStore.swift */, - 374A77B02F5BE1AC00E96858 /* LAThresholdSync.swift */, 374A77B12F5BE1AC00E96858 /* LiveActivityManager.swift */, 374A77B22F5BE1AC00E96858 /* PreferredGlucoseUnit.swift */, 374A77B32F5BE1AC00E96858 /* StorageCurrentGlucoseStateProvider.swift */, 374A779F2F5BE17000E96858 /* AppGroupID.swift */, 374A77A02F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift */, 374A77A12F5BE17000E96858 /* GlucoseSnapshot.swift */, - 374A77A22F5BE17000E96858 /* GlucoseUnitConversion.swift */, 374A77A32F5BE17000E96858 /* LAAppGroupSettings.swift */, 374A77982F5BD8AB00E96858 /* APNSClient.swift */, ); @@ -2051,7 +2045,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 374A77A92F5BE17000E96858 /* GlucoseUnitConversion.swift in Sources */, + DD91E4DE2BDEC3F8002D9E97 /* GlucoseConversion.swift in Sources */, 374A77AA2F5BE17000E96858 /* AppGroupID.swift in Sources */, 374A77AB2F5BE17000E96858 /* GlucoseSnapshot.swift in Sources */, 374A77AC2F5BE17000E96858 /* LAAppGroupSettings.swift in Sources */, @@ -2084,7 +2078,6 @@ DD7F4C1B2DD6501D00D449E9 /* COBCondition.swift in Sources */, 374A77B42F5BE1AC00E96858 /* StorageCurrentGlucoseStateProvider.swift in Sources */, 374A77B52F5BE1AC00E96858 /* GlucoseSnapshotBuilder.swift in Sources */, - 374A77B62F5BE1AC00E96858 /* LAThresholdSync.swift in Sources */, 374A77B72F5BE1AC00E96858 /* LiveActivityManager.swift in Sources */, 374A77B82F5BE1AC00E96858 /* GlucoseSnapshotStore.swift in Sources */, 374A77B92F5BE1AC00E96858 /* PreferredGlucoseUnit.swift in Sources */, @@ -2296,7 +2289,6 @@ DD7F4C212DD66BB200D449E9 /* FastRiseAlarmEditor.swift in Sources */, FC97881A2485969B00A7906C /* SceneDelegate.swift in Sources */, DD0C0C662C46E54C00DBADDF /* InfoDataSeparator.swift in Sources */, - 374A77A42F5BE17000E96858 /* GlucoseUnitConversion.swift in Sources */, 374A77A52F5BE17000E96858 /* AppGroupID.swift in Sources */, 374A77A62F5BE17000E96858 /* GlucoseSnapshot.swift in Sources */, 374A77A72F5BE17000E96858 /* LAAppGroupSettings.swift in Sources */, diff --git a/LoopFollow/Helpers/GlucoseConversion.swift b/LoopFollow/Helpers/GlucoseConversion.swift index bee265965..dab205bfa 100644 --- a/LoopFollow/Helpers/GlucoseConversion.swift +++ b/LoopFollow/Helpers/GlucoseConversion.swift @@ -4,6 +4,10 @@ import Foundation enum GlucoseConversion { - static let mgDlToMmolL: Double = 0.0555 static let mmolToMgDl: Double = 18.01559 + static let mgDlToMmolL: Double = 1.0 / mmolToMgDl + + static func toMmol(_ mgdl: Double) -> Double { + mgdl * mgDlToMmolL + } } diff --git a/LoopFollow/LiveActivity/APNSClient.swift b/LoopFollow/LiveActivity/APNSClient.swift index a8f079d05..ac2dfc782 100644 --- a/LoopFollow/LiveActivity/APNSClient.swift +++ b/LoopFollow/LiveActivity/APNSClient.swift @@ -103,6 +103,7 @@ class APNSClient { "unit": snapshot.unit.rawValue, ] + snapshotDict["isNotLooping"] = snapshot.isNotLooping if let iob = snapshot.iob { snapshotDict["iob"] = iob } if let cob = snapshot.cob { snapshotDict["cob"] = cob } if let projected = snapshot.projected { snapshotDict["projected"] = projected } diff --git a/LoopFollow/LiveActivity/GlucoseSnapshot.swift b/LoopFollow/LiveActivity/GlucoseSnapshot.swift index 2f0aac9dc..934f44eac 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshot.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshot.swift @@ -16,10 +16,10 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { // MARK: - Core Glucose - /// Raw glucose value in the user-selected unit. + /// Glucose value in mg/dL (canonical internal unit). let glucose: Double - /// Raw delta in the user-selected unit. May be 0.0 if unchanged. + /// Delta in mg/dL. May be 0.0 if unchanged. let delta: Double /// Trend direction (mapped from LoopFollow state). @@ -36,12 +36,13 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { /// Carbs On Board let cob: Double? - /// Projected glucose (if available) + /// Projected glucose in mg/dL (if available) let projected: Double? // MARK: - Unit Context - /// Unit selected by the user in LoopFollow settings. + /// 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 @@ -116,10 +117,18 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { extension GlucoseSnapshot { enum Trend: String, Codable, Hashable { case up + case upSlight case upFast case flat case down + case downSlight case downFast case unknown + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let raw = try container.decode(String.self) + self = Trend(rawValue: raw) ?? .unknown + } } } diff --git a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift index 2f0fcaa13..ad5b93dae 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift @@ -46,17 +46,7 @@ enum GlucoseSnapshotBuilder { let preferredUnit = PreferredGlucoseUnit.snapshotUnit() - let glucose = GlucoseUnitConversion.convertGlucose(glucoseMgdl, from: .mgdl, to: preferredUnit) - let deltaMgdl = provider.deltaMgdl ?? 0.0 - let delta = GlucoseUnitConversion.convertGlucose(deltaMgdl, from: .mgdl, to: preferredUnit) - - let projected: Double? - if let projMgdl = provider.projectedMgdl { - projected = GlucoseUnitConversion.convertGlucose(projMgdl, from: .mgdl, to: preferredUnit) - } else { - projected = nil - } let trend = mapTrend(provider.trendCode) @@ -70,13 +60,13 @@ enum GlucoseSnapshotBuilder { ) return GlucoseSnapshot( - glucose: glucose, - delta: delta, + glucose: glucoseMgdl, + delta: deltaMgdl, trend: trend, updatedAt: updatedAt, iob: provider.iob, cob: provider.cob, - projected: projected, + projected: provider.projectedMgdl, unit: preferredUnit, isNotLooping: isNotLooping ) @@ -98,7 +88,10 @@ enum GlucoseSnapshotBuilder { if raw.contains("doubleup") || raw.contains("rapidrise") || raw == "up2" || raw == "upfast" { return .upFast } - if raw.contains("singleup") || raw.contains("fortyfiveup") || raw == "up" || raw == "up1" || raw == "rising" { + if raw.contains("fortyfiveup") { + return .upSlight + } + if raw.contains("singleup") || raw == "up" || raw == "up1" || raw == "rising" { return .up } @@ -109,7 +102,10 @@ enum GlucoseSnapshotBuilder { if raw.contains("doubledown") || raw.contains("rapidfall") || raw == "down2" || raw == "downfast" { return .downFast } - if raw.contains("singledown") || raw.contains("fortyfivedown") || raw == "down" || raw == "down1" || raw == "falling" { + if raw.contains("fortyfivedown") { + return .downSlight + } + if raw.contains("singledown") || raw == "down" || raw == "down1" || raw == "falling" { return .down } diff --git a/LoopFollow/LiveActivity/GlucoseUnitConversion.swift b/LoopFollow/LiveActivity/GlucoseUnitConversion.swift deleted file mode 100644 index cf39988d2..000000000 --- a/LoopFollow/LiveActivity/GlucoseUnitConversion.swift +++ /dev/null @@ -1,23 +0,0 @@ -// LoopFollow -// GlucoseUnitConversion.swift - -import Foundation - -enum GlucoseUnitConversion { - // 1 mmol/L glucose ≈ 18.0182 mg/dL (commonly rounded to 18) - // Using 18.0182 is standard for glucose conversions. - private static let mgdlPerMmol: Double = 18.0182 - - static func convertGlucose(_ value: Double, from: GlucoseSnapshot.Unit, to: GlucoseSnapshot.Unit) -> Double { - guard from != to else { return value } - - switch (from, to) { - case (.mgdl, .mmol): - return value / mgdlPerMmol - case (.mmol, .mgdl): - return value * mgdlPerMmol - default: - return value - } - } -} diff --git a/LoopFollow/LiveActivity/LAThresholdSync.swift b/LoopFollow/LiveActivity/LAThresholdSync.swift deleted file mode 100644 index 0c6c48e51..000000000 --- a/LoopFollow/LiveActivity/LAThresholdSync.swift +++ /dev/null @@ -1,17 +0,0 @@ -// LoopFollow -// LAThresholdSync.swift - -import Foundation - -/// Bridges LoopFollow's internal threshold settings -/// into the App Group for extension consumption. -/// -/// This file belongs ONLY to the main app target. -enum LAThresholdSync { - static func syncToAppGroup() { - LAAppGroupSettings.setThresholds( - lowMgdl: Storage.shared.lowLine.value, - highMgdl: Storage.shared.highLine.value - ) - } -} diff --git a/LoopFollow/LiveActivity/PreferredGlucoseUnit.swift b/LoopFollow/LiveActivity/PreferredGlucoseUnit.swift index f9e40468e..eb26b9b54 100644 --- a/LoopFollow/LiveActivity/PreferredGlucoseUnit.swift +++ b/LoopFollow/LiveActivity/PreferredGlucoseUnit.swift @@ -6,15 +6,8 @@ import HealthKit enum PreferredGlucoseUnit { /// LoopFollow’s existing source of truth for unit selection. - /// NOTE: Do not duplicate the string constant elsewhere—keep it here. static func hkUnit() -> HKUnit { - let unitString = Storage.shared.units.value - switch unitString { - case "mmol/L": - return .millimolesPerLiter - default: - return .milligramsPerDeciliter - } + Localizer.getPreferredUnit() } /// Maps HKUnit -> GlucoseSnapshot.Unit (our cross-platform enum). diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 55342bc22..cca77be83 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -286,18 +286,41 @@ private struct DynamicIslandMinimalView: View { // MARK: - Formatting private enum LAFormat { - // MARK: Glucose + // MARK: - NumberFormatters (locale-aware) + + private static let mgdlFormatter: NumberFormatter = { + let nf = NumberFormatter() + nf.numberStyle = .decimal + nf.maximumFractionDigits = 0 + nf.locale = .current + return nf + }() - static func glucose(_ s: GlucoseSnapshot) -> String { - switch s.unit { + 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 String(Int(round(s.glucose))) + return mgdlFormatter.string(from: NSNumber(value: round(mgdl))) ?? "\(Int(round(mgdl)))" case .mmol: - // 1 decimal always (contract: clinical, consistent) - return String(format: "%.1f", s.glucose) + let mmol = GlucoseConversion.toMmol(mgdl) + return mmolFormatter.string(from: NSNumber(value: mmol)) ?? String(format: "%.1f", mmol) } } + // MARK: Glucose + + static func glucose(_ s: GlucoseSnapshot) -> String { + formatGlucoseValue(s.glucose, unit: s.unit) + } + static func delta(_ s: GlucoseSnapshot) -> String { switch s.unit { case .mgdl: @@ -306,21 +329,23 @@ private enum LAFormat { return v > 0 ? "+\(v)" : "\(v)" case .mmol: - // Treat tiny fluctuations as 0.0 to avoid “+0.0” noise - let d = (abs(s.delta) < 0.05) ? 0.0 : s.delta - if d == 0 { return "0.0" } - return d > 0 ? String(format: "+%.1f", d) : String(format: "%.1f", d) + let mmol = GlucoseConversion.toMmol(s.delta) + let d = (abs(mmol) < 0.05) ? 0.0 : mmol + if d == 0 { return mmolFormatter.string(from: 0) ?? "0.0" } + let formatted = mmolFormatter.string(from: NSNumber(value: abs(d))) ?? String(format: "%.1f", abs(d)) + return d > 0 ? "+\(formatted)" : "-\(formatted)" } } // MARK: Trend static func trendArrow(_ s: GlucoseSnapshot) -> String { - // Map to the common clinical arrows; keep unknown as a neutral dash. switch s.trend { case .upFast: return "↑↑" case .up: return "↑" + case .upSlight: return "↗" case .flat: return "→" + case .downSlight: return "↘︎" case .down: return "↓" case .downFast: return "↓↓" case .unknown: return "–" @@ -331,24 +356,17 @@ private enum LAFormat { static func iob(_ s: GlucoseSnapshot) -> String { guard let v = s.iob else { return "—" } - // Contract-friendly: one decimal, no unit suffix return String(format: "%.1f", v) } static func cob(_ s: GlucoseSnapshot) -> String { guard let v = s.cob else { return "—" } - // Contract-friendly: whole grams return String(Int(round(v))) } static func projected(_ s: GlucoseSnapshot) -> String { guard let v = s.projected else { return "—" } - switch s.unit { - case .mgdl: - return String(Int(round(v))) - case .mmol: - return String(format: "%.1f", v) - } + return formatGlucoseValue(v, unit: s.unit) } // MARK: Update time @@ -382,7 +400,7 @@ private enum LAFormat { private enum LAColors { static func backgroundTint(for snapshot: GlucoseSnapshot) -> Color { - let mgdl = toMgdl(snapshot) + let mgdl = snapshot.glucose let t = LAAppGroupSettings.thresholdsMgdl() let low = t.low @@ -399,13 +417,12 @@ private enum LAColors { return Color(uiColor: UIColor.systemOrange).opacity(opacity) } else { - // In range: fixed at your existing value return Color(uiColor: UIColor.systemGreen).opacity(0.36) } } static func keyline(for snapshot: GlucoseSnapshot) -> Color { - let mgdl = toMgdl(snapshot) + let mgdl = snapshot.glucose let t = LAAppGroupSettings.thresholdsMgdl() let low = t.low @@ -419,14 +436,4 @@ private enum LAColors { return Color(uiColor: UIColor.systemGreen) } } - - private static func toMgdl(_ snapshot: GlucoseSnapshot) -> Double { - switch snapshot.unit { - case .mgdl: - return snapshot.glucose - case .mmol: - // Convert mmol/L → mg/dL for threshold comparison - return GlucoseUnitConversion.convertGlucose(snapshot.glucose, from: .mmol, to: .mgdl) - } - } } diff --git a/docs/LiveActivity.md b/docs/LiveActivity.md index 651b80086..979213a96 100644 --- a/docs/LiveActivity.md +++ b/docs/LiveActivity.md @@ -89,7 +89,6 @@ Because `liveactivitiesd` receives the update via APNs rather than via an inter- | `GlucoseSnapshotBuilder.swift` | Pure data transformation — builds `GlucoseSnapshot` from storage | | `StorageCurrentGlucoseStateProvider.swift` | Thin abstraction over `Storage.shared` and `Observable.shared` | | `GlucoseSnapshotStore.swift` | App Group persistence — saves/loads latest snapshot | -| `LAThresholdSync.swift` | Reads threshold config from Storage for widget color | | `PreferredGlucoseUnit.swift` | Reads user unit preference, converts mg/dL ↔ mmol/L | | `APNSClient.swift` | Sends APNs self-push with Live Activity content state | | `APNSJWTGenerator.swift` | Generates ES256-signed JWT for APNs authentication | @@ -100,7 +99,7 @@ Because `liveactivitiesd` receives the update via APNs rather than via an inter- |---|---| | `GlucoseLiveActivityAttributes.swift` | ActivityKit attributes and content state definition | | `GlucoseSnapshot.swift` | Canonical cross-platform glucose data struct | -| `GlucoseUnitConversion.swift` | Unit conversion helpers | +| `GlucoseConversion.swift` | Single source of truth for mg/dL ↔ mmol/L conversion | | `LAAppGroupSettings.swift` | App Group UserDefaults access | | `AppGroupID.swift` | Derives App Group ID dynamically from bundle identifier | From 63326d87c7380cf930e895ed9e7ad6a166478a87 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Thu, 12 Mar 2026 21:40:01 -0400 Subject: [PATCH 07/86] feat: Live Activity auto-renewal to work around 8-hour system limit Co-Authored-By: Claude Sonnet 4.6 --- .../LiveActivity/LiveActivityManager.swift | 56 +++++++++++++++++++ LoopFollow/Storage/Storage.swift | 3 + 2 files changed, 59 insertions(+) diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index b342711a7..c79468845 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -13,6 +13,8 @@ final class LiveActivityManager { static let shared = LiveActivityManager() private init() {} + private static let renewalThreshold: TimeInterval = 7.5 * 3600 + private(set) var current: Activity? private var stateObserverTask: Task? private var updateTask: Task? @@ -61,6 +63,7 @@ final class LiveActivityManager { let activity = try Activity.request(attributes: attributes, content: content, pushType: .token) bind(to: activity, logReason: "start-new") + Storage.shared.laRenewBy.value = Date().timeIntervalSince1970 + LiveActivityManager.renewalThreshold 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)") @@ -98,11 +101,13 @@ final class LiveActivityManager { if current?.id == activity.id { current = nil + Storage.shared.laRenewBy.value = 0 } } } func startFromCurrentState() { + endOrphanedActivities() let provider = StorageCurrentGlucoseStateProvider() if let snapshot = GlucoseSnapshotBuilder.build(from: provider) { LAAppGroupSettings.setThresholds( @@ -123,6 +128,40 @@ final class LiveActivityManager { DispatchQueue.main.asyncAfter(deadline: .now() + 5.0, execute: workItem) } + // MARK: - Renewal + + /// Ends the current Live Activity immediately and re-requests a fresh one, + /// working around Apple's 8-hour maximum LA lifetime. + /// Returns true if renewal was performed (caller should return early). + private func renewIfNeeded(snapshot: GlucoseSnapshot) -> Bool { + guard let activity = current else { return false } + + let renewBy = Storage.shared.laRenewBy.value + guard renewBy > 0, Date().timeIntervalSince1970 >= renewBy else { return false } + + LogManager.shared.log(category: .general, message: "[LA] renewal deadline passed, renewing") + + // Clear our reference before re-requesting so startIfNeeded() creates a fresh one + current = nil + updateTask?.cancel() + updateTask = nil + tokenObservationTask?.cancel() + tokenObservationTask = nil + stateObserverTask?.cancel() + stateObserverTask = nil + pushToken = nil + + Task { + // .immediate clears the stale Lock Screen card before the new one appears + await activity.end(nil, dismissalPolicy: .immediate) + await MainActor.run { + self.startFromCurrentState() + } + } + + return true + } + private func performRefresh(reason: String) { let provider = StorageCurrentGlucoseStateProvider() guard let snapshot = GlucoseSnapshotBuilder.build(from: provider) else { @@ -134,6 +173,10 @@ final class LiveActivityManager { "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 } + let now = Date() let timeSinceLastUpdate = now.timeIntervalSince(lastUpdateTime ?? .distantPast) let forceRefreshNeeded = timeSinceLastUpdate >= 5 * 60 @@ -238,6 +281,19 @@ final class LiveActivityManager { // MARK: - Binding / Lifecycle + /// Ends any Live Activities of this type that are not the one currently tracked. + /// Called on app launch to clean up cards left behind by a previous crash. + private func endOrphanedActivities() { + for activity in Activity.activities { + guard activity.id != current?.id else { continue } + let orphanID = activity.id + Task { + await activity.end(nil, dismissalPolicy: .immediate) + LogManager.shared.log(category: .general, message: "Ended orphaned Live Activity id=\(orphanID)") + } + } + } + private func bind(to activity: Activity, logReason: String) { if current?.id == activity.id { return } current = activity diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index dc0c8a282..cfc0249ec 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -91,6 +91,9 @@ class Storage { var lastCOB = StorageValue(key: "lastCOB", defaultValue: nil) var projectedBgMgdl = StorageValue(key: "projectedBgMgdl", defaultValue: nil) + // Live Activity renewal + var laRenewBy = StorageValue(key: "laRenewBy", defaultValue: 0) + // Graph Settings [BEGIN] var showDots = StorageValue(key: "showDots", defaultValue: true) var showLines = StorageValue(key: "showLines", defaultValue: true) From a020c8f08ccd336fbbb3a41b439d97d8e534096c Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Thu, 12 Mar 2026 21:55:48 -0400 Subject: [PATCH 08/86] test: reduce LA renewal threshold to 20 min for testing Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow/LiveActivity/LiveActivityManager.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index c79468845..feba7cfb1 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -13,7 +13,7 @@ final class LiveActivityManager { static let shared = LiveActivityManager() private init() {} - private static let renewalThreshold: TimeInterval = 7.5 * 3600 + private static let renewalThreshold: TimeInterval = 20 * 60 private(set) var current: Activity? private var stateObserverTask: Task? @@ -156,6 +156,7 @@ final class LiveActivityManager { await activity.end(nil, dismissalPolicy: .immediate) await MainActor.run { self.startFromCurrentState() + LogManager.shared.log(category: .general, message: "[LA] Live Activity renewed successfully") } } From bae228d6da6c07b292f720704c24c40694b1cf58 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Thu, 12 Mar 2026 22:42:36 -0400 Subject: [PATCH 09/86] feat: improve LA renewal robustness and stale indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - staleDate on every ActivityContent now tracks the renewal deadline, so the system shows Apple's built-in stale indicator if renewal fails - Add laRenewalFailed StorageValue; set on Activity.request() failure, cleared on any successful LA start - Observe willEnterForegroundNotification: retry startIfNeeded() if a previous renewal attempt failed - New-first renewal order: request the replacement LA before ending the old one — if the request throws the existing LA stays alive so the user keeps live data until the system kills it at the 8-hour mark Co-Authored-By: Claude Sonnet 4.6 --- .../LiveActivity/LiveActivityManager.swift | 84 +++++++++++++------ LoopFollow/Storage/Storage.swift | 1 + 2 files changed, 60 insertions(+), 25 deletions(-) diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index feba7cfb1..f7aee54b8 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -11,7 +11,20 @@ import UIKit @available(iOS 16.1, *) final class LiveActivityManager { static let shared = LiveActivityManager() - private init() {} + private init() { + NotificationCenter.default.addObserver( + self, + selector: #selector(handleForeground), + name: UIApplication.willEnterForegroundNotification, + object: nil + ) + } + + @objc private func handleForeground() { + guard Storage.shared.laRenewalFailed.value else { return } + LogManager.shared.log(category: .general, message: "[LA] retrying Live Activity start after previous renewal failure") + startIfNeeded() + } private static let renewalThreshold: TimeInterval = 20 * 60 @@ -34,6 +47,7 @@ final class LiveActivityManager { if let existing = Activity.activities.first { bind(to: existing, logReason: "reuse") + Storage.shared.laRenewalFailed.value = false return } @@ -59,11 +73,13 @@ final class LiveActivityManager { producedAt: Date() ) - let content = ActivityContent(state: initialState, staleDate: Date().addingTimeInterval(15 * 60)) + let renewDeadline = Date().addingTimeInterval(LiveActivityManager.renewalThreshold) + let content = ActivityContent(state: initialState, staleDate: renewDeadline) let activity = try Activity.request(attributes: attributes, content: content, pushType: .token) bind(to: activity, logReason: "start-new") - Storage.shared.laRenewBy.value = Date().timeIntervalSince1970 + LiveActivityManager.renewalThreshold + 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)") @@ -130,37 +146,55 @@ final class LiveActivityManager { // MARK: - Renewal - /// Ends the current Live Activity immediately and re-requests a fresh one, - /// working around Apple's 8-hour maximum LA lifetime. + /// 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 activity = current else { return false } + guard let oldActivity = current else { return false } let renewBy = Storage.shared.laRenewBy.value guard renewBy > 0, Date().timeIntervalSince1970 >= renewBy else { return false } - LogManager.shared.log(category: .general, message: "[LA] renewal deadline passed, renewing") + LogManager.shared.log(category: .general, message: "[LA] renewal deadline passed, requesting new LA") - // Clear our reference before re-requesting so startIfNeeded() creates a fresh one - current = nil - updateTask?.cancel() - updateTask = nil - tokenObservationTask?.cancel() - tokenObservationTask = nil - stateObserverTask?.cancel() - stateObserverTask = nil - pushToken = nil + let renewDeadline = Date().addingTimeInterval(LiveActivityManager.renewalThreshold) + let attributes = GlucoseLiveActivityAttributes(title: "LoopFollow") + let state = GlucoseLiveActivityAttributes.ContentState( + snapshot: snapshot, + seq: seq, + reason: "renew", + producedAt: Date() + ) + let content = ActivityContent(state: state, staleDate: renewDeadline) - Task { - // .immediate clears the stale Lock Screen card before the new one appears - await activity.end(nil, dismissalPolicy: .immediate) - await MainActor.run { - self.startFromCurrentState() - LogManager.shared.log(category: .general, message: "[LA] Live Activity renewed successfully") + do { + let newActivity = try Activity.request(attributes: attributes, content: content, pushType: .token) + + // New LA is live — now it's safe to remove the old card. + Task { + await oldActivity.end(nil, dismissalPolicy: .immediate) } - } - return true + updateTask?.cancel() + updateTask = nil + tokenObservationTask?.cancel() + tokenObservationTask = nil + stateObserverTask?.cancel() + stateObserverTask = nil + pushToken = nil + + bind(to: newActivity, logReason: "renew") + Storage.shared.laRenewBy.value = renewDeadline.timeIntervalSince1970 + Storage.shared.laRenewalFailed.value = false + LogManager.shared.log(category: .general, message: "[LA] Live Activity renewed successfully id=\(newActivity.id)") + return true + } catch { + Storage.shared.laRenewalFailed.value = true + LogManager.shared.log(category: .general, message: "[LA] renewal failed, keeping existing LA: \(error)") + return false + } } private func performRefresh(reason: String) { @@ -244,7 +278,7 @@ final class LiveActivityManager { let content = ActivityContent( state: state, - staleDate: Date().addingTimeInterval(15 * 60), + staleDate: Date(timeIntervalSince1970: Storage.shared.laRenewBy.value), relevanceScore: 100.0 ) diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index cfc0249ec..7de9ac7e7 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -93,6 +93,7 @@ class Storage { // Live Activity renewal var laRenewBy = StorageValue(key: "laRenewBy", defaultValue: 0) + var laRenewalFailed = StorageValue(key: "laRenewalFailed", defaultValue: false) // Graph Settings [BEGIN] var showDots = StorageValue(key: "showDots", defaultValue: true) From 2785502867d7450dda3f98755b2b4119bb74a9ab Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Thu, 12 Mar 2026 23:42:47 -0400 Subject: [PATCH 10/86] feat: renewal warning overlay + restore 7.5h threshold - Restore renewalThreshold to 7.5 * 3600 (testing complete) - Add showRenewalOverlay: Bool to GlucoseSnapshot (Codable, default false) - GlucoseSnapshotBuilder sets it true when now >= laRenewBy - 1800 (30 minutes before the renewal deadline) - Lock screen: 60% gray overlay with "Tap to update" centered in white, layered above the existing isNotLooping overlay - DI expanded: RenewalOverlayView applied to leading/trailing/bottom regions; "Tap to update" text shown on the bottom region only - showRenewalOverlay resets to false automatically on renewal since laRenewBy is updated and the next snapshot rebuild clears the flag Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow/LiveActivity/GlucoseSnapshot.swift | 14 ++++++-- .../LiveActivity/GlucoseSnapshotBuilder.swift | 8 ++++- .../LiveActivity/LiveActivityManager.swift | 2 +- .../LoopFollowLiveActivity.swift | 36 +++++++++++++++++++ 4 files changed, 56 insertions(+), 4 deletions(-) diff --git a/LoopFollow/LiveActivity/GlucoseSnapshot.swift b/LoopFollow/LiveActivity/GlucoseSnapshot.swift index 934f44eac..1e573cba6 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshot.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshot.swift @@ -50,6 +50,12 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { /// 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 30 minutes of its renewal deadline. + /// The extension renders a "Tap to update" overlay so the user knows renewal is imminent. + let showRenewalOverlay: Bool + init( glucose: Double, delta: Double, @@ -59,7 +65,8 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { cob: Double?, projected: Double?, unit: Unit, - isNotLooping: Bool + isNotLooping: Bool, + showRenewalOverlay: Bool = false ) { self.glucose = glucose self.delta = delta @@ -70,6 +77,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { self.projected = projected self.unit = unit self.isNotLooping = isNotLooping + self.showRenewalOverlay = showRenewalOverlay } func encode(to encoder: Encoder) throws { @@ -83,10 +91,11 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { try container.encodeIfPresent(projected, forKey: .projected) try container.encode(unit, forKey: .unit) try container.encode(isNotLooping, forKey: .isNotLooping) + try container.encode(showRenewalOverlay, forKey: .showRenewalOverlay) } private enum CodingKeys: String, CodingKey { - case glucose, delta, trend, updatedAt, iob, cob, projected, unit, isNotLooping + case glucose, delta, trend, updatedAt, iob, cob, projected, unit, isNotLooping, showRenewalOverlay } // MARK: - Codable @@ -102,6 +111,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { projected = try container.decodeIfPresent(Double.self, forKey: .projected) unit = try container.decode(Unit.self, forKey: .unit) isNotLooping = try container.decodeIfPresent(Bool.self, forKey: .isNotLooping) ?? false + showRenewalOverlay = try container.decodeIfPresent(Bool.self, forKey: .showRenewalOverlay) ?? false } // MARK: - Derived Convenience diff --git a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift index ad5b93dae..862d465b4 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift @@ -53,6 +53,11 @@ enum GlucoseSnapshotBuilder { // Not Looping — read from Observable, set by evaluateNotLooping() in DeviceStatus.swift let isNotLooping = Observable.shared.isNotLooping.value + // Renewal overlay — show 30 minutes before the renewal deadline so the user + // knows the LA is about to be replaced. + let renewBy = Storage.shared.laRenewBy.value + let showRenewalOverlay = renewBy > 0 && Date().timeIntervalSince1970 >= renewBy - 1800 + LogManager.shared.log( category: .general, message: "LA snapshot built: updatedAt=\(updatedAt) interval=\(updatedAt.timeIntervalSince1970)", @@ -68,7 +73,8 @@ enum GlucoseSnapshotBuilder { cob: provider.cob, projected: provider.projectedMgdl, unit: preferredUnit, - isNotLooping: isNotLooping + isNotLooping: isNotLooping, + showRenewalOverlay: showRenewalOverlay ) } diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index f7aee54b8..400d4f58c 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -26,7 +26,7 @@ final class LiveActivityManager { startIfNeeded() } - private static let renewalThreshold: TimeInterval = 20 * 60 + private static let renewalThreshold: TimeInterval = 7.5 * 3600 private(set) var current: Activity? private var stateObserverTask: Task? diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index cca77be83..cba942ea2 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -21,14 +21,17 @@ struct LoopFollowLiveActivityWidget: Widget { DynamicIslandExpandedRegion(.leading) { DynamicIslandLeadingView(snapshot: context.state.snapshot) .id(context.state.seq) + .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) } DynamicIslandExpandedRegion(.trailing) { DynamicIslandTrailingView(snapshot: context.state.snapshot) .id(context.state.seq) + .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) } DynamicIslandExpandedRegion(.bottom) { DynamicIslandBottomView(snapshot: context.state.snapshot) .id(context.state.seq) + .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay, showText: true)) } } compactLeading: { DynamicIslandCompactLeadingView(snapshot: context.state.snapshot) @@ -130,6 +133,39 @@ private struct LockScreenLiveActivityView: View { } } ) + .overlay( + Group { + if state.snapshot.showRenewalOverlay { + ZStack { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color.gray.opacity(0.6)) + Text("Tap to update") + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(.white) + } + } + } + ) + } +} + +/// 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 { + if show { + ZStack { + Color.gray.opacity(0.6) + if showText { + Text("Tap to update") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.white) + } + } + } } } From 0250633f4f949f1ca2446de8f7454cf643684f6d Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Fri, 13 Mar 2026 08:17:08 -0400 Subject: [PATCH 11/86] fix: overlay not appearing + foreground restart not working MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Overlay rendering: - Replace Group{if condition{ZStack{RoundedRectangle...}}} with a permanently-present ZStack toggled via .opacity(). The Group/if pattern causes SwiftUI sizing ambiguity when the condition transitions from false→true inside an .overlay(), producing a zero-size result. The .opacity() approach keeps a stable view hierarchy. - Same fix applied to RenewalOverlayView used on DI expanded regions. Foreground restart: - handleForeground() was calling startIfNeeded(), which finds the still-alive (failed-to-renew) LA in Activity.activities and reuses it, doing nothing useful. Fixed to manually nil out current, cancel all tasks, await activity.end(.immediate), then startFromCurrentState(). Overlay timing: - Changed warning window from 30 min (1800s) to 20 min (1200s) before the renewal deadline, matching the intended test cadence. Logging: - handleForeground: log on every foreground event with laRenewalFailed value - renewIfNeeded: log how many seconds past the deadline when firing - GlucoseSnapshotBuilder: log when overlay activates with seconds to deadline - performRefresh: log when sending an update with the overlay visible Co-Authored-By: Claude Sonnet 4.6 --- .../LiveActivity/GlucoseSnapshotBuilder.swift | 10 ++++-- .../LiveActivity/LiveActivityManager.swift | 34 +++++++++++++++++-- .../LoopFollowLiveActivity.swift | 32 ++++++++--------- 3 files changed, 53 insertions(+), 23 deletions(-) diff --git a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift index 862d465b4..db0945d30 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift @@ -53,10 +53,16 @@ enum GlucoseSnapshotBuilder { // Not Looping — read from Observable, set by evaluateNotLooping() in DeviceStatus.swift let isNotLooping = Observable.shared.isNotLooping.value - // Renewal overlay — show 30 minutes before the renewal deadline so the user + // Renewal overlay — show 20 minutes before the renewal deadline so the user // knows the LA is about to be replaced. let renewBy = Storage.shared.laRenewBy.value - let showRenewalOverlay = renewBy > 0 && Date().timeIntervalSince1970 >= renewBy - 1800 + let now = Date().timeIntervalSince1970 + let showRenewalOverlay = renewBy > 0 && now >= renewBy - 1200 + + if showRenewalOverlay { + let timeLeft = max(renewBy - now, 0) + LogManager.shared.log(category: .general, message: "[LA] renewal overlay ON — \(Int(timeLeft))s until deadline") + } LogManager.shared.log( category: .general, diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 400d4f58c..6049f1016 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -21,9 +21,32 @@ final class LiveActivityManager { } @objc private func handleForeground() { + LogManager.shared.log(category: .general, message: "[LA] foreground notification received, laRenewalFailed=\(Storage.shared.laRenewalFailed.value)") guard Storage.shared.laRenewalFailed.value else { return } - LogManager.shared.log(category: .general, message: "[LA] retrying Live Activity start after previous renewal failure") - startIfNeeded() + + // Renewal previously failed — end the stale LA and start a fresh one. + // We cannot call startIfNeeded() here: it finds the existing activity in + // Activity.activities and reuses it rather than replacing it. + LogManager.shared.log(category: .general, message: "[LA] ending stale LA and restarting after renewal failure") + if let activity = current { + current = nil + updateTask?.cancel() + updateTask = nil + tokenObservationTask?.cancel() + tokenObservationTask = nil + stateObserverTask?.cancel() + stateObserverTask = nil + pushToken = nil + Task { + await activity.end(nil, dismissalPolicy: .immediate) + await MainActor.run { + self.startFromCurrentState() + LogManager.shared.log(category: .general, message: "[LA] Live Activity restarted after foreground retry") + } + } + } else { + startFromCurrentState() + } } private static let renewalThreshold: TimeInterval = 7.5 * 3600 @@ -157,7 +180,8 @@ final class LiveActivityManager { let renewBy = Storage.shared.laRenewBy.value guard renewBy > 0, Date().timeIntervalSince1970 >= renewBy else { return false } - LogManager.shared.log(category: .general, message: "[LA] renewal deadline passed, requesting new LA") + 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") @@ -212,6 +236,10 @@ final class LiveActivityManager { // 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 diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index cba942ea2..2ef72f6fe 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -134,17 +134,14 @@ private struct LockScreenLiveActivityView: View { } ) .overlay( - Group { - if state.snapshot.showRenewalOverlay { - ZStack { - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(Color.gray.opacity(0.6)) - Text("Tap to update") - .font(.system(size: 20, weight: .semibold)) - .foregroundStyle(.white) - } - } + ZStack { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color.gray.opacity(0.6)) + Text("Tap to update") + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(.white) } + .opacity(state.snapshot.showRenewalOverlay ? 1 : 0) ) } } @@ -156,16 +153,15 @@ private struct RenewalOverlayView: View { var showText: Bool = false var body: some View { - if show { - ZStack { - Color.gray.opacity(0.6) - if showText { - Text("Tap to update") - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(.white) - } + ZStack { + Color.gray.opacity(0.6) + if showText { + Text("Tap to update") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.white) } } + .opacity(show ? 1 : 0) } } From 4e48c45108f49976232945fa8a1f013fd1cd156b Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Fri, 13 Mar 2026 08:18:57 -0400 Subject: [PATCH 12/86] test: set renewalThreshold to 20 min for testing Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow/LiveActivity/LiveActivityManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 6049f1016..d4695f096 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -49,7 +49,7 @@ final class LiveActivityManager { } } - private static let renewalThreshold: TimeInterval = 7.5 * 3600 + private static let renewalThreshold: TimeInterval = 20 * 60 private(set) var current: Activity? private var stateObserverTask: Task? From 136dba040bb35aecea1412aa021413a8b000c450 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Fri, 13 Mar 2026 08:59:21 -0400 Subject: [PATCH 13/86] fix: renewal overlay not clearing after LA is refreshed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Normal renewal path (renewIfNeeded success): - Build a fresh snapshot with showRenewalOverlay: false for the new LA's initial content — it has a new deadline so the overlay should never be visible from the first frame. - Save that fresh snapshot to GlucoseSnapshotStore so the next duplicate check has the correct baseline and doesn't suppress the first real BG update. Foreground restart path (handleForeground): - Zero laRenewBy before calling startFromCurrentState() so GlucoseSnapshotBuilder computes showRenewalOverlay = false for the seed snapshot. startIfNeeded() then sets the new deadline after the request succeeds. Co-Authored-By: Claude Sonnet 4.6 --- .../LiveActivity/LiveActivityManager.swift | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index d4695f096..e86cb678b 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -40,6 +40,9 @@ final class LiveActivityManager { Task { await activity.end(nil, dismissalPolicy: .immediate) await MainActor.run { + // Clear the expired deadline before rebuilding the snapshot so + // GlucoseSnapshotBuilder computes showRenewalOverlay = false. + Storage.shared.laRenewBy.value = 0 self.startFromCurrentState() LogManager.shared.log(category: .general, message: "[LA] Live Activity restarted after foreground retry") } @@ -185,8 +188,23 @@ final class LiveActivityManager { let renewDeadline = Date().addingTimeInterval(LiveActivityManager.renewalThreshold) let attributes = GlucoseLiveActivityAttributes(title: "LoopFollow") + + // Strip the overlay flag — the new LA has a fresh deadline so it should + // open clean, without the warning visible from the first frame. + let freshSnapshot = GlucoseSnapshot( + glucose: snapshot.glucose, + delta: snapshot.delta, + trend: snapshot.trend, + updatedAt: snapshot.updatedAt, + iob: snapshot.iob, + cob: snapshot.cob, + projected: snapshot.projected, + unit: snapshot.unit, + isNotLooping: snapshot.isNotLooping, + showRenewalOverlay: false + ) let state = GlucoseLiveActivityAttributes.ContentState( - snapshot: snapshot, + snapshot: freshSnapshot, seq: seq, reason: "renew", producedAt: Date() @@ -212,6 +230,8 @@ final class LiveActivityManager { bind(to: newActivity, logReason: "renew") Storage.shared.laRenewBy.value = renewDeadline.timeIntervalSince1970 Storage.shared.laRenewalFailed.value = false + // Update the store so the next duplicate check has the correct baseline. + GlucoseSnapshotStore.shared.save(freshSnapshot) LogManager.shared.log(category: .general, message: "[LA] Live Activity renewed successfully id=\(newActivity.id)") return true } catch { From 32a6dd0b542c9f7dd7dbb141dbb6154d0006abcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Fri, 13 Mar 2026 14:19:55 +0100 Subject: [PATCH 14/86] Fix Mac Catalyst build: guard ActivityKit code and exclude widget extension - Wrap ActivityKit-dependent files (GlucoseLiveActivityAttributes, LiveActivityManager, APNSClient) in #if !targetEnvironment(macCatalyst) - Guard LiveActivityManager call sites in MainViewController, BGData, and DeviceStatus with the same compile-time check - Remove unnecessary @available(iOS 16.1, *) checks (deployment target is already 16.6) - Add platformFilter = ios to the widget extension embed phase and target dependency so it is excluded from Mac Catalyst builds --- LoopFollow.xcodeproj/project.pbxproj | 3 ++- LoopFollow/Controllers/Nightscout/BGData.swift | 4 ++-- LoopFollow/Controllers/Nightscout/DeviceStatus.swift | 8 ++++---- LoopFollow/LiveActivity/APNSClient.swift | 5 +++++ .../LiveActivity/GlucoseLiveActivityAttributes.swift | 5 +++++ LoopFollow/LiveActivity/LiveActivityManager.swift | 6 +++++- LoopFollow/ViewControllers/MainViewController.swift | 4 ++-- 7 files changed, 25 insertions(+), 10 deletions(-) diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index d7ed09428..fb04f561a 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -24,7 +24,7 @@ 374A77B92F5BE1AC00E96858 /* PreferredGlucoseUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77B22F5BE1AC00E96858 /* PreferredGlucoseUnit.swift */; }; 37A4BDDB2F5B6B4A00EEB289 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 37A4BDDA2F5B6B4A00EEB289 /* WidgetKit.framework */; }; 37A4BDDD2F5B6B4A00EEB289 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 37A4BDDC2F5B6B4A00EEB289 /* SwiftUI.framework */; }; - 37A4BDE82F5B6B4C00EEB289 /* LoopFollowLAExtensionExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 37A4BDE82F5B6B4C00EEB289 /* LoopFollowLAExtensionExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */; platformFilter = ios; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 3F1335F351590E573D8E6962 /* Pods_LoopFollow.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */; }; 654132E72E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654132E62E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift */; }; 654132EA2E19F24800BDBE08 /* TOTPGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654132E92E19F24800BDBE08 /* TOTPGenerator.swift */; }; @@ -2347,6 +2347,7 @@ /* Begin PBXTargetDependency section */ 37A4BDE72F5B6B4C00EEB289 /* PBXTargetDependency */ = { isa = PBXTargetDependency; + platformFilter = ios; target = 37A4BDD82F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension */; targetProxy = 37A4BDE62F5B6B4C00EEB289 /* PBXContainerItemProxy */; }; diff --git a/LoopFollow/Controllers/Nightscout/BGData.swift b/LoopFollow/Controllers/Nightscout/BGData.swift index c870abe57..c0721b8a4 100644 --- a/LoopFollow/Controllers/Nightscout/BGData.swift +++ b/LoopFollow/Controllers/Nightscout/BGData.swift @@ -269,9 +269,9 @@ extension MainViewController { self.markDataLoaded("bg") // Live Activity update - if #available(iOS 16.1, *) { + #if !targetEnvironment(macCatalyst) LiveActivityManager.shared.refreshFromCurrentState(reason: "bg") - } + #endif // Update contact if Storage.shared.contactEnabled.value { diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift index fc1739c4c..b7f88634e 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift @@ -56,9 +56,9 @@ extension MainViewController { LoopStatusLabel.text = "⚠️ Not Looping!" LoopStatusLabel.textColor = UIColor.systemYellow LoopStatusLabel.font = UIFont.boldSystemFont(ofSize: 18) - if #available(iOS 16.1, *) { + #if !targetEnvironment(macCatalyst) LiveActivityManager.shared.refreshFromCurrentState(reason: "notLooping") - } + #endif } else { IsNotLooping = false @@ -77,9 +77,9 @@ extension MainViewController { case .system: LoopStatusLabel.textColor = UIColor.label } - if #available(iOS 16.1, *) { + #if !targetEnvironment(macCatalyst) LiveActivityManager.shared.refreshFromCurrentState(reason: "loopingResumed") - } + #endif } } diff --git a/LoopFollow/LiveActivity/APNSClient.swift b/LoopFollow/LiveActivity/APNSClient.swift index ac2dfc782..4679989f9 100644 --- a/LoopFollow/LiveActivity/APNSClient.swift +++ b/LoopFollow/LiveActivity/APNSClient.swift @@ -1,6 +1,9 @@ // LoopFollow // APNSClient.swift +// swiftformat:disable indent +#if !targetEnvironment(macCatalyst) + import Foundation class APNSClient { @@ -126,3 +129,5 @@ class APNSClient { return try? JSONSerialization.data(withJSONObject: payload) } } + +#endif diff --git a/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift b/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift index 9d6811e56..b04768fab 100644 --- a/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift +++ b/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift @@ -1,6 +1,9 @@ // LoopFollow // GlucoseLiveActivityAttributes.swift +// swiftformat:disable indent +#if !targetEnvironment(macCatalyst) + import ActivityKit import Foundation @@ -35,3 +38,5 @@ struct GlucoseLiveActivityAttributes: ActivityAttributes { /// Reserved for future metadata. Keep minimal for stability. let title: String } + +#endif diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index b342711a7..bec4b4d4d 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -1,6 +1,9 @@ // LoopFollow // LiveActivityManager.swift +// swiftformat:disable indent +#if !targetEnvironment(macCatalyst) + @preconcurrency import ActivityKit import Foundation import os @@ -8,7 +11,6 @@ import UIKit /// Live Activity manager for LoopFollow. -@available(iOS 16.1, *) final class LiveActivityManager { static let shared = LiveActivityManager() private init() {} @@ -277,3 +279,5 @@ final class LiveActivityManager { } } } + +#endif diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 9173bec9f..270b9be87 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -992,9 +992,9 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele @objc override func viewDidAppear(_: Bool) { showHideNSDetails() - if #available(iOS 16.1, *) { + #if !targetEnvironment(macCatalyst) LiveActivityManager.shared.startFromCurrentState() - } + #endif } func stringFromTimeInterval(interval: TimeInterval) -> String { From 921a96615068022f2230a90a2dc20856977a5822 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Fri, 13 Mar 2026 09:54:09 -0400 Subject: [PATCH 15/86] fix: overlay permanently active when warning window equals threshold With renewalThreshold=20min and the hardcoded 1200s warning window, renewBy-1200 = start, so showRenewalOverlay is always true from the moment the LA begins. Extract a named renewalWarning constant (5 min for testing) so the warning window is always less than the threshold. The builder now reads LiveActivityManager.renewalWarning instead of a hardcoded literal. Production values to restore before merging: renewalThreshold = 7.5 * 3600 renewalWarning = 20 * 60 Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift | 6 +++--- LoopFollow/LiveActivity/LiveActivityManager.swift | 6 +++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift index db0945d30..f6a1d7208 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift @@ -53,11 +53,11 @@ enum GlucoseSnapshotBuilder { // Not Looping — read from Observable, set by evaluateNotLooping() in DeviceStatus.swift let isNotLooping = Observable.shared.isNotLooping.value - // Renewal overlay — show 20 minutes before the renewal deadline so the user - // knows the LA is about to be replaced. + // Renewal overlay — show renewalWarning seconds before the renewal deadline + // so the user knows the LA is about to be replaced. let renewBy = Storage.shared.laRenewBy.value let now = Date().timeIntervalSince1970 - let showRenewalOverlay = renewBy > 0 && now >= renewBy - 1200 + let showRenewalOverlay = renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning if showRenewalOverlay { let timeLeft = max(renewBy - now, 0) diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index e86cb678b..21590e7f7 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -52,7 +52,11 @@ final class LiveActivityManager { } } - private static let renewalThreshold: TimeInterval = 20 * 60 + // TEST VALUES — restore both to production before merging: + // renewalThreshold = 7.5 * 3600 + // renewalWarning = 20 * 60 + static let renewalThreshold: TimeInterval = 20 * 60 + static let renewalWarning: TimeInterval = 5 * 60 private(set) var current: Activity? private var stateObserverTask: Task? From 8989103f5095f560db0b2f32bf6b7a16f2c652cb Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Fri, 13 Mar 2026 10:43:01 -0400 Subject: [PATCH 16/86] fix: include showRenewalOverlay in APNs payload and clear laRenewBy synchronously MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit APNSClient was missing showRenewalOverlay from the push payload, so background APNs updates never delivered the overlay flag to the extension — only foreground direct ActivityKit updates did. In handleForeground, laRenewBy is now zeroed synchronously before spawning the async end/restart Task. This means any snapshot built between the foreground notification and the new LA start (e.g. from viewDidAppear's startFromCurrentState) computes showRenewalOverlay = false rather than reading the stale expired deadline. Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow/LiveActivity/APNSClient.swift | 1 + LoopFollow/LiveActivity/LiveActivityManager.swift | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/LoopFollow/LiveActivity/APNSClient.swift b/LoopFollow/LiveActivity/APNSClient.swift index ac2dfc782..de721fd58 100644 --- a/LoopFollow/LiveActivity/APNSClient.swift +++ b/LoopFollow/LiveActivity/APNSClient.swift @@ -104,6 +104,7 @@ class APNSClient { ] 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 } diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 21590e7f7..4c67531e4 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -28,6 +28,9 @@ final class LiveActivityManager { // We cannot call startIfNeeded() here: it finds the existing activity in // Activity.activities and reuses it rather than replacing it. LogManager.shared.log(category: .general, message: "[LA] ending stale LA and restarting after renewal failure") + // Clear the expired deadline synchronously so any snapshot built between now + // and when the new LA is started computes showRenewalOverlay = false. + Storage.shared.laRenewBy.value = 0 if let activity = current { current = nil updateTask?.cancel() @@ -40,9 +43,6 @@ final class LiveActivityManager { Task { await activity.end(nil, dismissalPolicy: .immediate) await MainActor.run { - // Clear the expired deadline before rebuilding the snapshot so - // GlucoseSnapshotBuilder computes showRenewalOverlay = false. - Storage.shared.laRenewBy.value = 0 self.startFromCurrentState() LogManager.shared.log(category: .general, message: "[LA] Live Activity restarted after foreground retry") } From 1ab3930b0f70095bc750ce335e27f5b54af81e33 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Fri, 13 Mar 2026 11:34:46 -0400 Subject: [PATCH 17/86] fix: await LA end before restarting on foreground retry to avoid reuse path Reset laRenewBy and laRenewalFailed synchronously before tearing down the failed LA, then await activity.end() before calling startFromCurrentState(). This guarantees Activity.activities is clear when startIfNeeded() runs, so it takes the fresh-request path and writes a new laRenewBy. startFromCurrentState rebuilds the snapshot with showRenewalOverlay=false (laRenewBy=0), saves it to the store, then startIfNeeded uses that clean snapshot as the seed. Co-Authored-By: Claude Sonnet 4.6 --- .../LiveActivity/LiveActivityManager.swift | 48 +++++++++++-------- 1 file changed, 29 insertions(+), 19 deletions(-) diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 4c67531e4..75449d2d6 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -28,27 +28,37 @@ final class LiveActivityManager { // We cannot call startIfNeeded() here: it finds the existing activity in // Activity.activities and reuses it rather than replacing it. LogManager.shared.log(category: .general, message: "[LA] ending stale LA and restarting after renewal failure") - // Clear the expired deadline synchronously so any snapshot built between now - // and when the new LA is started computes showRenewalOverlay = false. + // Clear state synchronously so any snapshot built between now and when the + // new LA is started computes showRenewalOverlay = false. Storage.shared.laRenewBy.value = 0 - if let activity = current { - current = nil - updateTask?.cancel() - updateTask = nil - tokenObservationTask?.cancel() - tokenObservationTask = nil - stateObserverTask?.cancel() - stateObserverTask = nil - pushToken = nil - Task { - await activity.end(nil, dismissalPolicy: .immediate) - await MainActor.run { - self.startFromCurrentState() - LogManager.shared.log(category: .general, message: "[LA] Live Activity restarted after foreground retry") - } - } - } else { + Storage.shared.laRenewalFailed.value = false + + guard let activity = current else { startFromCurrentState() + return + } + + current = nil + updateTask?.cancel() + updateTask = nil + tokenObservationTask?.cancel() + tokenObservationTask = nil + stateObserverTask?.cancel() + stateObserverTask = nil + pushToken = nil + + Task { + // Await end so the activity is removed from Activity.activities before + // startIfNeeded() runs — otherwise it hits the reuse path and skips + // writing a new laRenewBy deadline. + await activity.end(nil, dismissalPolicy: .immediate) + await MainActor.run { + // startFromCurrentState rebuilds the snapshot (showRenewalOverlay = false + // since laRenewBy is 0), saves it to the store, then calls startIfNeeded() + // which finds no existing activity and requests a fresh LA with a new deadline. + self.startFromCurrentState() + LogManager.shared.log(category: .general, message: "[LA] Live Activity restarted after foreground retry") + } } } From cdd4f8509bd5c11980592467122511b55c5d4f56 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Fri, 13 Mar 2026 12:10:47 -0400 Subject: [PATCH 18/86] chore: restore production renewal timing (7.5h threshold, 20min warning) Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow/LiveActivity/LiveActivityManager.swift | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 75449d2d6..97386de3d 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -62,11 +62,8 @@ final class LiveActivityManager { } } - // TEST VALUES — restore both to production before merging: - // renewalThreshold = 7.5 * 3600 - // renewalWarning = 20 * 60 - static let renewalThreshold: TimeInterval = 20 * 60 - static let renewalWarning: TimeInterval = 5 * 60 + static let renewalThreshold: TimeInterval = 7.5 * 3600 + static let renewalWarning: TimeInterval = 20 * 60 private(set) var current: Activity? private var stateObserverTask: Task? From e737bce61c98ae7b75a33cf7d90492bc0a8907bc Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Fri, 13 Mar 2026 14:32:45 -0400 Subject: [PATCH 19/86] **Live Activity auto-renewal (8-hour limit workaround)** (#539) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Live Activity auto-renewal to work around 8-hour system limit Co-Authored-By: Claude Sonnet 4.6 * test: reduce LA renewal threshold to 20 min for testing Co-Authored-By: Claude Sonnet 4.6 * feat: improve LA renewal robustness and stale indicator - staleDate on every ActivityContent now tracks the renewal deadline, so the system shows Apple's built-in stale indicator if renewal fails - Add laRenewalFailed StorageValue; set on Activity.request() failure, cleared on any successful LA start - Observe willEnterForegroundNotification: retry startIfNeeded() if a previous renewal attempt failed - New-first renewal order: request the replacement LA before ending the old one — if the request throws the existing LA stays alive so the user keeps live data until the system kills it at the 8-hour mark Co-Authored-By: Claude Sonnet 4.6 * feat: renewal warning overlay + restore 7.5h threshold - Restore renewalThreshold to 7.5 * 3600 (testing complete) - Add showRenewalOverlay: Bool to GlucoseSnapshot (Codable, default false) - GlucoseSnapshotBuilder sets it true when now >= laRenewBy - 1800 (30 minutes before the renewal deadline) - Lock screen: 60% gray overlay with "Tap to update" centered in white, layered above the existing isNotLooping overlay - DI expanded: RenewalOverlayView applied to leading/trailing/bottom regions; "Tap to update" text shown on the bottom region only - showRenewalOverlay resets to false automatically on renewal since laRenewBy is updated and the next snapshot rebuild clears the flag Co-Authored-By: Claude Sonnet 4.6 * fix: overlay not appearing + foreground restart not working Overlay rendering: - Replace Group{if condition{ZStack{RoundedRectangle...}}} with a permanently-present ZStack toggled via .opacity(). The Group/if pattern causes SwiftUI sizing ambiguity when the condition transitions from false→true inside an .overlay(), producing a zero-size result. The .opacity() approach keeps a stable view hierarchy. - Same fix applied to RenewalOverlayView used on DI expanded regions. Foreground restart: - handleForeground() was calling startIfNeeded(), which finds the still-alive (failed-to-renew) LA in Activity.activities and reuses it, doing nothing useful. Fixed to manually nil out current, cancel all tasks, await activity.end(.immediate), then startFromCurrentState(). Overlay timing: - Changed warning window from 30 min (1800s) to 20 min (1200s) before the renewal deadline, matching the intended test cadence. Logging: - handleForeground: log on every foreground event with laRenewalFailed value - renewIfNeeded: log how many seconds past the deadline when firing - GlucoseSnapshotBuilder: log when overlay activates with seconds to deadline - performRefresh: log when sending an update with the overlay visible Co-Authored-By: Claude Sonnet 4.6 * test: set renewalThreshold to 20 min for testing Co-Authored-By: Claude Sonnet 4.6 * fix: renewal overlay not clearing after LA is refreshed Normal renewal path (renewIfNeeded success): - Build a fresh snapshot with showRenewalOverlay: false for the new LA's initial content — it has a new deadline so the overlay should never be visible from the first frame. - Save that fresh snapshot to GlucoseSnapshotStore so the next duplicate check has the correct baseline and doesn't suppress the first real BG update. Foreground restart path (handleForeground): - Zero laRenewBy before calling startFromCurrentState() so GlucoseSnapshotBuilder computes showRenewalOverlay = false for the seed snapshot. startIfNeeded() then sets the new deadline after the request succeeds. Co-Authored-By: Claude Sonnet 4.6 * fix: overlay permanently active when warning window equals threshold With renewalThreshold=20min and the hardcoded 1200s warning window, renewBy-1200 = start, so showRenewalOverlay is always true from the moment the LA begins. Extract a named renewalWarning constant (5 min for testing) so the warning window is always less than the threshold. The builder now reads LiveActivityManager.renewalWarning instead of a hardcoded literal. Production values to restore before merging: renewalThreshold = 7.5 * 3600 renewalWarning = 20 * 60 Co-Authored-By: Claude Sonnet 4.6 * fix: include showRenewalOverlay in APNs payload and clear laRenewBy synchronously APNSClient was missing showRenewalOverlay from the push payload, so background APNs updates never delivered the overlay flag to the extension — only foreground direct ActivityKit updates did. In handleForeground, laRenewBy is now zeroed synchronously before spawning the async end/restart Task. This means any snapshot built between the foreground notification and the new LA start (e.g. from viewDidAppear's startFromCurrentState) computes showRenewalOverlay = false rather than reading the stale expired deadline. Co-Authored-By: Claude Sonnet 4.6 * fix: await LA end before restarting on foreground retry to avoid reuse path Reset laRenewBy and laRenewalFailed synchronously before tearing down the failed LA, then await activity.end() before calling startFromCurrentState(). This guarantees Activity.activities is clear when startIfNeeded() runs, so it takes the fresh-request path and writes a new laRenewBy. startFromCurrentState rebuilds the snapshot with showRenewalOverlay=false (laRenewBy=0), saves it to the store, then startIfNeeded uses that clean snapshot as the seed. Co-Authored-By: Claude Sonnet 4.6 * chore: restore production renewal timing (7.5h threshold, 20min warning) Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- LoopFollow/LiveActivity/APNSClient.swift | 1 + LoopFollow/LiveActivity/GlucoseSnapshot.swift | 14 +- .../LiveActivity/GlucoseSnapshotBuilder.swift | 14 +- .../LiveActivity/LiveActivityManager.swift | 156 +++++++++++++++++- LoopFollow/Storage/Storage.swift | 4 + .../LoopFollowLiveActivity.swift | 32 ++++ 6 files changed, 215 insertions(+), 6 deletions(-) diff --git a/LoopFollow/LiveActivity/APNSClient.swift b/LoopFollow/LiveActivity/APNSClient.swift index 4679989f9..358d99469 100644 --- a/LoopFollow/LiveActivity/APNSClient.swift +++ b/LoopFollow/LiveActivity/APNSClient.swift @@ -107,6 +107,7 @@ class APNSClient { ] 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 } diff --git a/LoopFollow/LiveActivity/GlucoseSnapshot.swift b/LoopFollow/LiveActivity/GlucoseSnapshot.swift index 934f44eac..1e573cba6 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshot.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshot.swift @@ -50,6 +50,12 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { /// 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 30 minutes of its renewal deadline. + /// The extension renders a "Tap to update" overlay so the user knows renewal is imminent. + let showRenewalOverlay: Bool + init( glucose: Double, delta: Double, @@ -59,7 +65,8 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { cob: Double?, projected: Double?, unit: Unit, - isNotLooping: Bool + isNotLooping: Bool, + showRenewalOverlay: Bool = false ) { self.glucose = glucose self.delta = delta @@ -70,6 +77,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { self.projected = projected self.unit = unit self.isNotLooping = isNotLooping + self.showRenewalOverlay = showRenewalOverlay } func encode(to encoder: Encoder) throws { @@ -83,10 +91,11 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { try container.encodeIfPresent(projected, forKey: .projected) try container.encode(unit, forKey: .unit) try container.encode(isNotLooping, forKey: .isNotLooping) + try container.encode(showRenewalOverlay, forKey: .showRenewalOverlay) } private enum CodingKeys: String, CodingKey { - case glucose, delta, trend, updatedAt, iob, cob, projected, unit, isNotLooping + case glucose, delta, trend, updatedAt, iob, cob, projected, unit, isNotLooping, showRenewalOverlay } // MARK: - Codable @@ -102,6 +111,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { projected = try container.decodeIfPresent(Double.self, forKey: .projected) unit = try container.decode(Unit.self, forKey: .unit) isNotLooping = try container.decodeIfPresent(Bool.self, forKey: .isNotLooping) ?? false + showRenewalOverlay = try container.decodeIfPresent(Bool.self, forKey: .showRenewalOverlay) ?? false } // MARK: - Derived Convenience diff --git a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift index ad5b93dae..f6a1d7208 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift @@ -53,6 +53,17 @@ enum GlucoseSnapshotBuilder { // Not Looping — read from Observable, set by evaluateNotLooping() in DeviceStatus.swift let isNotLooping = Observable.shared.isNotLooping.value + // Renewal overlay — show renewalWarning seconds before the renewal deadline + // so the user knows the LA is about to be replaced. + let renewBy = Storage.shared.laRenewBy.value + let now = Date().timeIntervalSince1970 + let showRenewalOverlay = renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning + + if showRenewalOverlay { + let timeLeft = max(renewBy - now, 0) + LogManager.shared.log(category: .general, message: "[LA] renewal overlay ON — \(Int(timeLeft))s until deadline") + } + LogManager.shared.log( category: .general, message: "LA snapshot built: updatedAt=\(updatedAt) interval=\(updatedAt.timeIntervalSince1970)", @@ -68,7 +79,8 @@ enum GlucoseSnapshotBuilder { cob: provider.cob, projected: provider.projectedMgdl, unit: preferredUnit, - isNotLooping: isNotLooping + isNotLooping: isNotLooping, + showRenewalOverlay: showRenewalOverlay ) } diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index bec4b4d4d..1313739f0 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -13,7 +13,59 @@ import UIKit final class LiveActivityManager { static let shared = LiveActivityManager() - private init() {} + private init() { + NotificationCenter.default.addObserver( + self, + selector: #selector(handleForeground), + name: UIApplication.willEnterForegroundNotification, + object: nil + ) + } + + @objc private func handleForeground() { + LogManager.shared.log(category: .general, message: "[LA] foreground notification received, laRenewalFailed=\(Storage.shared.laRenewalFailed.value)") + guard Storage.shared.laRenewalFailed.value else { return } + + // Renewal previously failed — end the stale LA and start a fresh one. + // We cannot call startIfNeeded() here: it finds the existing activity in + // Activity.activities and reuses it rather than replacing it. + LogManager.shared.log(category: .general, message: "[LA] ending stale LA and restarting after renewal failure") + // Clear state synchronously so any snapshot built between now and when the + // new LA is started computes showRenewalOverlay = false. + Storage.shared.laRenewBy.value = 0 + Storage.shared.laRenewalFailed.value = false + + guard let activity = current else { + startFromCurrentState() + return + } + + current = nil + updateTask?.cancel() + updateTask = nil + tokenObservationTask?.cancel() + tokenObservationTask = nil + stateObserverTask?.cancel() + stateObserverTask = nil + pushToken = nil + + Task { + // Await end so the activity is removed from Activity.activities before + // startIfNeeded() runs — otherwise it hits the reuse path and skips + // writing a new laRenewBy deadline. + await activity.end(nil, dismissalPolicy: .immediate) + await MainActor.run { + // startFromCurrentState rebuilds the snapshot (showRenewalOverlay = false + // since laRenewBy is 0), saves it to the store, then calls startIfNeeded() + // which finds no existing activity and requests a fresh LA with a new deadline. + self.startFromCurrentState() + LogManager.shared.log(category: .general, message: "[LA] Live Activity restarted after foreground retry") + } + } + } + + static let renewalThreshold: TimeInterval = 7.5 * 3600 + static let renewalWarning: TimeInterval = 20 * 60 private(set) var current: Activity? private var stateObserverTask: Task? @@ -34,6 +86,7 @@ final class LiveActivityManager { if let existing = Activity.activities.first { bind(to: existing, logReason: "reuse") + Storage.shared.laRenewalFailed.value = false return } @@ -59,10 +112,13 @@ final class LiveActivityManager { producedAt: Date() ) - let content = ActivityContent(state: initialState, staleDate: Date().addingTimeInterval(15 * 60)) + let renewDeadline = Date().addingTimeInterval(LiveActivityManager.renewalThreshold) + let content = ActivityContent(state: initialState, staleDate: renewDeadline) let activity = try Activity.request(attributes: attributes, content: content, pushType: .token) bind(to: activity, logReason: "start-new") + Storage.shared.laRenewBy.value = renewDeadline.timeIntervalSince1970 + Storage.shared.laRenewalFailed.value = false LogManager.shared.log(category: .general, message: "Live Activity started id=\(activity.id)") } catch { LogManager.shared.log(category: .general, message: "Live Activity failed to start: \(error)") @@ -100,11 +156,13 @@ final class LiveActivityManager { if current?.id == activity.id { current = nil + Storage.shared.laRenewBy.value = 0 } } } func startFromCurrentState() { + endOrphanedActivities() let provider = StorageCurrentGlucoseStateProvider() if let snapshot = GlucoseSnapshotBuilder.build(from: provider) { LAAppGroupSettings.setThresholds( @@ -125,6 +183,77 @@ final class LiveActivityManager { DispatchQueue.main.asyncAfter(deadline: .now() + 5.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") + + // Strip the overlay flag — the new LA has a fresh deadline so it should + // open clean, without the warning visible from the first frame. + let freshSnapshot = GlucoseSnapshot( + glucose: snapshot.glucose, + delta: snapshot.delta, + trend: snapshot.trend, + updatedAt: snapshot.updatedAt, + iob: snapshot.iob, + cob: snapshot.cob, + projected: snapshot.projected, + unit: snapshot.unit, + isNotLooping: snapshot.isNotLooping, + showRenewalOverlay: 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) + + // New LA is live — now it's safe to remove the old card. + Task { + await oldActivity.end(nil, dismissalPolicy: .immediate) + } + + updateTask?.cancel() + updateTask = nil + tokenObservationTask?.cancel() + tokenObservationTask = nil + stateObserverTask?.cancel() + stateObserverTask = nil + pushToken = nil + + bind(to: newActivity, logReason: "renew") + Storage.shared.laRenewBy.value = renewDeadline.timeIntervalSince1970 + Storage.shared.laRenewalFailed.value = false + // Update the store so the next duplicate check has the correct baseline. + GlucoseSnapshotStore.shared.save(freshSnapshot) + LogManager.shared.log(category: .general, message: "[LA] Live Activity renewed successfully id=\(newActivity.id)") + return true + } catch { + Storage.shared.laRenewalFailed.value = true + LogManager.shared.log(category: .general, message: "[LA] renewal failed, keeping existing LA: \(error)") + return false + } + } + private func performRefresh(reason: String) { let provider = StorageCurrentGlucoseStateProvider() guard let snapshot = GlucoseSnapshotBuilder.build(from: provider) else { @@ -136,6 +265,14 @@ final class LiveActivityManager { "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 @@ -202,7 +339,7 @@ final class LiveActivityManager { let content = ActivityContent( state: state, - staleDate: Date().addingTimeInterval(15 * 60), + staleDate: Date(timeIntervalSince1970: Storage.shared.laRenewBy.value), relevanceScore: 100.0 ) @@ -240,6 +377,19 @@ final class LiveActivityManager { // MARK: - Binding / Lifecycle + /// Ends any Live Activities of this type that are not the one currently tracked. + /// Called on app launch to clean up cards left behind by a previous crash. + private func endOrphanedActivities() { + for activity in Activity.activities { + guard activity.id != current?.id else { continue } + let orphanID = activity.id + Task { + await activity.end(nil, dismissalPolicy: .immediate) + LogManager.shared.log(category: .general, message: "Ended orphaned Live Activity id=\(orphanID)") + } + } + } + private func bind(to activity: Activity, logReason: String) { if current?.id == activity.id { return } current = activity diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index dc0c8a282..7de9ac7e7 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -91,6 +91,10 @@ class Storage { var lastCOB = StorageValue(key: "lastCOB", defaultValue: nil) var projectedBgMgdl = StorageValue(key: "projectedBgMgdl", defaultValue: nil) + // Live Activity renewal + var laRenewBy = StorageValue(key: "laRenewBy", defaultValue: 0) + var laRenewalFailed = StorageValue(key: "laRenewalFailed", defaultValue: false) + // Graph Settings [BEGIN] var showDots = StorageValue(key: "showDots", defaultValue: true) var showLines = StorageValue(key: "showLines", defaultValue: true) diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index cca77be83..2ef72f6fe 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -21,14 +21,17 @@ struct LoopFollowLiveActivityWidget: Widget { DynamicIslandExpandedRegion(.leading) { DynamicIslandLeadingView(snapshot: context.state.snapshot) .id(context.state.seq) + .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) } DynamicIslandExpandedRegion(.trailing) { DynamicIslandTrailingView(snapshot: context.state.snapshot) .id(context.state.seq) + .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) } DynamicIslandExpandedRegion(.bottom) { DynamicIslandBottomView(snapshot: context.state.snapshot) .id(context.state.seq) + .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay, showText: true)) } } compactLeading: { DynamicIslandCompactLeadingView(snapshot: context.state.snapshot) @@ -130,6 +133,35 @@ private struct LockScreenLiveActivityView: View { } } ) + .overlay( + ZStack { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color.gray.opacity(0.6)) + 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.6) + if showText { + Text("Tap to update") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.white) + } + } + .opacity(show ? 1 : 0) } } From e0a729a69b4d3b798d13f390402ca01417d45696 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 14 Mar 2026 08:05:59 -0400 Subject: [PATCH 20/86] feat: laEnabled toggle, forceRestart(), and RestartLiveActivityIntent - SettingsMenuView: rename "APN" menu entry to "Live Activity" - Storage: add laEnabled: Bool StorageValue (default false) - APNSettingsView: on/off toggle bound to laEnabled; APNs key fields and Restart button shown only when enabled; disabling immediately ends any running LA - LiveActivityManager: - forceRestart() (@MainActor) ends all running activities, resets laRenewBy and laRenewalFailed, then calls startFromCurrentState() - laEnabled guard added to startFromCurrentState(), refreshFromCurrentState(), handleForeground(), and handleDidBecomeActive() - didBecomeActiveNotification observer calls forceRestart() on every foreground transition when laEnabled is true - RestartLiveActivityIntent: AppIntent + ForegroundContinuableIntent; sets laEnabled, validates credentials, opens settings deep link if missing, otherwise calls forceRestart() in foreground; LoopFollowAppShortcuts exposes the intent with Siri phrase Note: RestartLiveActivityIntent.swift must be added to the app target in Xcode. Co-Authored-By: Claude Sonnet 4.6 --- .../LiveActivity/LiveActivityManager.swift | 38 +++++++++++++ .../RestartLiveActivityIntent.swift | 43 +++++++++++++++ LoopFollow/Settings/APNSettingsView.swift | 53 +++++++++++++------ LoopFollow/Settings/SettingsMenuView.swift | 2 +- LoopFollow/Storage/Storage.swift | 3 +- 5 files changed, 120 insertions(+), 19 deletions(-) create mode 100644 LoopFollow/LiveActivity/RestartLiveActivityIntent.swift diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 97386de3d..69c2fd9b9 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -18,9 +18,21 @@ final class LiveActivityManager { name: UIApplication.willEnterForegroundNotification, object: nil ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleDidBecomeActive), + name: UIApplication.didBecomeActiveNotification, + object: nil + ) + } + + @objc private func handleDidBecomeActive() { + guard Storage.shared.laEnabled.value else { return } + forceRestart() } @objc private func handleForeground() { + guard Storage.shared.laEnabled.value else { return } LogManager.shared.log(category: .general, message: "[LA] foreground notification received, laRenewalFailed=\(Storage.shared.laRenewalFailed.value)") guard Storage.shared.laRenewalFailed.value else { return } @@ -159,7 +171,32 @@ final class LiveActivityManager { } } + /// 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") + Storage.shared.laRenewBy.value = 0 + Storage.shared.laRenewalFailed.value = false + current = nil + updateTask?.cancel(); updateTask = nil + tokenObservationTask?.cancel(); tokenObservationTask = nil + stateObserverTask?.cancel(); stateObserverTask = nil + pushToken = nil + Task { + for activity in Activity.activities { + await activity.end(nil, dismissalPolicy: .immediate) + } + await MainActor.run { + self.startFromCurrentState() + LogManager.shared.log(category: .general, message: "[LA] forceRestart: Live Activity restarted") + } + } + } + func startFromCurrentState() { + guard Storage.shared.laEnabled.value else { return } endOrphanedActivities() let provider = StorageCurrentGlucoseStateProvider() if let snapshot = GlucoseSnapshotBuilder.build(from: provider) { @@ -173,6 +210,7 @@ final class LiveActivityManager { } func refreshFromCurrentState(reason: String) { + guard Storage.shared.laEnabled.value else { return } refreshWorkItem?.cancel() let workItem = DispatchWorkItem { [weak self] in self?.performRefresh(reason: reason) diff --git a/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift b/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift new file mode 100644 index 000000000..4a615b2e0 --- /dev/null +++ b/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift @@ -0,0 +1,43 @@ +// 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.") + } + + try await continueInForeground() + + await MainActor.run { LiveActivityManager.shared.forceRestart() } + + return .result(dialog: "Live Activity restarted.") + } +} + +@available(iOS 16.4, *) +struct LoopFollowAppShortcuts: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: RestartLiveActivityIntent(), + phrases: ["Restart Live Activity in \(.applicationName)"], + shortTitle: "Restart Live Activity", + systemImageName: "dot.radiowaves.left.and.right" + ) + } +} diff --git a/LoopFollow/Settings/APNSettingsView.swift b/LoopFollow/Settings/APNSettingsView.swift index 79b07e7cd..7f7828ca9 100644 --- a/LoopFollow/Settings/APNSettingsView.swift +++ b/LoopFollow/Settings/APNSettingsView.swift @@ -4,32 +4,51 @@ import SwiftUI struct APNSettingsView: View { + @State private var laEnabled: Bool = Storage.shared.laEnabled.value @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 - ) + Section(header: Text("Live Activity")) { + Toggle("Enable Live Activity", isOn: $laEnabled) + } + + if laEnabled { + 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) + } } - VStack(alignment: .leading) { - Text("APNS Key") - TogglableSecureInput( - placeholder: "Paste APNS Key", - text: $apnsKey, - style: .multiLine - ) - .frame(minHeight: 110) + Section { + Button("Restart Live Activity") { + LiveActivityManager.shared.forceRestart() + } } } } + .onChange(of: laEnabled) { newValue in + Storage.shared.laEnabled.value = newValue + if !newValue { + LiveActivityManager.shared.end(dismissalPolicy: .immediate) + } + } .onChange(of: keyId) { newValue in Storage.shared.lfKeyId.value = newValue } @@ -38,7 +57,7 @@ struct APNSettingsView: View { Storage.shared.lfApnsKey.value = apnsService.validateAndFixAPNSKey(newValue) } .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) - .navigationTitle("APN") + .navigationTitle("Live Activity") .navigationBarTitleDisplayMode(.inline) } } diff --git a/LoopFollow/Settings/SettingsMenuView.swift b/LoopFollow/Settings/SettingsMenuView.swift index 1ddcffc77..8b562be9b 100644 --- a/LoopFollow/Settings/SettingsMenuView.swift +++ b/LoopFollow/Settings/SettingsMenuView.swift @@ -60,7 +60,7 @@ struct SettingsMenuView: View { settingsPath.value.append(Sheet.importExport) } - NavigationRow(title: "APN", + NavigationRow(title: "Live Activity", icon: "bell.and.waves.left.and.right") { settingsPath.value.append(Sheet.apn) diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 7de9ac7e7..141293e7c 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -91,7 +91,8 @@ class Storage { var lastCOB = StorageValue(key: "lastCOB", defaultValue: nil) var projectedBgMgdl = StorageValue(key: "projectedBgMgdl", defaultValue: nil) - // Live Activity renewal + // Live Activity + var laEnabled = StorageValue(key: "laEnabled", defaultValue: false) var laRenewBy = StorageValue(key: "laRenewBy", defaultValue: 0) var laRenewalFailed = StorageValue(key: "laRenewalFailed", defaultValue: false) From 7588c93a28fef97aa800851527ceca02629c27f3 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 14 Mar 2026 08:13:09 -0400 Subject: [PATCH 21/86] Added RestartLiveActivityIntent to project --- LoopFollow.xcodeproj/project.pbxproj | 20 +++++++------ RestartLiveActivityIntent.swift | 43 ++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 9 deletions(-) create mode 100644 RestartLiveActivityIntent.swift diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index d7ed09428..1ec8f05eb 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -12,7 +12,6 @@ 374A77A62F5BE17000E96858 /* GlucoseSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A12F5BE17000E96858 /* GlucoseSnapshot.swift */; }; 374A77A72F5BE17000E96858 /* LAAppGroupSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A32F5BE17000E96858 /* LAAppGroupSettings.swift */; }; 374A77A82F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A02F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift */; }; - DD91E4DE2BDEC3F8002D9E97 /* GlucoseConversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD91E4DC2BDEC3F8002D9E97 /* GlucoseConversion.swift */; }; 374A77AA2F5BE17000E96858 /* AppGroupID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A779F2F5BE17000E96858 /* AppGroupID.swift */; }; 374A77AB2F5BE17000E96858 /* GlucoseSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A12F5BE17000E96858 /* GlucoseSnapshot.swift */; }; 374A77AC2F5BE17000E96858 /* LAAppGroupSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A32F5BE17000E96858 /* LAAppGroupSettings.swift */; }; @@ -22,10 +21,12 @@ 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 */; }; 37A4BDDB2F5B6B4A00EEB289 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 37A4BDDA2F5B6B4A00EEB289 /* WidgetKit.framework */; }; 37A4BDDD2F5B6B4A00EEB289 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 37A4BDDC2F5B6B4A00EEB289 /* SwiftUI.framework */; }; 37A4BDE82F5B6B4C00EEB289 /* LoopFollowLAExtensionExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 3F1335F351590E573D8E6962 /* Pods_LoopFollow.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */; }; + 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 */; }; @@ -39,7 +40,6 @@ 6584B1012E4A263900135D4D /* TOTPService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6584B1002E4A263900135D4D /* TOTPService.swift */; }; 6589CC622E9E7D1600BB18FE /* ImportExportSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC532E9E7D1600BB18FE /* ImportExportSettingsView.swift */; }; 6589CC632E9E7D1600BB18FE /* GeneralSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC5D2E9E7D1600BB18FE /* GeneralSettingsView.swift */; }; - 5544D8C363FB5D3B9BF8CE4A /* APNSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7C2676561D686C6459CAA2D /* APNSettingsView.swift */; }; 6589CC642E9E7D1600BB18FE /* ContactSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC592E9E7D1600BB18FE /* ContactSettingsView.swift */; }; 6589CC652E9E7D1600BB18FE /* DexcomSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC5C2E9E7D1600BB18FE /* DexcomSettingsViewModel.swift */; }; 6589CC662E9E7D1600BB18FE /* AdvancedSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC562E9E7D1600BB18FE /* AdvancedSettingsView.swift */; }; @@ -54,7 +54,6 @@ 6589CC6F2E9E7D1600BB18FE /* AdvancedSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC572E9E7D1600BB18FE /* AdvancedSettingsViewModel.swift */; }; 6589CC712E9E814F00BB18FE /* AlarmSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC702E9E814F00BB18FE /* AlarmSelectionView.swift */; }; 6589CC752E9EAFB700BB18FE /* SettingsMigrationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC742E9EAFB700BB18FE /* SettingsMigrationManager.swift */; }; - 6584B1012E4A263900135D4D /* TOTPService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6584B1002E4A263900135D4D /* TOTPService.swift */; }; 65E153C32E4BB69100693A4F /* URLTokenValidationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */; }; 65E8A2862E44B0300065037B /* VolumeButtonHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */; }; DD0247592DB2E89600FCADF6 /* AlarmCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */; }; @@ -177,6 +176,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 */; }; @@ -463,6 +463,7 @@ 374A77B22F5BE1AC00E96858 /* PreferredGlucoseUnit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferredGlucoseUnit.swift; sourceTree = ""; }; 374A77B32F5BE1AC00E96858 /* StorageCurrentGlucoseStateProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageCurrentGlucoseStateProvider.swift; sourceTree = ""; }; 374DAACA2F5B924B00BB663B /* LoopFollowLAExtensionExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = LoopFollowLAExtensionExtension.entitlements; sourceTree = ""; }; + 379BECA92F6588300069DC62 /* RestartLiveActivityIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestartLiveActivityIntent.swift; sourceTree = ""; }; 37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = LoopFollowLAExtensionExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 37A4BDDA2F5B6B4A00EEB289 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = /System/Library/Frameworks/WidgetKit.framework; sourceTree = ""; }; 37A4BDDC2F5B6B4A00EEB289 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = /System/Library/Frameworks/SwiftUI.framework; sourceTree = ""; }; @@ -488,13 +489,11 @@ 6589CC5B2E9E7D1600BB18FE /* DexcomSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DexcomSettingsView.swift; sourceTree = ""; }; 6589CC5C2E9E7D1600BB18FE /* DexcomSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DexcomSettingsViewModel.swift; sourceTree = ""; }; 6589CC5D2E9E7D1600BB18FE /* GeneralSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingsView.swift; sourceTree = ""; }; - E7C2676561D686C6459CAA2D /* APNSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNSettingsView.swift; sourceTree = ""; }; 6589CC5E2E9E7D1600BB18FE /* GraphSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraphSettingsView.swift; sourceTree = ""; }; 6589CC5F2E9E7D1600BB18FE /* SettingsMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsMenuView.swift; sourceTree = ""; }; 6589CC602E9E7D1600BB18FE /* TabCustomizationModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabCustomizationModal.swift; sourceTree = ""; }; 6589CC702E9E814F00BB18FE /* AlarmSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmSelectionView.swift; sourceTree = ""; }; 6589CC742E9EAFB700BB18FE /* SettingsMigrationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsMigrationManager.swift; sourceTree = ""; }; - 6584B1002E4A263900135D4D /* TOTPService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOTPService.swift; sourceTree = ""; }; 65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLTokenValidationView.swift; sourceTree = ""; }; 65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeButtonHandler.swift; sourceTree = ""; }; A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_LoopFollow.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -712,6 +711,7 @@ DDFF3D842D14279B00BF9D9E /* BackgroundRefreshSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundRefreshSettingsView.swift; sourceTree = ""; }; DDFF3D862D14280500BF9D9E /* BackgroundRefreshSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundRefreshSettingsViewModel.swift; sourceTree = ""; }; DDFF3D882D1429AB00BF9D9E /* BackgroundRefreshType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundRefreshType.swift; sourceTree = ""; }; + E7C2676561D686C6459CAA2D /* APNSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNSettingsView.swift; sourceTree = ""; }; ECA3EFB4037410B4973BB632 /* Pods-LoopFollow.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-LoopFollow.debug.xcconfig"; path = "Target Support Files/Pods-LoopFollow/Pods-LoopFollow.debug.xcconfig"; sourceTree = ""; }; FC16A97924996673003D6245 /* NightScout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightScout.swift; sourceTree = ""; }; FC16A97C24996747003D6245 /* SpeakBG.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeakBG.swift; sourceTree = ""; }; @@ -1597,6 +1597,7 @@ FC97880B2485969B00A7906C = { isa = PBXGroup; children = ( + 379BECA92F6588300069DC62 /* RestartLiveActivityIntent.swift */, 374DAACA2F5B924B00BB663B /* LoopFollowLAExtensionExtension.entitlements */, DDF2C0132BEFD468007A20E6 /* blacklisted-versions.json */, DDB0AF542BB1B24A00AFA48B /* BuildDetails.plist */, @@ -2318,6 +2319,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 */, @@ -2657,8 +2659,8 @@ isa = XCBuildConfiguration; baseConfigurationReference = ECA3EFB4037410B4973BB632 /* Pods-LoopFollow.debug.xcconfig */; buildSettings = { - ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CODE_SIGN_ENTITLEMENTS = "LoopFollow/Loop Follow.entitlements"; CODE_SIGN_STYLE = Automatic; DEFINES_MODULE = YES; @@ -2682,8 +2684,8 @@ isa = XCBuildConfiguration; baseConfigurationReference = 059B0FA59AABFE72FE13DDDA /* Pods-LoopFollow.release.xcconfig */; buildSettings = { - ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CODE_SIGN_ENTITLEMENTS = "LoopFollow/Loop Follow.entitlements"; CODE_SIGN_STYLE = Automatic; DEFINES_MODULE = YES; @@ -2753,14 +2755,14 @@ minimumVersion = 1.9.0; }; }; - /* End XCRemoteSwiftPackageReference section */ +/* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ DD485F152E46631000CE8CBF /* CryptoSwift */ = { isa = XCSwiftPackageProductDependency; productName = CryptoSwift; }; - /* End XCSwiftPackageProductDependency section */ +/* End XCSwiftPackageProductDependency section */ /* Begin XCVersionGroup section */ FC3AE7B3249E8E0E00AAE1E0 /* LoopFollow.xcdatamodeld */ = { diff --git a/RestartLiveActivityIntent.swift b/RestartLiveActivityIntent.swift new file mode 100644 index 000000000..4a615b2e0 --- /dev/null +++ b/RestartLiveActivityIntent.swift @@ -0,0 +1,43 @@ +// 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.") + } + + 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" + ) + } +} From 0c2190997e40bcc8ceeb34dcb7a5bc76bfafa78d Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 14 Mar 2026 08:38:08 -0400 Subject: [PATCH 22/86] fix: resolve two build errors in LiveActivityManager and RestartLiveActivityIntent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LiveActivityManager: handleDidBecomeActive() calls @MainActor forceRestart() via Task { @MainActor in ... } to satisfy Swift concurrency isolation - RestartLiveActivityIntent: drop ForegroundContinuableIntent — continueInForeground() was renamed to continueInForeground(_:alwaysConfirm:) in the iOS 26 SDK and requires iOS 26+; the didBecomeActiveNotification observer handles restart when the app comes to foreground, making explicit continuation unnecessary Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow/LiveActivity/LiveActivityManager.swift | 2 +- LoopFollow/LiveActivity/RestartLiveActivityIntent.swift | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 69c2fd9b9..0d2cf7c56 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -28,7 +28,7 @@ final class LiveActivityManager { @objc private func handleDidBecomeActive() { guard Storage.shared.laEnabled.value else { return } - forceRestart() + Task { @MainActor in self.forceRestart() } } @objc private func handleForeground() { diff --git a/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift b/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift index 4a615b2e0..9e3179244 100644 --- a/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift +++ b/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift @@ -5,7 +5,7 @@ import AppIntents import UIKit @available(iOS 16.4, *) -struct RestartLiveActivityIntent: AppIntent, ForegroundContinuableIntent { +struct RestartLiveActivityIntent: AppIntent { static var title: LocalizedStringResource = "Restart Live Activity" static var description = IntentDescription("Starts or restarts the LoopFollow Live Activity.") @@ -22,8 +22,6 @@ struct RestartLiveActivityIntent: AppIntent, ForegroundContinuableIntent { return .result(dialog: "Please enter your APNs credentials in LoopFollow settings to use the Live Activity.") } - try await continueInForeground() - await MainActor.run { LiveActivityManager.shared.forceRestart() } return .result(dialog: "Live Activity restarted.") From 9f5ddf29eb58268f8bfec57aa4d4150ef7c6bb4a Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 14 Mar 2026 09:15:45 -0400 Subject: [PATCH 23/86] fix: guard continueInForeground() behind iOS 26 availability check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit continueInForeground(_:alwaysConfirm:) is iOS 26+ only. On earlier versions, forceRestart() runs directly via the existing background audio session — no foreground continuation needed. Co-Authored-By: Claude Sonnet 4.6 --- RestartLiveActivityIntent.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/RestartLiveActivityIntent.swift b/RestartLiveActivityIntent.swift index 4a615b2e0..c594d5fa9 100644 --- a/RestartLiveActivityIntent.swift +++ b/RestartLiveActivityIntent.swift @@ -22,7 +22,9 @@ struct RestartLiveActivityIntent: AppIntent, ForegroundContinuableIntent { return .result(dialog: "Please enter your APNs credentials in LoopFollow settings to use the Live Activity.") } - try await continueInForeground() + if #available(iOS 26.0, *) { + try await continueInForeground() + } await MainActor.run { LiveActivityManager.shared.forceRestart() } From c2e4c34ab3b5f5fe2ff31303d8e7ab0ac385fa34 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 14 Mar 2026 09:45:36 -0400 Subject: [PATCH 24/86] fix: use startFromCurrentState in handleDidBecomeActive instead of forceRestart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit forceRestart() was killing the LA that the RestartLiveActivityIntent had just created, because continueInForeground() triggers didBecomeActive. startFromCurrentState() reuses an existing active LA via startIfNeeded() and only creates one if none is present — no destructive race. Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow/LiveActivity/LiveActivityManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 0d2cf7c56..042e05e96 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -28,7 +28,7 @@ final class LiveActivityManager { @objc private func handleDidBecomeActive() { guard Storage.shared.laEnabled.value else { return } - Task { @MainActor in self.forceRestart() } + Task { @MainActor in self.startFromCurrentState() } } @objc private func handleForeground() { From 2869d2492f907993cd9121b0721b6759f3bd0328 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 14 Mar 2026 12:56:37 -0400 Subject: [PATCH 25/86] feat: LA foreground tab navigation, button feedback, and toggle sync APNSettingsView: - "Restart Live Activity" button shows "Live Activity Restarted" for 2 seconds and disables itself during that window to prevent double-taps - Toggle observes Storage.shared.laEnabled via onReceive so it reflects changes made externally (e.g. by the App Intent / Shortcuts) LiveActivityManager: - handleDidBecomeActive posts .liveActivityDidForeground after startFromCurrentState - Notification.Name.liveActivityDidForeground extension defined here MainViewController: - Observes .liveActivityDidForeground; navigates to Snoozer tab if an alarm is currently active, otherwise navigates to tab 0 (Home) Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow/LiveActivity/LiveActivityManager.swift | 11 ++++++++++- LoopFollow/Settings/APNSettingsView.swift | 11 ++++++++++- LoopFollow/ViewControllers/MainViewController.swift | 12 ++++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 042e05e96..660c25465 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -28,7 +28,10 @@ final class LiveActivityManager { @objc private func handleDidBecomeActive() { guard Storage.shared.laEnabled.value else { return } - Task { @MainActor in self.startFromCurrentState() } + Task { @MainActor in + self.startFromCurrentState() + NotificationCenter.default.post(name: .liveActivityDidForeground, object: nil) + } } @objc private func handleForeground() { @@ -465,3 +468,9 @@ final class LiveActivityManager { } } } + +extension Notification.Name { + /// Posted on the main actor after the Live Activity manager handles a didBecomeActive event. + /// MainViewController observes this to navigate to the Home or Snoozer tab. + static let liveActivityDidForeground = Notification.Name("liveActivityDidForeground") +} diff --git a/LoopFollow/Settings/APNSettingsView.swift b/LoopFollow/Settings/APNSettingsView.swift index 7f7828ca9..d43afe0bc 100644 --- a/LoopFollow/Settings/APNSettingsView.swift +++ b/LoopFollow/Settings/APNSettingsView.swift @@ -7,6 +7,7 @@ struct APNSettingsView: View { @State private var laEnabled: Bool = Storage.shared.laEnabled.value @State private var keyId: String = Storage.shared.lfKeyId.value @State private var apnsKey: String = Storage.shared.lfApnsKey.value + @State private var restartConfirmed = false var body: some View { Form { @@ -37,12 +38,20 @@ struct APNSettingsView: View { } Section { - Button("Restart Live Activity") { + 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 { diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 9173bec9f..fe4d97d70 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -206,6 +206,7 @@ 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) + notificationCenter.addObserver(self, selector: #selector(navigateOnLAForeground), name: .liveActivityDidForeground, object: nil) // Setup the Graph if firstGraphLoad { @@ -682,6 +683,17 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele updateNightscoutTabState() } + @objc private func navigateOnLAForeground() { + guard let tabBarController = tabBarController, + let vcs = tabBarController.viewControllers, !vcs.isEmpty else { return } + if Observable.shared.currentAlarm.value != nil, + let snoozerIndex = getSnoozerTabIndex(), snoozerIndex < vcs.count { + tabBarController.selectedIndex = snoozerIndex + } else { + tabBarController.selectedIndex = 0 + } + } + private func getSnoozerTabIndex() -> Int? { guard let tabBarController = tabBarController, let viewControllers = tabBarController.viewControllers else { return nil } From 3259dcbe14312c65da05dbefbf10e2b0f42ac0a6 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 14 Mar 2026 13:17:59 -0400 Subject: [PATCH 26/86] fix: flush LA update on willResignActive to ensure lock screen shows latest data The 5-second debounce on refreshFromCurrentState means a BG update arriving while the app is foreground won't reach the LA before the user returns to the lock screen. When the debounce fires in background, isForeground=false so the direct ActivityKit update is skipped. Fix: observe willResignActiveNotification, cancel the pending debounce, and immediately push the latest snapshot via direct ActivityKit update while the app is still foreground-active. The APNs push is also sent as a backup. Co-Authored-By: Claude Sonnet 4.6 --- .../LiveActivity/LiveActivityManager.swift | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 660c25465..81d05c4d7 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -24,6 +24,56 @@ final class LiveActivityManager { name: UIApplication.didBecomeActiveNotification, object: nil ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleWillResignActive), + name: UIApplication.willResignActiveNotification, + 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() { From 54e3ed979ed809380d84dfdd10e9cbee7bc9cb7a Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 14 Mar 2026 13:22:11 -0400 Subject: [PATCH 27/86] feat: redesign Dynamic Island compact and expanded views Compact: - Trailing now shows delta instead of trend arrow (leading already showed BG) Expanded leading: - Large BG value at top - Second row: trend arrow, delta, and "Proj: X" in smaller text below Expanded trailing: - IOB and COB (moved from bottom) Expanded bottom: - "Updated at: HH:mm" (moved from trailing) Co-Authored-By: Claude Sonnet 4.6 --- .../LoopFollowLiveActivity.swift | 66 +++++++++---------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 2ef72f6fe..86441d974 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -193,30 +193,31 @@ private struct DynamicIslandLeadingView: View { let snapshot: GlucoseSnapshot var body: some View { if snapshot.isNotLooping { - VStack(alignment: .leading, spacing: 2) { - Text("⚠️ Not Looping") - .font(.system(size: 20, weight: .heavy, design: .rounded)) - .foregroundStyle(.white) - .tracking(1.0) - .lineLimit(1) - .minimumScaleFactor(0.7) - } + Text("⚠️ Not Looping") + .font(.system(size: 20, weight: .heavy, design: .rounded)) + .foregroundStyle(.white) + .tracking(1.0) + .lineLimit(1) + .minimumScaleFactor(0.7) } else { VStack(alignment: .leading, spacing: 2) { - HStack(alignment: .firstTextBaseline, spacing: 6) { - Text(LAFormat.glucose(snapshot)) - .font(.system(size: 28, weight: .bold, design: .rounded)) - .monospacedDigit() - .foregroundStyle(.white) + Text(LAFormat.glucose(snapshot)) + .font(.system(size: 28, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white) + HStack(spacing: 5) { Text(LAFormat.trendArrow(snapshot)) - .font(.system(size: 16, weight: .semibold, design: .rounded)) - .foregroundStyle(.white.opacity(0.95)) - .padding(.top, 2) + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.9)) + Text(LAFormat.delta(snapshot)) + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.9)) + Text("Proj: \(LAFormat.projected(snapshot))") + .font(.system(size: 12, weight: .regular, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.75)) } - Text(LAFormat.delta(snapshot)) - .font(.system(size: 14, weight: .semibold, design: .rounded)) - .monospacedDigit() - .foregroundStyle(.white.opacity(0.9)) } } } @@ -230,10 +231,11 @@ private struct DynamicIslandTrailingView: View { EmptyView() } else { VStack(alignment: .trailing, spacing: 3) { - Text("Upd \(LAFormat.updated(snapshot))") - .font(.system(size: 12, weight: .semibold, design: .rounded)) - .foregroundStyle(.white.opacity(0.85)) - Text("Proj \(LAFormat.projected(snapshot))") + Text("IOB \(LAFormat.iob(snapshot))") + .font(.system(size: 13, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.95)) + Text("COB \(LAFormat.cob(snapshot))") .font(.system(size: 13, weight: .bold, design: .rounded)) .monospacedDigit() .foregroundStyle(.white.opacity(0.95)) @@ -253,14 +255,11 @@ private struct DynamicIslandBottomView: View { .lineLimit(1) .minimumScaleFactor(0.75) } else { - HStack(spacing: 14) { - Text("IOB \(LAFormat.iob(snapshot))") - Text("COB \(LAFormat.cob(snapshot))") - } - .font(.system(size: 13, weight: .semibold, design: .rounded)) - .foregroundStyle(.white.opacity(0.92)) - .lineLimit(1) - .minimumScaleFactor(0.85) + Text("Updated at: \(LAFormat.updated(snapshot))") + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.92)) + .lineLimit(1) + .minimumScaleFactor(0.85) } } } @@ -276,8 +275,9 @@ private struct DynamicIslandCompactTrailingView: View { .lineLimit(1) .minimumScaleFactor(0.7) } else { - Text(LAFormat.trendArrow(snapshot)) + Text(LAFormat.delta(snapshot)) .font(.system(size: 14, weight: .semibold, design: .rounded)) + .monospacedDigit() .foregroundStyle(.white.opacity(0.95)) } } From 6752fb2b857e8798fcf9d332e2ca08ddf3e5936f Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 14 Mar 2026 13:37:01 -0400 Subject: [PATCH 28/86] fix: match Proj text style to delta; add trailing padding to IOB/COB - Proj label now uses same size (13), weight (semibold), and opacity (0.9) as the delta text beside it - Added 6pt trailing padding to the expanded DI trailing VStack so the IOB decimal is not clipped by the Dynamic Island edge Co-Authored-By: Claude Sonnet 4.6 --- LoopFollowLAExtension/LoopFollowLiveActivity.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 86441d974..d62c96b81 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -214,9 +214,9 @@ private struct DynamicIslandLeadingView: View { .monospacedDigit() .foregroundStyle(.white.opacity(0.9)) Text("Proj: \(LAFormat.projected(snapshot))") - .font(.system(size: 12, weight: .regular, design: .rounded)) + .font(.system(size: 13, weight: .semibold, design: .rounded)) .monospacedDigit() - .foregroundStyle(.white.opacity(0.75)) + .foregroundStyle(.white.opacity(0.9)) } } } @@ -240,6 +240,7 @@ private struct DynamicIslandTrailingView: View { .monospacedDigit() .foregroundStyle(.white.opacity(0.95)) } + .padding(.trailing, 6) } } } From a3a37a072257ad598d5b5e784e1002108ea90fda Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 14 Mar 2026 13:57:48 -0400 Subject: [PATCH 29/86] feat: separate Live Activity and APN settings into distinct menus Per maintainer preference, the APNs credentials (Key ID and Key) remain in their own "APN" settings screen. A new separate "Live Activity" settings screen contains the enable/disable toggle and the Restart Live Activity button. - APNSettingsView: restored to Key ID + Key only (no toggle or restart button) - LiveActivitySettingsView: new view with laEnabled toggle and restart button (with 2-second confirmation feedback); toggle stays in sync via onReceive - SettingsMenuView: "APN" entry restored; new "Live Activity" entry added with dot.radiowaves icon; Sheet.liveActivity case wired to LiveActivitySettingsView Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow/Settings/APNSettingsView.swift | 62 +++++-------------- .../Settings/LiveActivitySettingsView.swift | 42 +++++++++++++ LoopFollow/Settings/SettingsMenuView.swift | 10 ++- 3 files changed, 68 insertions(+), 46 deletions(-) create mode 100644 LoopFollow/Settings/LiveActivitySettingsView.swift diff --git a/LoopFollow/Settings/APNSettingsView.swift b/LoopFollow/Settings/APNSettingsView.swift index d43afe0bc..79b07e7cd 100644 --- a/LoopFollow/Settings/APNSettingsView.swift +++ b/LoopFollow/Settings/APNSettingsView.swift @@ -4,60 +4,32 @@ import SwiftUI struct APNSettingsView: View { - @State private var laEnabled: Bool = Storage.shared.laEnabled.value @State private var keyId: String = Storage.shared.lfKeyId.value @State private var apnsKey: String = Storage.shared.lfApnsKey.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(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) - } + Section(header: Text("LoopFollow APNs Credentials")) { + HStack { + Text("APNS Key ID") + TogglableSecureInput( + placeholder: "Enter APNS Key ID", + text: $keyId, + style: .singleLine + ) } - Section { - Button(restartConfirmed ? "Live Activity Restarted" : "Restart Live Activity") { - LiveActivityManager.shared.forceRestart() - restartConfirmed = true - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - restartConfirmed = false - } - } - .disabled(restartConfirmed) + VStack(alignment: .leading) { + Text("APNS Key") + TogglableSecureInput( + placeholder: "Paste APNS Key", + text: $apnsKey, + style: .multiLine + ) + .frame(minHeight: 110) } } } - .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) - } - } .onChange(of: keyId) { newValue in Storage.shared.lfKeyId.value = newValue } @@ -66,7 +38,7 @@ struct APNSettingsView: View { Storage.shared.lfApnsKey.value = apnsService.validateAndFixAPNSKey(newValue) } .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) - .navigationTitle("Live Activity") + .navigationTitle("APN") .navigationBarTitleDisplayMode(.inline) } } diff --git a/LoopFollow/Settings/LiveActivitySettingsView.swift b/LoopFollow/Settings/LiveActivitySettingsView.swift new file mode 100644 index 000000000..bfe39b3ee --- /dev/null +++ b/LoopFollow/Settings/LiveActivitySettingsView.swift @@ -0,0 +1,42 @@ +// LoopFollow +// LiveActivitySettingsView.swift + +import SwiftUI + +struct LiveActivitySettingsView: View { + @State private var laEnabled: Bool = Storage.shared.laEnabled.value + @State private var restartConfirmed = false + + var body: some View { + Form { + Section(header: Text("Live Activity")) { + Toggle("Enable Live Activity", isOn: $laEnabled) + } + + if laEnabled { + Section { + Button(restartConfirmed ? "Live Activity Restarted" : "Restart Live Activity") { + LiveActivityManager.shared.forceRestart() + restartConfirmed = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + restartConfirmed = false + } + } + .disabled(restartConfirmed) + } + } + } + .onReceive(Storage.shared.laEnabled.$value) { newValue in + if newValue != laEnabled { laEnabled = newValue } + } + .onChange(of: laEnabled) { newValue in + Storage.shared.laEnabled.value = newValue + if !newValue { + LiveActivityManager.shared.end(dismissalPolicy: .immediate) + } + } + .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) + .navigationTitle("Live Activity") + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/LoopFollow/Settings/SettingsMenuView.swift b/LoopFollow/Settings/SettingsMenuView.swift index 8b562be9b..b57d00503 100644 --- a/LoopFollow/Settings/SettingsMenuView.swift +++ b/LoopFollow/Settings/SettingsMenuView.swift @@ -60,12 +60,18 @@ struct SettingsMenuView: View { settingsPath.value.append(Sheet.importExport) } - NavigationRow(title: "Live Activity", + 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: "Information Display Settings", icon: "info.circle") @@ -245,6 +251,7 @@ private enum Sheet: Hashable, Identifiable { case infoDisplay case alarmSettings case apn + case liveActivity case remote case importExport case calendar, contact @@ -265,6 +272,7 @@ private enum Sheet: Hashable, Identifiable { 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() From 6f43a2c841912e6c33050940345449182a94fdd5 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 14 Mar 2026 14:01:15 -0400 Subject: [PATCH 30/86] Added Live Activity menu --- .../LiveActivitySettingsView.swift | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 LoopFollowLAExtension/LiveActivitySettingsView.swift diff --git a/LoopFollowLAExtension/LiveActivitySettingsView.swift b/LoopFollowLAExtension/LiveActivitySettingsView.swift new file mode 100644 index 000000000..bfe39b3ee --- /dev/null +++ b/LoopFollowLAExtension/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) + } +} From 48ddc770c059d94a4d065081d0368fb4917177ad Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 14 Mar 2026 14:05:02 -0400 Subject: [PATCH 31/86] chore: add LiveActivitySettingsView to Xcode project Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow.xcodeproj/project.pbxproj | 4 ++++ .../LiveActivitySettingsView.swift | 0 2 files changed, 4 insertions(+) rename {LoopFollowLAExtension => LoopFollow}/LiveActivitySettingsView.swift (100%) diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 1ec8f05eb..11a191956 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -22,6 +22,7 @@ 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 */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; @@ -464,6 +465,7 @@ 374A77B32F5BE1AC00E96858 /* StorageCurrentGlucoseStateProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageCurrentGlucoseStateProvider.swift; sourceTree = ""; }; 374DAACA2F5B924B00BB663B /* LoopFollowLAExtensionExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = LoopFollowLAExtensionExtension.entitlements; sourceTree = ""; }; 379BECA92F6588300069DC62 /* RestartLiveActivityIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestartLiveActivityIntent.swift; sourceTree = ""; }; + 379BECAF2F65DA4B0069DC62 /* LiveActivitySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivitySettingsView.swift; sourceTree = ""; }; 37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = LoopFollowLAExtensionExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 37A4BDDA2F5B6B4A00EEB289 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = /System/Library/Frameworks/WidgetKit.framework; sourceTree = ""; }; 37A4BDDC2F5B6B4A00EEB289 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = /System/Library/Frameworks/SwiftUI.framework; sourceTree = ""; }; @@ -1566,6 +1568,7 @@ FC8DEEE32485D1680075863F /* LoopFollow */ = { isa = PBXGroup; children = ( + 379BECAF2F65DA4B0069DC62 /* LiveActivitySettingsView.swift */, 376310762F5CD65100656488 /* LiveActivity */, 6589CC612E9E7D1600BB18FE /* Settings */, 65AC26702ED245DF00421360 /* Treatments */, @@ -2257,6 +2260,7 @@ DDFF3D852D14279B00BF9D9E /* BackgroundRefreshSettingsView.swift in Sources */, DDCF9A882D85FD33004DF4DD /* AlarmData.swift in Sources */, DD608A0C2C27415C00F91132 /* BackgroundAlertManager.swift in Sources */, + 379BECB02F65DA4B0069DC62 /* LiveActivitySettingsView.swift in Sources */, DD4878082C7B30BF0048F05C /* RemoteSettingsView.swift in Sources */, DDE75D2B2DE5E613007C1FC1 /* NavigationRow.swift in Sources */, DD4AFB3B2DB55CB600BB593F /* TimeOfDay.swift in Sources */, diff --git a/LoopFollowLAExtension/LiveActivitySettingsView.swift b/LoopFollow/LiveActivitySettingsView.swift similarity index 100% rename from LoopFollowLAExtension/LiveActivitySettingsView.swift rename to LoopFollow/LiveActivitySettingsView.swift From 5939ed9c3e42ac36e6f32a3d2a9656224e93574a Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 14 Mar 2026 18:36:38 -0400 Subject: [PATCH 32/86] fix: LA tap navigation, manual dismissal prevention, and toggle start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - widgetURL on lock screen LA → opens app via loopfollow://la-tap - Link views in expanded DI regions → same URL on tap - AppDelegate.application(_:open:) handles loopfollow://la-tap and posts .liveActivityDidForeground so MainViewController navigates to Snoozer (if alarm active) or Home tab — fires only on LA/DI taps, not every foreground - Notification.Name.liveActivityDidForeground moved outside #if macCatalyst so AppDelegate can reference it unconditionally - handleDidBecomeActive no longer posts .liveActivityDidForeground (was firing on every app foreground, not just LA taps) - attachStateObserver: .dismissed state (user swipe) sets laEnabled = false so the LA does not auto-restart when the app foregrounds - LiveActivitySettingsView: toggling ON calls startFromCurrentState() Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow/Application/AppDelegate.swift | 10 ++++++++ .../LiveActivity/LiveActivityManager.swift | 16 ++++++++---- LoopFollow/LiveActivitySettingsView.swift | 4 ++- .../LoopFollowLiveActivity.swift | 25 ++++++++++++------- 4 files changed, 40 insertions(+), 15 deletions(-) diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index 604cf3e9e..14b5879d3 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -97,6 +97,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate { completionHandler(.newData) } + // MARK: - URL handling + + func application(_: UIApplication, open url: URL, options _: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { + if url.scheme == "loopfollow" && url.host == "la-tap" { + NotificationCenter.default.post(name: .liveActivityDidForeground, object: nil) + return true + } + return false + } + // MARK: UISceneSession Lifecycle func application(_: UIApplication, willFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index bf0b21ad5..1504952bd 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -82,7 +82,6 @@ final class LiveActivityManager { guard Storage.shared.laEnabled.value else { return } Task { @MainActor in self.startFromCurrentState() - NotificationCenter.default.post(name: .liveActivityDidForeground, object: nil) } } @@ -513,18 +512,25 @@ final class LiveActivityManager { if state == .ended || state == .dismissed { if current?.id == activity.id { current = nil + Storage.shared.laRenewBy.value = 0 LogManager.shared.log(category: .general, message: "Live Activity cleared id=\(activity.id)", isDebug: true) } + if state == .dismissed { + // User manually swiped away the LA — treat as an implicit disable + // so it does not auto-restart when the app foregrounds. + Storage.shared.laEnabled.value = false + LogManager.shared.log(category: .general, message: "Live Activity dismissed by user — laEnabled set to false") + } } } } } } +#endif + extension Notification.Name { - /// Posted on the main actor after the Live Activity manager handles a didBecomeActive event. - /// MainViewController observes this to navigate to the Home or Snoozer tab. + /// 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") } - -#endif diff --git a/LoopFollow/LiveActivitySettingsView.swift b/LoopFollow/LiveActivitySettingsView.swift index bfe39b3ee..20ef50f5f 100644 --- a/LoopFollow/LiveActivitySettingsView.swift +++ b/LoopFollow/LiveActivitySettingsView.swift @@ -31,7 +31,9 @@ struct LiveActivitySettingsView: View { } .onChange(of: laEnabled) { newValue in Storage.shared.laEnabled.value = newValue - if !newValue { + if newValue { + LiveActivityManager.shared.startFromCurrentState() + } else { LiveActivityManager.shared.end(dismissalPolicy: .immediate) } } diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index d62c96b81..294ba8645 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -15,23 +15,30 @@ struct LoopFollowLiveActivityWidget: Widget { .activitySystemActionForegroundColor(.white) .activityBackgroundTint(LAColors.backgroundTint(for: context.state.snapshot)) .applyActivityContentMarginsFixIfAvailable() + .widgetURL(URL(string: "loopfollow://la-tap")!) } dynamicIsland: { context in // DYNAMIC ISLAND UI DynamicIsland { DynamicIslandExpandedRegion(.leading) { - DynamicIslandLeadingView(snapshot: context.state.snapshot) - .id(context.state.seq) - .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) + Link(destination: URL(string: "loopfollow://la-tap")!) { + DynamicIslandLeadingView(snapshot: context.state.snapshot) + .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) + } + .id(context.state.seq) } DynamicIslandExpandedRegion(.trailing) { - DynamicIslandTrailingView(snapshot: context.state.snapshot) - .id(context.state.seq) - .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) + Link(destination: URL(string: "loopfollow://la-tap")!) { + DynamicIslandTrailingView(snapshot: context.state.snapshot) + .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) + } + .id(context.state.seq) } DynamicIslandExpandedRegion(.bottom) { - DynamicIslandBottomView(snapshot: context.state.snapshot) - .id(context.state.seq) - .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay, showText: true)) + Link(destination: URL(string: "loopfollow://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) From ef3f2f54884019296a34f876b3215581c2b831bb Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 14 Mar 2026 18:57:14 -0400 Subject: [PATCH 33/86] fix: end Live Activity on app force-quit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds endOnTerminate() to LiveActivityManager — blocks up to 3 s using Task.detached + DispatchSemaphore so the async activity.end() completes before the process exits. Called from applicationWillTerminate so the LA clears from the lock screen immediately on force-quit. laEnabled is preserved so the LA restarts correctly on next launch. Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow/Application/AppDelegate.swift | 6 +++++- .../LiveActivity/LiveActivityManager.swift | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index 14b5879d3..c34b5b3b3 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -48,7 +48,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate { return true } - func applicationWillTerminate(_: UIApplication) {} + func applicationWillTerminate(_: UIApplication) { + #if !targetEnvironment(macCatalyst) + LiveActivityManager.shared.endOnTerminate() + #endif + } // MARK: - Remote Notifications diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 1504952bd..7e8b4dd20 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -189,6 +189,22 @@ final class LiveActivityManager { } } + /// Called from applicationWillTerminate. Ends the LA synchronously (blocking + /// up to 3 s) so it clears from the lock screen before the process exits. + /// Does not clear laEnabled — the user's preference is preserved for relaunch. + func endOnTerminate() { + guard let activity = current else { return } + current = nil + Storage.shared.laRenewBy.value = 0 + let semaphore = DispatchSemaphore(value: 0) + Task.detached { + await activity.end(nil, dismissalPolicy: .immediate) + semaphore.signal() + } + _ = semaphore.wait(timeout: .now() + 3) + LogManager.shared.log(category: .general, message: "[LA] ended on app terminate") + } + func end(dismissalPolicy: ActivityUIDismissalPolicy = .default) { updateTask?.cancel() updateTask = nil From 11aeadd5b480b3aa2ea60b5e039190d2ea87ccfe Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 14 Mar 2026 19:02:24 -0400 Subject: [PATCH 34/86] fix: use dismissedByUser flag instead of disabling laEnabled on manual dismiss - Manual LA swipe-away sets dismissedByUser (in-memory) instead of laEnabled = false, preserving the user's preference - startFromCurrentState() guards on !dismissedByUser so the LA does not auto-restart on foreground, BG refresh, or handleDidBecomeActive - forceRestart() clears dismissedByUser before starting, so the Restart button and App Intent both work as the explicit re-enable mechanism - Toggle ON in settings calls forceRestart() (clears flag + starts) instead of startFromCurrentState() which would have been blocked by the flag - dismissedByUser resets to false on app relaunch (in-memory only), so a kill + relaunch starts the LA fresh as expected Co-Authored-By: Claude Sonnet 4.6 --- .../LiveActivity/LiveActivityManager.swift | 17 ++++++++++++----- LoopFollow/LiveActivitySettingsView.swift | 2 +- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 7e8b4dd20..5521c2dfa 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -139,6 +139,11 @@ final class LiveActivityManager { private var pushToken: String? private var tokenObservationTask: Task? private var refreshWorkItem: DispatchWorkItem? + /// Set when the user manually swipes away the LA. Blocks auto-restart until + /// an explicit user action (Restart button, App Intent) clears it. + /// In-memory only — resets to false on app relaunch, so a kill + relaunch + /// starts fresh as expected. + private var dismissedByUser = false // MARK: - Public API @@ -247,6 +252,7 @@ final class LiveActivityManager { 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 current = nil @@ -266,7 +272,7 @@ final class LiveActivityManager { } func startFromCurrentState() { - guard Storage.shared.laEnabled.value else { return } + guard Storage.shared.laEnabled.value, !dismissedByUser else { return } endOrphanedActivities() let provider = StorageCurrentGlucoseStateProvider() if let snapshot = GlucoseSnapshotBuilder.build(from: provider) { @@ -532,10 +538,11 @@ final class LiveActivityManager { LogManager.shared.log(category: .general, message: "Live Activity cleared id=\(activity.id)", isDebug: true) } if state == .dismissed { - // User manually swiped away the LA — treat as an implicit disable - // so it does not auto-restart when the app foregrounds. - Storage.shared.laEnabled.value = false - LogManager.shared.log(category: .general, message: "Live Activity dismissed by user — laEnabled set to false") + // User manually swiped away the LA. Block auto-restart until + // the user explicitly restarts via button or App Intent. + // laEnabled is left true — the user's preference is preserved. + dismissedByUser = true + LogManager.shared.log(category: .general, message: "Live Activity dismissed by user — auto-restart blocked until explicit restart") } } } diff --git a/LoopFollow/LiveActivitySettingsView.swift b/LoopFollow/LiveActivitySettingsView.swift index 20ef50f5f..0a29d702a 100644 --- a/LoopFollow/LiveActivitySettingsView.swift +++ b/LoopFollow/LiveActivitySettingsView.swift @@ -32,7 +32,7 @@ struct LiveActivitySettingsView: View { .onChange(of: laEnabled) { newValue in Storage.shared.laEnabled.value = newValue if newValue { - LiveActivityManager.shared.startFromCurrentState() + LiveActivityManager.shared.forceRestart() } else { LiveActivityManager.shared.end(dismissalPolicy: .immediate) } From c81911c6b0aaff28994990ddf25cab672a4d589b Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 14 Mar 2026 19:06:31 -0400 Subject: [PATCH 35/86] fix: dismiss modal (Settings sheet) before tab switch on LA tap navigateOnLAForeground now checks tabBarController.presentedViewController and dismisses it before switching to the target tab, matching the existing pattern used elsewhere in MainViewController for the same scenario. Co-Authored-By: Claude Sonnet 4.6 --- .../ViewControllers/MainViewController.swift | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 8787948d7..516b58e3c 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -686,11 +686,21 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele @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 { - tabBarController.selectedIndex = snoozerIndex + targetIndex = snoozerIndex } else { - tabBarController.selectedIndex = 0 + targetIndex = 0 + } + + if let presented = tabBarController.presentedViewController { + presented.dismiss(animated: false) { + tabBarController.selectedIndex = targetIndex + } + } else { + tabBarController.selectedIndex = targetIndex } } From 9ccc806e8bfa2daa874c4ab17e72198aeeb5e88f Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 14 Mar 2026 21:03:52 -0400 Subject: [PATCH 36/86] fix: LA tap navigation timing and LA reappear-after-dismiss Navigation fix: - loopfollow://la-tap URL can arrive before applicationDidBecomeActive while UIKit is still restoring the view hierarchy from background. At that moment tabBarController.presentedViewController is nil so the Settings modal is never dismissed and the navigation notification fires too early. - Now stores a pendingLATapNavigation flag if app is not yet .active, and fires the notification in applicationDidBecomeActive when the full view hierarchy (including presented modals) is restored. LA reappear fix: - performRefresh() calls startIfNeeded() directly, bypassing the dismissedByUser guard that only exists in startFromCurrentState(). On every BG refresh with laEnabled=true and current==nil (post-dismiss), it would recreate the LA. - Added !dismissedByUser guard to refreshFromCurrentState() so the entire refresh pipeline is skipped while dismissed, matching the existing guard in startFromCurrentState(). Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow/Application/AppDelegate.swift | 24 +++++++++++++++++-- .../LiveActivity/LiveActivityManager.swift | 2 +- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index c34b5b3b3..802175527 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -103,14 +103,34 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // MARK: - URL handling - func application(_: UIApplication, open url: URL, options _: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { + /// Set when loopfollow://la-tap arrives while the app is still transitioning + /// from background. Consumed in applicationDidBecomeActive once the view + /// hierarchy is fully restored and the modal can actually be dismissed. + private var pendingLATapNavigation = false + + func application(_ app: UIApplication, open url: URL, options _: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { if url.scheme == "loopfollow" && url.host == "la-tap" { - NotificationCenter.default.post(name: .liveActivityDidForeground, object: nil) + if app.applicationState == .active { + // App already fully active — safe to navigate immediately. + NotificationCenter.default.post(name: .liveActivityDidForeground, object: nil) + } else { + // URL arrived during the background→foreground transition. + // Defer until applicationDidBecomeActive so UIKit has finished + // restoring the view hierarchy (including any presented modals). + pendingLATapNavigation = true + } return true } return false } + func applicationDidBecomeActive(_: UIApplication) { + if pendingLATapNavigation { + pendingLATapNavigation = false + NotificationCenter.default.post(name: .liveActivityDidForeground, object: nil) + } + } + // MARK: UISceneSession Lifecycle func application(_: UIApplication, willFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 5521c2dfa..41f129c60 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -286,7 +286,7 @@ final class LiveActivityManager { } func refreshFromCurrentState(reason: String) { - guard Storage.shared.laEnabled.value else { return } + guard Storage.shared.laEnabled.value, !dismissedByUser else { return } refreshWorkItem?.cancel() let workItem = DispatchWorkItem { [weak self] in self?.performRefresh(reason: reason) From 31a8e97b76ef32d59bc6eed513f76b10882f946f Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 14 Mar 2026 23:29:13 -0400 Subject: [PATCH 37/86] fix: handle loopfollow://la-tap URL in SceneDelegate, not AppDelegate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With scene-based lifecycle (iOS 13+), widget/LA tap URLs are delivered to scene(_:openURLContexts:) on UIWindowSceneDelegate — AppDelegate.application (_:open:url:options:) is never called. Our previous handler was dead code. SceneDelegate now implements scene(_:openURLContexts:) which posts .liveActivityDidForeground on the next run loop (via DispatchQueue.main.async) to let the view hierarchy fully settle before navigateOnLAForeground() runs. Also handles the edge case where the URL arrives before sceneDidBecomeActive by storing a pendingLATapNavigation flag. Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow/Application/AppDelegate.swift | 31 +++------------------- LoopFollow/Application/SceneDelegate.swift | 18 +++++++++++++ 2 files changed, 21 insertions(+), 28 deletions(-) diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index 802175527..bf87a3343 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -102,34 +102,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } // MARK: - URL handling - - /// Set when loopfollow://la-tap arrives while the app is still transitioning - /// from background. Consumed in applicationDidBecomeActive once the view - /// hierarchy is fully restored and the modal can actually be dismissed. - private var pendingLATapNavigation = false - - func application(_ app: UIApplication, open url: URL, options _: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { - if url.scheme == "loopfollow" && url.host == "la-tap" { - if app.applicationState == .active { - // App already fully active — safe to navigate immediately. - NotificationCenter.default.post(name: .liveActivityDidForeground, object: nil) - } else { - // URL arrived during the background→foreground transition. - // Defer until applicationDidBecomeActive so UIKit has finished - // restoring the view hierarchy (including any presented modals). - pendingLATapNavigation = true - } - return true - } - return false - } - - func applicationDidBecomeActive(_: UIApplication) { - if pendingLATapNavigation { - pendingLATapNavigation = false - NotificationCenter.default.post(name: .liveActivityDidForeground, object: nil) - } - } + // Note: with scene-based lifecycle (iOS 13+), URLs are delivered to + // SceneDelegate.scene(_:openURLContexts:) — not here. The scene delegate + // handles loopfollow://la-tap for Live Activity tap navigation. // MARK: UISceneSession Lifecycle diff --git a/LoopFollow/Application/SceneDelegate.swift b/LoopFollow/Application/SceneDelegate.swift index b15fb0bd5..a8fbb236f 100644 --- a/LoopFollow/Application/SceneDelegate.swift +++ b/LoopFollow/Application/SceneDelegate.swift @@ -32,6 +32,24 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { func sceneDidBecomeActive(_: UIScene) { // Called when the scene has moved from an inactive state to an active state. // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. + if pendingLATapNavigation { + pendingLATapNavigation = false + NotificationCenter.default.post(name: .liveActivityDidForeground, object: nil) + } + } + + /// Set when loopfollow://la-tap arrives before the scene is fully active. + /// Consumed in sceneDidBecomeActive once the view hierarchy is restored. + private var pendingLATapNavigation = false + + func scene(_: UIScene, openURLContexts URLContexts: Set) { + guard URLContexts.contains(where: { $0.url.scheme == "loopfollow" && $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) { From 26b244e31dc01285b45e9326413bdf7d50cb34c0 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sun, 15 Mar 2026 11:25:14 -0400 Subject: [PATCH 38/86] =?UTF-8?q?Live=20Activity=20=E2=80=94=20UX=20Improv?= =?UTF-8?q?ements=20and=20Reliability=20Fixes=20(#540)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Live Activity auto-renewal to work around 8-hour system limit Co-Authored-By: Claude Sonnet 4.6 * test: reduce LA renewal threshold to 20 min for testing Co-Authored-By: Claude Sonnet 4.6 * feat: improve LA renewal robustness and stale indicator - staleDate on every ActivityContent now tracks the renewal deadline, so the system shows Apple's built-in stale indicator if renewal fails - Add laRenewalFailed StorageValue; set on Activity.request() failure, cleared on any successful LA start - Observe willEnterForegroundNotification: retry startIfNeeded() if a previous renewal attempt failed - New-first renewal order: request the replacement LA before ending the old one — if the request throws the existing LA stays alive so the user keeps live data until the system kills it at the 8-hour mark Co-Authored-By: Claude Sonnet 4.6 * feat: renewal warning overlay + restore 7.5h threshold - Restore renewalThreshold to 7.5 * 3600 (testing complete) - Add showRenewalOverlay: Bool to GlucoseSnapshot (Codable, default false) - GlucoseSnapshotBuilder sets it true when now >= laRenewBy - 1800 (30 minutes before the renewal deadline) - Lock screen: 60% gray overlay with "Tap to update" centered in white, layered above the existing isNotLooping overlay - DI expanded: RenewalOverlayView applied to leading/trailing/bottom regions; "Tap to update" text shown on the bottom region only - showRenewalOverlay resets to false automatically on renewal since laRenewBy is updated and the next snapshot rebuild clears the flag Co-Authored-By: Claude Sonnet 4.6 * fix: overlay not appearing + foreground restart not working Overlay rendering: - Replace Group{if condition{ZStack{RoundedRectangle...}}} with a permanently-present ZStack toggled via .opacity(). The Group/if pattern causes SwiftUI sizing ambiguity when the condition transitions from false→true inside an .overlay(), producing a zero-size result. The .opacity() approach keeps a stable view hierarchy. - Same fix applied to RenewalOverlayView used on DI expanded regions. Foreground restart: - handleForeground() was calling startIfNeeded(), which finds the still-alive (failed-to-renew) LA in Activity.activities and reuses it, doing nothing useful. Fixed to manually nil out current, cancel all tasks, await activity.end(.immediate), then startFromCurrentState(). Overlay timing: - Changed warning window from 30 min (1800s) to 20 min (1200s) before the renewal deadline, matching the intended test cadence. Logging: - handleForeground: log on every foreground event with laRenewalFailed value - renewIfNeeded: log how many seconds past the deadline when firing - GlucoseSnapshotBuilder: log when overlay activates with seconds to deadline - performRefresh: log when sending an update with the overlay visible Co-Authored-By: Claude Sonnet 4.6 * test: set renewalThreshold to 20 min for testing Co-Authored-By: Claude Sonnet 4.6 * fix: renewal overlay not clearing after LA is refreshed Normal renewal path (renewIfNeeded success): - Build a fresh snapshot with showRenewalOverlay: false for the new LA's initial content — it has a new deadline so the overlay should never be visible from the first frame. - Save that fresh snapshot to GlucoseSnapshotStore so the next duplicate check has the correct baseline and doesn't suppress the first real BG update. Foreground restart path (handleForeground): - Zero laRenewBy before calling startFromCurrentState() so GlucoseSnapshotBuilder computes showRenewalOverlay = false for the seed snapshot. startIfNeeded() then sets the new deadline after the request succeeds. Co-Authored-By: Claude Sonnet 4.6 * fix: overlay permanently active when warning window equals threshold With renewalThreshold=20min and the hardcoded 1200s warning window, renewBy-1200 = start, so showRenewalOverlay is always true from the moment the LA begins. Extract a named renewalWarning constant (5 min for testing) so the warning window is always less than the threshold. The builder now reads LiveActivityManager.renewalWarning instead of a hardcoded literal. Production values to restore before merging: renewalThreshold = 7.5 * 3600 renewalWarning = 20 * 60 Co-Authored-By: Claude Sonnet 4.6 * fix: include showRenewalOverlay in APNs payload and clear laRenewBy synchronously APNSClient was missing showRenewalOverlay from the push payload, so background APNs updates never delivered the overlay flag to the extension — only foreground direct ActivityKit updates did. In handleForeground, laRenewBy is now zeroed synchronously before spawning the async end/restart Task. This means any snapshot built between the foreground notification and the new LA start (e.g. from viewDidAppear's startFromCurrentState) computes showRenewalOverlay = false rather than reading the stale expired deadline. Co-Authored-By: Claude Sonnet 4.6 * fix: await LA end before restarting on foreground retry to avoid reuse path Reset laRenewBy and laRenewalFailed synchronously before tearing down the failed LA, then await activity.end() before calling startFromCurrentState(). This guarantees Activity.activities is clear when startIfNeeded() runs, so it takes the fresh-request path and writes a new laRenewBy. startFromCurrentState rebuilds the snapshot with showRenewalOverlay=false (laRenewBy=0), saves it to the store, then startIfNeeded uses that clean snapshot as the seed. Co-Authored-By: Claude Sonnet 4.6 * chore: restore production renewal timing (7.5h threshold, 20min warning) Co-Authored-By: Claude Sonnet 4.6 * feat: laEnabled toggle, forceRestart(), and RestartLiveActivityIntent - SettingsMenuView: rename "APN" menu entry to "Live Activity" - Storage: add laEnabled: Bool StorageValue (default false) - APNSettingsView: on/off toggle bound to laEnabled; APNs key fields and Restart button shown only when enabled; disabling immediately ends any running LA - LiveActivityManager: - forceRestart() (@MainActor) ends all running activities, resets laRenewBy and laRenewalFailed, then calls startFromCurrentState() - laEnabled guard added to startFromCurrentState(), refreshFromCurrentState(), handleForeground(), and handleDidBecomeActive() - didBecomeActiveNotification observer calls forceRestart() on every foreground transition when laEnabled is true - RestartLiveActivityIntent: AppIntent + ForegroundContinuableIntent; sets laEnabled, validates credentials, opens settings deep link if missing, otherwise calls forceRestart() in foreground; LoopFollowAppShortcuts exposes the intent with Siri phrase Note: RestartLiveActivityIntent.swift must be added to the app target in Xcode. Co-Authored-By: Claude Sonnet 4.6 * Added RestartLiveActivityIntent to project * fix: resolve two build errors in LiveActivityManager and RestartLiveActivityIntent - LiveActivityManager: handleDidBecomeActive() calls @MainActor forceRestart() via Task { @MainActor in ... } to satisfy Swift concurrency isolation - RestartLiveActivityIntent: drop ForegroundContinuableIntent — continueInForeground() was renamed to continueInForeground(_:alwaysConfirm:) in the iOS 26 SDK and requires iOS 26+; the didBecomeActiveNotification observer handles restart when the app comes to foreground, making explicit continuation unnecessary Co-Authored-By: Claude Sonnet 4.6 * fix: guard continueInForeground() behind iOS 26 availability check continueInForeground(_:alwaysConfirm:) is iOS 26+ only. On earlier versions, forceRestart() runs directly via the existing background audio session — no foreground continuation needed. Co-Authored-By: Claude Sonnet 4.6 * fix: use startFromCurrentState in handleDidBecomeActive instead of forceRestart forceRestart() was killing the LA that the RestartLiveActivityIntent had just created, because continueInForeground() triggers didBecomeActive. startFromCurrentState() reuses an existing active LA via startIfNeeded() and only creates one if none is present — no destructive race. Co-Authored-By: Claude Sonnet 4.6 * feat: LA foreground tab navigation, button feedback, and toggle sync APNSettingsView: - "Restart Live Activity" button shows "Live Activity Restarted" for 2 seconds and disables itself during that window to prevent double-taps - Toggle observes Storage.shared.laEnabled via onReceive so it reflects changes made externally (e.g. by the App Intent / Shortcuts) LiveActivityManager: - handleDidBecomeActive posts .liveActivityDidForeground after startFromCurrentState - Notification.Name.liveActivityDidForeground extension defined here MainViewController: - Observes .liveActivityDidForeground; navigates to Snoozer tab if an alarm is currently active, otherwise navigates to tab 0 (Home) Co-Authored-By: Claude Sonnet 4.6 * fix: flush LA update on willResignActive to ensure lock screen shows latest data The 5-second debounce on refreshFromCurrentState means a BG update arriving while the app is foreground won't reach the LA before the user returns to the lock screen. When the debounce fires in background, isForeground=false so the direct ActivityKit update is skipped. Fix: observe willResignActiveNotification, cancel the pending debounce, and immediately push the latest snapshot via direct ActivityKit update while the app is still foreground-active. The APNs push is also sent as a backup. Co-Authored-By: Claude Sonnet 4.6 * feat: redesign Dynamic Island compact and expanded views Compact: - Trailing now shows delta instead of trend arrow (leading already showed BG) Expanded leading: - Large BG value at top - Second row: trend arrow, delta, and "Proj: X" in smaller text below Expanded trailing: - IOB and COB (moved from bottom) Expanded bottom: - "Updated at: HH:mm" (moved from trailing) Co-Authored-By: Claude Sonnet 4.6 * fix: match Proj text style to delta; add trailing padding to IOB/COB - Proj label now uses same size (13), weight (semibold), and opacity (0.9) as the delta text beside it - Added 6pt trailing padding to the expanded DI trailing VStack so the IOB decimal is not clipped by the Dynamic Island edge Co-Authored-By: Claude Sonnet 4.6 * feat: separate Live Activity and APN settings into distinct menus Per maintainer preference, the APNs credentials (Key ID and Key) remain in their own "APN" settings screen. A new separate "Live Activity" settings screen contains the enable/disable toggle and the Restart Live Activity button. - APNSettingsView: restored to Key ID + Key only (no toggle or restart button) - LiveActivitySettingsView: new view with laEnabled toggle and restart button (with 2-second confirmation feedback); toggle stays in sync via onReceive - SettingsMenuView: "APN" entry restored; new "Live Activity" entry added with dot.radiowaves icon; Sheet.liveActivity case wired to LiveActivitySettingsView Co-Authored-By: Claude Sonnet 4.6 * Added Live Activity menu * chore: add LiveActivitySettingsView to Xcode project Co-Authored-By: Claude Sonnet 4.6 * fix: LA tap navigation, manual dismissal prevention, and toggle start - widgetURL on lock screen LA → opens app via loopfollow://la-tap - Link views in expanded DI regions → same URL on tap - AppDelegate.application(_:open:) handles loopfollow://la-tap and posts .liveActivityDidForeground so MainViewController navigates to Snoozer (if alarm active) or Home tab — fires only on LA/DI taps, not every foreground - Notification.Name.liveActivityDidForeground moved outside #if macCatalyst so AppDelegate can reference it unconditionally - handleDidBecomeActive no longer posts .liveActivityDidForeground (was firing on every app foreground, not just LA taps) - attachStateObserver: .dismissed state (user swipe) sets laEnabled = false so the LA does not auto-restart when the app foregrounds - LiveActivitySettingsView: toggling ON calls startFromCurrentState() Co-Authored-By: Claude Sonnet 4.6 * fix: end Live Activity on app force-quit Adds endOnTerminate() to LiveActivityManager — blocks up to 3 s using Task.detached + DispatchSemaphore so the async activity.end() completes before the process exits. Called from applicationWillTerminate so the LA clears from the lock screen immediately on force-quit. laEnabled is preserved so the LA restarts correctly on next launch. Co-Authored-By: Claude Sonnet 4.6 * fix: use dismissedByUser flag instead of disabling laEnabled on manual dismiss - Manual LA swipe-away sets dismissedByUser (in-memory) instead of laEnabled = false, preserving the user's preference - startFromCurrentState() guards on !dismissedByUser so the LA does not auto-restart on foreground, BG refresh, or handleDidBecomeActive - forceRestart() clears dismissedByUser before starting, so the Restart button and App Intent both work as the explicit re-enable mechanism - Toggle ON in settings calls forceRestart() (clears flag + starts) instead of startFromCurrentState() which would have been blocked by the flag - dismissedByUser resets to false on app relaunch (in-memory only), so a kill + relaunch starts the LA fresh as expected Co-Authored-By: Claude Sonnet 4.6 * fix: dismiss modal (Settings sheet) before tab switch on LA tap navigateOnLAForeground now checks tabBarController.presentedViewController and dismisses it before switching to the target tab, matching the existing pattern used elsewhere in MainViewController for the same scenario. Co-Authored-By: Claude Sonnet 4.6 * fix: LA tap navigation timing and LA reappear-after-dismiss Navigation fix: - loopfollow://la-tap URL can arrive before applicationDidBecomeActive while UIKit is still restoring the view hierarchy from background. At that moment tabBarController.presentedViewController is nil so the Settings modal is never dismissed and the navigation notification fires too early. - Now stores a pendingLATapNavigation flag if app is not yet .active, and fires the notification in applicationDidBecomeActive when the full view hierarchy (including presented modals) is restored. LA reappear fix: - performRefresh() calls startIfNeeded() directly, bypassing the dismissedByUser guard that only exists in startFromCurrentState(). On every BG refresh with laEnabled=true and current==nil (post-dismiss), it would recreate the LA. - Added !dismissedByUser guard to refreshFromCurrentState() so the entire refresh pipeline is skipped while dismissed, matching the existing guard in startFromCurrentState(). Co-Authored-By: Claude Sonnet 4.6 * fix: handle loopfollow://la-tap URL in SceneDelegate, not AppDelegate With scene-based lifecycle (iOS 13+), widget/LA tap URLs are delivered to scene(_:openURLContexts:) on UIWindowSceneDelegate — AppDelegate.application (_:open:url:options:) is never called. Our previous handler was dead code. SceneDelegate now implements scene(_:openURLContexts:) which posts .liveActivityDidForeground on the next run loop (via DispatchQueue.main.async) to let the view hierarchy fully settle before navigateOnLAForeground() runs. Also handles the edge case where the URL arrives before sceneDidBecomeActive by storing a pendingLATapNavigation flag. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- LoopFollow.xcodeproj/project.pbxproj | 24 ++-- LoopFollow/Application/AppDelegate.swift | 11 +- LoopFollow/Application/SceneDelegate.swift | 18 +++ .../LiveActivity/LiveActivityManager.swift | 126 ++++++++++++++++++ .../RestartLiveActivityIntent.swift | 41 ++++++ LoopFollow/LiveActivitySettingsView.swift | 44 ++++++ .../Settings/LiveActivitySettingsView.swift | 42 ++++++ LoopFollow/Settings/SettingsMenuView.swift | 8 ++ LoopFollow/Storage/Storage.swift | 3 +- .../ViewControllers/MainViewController.swift | 22 +++ .../LoopFollowLiveActivity.swift | 92 +++++++------ RestartLiveActivityIntent.swift | 45 +++++++ 12 files changed, 423 insertions(+), 53 deletions(-) create mode 100644 LoopFollow/LiveActivity/RestartLiveActivityIntent.swift create mode 100644 LoopFollow/LiveActivitySettingsView.swift create mode 100644 LoopFollow/Settings/LiveActivitySettingsView.swift create mode 100644 RestartLiveActivityIntent.swift diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index fb04f561a..cb778f75d 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -12,7 +12,6 @@ 374A77A62F5BE17000E96858 /* GlucoseSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A12F5BE17000E96858 /* GlucoseSnapshot.swift */; }; 374A77A72F5BE17000E96858 /* LAAppGroupSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A32F5BE17000E96858 /* LAAppGroupSettings.swift */; }; 374A77A82F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A02F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift */; }; - DD91E4DE2BDEC3F8002D9E97 /* GlucoseConversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD91E4DC2BDEC3F8002D9E97 /* GlucoseConversion.swift */; }; 374A77AA2F5BE17000E96858 /* AppGroupID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A779F2F5BE17000E96858 /* AppGroupID.swift */; }; 374A77AB2F5BE17000E96858 /* GlucoseSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A12F5BE17000E96858 /* GlucoseSnapshot.swift */; }; 374A77AC2F5BE17000E96858 /* LAAppGroupSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A32F5BE17000E96858 /* LAAppGroupSettings.swift */; }; @@ -22,10 +21,13 @@ 374A77B72F5BE1AC00E96858 /* LiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77B12F5BE1AC00E96858 /* LiveActivityManager.swift */; }; 374A77B82F5BE1AC00E96858 /* GlucoseSnapshotStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77AF2F5BE1AC00E96858 /* GlucoseSnapshotStore.swift */; }; 374A77B92F5BE1AC00E96858 /* PreferredGlucoseUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77B22F5BE1AC00E96858 /* PreferredGlucoseUnit.swift */; }; + 379BECAA2F6588300069DC62 /* RestartLiveActivityIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379BECA92F6588300069DC62 /* RestartLiveActivityIntent.swift */; }; + 379BECB02F65DA4B0069DC62 /* LiveActivitySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379BECAF2F65DA4B0069DC62 /* LiveActivitySettingsView.swift */; }; 37A4BDDB2F5B6B4A00EEB289 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 37A4BDDA2F5B6B4A00EEB289 /* WidgetKit.framework */; }; 37A4BDDD2F5B6B4A00EEB289 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 37A4BDDC2F5B6B4A00EEB289 /* SwiftUI.framework */; }; 37A4BDE82F5B6B4C00EEB289 /* LoopFollowLAExtensionExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */; platformFilter = ios; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 3F1335F351590E573D8E6962 /* Pods_LoopFollow.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */; }; + 5544D8C363FB5D3B9BF8CE4A /* APNSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7C2676561D686C6459CAA2D /* APNSettingsView.swift */; }; 654132E72E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654132E62E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift */; }; 654132EA2E19F24800BDBE08 /* TOTPGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654132E92E19F24800BDBE08 /* TOTPGenerator.swift */; }; 654134182E1DC09700BDBE08 /* OverridePresetsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654134172E1DC09700BDBE08 /* OverridePresetsView.swift */; }; @@ -39,7 +41,6 @@ 6584B1012E4A263900135D4D /* TOTPService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6584B1002E4A263900135D4D /* TOTPService.swift */; }; 6589CC622E9E7D1600BB18FE /* ImportExportSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC532E9E7D1600BB18FE /* ImportExportSettingsView.swift */; }; 6589CC632E9E7D1600BB18FE /* GeneralSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC5D2E9E7D1600BB18FE /* GeneralSettingsView.swift */; }; - 5544D8C363FB5D3B9BF8CE4A /* APNSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7C2676561D686C6459CAA2D /* APNSettingsView.swift */; }; 6589CC642E9E7D1600BB18FE /* ContactSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC592E9E7D1600BB18FE /* ContactSettingsView.swift */; }; 6589CC652E9E7D1600BB18FE /* DexcomSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC5C2E9E7D1600BB18FE /* DexcomSettingsViewModel.swift */; }; 6589CC662E9E7D1600BB18FE /* AdvancedSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC562E9E7D1600BB18FE /* AdvancedSettingsView.swift */; }; @@ -54,7 +55,6 @@ 6589CC6F2E9E7D1600BB18FE /* AdvancedSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC572E9E7D1600BB18FE /* AdvancedSettingsViewModel.swift */; }; 6589CC712E9E814F00BB18FE /* AlarmSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC702E9E814F00BB18FE /* AlarmSelectionView.swift */; }; 6589CC752E9EAFB700BB18FE /* SettingsMigrationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC742E9EAFB700BB18FE /* SettingsMigrationManager.swift */; }; - 6584B1012E4A263900135D4D /* TOTPService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6584B1002E4A263900135D4D /* TOTPService.swift */; }; 65E153C32E4BB69100693A4F /* URLTokenValidationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */; }; 65E8A2862E44B0300065037B /* VolumeButtonHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */; }; DD0247592DB2E89600FCADF6 /* AlarmCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */; }; @@ -177,6 +177,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 */; }; @@ -463,6 +464,8 @@ 374A77B22F5BE1AC00E96858 /* PreferredGlucoseUnit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferredGlucoseUnit.swift; sourceTree = ""; }; 374A77B32F5BE1AC00E96858 /* StorageCurrentGlucoseStateProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageCurrentGlucoseStateProvider.swift; sourceTree = ""; }; 374DAACA2F5B924B00BB663B /* LoopFollowLAExtensionExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = LoopFollowLAExtensionExtension.entitlements; sourceTree = ""; }; + 379BECA92F6588300069DC62 /* RestartLiveActivityIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestartLiveActivityIntent.swift; sourceTree = ""; }; + 379BECAF2F65DA4B0069DC62 /* LiveActivitySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivitySettingsView.swift; sourceTree = ""; }; 37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = LoopFollowLAExtensionExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 37A4BDDA2F5B6B4A00EEB289 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = /System/Library/Frameworks/WidgetKit.framework; sourceTree = ""; }; 37A4BDDC2F5B6B4A00EEB289 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = /System/Library/Frameworks/SwiftUI.framework; sourceTree = ""; }; @@ -488,13 +491,11 @@ 6589CC5B2E9E7D1600BB18FE /* DexcomSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DexcomSettingsView.swift; sourceTree = ""; }; 6589CC5C2E9E7D1600BB18FE /* DexcomSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DexcomSettingsViewModel.swift; sourceTree = ""; }; 6589CC5D2E9E7D1600BB18FE /* GeneralSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingsView.swift; sourceTree = ""; }; - E7C2676561D686C6459CAA2D /* APNSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNSettingsView.swift; sourceTree = ""; }; 6589CC5E2E9E7D1600BB18FE /* GraphSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraphSettingsView.swift; sourceTree = ""; }; 6589CC5F2E9E7D1600BB18FE /* SettingsMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsMenuView.swift; sourceTree = ""; }; 6589CC602E9E7D1600BB18FE /* TabCustomizationModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabCustomizationModal.swift; sourceTree = ""; }; 6589CC702E9E814F00BB18FE /* AlarmSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmSelectionView.swift; sourceTree = ""; }; 6589CC742E9EAFB700BB18FE /* SettingsMigrationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsMigrationManager.swift; sourceTree = ""; }; - 6584B1002E4A263900135D4D /* TOTPService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOTPService.swift; sourceTree = ""; }; 65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLTokenValidationView.swift; sourceTree = ""; }; 65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeButtonHandler.swift; sourceTree = ""; }; A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_LoopFollow.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -712,6 +713,7 @@ DDFF3D842D14279B00BF9D9E /* BackgroundRefreshSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundRefreshSettingsView.swift; sourceTree = ""; }; DDFF3D862D14280500BF9D9E /* BackgroundRefreshSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundRefreshSettingsViewModel.swift; sourceTree = ""; }; DDFF3D882D1429AB00BF9D9E /* BackgroundRefreshType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundRefreshType.swift; sourceTree = ""; }; + E7C2676561D686C6459CAA2D /* APNSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNSettingsView.swift; sourceTree = ""; }; ECA3EFB4037410B4973BB632 /* Pods-LoopFollow.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-LoopFollow.debug.xcconfig"; path = "Target Support Files/Pods-LoopFollow/Pods-LoopFollow.debug.xcconfig"; sourceTree = ""; }; FC16A97924996673003D6245 /* NightScout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightScout.swift; sourceTree = ""; }; FC16A97C24996747003D6245 /* SpeakBG.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeakBG.swift; sourceTree = ""; }; @@ -1566,6 +1568,7 @@ FC8DEEE32485D1680075863F /* LoopFollow */ = { isa = PBXGroup; children = ( + 379BECAF2F65DA4B0069DC62 /* LiveActivitySettingsView.swift */, 376310762F5CD65100656488 /* LiveActivity */, 6589CC612E9E7D1600BB18FE /* Settings */, 65AC26702ED245DF00421360 /* Treatments */, @@ -1597,6 +1600,7 @@ FC97880B2485969B00A7906C = { isa = PBXGroup; children = ( + 379BECA92F6588300069DC62 /* RestartLiveActivityIntent.swift */, 374DAACA2F5B924B00BB663B /* LoopFollowLAExtensionExtension.entitlements */, DDF2C0132BEFD468007A20E6 /* blacklisted-versions.json */, DDB0AF542BB1B24A00AFA48B /* BuildDetails.plist */, @@ -2256,6 +2260,7 @@ DDFF3D852D14279B00BF9D9E /* BackgroundRefreshSettingsView.swift in Sources */, DDCF9A882D85FD33004DF4DD /* AlarmData.swift in Sources */, DD608A0C2C27415C00F91132 /* BackgroundAlertManager.swift in Sources */, + 379BECB02F65DA4B0069DC62 /* LiveActivitySettingsView.swift in Sources */, DD4878082C7B30BF0048F05C /* RemoteSettingsView.swift in Sources */, DDE75D2B2DE5E613007C1FC1 /* NavigationRow.swift in Sources */, DD4AFB3B2DB55CB600BB593F /* TimeOfDay.swift in Sources */, @@ -2318,6 +2323,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 */, @@ -2658,8 +2664,8 @@ isa = XCBuildConfiguration; baseConfigurationReference = ECA3EFB4037410B4973BB632 /* Pods-LoopFollow.debug.xcconfig */; buildSettings = { - ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CODE_SIGN_ENTITLEMENTS = "LoopFollow/Loop Follow.entitlements"; CODE_SIGN_STYLE = Automatic; DEFINES_MODULE = YES; @@ -2683,8 +2689,8 @@ isa = XCBuildConfiguration; baseConfigurationReference = 059B0FA59AABFE72FE13DDDA /* Pods-LoopFollow.release.xcconfig */; buildSettings = { - ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CODE_SIGN_ENTITLEMENTS = "LoopFollow/Loop Follow.entitlements"; CODE_SIGN_STYLE = Automatic; DEFINES_MODULE = YES; @@ -2754,14 +2760,14 @@ minimumVersion = 1.9.0; }; }; - /* End XCRemoteSwiftPackageReference section */ +/* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ DD485F152E46631000CE8CBF /* CryptoSwift */ = { isa = XCSwiftPackageProductDependency; productName = CryptoSwift; }; - /* End XCSwiftPackageProductDependency section */ +/* End XCSwiftPackageProductDependency section */ /* Begin XCVersionGroup section */ FC3AE7B3249E8E0E00AAE1E0 /* LoopFollow.xcdatamodeld */ = { diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index 604cf3e9e..bf87a3343 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -48,7 +48,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate { return true } - func applicationWillTerminate(_: UIApplication) {} + func applicationWillTerminate(_: UIApplication) { + #if !targetEnvironment(macCatalyst) + LiveActivityManager.shared.endOnTerminate() + #endif + } // MARK: - Remote Notifications @@ -97,6 +101,11 @@ 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 loopfollow://la-tap for Live Activity tap navigation. + // MARK: UISceneSession Lifecycle func application(_: UIApplication, willFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { diff --git a/LoopFollow/Application/SceneDelegate.swift b/LoopFollow/Application/SceneDelegate.swift index b15fb0bd5..a8fbb236f 100644 --- a/LoopFollow/Application/SceneDelegate.swift +++ b/LoopFollow/Application/SceneDelegate.swift @@ -32,6 +32,24 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { func sceneDidBecomeActive(_: UIScene) { // Called when the scene has moved from an inactive state to an active state. // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. + if pendingLATapNavigation { + pendingLATapNavigation = false + NotificationCenter.default.post(name: .liveActivityDidForeground, object: nil) + } + } + + /// Set when loopfollow://la-tap arrives before the scene is fully active. + /// Consumed in sceneDidBecomeActive once the view hierarchy is restored. + private var pendingLATapNavigation = false + + func scene(_: UIScene, openURLContexts URLContexts: Set) { + guard URLContexts.contains(where: { $0.url.scheme == "loopfollow" && $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) { diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 1313739f0..41f129c60 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -20,9 +20,73 @@ final class LiveActivityManager { 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 + ) + } + + /// 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 } + Task { @MainActor in + self.startFromCurrentState() + } } @objc private func handleForeground() { + guard Storage.shared.laEnabled.value else { return } LogManager.shared.log(category: .general, message: "[LA] foreground notification received, laRenewalFailed=\(Storage.shared.laRenewalFailed.value)") guard Storage.shared.laRenewalFailed.value else { return } @@ -75,6 +139,11 @@ final class LiveActivityManager { private var pushToken: String? private var tokenObservationTask: Task? private var refreshWorkItem: DispatchWorkItem? + /// Set when the user manually swipes away the LA. Blocks auto-restart until + /// an explicit user action (Restart button, App Intent) clears it. + /// In-memory only — resets to false on app relaunch, so a kill + relaunch + /// starts fresh as expected. + private var dismissedByUser = false // MARK: - Public API @@ -125,6 +194,22 @@ final class LiveActivityManager { } } + /// Called from applicationWillTerminate. Ends the LA synchronously (blocking + /// up to 3 s) so it clears from the lock screen before the process exits. + /// Does not clear laEnabled — the user's preference is preserved for relaunch. + func endOnTerminate() { + guard let activity = current else { return } + current = nil + Storage.shared.laRenewBy.value = 0 + let semaphore = DispatchSemaphore(value: 0) + Task.detached { + await activity.end(nil, dismissalPolicy: .immediate) + semaphore.signal() + } + _ = semaphore.wait(timeout: .now() + 3) + LogManager.shared.log(category: .general, message: "[LA] ended on app terminate") + } + func end(dismissalPolicy: ActivityUIDismissalPolicy = .default) { updateTask?.cancel() updateTask = nil @@ -161,7 +246,33 @@ final class LiveActivityManager { } } + /// 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 + current = nil + updateTask?.cancel(); updateTask = nil + tokenObservationTask?.cancel(); tokenObservationTask = nil + stateObserverTask?.cancel(); stateObserverTask = nil + pushToken = nil + Task { + for activity in Activity.activities { + await activity.end(nil, dismissalPolicy: .immediate) + } + await MainActor.run { + self.startFromCurrentState() + LogManager.shared.log(category: .general, message: "[LA] forceRestart: Live Activity restarted") + } + } + } + func startFromCurrentState() { + guard Storage.shared.laEnabled.value, !dismissedByUser else { return } endOrphanedActivities() let provider = StorageCurrentGlucoseStateProvider() if let snapshot = GlucoseSnapshotBuilder.build(from: provider) { @@ -175,6 +286,7 @@ final class LiveActivityManager { } func refreshFromCurrentState(reason: String) { + guard Storage.shared.laEnabled.value, !dismissedByUser else { return } refreshWorkItem?.cancel() let workItem = DispatchWorkItem { [weak self] in self?.performRefresh(reason: reason) @@ -422,8 +534,16 @@ final class LiveActivityManager { if state == .ended || state == .dismissed { if current?.id == activity.id { current = nil + Storage.shared.laRenewBy.value = 0 LogManager.shared.log(category: .general, message: "Live Activity cleared id=\(activity.id)", isDebug: true) } + if state == .dismissed { + // User manually swiped away the LA. Block auto-restart until + // the user explicitly restarts via button or App Intent. + // laEnabled is left true — the user's preference is preserved. + dismissedByUser = true + LogManager.shared.log(category: .general, message: "Live Activity dismissed by user — auto-restart blocked until explicit restart") + } } } } @@ -431,3 +551,9 @@ final class LiveActivityManager { } #endif + +extension Notification.Name { + /// Posted when the user taps the Live Activity or Dynamic Island. + /// Observers navigate to the Home or Snoozer tab as appropriate. + static let liveActivityDidForeground = Notification.Name("liveActivityDidForeground") +} diff --git a/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift b/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift new file mode 100644 index 000000000..9e3179244 --- /dev/null +++ b/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift @@ -0,0 +1,41 @@ +// LoopFollow +// RestartLiveActivityIntent.swift + +import AppIntents +import UIKit + +@available(iOS 16.4, *) +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: "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.") + } + + await MainActor.run { LiveActivityManager.shared.forceRestart() } + + return .result(dialog: "Live Activity restarted.") + } +} + +@available(iOS 16.4, *) +struct LoopFollowAppShortcuts: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: RestartLiveActivityIntent(), + phrases: ["Restart Live Activity in \(.applicationName)"], + shortTitle: "Restart Live Activity", + systemImageName: "dot.radiowaves.left.and.right" + ) + } +} diff --git a/LoopFollow/LiveActivitySettingsView.swift b/LoopFollow/LiveActivitySettingsView.swift new file mode 100644 index 000000000..0a29d702a --- /dev/null +++ b/LoopFollow/LiveActivitySettingsView.swift @@ -0,0 +1,44 @@ +// 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.forceRestart() + } else { + LiveActivityManager.shared.end(dismissalPolicy: .immediate) + } + } + .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) + .navigationTitle("Live Activity") + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/LoopFollow/Settings/LiveActivitySettingsView.swift b/LoopFollow/Settings/LiveActivitySettingsView.swift new file mode 100644 index 000000000..bfe39b3ee --- /dev/null +++ b/LoopFollow/Settings/LiveActivitySettingsView.swift @@ -0,0 +1,42 @@ +// LoopFollow +// LiveActivitySettingsView.swift + +import SwiftUI + +struct LiveActivitySettingsView: View { + @State private var laEnabled: Bool = Storage.shared.laEnabled.value + @State private var restartConfirmed = false + + var body: some View { + Form { + Section(header: Text("Live Activity")) { + Toggle("Enable Live Activity", isOn: $laEnabled) + } + + if laEnabled { + Section { + Button(restartConfirmed ? "Live Activity Restarted" : "Restart Live Activity") { + LiveActivityManager.shared.forceRestart() + restartConfirmed = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + restartConfirmed = false + } + } + .disabled(restartConfirmed) + } + } + } + .onReceive(Storage.shared.laEnabled.$value) { newValue in + if newValue != laEnabled { laEnabled = newValue } + } + .onChange(of: laEnabled) { newValue in + Storage.shared.laEnabled.value = newValue + if !newValue { + LiveActivityManager.shared.end(dismissalPolicy: .immediate) + } + } + .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) + .navigationTitle("Live Activity") + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/LoopFollow/Settings/SettingsMenuView.swift b/LoopFollow/Settings/SettingsMenuView.swift index 1ddcffc77..b57d00503 100644 --- a/LoopFollow/Settings/SettingsMenuView.swift +++ b/LoopFollow/Settings/SettingsMenuView.swift @@ -66,6 +66,12 @@ struct SettingsMenuView: View { 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: "Information Display Settings", icon: "info.circle") @@ -245,6 +251,7 @@ private enum Sheet: Hashable, Identifiable { case infoDisplay case alarmSettings case apn + case liveActivity case remote case importExport case calendar, contact @@ -265,6 +272,7 @@ private enum Sheet: Hashable, Identifiable { case .infoDisplay: InfoDisplaySettingsView(viewModel: .init()) case .alarmSettings: AlarmSettingsView() case .apn: APNSettingsView() + case .liveActivity: LiveActivitySettingsView() case .remote: RemoteSettingsView(viewModel: .init()) case .importExport: ImportExportSettingsView() case .calendar: CalendarSettingsView() diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 7de9ac7e7..141293e7c 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -91,7 +91,8 @@ class Storage { var lastCOB = StorageValue(key: "lastCOB", defaultValue: nil) var projectedBgMgdl = StorageValue(key: "projectedBgMgdl", defaultValue: nil) - // Live Activity renewal + // Live Activity + var laEnabled = StorageValue(key: "laEnabled", defaultValue: false) var laRenewBy = StorageValue(key: "laRenewBy", defaultValue: 0) var laRenewalFailed = StorageValue(key: "laRenewalFailed", defaultValue: false) diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 270b9be87..516b58e3c 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -206,6 +206,7 @@ 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) + notificationCenter.addObserver(self, selector: #selector(navigateOnLAForeground), name: .liveActivityDidForeground, object: nil) // Setup the Graph if firstGraphLoad { @@ -682,6 +683,27 @@ 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 } diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 2ef72f6fe..294ba8645 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -15,23 +15,30 @@ struct LoopFollowLiveActivityWidget: Widget { .activitySystemActionForegroundColor(.white) .activityBackgroundTint(LAColors.backgroundTint(for: context.state.snapshot)) .applyActivityContentMarginsFixIfAvailable() + .widgetURL(URL(string: "loopfollow://la-tap")!) } dynamicIsland: { context in // DYNAMIC ISLAND UI DynamicIsland { DynamicIslandExpandedRegion(.leading) { - DynamicIslandLeadingView(snapshot: context.state.snapshot) - .id(context.state.seq) - .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) + Link(destination: URL(string: "loopfollow://la-tap")!) { + DynamicIslandLeadingView(snapshot: context.state.snapshot) + .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) + } + .id(context.state.seq) } DynamicIslandExpandedRegion(.trailing) { - DynamicIslandTrailingView(snapshot: context.state.snapshot) - .id(context.state.seq) - .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) + Link(destination: URL(string: "loopfollow://la-tap")!) { + DynamicIslandTrailingView(snapshot: context.state.snapshot) + .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) + } + .id(context.state.seq) } DynamicIslandExpandedRegion(.bottom) { - DynamicIslandBottomView(snapshot: context.state.snapshot) - .id(context.state.seq) - .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay, showText: true)) + Link(destination: URL(string: "loopfollow://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) @@ -193,30 +200,31 @@ private struct DynamicIslandLeadingView: View { let snapshot: GlucoseSnapshot var body: some View { if snapshot.isNotLooping { - VStack(alignment: .leading, spacing: 2) { - Text("⚠️ Not Looping") - .font(.system(size: 20, weight: .heavy, design: .rounded)) - .foregroundStyle(.white) - .tracking(1.0) - .lineLimit(1) - .minimumScaleFactor(0.7) - } + Text("⚠️ Not Looping") + .font(.system(size: 20, weight: .heavy, design: .rounded)) + .foregroundStyle(.white) + .tracking(1.0) + .lineLimit(1) + .minimumScaleFactor(0.7) } else { VStack(alignment: .leading, spacing: 2) { - HStack(alignment: .firstTextBaseline, spacing: 6) { - Text(LAFormat.glucose(snapshot)) - .font(.system(size: 28, weight: .bold, design: .rounded)) - .monospacedDigit() - .foregroundStyle(.white) + Text(LAFormat.glucose(snapshot)) + .font(.system(size: 28, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white) + HStack(spacing: 5) { Text(LAFormat.trendArrow(snapshot)) - .font(.system(size: 16, weight: .semibold, design: .rounded)) - .foregroundStyle(.white.opacity(0.95)) - .padding(.top, 2) + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.9)) + Text(LAFormat.delta(snapshot)) + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.9)) + Text("Proj: \(LAFormat.projected(snapshot))") + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.9)) } - Text(LAFormat.delta(snapshot)) - .font(.system(size: 14, weight: .semibold, design: .rounded)) - .monospacedDigit() - .foregroundStyle(.white.opacity(0.9)) } } } @@ -230,14 +238,16 @@ private struct DynamicIslandTrailingView: View { EmptyView() } else { VStack(alignment: .trailing, spacing: 3) { - Text("Upd \(LAFormat.updated(snapshot))") - .font(.system(size: 12, weight: .semibold, design: .rounded)) - .foregroundStyle(.white.opacity(0.85)) - Text("Proj \(LAFormat.projected(snapshot))") + Text("IOB \(LAFormat.iob(snapshot))") + .font(.system(size: 13, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.95)) + Text("COB \(LAFormat.cob(snapshot))") .font(.system(size: 13, weight: .bold, design: .rounded)) .monospacedDigit() .foregroundStyle(.white.opacity(0.95)) } + .padding(.trailing, 6) } } } @@ -253,14 +263,11 @@ private struct DynamicIslandBottomView: View { .lineLimit(1) .minimumScaleFactor(0.75) } else { - HStack(spacing: 14) { - Text("IOB \(LAFormat.iob(snapshot))") - Text("COB \(LAFormat.cob(snapshot))") - } - .font(.system(size: 13, weight: .semibold, design: .rounded)) - .foregroundStyle(.white.opacity(0.92)) - .lineLimit(1) - .minimumScaleFactor(0.85) + Text("Updated at: \(LAFormat.updated(snapshot))") + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.92)) + .lineLimit(1) + .minimumScaleFactor(0.85) } } } @@ -276,8 +283,9 @@ private struct DynamicIslandCompactTrailingView: View { .lineLimit(1) .minimumScaleFactor(0.7) } else { - Text(LAFormat.trendArrow(snapshot)) + Text(LAFormat.delta(snapshot)) .font(.system(size: 14, weight: .semibold, design: .rounded)) + .monospacedDigit() .foregroundStyle(.white.opacity(0.95)) } } diff --git a/RestartLiveActivityIntent.swift b/RestartLiveActivityIntent.swift new file mode 100644 index 000000000..c594d5fa9 --- /dev/null +++ b/RestartLiveActivityIntent.swift @@ -0,0 +1,45 @@ +// LoopFollow +// RestartLiveActivityIntent.swift + +import AppIntents +import UIKit + +@available(iOS 16.4, *) +struct RestartLiveActivityIntent: AppIntent, ForegroundContinuableIntent { + static var title: LocalizedStringResource = "Restart Live Activity" + static var description = IntentDescription("Starts or restarts the LoopFollow Live Activity.") + + func perform() async throws -> some IntentResult & ProvidesDialog { + Storage.shared.laEnabled.value = true + + let keyId = Storage.shared.lfKeyId.value + let apnsKey = Storage.shared.lfApnsKey.value + + if keyId.isEmpty || apnsKey.isEmpty { + if let url = URL(string: "loopfollow://settings/live-activity") { + await MainActor.run { UIApplication.shared.open(url) } + } + return .result(dialog: "Please enter your APNs credentials in LoopFollow settings to use the Live Activity.") + } + + if #available(iOS 26.0, *) { + try await continueInForeground() + } + + await MainActor.run { LiveActivityManager.shared.forceRestart() } + + return .result(dialog: "Live Activity restarted.") + } +} + +@available(iOS 16.4, *) +struct LoopFollowAppShortcuts: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: RestartLiveActivityIntent(), + phrases: ["Restart Live Activity in \(.applicationName)"], + shortTitle: "Restart Live Activity", + systemImageName: "dot.radiowaves.left.and.right" + ) + } +} From ad647e58b4e9e69cd7ecd4be25165636f503a3c8 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sun, 15 Mar 2026 13:16:02 -0400 Subject: [PATCH 39/86] feat: configurable LA grid slots + full InfoType snapshot coverage Lock screen layout: - Left column: glucose + trend arrow, delta below (replaces "Last Update:") - Right grid: 4 configurable slots read from LAAppGroupSettings (defaults: IOB/COB/Proj/empty) - Footer: centered HH:MM update time at reduced opacity Slot configuration: - LiveActivitySlotOption enum with all 22 InfoType-aligned cases - LAAppGroupSettings.setSlots() / slots() persisted to App Group UserDefaults - Uniqueness enforced: selecting an option clears it from any other slot - Settings UI: "Grid slots" section with 4 pickers in LiveActivitySettingsView - Changes take effect immediately (refreshFromCurrentState called on save) GlucoseSnapshot extended with 19 new fields covering all InfoType items: override, recBolus, battery, pumpBattery, basalRate, pumpReservoirU, autosens, tdd, targetLowMgdl/High, isfMgdlPerU, carbRatio, carbsToday, profileName, sageInsertTime, cageInsertTime, iageInsertTime, minBgMgdl/Max Storage.swift: 13 new UserDefaults-backed fields for the above metrics Controllers updated to write new Storage fields on each data fetch: Basals, DeviceStatus, DeviceStatusLoop, DeviceStatusOpenAPS, Carbs, Profile, IAge Co-Authored-By: Claude Sonnet 4.6 --- .../Controllers/Nightscout/DeviceStatus.swift | 2 + .../Nightscout/DeviceStatusLoop.swift | 6 + .../Nightscout/DeviceStatusOpenAPS.swift | 15 ++ LoopFollow/Controllers/Nightscout/IAge.swift | 1 + .../Controllers/Nightscout/Profile.swift | 1 + .../Nightscout/Treatments/Basals.swift | 1 + .../Nightscout/Treatments/Carbs.swift | 1 + LoopFollow/LiveActivity/GlucoseSnapshot.swift | 142 +++++++++++- .../LiveActivity/GlucoseSnapshotBuilder.swift | 20 ++ .../LiveActivity/LAAppGroupSettings.swift | 149 +++++++++++- .../LiveActivity/LiveActivitySlotConfig.swift | 44 ++++ LoopFollow/LiveActivitySettingsView.swift | 31 +++ .../Settings/LiveActivitySettingsView.swift | 42 ---- LoopFollow/Storage/Storage.swift | 15 ++ .../LoopFollowLiveActivity.swift | 216 +++++++++++++++--- 15 files changed, 605 insertions(+), 81 deletions(-) create mode 100644 LoopFollow/LiveActivity/LiveActivitySlotConfig.swift delete mode 100644 LoopFollow/Settings/LiveActivitySettingsView.swift diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift index b7f88634e..ae3967b3e 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift @@ -132,9 +132,11 @@ extension MainViewController { if let reservoirData = lastPumpRecord["reservoir"] as? Double { latestPumpVolume = reservoirData infoManager.updateInfoData(type: .pump, value: String(format: "%.0f", reservoirData) + "U") + Storage.shared.lastPumpReservoirU.value = reservoirData } else { latestPumpVolume = 50.0 infoManager.updateInfoData(type: .pump, value: "50+U") + Storage.shared.lastPumpReservoirU.value = nil } } diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift index 650092237..89c4163cd 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift @@ -30,12 +30,14 @@ extension MainViewController { let profileISF = profileManager.currentISF() if let profileISF = profileISF { infoManager.updateInfoData(type: .isf, value: profileISF) + Storage.shared.lastIsfMgdlPerU.value = profileISF.doubleValue(for: .milligramsPerDeciliter) } // Carb Ratio (CR) let profileCR = profileManager.currentCarbRatio() if let profileCR = profileCR { infoManager.updateInfoData(type: .carbRatio, value: profileCR) + Storage.shared.lastCarbRatio.value = profileCR } // Target @@ -47,6 +49,8 @@ extension MainViewController { } else if let profileTargetLow = profileTargetLow { infoManager.updateInfoData(type: .target, value: profileTargetLow) } + Storage.shared.lastTargetLowMgdl.value = profileTargetLow?.doubleValue(for: .milligramsPerDeciliter) + Storage.shared.lastTargetHighMgdl.value = profileTargetHigh?.doubleValue(for: .milligramsPerDeciliter) // IOB if let insulinMetric = InsulinMetric(from: lastLoopRecord["iob"], key: "iob") { @@ -87,6 +91,8 @@ extension MainViewController { let formattedMax = Localizer.toDisplayUnits(String(predMax)) let value = "\(formattedMin)/\(formattedMax)" infoManager.updateInfoData(type: .minMax, value: value) + Storage.shared.lastMinBgMgdl.value = predMin + Storage.shared.lastMaxBgMgdl.value = predMax } updatePredictionGraph() diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift index fc3b3c5b5..20827c253 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift @@ -37,8 +37,10 @@ extension MainViewController { } if let profileISF = profileISF, let enactedISF = enactedISF, profileISF != enactedISF { infoManager.updateInfoData(type: .isf, firstValue: profileISF, secondValue: enactedISF, separator: .arrow) + Storage.shared.lastIsfMgdlPerU.value = enactedISF.doubleValue(for: .milligramsPerDeciliter) } else if let profileISF = profileISF { infoManager.updateInfoData(type: .isf, value: profileISF) + Storage.shared.lastIsfMgdlPerU.value = profileISF.doubleValue(for: .milligramsPerDeciliter) } // Carb Ratio (CR) @@ -57,8 +59,10 @@ extension MainViewController { if let profileCR = profileCR, let enactedCR = enactedCR, profileCR != enactedCR { infoManager.updateInfoData(type: .carbRatio, value: profileCR, enactedValue: enactedCR, separator: .arrow) + Storage.shared.lastCarbRatio.value = enactedCR } else if let profileCR = profileCR { infoManager.updateInfoData(type: .carbRatio, value: profileCR) + Storage.shared.lastCarbRatio.value = profileCR } // IOB @@ -98,6 +102,7 @@ extension MainViewController { if let sens = enactedOrSuggested["sensitivityRatio"] as? Double { let formattedSens = String(format: "%.0f", sens * 100.0) + "%" infoManager.updateInfoData(type: .autosens, value: formattedSens) + Storage.shared.lastAutosens.value = sens } // Recommended Bolus @@ -136,11 +141,19 @@ extension MainViewController { } else { infoManager.updateInfoData(type: .target, value: profileTargetHigh) } + let effectiveMgdl = enactedTarget.doubleValue(for: .milligramsPerDeciliter) + Storage.shared.lastTargetLowMgdl.value = effectiveMgdl + Storage.shared.lastTargetHighMgdl.value = effectiveMgdl + } else if let profileTargetHigh = profileTargetHigh { + let profileMgdl = profileTargetHigh.doubleValue(for: .milligramsPerDeciliter) + Storage.shared.lastTargetLowMgdl.value = profileMgdl + Storage.shared.lastTargetHighMgdl.value = profileMgdl } // TDD if let tddMetric = InsulinMetric(from: enactedOrSuggested, key: "TDD") { infoManager.updateInfoData(type: .tdd, value: tddMetric) + Storage.shared.lastTdd.value = tddMetric.value } let predBGsData: [String: AnyObject]? = { @@ -201,6 +214,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") } diff --git a/LoopFollow/Controllers/Nightscout/IAge.swift b/LoopFollow/Controllers/Nightscout/IAge.swift index 69a683c57..50e9bd592 100644 --- a/LoopFollow/Controllers/Nightscout/IAge.swift +++ b/LoopFollow/Controllers/Nightscout/IAge.swift @@ -45,6 +45,7 @@ extension MainViewController { .withColonSeparatorInTime] if let iageTime = formatter.date(from: (lastIageString as! String))?.timeIntervalSince1970 { + Storage.shared.iageInsertTime.value = iageTime let now = dateTimeUtils.getNowTimeIntervalUTC() let secondsAgo = now - iageTime diff --git a/LoopFollow/Controllers/Nightscout/Profile.swift b/LoopFollow/Controllers/Nightscout/Profile.swift index c00ac195e..f76c74a4c 100644 --- a/LoopFollow/Controllers/Nightscout/Profile.swift +++ b/LoopFollow/Controllers/Nightscout/Profile.swift @@ -23,6 +23,7 @@ extension MainViewController { } profileManager.loadProfile(from: profileData) infoManager.updateInfoData(type: .profile, value: profileData.defaultProfile) + Storage.shared.lastProfileName.value = profileData.defaultProfile // Mark profile data as loaded for initial loading state markDataLoaded("profile") diff --git a/LoopFollow/Controllers/Nightscout/Treatments/Basals.swift b/LoopFollow/Controllers/Nightscout/Treatments/Basals.swift index 5ee0891fe..405281926 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/Basals.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/Basals.swift @@ -147,5 +147,6 @@ extension MainViewController { latestBasal = "\(profileBasal) → \(latestBasal)" } infoManager.updateInfoData(type: .basal, value: latestBasal) + Storage.shared.lastBasal.value = latestBasal } } diff --git a/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift b/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift index baa4af7a1..5d75adb2d 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift @@ -82,5 +82,6 @@ extension MainViewController { let resultString = String(format: "%.0f", totalCarbs) infoManager.updateInfoData(type: .carbsToday, value: resultString) + Storage.shared.lastCarbsToday.value = totalCarbs } } diff --git a/LoopFollow/LiveActivity/GlucoseSnapshot.swift b/LoopFollow/LiveActivity/GlucoseSnapshot.swift index 1e573cba6..4e914ab7e 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshot.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshot.swift @@ -39,6 +39,65 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { /// 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; @@ -64,6 +123,25 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { 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 @@ -75,6 +153,25 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { 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 @@ -89,13 +186,37 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { 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) } private enum CodingKeys: String, CodingKey { - case glucose, delta, trend, updatedAt, iob, cob, projected, unit, isNotLooping, showRenewalOverlay + 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: - Codable @@ -109,6 +230,25 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { 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 diff --git a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift index f6a1d7208..dd845b116 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift @@ -70,6 +70,7 @@ enum GlucoseSnapshotBuilder { isDebug: true ) + let profileNameRaw = Storage.shared.lastProfileName.value return GlucoseSnapshot( glucose: glucoseMgdl, delta: deltaMgdl, @@ -78,6 +79,25 @@ enum GlucoseSnapshotBuilder { iob: provider.iob, cob: provider.cob, projected: provider.projectedMgdl, + override: Observable.shared.override.value, + recBolus: Observable.shared.deviceRecBolus.value, + battery: Observable.shared.deviceBatteryLevel.value, + pumpBattery: Observable.shared.pumpBatteryLevel.value, + basalRate: Storage.shared.lastBasal.value, + pumpReservoirU: Storage.shared.lastPumpReservoirU.value, + autosens: Storage.shared.lastAutosens.value, + tdd: Storage.shared.lastTdd.value, + targetLowMgdl: Storage.shared.lastTargetLowMgdl.value, + targetHighMgdl: Storage.shared.lastTargetHighMgdl.value, + isfMgdlPerU: Storage.shared.lastIsfMgdlPerU.value, + carbRatio: Storage.shared.lastCarbRatio.value, + carbsToday: Storage.shared.lastCarbsToday.value, + profileName: profileNameRaw.isEmpty ? nil : profileNameRaw, + sageInsertTime: Storage.shared.sageInsertTime.value, + cageInsertTime: Storage.shared.cageInsertTime.value, + iageInsertTime: Storage.shared.iageInsertTime.value, + minBgMgdl: Storage.shared.lastMinBgMgdl.value, + maxBgMgdl: Storage.shared.lastMaxBgMgdl.value, unit: preferredUnit, isNotLooping: isNotLooping, showRenewalOverlay: showRenewalOverlay diff --git a/LoopFollow/LiveActivity/LAAppGroupSettings.swift b/LoopFollow/LiveActivity/LAAppGroupSettings.swift index 7615b2cf7..2880c0efe 100644 --- a/LoopFollow/LiveActivity/LAAppGroupSettings.swift +++ b/LoopFollow/LiveActivity/LAAppGroupSettings.swift @@ -3,6 +3,129 @@ 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: return "Empty" + case .delta: return "Delta" + case .projectedBG: return "Projected BG" + case .minMax: return "Min/Max" + case .iob: return "IOB" + case .cob: return "COB" + case .recBolus: return "Rec. Bolus" + case .autosens: return "Autosens" + case .tdd: return "TDD" + case .basal: return "Basal" + case .pump: return "Pump" + case .pumpBattery: return "Pump Battery" + case .battery: return "Battery" + case .target: return "Target" + case .isf: return "ISF" + case .carbRatio: return "CR" + case .sage: return "SAGE" + case .cage: return "CAGE" + case .iage: return "IAGE" + case .carbsToday: return "Carbs today" + case .override: return "Override" + case .profile: return "Profile" + } + } + + /// Short label used inside the MetricBlock on the Live Activity card. + var gridLabel: String { + switch self { + case .none: return "" + case .delta: return "Delta" + case .projectedBG: return "Proj" + case .minMax: return "Min/Max" + case .iob: return "IOB" + case .cob: return "COB" + case .recBolus: return "Rec." + case .autosens: return "Sens" + case .tdd: return "TDD" + case .basal: return "Basal" + case .pump: return "Pump" + case .pumpBattery: return "Pump%" + case .battery: return "Bat." + case .target: return "Target" + case .isf: return "ISF" + case .carbRatio: return "CR" + case .sage: return "SAGE" + case .cage: return "CAGE" + case .iage: return "IAGE" + case .carbsToday: return "Carbs" + case .override: return "Ovrd" + case .profile: return "Prof" + } + } + + /// 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: return false + default: return true + } + } +} + +// MARK: - Default slot assignments + +struct 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 + + 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 @@ -11,24 +134,46 @@ enum LAAppGroupSettings { private enum Keys { static let lowLineMgdl = "la.lowLine.mgdl" static let highLineMgdl = "la.highLine.mgdl" + static let slots = "la.slots" } private static var defaults: UserDefaults? { UserDefaults(suiteName: AppGroupID.current()) } - // MARK: - Write (App) + // MARK: - Thresholds (Write) static func setThresholds(lowMgdl: Double, highMgdl: Double) { defaults?.set(lowMgdl, forKey: Keys.lowLineMgdl) defaults?.set(highMgdl, forKey: Keys.highLineMgdl) } - // MARK: - Read (Extension) + // 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 { $0.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 } + } } diff --git a/LoopFollow/LiveActivity/LiveActivitySlotConfig.swift b/LoopFollow/LiveActivity/LiveActivitySlotConfig.swift new file mode 100644 index 000000000..2b097a6b1 --- /dev/null +++ b/LoopFollow/LiveActivity/LiveActivitySlotConfig.swift @@ -0,0 +1,44 @@ +// LoopFollow +// LiveActivitySlotConfig.swift + +// MARK: - Information Display Settings audit +// +// LoopFollow exposes 20 items in Information Display Settings (InfoType.swift). +// The table below maps each item to its availability as a Live Activity grid slot. +// +// AVAILABLE NOW — value present in GlucoseSnapshot: +// Display name | InfoType case | Snapshot field | Optional (nil for Dexcom-only) +// ───────────────────────────────────────────────────────────────────────────────── +// IOB | .iob | snapshot.iob | YES +// COB | .cob | snapshot.cob | YES +// Projected BG | (none) | snapshot.projected | YES +// Delta | (none) | snapshot.delta | NO (always available) +// +// Note: "Updated" (InfoType.updated) is intentionally excluded — it is displayed +// in the card footer and is not a configurable slot. +// +// NOT YET AVAILABLE — requires adding fields to GlucoseSnapshot, GlucoseSnapshotBuilder, +// and the APNs payload before they can be offered as slot options: +// Display name | InfoType case | Source in app +// ───────────────────────────────────────────────────────────────────────────────── +// Basal | .basal | DeviceStatus basal rate +// Override | .override | DeviceStatus override name +// Battery | .battery | DeviceStatus CGM/device battery % +// Pump | .pump | DeviceStatus pump name / status +// Pump Battery | .pumpBattery | DeviceStatus pump battery % +// SAGE | .sage | DeviceStatus sensor age (hours) +// CAGE | .cage | DeviceStatus cannula age (hours) +// Rec. Bolus | .recBolus | DeviceStatus recommended bolus +// Min/Max | .minMax | Computed from recent BG history +// Carbs today | .carbsToday | Computed from COB history +// Autosens | .autosens | DeviceStatusOpenAPS autosens ratio +// Profile | .profile | DeviceStatus profile name +// Target | .target | DeviceStatus BG target +// ISF | .isf | DeviceStatus insulin sensitivity factor +// CR | .carbRatio | DeviceStatus carb ratio +// TDD | .tdd | DeviceStatus total daily dose +// IAGE | .iage | DeviceStatus insulin/pod age (hours) +// +// The LiveActivitySlotOption enum, LiveActivitySlotDefaults struct, and +// LAAppGroupSettings.setSlots() / slots() storage are defined in +// LAAppGroupSettings.swift (shared between app and extension targets). diff --git a/LoopFollow/LiveActivitySettingsView.swift b/LoopFollow/LiveActivitySettingsView.swift index 0a29d702a..99dbc13e6 100644 --- a/LoopFollow/LiveActivitySettingsView.swift +++ b/LoopFollow/LiveActivitySettingsView.swift @@ -6,6 +6,9 @@ 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() + + private let slotLabels = ["Top left", "Top right", "Bottom left", "Bottom right"] var body: some View { Form { @@ -25,6 +28,19 @@ struct LiveActivitySettingsView: View { .disabled(restartConfirmed) } } + + Section(header: Text("Grid slots")) { + 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) + } + } + } + } } .onReceive(Storage.shared.laEnabled.$value) { newValue in if newValue != laEnabled { laEnabled = newValue } @@ -41,4 +57,19 @@ struct LiveActivitySettingsView: View { .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) + Task { + await LiveActivityManager.shared.refreshFromCurrentState(reason: "slot config changed") + } + } } diff --git a/LoopFollow/Settings/LiveActivitySettingsView.swift b/LoopFollow/Settings/LiveActivitySettingsView.swift deleted file mode 100644 index bfe39b3ee..000000000 --- a/LoopFollow/Settings/LiveActivitySettingsView.swift +++ /dev/null @@ -1,42 +0,0 @@ -// LoopFollow -// LiveActivitySettingsView.swift - -import SwiftUI - -struct LiveActivitySettingsView: View { - @State private var laEnabled: Bool = Storage.shared.laEnabled.value - @State private var restartConfirmed = false - - var body: some View { - Form { - Section(header: Text("Live Activity")) { - Toggle("Enable Live Activity", isOn: $laEnabled) - } - - if laEnabled { - Section { - Button(restartConfirmed ? "Live Activity Restarted" : "Restart Live Activity") { - LiveActivityManager.shared.forceRestart() - restartConfirmed = true - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - restartConfirmed = false - } - } - .disabled(restartConfirmed) - } - } - } - .onReceive(Storage.shared.laEnabled.$value) { newValue in - if newValue != laEnabled { laEnabled = newValue } - } - .onChange(of: laEnabled) { newValue in - Storage.shared.laEnabled.value = newValue - if !newValue { - LiveActivityManager.shared.end(dismissalPolicy: .immediate) - } - } - .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) - .navigationTitle("Live Activity") - .navigationBarTitleDisplayMode(.inline) - } -} diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 141293e7c..7884e6589 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -91,6 +91,21 @@ class Storage { var lastCOB = StorageValue(key: "lastCOB", defaultValue: nil) var projectedBgMgdl = StorageValue(key: "projectedBgMgdl", defaultValue: nil) + // Live Activity extended InfoType data + var lastBasal = StorageValue(key: "lastBasal", defaultValue: "") + var lastPumpReservoirU = StorageValue(key: "lastPumpReservoirU", defaultValue: nil) + var lastAutosens = StorageValue(key: "lastAutosens", defaultValue: nil) + var lastTdd = StorageValue(key: "lastTdd", defaultValue: nil) + var lastTargetLowMgdl = StorageValue(key: "lastTargetLowMgdl", defaultValue: nil) + var lastTargetHighMgdl = StorageValue(key: "lastTargetHighMgdl", defaultValue: nil) + var lastIsfMgdlPerU = StorageValue(key: "lastIsfMgdlPerU", defaultValue: nil) + var lastCarbRatio = StorageValue(key: "lastCarbRatio", defaultValue: nil) + var lastCarbsToday = StorageValue(key: "lastCarbsToday", defaultValue: nil) + var lastProfileName = StorageValue(key: "lastProfileName", defaultValue: "") + var iageInsertTime = StorageValue(key: "iageInsertTime", defaultValue: 0) + var lastMinBgMgdl = StorageValue(key: "lastMinBgMgdl", defaultValue: nil) + var lastMaxBgMgdl = StorageValue(key: "lastMaxBgMgdl", defaultValue: nil) + // Live Activity var laEnabled = StorageValue(key: "laEnabled", defaultValue: false) var laRenewBy = StorageValue(key: "laRenewBy", defaultValue: 0) diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 294ba8645..9a108a21a 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -78,50 +78,62 @@ private struct LockScreenLiveActivityView: View { var body: some View { let s = state.snapshot + let slotConfig = LAAppGroupSettings.slots() + + VStack(spacing: 6) { + HStack(spacing: 12) { + // LEFT: Glucose + trend arrow, delta below + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(LAFormat.glucose(s)) + .font(.system(size: 46, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white) - HStack(spacing: 12) { - // LEFT: Glucose + trend, update time below - VStack(alignment: .leading, spacing: 4) { - HStack(alignment: .firstTextBaseline, spacing: 8) { - Text(LAFormat.glucose(s)) - .font(.system(size: 46, weight: .bold, design: .rounded)) - .monospacedDigit() - .foregroundStyle(.white) - - Text(LAFormat.trendArrow(s)) - .font(.system(size: 46, weight: .bold, design: .rounded)) - .foregroundStyle(.white.opacity(0.95)) - } + Text(LAFormat.trendArrow(s)) + .font(.system(size: 46, weight: .bold, design: .rounded)) + .foregroundStyle(.white.opacity(0.95)) + } - Text("Last Update: \(LAFormat.updated(s))") - .font(.system(size: 13, weight: .regular, design: .rounded)) - .foregroundStyle(.white.opacity(0.75)) - } - .frame(width: 168, alignment: .leading) - .layoutPriority(2) - - // Divider - Rectangle() - .fill(Color.white.opacity(0.20)) - .frame(width: 1) - .padding(.vertical, 8) - - // RIGHT: 2x2 grid — delta/proj | iob/cob - VStack(spacing: 10) { - HStack(spacing: 16) { - MetricBlock(label: "Delta", value: LAFormat.delta(s)) - MetricBlock(label: "IOB", value: LAFormat.iob(s)) + Text(LAFormat.delta(s)) + .font(.system(size: 15, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.80)) } - HStack(spacing: 16) { - MetricBlock(label: "Proj", value: LAFormat.projected(s)) - MetricBlock(label: "COB", value: LAFormat.cob(s)) + .frame(width: 168, alignment: .leading) + .layoutPriority(2) + + // Divider + Rectangle() + .fill(Color.white.opacity(0.20)) + .frame(width: 1) + .padding(.vertical, 8) + + // RIGHT: configurable 2×2 grid + VStack(spacing: 10) { + HStack(spacing: 16) { + SlotView(option: slotConfig[0], snapshot: s) + SlotView(option: slotConfig[1], snapshot: s) + } + HStack(spacing: 16) { + SlotView(option: slotConfig[2], snapshot: s) + SlotView(option: slotConfig[3], snapshot: s) + } } + .frame(maxWidth: .infinity, alignment: .trailing) } - .frame(maxWidth: .infinity, alignment: .trailing) + + // Footer: last update time + Text(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(.vertical, 12) + .padding(.top, 12) + .padding(.bottom, 8) .overlay( RoundedRectangle(cornerRadius: 16, style: .continuous) .stroke(Color.white.opacity(0.20), lineWidth: 1) @@ -193,6 +205,50 @@ private struct MetricBlock: View { } } +/// Renders one configurable slot in the lock screen 2×2 grid. +/// Shows nothing (invisible placeholder) when the slot option is `.none`. +private struct SlotView: View { + let option: LiveActivitySlotOption + let snapshot: GlucoseSnapshot + + var body: some View { + if option == .none { + // Invisible spacer — preserves grid alignment + Color.clear + .frame(width: 64, height: 36) + } else { + MetricBlock(label: option.gridLabel, value: value(for: option)) + } + } + + private func value(for option: LiveActivitySlotOption) -> String { + switch option { + case .none: return "" + case .delta: return LAFormat.delta(snapshot) + case .projectedBG: return LAFormat.projected(snapshot) + case .minMax: return LAFormat.minMax(snapshot) + case .iob: return LAFormat.iob(snapshot) + case .cob: return LAFormat.cob(snapshot) + case .recBolus: return LAFormat.recBolus(snapshot) + case .autosens: return LAFormat.autosens(snapshot) + case .tdd: return LAFormat.tdd(snapshot) + case .basal: return LAFormat.basal(snapshot) + case .pump: return LAFormat.pump(snapshot) + case .pumpBattery: return LAFormat.pumpBattery(snapshot) + case .battery: return LAFormat.battery(snapshot) + case .target: return LAFormat.target(snapshot) + case .isf: return LAFormat.isf(snapshot) + case .carbRatio: return LAFormat.carbRatio(snapshot) + case .sage: return LAFormat.age(insertTime: snapshot.sageInsertTime) + case .cage: return LAFormat.age(insertTime: snapshot.cageInsertTime) + case .iage: return LAFormat.age(insertTime: snapshot.iageInsertTime) + case .carbsToday: return LAFormat.carbsToday(snapshot) + case .override: return LAFormat.override(snapshot) + case .profile: return LAFormat.profileName(snapshot) + } + } +} + // MARK: - Dynamic Island @available(iOS 16.1, *) @@ -409,6 +465,94 @@ private enum LAFormat { return formatGlucoseValue(v, unit: s.unit) } + // MARK: Extended InfoType formatters + + private static let ageFormatter: DateComponentsFormatter = { + let f = DateComponentsFormatter() + f.unitsStyle = .positional + f.allowedUnits = [.day, .hour] + f.zeroFormattingBehavior = [.pad] + return f + }() + + /// Formats an insert-time epoch into "D:HH" age string. Returns "—" if time is 0. + 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 ?? "—" + } + // MARK: Update time private static let hhmmFormatter: DateFormatter = { From 0401c48e30635c7f1004f445c11d9022d716b85c Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sun, 15 Mar 2026 13:32:34 -0400 Subject: [PATCH 40/86] fix: label delta and footer on lock screen LA card Co-Authored-By: Claude Sonnet 4.6 --- LoopFollowLAExtension/LoopFollowLiveActivity.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 9a108a21a..d0f351611 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -95,7 +95,7 @@ private struct LockScreenLiveActivityView: View { .foregroundStyle(.white.opacity(0.95)) } - Text(LAFormat.delta(s)) + Text("Delta: \(LAFormat.delta(s))") .font(.system(size: 15, weight: .semibold, design: .rounded)) .monospacedDigit() .foregroundStyle(.white.opacity(0.80)) @@ -124,7 +124,7 @@ private struct LockScreenLiveActivityView: View { } // Footer: last update time - Text(LAFormat.updated(s)) + Text("Last Update: \(LAFormat.updated(s))") .font(.system(size: 11, weight: .regular, design: .rounded)) .monospacedDigit() .foregroundStyle(.white.opacity(0.65)) From f42e502b0c09938901ea7f29b2235aae273b39a1 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sun, 15 Mar 2026 14:24:54 -0400 Subject: [PATCH 41/86] docs: add PR description for configurable LA grid slots Co-Authored-By: Claude Sonnet 4.6 --- docs/PR_configurable_slots.md | 117 ++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 docs/PR_configurable_slots.md diff --git a/docs/PR_configurable_slots.md b/docs/PR_configurable_slots.md new file mode 100644 index 000000000..46db92cf2 --- /dev/null +++ b/docs/PR_configurable_slots.md @@ -0,0 +1,117 @@ +# Configurable Live Activity Grid Slots + Full InfoType Snapshot Coverage + +## Summary + +- Replace the hardcoded 2×2 grid on the Live Activity lock screen with four fully configurable slots, each independently selectable from all 20+ available metrics via a new Settings picker UI +- Extend `GlucoseSnapshot` with 19 new fields covering all InfoType items (basal, pump, autosens, TDD, ISF, CR, target, ages, carbs today, profile name, min/max BG, override) +- Wire up all downstream data sources (controllers + Storage) so every new field is populated on each data refresh cycle +- Redesign the lock screen layout: glucose + trend arrow left-aligned, delta below the BG value, configurable grid on the right, "Last Update: HH:MM" footer centered at the bottom + +--- + +## Changes + +### Lock screen layout redesign (`LoopFollowLAExtension/LoopFollowLiveActivity.swift`) + +The previous layout had glucose + a fixed four-slot grid side by side with no clear hierarchy. The new layout: + +- **Left column:** Large glucose value + trend arrow (`.system(size: 46)`), with `Delta: ±X` below in a smaller semibold font +- **Right column:** Configurable 2×2 grid — slot content driven by `LAAppGroupSettings.slots()`, read from the shared App Group container +- **Footer:** `Last Update: HH:MM` centered below both columns + +A new `SlotView` struct handles dispatch for all 22 slot cases. Fifteen new `LAFormat` static methods were added to format each metric consistently (locale-aware number formatting, unit suffix, graceful `—` for nil/unavailable values). + +### Configurable slot picker UI (`LoopFollow/LiveActivitySettingsView.swift`) + +A new **Grid slots** section appears in the Live Activity settings screen with four pickers labelled Top left, Top right, Bottom left, Bottom right. Selecting a metric for one slot automatically clears that metric from any other slot (uniqueness enforced). Changes take effect immediately — `LiveActivityManager.shared.refreshFromCurrentState(reason: "slot config changed")` is called on every picker change. + +### Slot type definitions (`LoopFollow/LiveActivity/LAAppGroupSettings.swift`) + +- New `LiveActivitySlotOption` enum (22 cases: `none`, `delta`, `projectedBG`, `minMax`, `iob`, `cob`, `recBolus`, `autosens`, `tdd`, `basal`, `pump`, `pumpBattery`, `battery`, `target`, `isf`, `carbRatio`, `sage`, `cage`, `iage`, `carbsToday`, `override`, `profile`) +- `displayName` (used in Settings picker) and `gridLabel` (used inside the MetricBlock on the LA card) computed properties +- `isOptional` flag — `true` for metrics that may be absent for Dexcom-only users; the widget renders `—` in those cases +- `LiveActivitySlotDefaults` struct with out-of-the-box defaults: IOB / COB / Projected BG / Empty +- `LAAppGroupSettings.setSlots()` / `slots()` — persist and read the 4-slot configuration via the shared App Group `UserDefaults` container, so the extension always sees the current user selection + +All of this is placed in `LAAppGroupSettings.swift` because that file is already compiled into both the app target and the extension target. No new Xcode project file membership was required. + +### Extended GlucoseSnapshot (`LoopFollow/LiveActivity/GlucoseSnapshot.swift`) + +Added 19 new stored properties. All are optional or have safe defaults so decoding an older snapshot (e.g. from a push that arrived before the app updated) never crashes: + +| Property | Type | Source | +|---|---|---| +| `override` | `String?` | `Observable.shared.override` | +| `recBolus` | `Double?` | `Observable.shared.recBolus` | +| `battery` | `Double?` | `Observable.shared.battery` | +| `pumpBattery` | `Double?` | `Observable.shared.pumpBattery` | +| `basalRate` | `String` | `Storage.shared.lastBasal` | +| `pumpReservoirU` | `Double?` | `Storage.shared.lastPumpReservoirU` | +| `autosens` | `Double?` | `Storage.shared.lastAutosens` | +| `tdd` | `Double?` | `Storage.shared.lastTdd` | +| `targetLowMgdl` | `Double?` | `Storage.shared.lastTargetLowMgdl` | +| `targetHighMgdl` | `Double?` | `Storage.shared.lastTargetHighMgdl` | +| `isfMgdlPerU` | `Double?` | `Storage.shared.lastIsfMgdlPerU` | +| `carbRatio` | `Double?` | `Storage.shared.lastCarbRatio` | +| `carbsToday` | `Double?` | `Storage.shared.lastCarbsToday` | +| `profileName` | `String?` | `Storage.shared.lastProfileName` | +| `sageInsertTime` | `TimeInterval` | `Storage.shared.sageInsertTime` | +| `cageInsertTime` | `TimeInterval` | `Storage.shared.cageInsertTime` | +| `iageInsertTime` | `TimeInterval` | `Storage.shared.iageInsertTime` | +| `minBgMgdl` | `Double?` | `Storage.shared.lastMinBgMgdl` | +| `maxBgMgdl` | `Double?` | `Storage.shared.lastMaxBgMgdl` | + +All glucose-valued fields are stored in **mg/dL**; conversion to mmol/L happens at display time in `LAFormat`, consistent with the existing snapshot design. + +Age-based fields (SAGE, CAGE, IAGE) are stored as Unix epoch `TimeInterval` (0 = not set). `LAFormat.age(insertTime:)` computes the human-readable age string at render time using `DateComponentsFormatter` with `.positional` style and `[.day, .hour]` units. + +### GlucoseSnapshotBuilder (`LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift`) + +Extended `build(from:)` to populate all 19 new fields from `Observable.shared` and `Storage.shared`. + +### Storage additions (`LoopFollow/Storage/Storage.swift`) + +13 new `StorageValue`-backed fields in a dedicated "Live Activity extended InfoType data" section: + +``` +lastBasal, lastPumpReservoirU, lastAutosens, lastTdd, +lastTargetLowMgdl, lastTargetHighMgdl, lastIsfMgdlPerU, +lastCarbRatio, lastCarbsToday, lastProfileName, +iageInsertTime, lastMinBgMgdl, lastMaxBgMgdl +``` + +### Controller writes + +Each data-fetching controller now writes one additional `Storage.shared` value alongside its existing `infoManager.updateInfoData` call. No existing logic was changed — these are purely additive writes: + +| Controller | Field written | +|---|---| +| `Basals.swift` | `lastBasal` | +| `DeviceStatus.swift` | `lastPumpReservoirU` | +| `DeviceStatusLoop.swift` | `lastIsfMgdlPerU`, `lastCarbRatio`, `lastTargetLowMgdl`, `lastTargetHighMgdl`, `lastMinBgMgdl`, `lastMaxBgMgdl` | +| `DeviceStatusOpenAPS.swift` | `lastAutosens`, `lastTdd`, `lastIsfMgdlPerU`, `lastCarbRatio`, `lastTargetLowMgdl`, `lastTargetHighMgdl`, `lastMinBgMgdl`, `lastMaxBgMgdl` | +| `Carbs.swift` | `lastCarbsToday` | +| `Profile.swift` | `lastProfileName` | +| `IAge.swift` | `iageInsertTime` | + +--- + +## What was not changed + +- APNs push infrastructure — no changes to `APNSClient`, `JWTManager`, or the push payload format beyond what was already present +- Dynamic Island layout — compact, expanded, and minimal presentations are unchanged +- Threshold-driven background color logic — unchanged +- "Not Looping" banner logic — unchanged +- All existing `LAFormat` methods — unchanged; new methods were added alongside + +--- + +## Testing + +- Build and run on a device with Live Activity enabled +- Open Settings → Live Activity → Grid slots; verify four pickers appear with all options +- Select a metric in one slot; verify it is cleared from any other slot that had it +- Verify the lock screen shows the new layout: large BG + arrow left, delta below, configurable grid right, footer bottom +- For Loop users: verify IOB, COB, basal, ISF, CR, target, TDD, autosens, projected BG, pump, override, profile name all populate correctly +- For Dexcom-only users: verify optional slots show `—` rather than crashing +- Verify SAGE, CAGE, IAGE display as `D:HH` age strings From b8c19cf2068a8e742694a93360c1c9deaa687a26 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sun, 15 Mar 2026 14:27:32 -0400 Subject: [PATCH 42/86] Update PR_configurable_slots.md --- docs/PR_configurable_slots.md | 22 +--------------------- 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/docs/PR_configurable_slots.md b/docs/PR_configurable_slots.md index 46db92cf2..685e0204e 100644 --- a/docs/PR_configurable_slots.md +++ b/docs/PR_configurable_slots.md @@ -94,24 +94,4 @@ Each data-fetching controller now writes one additional `Storage.shared` value a | `Profile.swift` | `lastProfileName` | | `IAge.swift` | `iageInsertTime` | ---- - -## What was not changed - -- APNs push infrastructure — no changes to `APNSClient`, `JWTManager`, or the push payload format beyond what was already present -- Dynamic Island layout — compact, expanded, and minimal presentations are unchanged -- Threshold-driven background color logic — unchanged -- "Not Looping" banner logic — unchanged -- All existing `LAFormat` methods — unchanged; new methods were added alongside - ---- - -## Testing - -- Build and run on a device with Live Activity enabled -- Open Settings → Live Activity → Grid slots; verify four pickers appear with all options -- Select a metric in one slot; verify it is cleared from any other slot that had it -- Verify the lock screen shows the new layout: large BG + arrow left, delta below, configurable grid right, footer bottom -- For Loop users: verify IOB, COB, basal, ISF, CR, target, TDD, autosens, projected BG, pump, override, profile name all populate correctly -- For Dexcom-only users: verify optional slots show `—` rather than crashing -- Verify SAGE, CAGE, IAGE display as `D:HH` age strings +--- \ No newline at end of file From b571cad6770e3d9253f463a31cc5e6ad7b33bc46 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sun, 15 Mar 2026 15:54:07 -0400 Subject: [PATCH 43/86] chore: remove PR notes from tracking, keep docs/LiveActivity.md only - Untrack docs/PR_configurable_slots.md (local-only reference doc) - Add docs/PR_configurable_slots.md and docs/LiveActivityTestPlan.md to .gitignore Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 2 + docs/PR_configurable_slots.md | 97 ----------------------------------- 2 files changed, 2 insertions(+), 97 deletions(-) delete mode 100644 docs/PR_configurable_slots.md diff --git a/.gitignore b/.gitignore index 178842387..d372f7c5a 100644 --- a/.gitignore +++ b/.gitignore @@ -81,3 +81,5 @@ fastlane/FastlaneRunner LoopFollowConfigOverride.xcconfig .history*.xcuserstate +docs/PR_configurable_slots.md +docs/LiveActivityTestPlan.md diff --git a/docs/PR_configurable_slots.md b/docs/PR_configurable_slots.md deleted file mode 100644 index 685e0204e..000000000 --- a/docs/PR_configurable_slots.md +++ /dev/null @@ -1,97 +0,0 @@ -# Configurable Live Activity Grid Slots + Full InfoType Snapshot Coverage - -## Summary - -- Replace the hardcoded 2×2 grid on the Live Activity lock screen with four fully configurable slots, each independently selectable from all 20+ available metrics via a new Settings picker UI -- Extend `GlucoseSnapshot` with 19 new fields covering all InfoType items (basal, pump, autosens, TDD, ISF, CR, target, ages, carbs today, profile name, min/max BG, override) -- Wire up all downstream data sources (controllers + Storage) so every new field is populated on each data refresh cycle -- Redesign the lock screen layout: glucose + trend arrow left-aligned, delta below the BG value, configurable grid on the right, "Last Update: HH:MM" footer centered at the bottom - ---- - -## Changes - -### Lock screen layout redesign (`LoopFollowLAExtension/LoopFollowLiveActivity.swift`) - -The previous layout had glucose + a fixed four-slot grid side by side with no clear hierarchy. The new layout: - -- **Left column:** Large glucose value + trend arrow (`.system(size: 46)`), with `Delta: ±X` below in a smaller semibold font -- **Right column:** Configurable 2×2 grid — slot content driven by `LAAppGroupSettings.slots()`, read from the shared App Group container -- **Footer:** `Last Update: HH:MM` centered below both columns - -A new `SlotView` struct handles dispatch for all 22 slot cases. Fifteen new `LAFormat` static methods were added to format each metric consistently (locale-aware number formatting, unit suffix, graceful `—` for nil/unavailable values). - -### Configurable slot picker UI (`LoopFollow/LiveActivitySettingsView.swift`) - -A new **Grid slots** section appears in the Live Activity settings screen with four pickers labelled Top left, Top right, Bottom left, Bottom right. Selecting a metric for one slot automatically clears that metric from any other slot (uniqueness enforced). Changes take effect immediately — `LiveActivityManager.shared.refreshFromCurrentState(reason: "slot config changed")` is called on every picker change. - -### Slot type definitions (`LoopFollow/LiveActivity/LAAppGroupSettings.swift`) - -- New `LiveActivitySlotOption` enum (22 cases: `none`, `delta`, `projectedBG`, `minMax`, `iob`, `cob`, `recBolus`, `autosens`, `tdd`, `basal`, `pump`, `pumpBattery`, `battery`, `target`, `isf`, `carbRatio`, `sage`, `cage`, `iage`, `carbsToday`, `override`, `profile`) -- `displayName` (used in Settings picker) and `gridLabel` (used inside the MetricBlock on the LA card) computed properties -- `isOptional` flag — `true` for metrics that may be absent for Dexcom-only users; the widget renders `—` in those cases -- `LiveActivitySlotDefaults` struct with out-of-the-box defaults: IOB / COB / Projected BG / Empty -- `LAAppGroupSettings.setSlots()` / `slots()` — persist and read the 4-slot configuration via the shared App Group `UserDefaults` container, so the extension always sees the current user selection - -All of this is placed in `LAAppGroupSettings.swift` because that file is already compiled into both the app target and the extension target. No new Xcode project file membership was required. - -### Extended GlucoseSnapshot (`LoopFollow/LiveActivity/GlucoseSnapshot.swift`) - -Added 19 new stored properties. All are optional or have safe defaults so decoding an older snapshot (e.g. from a push that arrived before the app updated) never crashes: - -| Property | Type | Source | -|---|---|---| -| `override` | `String?` | `Observable.shared.override` | -| `recBolus` | `Double?` | `Observable.shared.recBolus` | -| `battery` | `Double?` | `Observable.shared.battery` | -| `pumpBattery` | `Double?` | `Observable.shared.pumpBattery` | -| `basalRate` | `String` | `Storage.shared.lastBasal` | -| `pumpReservoirU` | `Double?` | `Storage.shared.lastPumpReservoirU` | -| `autosens` | `Double?` | `Storage.shared.lastAutosens` | -| `tdd` | `Double?` | `Storage.shared.lastTdd` | -| `targetLowMgdl` | `Double?` | `Storage.shared.lastTargetLowMgdl` | -| `targetHighMgdl` | `Double?` | `Storage.shared.lastTargetHighMgdl` | -| `isfMgdlPerU` | `Double?` | `Storage.shared.lastIsfMgdlPerU` | -| `carbRatio` | `Double?` | `Storage.shared.lastCarbRatio` | -| `carbsToday` | `Double?` | `Storage.shared.lastCarbsToday` | -| `profileName` | `String?` | `Storage.shared.lastProfileName` | -| `sageInsertTime` | `TimeInterval` | `Storage.shared.sageInsertTime` | -| `cageInsertTime` | `TimeInterval` | `Storage.shared.cageInsertTime` | -| `iageInsertTime` | `TimeInterval` | `Storage.shared.iageInsertTime` | -| `minBgMgdl` | `Double?` | `Storage.shared.lastMinBgMgdl` | -| `maxBgMgdl` | `Double?` | `Storage.shared.lastMaxBgMgdl` | - -All glucose-valued fields are stored in **mg/dL**; conversion to mmol/L happens at display time in `LAFormat`, consistent with the existing snapshot design. - -Age-based fields (SAGE, CAGE, IAGE) are stored as Unix epoch `TimeInterval` (0 = not set). `LAFormat.age(insertTime:)` computes the human-readable age string at render time using `DateComponentsFormatter` with `.positional` style and `[.day, .hour]` units. - -### GlucoseSnapshotBuilder (`LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift`) - -Extended `build(from:)` to populate all 19 new fields from `Observable.shared` and `Storage.shared`. - -### Storage additions (`LoopFollow/Storage/Storage.swift`) - -13 new `StorageValue`-backed fields in a dedicated "Live Activity extended InfoType data" section: - -``` -lastBasal, lastPumpReservoirU, lastAutosens, lastTdd, -lastTargetLowMgdl, lastTargetHighMgdl, lastIsfMgdlPerU, -lastCarbRatio, lastCarbsToday, lastProfileName, -iageInsertTime, lastMinBgMgdl, lastMaxBgMgdl -``` - -### Controller writes - -Each data-fetching controller now writes one additional `Storage.shared` value alongside its existing `infoManager.updateInfoData` call. No existing logic was changed — these are purely additive writes: - -| Controller | Field written | -|---|---| -| `Basals.swift` | `lastBasal` | -| `DeviceStatus.swift` | `lastPumpReservoirU` | -| `DeviceStatusLoop.swift` | `lastIsfMgdlPerU`, `lastCarbRatio`, `lastTargetLowMgdl`, `lastTargetHighMgdl`, `lastMinBgMgdl`, `lastMaxBgMgdl` | -| `DeviceStatusOpenAPS.swift` | `lastAutosens`, `lastTdd`, `lastIsfMgdlPerU`, `lastCarbRatio`, `lastTargetLowMgdl`, `lastTargetHighMgdl`, `lastMinBgMgdl`, `lastMaxBgMgdl` | -| `Carbs.swift` | `lastCarbsToday` | -| `Profile.swift` | `lastProfileName` | -| `IAge.swift` | `iageInsertTime` | - ---- \ No newline at end of file From fec3f79927a355d238f93f43780bf9cc0245d185 Mon Sep 17 00:00:00 2001 From: Phil A <76601115+MtlPhil@users.noreply.github.com> Date: Sun, 15 Mar 2026 16:08:25 -0400 Subject: [PATCH 44/86] Configurable Live Activity Grid Slots + Full InfoType Snapshot Coverage (#547) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Live Activity auto-renewal to work around 8-hour system limit Co-Authored-By: Claude Sonnet 4.6 * test: reduce LA renewal threshold to 20 min for testing Co-Authored-By: Claude Sonnet 4.6 * feat: improve LA renewal robustness and stale indicator - staleDate on every ActivityContent now tracks the renewal deadline, so the system shows Apple's built-in stale indicator if renewal fails - Add laRenewalFailed StorageValue; set on Activity.request() failure, cleared on any successful LA start - Observe willEnterForegroundNotification: retry startIfNeeded() if a previous renewal attempt failed - New-first renewal order: request the replacement LA before ending the old one — if the request throws the existing LA stays alive so the user keeps live data until the system kills it at the 8-hour mark Co-Authored-By: Claude Sonnet 4.6 * feat: renewal warning overlay + restore 7.5h threshold - Restore renewalThreshold to 7.5 * 3600 (testing complete) - Add showRenewalOverlay: Bool to GlucoseSnapshot (Codable, default false) - GlucoseSnapshotBuilder sets it true when now >= laRenewBy - 1800 (30 minutes before the renewal deadline) - Lock screen: 60% gray overlay with "Tap to update" centered in white, layered above the existing isNotLooping overlay - DI expanded: RenewalOverlayView applied to leading/trailing/bottom regions; "Tap to update" text shown on the bottom region only - showRenewalOverlay resets to false automatically on renewal since laRenewBy is updated and the next snapshot rebuild clears the flag Co-Authored-By: Claude Sonnet 4.6 * fix: overlay not appearing + foreground restart not working Overlay rendering: - Replace Group{if condition{ZStack{RoundedRectangle...}}} with a permanently-present ZStack toggled via .opacity(). The Group/if pattern causes SwiftUI sizing ambiguity when the condition transitions from false→true inside an .overlay(), producing a zero-size result. The .opacity() approach keeps a stable view hierarchy. - Same fix applied to RenewalOverlayView used on DI expanded regions. Foreground restart: - handleForeground() was calling startIfNeeded(), which finds the still-alive (failed-to-renew) LA in Activity.activities and reuses it, doing nothing useful. Fixed to manually nil out current, cancel all tasks, await activity.end(.immediate), then startFromCurrentState(). Overlay timing: - Changed warning window from 30 min (1800s) to 20 min (1200s) before the renewal deadline, matching the intended test cadence. Logging: - handleForeground: log on every foreground event with laRenewalFailed value - renewIfNeeded: log how many seconds past the deadline when firing - GlucoseSnapshotBuilder: log when overlay activates with seconds to deadline - performRefresh: log when sending an update with the overlay visible Co-Authored-By: Claude Sonnet 4.6 * test: set renewalThreshold to 20 min for testing Co-Authored-By: Claude Sonnet 4.6 * fix: renewal overlay not clearing after LA is refreshed Normal renewal path (renewIfNeeded success): - Build a fresh snapshot with showRenewalOverlay: false for the new LA's initial content — it has a new deadline so the overlay should never be visible from the first frame. - Save that fresh snapshot to GlucoseSnapshotStore so the next duplicate check has the correct baseline and doesn't suppress the first real BG update. Foreground restart path (handleForeground): - Zero laRenewBy before calling startFromCurrentState() so GlucoseSnapshotBuilder computes showRenewalOverlay = false for the seed snapshot. startIfNeeded() then sets the new deadline after the request succeeds. Co-Authored-By: Claude Sonnet 4.6 * fix: overlay permanently active when warning window equals threshold With renewalThreshold=20min and the hardcoded 1200s warning window, renewBy-1200 = start, so showRenewalOverlay is always true from the moment the LA begins. Extract a named renewalWarning constant (5 min for testing) so the warning window is always less than the threshold. The builder now reads LiveActivityManager.renewalWarning instead of a hardcoded literal. Production values to restore before merging: renewalThreshold = 7.5 * 3600 renewalWarning = 20 * 60 Co-Authored-By: Claude Sonnet 4.6 * fix: include showRenewalOverlay in APNs payload and clear laRenewBy synchronously APNSClient was missing showRenewalOverlay from the push payload, so background APNs updates never delivered the overlay flag to the extension — only foreground direct ActivityKit updates did. In handleForeground, laRenewBy is now zeroed synchronously before spawning the async end/restart Task. This means any snapshot built between the foreground notification and the new LA start (e.g. from viewDidAppear's startFromCurrentState) computes showRenewalOverlay = false rather than reading the stale expired deadline. Co-Authored-By: Claude Sonnet 4.6 * fix: await LA end before restarting on foreground retry to avoid reuse path Reset laRenewBy and laRenewalFailed synchronously before tearing down the failed LA, then await activity.end() before calling startFromCurrentState(). This guarantees Activity.activities is clear when startIfNeeded() runs, so it takes the fresh-request path and writes a new laRenewBy. startFromCurrentState rebuilds the snapshot with showRenewalOverlay=false (laRenewBy=0), saves it to the store, then startIfNeeded uses that clean snapshot as the seed. Co-Authored-By: Claude Sonnet 4.6 * chore: restore production renewal timing (7.5h threshold, 20min warning) Co-Authored-By: Claude Sonnet 4.6 * feat: laEnabled toggle, forceRestart(), and RestartLiveActivityIntent - SettingsMenuView: rename "APN" menu entry to "Live Activity" - Storage: add laEnabled: Bool StorageValue (default false) - APNSettingsView: on/off toggle bound to laEnabled; APNs key fields and Restart button shown only when enabled; disabling immediately ends any running LA - LiveActivityManager: - forceRestart() (@MainActor) ends all running activities, resets laRenewBy and laRenewalFailed, then calls startFromCurrentState() - laEnabled guard added to startFromCurrentState(), refreshFromCurrentState(), handleForeground(), and handleDidBecomeActive() - didBecomeActiveNotification observer calls forceRestart() on every foreground transition when laEnabled is true - RestartLiveActivityIntent: AppIntent + ForegroundContinuableIntent; sets laEnabled, validates credentials, opens settings deep link if missing, otherwise calls forceRestart() in foreground; LoopFollowAppShortcuts exposes the intent with Siri phrase Note: RestartLiveActivityIntent.swift must be added to the app target in Xcode. Co-Authored-By: Claude Sonnet 4.6 * Added RestartLiveActivityIntent to project * fix: resolve two build errors in LiveActivityManager and RestartLiveActivityIntent - LiveActivityManager: handleDidBecomeActive() calls @MainActor forceRestart() via Task { @MainActor in ... } to satisfy Swift concurrency isolation - RestartLiveActivityIntent: drop ForegroundContinuableIntent — continueInForeground() was renamed to continueInForeground(_:alwaysConfirm:) in the iOS 26 SDK and requires iOS 26+; the didBecomeActiveNotification observer handles restart when the app comes to foreground, making explicit continuation unnecessary Co-Authored-By: Claude Sonnet 4.6 * fix: guard continueInForeground() behind iOS 26 availability check continueInForeground(_:alwaysConfirm:) is iOS 26+ only. On earlier versions, forceRestart() runs directly via the existing background audio session — no foreground continuation needed. Co-Authored-By: Claude Sonnet 4.6 * fix: use startFromCurrentState in handleDidBecomeActive instead of forceRestart forceRestart() was killing the LA that the RestartLiveActivityIntent had just created, because continueInForeground() triggers didBecomeActive. startFromCurrentState() reuses an existing active LA via startIfNeeded() and only creates one if none is present — no destructive race. Co-Authored-By: Claude Sonnet 4.6 * feat: LA foreground tab navigation, button feedback, and toggle sync APNSettingsView: - "Restart Live Activity" button shows "Live Activity Restarted" for 2 seconds and disables itself during that window to prevent double-taps - Toggle observes Storage.shared.laEnabled via onReceive so it reflects changes made externally (e.g. by the App Intent / Shortcuts) LiveActivityManager: - handleDidBecomeActive posts .liveActivityDidForeground after startFromCurrentState - Notification.Name.liveActivityDidForeground extension defined here MainViewController: - Observes .liveActivityDidForeground; navigates to Snoozer tab if an alarm is currently active, otherwise navigates to tab 0 (Home) Co-Authored-By: Claude Sonnet 4.6 * fix: flush LA update on willResignActive to ensure lock screen shows latest data The 5-second debounce on refreshFromCurrentState means a BG update arriving while the app is foreground won't reach the LA before the user returns to the lock screen. When the debounce fires in background, isForeground=false so the direct ActivityKit update is skipped. Fix: observe willResignActiveNotification, cancel the pending debounce, and immediately push the latest snapshot via direct ActivityKit update while the app is still foreground-active. The APNs push is also sent as a backup. Co-Authored-By: Claude Sonnet 4.6 * feat: redesign Dynamic Island compact and expanded views Compact: - Trailing now shows delta instead of trend arrow (leading already showed BG) Expanded leading: - Large BG value at top - Second row: trend arrow, delta, and "Proj: X" in smaller text below Expanded trailing: - IOB and COB (moved from bottom) Expanded bottom: - "Updated at: HH:mm" (moved from trailing) Co-Authored-By: Claude Sonnet 4.6 * fix: match Proj text style to delta; add trailing padding to IOB/COB - Proj label now uses same size (13), weight (semibold), and opacity (0.9) as the delta text beside it - Added 6pt trailing padding to the expanded DI trailing VStack so the IOB decimal is not clipped by the Dynamic Island edge Co-Authored-By: Claude Sonnet 4.6 * feat: separate Live Activity and APN settings into distinct menus Per maintainer preference, the APNs credentials (Key ID and Key) remain in their own "APN" settings screen. A new separate "Live Activity" settings screen contains the enable/disable toggle and the Restart Live Activity button. - APNSettingsView: restored to Key ID + Key only (no toggle or restart button) - LiveActivitySettingsView: new view with laEnabled toggle and restart button (with 2-second confirmation feedback); toggle stays in sync via onReceive - SettingsMenuView: "APN" entry restored; new "Live Activity" entry added with dot.radiowaves icon; Sheet.liveActivity case wired to LiveActivitySettingsView Co-Authored-By: Claude Sonnet 4.6 * Added Live Activity menu * chore: add LiveActivitySettingsView to Xcode project Co-Authored-By: Claude Sonnet 4.6 * fix: LA tap navigation, manual dismissal prevention, and toggle start - widgetURL on lock screen LA → opens app via loopfollow://la-tap - Link views in expanded DI regions → same URL on tap - AppDelegate.application(_:open:) handles loopfollow://la-tap and posts .liveActivityDidForeground so MainViewController navigates to Snoozer (if alarm active) or Home tab — fires only on LA/DI taps, not every foreground - Notification.Name.liveActivityDidForeground moved outside #if macCatalyst so AppDelegate can reference it unconditionally - handleDidBecomeActive no longer posts .liveActivityDidForeground (was firing on every app foreground, not just LA taps) - attachStateObserver: .dismissed state (user swipe) sets laEnabled = false so the LA does not auto-restart when the app foregrounds - LiveActivitySettingsView: toggling ON calls startFromCurrentState() Co-Authored-By: Claude Sonnet 4.6 * fix: end Live Activity on app force-quit Adds endOnTerminate() to LiveActivityManager — blocks up to 3 s using Task.detached + DispatchSemaphore so the async activity.end() completes before the process exits. Called from applicationWillTerminate so the LA clears from the lock screen immediately on force-quit. laEnabled is preserved so the LA restarts correctly on next launch. Co-Authored-By: Claude Sonnet 4.6 * fix: use dismissedByUser flag instead of disabling laEnabled on manual dismiss - Manual LA swipe-away sets dismissedByUser (in-memory) instead of laEnabled = false, preserving the user's preference - startFromCurrentState() guards on !dismissedByUser so the LA does not auto-restart on foreground, BG refresh, or handleDidBecomeActive - forceRestart() clears dismissedByUser before starting, so the Restart button and App Intent both work as the explicit re-enable mechanism - Toggle ON in settings calls forceRestart() (clears flag + starts) instead of startFromCurrentState() which would have been blocked by the flag - dismissedByUser resets to false on app relaunch (in-memory only), so a kill + relaunch starts the LA fresh as expected Co-Authored-By: Claude Sonnet 4.6 * fix: dismiss modal (Settings sheet) before tab switch on LA tap navigateOnLAForeground now checks tabBarController.presentedViewController and dismisses it before switching to the target tab, matching the existing pattern used elsewhere in MainViewController for the same scenario. Co-Authored-By: Claude Sonnet 4.6 * fix: LA tap navigation timing and LA reappear-after-dismiss Navigation fix: - loopfollow://la-tap URL can arrive before applicationDidBecomeActive while UIKit is still restoring the view hierarchy from background. At that moment tabBarController.presentedViewController is nil so the Settings modal is never dismissed and the navigation notification fires too early. - Now stores a pendingLATapNavigation flag if app is not yet .active, and fires the notification in applicationDidBecomeActive when the full view hierarchy (including presented modals) is restored. LA reappear fix: - performRefresh() calls startIfNeeded() directly, bypassing the dismissedByUser guard that only exists in startFromCurrentState(). On every BG refresh with laEnabled=true and current==nil (post-dismiss), it would recreate the LA. - Added !dismissedByUser guard to refreshFromCurrentState() so the entire refresh pipeline is skipped while dismissed, matching the existing guard in startFromCurrentState(). Co-Authored-By: Claude Sonnet 4.6 * fix: handle loopfollow://la-tap URL in SceneDelegate, not AppDelegate With scene-based lifecycle (iOS 13+), widget/LA tap URLs are delivered to scene(_:openURLContexts:) on UIWindowSceneDelegate — AppDelegate.application (_:open:url:options:) is never called. Our previous handler was dead code. SceneDelegate now implements scene(_:openURLContexts:) which posts .liveActivityDidForeground on the next run loop (via DispatchQueue.main.async) to let the view hierarchy fully settle before navigateOnLAForeground() runs. Also handles the edge case where the URL arrives before sceneDidBecomeActive by storing a pendingLATapNavigation flag. Co-Authored-By: Claude Sonnet 4.6 * feat: configurable LA grid slots + full InfoType snapshot coverage Lock screen layout: - Left column: glucose + trend arrow, delta below (replaces "Last Update:") - Right grid: 4 configurable slots read from LAAppGroupSettings (defaults: IOB/COB/Proj/empty) - Footer: centered HH:MM update time at reduced opacity Slot configuration: - LiveActivitySlotOption enum with all 22 InfoType-aligned cases - LAAppGroupSettings.setSlots() / slots() persisted to App Group UserDefaults - Uniqueness enforced: selecting an option clears it from any other slot - Settings UI: "Grid slots" section with 4 pickers in LiveActivitySettingsView - Changes take effect immediately (refreshFromCurrentState called on save) GlucoseSnapshot extended with 19 new fields covering all InfoType items: override, recBolus, battery, pumpBattery, basalRate, pumpReservoirU, autosens, tdd, targetLowMgdl/High, isfMgdlPerU, carbRatio, carbsToday, profileName, sageInsertTime, cageInsertTime, iageInsertTime, minBgMgdl/Max Storage.swift: 13 new UserDefaults-backed fields for the above metrics Controllers updated to write new Storage fields on each data fetch: Basals, DeviceStatus, DeviceStatusLoop, DeviceStatusOpenAPS, Carbs, Profile, IAge Co-Authored-By: Claude Sonnet 4.6 * fix: label delta and footer on lock screen LA card Co-Authored-By: Claude Sonnet 4.6 * docs: add PR description for configurable LA grid slots Co-Authored-By: Claude Sonnet 4.6 * Update PR_configurable_slots.md * chore: remove PR notes from tracking, keep docs/LiveActivity.md only - Untrack docs/PR_configurable_slots.md (local-only reference doc) - Add docs/PR_configurable_slots.md and docs/LiveActivityTestPlan.md to .gitignore Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- .gitignore | 2 + .../Controllers/Nightscout/DeviceStatus.swift | 2 + .../Nightscout/DeviceStatusLoop.swift | 6 + .../Nightscout/DeviceStatusOpenAPS.swift | 15 ++ LoopFollow/Controllers/Nightscout/IAge.swift | 1 + .../Controllers/Nightscout/Profile.swift | 1 + .../Nightscout/Treatments/Basals.swift | 1 + .../Nightscout/Treatments/Carbs.swift | 1 + LoopFollow/LiveActivity/GlucoseSnapshot.swift | 142 +++++++++++- .../LiveActivity/GlucoseSnapshotBuilder.swift | 20 ++ .../LiveActivity/LAAppGroupSettings.swift | 149 +++++++++++- .../LiveActivity/LiveActivitySlotConfig.swift | 44 ++++ LoopFollow/LiveActivitySettingsView.swift | 31 +++ LoopFollow/Storage/Storage.swift | 16 ++ .../LoopFollowLiveActivity.swift | 216 +++++++++++++++--- 15 files changed, 608 insertions(+), 39 deletions(-) create mode 100644 LoopFollow/LiveActivity/LiveActivitySlotConfig.swift diff --git a/.gitignore b/.gitignore index 178842387..d372f7c5a 100644 --- a/.gitignore +++ b/.gitignore @@ -81,3 +81,5 @@ fastlane/FastlaneRunner LoopFollowConfigOverride.xcconfig .history*.xcuserstate +docs/PR_configurable_slots.md +docs/LiveActivityTestPlan.md diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift index b7f88634e..ae3967b3e 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift @@ -132,9 +132,11 @@ extension MainViewController { if let reservoirData = lastPumpRecord["reservoir"] as? Double { latestPumpVolume = reservoirData infoManager.updateInfoData(type: .pump, value: String(format: "%.0f", reservoirData) + "U") + Storage.shared.lastPumpReservoirU.value = reservoirData } else { latestPumpVolume = 50.0 infoManager.updateInfoData(type: .pump, value: "50+U") + Storage.shared.lastPumpReservoirU.value = nil } } diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift index 650092237..89c4163cd 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift @@ -30,12 +30,14 @@ extension MainViewController { let profileISF = profileManager.currentISF() if let profileISF = profileISF { infoManager.updateInfoData(type: .isf, value: profileISF) + Storage.shared.lastIsfMgdlPerU.value = profileISF.doubleValue(for: .milligramsPerDeciliter) } // Carb Ratio (CR) let profileCR = profileManager.currentCarbRatio() if let profileCR = profileCR { infoManager.updateInfoData(type: .carbRatio, value: profileCR) + Storage.shared.lastCarbRatio.value = profileCR } // Target @@ -47,6 +49,8 @@ extension MainViewController { } else if let profileTargetLow = profileTargetLow { infoManager.updateInfoData(type: .target, value: profileTargetLow) } + Storage.shared.lastTargetLowMgdl.value = profileTargetLow?.doubleValue(for: .milligramsPerDeciliter) + Storage.shared.lastTargetHighMgdl.value = profileTargetHigh?.doubleValue(for: .milligramsPerDeciliter) // IOB if let insulinMetric = InsulinMetric(from: lastLoopRecord["iob"], key: "iob") { @@ -87,6 +91,8 @@ extension MainViewController { let formattedMax = Localizer.toDisplayUnits(String(predMax)) let value = "\(formattedMin)/\(formattedMax)" infoManager.updateInfoData(type: .minMax, value: value) + Storage.shared.lastMinBgMgdl.value = predMin + Storage.shared.lastMaxBgMgdl.value = predMax } updatePredictionGraph() diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift index fc3b3c5b5..20827c253 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift @@ -37,8 +37,10 @@ extension MainViewController { } if let profileISF = profileISF, let enactedISF = enactedISF, profileISF != enactedISF { infoManager.updateInfoData(type: .isf, firstValue: profileISF, secondValue: enactedISF, separator: .arrow) + Storage.shared.lastIsfMgdlPerU.value = enactedISF.doubleValue(for: .milligramsPerDeciliter) } else if let profileISF = profileISF { infoManager.updateInfoData(type: .isf, value: profileISF) + Storage.shared.lastIsfMgdlPerU.value = profileISF.doubleValue(for: .milligramsPerDeciliter) } // Carb Ratio (CR) @@ -57,8 +59,10 @@ extension MainViewController { if let profileCR = profileCR, let enactedCR = enactedCR, profileCR != enactedCR { infoManager.updateInfoData(type: .carbRatio, value: profileCR, enactedValue: enactedCR, separator: .arrow) + Storage.shared.lastCarbRatio.value = enactedCR } else if let profileCR = profileCR { infoManager.updateInfoData(type: .carbRatio, value: profileCR) + Storage.shared.lastCarbRatio.value = profileCR } // IOB @@ -98,6 +102,7 @@ extension MainViewController { if let sens = enactedOrSuggested["sensitivityRatio"] as? Double { let formattedSens = String(format: "%.0f", sens * 100.0) + "%" infoManager.updateInfoData(type: .autosens, value: formattedSens) + Storage.shared.lastAutosens.value = sens } // Recommended Bolus @@ -136,11 +141,19 @@ extension MainViewController { } else { infoManager.updateInfoData(type: .target, value: profileTargetHigh) } + let effectiveMgdl = enactedTarget.doubleValue(for: .milligramsPerDeciliter) + Storage.shared.lastTargetLowMgdl.value = effectiveMgdl + Storage.shared.lastTargetHighMgdl.value = effectiveMgdl + } else if let profileTargetHigh = profileTargetHigh { + let profileMgdl = profileTargetHigh.doubleValue(for: .milligramsPerDeciliter) + Storage.shared.lastTargetLowMgdl.value = profileMgdl + Storage.shared.lastTargetHighMgdl.value = profileMgdl } // TDD if let tddMetric = InsulinMetric(from: enactedOrSuggested, key: "TDD") { infoManager.updateInfoData(type: .tdd, value: tddMetric) + Storage.shared.lastTdd.value = tddMetric.value } let predBGsData: [String: AnyObject]? = { @@ -201,6 +214,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") } diff --git a/LoopFollow/Controllers/Nightscout/IAge.swift b/LoopFollow/Controllers/Nightscout/IAge.swift index 69a683c57..50e9bd592 100644 --- a/LoopFollow/Controllers/Nightscout/IAge.swift +++ b/LoopFollow/Controllers/Nightscout/IAge.swift @@ -45,6 +45,7 @@ extension MainViewController { .withColonSeparatorInTime] if let iageTime = formatter.date(from: (lastIageString as! String))?.timeIntervalSince1970 { + Storage.shared.iageInsertTime.value = iageTime let now = dateTimeUtils.getNowTimeIntervalUTC() let secondsAgo = now - iageTime diff --git a/LoopFollow/Controllers/Nightscout/Profile.swift b/LoopFollow/Controllers/Nightscout/Profile.swift index c00ac195e..f76c74a4c 100644 --- a/LoopFollow/Controllers/Nightscout/Profile.swift +++ b/LoopFollow/Controllers/Nightscout/Profile.swift @@ -23,6 +23,7 @@ extension MainViewController { } profileManager.loadProfile(from: profileData) infoManager.updateInfoData(type: .profile, value: profileData.defaultProfile) + Storage.shared.lastProfileName.value = profileData.defaultProfile // Mark profile data as loaded for initial loading state markDataLoaded("profile") diff --git a/LoopFollow/Controllers/Nightscout/Treatments/Basals.swift b/LoopFollow/Controllers/Nightscout/Treatments/Basals.swift index 5ee0891fe..405281926 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/Basals.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/Basals.swift @@ -147,5 +147,6 @@ extension MainViewController { latestBasal = "\(profileBasal) → \(latestBasal)" } infoManager.updateInfoData(type: .basal, value: latestBasal) + Storage.shared.lastBasal.value = latestBasal } } diff --git a/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift b/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift index baa4af7a1..5d75adb2d 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift @@ -82,5 +82,6 @@ extension MainViewController { let resultString = String(format: "%.0f", totalCarbs) infoManager.updateInfoData(type: .carbsToday, value: resultString) + Storage.shared.lastCarbsToday.value = totalCarbs } } diff --git a/LoopFollow/LiveActivity/GlucoseSnapshot.swift b/LoopFollow/LiveActivity/GlucoseSnapshot.swift index 1e573cba6..4e914ab7e 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshot.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshot.swift @@ -39,6 +39,65 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { /// 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; @@ -64,6 +123,25 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { 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 @@ -75,6 +153,25 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { 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 @@ -89,13 +186,37 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { 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) } private enum CodingKeys: String, CodingKey { - case glucose, delta, trend, updatedAt, iob, cob, projected, unit, isNotLooping, showRenewalOverlay + 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: - Codable @@ -109,6 +230,25 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { 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 diff --git a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift index f6a1d7208..dd845b116 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift @@ -70,6 +70,7 @@ enum GlucoseSnapshotBuilder { isDebug: true ) + let profileNameRaw = Storage.shared.lastProfileName.value return GlucoseSnapshot( glucose: glucoseMgdl, delta: deltaMgdl, @@ -78,6 +79,25 @@ enum GlucoseSnapshotBuilder { iob: provider.iob, cob: provider.cob, projected: provider.projectedMgdl, + override: Observable.shared.override.value, + recBolus: Observable.shared.deviceRecBolus.value, + battery: Observable.shared.deviceBatteryLevel.value, + pumpBattery: Observable.shared.pumpBatteryLevel.value, + basalRate: Storage.shared.lastBasal.value, + pumpReservoirU: Storage.shared.lastPumpReservoirU.value, + autosens: Storage.shared.lastAutosens.value, + tdd: Storage.shared.lastTdd.value, + targetLowMgdl: Storage.shared.lastTargetLowMgdl.value, + targetHighMgdl: Storage.shared.lastTargetHighMgdl.value, + isfMgdlPerU: Storage.shared.lastIsfMgdlPerU.value, + carbRatio: Storage.shared.lastCarbRatio.value, + carbsToday: Storage.shared.lastCarbsToday.value, + profileName: profileNameRaw.isEmpty ? nil : profileNameRaw, + sageInsertTime: Storage.shared.sageInsertTime.value, + cageInsertTime: Storage.shared.cageInsertTime.value, + iageInsertTime: Storage.shared.iageInsertTime.value, + minBgMgdl: Storage.shared.lastMinBgMgdl.value, + maxBgMgdl: Storage.shared.lastMaxBgMgdl.value, unit: preferredUnit, isNotLooping: isNotLooping, showRenewalOverlay: showRenewalOverlay diff --git a/LoopFollow/LiveActivity/LAAppGroupSettings.swift b/LoopFollow/LiveActivity/LAAppGroupSettings.swift index 7615b2cf7..2880c0efe 100644 --- a/LoopFollow/LiveActivity/LAAppGroupSettings.swift +++ b/LoopFollow/LiveActivity/LAAppGroupSettings.swift @@ -3,6 +3,129 @@ 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: return "Empty" + case .delta: return "Delta" + case .projectedBG: return "Projected BG" + case .minMax: return "Min/Max" + case .iob: return "IOB" + case .cob: return "COB" + case .recBolus: return "Rec. Bolus" + case .autosens: return "Autosens" + case .tdd: return "TDD" + case .basal: return "Basal" + case .pump: return "Pump" + case .pumpBattery: return "Pump Battery" + case .battery: return "Battery" + case .target: return "Target" + case .isf: return "ISF" + case .carbRatio: return "CR" + case .sage: return "SAGE" + case .cage: return "CAGE" + case .iage: return "IAGE" + case .carbsToday: return "Carbs today" + case .override: return "Override" + case .profile: return "Profile" + } + } + + /// Short label used inside the MetricBlock on the Live Activity card. + var gridLabel: String { + switch self { + case .none: return "" + case .delta: return "Delta" + case .projectedBG: return "Proj" + case .minMax: return "Min/Max" + case .iob: return "IOB" + case .cob: return "COB" + case .recBolus: return "Rec." + case .autosens: return "Sens" + case .tdd: return "TDD" + case .basal: return "Basal" + case .pump: return "Pump" + case .pumpBattery: return "Pump%" + case .battery: return "Bat." + case .target: return "Target" + case .isf: return "ISF" + case .carbRatio: return "CR" + case .sage: return "SAGE" + case .cage: return "CAGE" + case .iage: return "IAGE" + case .carbsToday: return "Carbs" + case .override: return "Ovrd" + case .profile: return "Prof" + } + } + + /// 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: return false + default: return true + } + } +} + +// MARK: - Default slot assignments + +struct 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 + + 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 @@ -11,24 +134,46 @@ enum LAAppGroupSettings { private enum Keys { static let lowLineMgdl = "la.lowLine.mgdl" static let highLineMgdl = "la.highLine.mgdl" + static let slots = "la.slots" } private static var defaults: UserDefaults? { UserDefaults(suiteName: AppGroupID.current()) } - // MARK: - Write (App) + // MARK: - Thresholds (Write) static func setThresholds(lowMgdl: Double, highMgdl: Double) { defaults?.set(lowMgdl, forKey: Keys.lowLineMgdl) defaults?.set(highMgdl, forKey: Keys.highLineMgdl) } - // MARK: - Read (Extension) + // 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 { $0.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 } + } } diff --git a/LoopFollow/LiveActivity/LiveActivitySlotConfig.swift b/LoopFollow/LiveActivity/LiveActivitySlotConfig.swift new file mode 100644 index 000000000..2b097a6b1 --- /dev/null +++ b/LoopFollow/LiveActivity/LiveActivitySlotConfig.swift @@ -0,0 +1,44 @@ +// LoopFollow +// LiveActivitySlotConfig.swift + +// MARK: - Information Display Settings audit +// +// LoopFollow exposes 20 items in Information Display Settings (InfoType.swift). +// The table below maps each item to its availability as a Live Activity grid slot. +// +// AVAILABLE NOW — value present in GlucoseSnapshot: +// Display name | InfoType case | Snapshot field | Optional (nil for Dexcom-only) +// ───────────────────────────────────────────────────────────────────────────────── +// IOB | .iob | snapshot.iob | YES +// COB | .cob | snapshot.cob | YES +// Projected BG | (none) | snapshot.projected | YES +// Delta | (none) | snapshot.delta | NO (always available) +// +// Note: "Updated" (InfoType.updated) is intentionally excluded — it is displayed +// in the card footer and is not a configurable slot. +// +// NOT YET AVAILABLE — requires adding fields to GlucoseSnapshot, GlucoseSnapshotBuilder, +// and the APNs payload before they can be offered as slot options: +// Display name | InfoType case | Source in app +// ───────────────────────────────────────────────────────────────────────────────── +// Basal | .basal | DeviceStatus basal rate +// Override | .override | DeviceStatus override name +// Battery | .battery | DeviceStatus CGM/device battery % +// Pump | .pump | DeviceStatus pump name / status +// Pump Battery | .pumpBattery | DeviceStatus pump battery % +// SAGE | .sage | DeviceStatus sensor age (hours) +// CAGE | .cage | DeviceStatus cannula age (hours) +// Rec. Bolus | .recBolus | DeviceStatus recommended bolus +// Min/Max | .minMax | Computed from recent BG history +// Carbs today | .carbsToday | Computed from COB history +// Autosens | .autosens | DeviceStatusOpenAPS autosens ratio +// Profile | .profile | DeviceStatus profile name +// Target | .target | DeviceStatus BG target +// ISF | .isf | DeviceStatus insulin sensitivity factor +// CR | .carbRatio | DeviceStatus carb ratio +// TDD | .tdd | DeviceStatus total daily dose +// IAGE | .iage | DeviceStatus insulin/pod age (hours) +// +// The LiveActivitySlotOption enum, LiveActivitySlotDefaults struct, and +// LAAppGroupSettings.setSlots() / slots() storage are defined in +// LAAppGroupSettings.swift (shared between app and extension targets). diff --git a/LoopFollow/LiveActivitySettingsView.swift b/LoopFollow/LiveActivitySettingsView.swift index 0a29d702a..99dbc13e6 100644 --- a/LoopFollow/LiveActivitySettingsView.swift +++ b/LoopFollow/LiveActivitySettingsView.swift @@ -6,6 +6,9 @@ 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() + + private let slotLabels = ["Top left", "Top right", "Bottom left", "Bottom right"] var body: some View { Form { @@ -25,6 +28,19 @@ struct LiveActivitySettingsView: View { .disabled(restartConfirmed) } } + + Section(header: Text("Grid slots")) { + 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) + } + } + } + } } .onReceive(Storage.shared.laEnabled.$value) { newValue in if newValue != laEnabled { laEnabled = newValue } @@ -41,4 +57,19 @@ struct LiveActivitySettingsView: View { .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) + Task { + await LiveActivityManager.shared.refreshFromCurrentState(reason: "slot config changed") + } + } } diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 141293e7c..754c6e0d7 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -91,6 +91,22 @@ class Storage { var lastCOB = StorageValue(key: "lastCOB", defaultValue: nil) var projectedBgMgdl = StorageValue(key: "projectedBgMgdl", defaultValue: nil) + // Live Activity extended InfoType data + var lastBasal = StorageValue(key: "lastBasal", defaultValue: "") + var lastPumpReservoirU = StorageValue(key: "lastPumpReservoirU", defaultValue: nil) + var lastAutosens = StorageValue(key: "lastAutosens", defaultValue: nil) + var lastTdd = StorageValue(key: "lastTdd", defaultValue: nil) + var lastTargetLowMgdl = StorageValue(key: "lastTargetLowMgdl", defaultValue: nil) + var lastTargetHighMgdl = StorageValue(key: "lastTargetHighMgdl", defaultValue: nil) + var lastIsfMgdlPerU = StorageValue(key: "lastIsfMgdlPerU", defaultValue: nil) + var lastCarbRatio = StorageValue(key: "lastCarbRatio", defaultValue: nil) + var lastCarbsToday = StorageValue(key: "lastCarbsToday", defaultValue: nil) + var lastProfileName = StorageValue(key: "lastProfileName", defaultValue: "") + var iageInsertTime = StorageValue(key: "iageInsertTime", defaultValue: 0) + var lastMinBgMgdl = StorageValue(key: "lastMinBgMgdl", defaultValue: nil) + var lastMaxBgMgdl = StorageValue(key: "lastMaxBgMgdl", defaultValue: nil) + + // Live Activity var laEnabled = StorageValue(key: "laEnabled", defaultValue: false) var laRenewBy = StorageValue(key: "laRenewBy", defaultValue: 0) diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 294ba8645..d0f351611 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -78,50 +78,62 @@ private struct LockScreenLiveActivityView: View { var body: some View { let s = state.snapshot + let slotConfig = LAAppGroupSettings.slots() + + VStack(spacing: 6) { + HStack(spacing: 12) { + // LEFT: Glucose + trend arrow, delta below + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(LAFormat.glucose(s)) + .font(.system(size: 46, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white) - HStack(spacing: 12) { - // LEFT: Glucose + trend, update time below - VStack(alignment: .leading, spacing: 4) { - HStack(alignment: .firstTextBaseline, spacing: 8) { - Text(LAFormat.glucose(s)) - .font(.system(size: 46, weight: .bold, design: .rounded)) - .monospacedDigit() - .foregroundStyle(.white) - - Text(LAFormat.trendArrow(s)) - .font(.system(size: 46, weight: .bold, design: .rounded)) - .foregroundStyle(.white.opacity(0.95)) - } + Text(LAFormat.trendArrow(s)) + .font(.system(size: 46, weight: .bold, design: .rounded)) + .foregroundStyle(.white.opacity(0.95)) + } - Text("Last Update: \(LAFormat.updated(s))") - .font(.system(size: 13, weight: .regular, design: .rounded)) - .foregroundStyle(.white.opacity(0.75)) - } - .frame(width: 168, alignment: .leading) - .layoutPriority(2) - - // Divider - Rectangle() - .fill(Color.white.opacity(0.20)) - .frame(width: 1) - .padding(.vertical, 8) - - // RIGHT: 2x2 grid — delta/proj | iob/cob - VStack(spacing: 10) { - HStack(spacing: 16) { - MetricBlock(label: "Delta", value: LAFormat.delta(s)) - MetricBlock(label: "IOB", value: LAFormat.iob(s)) + Text("Delta: \(LAFormat.delta(s))") + .font(.system(size: 15, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.80)) } - HStack(spacing: 16) { - MetricBlock(label: "Proj", value: LAFormat.projected(s)) - MetricBlock(label: "COB", value: LAFormat.cob(s)) + .frame(width: 168, alignment: .leading) + .layoutPriority(2) + + // Divider + Rectangle() + .fill(Color.white.opacity(0.20)) + .frame(width: 1) + .padding(.vertical, 8) + + // RIGHT: configurable 2×2 grid + VStack(spacing: 10) { + HStack(spacing: 16) { + SlotView(option: slotConfig[0], snapshot: s) + SlotView(option: slotConfig[1], snapshot: s) + } + HStack(spacing: 16) { + SlotView(option: slotConfig[2], snapshot: s) + SlotView(option: slotConfig[3], snapshot: s) + } } + .frame(maxWidth: .infinity, alignment: .trailing) } - .frame(maxWidth: .infinity, alignment: .trailing) + + // Footer: last update time + Text("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(.vertical, 12) + .padding(.top, 12) + .padding(.bottom, 8) .overlay( RoundedRectangle(cornerRadius: 16, style: .continuous) .stroke(Color.white.opacity(0.20), lineWidth: 1) @@ -193,6 +205,50 @@ private struct MetricBlock: View { } } +/// Renders one configurable slot in the lock screen 2×2 grid. +/// Shows nothing (invisible placeholder) when the slot option is `.none`. +private struct SlotView: View { + let option: LiveActivitySlotOption + let snapshot: GlucoseSnapshot + + var body: some View { + if option == .none { + // Invisible spacer — preserves grid alignment + Color.clear + .frame(width: 64, height: 36) + } else { + MetricBlock(label: option.gridLabel, value: value(for: option)) + } + } + + private func value(for option: LiveActivitySlotOption) -> String { + switch option { + case .none: return "" + case .delta: return LAFormat.delta(snapshot) + case .projectedBG: return LAFormat.projected(snapshot) + case .minMax: return LAFormat.minMax(snapshot) + case .iob: return LAFormat.iob(snapshot) + case .cob: return LAFormat.cob(snapshot) + case .recBolus: return LAFormat.recBolus(snapshot) + case .autosens: return LAFormat.autosens(snapshot) + case .tdd: return LAFormat.tdd(snapshot) + case .basal: return LAFormat.basal(snapshot) + case .pump: return LAFormat.pump(snapshot) + case .pumpBattery: return LAFormat.pumpBattery(snapshot) + case .battery: return LAFormat.battery(snapshot) + case .target: return LAFormat.target(snapshot) + case .isf: return LAFormat.isf(snapshot) + case .carbRatio: return LAFormat.carbRatio(snapshot) + case .sage: return LAFormat.age(insertTime: snapshot.sageInsertTime) + case .cage: return LAFormat.age(insertTime: snapshot.cageInsertTime) + case .iage: return LAFormat.age(insertTime: snapshot.iageInsertTime) + case .carbsToday: return LAFormat.carbsToday(snapshot) + case .override: return LAFormat.override(snapshot) + case .profile: return LAFormat.profileName(snapshot) + } + } +} + // MARK: - Dynamic Island @available(iOS 16.1, *) @@ -409,6 +465,94 @@ private enum LAFormat { return formatGlucoseValue(v, unit: s.unit) } + // MARK: Extended InfoType formatters + + private static let ageFormatter: DateComponentsFormatter = { + let f = DateComponentsFormatter() + f.unitsStyle = .positional + f.allowedUnits = [.day, .hour] + f.zeroFormattingBehavior = [.pad] + return f + }() + + /// Formats an insert-time epoch into "D:HH" age string. Returns "—" if time is 0. + 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 ?? "—" + } + // MARK: Update time private static let hhmmFormatter: DateFormatter = { From 83ba7c57a5c6bb3620e1c187625b5e490d945f42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 15 Mar 2026 21:39:10 +0100 Subject: [PATCH 45/86] Linting --- LoopFollow/Application/AppDelegate.swift | 3 ++- LoopFollow/LiveActivity/LAAppGroupSettings.swift | 2 +- LoopFollow/LiveActivity/LiveActivitySlotConfig.swift | 1 + LoopFollow/Storage/Storage.swift | 1 - LoopFollow/ViewControllers/MainViewController.swift | 3 ++- 5 files changed, 6 insertions(+), 4 deletions(-) diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index bf87a3343..81b01cf50 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -50,7 +50,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func applicationWillTerminate(_: UIApplication) { #if !targetEnvironment(macCatalyst) - LiveActivityManager.shared.endOnTerminate() + LiveActivityManager.shared.endOnTerminate() #endif } @@ -102,6 +102,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } // MARK: - URL handling + // Note: with scene-based lifecycle (iOS 13+), URLs are delivered to // SceneDelegate.scene(_:openURLContexts:) — not here. The scene delegate // handles loopfollow://la-tap for Live Activity tap navigation. diff --git a/LoopFollow/LiveActivity/LAAppGroupSettings.swift b/LoopFollow/LiveActivity/LAAppGroupSettings.swift index 2880c0efe..4e1d7b126 100644 --- a/LoopFollow/LiveActivity/LAAppGroupSettings.swift +++ b/LoopFollow/LiveActivity/LAAppGroupSettings.swift @@ -109,7 +109,7 @@ enum LiveActivitySlotOption: String, CaseIterable, Codable { // MARK: - Default slot assignments -struct LiveActivitySlotDefaults { +enum LiveActivitySlotDefaults { /// Top-left slot static let slot1: LiveActivitySlotOption = .iob /// Bottom-left slot diff --git a/LoopFollow/LiveActivity/LiveActivitySlotConfig.swift b/LoopFollow/LiveActivity/LiveActivitySlotConfig.swift index 2b097a6b1..10d8b13c3 100644 --- a/LoopFollow/LiveActivity/LiveActivitySlotConfig.swift +++ b/LoopFollow/LiveActivity/LiveActivitySlotConfig.swift @@ -2,6 +2,7 @@ // LiveActivitySlotConfig.swift // MARK: - Information Display Settings audit + // // LoopFollow exposes 20 items in Information Display Settings (InfoType.swift). // The table below maps each item to its availability as a Live Activity grid slot. diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 754c6e0d7..7884e6589 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -106,7 +106,6 @@ class Storage { var lastMinBgMgdl = StorageValue(key: "lastMinBgMgdl", defaultValue: nil) var lastMaxBgMgdl = StorageValue(key: "lastMaxBgMgdl", defaultValue: nil) - // Live Activity var laEnabled = StorageValue(key: "laEnabled", defaultValue: false) var laRenewBy = StorageValue(key: "laRenewBy", defaultValue: 0) diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 516b58e3c..72934bccb 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -689,7 +689,8 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele let targetIndex: Int if Observable.shared.currentAlarm.value != nil, - let snoozerIndex = getSnoozerTabIndex(), snoozerIndex < vcs.count { + let snoozerIndex = getSnoozerTabIndex(), snoozerIndex < vcs.count + { targetIndex = snoozerIndex } else { targetIndex = 0 From a20f3ecd99ddfb6cf010365ad6f0568b6f416efa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 15 Mar 2026 21:53:19 +0100 Subject: [PATCH 46/86] Fix PRODUCT_BUNDLE_IDENTIFIER for Tests --- LoopFollow.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 4af8291b3..795346767 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -2499,7 +2499,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)"; @@ -2526,7 +2526,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; From 145744c1f0f39b561c2d56e1b640cc5593f94d26 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sun, 15 Mar 2026 18:32:57 -0400 Subject: [PATCH 47/86] fix: include all extended InfoType fields in APNs push payload buildPayload() was only serializing the base fields (glucose, delta, trend, updatedAt, unit, iob, cob, projected). All extended fields added with the configurable grid slots (battery, sageInsertTime, pumpBattery, basalRate, autosens, tdd, targets, isf, carbRatio, carbsToday, profileName, CAGE, IAGE, minBg, maxBg, override, recBolus) were missing from the APNs payload, causing the extension to decode them as nil/0 and display '--' on every push-driven refresh. Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow/LiveActivity/APNSClient.swift | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/LoopFollow/LiveActivity/APNSClient.swift b/LoopFollow/LiveActivity/APNSClient.swift index 358d99469..94cee2a85 100644 --- a/LoopFollow/LiveActivity/APNSClient.swift +++ b/LoopFollow/LiveActivity/APNSClient.swift @@ -111,6 +111,25 @@ class APNSClient { 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, From dfe53b3ee18ac7fc10da6f90d4561ef2948c372e Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sun, 15 Mar 2026 22:57:45 -0400 Subject: [PATCH 48/86] feat: add small family view for CarPlay Dashboard and Watch Smart Stack Registers the Live Activity for the small activity family via .supplementalActivityFamilies([.small]), enabling automatic display on: - CarPlay Dashboard (iOS 26+) - Apple Watch Smart Stack (watchOS 11+) The small view is hardcoded to the essentials appropriate for a driving context: glucose value, trend arrow, delta, and time since last reading. Background tint matches the existing threshold-based color logic. The lock screen layout (full grid with configurable slots) is unchanged. Co-Authored-By: Claude Sonnet 4.6 --- .../LoopFollowLiveActivity.swift | 72 +++++++++++++++++-- 1 file changed, 65 insertions(+), 7 deletions(-) diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index d0f351611..354ea13df 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -9,13 +9,10 @@ import WidgetKit struct LoopFollowLiveActivityWidget: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: GlucoseLiveActivityAttributes.self) { context in - // LOCK SCREEN / BANNER UI - LockScreenLiveActivityView(state: context.state /* , activityID: context.activityID */ ) - .id(context.state.seq) // force SwiftUI to re-render on every update - .activitySystemActionForegroundColor(.white) - .activityBackgroundTint(LAColors.backgroundTint(for: context.state.snapshot)) - .applyActivityContentMarginsFixIfAvailable() - .widgetURL(URL(string: "loopfollow://la-tap")!) + // LOCK SCREEN / BANNER UI — also used for CarPlay Dashboard and Watch Smart Stack + // (small family) via supplementalActivityFamilies([.small]) + LockScreenFamilyAdaptiveView(state: context.state) + .id(context.state.seq) } dynamicIsland: { context in // DYNAMIC ISLAND UI DynamicIsland { @@ -52,6 +49,7 @@ struct LoopFollowLiveActivityWidget: Widget { } .keylineTint(LAColors.keyline(for: context.state.snapshot).opacity(0.75)) } + .supplementalActivityFamilies([.small]) } } @@ -69,6 +67,66 @@ private extension View { } } +// 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: compact glucose-only view +/// - everything else → full lock screen layout with configurable grid +@available(iOS 16.1, *) +private struct LockScreenFamilyAdaptiveView: View { + let state: GlucoseLiveActivityAttributes.ContentState + + @Environment(\.activityFamily) var activityFamily + + var body: some View { + if activityFamily == .small { + SmallFamilyView(snapshot: state.snapshot) + } else { + LockScreenLiveActivityView(state: state) + .activitySystemActionForegroundColor(.white) + .activityBackgroundTint(LAColors.backgroundTint(for: state.snapshot)) + .applyActivityContentMarginsFixIfAvailable() + .widgetURL(URL(string: "loopfollow://la-tap")!) + } + } +} + +// MARK: - Small family view (CarPlay Dashboard + Watch Smart Stack) + +/// Compact view shown on CarPlay Dashboard (iOS 26+) and Apple Watch Smart Stack (watchOS 11+). +/// Hardcoded to glucose + trend arrow + delta + time since last reading. +@available(iOS 16.1, *) +private struct SmallFamilyView: View { + let snapshot: GlucoseSnapshot + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(LAFormat.glucose(snapshot)) + .font(.system(size: 28, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white) + Text(LAFormat.trendArrow(snapshot)) + .font(.system(size: 22, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.9)) + } + HStack(spacing: 8) { + Text(LAFormat.delta(snapshot)) + .font(.system(size: 14, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.85)) + Text(LAFormat.updated(snapshot)) + .font(.system(size: 14, weight: .regular, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.65)) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .padding(10) + .activityBackgroundTint(LAColors.backgroundTint(for: snapshot)) + } +} + // MARK: - Lock Screen Contract View @available(iOS 16.1, *) From 2f28a1f0695e1410c1e3d54f1740df3422244776 Mon Sep 17 00:00:00 2001 From: Phil A <76601115+MtlPhil@users.noreply.github.com> Date: Mon, 16 Mar 2026 03:23:38 -0400 Subject: [PATCH 49/86] fix: include all extended InfoType fields in APNs push payload (#548) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Live Activity auto-renewal to work around 8-hour system limit Co-Authored-By: Claude Sonnet 4.6 * test: reduce LA renewal threshold to 20 min for testing Co-Authored-By: Claude Sonnet 4.6 * feat: improve LA renewal robustness and stale indicator - staleDate on every ActivityContent now tracks the renewal deadline, so the system shows Apple's built-in stale indicator if renewal fails - Add laRenewalFailed StorageValue; set on Activity.request() failure, cleared on any successful LA start - Observe willEnterForegroundNotification: retry startIfNeeded() if a previous renewal attempt failed - New-first renewal order: request the replacement LA before ending the old one — if the request throws the existing LA stays alive so the user keeps live data until the system kills it at the 8-hour mark Co-Authored-By: Claude Sonnet 4.6 * feat: renewal warning overlay + restore 7.5h threshold - Restore renewalThreshold to 7.5 * 3600 (testing complete) - Add showRenewalOverlay: Bool to GlucoseSnapshot (Codable, default false) - GlucoseSnapshotBuilder sets it true when now >= laRenewBy - 1800 (30 minutes before the renewal deadline) - Lock screen: 60% gray overlay with "Tap to update" centered in white, layered above the existing isNotLooping overlay - DI expanded: RenewalOverlayView applied to leading/trailing/bottom regions; "Tap to update" text shown on the bottom region only - showRenewalOverlay resets to false automatically on renewal since laRenewBy is updated and the next snapshot rebuild clears the flag Co-Authored-By: Claude Sonnet 4.6 * fix: overlay not appearing + foreground restart not working Overlay rendering: - Replace Group{if condition{ZStack{RoundedRectangle...}}} with a permanently-present ZStack toggled via .opacity(). The Group/if pattern causes SwiftUI sizing ambiguity when the condition transitions from false→true inside an .overlay(), producing a zero-size result. The .opacity() approach keeps a stable view hierarchy. - Same fix applied to RenewalOverlayView used on DI expanded regions. Foreground restart: - handleForeground() was calling startIfNeeded(), which finds the still-alive (failed-to-renew) LA in Activity.activities and reuses it, doing nothing useful. Fixed to manually nil out current, cancel all tasks, await activity.end(.immediate), then startFromCurrentState(). Overlay timing: - Changed warning window from 30 min (1800s) to 20 min (1200s) before the renewal deadline, matching the intended test cadence. Logging: - handleForeground: log on every foreground event with laRenewalFailed value - renewIfNeeded: log how many seconds past the deadline when firing - GlucoseSnapshotBuilder: log when overlay activates with seconds to deadline - performRefresh: log when sending an update with the overlay visible Co-Authored-By: Claude Sonnet 4.6 * test: set renewalThreshold to 20 min for testing Co-Authored-By: Claude Sonnet 4.6 * fix: renewal overlay not clearing after LA is refreshed Normal renewal path (renewIfNeeded success): - Build a fresh snapshot with showRenewalOverlay: false for the new LA's initial content — it has a new deadline so the overlay should never be visible from the first frame. - Save that fresh snapshot to GlucoseSnapshotStore so the next duplicate check has the correct baseline and doesn't suppress the first real BG update. Foreground restart path (handleForeground): - Zero laRenewBy before calling startFromCurrentState() so GlucoseSnapshotBuilder computes showRenewalOverlay = false for the seed snapshot. startIfNeeded() then sets the new deadline after the request succeeds. Co-Authored-By: Claude Sonnet 4.6 * fix: overlay permanently active when warning window equals threshold With renewalThreshold=20min and the hardcoded 1200s warning window, renewBy-1200 = start, so showRenewalOverlay is always true from the moment the LA begins. Extract a named renewalWarning constant (5 min for testing) so the warning window is always less than the threshold. The builder now reads LiveActivityManager.renewalWarning instead of a hardcoded literal. Production values to restore before merging: renewalThreshold = 7.5 * 3600 renewalWarning = 20 * 60 Co-Authored-By: Claude Sonnet 4.6 * fix: include showRenewalOverlay in APNs payload and clear laRenewBy synchronously APNSClient was missing showRenewalOverlay from the push payload, so background APNs updates never delivered the overlay flag to the extension — only foreground direct ActivityKit updates did. In handleForeground, laRenewBy is now zeroed synchronously before spawning the async end/restart Task. This means any snapshot built between the foreground notification and the new LA start (e.g. from viewDidAppear's startFromCurrentState) computes showRenewalOverlay = false rather than reading the stale expired deadline. Co-Authored-By: Claude Sonnet 4.6 * fix: await LA end before restarting on foreground retry to avoid reuse path Reset laRenewBy and laRenewalFailed synchronously before tearing down the failed LA, then await activity.end() before calling startFromCurrentState(). This guarantees Activity.activities is clear when startIfNeeded() runs, so it takes the fresh-request path and writes a new laRenewBy. startFromCurrentState rebuilds the snapshot with showRenewalOverlay=false (laRenewBy=0), saves it to the store, then startIfNeeded uses that clean snapshot as the seed. Co-Authored-By: Claude Sonnet 4.6 * chore: restore production renewal timing (7.5h threshold, 20min warning) Co-Authored-By: Claude Sonnet 4.6 * feat: laEnabled toggle, forceRestart(), and RestartLiveActivityIntent - SettingsMenuView: rename "APN" menu entry to "Live Activity" - Storage: add laEnabled: Bool StorageValue (default false) - APNSettingsView: on/off toggle bound to laEnabled; APNs key fields and Restart button shown only when enabled; disabling immediately ends any running LA - LiveActivityManager: - forceRestart() (@MainActor) ends all running activities, resets laRenewBy and laRenewalFailed, then calls startFromCurrentState() - laEnabled guard added to startFromCurrentState(), refreshFromCurrentState(), handleForeground(), and handleDidBecomeActive() - didBecomeActiveNotification observer calls forceRestart() on every foreground transition when laEnabled is true - RestartLiveActivityIntent: AppIntent + ForegroundContinuableIntent; sets laEnabled, validates credentials, opens settings deep link if missing, otherwise calls forceRestart() in foreground; LoopFollowAppShortcuts exposes the intent with Siri phrase Note: RestartLiveActivityIntent.swift must be added to the app target in Xcode. Co-Authored-By: Claude Sonnet 4.6 * Added RestartLiveActivityIntent to project * fix: resolve two build errors in LiveActivityManager and RestartLiveActivityIntent - LiveActivityManager: handleDidBecomeActive() calls @MainActor forceRestart() via Task { @MainActor in ... } to satisfy Swift concurrency isolation - RestartLiveActivityIntent: drop ForegroundContinuableIntent — continueInForeground() was renamed to continueInForeground(_:alwaysConfirm:) in the iOS 26 SDK and requires iOS 26+; the didBecomeActiveNotification observer handles restart when the app comes to foreground, making explicit continuation unnecessary Co-Authored-By: Claude Sonnet 4.6 * fix: guard continueInForeground() behind iOS 26 availability check continueInForeground(_:alwaysConfirm:) is iOS 26+ only. On earlier versions, forceRestart() runs directly via the existing background audio session — no foreground continuation needed. Co-Authored-By: Claude Sonnet 4.6 * fix: use startFromCurrentState in handleDidBecomeActive instead of forceRestart forceRestart() was killing the LA that the RestartLiveActivityIntent had just created, because continueInForeground() triggers didBecomeActive. startFromCurrentState() reuses an existing active LA via startIfNeeded() and only creates one if none is present — no destructive race. Co-Authored-By: Claude Sonnet 4.6 * feat: LA foreground tab navigation, button feedback, and toggle sync APNSettingsView: - "Restart Live Activity" button shows "Live Activity Restarted" for 2 seconds and disables itself during that window to prevent double-taps - Toggle observes Storage.shared.laEnabled via onReceive so it reflects changes made externally (e.g. by the App Intent / Shortcuts) LiveActivityManager: - handleDidBecomeActive posts .liveActivityDidForeground after startFromCurrentState - Notification.Name.liveActivityDidForeground extension defined here MainViewController: - Observes .liveActivityDidForeground; navigates to Snoozer tab if an alarm is currently active, otherwise navigates to tab 0 (Home) Co-Authored-By: Claude Sonnet 4.6 * fix: flush LA update on willResignActive to ensure lock screen shows latest data The 5-second debounce on refreshFromCurrentState means a BG update arriving while the app is foreground won't reach the LA before the user returns to the lock screen. When the debounce fires in background, isForeground=false so the direct ActivityKit update is skipped. Fix: observe willResignActiveNotification, cancel the pending debounce, and immediately push the latest snapshot via direct ActivityKit update while the app is still foreground-active. The APNs push is also sent as a backup. Co-Authored-By: Claude Sonnet 4.6 * feat: redesign Dynamic Island compact and expanded views Compact: - Trailing now shows delta instead of trend arrow (leading already showed BG) Expanded leading: - Large BG value at top - Second row: trend arrow, delta, and "Proj: X" in smaller text below Expanded trailing: - IOB and COB (moved from bottom) Expanded bottom: - "Updated at: HH:mm" (moved from trailing) Co-Authored-By: Claude Sonnet 4.6 * fix: match Proj text style to delta; add trailing padding to IOB/COB - Proj label now uses same size (13), weight (semibold), and opacity (0.9) as the delta text beside it - Added 6pt trailing padding to the expanded DI trailing VStack so the IOB decimal is not clipped by the Dynamic Island edge Co-Authored-By: Claude Sonnet 4.6 * feat: separate Live Activity and APN settings into distinct menus Per maintainer preference, the APNs credentials (Key ID and Key) remain in their own "APN" settings screen. A new separate "Live Activity" settings screen contains the enable/disable toggle and the Restart Live Activity button. - APNSettingsView: restored to Key ID + Key only (no toggle or restart button) - LiveActivitySettingsView: new view with laEnabled toggle and restart button (with 2-second confirmation feedback); toggle stays in sync via onReceive - SettingsMenuView: "APN" entry restored; new "Live Activity" entry added with dot.radiowaves icon; Sheet.liveActivity case wired to LiveActivitySettingsView Co-Authored-By: Claude Sonnet 4.6 * Added Live Activity menu * chore: add LiveActivitySettingsView to Xcode project Co-Authored-By: Claude Sonnet 4.6 * fix: LA tap navigation, manual dismissal prevention, and toggle start - widgetURL on lock screen LA → opens app via loopfollow://la-tap - Link views in expanded DI regions → same URL on tap - AppDelegate.application(_:open:) handles loopfollow://la-tap and posts .liveActivityDidForeground so MainViewController navigates to Snoozer (if alarm active) or Home tab — fires only on LA/DI taps, not every foreground - Notification.Name.liveActivityDidForeground moved outside #if macCatalyst so AppDelegate can reference it unconditionally - handleDidBecomeActive no longer posts .liveActivityDidForeground (was firing on every app foreground, not just LA taps) - attachStateObserver: .dismissed state (user swipe) sets laEnabled = false so the LA does not auto-restart when the app foregrounds - LiveActivitySettingsView: toggling ON calls startFromCurrentState() Co-Authored-By: Claude Sonnet 4.6 * fix: end Live Activity on app force-quit Adds endOnTerminate() to LiveActivityManager — blocks up to 3 s using Task.detached + DispatchSemaphore so the async activity.end() completes before the process exits. Called from applicationWillTerminate so the LA clears from the lock screen immediately on force-quit. laEnabled is preserved so the LA restarts correctly on next launch. Co-Authored-By: Claude Sonnet 4.6 * fix: use dismissedByUser flag instead of disabling laEnabled on manual dismiss - Manual LA swipe-away sets dismissedByUser (in-memory) instead of laEnabled = false, preserving the user's preference - startFromCurrentState() guards on !dismissedByUser so the LA does not auto-restart on foreground, BG refresh, or handleDidBecomeActive - forceRestart() clears dismissedByUser before starting, so the Restart button and App Intent both work as the explicit re-enable mechanism - Toggle ON in settings calls forceRestart() (clears flag + starts) instead of startFromCurrentState() which would have been blocked by the flag - dismissedByUser resets to false on app relaunch (in-memory only), so a kill + relaunch starts the LA fresh as expected Co-Authored-By: Claude Sonnet 4.6 * fix: dismiss modal (Settings sheet) before tab switch on LA tap navigateOnLAForeground now checks tabBarController.presentedViewController and dismisses it before switching to the target tab, matching the existing pattern used elsewhere in MainViewController for the same scenario. Co-Authored-By: Claude Sonnet 4.6 * fix: LA tap navigation timing and LA reappear-after-dismiss Navigation fix: - loopfollow://la-tap URL can arrive before applicationDidBecomeActive while UIKit is still restoring the view hierarchy from background. At that moment tabBarController.presentedViewController is nil so the Settings modal is never dismissed and the navigation notification fires too early. - Now stores a pendingLATapNavigation flag if app is not yet .active, and fires the notification in applicationDidBecomeActive when the full view hierarchy (including presented modals) is restored. LA reappear fix: - performRefresh() calls startIfNeeded() directly, bypassing the dismissedByUser guard that only exists in startFromCurrentState(). On every BG refresh with laEnabled=true and current==nil (post-dismiss), it would recreate the LA. - Added !dismissedByUser guard to refreshFromCurrentState() so the entire refresh pipeline is skipped while dismissed, matching the existing guard in startFromCurrentState(). Co-Authored-By: Claude Sonnet 4.6 * fix: handle loopfollow://la-tap URL in SceneDelegate, not AppDelegate With scene-based lifecycle (iOS 13+), widget/LA tap URLs are delivered to scene(_:openURLContexts:) on UIWindowSceneDelegate — AppDelegate.application (_:open:url:options:) is never called. Our previous handler was dead code. SceneDelegate now implements scene(_:openURLContexts:) which posts .liveActivityDidForeground on the next run loop (via DispatchQueue.main.async) to let the view hierarchy fully settle before navigateOnLAForeground() runs. Also handles the edge case where the URL arrives before sceneDidBecomeActive by storing a pendingLATapNavigation flag. Co-Authored-By: Claude Sonnet 4.6 * feat: configurable LA grid slots + full InfoType snapshot coverage Lock screen layout: - Left column: glucose + trend arrow, delta below (replaces "Last Update:") - Right grid: 4 configurable slots read from LAAppGroupSettings (defaults: IOB/COB/Proj/empty) - Footer: centered HH:MM update time at reduced opacity Slot configuration: - LiveActivitySlotOption enum with all 22 InfoType-aligned cases - LAAppGroupSettings.setSlots() / slots() persisted to App Group UserDefaults - Uniqueness enforced: selecting an option clears it from any other slot - Settings UI: "Grid slots" section with 4 pickers in LiveActivitySettingsView - Changes take effect immediately (refreshFromCurrentState called on save) GlucoseSnapshot extended with 19 new fields covering all InfoType items: override, recBolus, battery, pumpBattery, basalRate, pumpReservoirU, autosens, tdd, targetLowMgdl/High, isfMgdlPerU, carbRatio, carbsToday, profileName, sageInsertTime, cageInsertTime, iageInsertTime, minBgMgdl/Max Storage.swift: 13 new UserDefaults-backed fields for the above metrics Controllers updated to write new Storage fields on each data fetch: Basals, DeviceStatus, DeviceStatusLoop, DeviceStatusOpenAPS, Carbs, Profile, IAge Co-Authored-By: Claude Sonnet 4.6 * fix: label delta and footer on lock screen LA card Co-Authored-By: Claude Sonnet 4.6 * docs: add PR description for configurable LA grid slots Co-Authored-By: Claude Sonnet 4.6 * Update PR_configurable_slots.md * chore: remove PR notes from tracking, keep docs/LiveActivity.md only - Untrack docs/PR_configurable_slots.md (local-only reference doc) - Add docs/PR_configurable_slots.md and docs/LiveActivityTestPlan.md to .gitignore Co-Authored-By: Claude Sonnet 4.6 * fix: include all extended InfoType fields in APNs push payload buildPayload() was only serializing the base fields (glucose, delta, trend, updatedAt, unit, iob, cob, projected). All extended fields added with the configurable grid slots (battery, sageInsertTime, pumpBattery, basalRate, autosens, tdd, targets, isf, carbRatio, carbsToday, profileName, CAGE, IAGE, minBg, maxBg, override, recBolus) were missing from the APNs payload, causing the extension to decode them as nil/0 and display '--' on every push-driven refresh. Co-Authored-By: Claude Sonnet 4.6 * feat: add small family view for CarPlay Dashboard and Watch Smart Stack Registers the Live Activity for the small activity family via .supplementalActivityFamilies([.small]), enabling automatic display on: - CarPlay Dashboard (iOS 26+) - Apple Watch Smart Stack (watchOS 11+) The small view is hardcoded to the essentials appropriate for a driving context: glucose value, trend arrow, delta, and time since last reading. Background tint matches the existing threshold-based color logic. The lock screen layout (full grid with configurable slots) is unchanged. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- LoopFollow/LiveActivity/APNSClient.swift | 19 +++++ .../LoopFollowLiveActivity.swift | 72 +++++++++++++++++-- 2 files changed, 84 insertions(+), 7 deletions(-) diff --git a/LoopFollow/LiveActivity/APNSClient.swift b/LoopFollow/LiveActivity/APNSClient.swift index 358d99469..94cee2a85 100644 --- a/LoopFollow/LiveActivity/APNSClient.swift +++ b/LoopFollow/LiveActivity/APNSClient.swift @@ -111,6 +111,25 @@ class APNSClient { 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, diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index d0f351611..354ea13df 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -9,13 +9,10 @@ import WidgetKit struct LoopFollowLiveActivityWidget: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: GlucoseLiveActivityAttributes.self) { context in - // LOCK SCREEN / BANNER UI - LockScreenLiveActivityView(state: context.state /* , activityID: context.activityID */ ) - .id(context.state.seq) // force SwiftUI to re-render on every update - .activitySystemActionForegroundColor(.white) - .activityBackgroundTint(LAColors.backgroundTint(for: context.state.snapshot)) - .applyActivityContentMarginsFixIfAvailable() - .widgetURL(URL(string: "loopfollow://la-tap")!) + // LOCK SCREEN / BANNER UI — also used for CarPlay Dashboard and Watch Smart Stack + // (small family) via supplementalActivityFamilies([.small]) + LockScreenFamilyAdaptiveView(state: context.state) + .id(context.state.seq) } dynamicIsland: { context in // DYNAMIC ISLAND UI DynamicIsland { @@ -52,6 +49,7 @@ struct LoopFollowLiveActivityWidget: Widget { } .keylineTint(LAColors.keyline(for: context.state.snapshot).opacity(0.75)) } + .supplementalActivityFamilies([.small]) } } @@ -69,6 +67,66 @@ private extension View { } } +// 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: compact glucose-only view +/// - everything else → full lock screen layout with configurable grid +@available(iOS 16.1, *) +private struct LockScreenFamilyAdaptiveView: View { + let state: GlucoseLiveActivityAttributes.ContentState + + @Environment(\.activityFamily) var activityFamily + + var body: some View { + if activityFamily == .small { + SmallFamilyView(snapshot: state.snapshot) + } else { + LockScreenLiveActivityView(state: state) + .activitySystemActionForegroundColor(.white) + .activityBackgroundTint(LAColors.backgroundTint(for: state.snapshot)) + .applyActivityContentMarginsFixIfAvailable() + .widgetURL(URL(string: "loopfollow://la-tap")!) + } + } +} + +// MARK: - Small family view (CarPlay Dashboard + Watch Smart Stack) + +/// Compact view shown on CarPlay Dashboard (iOS 26+) and Apple Watch Smart Stack (watchOS 11+). +/// Hardcoded to glucose + trend arrow + delta + time since last reading. +@available(iOS 16.1, *) +private struct SmallFamilyView: View { + let snapshot: GlucoseSnapshot + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(LAFormat.glucose(snapshot)) + .font(.system(size: 28, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white) + Text(LAFormat.trendArrow(snapshot)) + .font(.system(size: 22, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.9)) + } + HStack(spacing: 8) { + Text(LAFormat.delta(snapshot)) + .font(.system(size: 14, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.85)) + Text(LAFormat.updated(snapshot)) + .font(.system(size: 14, weight: .regular, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.65)) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .padding(10) + .activityBackgroundTint(LAColors.backgroundTint(for: snapshot)) + } +} + // MARK: - Lock Screen Contract View @available(iOS 16.1, *) From a98f0a88cfc2683313dd75aa7536a8d107b97e16 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Mon, 16 Mar 2026 07:41:41 -0400 Subject: [PATCH 50/86] fix: guard CarPlay/Watch small family behind iOS 18 availability; increase renewal overlay opacity - supplementalActivityFamilies and activityFamily require iOS 18.0+; restructured into two Widget structs sharing a makeDynamicIsland() helper: - LoopFollowLiveActivityWidget (iOS 16.1+): lock screen + Dynamic Island - LoopFollowLiveActivityWidgetWithCarPlay (iOS 18.0+): adds .supplementalActivityFamilies([.small]) - Bundle uses if #available(iOS 18.0, *) to select the right variant - Podfile: set minimum deployment target to 16.6 for all pods - Increase 'Tap to update' renewal overlay opacity from 60% to 90% Co-Authored-By: Claude Sonnet 4.6 --- .../LoopFollowLABundle.swift | 5 +- .../LoopFollowLiveActivity.swift | 101 +++++++++++------- Podfile | 9 ++ 3 files changed, 74 insertions(+), 41 deletions(-) diff --git a/LoopFollowLAExtension/LoopFollowLABundle.swift b/LoopFollowLAExtension/LoopFollowLABundle.swift index e3a043783..1f901463f 100644 --- a/LoopFollowLAExtension/LoopFollowLABundle.swift +++ b/LoopFollowLAExtension/LoopFollowLABundle.swift @@ -11,7 +11,10 @@ import WidgetKit @main struct LoopFollowLABundle: WidgetBundle { var body: some Widget { - if #available(iOS 16.1, *) { + if #available(iOS 18.0, *) { + // CarPlay Dashboard + Watch Smart Stack support (iOS 18+) + LoopFollowLiveActivityWidgetWithCarPlay() + } else { LoopFollowLiveActivityWidget() } } diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 354ea13df..51e144f54 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -5,49 +5,70 @@ import ActivityKit import SwiftUI import WidgetKit +/// Builds the shared Dynamic Island content used by both widget variants. +@available(iOS 16.1, *) +private func makeDynamicIsland(context: ActivityViewContext) -> DynamicIsland { + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + Link(destination: URL(string: "loopfollow://la-tap")!) { + DynamicIslandLeadingView(snapshot: context.state.snapshot) + .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) + } + .id(context.state.seq) + } + DynamicIslandExpandedRegion(.trailing) { + Link(destination: URL(string: "loopfollow://la-tap")!) { + DynamicIslandTrailingView(snapshot: context.state.snapshot) + .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) + } + .id(context.state.seq) + } + DynamicIslandExpandedRegion(.bottom) { + Link(destination: URL(string: "loopfollow://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)) +} + +/// Base widget — Lock Screen + Dynamic Island. Used on iOS 16.1–17.x. @available(iOS 16.1, *) struct LoopFollowLiveActivityWidget: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: GlucoseLiveActivityAttributes.self) { context in - // LOCK SCREEN / BANNER UI — also used for CarPlay Dashboard and Watch Smart Stack - // (small family) via supplementalActivityFamilies([.small]) + LockScreenLiveActivityView(state: context.state) + .id(context.state.seq) + .activitySystemActionForegroundColor(.white) + .activityBackgroundTint(LAColors.backgroundTint(for: context.state.snapshot)) + .applyActivityContentMarginsFixIfAvailable() + .widgetURL(URL(string: "loopfollow://la-tap")!) + } dynamicIsland: { context in + makeDynamicIsland(context: context) + } + } +} + +/// iOS 18+ widget — adds CarPlay Dashboard + Watch Smart Stack via the small activity family. +@available(iOS 18.0, *) +struct LoopFollowLiveActivityWidgetWithCarPlay: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: GlucoseLiveActivityAttributes.self) { context in LockScreenFamilyAdaptiveView(state: context.state) .id(context.state.seq) } dynamicIsland: { context in - // DYNAMIC ISLAND UI - DynamicIsland { - DynamicIslandExpandedRegion(.leading) { - Link(destination: URL(string: "loopfollow://la-tap")!) { - DynamicIslandLeadingView(snapshot: context.state.snapshot) - .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) - } - .id(context.state.seq) - } - DynamicIslandExpandedRegion(.trailing) { - Link(destination: URL(string: "loopfollow://la-tap")!) { - DynamicIslandTrailingView(snapshot: context.state.snapshot) - .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) - } - .id(context.state.seq) - } - DynamicIslandExpandedRegion(.bottom) { - Link(destination: URL(string: "loopfollow://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)) + makeDynamicIsland(context: context) } .supplementalActivityFamilies([.small]) } @@ -72,7 +93,7 @@ private extension View { /// Reads the activityFamily environment value and routes to the appropriate layout. /// - `.small` → CarPlay Dashboard & Watch Smart Stack: compact glucose-only view /// - everything else → full lock screen layout with configurable grid -@available(iOS 16.1, *) +@available(iOS 18.0, *) private struct LockScreenFamilyAdaptiveView: View { let state: GlucoseLiveActivityAttributes.ContentState @@ -95,7 +116,7 @@ private struct LockScreenFamilyAdaptiveView: View { /// Compact view shown on CarPlay Dashboard (iOS 26+) and Apple Watch Smart Stack (watchOS 11+). /// Hardcoded to glucose + trend arrow + delta + time since last reading. -@available(iOS 16.1, *) +@available(iOS 18.0, *) private struct SmallFamilyView: View { let snapshot: GlucoseSnapshot @@ -213,7 +234,7 @@ private struct LockScreenLiveActivityView: View { .overlay( ZStack { RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(Color.gray.opacity(0.6)) + .fill(Color.gray.opacity(0.9)) Text("Tap to update") .font(.system(size: 20, weight: .semibold)) .foregroundStyle(.white) @@ -231,7 +252,7 @@ private struct RenewalOverlayView: View { var body: some View { ZStack { - Color.gray.opacity(0.6) + Color.gray.opacity(0.9) if showText { Text("Tap to update") .font(.system(size: 14, weight: .semibold)) diff --git a/Podfile b/Podfile index 5a8c0f868..f8c2df3b2 100644 --- a/Podfile +++ b/Podfile @@ -7,6 +7,15 @@ target 'LoopFollow' do end post_install do |installer| + # Set minimum deployment target for all pods to match the app (suppresses deprecation warnings) + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + if config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'].to_f < 16.6 + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '16.6' + end + end + end + # Patch Charts Transformer to avoid "CGAffineTransformInvert: singular matrix" # warnings when chart views have zero dimensions (before layout). transformer = 'Pods/Charts/Source/Charts/Utils/Transformer.swift' From 65e679ad30eda00d2d032ab44e635be059340deb Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Mon, 16 Mar 2026 08:24:36 -0400 Subject: [PATCH 51/86] fix: move if #available into Widget.body to avoid WidgetBundleBuilder limitation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @WidgetBundleBuilder does not support if #available { } else { }, but @WidgetConfigurationBuilder (used by Widget.body) does. Collapsed back to a single LoopFollowLiveActivityWidget struct with the iOS 18 conditional inside body — iOS 18+ branch adds .supplementalActivityFamilies([.small]) for CarPlay and Watch Smart Stack; else branch uses the plain lock screen view. Bundle reverts to the original single if #available(iOS 16.1, *) pattern. Co-Authored-By: Claude Sonnet 4.6 --- .../LoopFollowLABundle.swift | 5 +- .../LoopFollowLiveActivity.swift | 46 +++++++++---------- 2 files changed, 23 insertions(+), 28 deletions(-) diff --git a/LoopFollowLAExtension/LoopFollowLABundle.swift b/LoopFollowLAExtension/LoopFollowLABundle.swift index 1f901463f..e3a043783 100644 --- a/LoopFollowLAExtension/LoopFollowLABundle.swift +++ b/LoopFollowLAExtension/LoopFollowLABundle.swift @@ -11,10 +11,7 @@ import WidgetKit @main struct LoopFollowLABundle: WidgetBundle { var body: some Widget { - if #available(iOS 18.0, *) { - // CarPlay Dashboard + Watch Smart Stack support (iOS 18+) - LoopFollowLiveActivityWidgetWithCarPlay() - } else { + if #available(iOS 16.1, *) { LoopFollowLiveActivityWidget() } } diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 51e144f54..4e3c3a84c 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -43,34 +43,32 @@ private func makeDynamicIsland(context: ActivityViewContext Date: Mon, 16 Mar 2026 08:34:19 -0400 Subject: [PATCH 52/86] fix: use two separate single-branch if #available in bundle for CarPlay support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @WidgetConfigurationBuilder's buildEither requires both branches to return the same concrete type, making if/else with supplementalActivityFamilies impossible (it wraps to a different opaque type). @WidgetBundleBuilder does not support if #available { } else { } at all. Solution: two separate single-branch if #available blocks in the bundle — the pattern that @WidgetBundleBuilder already supported in the original code: - LoopFollowLiveActivityWidget (iOS 16.1+): primary, lock screen + Dynamic Island - LoopFollowLiveActivityWidgetWithCarPlay (iOS 18.0+): supplemental, adds .supplementalActivityFamilies([.small]) for CarPlay Dashboard + Watch Smart Stack ActivityKit uses the supplemental widget for small-family surfaces and the primary widget for lock screen / Dynamic Island, keeping iOS 16.6+ support intact. Co-Authored-By: Claude Sonnet 4.6 --- .../LoopFollowLABundle.swift | 3 ++ .../LoopFollowLiveActivity.swift | 48 ++++++++++--------- 2 files changed, 29 insertions(+), 22 deletions(-) diff --git a/LoopFollowLAExtension/LoopFollowLABundle.swift b/LoopFollowLAExtension/LoopFollowLABundle.swift index e3a043783..a9f7daf6c 100644 --- a/LoopFollowLAExtension/LoopFollowLABundle.swift +++ b/LoopFollowLAExtension/LoopFollowLABundle.swift @@ -14,5 +14,8 @@ struct LoopFollowLABundle: WidgetBundle { if #available(iOS 16.1, *) { LoopFollowLiveActivityWidget() } + if #available(iOS 18.0, *) { + LoopFollowLiveActivityWidgetWithCarPlay() + } } } diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 4e3c3a84c..5e33cb500 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -43,32 +43,36 @@ private func makeDynamicIsland(context: ActivityViewContext Date: Mon, 16 Mar 2026 11:33:59 -0400 Subject: [PATCH 53/86] Live Activity: CarPlay Dashboard + Apple Watch Smart Stack support (#549) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Live Activity auto-renewal to work around 8-hour system limit Co-Authored-By: Claude Sonnet 4.6 * test: reduce LA renewal threshold to 20 min for testing Co-Authored-By: Claude Sonnet 4.6 * feat: improve LA renewal robustness and stale indicator - staleDate on every ActivityContent now tracks the renewal deadline, so the system shows Apple's built-in stale indicator if renewal fails - Add laRenewalFailed StorageValue; set on Activity.request() failure, cleared on any successful LA start - Observe willEnterForegroundNotification: retry startIfNeeded() if a previous renewal attempt failed - New-first renewal order: request the replacement LA before ending the old one — if the request throws the existing LA stays alive so the user keeps live data until the system kills it at the 8-hour mark Co-Authored-By: Claude Sonnet 4.6 * feat: renewal warning overlay + restore 7.5h threshold - Restore renewalThreshold to 7.5 * 3600 (testing complete) - Add showRenewalOverlay: Bool to GlucoseSnapshot (Codable, default false) - GlucoseSnapshotBuilder sets it true when now >= laRenewBy - 1800 (30 minutes before the renewal deadline) - Lock screen: 60% gray overlay with "Tap to update" centered in white, layered above the existing isNotLooping overlay - DI expanded: RenewalOverlayView applied to leading/trailing/bottom regions; "Tap to update" text shown on the bottom region only - showRenewalOverlay resets to false automatically on renewal since laRenewBy is updated and the next snapshot rebuild clears the flag Co-Authored-By: Claude Sonnet 4.6 * fix: overlay not appearing + foreground restart not working Overlay rendering: - Replace Group{if condition{ZStack{RoundedRectangle...}}} with a permanently-present ZStack toggled via .opacity(). The Group/if pattern causes SwiftUI sizing ambiguity when the condition transitions from false→true inside an .overlay(), producing a zero-size result. The .opacity() approach keeps a stable view hierarchy. - Same fix applied to RenewalOverlayView used on DI expanded regions. Foreground restart: - handleForeground() was calling startIfNeeded(), which finds the still-alive (failed-to-renew) LA in Activity.activities and reuses it, doing nothing useful. Fixed to manually nil out current, cancel all tasks, await activity.end(.immediate), then startFromCurrentState(). Overlay timing: - Changed warning window from 30 min (1800s) to 20 min (1200s) before the renewal deadline, matching the intended test cadence. Logging: - handleForeground: log on every foreground event with laRenewalFailed value - renewIfNeeded: log how many seconds past the deadline when firing - GlucoseSnapshotBuilder: log when overlay activates with seconds to deadline - performRefresh: log when sending an update with the overlay visible Co-Authored-By: Claude Sonnet 4.6 * test: set renewalThreshold to 20 min for testing Co-Authored-By: Claude Sonnet 4.6 * fix: renewal overlay not clearing after LA is refreshed Normal renewal path (renewIfNeeded success): - Build a fresh snapshot with showRenewalOverlay: false for the new LA's initial content — it has a new deadline so the overlay should never be visible from the first frame. - Save that fresh snapshot to GlucoseSnapshotStore so the next duplicate check has the correct baseline and doesn't suppress the first real BG update. Foreground restart path (handleForeground): - Zero laRenewBy before calling startFromCurrentState() so GlucoseSnapshotBuilder computes showRenewalOverlay = false for the seed snapshot. startIfNeeded() then sets the new deadline after the request succeeds. Co-Authored-By: Claude Sonnet 4.6 * fix: overlay permanently active when warning window equals threshold With renewalThreshold=20min and the hardcoded 1200s warning window, renewBy-1200 = start, so showRenewalOverlay is always true from the moment the LA begins. Extract a named renewalWarning constant (5 min for testing) so the warning window is always less than the threshold. The builder now reads LiveActivityManager.renewalWarning instead of a hardcoded literal. Production values to restore before merging: renewalThreshold = 7.5 * 3600 renewalWarning = 20 * 60 Co-Authored-By: Claude Sonnet 4.6 * fix: include showRenewalOverlay in APNs payload and clear laRenewBy synchronously APNSClient was missing showRenewalOverlay from the push payload, so background APNs updates never delivered the overlay flag to the extension — only foreground direct ActivityKit updates did. In handleForeground, laRenewBy is now zeroed synchronously before spawning the async end/restart Task. This means any snapshot built between the foreground notification and the new LA start (e.g. from viewDidAppear's startFromCurrentState) computes showRenewalOverlay = false rather than reading the stale expired deadline. Co-Authored-By: Claude Sonnet 4.6 * fix: await LA end before restarting on foreground retry to avoid reuse path Reset laRenewBy and laRenewalFailed synchronously before tearing down the failed LA, then await activity.end() before calling startFromCurrentState(). This guarantees Activity.activities is clear when startIfNeeded() runs, so it takes the fresh-request path and writes a new laRenewBy. startFromCurrentState rebuilds the snapshot with showRenewalOverlay=false (laRenewBy=0), saves it to the store, then startIfNeeded uses that clean snapshot as the seed. Co-Authored-By: Claude Sonnet 4.6 * chore: restore production renewal timing (7.5h threshold, 20min warning) Co-Authored-By: Claude Sonnet 4.6 * feat: laEnabled toggle, forceRestart(), and RestartLiveActivityIntent - SettingsMenuView: rename "APN" menu entry to "Live Activity" - Storage: add laEnabled: Bool StorageValue (default false) - APNSettingsView: on/off toggle bound to laEnabled; APNs key fields and Restart button shown only when enabled; disabling immediately ends any running LA - LiveActivityManager: - forceRestart() (@MainActor) ends all running activities, resets laRenewBy and laRenewalFailed, then calls startFromCurrentState() - laEnabled guard added to startFromCurrentState(), refreshFromCurrentState(), handleForeground(), and handleDidBecomeActive() - didBecomeActiveNotification observer calls forceRestart() on every foreground transition when laEnabled is true - RestartLiveActivityIntent: AppIntent + ForegroundContinuableIntent; sets laEnabled, validates credentials, opens settings deep link if missing, otherwise calls forceRestart() in foreground; LoopFollowAppShortcuts exposes the intent with Siri phrase Note: RestartLiveActivityIntent.swift must be added to the app target in Xcode. Co-Authored-By: Claude Sonnet 4.6 * Added RestartLiveActivityIntent to project * fix: resolve two build errors in LiveActivityManager and RestartLiveActivityIntent - LiveActivityManager: handleDidBecomeActive() calls @MainActor forceRestart() via Task { @MainActor in ... } to satisfy Swift concurrency isolation - RestartLiveActivityIntent: drop ForegroundContinuableIntent — continueInForeground() was renamed to continueInForeground(_:alwaysConfirm:) in the iOS 26 SDK and requires iOS 26+; the didBecomeActiveNotification observer handles restart when the app comes to foreground, making explicit continuation unnecessary Co-Authored-By: Claude Sonnet 4.6 * fix: guard continueInForeground() behind iOS 26 availability check continueInForeground(_:alwaysConfirm:) is iOS 26+ only. On earlier versions, forceRestart() runs directly via the existing background audio session — no foreground continuation needed. Co-Authored-By: Claude Sonnet 4.6 * fix: use startFromCurrentState in handleDidBecomeActive instead of forceRestart forceRestart() was killing the LA that the RestartLiveActivityIntent had just created, because continueInForeground() triggers didBecomeActive. startFromCurrentState() reuses an existing active LA via startIfNeeded() and only creates one if none is present — no destructive race. Co-Authored-By: Claude Sonnet 4.6 * feat: LA foreground tab navigation, button feedback, and toggle sync APNSettingsView: - "Restart Live Activity" button shows "Live Activity Restarted" for 2 seconds and disables itself during that window to prevent double-taps - Toggle observes Storage.shared.laEnabled via onReceive so it reflects changes made externally (e.g. by the App Intent / Shortcuts) LiveActivityManager: - handleDidBecomeActive posts .liveActivityDidForeground after startFromCurrentState - Notification.Name.liveActivityDidForeground extension defined here MainViewController: - Observes .liveActivityDidForeground; navigates to Snoozer tab if an alarm is currently active, otherwise navigates to tab 0 (Home) Co-Authored-By: Claude Sonnet 4.6 * fix: flush LA update on willResignActive to ensure lock screen shows latest data The 5-second debounce on refreshFromCurrentState means a BG update arriving while the app is foreground won't reach the LA before the user returns to the lock screen. When the debounce fires in background, isForeground=false so the direct ActivityKit update is skipped. Fix: observe willResignActiveNotification, cancel the pending debounce, and immediately push the latest snapshot via direct ActivityKit update while the app is still foreground-active. The APNs push is also sent as a backup. Co-Authored-By: Claude Sonnet 4.6 * feat: redesign Dynamic Island compact and expanded views Compact: - Trailing now shows delta instead of trend arrow (leading already showed BG) Expanded leading: - Large BG value at top - Second row: trend arrow, delta, and "Proj: X" in smaller text below Expanded trailing: - IOB and COB (moved from bottom) Expanded bottom: - "Updated at: HH:mm" (moved from trailing) Co-Authored-By: Claude Sonnet 4.6 * fix: match Proj text style to delta; add trailing padding to IOB/COB - Proj label now uses same size (13), weight (semibold), and opacity (0.9) as the delta text beside it - Added 6pt trailing padding to the expanded DI trailing VStack so the IOB decimal is not clipped by the Dynamic Island edge Co-Authored-By: Claude Sonnet 4.6 * feat: separate Live Activity and APN settings into distinct menus Per maintainer preference, the APNs credentials (Key ID and Key) remain in their own "APN" settings screen. A new separate "Live Activity" settings screen contains the enable/disable toggle and the Restart Live Activity button. - APNSettingsView: restored to Key ID + Key only (no toggle or restart button) - LiveActivitySettingsView: new view with laEnabled toggle and restart button (with 2-second confirmation feedback); toggle stays in sync via onReceive - SettingsMenuView: "APN" entry restored; new "Live Activity" entry added with dot.radiowaves icon; Sheet.liveActivity case wired to LiveActivitySettingsView Co-Authored-By: Claude Sonnet 4.6 * Added Live Activity menu * chore: add LiveActivitySettingsView to Xcode project Co-Authored-By: Claude Sonnet 4.6 * fix: LA tap navigation, manual dismissal prevention, and toggle start - widgetURL on lock screen LA → opens app via loopfollow://la-tap - Link views in expanded DI regions → same URL on tap - AppDelegate.application(_:open:) handles loopfollow://la-tap and posts .liveActivityDidForeground so MainViewController navigates to Snoozer (if alarm active) or Home tab — fires only on LA/DI taps, not every foreground - Notification.Name.liveActivityDidForeground moved outside #if macCatalyst so AppDelegate can reference it unconditionally - handleDidBecomeActive no longer posts .liveActivityDidForeground (was firing on every app foreground, not just LA taps) - attachStateObserver: .dismissed state (user swipe) sets laEnabled = false so the LA does not auto-restart when the app foregrounds - LiveActivitySettingsView: toggling ON calls startFromCurrentState() Co-Authored-By: Claude Sonnet 4.6 * fix: end Live Activity on app force-quit Adds endOnTerminate() to LiveActivityManager — blocks up to 3 s using Task.detached + DispatchSemaphore so the async activity.end() completes before the process exits. Called from applicationWillTerminate so the LA clears from the lock screen immediately on force-quit. laEnabled is preserved so the LA restarts correctly on next launch. Co-Authored-By: Claude Sonnet 4.6 * fix: use dismissedByUser flag instead of disabling laEnabled on manual dismiss - Manual LA swipe-away sets dismissedByUser (in-memory) instead of laEnabled = false, preserving the user's preference - startFromCurrentState() guards on !dismissedByUser so the LA does not auto-restart on foreground, BG refresh, or handleDidBecomeActive - forceRestart() clears dismissedByUser before starting, so the Restart button and App Intent both work as the explicit re-enable mechanism - Toggle ON in settings calls forceRestart() (clears flag + starts) instead of startFromCurrentState() which would have been blocked by the flag - dismissedByUser resets to false on app relaunch (in-memory only), so a kill + relaunch starts the LA fresh as expected Co-Authored-By: Claude Sonnet 4.6 * fix: dismiss modal (Settings sheet) before tab switch on LA tap navigateOnLAForeground now checks tabBarController.presentedViewController and dismisses it before switching to the target tab, matching the existing pattern used elsewhere in MainViewController for the same scenario. Co-Authored-By: Claude Sonnet 4.6 * fix: LA tap navigation timing and LA reappear-after-dismiss Navigation fix: - loopfollow://la-tap URL can arrive before applicationDidBecomeActive while UIKit is still restoring the view hierarchy from background. At that moment tabBarController.presentedViewController is nil so the Settings modal is never dismissed and the navigation notification fires too early. - Now stores a pendingLATapNavigation flag if app is not yet .active, and fires the notification in applicationDidBecomeActive when the full view hierarchy (including presented modals) is restored. LA reappear fix: - performRefresh() calls startIfNeeded() directly, bypassing the dismissedByUser guard that only exists in startFromCurrentState(). On every BG refresh with laEnabled=true and current==nil (post-dismiss), it would recreate the LA. - Added !dismissedByUser guard to refreshFromCurrentState() so the entire refresh pipeline is skipped while dismissed, matching the existing guard in startFromCurrentState(). Co-Authored-By: Claude Sonnet 4.6 * fix: handle loopfollow://la-tap URL in SceneDelegate, not AppDelegate With scene-based lifecycle (iOS 13+), widget/LA tap URLs are delivered to scene(_:openURLContexts:) on UIWindowSceneDelegate — AppDelegate.application (_:open:url:options:) is never called. Our previous handler was dead code. SceneDelegate now implements scene(_:openURLContexts:) which posts .liveActivityDidForeground on the next run loop (via DispatchQueue.main.async) to let the view hierarchy fully settle before navigateOnLAForeground() runs. Also handles the edge case where the URL arrives before sceneDidBecomeActive by storing a pendingLATapNavigation flag. Co-Authored-By: Claude Sonnet 4.6 * feat: configurable LA grid slots + full InfoType snapshot coverage Lock screen layout: - Left column: glucose + trend arrow, delta below (replaces "Last Update:") - Right grid: 4 configurable slots read from LAAppGroupSettings (defaults: IOB/COB/Proj/empty) - Footer: centered HH:MM update time at reduced opacity Slot configuration: - LiveActivitySlotOption enum with all 22 InfoType-aligned cases - LAAppGroupSettings.setSlots() / slots() persisted to App Group UserDefaults - Uniqueness enforced: selecting an option clears it from any other slot - Settings UI: "Grid slots" section with 4 pickers in LiveActivitySettingsView - Changes take effect immediately (refreshFromCurrentState called on save) GlucoseSnapshot extended with 19 new fields covering all InfoType items: override, recBolus, battery, pumpBattery, basalRate, pumpReservoirU, autosens, tdd, targetLowMgdl/High, isfMgdlPerU, carbRatio, carbsToday, profileName, sageInsertTime, cageInsertTime, iageInsertTime, minBgMgdl/Max Storage.swift: 13 new UserDefaults-backed fields for the above metrics Controllers updated to write new Storage fields on each data fetch: Basals, DeviceStatus, DeviceStatusLoop, DeviceStatusOpenAPS, Carbs, Profile, IAge Co-Authored-By: Claude Sonnet 4.6 * fix: label delta and footer on lock screen LA card Co-Authored-By: Claude Sonnet 4.6 * docs: add PR description for configurable LA grid slots Co-Authored-By: Claude Sonnet 4.6 * Update PR_configurable_slots.md * chore: remove PR notes from tracking, keep docs/LiveActivity.md only - Untrack docs/PR_configurable_slots.md (local-only reference doc) - Add docs/PR_configurable_slots.md and docs/LiveActivityTestPlan.md to .gitignore Co-Authored-By: Claude Sonnet 4.6 * fix: include all extended InfoType fields in APNs push payload buildPayload() was only serializing the base fields (glucose, delta, trend, updatedAt, unit, iob, cob, projected). All extended fields added with the configurable grid slots (battery, sageInsertTime, pumpBattery, basalRate, autosens, tdd, targets, isf, carbRatio, carbsToday, profileName, CAGE, IAGE, minBg, maxBg, override, recBolus) were missing from the APNs payload, causing the extension to decode them as nil/0 and display '--' on every push-driven refresh. Co-Authored-By: Claude Sonnet 4.6 * feat: add small family view for CarPlay Dashboard and Watch Smart Stack Registers the Live Activity for the small activity family via .supplementalActivityFamilies([.small]), enabling automatic display on: - CarPlay Dashboard (iOS 26+) - Apple Watch Smart Stack (watchOS 11+) The small view is hardcoded to the essentials appropriate for a driving context: glucose value, trend arrow, delta, and time since last reading. Background tint matches the existing threshold-based color logic. The lock screen layout (full grid with configurable slots) is unchanged. Co-Authored-By: Claude Sonnet 4.6 * fix: guard CarPlay/Watch small family behind iOS 18 availability; increase renewal overlay opacity - supplementalActivityFamilies and activityFamily require iOS 18.0+; restructured into two Widget structs sharing a makeDynamicIsland() helper: - LoopFollowLiveActivityWidget (iOS 16.1+): lock screen + Dynamic Island - LoopFollowLiveActivityWidgetWithCarPlay (iOS 18.0+): adds .supplementalActivityFamilies([.small]) - Bundle uses if #available(iOS 18.0, *) to select the right variant - Podfile: set minimum deployment target to 16.6 for all pods - Increase 'Tap to update' renewal overlay opacity from 60% to 90% Co-Authored-By: Claude Sonnet 4.6 * fix: move if #available into Widget.body to avoid WidgetBundleBuilder limitation @WidgetBundleBuilder does not support if #available { } else { }, but @WidgetConfigurationBuilder (used by Widget.body) does. Collapsed back to a single LoopFollowLiveActivityWidget struct with the iOS 18 conditional inside body — iOS 18+ branch adds .supplementalActivityFamilies([.small]) for CarPlay and Watch Smart Stack; else branch uses the plain lock screen view. Bundle reverts to the original single if #available(iOS 16.1, *) pattern. Co-Authored-By: Claude Sonnet 4.6 * fix: use two separate single-branch if #available in bundle for CarPlay support @WidgetConfigurationBuilder's buildEither requires both branches to return the same concrete type, making if/else with supplementalActivityFamilies impossible (it wraps to a different opaque type). @WidgetBundleBuilder does not support if #available { } else { } at all. Solution: two separate single-branch if #available blocks in the bundle — the pattern that @WidgetBundleBuilder already supported in the original code: - LoopFollowLiveActivityWidget (iOS 16.1+): primary, lock screen + Dynamic Island - LoopFollowLiveActivityWidgetWithCarPlay (iOS 18.0+): supplemental, adds .supplementalActivityFamilies([.small]) for CarPlay Dashboard + Watch Smart Stack ActivityKit uses the supplemental widget for small-family surfaces and the primary widget for lock screen / Dynamic Island, keeping iOS 16.6+ support intact. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- LoopFollowLAExtension/LoopFollowLiveActivity.swift | 4 ++-- Podfile | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 354ea13df..ee535f952 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -213,7 +213,7 @@ private struct LockScreenLiveActivityView: View { .overlay( ZStack { RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(Color.gray.opacity(0.6)) + .fill(Color.gray.opacity(0.9)) Text("Tap to update") .font(.system(size: 20, weight: .semibold)) .foregroundStyle(.white) @@ -231,7 +231,7 @@ private struct RenewalOverlayView: View { var body: some View { ZStack { - Color.gray.opacity(0.6) + Color.gray.opacity(0.9) if showText { Text("Tap to update") .font(.system(size: 14, weight: .semibold)) diff --git a/Podfile b/Podfile index 5a8c0f868..f8c2df3b2 100644 --- a/Podfile +++ b/Podfile @@ -7,6 +7,15 @@ target 'LoopFollow' do end post_install do |installer| + # Set minimum deployment target for all pods to match the app (suppresses deprecation warnings) + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + if config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'].to_f < 16.6 + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '16.6' + end + end + end + # Patch Charts Transformer to avoid "CGAffineTransformInvert: singular matrix" # warnings when chart views have zero dimensions (before layout). transformer = 'Pods/Charts/Source/Charts/Utils/Transformer.swift' From 98de41692b2abdef1c06b57efc6d27a516daaf4f Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Mon, 16 Mar 2026 11:41:41 -0400 Subject: [PATCH 54/86] fix: restore two-widget bundle; guard supplementalActivityFamilies and activityFamily behind iOS 18 Upstream's single-widget approach placed iOS 18+ APIs (supplementalActivityFamilies, activityFamily, ActivityFamily) behind @available(iOS 16.1, *), which fails to compile at the 16.6 deployment target. Restoring the two-widget pattern: - LoopFollowLiveActivityWidget (@available iOS 16.1): lock screen + DI, uses LockScreenLiveActivityView, no supplementalActivityFamilies - LoopFollowLiveActivityWidgetWithCarPlay (@available iOS 18.0): adds CarPlay Dashboard + Watch Smart Stack via supplementalActivityFamilies([.small]), uses LockScreenFamilyAdaptiveView (also @available iOS 18.0) - SmallFamilyView availability corrected to @available(iOS 18.0, *) - Bundle registers both via separate if #available blocks Co-Authored-By: Claude Sonnet 4.6 --- .../LoopFollowLABundle.swift | 3 + .../LoopFollowLiveActivity.swift | 98 ++++++++++++------- 2 files changed, 63 insertions(+), 38 deletions(-) diff --git a/LoopFollowLAExtension/LoopFollowLABundle.swift b/LoopFollowLAExtension/LoopFollowLABundle.swift index e3a043783..a9f7daf6c 100644 --- a/LoopFollowLAExtension/LoopFollowLABundle.swift +++ b/LoopFollowLAExtension/LoopFollowLABundle.swift @@ -14,5 +14,8 @@ struct LoopFollowLABundle: WidgetBundle { if #available(iOS 16.1, *) { LoopFollowLiveActivityWidget() } + if #available(iOS 18.0, *) { + LoopFollowLiveActivityWidgetWithCarPlay() + } } } diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index ee535f952..a70008414 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -5,49 +5,71 @@ import ActivityKit import SwiftUI import WidgetKit +/// Builds the shared Dynamic Island content used by both widget variants. +@available(iOS 16.1, *) +private func makeDynamicIsland(context: ActivityViewContext) -> DynamicIsland { + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + Link(destination: URL(string: "loopfollow://la-tap")!) { + DynamicIslandLeadingView(snapshot: context.state.snapshot) + .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) + } + .id(context.state.seq) + } + DynamicIslandExpandedRegion(.trailing) { + Link(destination: URL(string: "loopfollow://la-tap")!) { + DynamicIslandTrailingView(snapshot: context.state.snapshot) + .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) + } + .id(context.state.seq) + } + DynamicIslandExpandedRegion(.bottom) { + Link(destination: URL(string: "loopfollow://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)) +} + +/// Primary widget (iOS 16.1+) — Lock Screen + Dynamic Island for all iOS versions. @available(iOS 16.1, *) struct LoopFollowLiveActivityWidget: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: GlucoseLiveActivityAttributes.self) { context in - // LOCK SCREEN / BANNER UI — also used for CarPlay Dashboard and Watch Smart Stack - // (small family) via supplementalActivityFamilies([.small]) + LockScreenLiveActivityView(state: context.state) + .id(context.state.seq) + .activitySystemActionForegroundColor(.white) + .activityBackgroundTint(LAColors.backgroundTint(for: context.state.snapshot)) + .applyActivityContentMarginsFixIfAvailable() + .widgetURL(URL(string: "loopfollow://la-tap")!) + } dynamicIsland: { context in + makeDynamicIsland(context: context) + } + } +} + +/// Supplemental widget (iOS 18.0+) — adds CarPlay Dashboard + Watch Smart Stack +/// via supplementalActivityFamilies([.small]). +@available(iOS 18.0, *) +struct LoopFollowLiveActivityWidgetWithCarPlay: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: GlucoseLiveActivityAttributes.self) { context in LockScreenFamilyAdaptiveView(state: context.state) .id(context.state.seq) } dynamicIsland: { context in - // DYNAMIC ISLAND UI - DynamicIsland { - DynamicIslandExpandedRegion(.leading) { - Link(destination: URL(string: "loopfollow://la-tap")!) { - DynamicIslandLeadingView(snapshot: context.state.snapshot) - .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) - } - .id(context.state.seq) - } - DynamicIslandExpandedRegion(.trailing) { - Link(destination: URL(string: "loopfollow://la-tap")!) { - DynamicIslandTrailingView(snapshot: context.state.snapshot) - .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) - } - .id(context.state.seq) - } - DynamicIslandExpandedRegion(.bottom) { - Link(destination: URL(string: "loopfollow://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)) + makeDynamicIsland(context: context) } .supplementalActivityFamilies([.small]) } @@ -72,7 +94,7 @@ private extension View { /// Reads the activityFamily environment value and routes to the appropriate layout. /// - `.small` → CarPlay Dashboard & Watch Smart Stack: compact glucose-only view /// - everything else → full lock screen layout with configurable grid -@available(iOS 16.1, *) +@available(iOS 18.0, *) private struct LockScreenFamilyAdaptiveView: View { let state: GlucoseLiveActivityAttributes.ContentState @@ -95,7 +117,7 @@ private struct LockScreenFamilyAdaptiveView: View { /// Compact view shown on CarPlay Dashboard (iOS 26+) and Apple Watch Smart Stack (watchOS 11+). /// Hardcoded to glucose + trend arrow + delta + time since last reading. -@available(iOS 16.1, *) +@available(iOS 18.0, *) private struct SmallFamilyView: View { let snapshot: GlucoseSnapshot From e8daddab6a4ad550890ecfe931653ffc427ed047 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Mon, 16 Mar 2026 11:51:41 -0400 Subject: [PATCH 55/86] fix: extension version inherits from parent; remove spurious await in slot config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LoopFollowLAExtension MARKETING_VERSION now uses "$(MARKETING_VERSION)" to match the parent app version automatically, resolving CFBundleShortVersionString mismatch warning - Remove unnecessary Task/await wrapping of refreshFromCurrentState in LiveActivitySettingsView — the method is not async Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow.xcodeproj/project.pbxproj | 4 ++-- LoopFollow/LiveActivitySettingsView.swift | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 795346767..34c2b838c 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -2413,7 +2413,7 @@ ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MACOSX_DEPLOYMENT_TARGET = 26.2; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = "$(MARKETING_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix).LoopFollowLAExtension"; PRODUCT_MODULE_NAME = LoopFollowLAExtension; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -2465,7 +2465,7 @@ ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MACOSX_DEPLOYMENT_TARGET = 26.2; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = "$(MARKETING_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix).LoopFollowLAExtension"; PRODUCT_MODULE_NAME = LoopFollowLAExtension; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/LoopFollow/LiveActivitySettingsView.swift b/LoopFollow/LiveActivitySettingsView.swift index 99dbc13e6..efe4ec321 100644 --- a/LoopFollow/LiveActivitySettingsView.swift +++ b/LoopFollow/LiveActivitySettingsView.swift @@ -68,8 +68,6 @@ struct LiveActivitySettingsView: View { } slots[index] = option LAAppGroupSettings.setSlots(slots) - Task { - await LiveActivityManager.shared.refreshFromCurrentState(reason: "slot config changed") - } + LiveActivityManager.shared.refreshFromCurrentState(reason: "slot config changed") } } From 9f9229abb1235415a516789cebabaa6c108786c5 Mon Sep 17 00:00:00 2001 From: Phil A <76601115+MtlPhil@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:30:34 -0400 Subject: [PATCH 56/86] Live Activity: fix iOS 18 availability guards, extension version, and minor warnings (#550) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Live Activity auto-renewal to work around 8-hour system limit Co-Authored-By: Claude Sonnet 4.6 * test: reduce LA renewal threshold to 20 min for testing Co-Authored-By: Claude Sonnet 4.6 * feat: improve LA renewal robustness and stale indicator - staleDate on every ActivityContent now tracks the renewal deadline, so the system shows Apple's built-in stale indicator if renewal fails - Add laRenewalFailed StorageValue; set on Activity.request() failure, cleared on any successful LA start - Observe willEnterForegroundNotification: retry startIfNeeded() if a previous renewal attempt failed - New-first renewal order: request the replacement LA before ending the old one — if the request throws the existing LA stays alive so the user keeps live data until the system kills it at the 8-hour mark Co-Authored-By: Claude Sonnet 4.6 * feat: renewal warning overlay + restore 7.5h threshold - Restore renewalThreshold to 7.5 * 3600 (testing complete) - Add showRenewalOverlay: Bool to GlucoseSnapshot (Codable, default false) - GlucoseSnapshotBuilder sets it true when now >= laRenewBy - 1800 (30 minutes before the renewal deadline) - Lock screen: 60% gray overlay with "Tap to update" centered in white, layered above the existing isNotLooping overlay - DI expanded: RenewalOverlayView applied to leading/trailing/bottom regions; "Tap to update" text shown on the bottom region only - showRenewalOverlay resets to false automatically on renewal since laRenewBy is updated and the next snapshot rebuild clears the flag Co-Authored-By: Claude Sonnet 4.6 * fix: overlay not appearing + foreground restart not working Overlay rendering: - Replace Group{if condition{ZStack{RoundedRectangle...}}} with a permanently-present ZStack toggled via .opacity(). The Group/if pattern causes SwiftUI sizing ambiguity when the condition transitions from false→true inside an .overlay(), producing a zero-size result. The .opacity() approach keeps a stable view hierarchy. - Same fix applied to RenewalOverlayView used on DI expanded regions. Foreground restart: - handleForeground() was calling startIfNeeded(), which finds the still-alive (failed-to-renew) LA in Activity.activities and reuses it, doing nothing useful. Fixed to manually nil out current, cancel all tasks, await activity.end(.immediate), then startFromCurrentState(). Overlay timing: - Changed warning window from 30 min (1800s) to 20 min (1200s) before the renewal deadline, matching the intended test cadence. Logging: - handleForeground: log on every foreground event with laRenewalFailed value - renewIfNeeded: log how many seconds past the deadline when firing - GlucoseSnapshotBuilder: log when overlay activates with seconds to deadline - performRefresh: log when sending an update with the overlay visible Co-Authored-By: Claude Sonnet 4.6 * test: set renewalThreshold to 20 min for testing Co-Authored-By: Claude Sonnet 4.6 * fix: renewal overlay not clearing after LA is refreshed Normal renewal path (renewIfNeeded success): - Build a fresh snapshot with showRenewalOverlay: false for the new LA's initial content — it has a new deadline so the overlay should never be visible from the first frame. - Save that fresh snapshot to GlucoseSnapshotStore so the next duplicate check has the correct baseline and doesn't suppress the first real BG update. Foreground restart path (handleForeground): - Zero laRenewBy before calling startFromCurrentState() so GlucoseSnapshotBuilder computes showRenewalOverlay = false for the seed snapshot. startIfNeeded() then sets the new deadline after the request succeeds. Co-Authored-By: Claude Sonnet 4.6 * fix: overlay permanently active when warning window equals threshold With renewalThreshold=20min and the hardcoded 1200s warning window, renewBy-1200 = start, so showRenewalOverlay is always true from the moment the LA begins. Extract a named renewalWarning constant (5 min for testing) so the warning window is always less than the threshold. The builder now reads LiveActivityManager.renewalWarning instead of a hardcoded literal. Production values to restore before merging: renewalThreshold = 7.5 * 3600 renewalWarning = 20 * 60 Co-Authored-By: Claude Sonnet 4.6 * fix: include showRenewalOverlay in APNs payload and clear laRenewBy synchronously APNSClient was missing showRenewalOverlay from the push payload, so background APNs updates never delivered the overlay flag to the extension — only foreground direct ActivityKit updates did. In handleForeground, laRenewBy is now zeroed synchronously before spawning the async end/restart Task. This means any snapshot built between the foreground notification and the new LA start (e.g. from viewDidAppear's startFromCurrentState) computes showRenewalOverlay = false rather than reading the stale expired deadline. Co-Authored-By: Claude Sonnet 4.6 * fix: await LA end before restarting on foreground retry to avoid reuse path Reset laRenewBy and laRenewalFailed synchronously before tearing down the failed LA, then await activity.end() before calling startFromCurrentState(). This guarantees Activity.activities is clear when startIfNeeded() runs, so it takes the fresh-request path and writes a new laRenewBy. startFromCurrentState rebuilds the snapshot with showRenewalOverlay=false (laRenewBy=0), saves it to the store, then startIfNeeded uses that clean snapshot as the seed. Co-Authored-By: Claude Sonnet 4.6 * chore: restore production renewal timing (7.5h threshold, 20min warning) Co-Authored-By: Claude Sonnet 4.6 * feat: laEnabled toggle, forceRestart(), and RestartLiveActivityIntent - SettingsMenuView: rename "APN" menu entry to "Live Activity" - Storage: add laEnabled: Bool StorageValue (default false) - APNSettingsView: on/off toggle bound to laEnabled; APNs key fields and Restart button shown only when enabled; disabling immediately ends any running LA - LiveActivityManager: - forceRestart() (@MainActor) ends all running activities, resets laRenewBy and laRenewalFailed, then calls startFromCurrentState() - laEnabled guard added to startFromCurrentState(), refreshFromCurrentState(), handleForeground(), and handleDidBecomeActive() - didBecomeActiveNotification observer calls forceRestart() on every foreground transition when laEnabled is true - RestartLiveActivityIntent: AppIntent + ForegroundContinuableIntent; sets laEnabled, validates credentials, opens settings deep link if missing, otherwise calls forceRestart() in foreground; LoopFollowAppShortcuts exposes the intent with Siri phrase Note: RestartLiveActivityIntent.swift must be added to the app target in Xcode. Co-Authored-By: Claude Sonnet 4.6 * Added RestartLiveActivityIntent to project * fix: resolve two build errors in LiveActivityManager and RestartLiveActivityIntent - LiveActivityManager: handleDidBecomeActive() calls @MainActor forceRestart() via Task { @MainActor in ... } to satisfy Swift concurrency isolation - RestartLiveActivityIntent: drop ForegroundContinuableIntent — continueInForeground() was renamed to continueInForeground(_:alwaysConfirm:) in the iOS 26 SDK and requires iOS 26+; the didBecomeActiveNotification observer handles restart when the app comes to foreground, making explicit continuation unnecessary Co-Authored-By: Claude Sonnet 4.6 * fix: guard continueInForeground() behind iOS 26 availability check continueInForeground(_:alwaysConfirm:) is iOS 26+ only. On earlier versions, forceRestart() runs directly via the existing background audio session — no foreground continuation needed. Co-Authored-By: Claude Sonnet 4.6 * fix: use startFromCurrentState in handleDidBecomeActive instead of forceRestart forceRestart() was killing the LA that the RestartLiveActivityIntent had just created, because continueInForeground() triggers didBecomeActive. startFromCurrentState() reuses an existing active LA via startIfNeeded() and only creates one if none is present — no destructive race. Co-Authored-By: Claude Sonnet 4.6 * feat: LA foreground tab navigation, button feedback, and toggle sync APNSettingsView: - "Restart Live Activity" button shows "Live Activity Restarted" for 2 seconds and disables itself during that window to prevent double-taps - Toggle observes Storage.shared.laEnabled via onReceive so it reflects changes made externally (e.g. by the App Intent / Shortcuts) LiveActivityManager: - handleDidBecomeActive posts .liveActivityDidForeground after startFromCurrentState - Notification.Name.liveActivityDidForeground extension defined here MainViewController: - Observes .liveActivityDidForeground; navigates to Snoozer tab if an alarm is currently active, otherwise navigates to tab 0 (Home) Co-Authored-By: Claude Sonnet 4.6 * fix: flush LA update on willResignActive to ensure lock screen shows latest data The 5-second debounce on refreshFromCurrentState means a BG update arriving while the app is foreground won't reach the LA before the user returns to the lock screen. When the debounce fires in background, isForeground=false so the direct ActivityKit update is skipped. Fix: observe willResignActiveNotification, cancel the pending debounce, and immediately push the latest snapshot via direct ActivityKit update while the app is still foreground-active. The APNs push is also sent as a backup. Co-Authored-By: Claude Sonnet 4.6 * feat: redesign Dynamic Island compact and expanded views Compact: - Trailing now shows delta instead of trend arrow (leading already showed BG) Expanded leading: - Large BG value at top - Second row: trend arrow, delta, and "Proj: X" in smaller text below Expanded trailing: - IOB and COB (moved from bottom) Expanded bottom: - "Updated at: HH:mm" (moved from trailing) Co-Authored-By: Claude Sonnet 4.6 * fix: match Proj text style to delta; add trailing padding to IOB/COB - Proj label now uses same size (13), weight (semibold), and opacity (0.9) as the delta text beside it - Added 6pt trailing padding to the expanded DI trailing VStack so the IOB decimal is not clipped by the Dynamic Island edge Co-Authored-By: Claude Sonnet 4.6 * feat: separate Live Activity and APN settings into distinct menus Per maintainer preference, the APNs credentials (Key ID and Key) remain in their own "APN" settings screen. A new separate "Live Activity" settings screen contains the enable/disable toggle and the Restart Live Activity button. - APNSettingsView: restored to Key ID + Key only (no toggle or restart button) - LiveActivitySettingsView: new view with laEnabled toggle and restart button (with 2-second confirmation feedback); toggle stays in sync via onReceive - SettingsMenuView: "APN" entry restored; new "Live Activity" entry added with dot.radiowaves icon; Sheet.liveActivity case wired to LiveActivitySettingsView Co-Authored-By: Claude Sonnet 4.6 * Added Live Activity menu * chore: add LiveActivitySettingsView to Xcode project Co-Authored-By: Claude Sonnet 4.6 * fix: LA tap navigation, manual dismissal prevention, and toggle start - widgetURL on lock screen LA → opens app via loopfollow://la-tap - Link views in expanded DI regions → same URL on tap - AppDelegate.application(_:open:) handles loopfollow://la-tap and posts .liveActivityDidForeground so MainViewController navigates to Snoozer (if alarm active) or Home tab — fires only on LA/DI taps, not every foreground - Notification.Name.liveActivityDidForeground moved outside #if macCatalyst so AppDelegate can reference it unconditionally - handleDidBecomeActive no longer posts .liveActivityDidForeground (was firing on every app foreground, not just LA taps) - attachStateObserver: .dismissed state (user swipe) sets laEnabled = false so the LA does not auto-restart when the app foregrounds - LiveActivitySettingsView: toggling ON calls startFromCurrentState() Co-Authored-By: Claude Sonnet 4.6 * fix: end Live Activity on app force-quit Adds endOnTerminate() to LiveActivityManager — blocks up to 3 s using Task.detached + DispatchSemaphore so the async activity.end() completes before the process exits. Called from applicationWillTerminate so the LA clears from the lock screen immediately on force-quit. laEnabled is preserved so the LA restarts correctly on next launch. Co-Authored-By: Claude Sonnet 4.6 * fix: use dismissedByUser flag instead of disabling laEnabled on manual dismiss - Manual LA swipe-away sets dismissedByUser (in-memory) instead of laEnabled = false, preserving the user's preference - startFromCurrentState() guards on !dismissedByUser so the LA does not auto-restart on foreground, BG refresh, or handleDidBecomeActive - forceRestart() clears dismissedByUser before starting, so the Restart button and App Intent both work as the explicit re-enable mechanism - Toggle ON in settings calls forceRestart() (clears flag + starts) instead of startFromCurrentState() which would have been blocked by the flag - dismissedByUser resets to false on app relaunch (in-memory only), so a kill + relaunch starts the LA fresh as expected Co-Authored-By: Claude Sonnet 4.6 * fix: dismiss modal (Settings sheet) before tab switch on LA tap navigateOnLAForeground now checks tabBarController.presentedViewController and dismisses it before switching to the target tab, matching the existing pattern used elsewhere in MainViewController for the same scenario. Co-Authored-By: Claude Sonnet 4.6 * fix: LA tap navigation timing and LA reappear-after-dismiss Navigation fix: - loopfollow://la-tap URL can arrive before applicationDidBecomeActive while UIKit is still restoring the view hierarchy from background. At that moment tabBarController.presentedViewController is nil so the Settings modal is never dismissed and the navigation notification fires too early. - Now stores a pendingLATapNavigation flag if app is not yet .active, and fires the notification in applicationDidBecomeActive when the full view hierarchy (including presented modals) is restored. LA reappear fix: - performRefresh() calls startIfNeeded() directly, bypassing the dismissedByUser guard that only exists in startFromCurrentState(). On every BG refresh with laEnabled=true and current==nil (post-dismiss), it would recreate the LA. - Added !dismissedByUser guard to refreshFromCurrentState() so the entire refresh pipeline is skipped while dismissed, matching the existing guard in startFromCurrentState(). Co-Authored-By: Claude Sonnet 4.6 * fix: handle loopfollow://la-tap URL in SceneDelegate, not AppDelegate With scene-based lifecycle (iOS 13+), widget/LA tap URLs are delivered to scene(_:openURLContexts:) on UIWindowSceneDelegate — AppDelegate.application (_:open:url:options:) is never called. Our previous handler was dead code. SceneDelegate now implements scene(_:openURLContexts:) which posts .liveActivityDidForeground on the next run loop (via DispatchQueue.main.async) to let the view hierarchy fully settle before navigateOnLAForeground() runs. Also handles the edge case where the URL arrives before sceneDidBecomeActive by storing a pendingLATapNavigation flag. Co-Authored-By: Claude Sonnet 4.6 * feat: configurable LA grid slots + full InfoType snapshot coverage Lock screen layout: - Left column: glucose + trend arrow, delta below (replaces "Last Update:") - Right grid: 4 configurable slots read from LAAppGroupSettings (defaults: IOB/COB/Proj/empty) - Footer: centered HH:MM update time at reduced opacity Slot configuration: - LiveActivitySlotOption enum with all 22 InfoType-aligned cases - LAAppGroupSettings.setSlots() / slots() persisted to App Group UserDefaults - Uniqueness enforced: selecting an option clears it from any other slot - Settings UI: "Grid slots" section with 4 pickers in LiveActivitySettingsView - Changes take effect immediately (refreshFromCurrentState called on save) GlucoseSnapshot extended with 19 new fields covering all InfoType items: override, recBolus, battery, pumpBattery, basalRate, pumpReservoirU, autosens, tdd, targetLowMgdl/High, isfMgdlPerU, carbRatio, carbsToday, profileName, sageInsertTime, cageInsertTime, iageInsertTime, minBgMgdl/Max Storage.swift: 13 new UserDefaults-backed fields for the above metrics Controllers updated to write new Storage fields on each data fetch: Basals, DeviceStatus, DeviceStatusLoop, DeviceStatusOpenAPS, Carbs, Profile, IAge Co-Authored-By: Claude Sonnet 4.6 * fix: label delta and footer on lock screen LA card Co-Authored-By: Claude Sonnet 4.6 * docs: add PR description for configurable LA grid slots Co-Authored-By: Claude Sonnet 4.6 * Update PR_configurable_slots.md * chore: remove PR notes from tracking, keep docs/LiveActivity.md only - Untrack docs/PR_configurable_slots.md (local-only reference doc) - Add docs/PR_configurable_slots.md and docs/LiveActivityTestPlan.md to .gitignore Co-Authored-By: Claude Sonnet 4.6 * fix: include all extended InfoType fields in APNs push payload buildPayload() was only serializing the base fields (glucose, delta, trend, updatedAt, unit, iob, cob, projected). All extended fields added with the configurable grid slots (battery, sageInsertTime, pumpBattery, basalRate, autosens, tdd, targets, isf, carbRatio, carbsToday, profileName, CAGE, IAGE, minBg, maxBg, override, recBolus) were missing from the APNs payload, causing the extension to decode them as nil/0 and display '--' on every push-driven refresh. Co-Authored-By: Claude Sonnet 4.6 * feat: add small family view for CarPlay Dashboard and Watch Smart Stack Registers the Live Activity for the small activity family via .supplementalActivityFamilies([.small]), enabling automatic display on: - CarPlay Dashboard (iOS 26+) - Apple Watch Smart Stack (watchOS 11+) The small view is hardcoded to the essentials appropriate for a driving context: glucose value, trend arrow, delta, and time since last reading. Background tint matches the existing threshold-based color logic. The lock screen layout (full grid with configurable slots) is unchanged. Co-Authored-By: Claude Sonnet 4.6 * fix: guard CarPlay/Watch small family behind iOS 18 availability; increase renewal overlay opacity - supplementalActivityFamilies and activityFamily require iOS 18.0+; restructured into two Widget structs sharing a makeDynamicIsland() helper: - LoopFollowLiveActivityWidget (iOS 16.1+): lock screen + Dynamic Island - LoopFollowLiveActivityWidgetWithCarPlay (iOS 18.0+): adds .supplementalActivityFamilies([.small]) - Bundle uses if #available(iOS 18.0, *) to select the right variant - Podfile: set minimum deployment target to 16.6 for all pods - Increase 'Tap to update' renewal overlay opacity from 60% to 90% Co-Authored-By: Claude Sonnet 4.6 * fix: move if #available into Widget.body to avoid WidgetBundleBuilder limitation @WidgetBundleBuilder does not support if #available { } else { }, but @WidgetConfigurationBuilder (used by Widget.body) does. Collapsed back to a single LoopFollowLiveActivityWidget struct with the iOS 18 conditional inside body — iOS 18+ branch adds .supplementalActivityFamilies([.small]) for CarPlay and Watch Smart Stack; else branch uses the plain lock screen view. Bundle reverts to the original single if #available(iOS 16.1, *) pattern. Co-Authored-By: Claude Sonnet 4.6 * fix: use two separate single-branch if #available in bundle for CarPlay support @WidgetConfigurationBuilder's buildEither requires both branches to return the same concrete type, making if/else with supplementalActivityFamilies impossible (it wraps to a different opaque type). @WidgetBundleBuilder does not support if #available { } else { } at all. Solution: two separate single-branch if #available blocks in the bundle — the pattern that @WidgetBundleBuilder already supported in the original code: - LoopFollowLiveActivityWidget (iOS 16.1+): primary, lock screen + Dynamic Island - LoopFollowLiveActivityWidgetWithCarPlay (iOS 18.0+): supplemental, adds .supplementalActivityFamilies([.small]) for CarPlay Dashboard + Watch Smart Stack ActivityKit uses the supplemental widget for small-family surfaces and the primary widget for lock screen / Dynamic Island, keeping iOS 16.6+ support intact. Co-Authored-By: Claude Sonnet 4.6 * fix: restore two-widget bundle; guard supplementalActivityFamilies and activityFamily behind iOS 18 Upstream's single-widget approach placed iOS 18+ APIs (supplementalActivityFamilies, activityFamily, ActivityFamily) behind @available(iOS 16.1, *), which fails to compile at the 16.6 deployment target. Restoring the two-widget pattern: - LoopFollowLiveActivityWidget (@available iOS 16.1): lock screen + DI, uses LockScreenLiveActivityView, no supplementalActivityFamilies - LoopFollowLiveActivityWidgetWithCarPlay (@available iOS 18.0): adds CarPlay Dashboard + Watch Smart Stack via supplementalActivityFamilies([.small]), uses LockScreenFamilyAdaptiveView (also @available iOS 18.0) - SmallFamilyView availability corrected to @available(iOS 18.0, *) - Bundle registers both via separate if #available blocks Co-Authored-By: Claude Sonnet 4.6 * fix: extension version inherits from parent; remove spurious await in slot config - LoopFollowLAExtension MARKETING_VERSION now uses "$(MARKETING_VERSION)" to match the parent app version automatically, resolving CFBundleShortVersionString mismatch warning - Remove unnecessary Task/await wrapping of refreshFromCurrentState in LiveActivitySettingsView — the method is not async Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- LoopFollow.xcodeproj/project.pbxproj | 4 +- LoopFollow/LiveActivitySettingsView.swift | 4 +- .../LoopFollowLABundle.swift | 3 + .../LoopFollowLiveActivity.swift | 98 ++++++++++++------- 4 files changed, 66 insertions(+), 43 deletions(-) diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 795346767..34c2b838c 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -2413,7 +2413,7 @@ ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MACOSX_DEPLOYMENT_TARGET = 26.2; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = "$(MARKETING_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix).LoopFollowLAExtension"; PRODUCT_MODULE_NAME = LoopFollowLAExtension; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -2465,7 +2465,7 @@ ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MACOSX_DEPLOYMENT_TARGET = 26.2; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = "$(MARKETING_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix).LoopFollowLAExtension"; PRODUCT_MODULE_NAME = LoopFollowLAExtension; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/LoopFollow/LiveActivitySettingsView.swift b/LoopFollow/LiveActivitySettingsView.swift index 99dbc13e6..efe4ec321 100644 --- a/LoopFollow/LiveActivitySettingsView.swift +++ b/LoopFollow/LiveActivitySettingsView.swift @@ -68,8 +68,6 @@ struct LiveActivitySettingsView: View { } slots[index] = option LAAppGroupSettings.setSlots(slots) - Task { - await LiveActivityManager.shared.refreshFromCurrentState(reason: "slot config changed") - } + LiveActivityManager.shared.refreshFromCurrentState(reason: "slot config changed") } } diff --git a/LoopFollowLAExtension/LoopFollowLABundle.swift b/LoopFollowLAExtension/LoopFollowLABundle.swift index e3a043783..a9f7daf6c 100644 --- a/LoopFollowLAExtension/LoopFollowLABundle.swift +++ b/LoopFollowLAExtension/LoopFollowLABundle.swift @@ -14,5 +14,8 @@ struct LoopFollowLABundle: WidgetBundle { if #available(iOS 16.1, *) { LoopFollowLiveActivityWidget() } + if #available(iOS 18.0, *) { + LoopFollowLiveActivityWidgetWithCarPlay() + } } } diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index ee535f952..a70008414 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -5,49 +5,71 @@ import ActivityKit import SwiftUI import WidgetKit +/// Builds the shared Dynamic Island content used by both widget variants. +@available(iOS 16.1, *) +private func makeDynamicIsland(context: ActivityViewContext) -> DynamicIsland { + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + Link(destination: URL(string: "loopfollow://la-tap")!) { + DynamicIslandLeadingView(snapshot: context.state.snapshot) + .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) + } + .id(context.state.seq) + } + DynamicIslandExpandedRegion(.trailing) { + Link(destination: URL(string: "loopfollow://la-tap")!) { + DynamicIslandTrailingView(snapshot: context.state.snapshot) + .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) + } + .id(context.state.seq) + } + DynamicIslandExpandedRegion(.bottom) { + Link(destination: URL(string: "loopfollow://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)) +} + +/// Primary widget (iOS 16.1+) — Lock Screen + Dynamic Island for all iOS versions. @available(iOS 16.1, *) struct LoopFollowLiveActivityWidget: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: GlucoseLiveActivityAttributes.self) { context in - // LOCK SCREEN / BANNER UI — also used for CarPlay Dashboard and Watch Smart Stack - // (small family) via supplementalActivityFamilies([.small]) + LockScreenLiveActivityView(state: context.state) + .id(context.state.seq) + .activitySystemActionForegroundColor(.white) + .activityBackgroundTint(LAColors.backgroundTint(for: context.state.snapshot)) + .applyActivityContentMarginsFixIfAvailable() + .widgetURL(URL(string: "loopfollow://la-tap")!) + } dynamicIsland: { context in + makeDynamicIsland(context: context) + } + } +} + +/// Supplemental widget (iOS 18.0+) — adds CarPlay Dashboard + Watch Smart Stack +/// via supplementalActivityFamilies([.small]). +@available(iOS 18.0, *) +struct LoopFollowLiveActivityWidgetWithCarPlay: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: GlucoseLiveActivityAttributes.self) { context in LockScreenFamilyAdaptiveView(state: context.state) .id(context.state.seq) } dynamicIsland: { context in - // DYNAMIC ISLAND UI - DynamicIsland { - DynamicIslandExpandedRegion(.leading) { - Link(destination: URL(string: "loopfollow://la-tap")!) { - DynamicIslandLeadingView(snapshot: context.state.snapshot) - .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) - } - .id(context.state.seq) - } - DynamicIslandExpandedRegion(.trailing) { - Link(destination: URL(string: "loopfollow://la-tap")!) { - DynamicIslandTrailingView(snapshot: context.state.snapshot) - .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) - } - .id(context.state.seq) - } - DynamicIslandExpandedRegion(.bottom) { - Link(destination: URL(string: "loopfollow://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)) + makeDynamicIsland(context: context) } .supplementalActivityFamilies([.small]) } @@ -72,7 +94,7 @@ private extension View { /// Reads the activityFamily environment value and routes to the appropriate layout. /// - `.small` → CarPlay Dashboard & Watch Smart Stack: compact glucose-only view /// - everything else → full lock screen layout with configurable grid -@available(iOS 16.1, *) +@available(iOS 18.0, *) private struct LockScreenFamilyAdaptiveView: View { let state: GlucoseLiveActivityAttributes.ContentState @@ -95,7 +117,7 @@ private struct LockScreenFamilyAdaptiveView: View { /// Compact view shown on CarPlay Dashboard (iOS 26+) and Apple Watch Smart Stack (watchOS 11+). /// Hardcoded to glucose + trend arrow + delta + time since last reading. -@available(iOS 16.1, *) +@available(iOS 18.0, *) private struct SmallFamilyView: View { let snapshot: GlucoseSnapshot From 83f4ad3a5e4e410debc01bd6ec79d5ddff6c3ae0 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Mon, 16 Mar 2026 16:49:35 -0400 Subject: [PATCH 57/86] fix: prevent glucose + trend arrow clipping on wide mmol/L values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At 46pt, a 4-character mmol/L value ("10.5") plus "↑↑" overflowed the 168pt left column, truncating the glucose reading. Fix: reduce trend arrow to 32pt and add minimumScaleFactor(0.7) + lineLimit(1) to the glucose text so values above 10 mmol/L render correctly. Co-Authored-By: Claude Sonnet 4.6 --- LoopFollowLAExtension/LoopFollowLiveActivity.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index a70008414..d681a9368 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -164,15 +164,18 @@ private struct LockScreenLiveActivityView: View { HStack(spacing: 12) { // LEFT: Glucose + trend arrow, delta below VStack(alignment: .leading, spacing: 4) { - HStack(alignment: .firstTextBaseline, spacing: 8) { + HStack(alignment: .firstTextBaseline, spacing: 6) { Text(LAFormat.glucose(s)) .font(.system(size: 46, weight: .bold, design: .rounded)) .monospacedDigit() .foregroundStyle(.white) + .minimumScaleFactor(0.7) + .lineLimit(1) Text(LAFormat.trendArrow(s)) - .font(.system(size: 46, weight: .bold, design: .rounded)) + .font(.system(size: 32, weight: .bold, design: .rounded)) .foregroundStyle(.white.opacity(0.95)) + .lineLimit(1) } Text("Delta: \(LAFormat.delta(s))") From 426fa3d581d3f6ea56f8261c322c0fa67c684d08 Mon Sep 17 00:00:00 2001 From: Phil A <76601115+MtlPhil@users.noreply.github.com> Date: Tue, 17 Mar 2026 02:17:36 -0400 Subject: [PATCH 58/86] Live Activity: fix glucose + trend arrow clipping on wide mmol/L values (#552) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Live Activity auto-renewal to work around 8-hour system limit Co-Authored-By: Claude Sonnet 4.6 * test: reduce LA renewal threshold to 20 min for testing Co-Authored-By: Claude Sonnet 4.6 * feat: improve LA renewal robustness and stale indicator - staleDate on every ActivityContent now tracks the renewal deadline, so the system shows Apple's built-in stale indicator if renewal fails - Add laRenewalFailed StorageValue; set on Activity.request() failure, cleared on any successful LA start - Observe willEnterForegroundNotification: retry startIfNeeded() if a previous renewal attempt failed - New-first renewal order: request the replacement LA before ending the old one — if the request throws the existing LA stays alive so the user keeps live data until the system kills it at the 8-hour mark Co-Authored-By: Claude Sonnet 4.6 * feat: renewal warning overlay + restore 7.5h threshold - Restore renewalThreshold to 7.5 * 3600 (testing complete) - Add showRenewalOverlay: Bool to GlucoseSnapshot (Codable, default false) - GlucoseSnapshotBuilder sets it true when now >= laRenewBy - 1800 (30 minutes before the renewal deadline) - Lock screen: 60% gray overlay with "Tap to update" centered in white, layered above the existing isNotLooping overlay - DI expanded: RenewalOverlayView applied to leading/trailing/bottom regions; "Tap to update" text shown on the bottom region only - showRenewalOverlay resets to false automatically on renewal since laRenewBy is updated and the next snapshot rebuild clears the flag Co-Authored-By: Claude Sonnet 4.6 * fix: overlay not appearing + foreground restart not working Overlay rendering: - Replace Group{if condition{ZStack{RoundedRectangle...}}} with a permanently-present ZStack toggled via .opacity(). The Group/if pattern causes SwiftUI sizing ambiguity when the condition transitions from false→true inside an .overlay(), producing a zero-size result. The .opacity() approach keeps a stable view hierarchy. - Same fix applied to RenewalOverlayView used on DI expanded regions. Foreground restart: - handleForeground() was calling startIfNeeded(), which finds the still-alive (failed-to-renew) LA in Activity.activities and reuses it, doing nothing useful. Fixed to manually nil out current, cancel all tasks, await activity.end(.immediate), then startFromCurrentState(). Overlay timing: - Changed warning window from 30 min (1800s) to 20 min (1200s) before the renewal deadline, matching the intended test cadence. Logging: - handleForeground: log on every foreground event with laRenewalFailed value - renewIfNeeded: log how many seconds past the deadline when firing - GlucoseSnapshotBuilder: log when overlay activates with seconds to deadline - performRefresh: log when sending an update with the overlay visible Co-Authored-By: Claude Sonnet 4.6 * test: set renewalThreshold to 20 min for testing Co-Authored-By: Claude Sonnet 4.6 * fix: renewal overlay not clearing after LA is refreshed Normal renewal path (renewIfNeeded success): - Build a fresh snapshot with showRenewalOverlay: false for the new LA's initial content — it has a new deadline so the overlay should never be visible from the first frame. - Save that fresh snapshot to GlucoseSnapshotStore so the next duplicate check has the correct baseline and doesn't suppress the first real BG update. Foreground restart path (handleForeground): - Zero laRenewBy before calling startFromCurrentState() so GlucoseSnapshotBuilder computes showRenewalOverlay = false for the seed snapshot. startIfNeeded() then sets the new deadline after the request succeeds. Co-Authored-By: Claude Sonnet 4.6 * fix: overlay permanently active when warning window equals threshold With renewalThreshold=20min and the hardcoded 1200s warning window, renewBy-1200 = start, so showRenewalOverlay is always true from the moment the LA begins. Extract a named renewalWarning constant (5 min for testing) so the warning window is always less than the threshold. The builder now reads LiveActivityManager.renewalWarning instead of a hardcoded literal. Production values to restore before merging: renewalThreshold = 7.5 * 3600 renewalWarning = 20 * 60 Co-Authored-By: Claude Sonnet 4.6 * fix: include showRenewalOverlay in APNs payload and clear laRenewBy synchronously APNSClient was missing showRenewalOverlay from the push payload, so background APNs updates never delivered the overlay flag to the extension — only foreground direct ActivityKit updates did. In handleForeground, laRenewBy is now zeroed synchronously before spawning the async end/restart Task. This means any snapshot built between the foreground notification and the new LA start (e.g. from viewDidAppear's startFromCurrentState) computes showRenewalOverlay = false rather than reading the stale expired deadline. Co-Authored-By: Claude Sonnet 4.6 * fix: await LA end before restarting on foreground retry to avoid reuse path Reset laRenewBy and laRenewalFailed synchronously before tearing down the failed LA, then await activity.end() before calling startFromCurrentState(). This guarantees Activity.activities is clear when startIfNeeded() runs, so it takes the fresh-request path and writes a new laRenewBy. startFromCurrentState rebuilds the snapshot with showRenewalOverlay=false (laRenewBy=0), saves it to the store, then startIfNeeded uses that clean snapshot as the seed. Co-Authored-By: Claude Sonnet 4.6 * chore: restore production renewal timing (7.5h threshold, 20min warning) Co-Authored-By: Claude Sonnet 4.6 * feat: laEnabled toggle, forceRestart(), and RestartLiveActivityIntent - SettingsMenuView: rename "APN" menu entry to "Live Activity" - Storage: add laEnabled: Bool StorageValue (default false) - APNSettingsView: on/off toggle bound to laEnabled; APNs key fields and Restart button shown only when enabled; disabling immediately ends any running LA - LiveActivityManager: - forceRestart() (@MainActor) ends all running activities, resets laRenewBy and laRenewalFailed, then calls startFromCurrentState() - laEnabled guard added to startFromCurrentState(), refreshFromCurrentState(), handleForeground(), and handleDidBecomeActive() - didBecomeActiveNotification observer calls forceRestart() on every foreground transition when laEnabled is true - RestartLiveActivityIntent: AppIntent + ForegroundContinuableIntent; sets laEnabled, validates credentials, opens settings deep link if missing, otherwise calls forceRestart() in foreground; LoopFollowAppShortcuts exposes the intent with Siri phrase Note: RestartLiveActivityIntent.swift must be added to the app target in Xcode. Co-Authored-By: Claude Sonnet 4.6 * Added RestartLiveActivityIntent to project * fix: resolve two build errors in LiveActivityManager and RestartLiveActivityIntent - LiveActivityManager: handleDidBecomeActive() calls @MainActor forceRestart() via Task { @MainActor in ... } to satisfy Swift concurrency isolation - RestartLiveActivityIntent: drop ForegroundContinuableIntent — continueInForeground() was renamed to continueInForeground(_:alwaysConfirm:) in the iOS 26 SDK and requires iOS 26+; the didBecomeActiveNotification observer handles restart when the app comes to foreground, making explicit continuation unnecessary Co-Authored-By: Claude Sonnet 4.6 * fix: guard continueInForeground() behind iOS 26 availability check continueInForeground(_:alwaysConfirm:) is iOS 26+ only. On earlier versions, forceRestart() runs directly via the existing background audio session — no foreground continuation needed. Co-Authored-By: Claude Sonnet 4.6 * fix: use startFromCurrentState in handleDidBecomeActive instead of forceRestart forceRestart() was killing the LA that the RestartLiveActivityIntent had just created, because continueInForeground() triggers didBecomeActive. startFromCurrentState() reuses an existing active LA via startIfNeeded() and only creates one if none is present — no destructive race. Co-Authored-By: Claude Sonnet 4.6 * feat: LA foreground tab navigation, button feedback, and toggle sync APNSettingsView: - "Restart Live Activity" button shows "Live Activity Restarted" for 2 seconds and disables itself during that window to prevent double-taps - Toggle observes Storage.shared.laEnabled via onReceive so it reflects changes made externally (e.g. by the App Intent / Shortcuts) LiveActivityManager: - handleDidBecomeActive posts .liveActivityDidForeground after startFromCurrentState - Notification.Name.liveActivityDidForeground extension defined here MainViewController: - Observes .liveActivityDidForeground; navigates to Snoozer tab if an alarm is currently active, otherwise navigates to tab 0 (Home) Co-Authored-By: Claude Sonnet 4.6 * fix: flush LA update on willResignActive to ensure lock screen shows latest data The 5-second debounce on refreshFromCurrentState means a BG update arriving while the app is foreground won't reach the LA before the user returns to the lock screen. When the debounce fires in background, isForeground=false so the direct ActivityKit update is skipped. Fix: observe willResignActiveNotification, cancel the pending debounce, and immediately push the latest snapshot via direct ActivityKit update while the app is still foreground-active. The APNs push is also sent as a backup. Co-Authored-By: Claude Sonnet 4.6 * feat: redesign Dynamic Island compact and expanded views Compact: - Trailing now shows delta instead of trend arrow (leading already showed BG) Expanded leading: - Large BG value at top - Second row: trend arrow, delta, and "Proj: X" in smaller text below Expanded trailing: - IOB and COB (moved from bottom) Expanded bottom: - "Updated at: HH:mm" (moved from trailing) Co-Authored-By: Claude Sonnet 4.6 * fix: match Proj text style to delta; add trailing padding to IOB/COB - Proj label now uses same size (13), weight (semibold), and opacity (0.9) as the delta text beside it - Added 6pt trailing padding to the expanded DI trailing VStack so the IOB decimal is not clipped by the Dynamic Island edge Co-Authored-By: Claude Sonnet 4.6 * feat: separate Live Activity and APN settings into distinct menus Per maintainer preference, the APNs credentials (Key ID and Key) remain in their own "APN" settings screen. A new separate "Live Activity" settings screen contains the enable/disable toggle and the Restart Live Activity button. - APNSettingsView: restored to Key ID + Key only (no toggle or restart button) - LiveActivitySettingsView: new view with laEnabled toggle and restart button (with 2-second confirmation feedback); toggle stays in sync via onReceive - SettingsMenuView: "APN" entry restored; new "Live Activity" entry added with dot.radiowaves icon; Sheet.liveActivity case wired to LiveActivitySettingsView Co-Authored-By: Claude Sonnet 4.6 * Added Live Activity menu * chore: add LiveActivitySettingsView to Xcode project Co-Authored-By: Claude Sonnet 4.6 * fix: LA tap navigation, manual dismissal prevention, and toggle start - widgetURL on lock screen LA → opens app via loopfollow://la-tap - Link views in expanded DI regions → same URL on tap - AppDelegate.application(_:open:) handles loopfollow://la-tap and posts .liveActivityDidForeground so MainViewController navigates to Snoozer (if alarm active) or Home tab — fires only on LA/DI taps, not every foreground - Notification.Name.liveActivityDidForeground moved outside #if macCatalyst so AppDelegate can reference it unconditionally - handleDidBecomeActive no longer posts .liveActivityDidForeground (was firing on every app foreground, not just LA taps) - attachStateObserver: .dismissed state (user swipe) sets laEnabled = false so the LA does not auto-restart when the app foregrounds - LiveActivitySettingsView: toggling ON calls startFromCurrentState() Co-Authored-By: Claude Sonnet 4.6 * fix: end Live Activity on app force-quit Adds endOnTerminate() to LiveActivityManager — blocks up to 3 s using Task.detached + DispatchSemaphore so the async activity.end() completes before the process exits. Called from applicationWillTerminate so the LA clears from the lock screen immediately on force-quit. laEnabled is preserved so the LA restarts correctly on next launch. Co-Authored-By: Claude Sonnet 4.6 * fix: use dismissedByUser flag instead of disabling laEnabled on manual dismiss - Manual LA swipe-away sets dismissedByUser (in-memory) instead of laEnabled = false, preserving the user's preference - startFromCurrentState() guards on !dismissedByUser so the LA does not auto-restart on foreground, BG refresh, or handleDidBecomeActive - forceRestart() clears dismissedByUser before starting, so the Restart button and App Intent both work as the explicit re-enable mechanism - Toggle ON in settings calls forceRestart() (clears flag + starts) instead of startFromCurrentState() which would have been blocked by the flag - dismissedByUser resets to false on app relaunch (in-memory only), so a kill + relaunch starts the LA fresh as expected Co-Authored-By: Claude Sonnet 4.6 * fix: dismiss modal (Settings sheet) before tab switch on LA tap navigateOnLAForeground now checks tabBarController.presentedViewController and dismisses it before switching to the target tab, matching the existing pattern used elsewhere in MainViewController for the same scenario. Co-Authored-By: Claude Sonnet 4.6 * fix: LA tap navigation timing and LA reappear-after-dismiss Navigation fix: - loopfollow://la-tap URL can arrive before applicationDidBecomeActive while UIKit is still restoring the view hierarchy from background. At that moment tabBarController.presentedViewController is nil so the Settings modal is never dismissed and the navigation notification fires too early. - Now stores a pendingLATapNavigation flag if app is not yet .active, and fires the notification in applicationDidBecomeActive when the full view hierarchy (including presented modals) is restored. LA reappear fix: - performRefresh() calls startIfNeeded() directly, bypassing the dismissedByUser guard that only exists in startFromCurrentState(). On every BG refresh with laEnabled=true and current==nil (post-dismiss), it would recreate the LA. - Added !dismissedByUser guard to refreshFromCurrentState() so the entire refresh pipeline is skipped while dismissed, matching the existing guard in startFromCurrentState(). Co-Authored-By: Claude Sonnet 4.6 * fix: handle loopfollow://la-tap URL in SceneDelegate, not AppDelegate With scene-based lifecycle (iOS 13+), widget/LA tap URLs are delivered to scene(_:openURLContexts:) on UIWindowSceneDelegate — AppDelegate.application (_:open:url:options:) is never called. Our previous handler was dead code. SceneDelegate now implements scene(_:openURLContexts:) which posts .liveActivityDidForeground on the next run loop (via DispatchQueue.main.async) to let the view hierarchy fully settle before navigateOnLAForeground() runs. Also handles the edge case where the URL arrives before sceneDidBecomeActive by storing a pendingLATapNavigation flag. Co-Authored-By: Claude Sonnet 4.6 * feat: configurable LA grid slots + full InfoType snapshot coverage Lock screen layout: - Left column: glucose + trend arrow, delta below (replaces "Last Update:") - Right grid: 4 configurable slots read from LAAppGroupSettings (defaults: IOB/COB/Proj/empty) - Footer: centered HH:MM update time at reduced opacity Slot configuration: - LiveActivitySlotOption enum with all 22 InfoType-aligned cases - LAAppGroupSettings.setSlots() / slots() persisted to App Group UserDefaults - Uniqueness enforced: selecting an option clears it from any other slot - Settings UI: "Grid slots" section with 4 pickers in LiveActivitySettingsView - Changes take effect immediately (refreshFromCurrentState called on save) GlucoseSnapshot extended with 19 new fields covering all InfoType items: override, recBolus, battery, pumpBattery, basalRate, pumpReservoirU, autosens, tdd, targetLowMgdl/High, isfMgdlPerU, carbRatio, carbsToday, profileName, sageInsertTime, cageInsertTime, iageInsertTime, minBgMgdl/Max Storage.swift: 13 new UserDefaults-backed fields for the above metrics Controllers updated to write new Storage fields on each data fetch: Basals, DeviceStatus, DeviceStatusLoop, DeviceStatusOpenAPS, Carbs, Profile, IAge Co-Authored-By: Claude Sonnet 4.6 * fix: label delta and footer on lock screen LA card Co-Authored-By: Claude Sonnet 4.6 * docs: add PR description for configurable LA grid slots Co-Authored-By: Claude Sonnet 4.6 * Update PR_configurable_slots.md * chore: remove PR notes from tracking, keep docs/LiveActivity.md only - Untrack docs/PR_configurable_slots.md (local-only reference doc) - Add docs/PR_configurable_slots.md and docs/LiveActivityTestPlan.md to .gitignore Co-Authored-By: Claude Sonnet 4.6 * fix: include all extended InfoType fields in APNs push payload buildPayload() was only serializing the base fields (glucose, delta, trend, updatedAt, unit, iob, cob, projected). All extended fields added with the configurable grid slots (battery, sageInsertTime, pumpBattery, basalRate, autosens, tdd, targets, isf, carbRatio, carbsToday, profileName, CAGE, IAGE, minBg, maxBg, override, recBolus) were missing from the APNs payload, causing the extension to decode them as nil/0 and display '--' on every push-driven refresh. Co-Authored-By: Claude Sonnet 4.6 * feat: add small family view for CarPlay Dashboard and Watch Smart Stack Registers the Live Activity for the small activity family via .supplementalActivityFamilies([.small]), enabling automatic display on: - CarPlay Dashboard (iOS 26+) - Apple Watch Smart Stack (watchOS 11+) The small view is hardcoded to the essentials appropriate for a driving context: glucose value, trend arrow, delta, and time since last reading. Background tint matches the existing threshold-based color logic. The lock screen layout (full grid with configurable slots) is unchanged. Co-Authored-By: Claude Sonnet 4.6 * fix: guard CarPlay/Watch small family behind iOS 18 availability; increase renewal overlay opacity - supplementalActivityFamilies and activityFamily require iOS 18.0+; restructured into two Widget structs sharing a makeDynamicIsland() helper: - LoopFollowLiveActivityWidget (iOS 16.1+): lock screen + Dynamic Island - LoopFollowLiveActivityWidgetWithCarPlay (iOS 18.0+): adds .supplementalActivityFamilies([.small]) - Bundle uses if #available(iOS 18.0, *) to select the right variant - Podfile: set minimum deployment target to 16.6 for all pods - Increase 'Tap to update' renewal overlay opacity from 60% to 90% Co-Authored-By: Claude Sonnet 4.6 * fix: move if #available into Widget.body to avoid WidgetBundleBuilder limitation @WidgetBundleBuilder does not support if #available { } else { }, but @WidgetConfigurationBuilder (used by Widget.body) does. Collapsed back to a single LoopFollowLiveActivityWidget struct with the iOS 18 conditional inside body — iOS 18+ branch adds .supplementalActivityFamilies([.small]) for CarPlay and Watch Smart Stack; else branch uses the plain lock screen view. Bundle reverts to the original single if #available(iOS 16.1, *) pattern. Co-Authored-By: Claude Sonnet 4.6 * fix: use two separate single-branch if #available in bundle for CarPlay support @WidgetConfigurationBuilder's buildEither requires both branches to return the same concrete type, making if/else with supplementalActivityFamilies impossible (it wraps to a different opaque type). @WidgetBundleBuilder does not support if #available { } else { } at all. Solution: two separate single-branch if #available blocks in the bundle — the pattern that @WidgetBundleBuilder already supported in the original code: - LoopFollowLiveActivityWidget (iOS 16.1+): primary, lock screen + Dynamic Island - LoopFollowLiveActivityWidgetWithCarPlay (iOS 18.0+): supplemental, adds .supplementalActivityFamilies([.small]) for CarPlay Dashboard + Watch Smart Stack ActivityKit uses the supplemental widget for small-family surfaces and the primary widget for lock screen / Dynamic Island, keeping iOS 16.6+ support intact. Co-Authored-By: Claude Sonnet 4.6 * fix: restore two-widget bundle; guard supplementalActivityFamilies and activityFamily behind iOS 18 Upstream's single-widget approach placed iOS 18+ APIs (supplementalActivityFamilies, activityFamily, ActivityFamily) behind @available(iOS 16.1, *), which fails to compile at the 16.6 deployment target. Restoring the two-widget pattern: - LoopFollowLiveActivityWidget (@available iOS 16.1): lock screen + DI, uses LockScreenLiveActivityView, no supplementalActivityFamilies - LoopFollowLiveActivityWidgetWithCarPlay (@available iOS 18.0): adds CarPlay Dashboard + Watch Smart Stack via supplementalActivityFamilies([.small]), uses LockScreenFamilyAdaptiveView (also @available iOS 18.0) - SmallFamilyView availability corrected to @available(iOS 18.0, *) - Bundle registers both via separate if #available blocks Co-Authored-By: Claude Sonnet 4.6 * fix: extension version inherits from parent; remove spurious await in slot config - LoopFollowLAExtension MARKETING_VERSION now uses "$(MARKETING_VERSION)" to match the parent app version automatically, resolving CFBundleShortVersionString mismatch warning - Remove unnecessary Task/await wrapping of refreshFromCurrentState in LiveActivitySettingsView — the method is not async Co-Authored-By: Claude Sonnet 4.6 * fix: prevent glucose + trend arrow clipping on wide mmol/L values At 46pt, a 4-character mmol/L value ("10.5") plus "↑↑" overflowed the 168pt left column, truncating the glucose reading. Fix: reduce trend arrow to 32pt and add minimumScaleFactor(0.7) + lineLimit(1) to the glucose text so values above 10 mmol/L render correctly. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- LoopFollowLAExtension/LoopFollowLiveActivity.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index a70008414..d681a9368 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -164,15 +164,18 @@ private struct LockScreenLiveActivityView: View { HStack(spacing: 12) { // LEFT: Glucose + trend arrow, delta below VStack(alignment: .leading, spacing: 4) { - HStack(alignment: .firstTextBaseline, spacing: 8) { + HStack(alignment: .firstTextBaseline, spacing: 6) { Text(LAFormat.glucose(s)) .font(.system(size: 46, weight: .bold, design: .rounded)) .monospacedDigit() .foregroundStyle(.white) + .minimumScaleFactor(0.7) + .lineLimit(1) Text(LAFormat.trendArrow(s)) - .font(.system(size: 46, weight: .bold, design: .rounded)) + .font(.system(size: 32, weight: .bold, design: .rounded)) .foregroundStyle(.white.opacity(0.95)) + .lineLimit(1) } Text("Delta: \(LAFormat.delta(s))") From e20ec46b776a5e438682614574d9768794ec2d95 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Tue, 17 Mar 2026 13:43:32 -0400 Subject: [PATCH 59/86] chore: remove redundant @available(iOS 16.1) guards The app's minimum deployment target is iOS 16.6, making all iOS 16.1 availability checks redundant. Removed @available(iOS 16.1, *) annotations from all types and the if #available(iOS 16.1, *) wrapper in the bundle. Co-Authored-By: Claude Sonnet 4.6 --- LoopFollowLAExtension/LoopFollowLABundle.swift | 4 +--- LoopFollowLAExtension/LoopFollowLiveActivity.swift | 11 +---------- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/LoopFollowLAExtension/LoopFollowLABundle.swift b/LoopFollowLAExtension/LoopFollowLABundle.swift index a9f7daf6c..fef1aa1fb 100644 --- a/LoopFollowLAExtension/LoopFollowLABundle.swift +++ b/LoopFollowLAExtension/LoopFollowLABundle.swift @@ -11,9 +11,7 @@ import WidgetKit @main struct LoopFollowLABundle: WidgetBundle { var body: some Widget { - if #available(iOS 16.1, *) { - LoopFollowLiveActivityWidget() - } + LoopFollowLiveActivityWidget() if #available(iOS 18.0, *) { LoopFollowLiveActivityWidgetWithCarPlay() } diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index d681a9368..353606b44 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -6,7 +6,6 @@ import SwiftUI import WidgetKit /// Builds the shared Dynamic Island content used by both widget variants. -@available(iOS 16.1, *) private func makeDynamicIsland(context: ActivityViewContext) -> DynamicIsland { DynamicIsland { DynamicIslandExpandedRegion(.leading) { @@ -43,8 +42,7 @@ private func makeDynamicIsland(context: ActivityViewContext Date: Tue, 17 Mar 2026 14:48:29 -0400 Subject: [PATCH 60/86] Fix Live Activity glucose overflow with flexible layout and tighter grid spacing --- CLAUDE.md | 479 ++++++++++++++++++ .../LoopFollowLiveActivity.swift | 20 +- 2 files changed, 491 insertions(+), 8 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..0f9f62f04 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,479 @@ +# LoopFollow Live Activity — Project Context for Claude Code + +## Who you're working with + +This codebase is being developed by **Philippe** (GitHub: `MtlPhil`), contributing to +`loopandlearn/LoopFollow` — an open-source iOS app that lets parents and caregivers of T1D +Loop users monitor glucose and loop status in real time. + +- **Upstream repo:** `https://github.com/loopandlearn/LoopFollow` +- **Philippe's fork:** `https://github.com/achkars-org/LoopFollow` +- **Local clone:** `/Users/philippe/Documents/GitHub/LoopFollowLA/` +- **Active upstream branch:** `live-activity` (PR #537, draft, targeting `dev`) +- **Philippe's original PR:** `#534` (closed, superseded by #537) +- **Maintainer:** `bjorkert` (Jonas Björkert) + +--- + +## What this feature is + +A **Live Activity** for LoopFollow that displays real-time glucose data on the iOS lock screen +and in the Dynamic Island. The feature uses **APNs self-push** — the app sends a push +notification to itself — to drive reliable background updates without interfering with the +background audio session LoopFollow uses to stay alive. + +### What the Live Activity shows +- Current glucose value + trend arrow +- Delta (change since last reading) +- IOB, COB, projected BG (optional — omitted gracefully for Dexcom-only users) +- Time since last reading +- "Not Looping" red banner when Loop hasn't reported in 15+ minutes +- Threshold-driven background color (green / orange / red) +- Dynamic Island: compact, expanded, and minimal presentations + +--- + +## Architecture overview (current state in PR #537) + +### Data flow +``` +BGData / DeviceStatusLoop / DeviceStatusOpenAPS + → write canonical values to Storage.shared + → GlucoseSnapshotBuilder reads Storage + → builds GlucoseSnapshot + → LiveActivityManager pushes via APNSClient + → LoopFollowLAExtension renders the UI +``` + +### Key files + +| File | Purpose | +|------|---------| +| `LiveActivity/LiveActivityManager.swift` | Orchestrates start/stop/refresh of the Live Activity; called from `MainViewController` | +| `LiveActivity/APNSClient.swift` | Sends the APNs self-push; uses `JWTManager.shared` for JWT; reads credentials from `Storage.shared` | +| `Helpers/JWTManager.swift` | **bjorkert addition** — replaces `APNSJWTGenerator`; uses CryptoKit (P256/ES256); multi-slot in-memory cache keyed by `keyId:teamId`, 55-min TTL | +| `LiveActivity/GlucoseSnapshot.swift` | The value-type snapshot passed to the extension; timestamp stored as Unix epoch seconds (UTC) — **timezone bug was fixed here** | +| `LiveActivity/GlucoseSnapshotBuilder.swift` | Reads from Storage, constructs GlucoseSnapshot | +| `LiveActivity/GlucoseSnapshotStore.swift` | In-memory store; debounces rapid successive refreshes | +| `LiveActivity/GlucoseLiveActivityAttributes.swift` | ActivityKit attributes struct | +| `LiveActivity/AppGroupID.swift` | Derives App Group ID dynamically from bundle identifier — no hardcoded team IDs | +| `LiveActivity/LAAppGroupSettings.swift` | Persists LA-specific settings to the shared App Group container | +| `LiveActivity/LAFormat.swift` | **bjorkert addition** — display formatting for LA values; uses `NumberFormatter` with `Locale.current` so decimal separators match device locale (e.g. "5,6" in Swedish) | +| `LiveActivity/PreferredGlucoseUnit.swift` | Reads preferred unit; delegates to `Localizer.getPreferredUnit()` — no longer duplicates unit detection logic | +| `GlucoseConversion.swift` | **Replaces `GlucoseUnitConversion.swift`** — unified constant `18.01559`; `mgDlToMmolL` is a computed reciprocal. Note: the old file used `18.0182` — do not use that constant anywhere | +| `LiveActivity/StorageCurrentGlucoseStateProvider.swift` | Protocol adapter between Storage and LiveActivityManager | +| `LoopFollowLAExtension/LoopFollowLiveActivity.swift` | SwiftUI widget views for lock screen + Dynamic Island | +| `LoopFollowLAExtension/LoopFollowLABundle.swift` | Extension bundle entry point | +| `Settings/APNSettingsView.swift` | **bjorkert addition** — dedicated settings screen for LoopFollow's own APNs key ID and key | +| `Storage/Storage.swift` | Added: `lastBgReadingTimeSeconds`, `lastDeltaMgdl`, `lastTrendCode`, `lastIOB`, `lastCOB`, `projectedBgMgdl` | +| `Storage/Observable.swift` | Added: `isNotLooping` | +| `Storage/Storage+Migrate.swift` | Added: `migrateStep5` — migrates legacy APNs credential keys to new split format | + +--- + +## The core design decisions Philippe made (and why) + +### 1. APNs self-push for background updates +LoopFollow uses a background audio session to stay alive in the background. Initially, the +temptation was to use `ActivityKit` updates directly from the app. The self-push approach was +chosen because it is more reliable and doesn't create timing conflicts with the audio session. +The app sends a push to itself using its own APNs key; the system delivers it with high +priority, waking the extension. + +### 2. Dynamic App Group ID (no hardcoded team IDs) +`AppGroupID.swift` derives the App Group ID from the bundle identifier at runtime. This makes +the feature work across all fork/build configurations without embedding any team-specific +identifiers in code. + +### 3. Single source of truth in Storage +All glucose and loop state is written to `Storage.shared` (and `Observable`) by the existing +data-fetching controllers (BGData, DeviceStatusLoop, DeviceStatusOpenAPS). The Live Activity +layer is purely a consumer — it never fetches its own data. This keeps the architecture clean +and source-agnostic. + +### 4. GlucoseSnapshot stores glucose in mg/dL only — conversion at display time only +The snapshot is a simple struct with no dependencies, designed to be safe to pass across the +app/extension boundary. All glucose values in `GlucoseSnapshot` are stored as **mg/dL**. +Conversion to mmol/L happens exclusively at display time inside `LAFormat`. This eliminates +the previous round-trip (mg/dL → mmol/L at snapshot creation, then mmol/L → mg/dL for +threshold comparison) that bjorkert identified and removed. + +**Rule for all future code:** anything writing a glucose value into a `GlucoseSnapshot` must +supply mg/dL. Anything reading a glucose value from a snapshot for display must convert via +`GlucoseConversion.mgDlToMmolL` if the user's preferred unit is mmol/L. + +### 5. Unix epoch timestamps (UTC) in GlucoseSnapshot +**Critical bug that was discovered and fixed:** ActivityKit operates in UTC epoch seconds, +but the original code was constructing timestamps using local time offsets, causing DST +errors of ±1 hour. The fix ensures all timestamps in `GlucoseSnapshot` are stored as +`TimeInterval` (seconds since Unix epoch, UTC) and converted to display strings only in the +extension, using the device's local calendar. This fix is in the codebase. + +### 6. Debounce on rapid refreshes +A coalescing `DispatchWorkItem` pattern is used in `GlucoseSnapshotStore` to debounce +rapid successive calls to refresh (e.g., when multiple Storage values update in quick +succession during a data fetch). Only one APNs push is sent per update cycle. + +### 7. APNs key injected via xcconfig/Info.plist (Philippe's original approach) +In Philippe's original PR #534, the APNs key was injected at build time via +`xcconfig` / `Info.plist`, sourced from a GitHub Actions secret. This meant credentials were +baked into the build and never committed. + +--- + +## What bjorkert changed (and why it differs from Philippe's approach) + +### Change 1: SwiftJWT → CryptoKit (`JWTManager.swift`) +**Philippe used:** `SwiftJWT` + `swift-crypto` SPM packages for JWT signing. +**bjorkert replaced with:** Apple's built-in `CryptoKit` (P256/ES256) via a new +`JWTManager.swift`. +**Rationale:** Eliminates two third-party dependencies. `JWTManager` adds a multi-slot +in-memory cache (keyed by `keyId:teamId`, 55-min TTL) instead of persisting JWT tokens to +UserDefaults. +**Impact:** `APNSJWTGenerator.swift` is deleted. All JWT logic lives in `JWTManager.shared`. + +### Change 2: Split APNs credentials (lf vs remote) +**Philippe's approach:** One set of APNs credentials shared between Live Activity and remote +commands. +**bjorkert's approach:** Two distinct credential sets: +- `lfApnsKey` / `lfKeyId` — for LoopFollow's own Live Activity self-push +- `remoteApnsKey` / `remoteKeyId` — for remote commands to Loop/Trio + +**Rationale:** Users who don't use remote commands shouldn't need to configure remote +credentials to get Live Activity working. Users who use both (different team IDs for Loop +vs LoopFollow) previously saw confusing "Return Notification Settings" UI that's now removed. +**Migration:** `migrateStep5` in `Storage+Migrate.swift` handles migrating the legacy keys. + +### Change 3: Runtime credential entry via APNSettingsView +**Philippe's approach:** APNs key injected at build time via xcconfig / CI secret. +**bjorkert's approach:** User enters APNs Key ID and Key at runtime via a new +`APNSettingsView` (under Settings menu). +**Rationale:** Removes the `Inject APNs Key Content` CI step entirely. No credentials are +baked into the build or present in `Info.plist`. Browser Build users don't need to manage +GitHub secrets for APNs. Credentials stored in `Storage.shared` at runtime. +**Impact:** `APNSKeyContent`, `APNSKeyID`, `APNSTeamID` removed from `Info.plist`. The CI +workflow no longer has an APNs key injection step. + +### Change 4: APNSClient reads from Storage instead of Info.plist +Follows directly from Change 3. `APNSClient` now calls `Storage.shared` for credentials +and uses `JWTManager.shared` instead of `APNSJWTGenerator`. Sandbox vs production APNs +host selection is based on `BuildDetails.isTestFlightBuild()`. + +### Change 5: Remote command settings UI simplification +The old "Return Notification Settings" section (which appeared when team IDs differed) is +removed. Remote credential fields only appear when team IDs differ. The new `APNSettingsView` +is always the place to enter LoopFollow's own credentials. + +### Change 6: CI / build updates +- `runs-on` updated from `macos-15` to `macos-26` +- Xcode version updated to `Xcode_26.2` +- APNs key injection step removed from `build_LoopFollow.yml` + +### Change 8: Consolidation pass (post-PR-#534 cleanup) +This batch of changes was made by bjorkert after integrating Philippe's code, to reduce +duplication and fix several bugs found during review. + +**mg/dL-only snapshot storage:** +All glucose values in `GlucoseSnapshot` are now stored in mg/dL. The previous code converted +to mmol/L at snapshot creation time, then converted back to mg/dL for threshold comparison — +a pointless round-trip. Conversion now happens only in `LAFormat` at display time. + +**Unified conversion constant:** +`GlucoseUnitConversion.swift` (used `18.0182`) is deleted. +`GlucoseConversion.swift` (uses `18.01559`) is the single source. Do not use `18.0182` anywhere. + +**Deduplicated unit detection:** +`PreferredGlucoseUnit.hkUnit()` now delegates to `Localizer.getPreferredUnit()` instead of +reimplementing the same logic. + +**New trend cases (↗ / ↘):** +`GlucoseSnapshot` trend now includes `upSlight` / `downSlight` cases (FortyFiveUp/Down), +rendering as `↗` / `↘` instead of collapsing to `↑` / `↓`. All trend switch statements +must handle these cases. + +**Locale bug fixed in `LAFormat`:** +`LAFormat` now uses `NumberFormatter` with `Locale.current` so decimal separators match +the device locale. Do not format glucose floats with string interpolation directly — +always go through `LAFormat`. + +**`LAThresholdSync.swift` deleted:** +Was never called. Removed as dead code. Do not re-introduce it. + +**APNs payload fix — `isNotLooping`:** +The APNs push payload was missing the `isNotLooping` field, so push-based updates never +showed the "Not Looping" overlay. Now fixed — the field is included in every push. + + +bjorkert ran swiftformat across all Live Activity files: standardized file headers, +alphabetized imports, added trailing commas, cleaned whitespace. No logic changes. + +--- + +## What was preserved from Philippe's PR intact + +- All `LiveActivity/` Swift files except those explicitly deleted: + - **Deleted:** `APNSJWTGenerator.swift` (replaced by `JWTManager.swift`) + - **Deleted:** `GlucoseUnitConversion.swift` (replaced by `GlucoseConversion.swift`) + - **Deleted:** `LAThresholdSync.swift` (dead code) +- The `LoopFollowLAExtension/` files (both `LoopFollowLiveActivity.swift` and + `LoopFollowLABundle.swift`) +- The data flow architecture (Storage → SnapshotBuilder → LiveActivityManager → APNSClient) +- The DST/timezone fix in `GlucoseSnapshot.swift` +- The debounce pattern in `GlucoseSnapshotStore.swift` +- The `AppGroupID` dynamic derivation approach +- The "Not Looping" detection via `Observable.isNotLooping` +- The Storage fields added for Live Activity data +- The `docs/LiveActivity.md` architecture + APNs setup guide +- The Fastfile changes for the extension App ID and provisioning profile + +--- + +## Current task: Live Activity auto-renewal (8-hour limit workaround) + +### Background +Apple enforces an **8-hour maximum lifetime** on Live Activities in the Dynamic Island +(12 hours on the Lock Screen, but the DA kills at 8). For a continuous glucose monitor +follower app used overnight or during long days, this is a hard UX problem: the LA simply +disappears mid-use without warning. + +bjorkert has asked Philippe to implement a workaround. + +### Apple's constraints (confirmed) +- 8 hours from `Activity.request()` call — not from last update +- System terminates the LA hard at that point; no callback before termination +- The app **can** call `Activity.end()` + `Activity.request()` from the background via + the existing audio session LoopFollow already holds +- `Activity.end(dismissalPolicy: .immediate)` removes the card from the Lock Screen + immediately — critical to avoid two cards appearing simultaneously during renewal +- There is no built-in Apple API to query an LA's remaining lifetime + +### Design decision: piggyback on the existing refresh heartbeat +**Rejected approach:** A standalone `Timer` or `DispatchQueue.asyncAfter` set for 7.5 hrs. +This is fragile — timers don't survive suspension, and adding a separate scheduling +mechanism is complexity for no benefit when a natural heartbeat already exists. + +**Chosen approach:** Check LA age on every call to `refreshFromCurrentState(reason:)`. +Since this is called on every glucose update (~every 5 minutes via LoopFollow's existing +BGData polling cycle), the worst-case gap before renewal is one polling interval. The +check is cheap (one subtraction). If age ≥ threshold, end the current LA and immediately +re-request before doing the normal refresh. + +### Files to change +| File | Change | +|------|--------| +| `Storage/Storage.swift` | Add `laStartTime: TimeInterval` stored property (UserDefaults-backed, default 0) | +| `LiveActivity/LiveActivityManager.swift` | Record `laStartTime` on every successful `Activity.request()`; check age in `refreshFromCurrentState(reason:)`; add `renewIfNeeded()` helper | + +No other files need to change. The renewal is fully encapsulated in `LiveActivityManager`. + +### Key constants +```swift +static let renewalThreshold: TimeInterval = 7.5 * 3600 // 27,000 s — renew at 7.5 hrs +static let storageKey = "laStartTime" // key in Storage/UserDefaults +``` + +### Behaviour spec +1. On every `refreshFromCurrentState(reason:)` call, before building the snapshot: + - Compute `age = now - Storage.shared.laStartTime` + - If `age >= renewalThreshold` AND a live activity is currently active: + - End it with `.immediate` dismissal (clears the Lock Screen card instantly) + - Re-request a new LA with the current snapshot content + - Record new `laStartTime = now` + - Return (the re-request itself sends the first APNs update) +2. On every successful `Activity.request()` (including normal `startFromCurrentState()`): + - Set `Storage.shared.laStartTime = Date().timeIntervalSince1970` +3. On `stopLiveActivity()` (user-initiated stop or app termination): + - Reset `Storage.shared.laStartTime = 0` +4. On app launch / `startFromCurrentState()` with an already-running LA (resume path): + - Do NOT reset `laStartTime` — the existing value is the correct age anchor + - This handles the case where the app is killed and relaunched mid-session + +### Edge cases to handle +- **User dismisses the LA manually:** ActivityKit transitions to `.dismissed`. The existing + `activityStateUpdates` observer in `LiveActivityManager` already handles this. `laStartTime` + will be stale but harmless — next call to `startFromCurrentState()` will overwrite it. +- **App is not running at the 8-hr mark:** The system kills the LA. When the app next + becomes active and calls `startFromCurrentState()`, it will detect no active LA and + request a fresh one, resetting `laStartTime`. No special handling needed. +- **Multiple rapid calls to `refreshFromCurrentState` during renewal:** The existing + debounce in `GlucoseSnapshotStore` guards this. The renewal path returns early after + re-requesting, so the debounce never even fires. +- **laStartTime = 0 (never set / first launch):** Age will be enormous (current epoch), + but the guard `currentActivity != nil` prevents a spurious renewal when there's no + active LA. Safe. + +### Full implementation (ready to apply) + +#### `Storage/Storage.swift` addition +Add alongside the other LA-related stored properties: + +```swift +// Live Activity renewal +var laStartTime: TimeInterval { + get { return UserDefaults.standard.double(forKey: "laStartTime") } + set { UserDefaults.standard.set(newValue, forKey: "laStartTime") } +} +``` + +#### `LiveActivity/LiveActivityManager.swift` changes + +Add the constant and the helper near the top of the class: + +```swift +// MARK: - Constants +private static let renewalThreshold: TimeInterval = 7.5 * 3600 + +// MARK: - Renewal + +/// Ends the current Live Activity immediately and re-requests a fresh one, +/// working around Apple's 8-hour maximum LA lifetime. +/// Returns true if renewal was performed (caller should return early). +@discardableResult +private func renewIfNeeded(snapshot: GlucoseSnapshot) async -> Bool { + guard let activity = currentActivity else { return false } + + let age = Date().timeIntervalSince1970 - Storage.shared.laStartTime + guard age >= LiveActivityManager.renewalThreshold else { return false } + + os_log(.info, log: log, "Live Activity age %.0f s >= threshold, renewing", age) + + // End with .immediate so the stale card clears before the new one appears + await activity.end(nil, dismissalPolicy: .immediate) + currentActivity = nil + + // Re-request using the snapshot we already built + await startWithSnapshot(snapshot) + return true +} +``` + +Modify `startFromCurrentState()` to record the start time after a successful request: + +```swift +func startFromCurrentState() async { + guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } + guard currentActivity == nil else { return } + + let snapshot = GlucoseSnapshotBuilder.build() + await startWithSnapshot(snapshot) +} + +/// Internal helper — requests a new LA and records the start time. +private func startWithSnapshot(_ snapshot: GlucoseSnapshot) async { + let attributes = GlucoseLiveActivityAttributes() + let content = ActivityContent(state: snapshot, staleDate: nil) + do { + currentActivity = try Activity.request( + attributes: attributes, + content: content, + pushType: .token + ) + // Record when this LA was started for renewal tracking + Storage.shared.laStartTime = Date().timeIntervalSince1970 + os_log(.info, log: log, "Live Activity started, laStartTime recorded") + + // Observe push token and state updates (existing logic) + observePushTokenUpdates() + observeActivityStateUpdates() + } catch { + os_log(.error, log: log, "Failed to start Live Activity: %@", error.localizedDescription) + } +} +``` + +Modify `refreshFromCurrentState(reason:)` to call `renewIfNeeded` before the normal path: + +```swift +func refreshFromCurrentState(reason: String) async { + guard currentActivity != nil else { + // No active LA — nothing to refresh + return + } + + let snapshot = GlucoseSnapshotBuilder.build() + + // Check if the LA is approaching Apple's 8-hour limit and renew if so. + // renewIfNeeded returns true if it performed a renewal; we return early + // because startWithSnapshot already sent the first update for the new LA. + if await renewIfNeeded(snapshot: snapshot) { return } + + // Normal refresh path — send APNs self-push with updated snapshot + await GlucoseSnapshotStore.shared.update(snapshot: snapshot) +} +``` + +Modify `stopLiveActivity()` to reset the start time: + +```swift +func stopLiveActivity() async { + guard let activity = currentActivity else { return } + await activity.end(nil, dismissalPolicy: .immediate) + currentActivity = nil + Storage.shared.laStartTime = 0 + os_log(.info, log: log, "Live Activity stopped, laStartTime reset") +} +``` + +### Testing checklist +- [ ] Manually set `renewalThreshold` to 60 seconds during testing to verify the + renewal cycle works without waiting 7.5 hours +- [ ] Confirm the old Lock Screen card disappears before the new one appears + (`.immediate` dismissal working correctly) +- [ ] Confirm `laStartTime` is reset to 0 on manual stop +- [ ] Confirm `laStartTime` is NOT reset when the app is relaunched with an existing + active LA (resume path) +- [ ] Confirm no duplicate LAs appear during renewal +- [ ] Restore `renewalThreshold` to `7.5 * 3600` before committing + +--- + +## Known issues / things still in progress + +- PR #537 is currently marked **Draft** as of March 12, 2026 +- bjorkert's last commit (`524b3bb`) was March 11, 2026 +- The PR is targeting `dev` and has 6 commits total (5 from Philippe, 1 from bjorkert) +- **Active task:** LA auto-renewal (8-hour limit workaround) — see section above + +--- + +## APNs self-push mechanics (important context) + +The self-push flow: +1. `LiveActivityManager.refreshFromCurrentState(reason:)` is called (from MainViewController + or on a not-looping state change) +2. It calls `GlucoseSnapshotBuilder` → `GlucoseSnapshotStore` +3. The store debounces and triggers `APNSClient.sendUpdate(snapshot:)` +4. `APNSClient` fetches credentials from `Storage.shared`, calls `JWTManager.shared` for a + signed JWT (cached for 55 min), then POSTs to the APNs HTTP/2 endpoint +5. The system delivers the push to `LoopFollowLAExtension`, which updates the Live Activity UI + +**APNs environments:** +- Sandbox (development/TestFlight): `api.sandbox.push.apple.com` +- Production: `api.push.apple.com` +- Selection is automatic via `BuildDetails.isTestFlightBuild()` + +**Token expiry handling:** APNs self-push token expiry (HTTP 410 / 400 BadDeviceToken) +is handled in `APNSClient` with appropriate error logging. The token is the Live Activity +push token obtained from `ActivityKit`, not a device token. + +--- + +## Repo / branch conventions + +- `main` — released versions only (version ends in `.0`) +- `dev` — integration branch; PR #537 targets this +- `live-activity` — bjorkert's working branch for the feature (upstream) +- Philippe's fork branches: `dev`, `live-activity-pr` (original work) +- Version format: `M.N.P` — P increments on each `dev` merge, N increments on release + +--- + +## Build configuration notes + +- App Group ID is derived dynamically — do not hardcode team IDs anywhere +- APNs credentials are now entered by the user at runtime in APNSettingsView +- No APNs secrets in xcconfig, Info.plist, or CI environment variables (as of bjorkert's + latest commit) +- The extension target is `LoopFollowLAExtension` with its own entitlements file + (`LoopFollowLAExtensionExtension.entitlements`) +- `Package.resolved` has been updated to remove SwiftJWT and swift-crypto dependencies diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 353606b44..753402e05 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -161,26 +161,30 @@ private struct LockScreenLiveActivityView: View { HStack(spacing: 12) { // LEFT: Glucose + trend arrow, delta below VStack(alignment: .leading, spacing: 4) { - HStack(alignment: .firstTextBaseline, spacing: 6) { + HStack(alignment: .firstTextBaseline, spacing: 4) { Text(LAFormat.glucose(s)) .font(.system(size: 46, weight: .bold, design: .rounded)) .monospacedDigit() .foregroundStyle(.white) - .minimumScaleFactor(0.7) .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(width: 168, alignment: .leading) + .frame(minWidth: 168, maxWidth: 190, alignment: .leading) .layoutPriority(2) // Divider @@ -190,12 +194,12 @@ private struct LockScreenLiveActivityView: View { .padding(.vertical, 8) // RIGHT: configurable 2×2 grid - VStack(spacing: 10) { - HStack(spacing: 16) { + VStack(spacing: 8) { + HStack(spacing: 12) { SlotView(option: slotConfig[0], snapshot: s) SlotView(option: slotConfig[1], snapshot: s) } - HStack(spacing: 16) { + HStack(spacing: 12) { SlotView(option: slotConfig[2], snapshot: s) SlotView(option: slotConfig[3], snapshot: s) } @@ -281,7 +285,7 @@ private struct MetricBlock: View { .lineLimit(1) .minimumScaleFactor(0.85) } - .frame(width: 64, alignment: .leading) // consistent 2×2 columns + .frame(width: 60, alignment: .leading) // slightly tighter columns to free space for glucose } } @@ -295,7 +299,7 @@ private struct SlotView: View { if option == .none { // Invisible spacer — preserves grid alignment Color.clear - .frame(width: 64, height: 36) + .frame(width: 60, height: 36) } else { MetricBlock(label: option.gridLabel, value: value(for: option)) } From d99e7784bb6c84bbb4ca89ad3b930a6341662aa6 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Tue, 17 Mar 2026 17:25:37 -0400 Subject: [PATCH 61/86] Fix Live Activity glucose overflow with flexible layout and tighter grid spacing Co-Authored-By: Claude Sonnet 4.6 --- .../LoopFollowLiveActivity.swift | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 353606b44..753402e05 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -161,26 +161,30 @@ private struct LockScreenLiveActivityView: View { HStack(spacing: 12) { // LEFT: Glucose + trend arrow, delta below VStack(alignment: .leading, spacing: 4) { - HStack(alignment: .firstTextBaseline, spacing: 6) { + HStack(alignment: .firstTextBaseline, spacing: 4) { Text(LAFormat.glucose(s)) .font(.system(size: 46, weight: .bold, design: .rounded)) .monospacedDigit() .foregroundStyle(.white) - .minimumScaleFactor(0.7) .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(width: 168, alignment: .leading) + .frame(minWidth: 168, maxWidth: 190, alignment: .leading) .layoutPriority(2) // Divider @@ -190,12 +194,12 @@ private struct LockScreenLiveActivityView: View { .padding(.vertical, 8) // RIGHT: configurable 2×2 grid - VStack(spacing: 10) { - HStack(spacing: 16) { + VStack(spacing: 8) { + HStack(spacing: 12) { SlotView(option: slotConfig[0], snapshot: s) SlotView(option: slotConfig[1], snapshot: s) } - HStack(spacing: 16) { + HStack(spacing: 12) { SlotView(option: slotConfig[2], snapshot: s) SlotView(option: slotConfig[3], snapshot: s) } @@ -281,7 +285,7 @@ private struct MetricBlock: View { .lineLimit(1) .minimumScaleFactor(0.85) } - .frame(width: 64, alignment: .leading) // consistent 2×2 columns + .frame(width: 60, alignment: .leading) // slightly tighter columns to free space for glucose } } @@ -295,7 +299,7 @@ private struct SlotView: View { if option == .none { // Invisible spacer — preserves grid alignment Color.clear - .frame(width: 64, height: 36) + .frame(width: 60, height: 36) } else { MetricBlock(label: option.gridLabel, value: value(for: option)) } From 68d2a069acaa9c0357cd2e2c5d01bc1421e4a5b9 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Tue, 17 Mar 2026 17:33:29 -0400 Subject: [PATCH 62/86] fix: restart LA on foreground when renewal overlay is showing Previously handleForeground() only restarted the LA when laRenewalFailed=true, but the renewal overlay also appears as a warning before renewal is attempted (while laRenewalFailed is still false). Users who foregrounded during the warning window saw the overlay persist with no restart occurring. Now triggers a restart whenever the overlay is showing (within the warning window before the deadline) OR renewal previously failed. Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow/LiveActivity/LiveActivityManager.swift | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 41f129c60..fc110d195 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -87,13 +87,19 @@ final class LiveActivityManager { @objc private func handleForeground() { guard Storage.shared.laEnabled.value else { return } - LogManager.shared.log(category: .general, message: "[LA] foreground notification received, laRenewalFailed=\(Storage.shared.laRenewalFailed.value)") - guard Storage.shared.laRenewalFailed.value else { return } - // Renewal previously failed — end the stale LA and start a fresh one. + 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 notification received, laRenewalFailed=\(renewalFailed), overlayShowing=\(overlayIsShowing)") + guard renewalFailed || overlayIsShowing else { return } + + // Overlay is showing or renewal previously failed — end the stale LA and start a fresh one. // We cannot call startIfNeeded() here: it finds the existing activity in // Activity.activities and reuses it rather than replacing it. - LogManager.shared.log(category: .general, message: "[LA] ending stale LA and restarting after renewal failure") + LogManager.shared.log(category: .general, message: "[LA] ending stale LA and restarting (renewalFailed=\(renewalFailed), overlayShowing=\(overlayIsShowing))") // Clear state synchronously so any snapshot built between now and when the // new LA is started computes showRenewalOverlay = false. Storage.shared.laRenewBy.value = 0 From 749264bdde1b753bb36616ad4b228ee21cbfe916 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Tue, 17 Mar 2026 18:24:48 -0400 Subject: [PATCH 63/86] fix: recover from audio session failure and alert user via LA overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When AVAudioSession.setActive() fails (e.g. another app holds the session exclusively), the app loses its background keep-alive with no recovery path. Two bugs fixed and recovery logic added: 1. interruptedAudio handler was calling playAudio() on interruption *began* (intValue == 1) instead of *ended* — corrected to restart on .ended only. 2. playAudio() catch block now retries up to 3 times (2s apart). After all retries are exhausted it posts a BackgroundAudioFailed notification. 3. LiveActivityManager observes BackgroundAudioFailed and immediately sets laRenewBy to now (making showRenewalOverlay = true) then pushes a refresh so the lock screen overlay tells the user to foreground the app. Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow/Helpers/BackgroundTaskAudio.swift | 38 ++++++++++++++++--- .../LiveActivity/LiveActivityManager.swift | 16 ++++++++ 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/LoopFollow/Helpers/BackgroundTaskAudio.swift b/LoopFollow/Helpers/BackgroundTaskAudio.swift index 91504ab5d..98e0ba2f5 100755 --- a/LoopFollow/Helpers/BackgroundTaskAudio.swift +++ b/LoopFollow/Helpers/BackgroundTaskAudio.swift @@ -9,26 +9,37 @@ class BackgroundTask { var player = AVAudioPlayer() var timer = Timer() + private var retryCount = 0 + private let maxRetries = 3 + private var retryTimer: Timer? + // MARK: - Methods func startBackgroundTask() { NotificationCenter.default.addObserver(self, selector: #selector(interruptedAudio), name: AVAudioSession.interruptionNotification, object: AVAudioSession.sharedInstance()) + retryCount = 0 playAudio() } func stopBackgroundTask() { NotificationCenter.default.removeObserver(self, name: AVAudioSession.interruptionNotification, object: nil) + retryTimer?.invalidate() + retryTimer = nil player.stop() 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() } + guard notification.name == AVAudioSession.interruptionNotification, + let userInfo = notification.userInfo, + let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt, + let type = AVAudioSession.InterruptionType(rawValue: typeValue) + else { return } + + if type == .ended { + retryCount = 0 + playAudio() } } @@ -36,7 +47,6 @@ class BackgroundTask { 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 +55,25 @@ class BackgroundTask { player.volume = 0.01 player.prepareToPlay() player.play() + retryCount = 0 LogManager.shared.log(category: .general, message: "Silent audio playing", isDebug: true) } catch { LogManager.shared.log(category: .general, message: "playAudio, error: \(error)") + if retryCount < maxRetries { + retryCount += 1 + LogManager.shared.log(category: .general, message: "playAudio retry \(retryCount)/\(maxRetries) in 2s") + retryTimer?.invalidate() + retryTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in + self?.playAudio() + } + } else { + LogManager.shared.log(category: .general, message: "playAudio failed after \(maxRetries) retries — posting BackgroundAudioFailed") + NotificationCenter.default.post(name: .backgroundAudioFailed, object: nil) + } } } } + +extension Notification.Name { + static let backgroundAudioFailed = Notification.Name("BackgroundAudioFailed") +} diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index fc110d195..00d230e40 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -32,6 +32,12 @@ final class LiveActivityManager { 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.). @@ -134,6 +140,16 @@ final class LiveActivityManager { } } + @objc private func handleBackgroundAudioFailed() { + guard Storage.shared.laEnabled.value, current != nil else { return } + // The background audio session has permanently failed — the app will lose its + // background keep-alive. Immediately push the renewal overlay so the user sees + // "Tap to update" on the lock screen and knows to foreground the app. + LogManager.shared.log(category: .general, message: "[LA] background audio failed — forcing renewal overlay") + Storage.shared.laRenewBy.value = Date().timeIntervalSince1970 + refreshFromCurrentState(reason: "audio-session-failed") + } + static let renewalThreshold: TimeInterval = 7.5 * 3600 static let renewalWarning: TimeInterval = 20 * 60 From 3769275294aaabbe875707dce7cce96feea37ac0 Mon Sep 17 00:00:00 2001 From: Phil A <76601115+MtlPhil@users.noreply.github.com> Date: Wed, 18 Mar 2026 08:25:31 -0400 Subject: [PATCH 64/86] Update BackgroundTaskAudio.swift --- LoopFollow/Helpers/BackgroundTaskAudio.swift | 61 +++++++++++++------- 1 file changed, 39 insertions(+), 22 deletions(-) diff --git a/LoopFollow/Helpers/BackgroundTaskAudio.swift b/LoopFollow/Helpers/BackgroundTaskAudio.swift index 98e0ba2f5..67e76a03e 100755 --- a/LoopFollow/Helpers/BackgroundTaskAudio.swift +++ b/LoopFollow/Helpers/BackgroundTaskAudio.swift @@ -5,45 +5,62 @@ import AVFoundation class BackgroundTask { // MARK: - Vars - + var player = AVAudioPlayer() - var timer = Timer() - + private var retryCount = 0 private let maxRetries = 3 - private var retryTimer: Timer? - + // MARK: - Methods - + func startBackgroundTask() { NotificationCenter.default.addObserver(self, selector: #selector(interruptedAudio), name: AVAudioSession.interruptionNotification, object: AVAudioSession.sharedInstance()) retryCount = 0 playAudio() } - + func stopBackgroundTask() { NotificationCenter.default.removeObserver(self, name: AVAudioSession.interruptionNotification, object: nil) - retryTimer?.invalidate() - retryTimer = nil player.stop() 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") guard notification.name == AVAudioSession.interruptionNotification, let userInfo = notification.userInfo, let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt, let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { return } - - if type == .ended { + + switch type { + case .began: + LogManager.shared.log(category: .general, message: "[LA] Silent audio session interrupted (began)") + + case .ended: + // Check shouldResume hint — skip restart if iOS says not to + if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt { + let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue) + guard options.contains(.shouldResume) else { + LogManager.shared.log(category: .general, message: "[LA] Silent audio interruption ended — shouldResume not set, skipping restart") + return + } + } + LogManager.shared.log(category: .general, message: "[LA] Silent audio interruption ended — scheduling restart in 0.5s") retryCount = 0 - playAudio() + // 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() { + let attemptDesc = retryCount == 0 ? "initial attempt" : "retry \(retryCount)/\(maxRetries)" do { let bundle = Bundle.main.path(forResource: "blank", ofType: "wav") let alertSound = URL(fileURLWithPath: bundle!) @@ -56,14 +73,13 @@ class BackgroundTask { player.prepareToPlay() player.play() retryCount = 0 - LogManager.shared.log(category: .general, message: "Silent audio playing", isDebug: true) + 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 retry \(retryCount)/\(maxRetries) in 2s") - retryTimer?.invalidate() - retryTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in + 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 { @@ -72,8 +88,9 @@ class BackgroundTask { } } } + } extension Notification.Name { - static let backgroundAudioFailed = Notification.Name("BackgroundAudioFailed") -} + static let backgroundAudioFailed = Notification.Name(“BackgroundAudioFailed”) +} \ No newline at end of file From 27a6efc89326cdee96d1a53816f653392b29f2fb Mon Sep 17 00:00:00 2001 From: Phil A <76601115+MtlPhil@users.noreply.github.com> Date: Wed, 18 Mar 2026 08:28:49 -0400 Subject: [PATCH 65/86] Live Activity: foreground restart on overlay, audio session recovery, layout fixes, cleanup (#554) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Live Activity auto-renewal to work around 8-hour system limit Co-Authored-By: Claude Sonnet 4.6 * test: reduce LA renewal threshold to 20 min for testing Co-Authored-By: Claude Sonnet 4.6 * feat: improve LA renewal robustness and stale indicator - staleDate on every ActivityContent now tracks the renewal deadline, so the system shows Apple's built-in stale indicator if renewal fails - Add laRenewalFailed StorageValue; set on Activity.request() failure, cleared on any successful LA start - Observe willEnterForegroundNotification: retry startIfNeeded() if a previous renewal attempt failed - New-first renewal order: request the replacement LA before ending the old one — if the request throws the existing LA stays alive so the user keeps live data until the system kills it at the 8-hour mark Co-Authored-By: Claude Sonnet 4.6 * feat: renewal warning overlay + restore 7.5h threshold - Restore renewalThreshold to 7.5 * 3600 (testing complete) - Add showRenewalOverlay: Bool to GlucoseSnapshot (Codable, default false) - GlucoseSnapshotBuilder sets it true when now >= laRenewBy - 1800 (30 minutes before the renewal deadline) - Lock screen: 60% gray overlay with "Tap to update" centered in white, layered above the existing isNotLooping overlay - DI expanded: RenewalOverlayView applied to leading/trailing/bottom regions; "Tap to update" text shown on the bottom region only - showRenewalOverlay resets to false automatically on renewal since laRenewBy is updated and the next snapshot rebuild clears the flag Co-Authored-By: Claude Sonnet 4.6 * fix: overlay not appearing + foreground restart not working Overlay rendering: - Replace Group{if condition{ZStack{RoundedRectangle...}}} with a permanently-present ZStack toggled via .opacity(). The Group/if pattern causes SwiftUI sizing ambiguity when the condition transitions from false→true inside an .overlay(), producing a zero-size result. The .opacity() approach keeps a stable view hierarchy. - Same fix applied to RenewalOverlayView used on DI expanded regions. Foreground restart: - handleForeground() was calling startIfNeeded(), which finds the still-alive (failed-to-renew) LA in Activity.activities and reuses it, doing nothing useful. Fixed to manually nil out current, cancel all tasks, await activity.end(.immediate), then startFromCurrentState(). Overlay timing: - Changed warning window from 30 min (1800s) to 20 min (1200s) before the renewal deadline, matching the intended test cadence. Logging: - handleForeground: log on every foreground event with laRenewalFailed value - renewIfNeeded: log how many seconds past the deadline when firing - GlucoseSnapshotBuilder: log when overlay activates with seconds to deadline - performRefresh: log when sending an update with the overlay visible Co-Authored-By: Claude Sonnet 4.6 * test: set renewalThreshold to 20 min for testing Co-Authored-By: Claude Sonnet 4.6 * fix: renewal overlay not clearing after LA is refreshed Normal renewal path (renewIfNeeded success): - Build a fresh snapshot with showRenewalOverlay: false for the new LA's initial content — it has a new deadline so the overlay should never be visible from the first frame. - Save that fresh snapshot to GlucoseSnapshotStore so the next duplicate check has the correct baseline and doesn't suppress the first real BG update. Foreground restart path (handleForeground): - Zero laRenewBy before calling startFromCurrentState() so GlucoseSnapshotBuilder computes showRenewalOverlay = false for the seed snapshot. startIfNeeded() then sets the new deadline after the request succeeds. Co-Authored-By: Claude Sonnet 4.6 * fix: overlay permanently active when warning window equals threshold With renewalThreshold=20min and the hardcoded 1200s warning window, renewBy-1200 = start, so showRenewalOverlay is always true from the moment the LA begins. Extract a named renewalWarning constant (5 min for testing) so the warning window is always less than the threshold. The builder now reads LiveActivityManager.renewalWarning instead of a hardcoded literal. Production values to restore before merging: renewalThreshold = 7.5 * 3600 renewalWarning = 20 * 60 Co-Authored-By: Claude Sonnet 4.6 * fix: include showRenewalOverlay in APNs payload and clear laRenewBy synchronously APNSClient was missing showRenewalOverlay from the push payload, so background APNs updates never delivered the overlay flag to the extension — only foreground direct ActivityKit updates did. In handleForeground, laRenewBy is now zeroed synchronously before spawning the async end/restart Task. This means any snapshot built between the foreground notification and the new LA start (e.g. from viewDidAppear's startFromCurrentState) computes showRenewalOverlay = false rather than reading the stale expired deadline. Co-Authored-By: Claude Sonnet 4.6 * fix: await LA end before restarting on foreground retry to avoid reuse path Reset laRenewBy and laRenewalFailed synchronously before tearing down the failed LA, then await activity.end() before calling startFromCurrentState(). This guarantees Activity.activities is clear when startIfNeeded() runs, so it takes the fresh-request path and writes a new laRenewBy. startFromCurrentState rebuilds the snapshot with showRenewalOverlay=false (laRenewBy=0), saves it to the store, then startIfNeeded uses that clean snapshot as the seed. Co-Authored-By: Claude Sonnet 4.6 * chore: restore production renewal timing (7.5h threshold, 20min warning) Co-Authored-By: Claude Sonnet 4.6 * feat: laEnabled toggle, forceRestart(), and RestartLiveActivityIntent - SettingsMenuView: rename "APN" menu entry to "Live Activity" - Storage: add laEnabled: Bool StorageValue (default false) - APNSettingsView: on/off toggle bound to laEnabled; APNs key fields and Restart button shown only when enabled; disabling immediately ends any running LA - LiveActivityManager: - forceRestart() (@MainActor) ends all running activities, resets laRenewBy and laRenewalFailed, then calls startFromCurrentState() - laEnabled guard added to startFromCurrentState(), refreshFromCurrentState(), handleForeground(), and handleDidBecomeActive() - didBecomeActiveNotification observer calls forceRestart() on every foreground transition when laEnabled is true - RestartLiveActivityIntent: AppIntent + ForegroundContinuableIntent; sets laEnabled, validates credentials, opens settings deep link if missing, otherwise calls forceRestart() in foreground; LoopFollowAppShortcuts exposes the intent with Siri phrase Note: RestartLiveActivityIntent.swift must be added to the app target in Xcode. Co-Authored-By: Claude Sonnet 4.6 * Added RestartLiveActivityIntent to project * fix: resolve two build errors in LiveActivityManager and RestartLiveActivityIntent - LiveActivityManager: handleDidBecomeActive() calls @MainActor forceRestart() via Task { @MainActor in ... } to satisfy Swift concurrency isolation - RestartLiveActivityIntent: drop ForegroundContinuableIntent — continueInForeground() was renamed to continueInForeground(_:alwaysConfirm:) in the iOS 26 SDK and requires iOS 26+; the didBecomeActiveNotification observer handles restart when the app comes to foreground, making explicit continuation unnecessary Co-Authored-By: Claude Sonnet 4.6 * fix: guard continueInForeground() behind iOS 26 availability check continueInForeground(_:alwaysConfirm:) is iOS 26+ only. On earlier versions, forceRestart() runs directly via the existing background audio session — no foreground continuation needed. Co-Authored-By: Claude Sonnet 4.6 * fix: use startFromCurrentState in handleDidBecomeActive instead of forceRestart forceRestart() was killing the LA that the RestartLiveActivityIntent had just created, because continueInForeground() triggers didBecomeActive. startFromCurrentState() reuses an existing active LA via startIfNeeded() and only creates one if none is present — no destructive race. Co-Authored-By: Claude Sonnet 4.6 * feat: LA foreground tab navigation, button feedback, and toggle sync APNSettingsView: - "Restart Live Activity" button shows "Live Activity Restarted" for 2 seconds and disables itself during that window to prevent double-taps - Toggle observes Storage.shared.laEnabled via onReceive so it reflects changes made externally (e.g. by the App Intent / Shortcuts) LiveActivityManager: - handleDidBecomeActive posts .liveActivityDidForeground after startFromCurrentState - Notification.Name.liveActivityDidForeground extension defined here MainViewController: - Observes .liveActivityDidForeground; navigates to Snoozer tab if an alarm is currently active, otherwise navigates to tab 0 (Home) Co-Authored-By: Claude Sonnet 4.6 * fix: flush LA update on willResignActive to ensure lock screen shows latest data The 5-second debounce on refreshFromCurrentState means a BG update arriving while the app is foreground won't reach the LA before the user returns to the lock screen. When the debounce fires in background, isForeground=false so the direct ActivityKit update is skipped. Fix: observe willResignActiveNotification, cancel the pending debounce, and immediately push the latest snapshot via direct ActivityKit update while the app is still foreground-active. The APNs push is also sent as a backup. Co-Authored-By: Claude Sonnet 4.6 * feat: redesign Dynamic Island compact and expanded views Compact: - Trailing now shows delta instead of trend arrow (leading already showed BG) Expanded leading: - Large BG value at top - Second row: trend arrow, delta, and "Proj: X" in smaller text below Expanded trailing: - IOB and COB (moved from bottom) Expanded bottom: - "Updated at: HH:mm" (moved from trailing) Co-Authored-By: Claude Sonnet 4.6 * fix: match Proj text style to delta; add trailing padding to IOB/COB - Proj label now uses same size (13), weight (semibold), and opacity (0.9) as the delta text beside it - Added 6pt trailing padding to the expanded DI trailing VStack so the IOB decimal is not clipped by the Dynamic Island edge Co-Authored-By: Claude Sonnet 4.6 * feat: separate Live Activity and APN settings into distinct menus Per maintainer preference, the APNs credentials (Key ID and Key) remain in their own "APN" settings screen. A new separate "Live Activity" settings screen contains the enable/disable toggle and the Restart Live Activity button. - APNSettingsView: restored to Key ID + Key only (no toggle or restart button) - LiveActivitySettingsView: new view with laEnabled toggle and restart button (with 2-second confirmation feedback); toggle stays in sync via onReceive - SettingsMenuView: "APN" entry restored; new "Live Activity" entry added with dot.radiowaves icon; Sheet.liveActivity case wired to LiveActivitySettingsView Co-Authored-By: Claude Sonnet 4.6 * Added Live Activity menu * chore: add LiveActivitySettingsView to Xcode project Co-Authored-By: Claude Sonnet 4.6 * fix: LA tap navigation, manual dismissal prevention, and toggle start - widgetURL on lock screen LA → opens app via loopfollow://la-tap - Link views in expanded DI regions → same URL on tap - AppDelegate.application(_:open:) handles loopfollow://la-tap and posts .liveActivityDidForeground so MainViewController navigates to Snoozer (if alarm active) or Home tab — fires only on LA/DI taps, not every foreground - Notification.Name.liveActivityDidForeground moved outside #if macCatalyst so AppDelegate can reference it unconditionally - handleDidBecomeActive no longer posts .liveActivityDidForeground (was firing on every app foreground, not just LA taps) - attachStateObserver: .dismissed state (user swipe) sets laEnabled = false so the LA does not auto-restart when the app foregrounds - LiveActivitySettingsView: toggling ON calls startFromCurrentState() Co-Authored-By: Claude Sonnet 4.6 * fix: end Live Activity on app force-quit Adds endOnTerminate() to LiveActivityManager — blocks up to 3 s using Task.detached + DispatchSemaphore so the async activity.end() completes before the process exits. Called from applicationWillTerminate so the LA clears from the lock screen immediately on force-quit. laEnabled is preserved so the LA restarts correctly on next launch. Co-Authored-By: Claude Sonnet 4.6 * fix: use dismissedByUser flag instead of disabling laEnabled on manual dismiss - Manual LA swipe-away sets dismissedByUser (in-memory) instead of laEnabled = false, preserving the user's preference - startFromCurrentState() guards on !dismissedByUser so the LA does not auto-restart on foreground, BG refresh, or handleDidBecomeActive - forceRestart() clears dismissedByUser before starting, so the Restart button and App Intent both work as the explicit re-enable mechanism - Toggle ON in settings calls forceRestart() (clears flag + starts) instead of startFromCurrentState() which would have been blocked by the flag - dismissedByUser resets to false on app relaunch (in-memory only), so a kill + relaunch starts the LA fresh as expected Co-Authored-By: Claude Sonnet 4.6 * fix: dismiss modal (Settings sheet) before tab switch on LA tap navigateOnLAForeground now checks tabBarController.presentedViewController and dismisses it before switching to the target tab, matching the existing pattern used elsewhere in MainViewController for the same scenario. Co-Authored-By: Claude Sonnet 4.6 * fix: LA tap navigation timing and LA reappear-after-dismiss Navigation fix: - loopfollow://la-tap URL can arrive before applicationDidBecomeActive while UIKit is still restoring the view hierarchy from background. At that moment tabBarController.presentedViewController is nil so the Settings modal is never dismissed and the navigation notification fires too early. - Now stores a pendingLATapNavigation flag if app is not yet .active, and fires the notification in applicationDidBecomeActive when the full view hierarchy (including presented modals) is restored. LA reappear fix: - performRefresh() calls startIfNeeded() directly, bypassing the dismissedByUser guard that only exists in startFromCurrentState(). On every BG refresh with laEnabled=true and current==nil (post-dismiss), it would recreate the LA. - Added !dismissedByUser guard to refreshFromCurrentState() so the entire refresh pipeline is skipped while dismissed, matching the existing guard in startFromCurrentState(). Co-Authored-By: Claude Sonnet 4.6 * fix: handle loopfollow://la-tap URL in SceneDelegate, not AppDelegate With scene-based lifecycle (iOS 13+), widget/LA tap URLs are delivered to scene(_:openURLContexts:) on UIWindowSceneDelegate — AppDelegate.application (_:open:url:options:) is never called. Our previous handler was dead code. SceneDelegate now implements scene(_:openURLContexts:) which posts .liveActivityDidForeground on the next run loop (via DispatchQueue.main.async) to let the view hierarchy fully settle before navigateOnLAForeground() runs. Also handles the edge case where the URL arrives before sceneDidBecomeActive by storing a pendingLATapNavigation flag. Co-Authored-By: Claude Sonnet 4.6 * feat: configurable LA grid slots + full InfoType snapshot coverage Lock screen layout: - Left column: glucose + trend arrow, delta below (replaces "Last Update:") - Right grid: 4 configurable slots read from LAAppGroupSettings (defaults: IOB/COB/Proj/empty) - Footer: centered HH:MM update time at reduced opacity Slot configuration: - LiveActivitySlotOption enum with all 22 InfoType-aligned cases - LAAppGroupSettings.setSlots() / slots() persisted to App Group UserDefaults - Uniqueness enforced: selecting an option clears it from any other slot - Settings UI: "Grid slots" section with 4 pickers in LiveActivitySettingsView - Changes take effect immediately (refreshFromCurrentState called on save) GlucoseSnapshot extended with 19 new fields covering all InfoType items: override, recBolus, battery, pumpBattery, basalRate, pumpReservoirU, autosens, tdd, targetLowMgdl/High, isfMgdlPerU, carbRatio, carbsToday, profileName, sageInsertTime, cageInsertTime, iageInsertTime, minBgMgdl/Max Storage.swift: 13 new UserDefaults-backed fields for the above metrics Controllers updated to write new Storage fields on each data fetch: Basals, DeviceStatus, DeviceStatusLoop, DeviceStatusOpenAPS, Carbs, Profile, IAge Co-Authored-By: Claude Sonnet 4.6 * fix: label delta and footer on lock screen LA card Co-Authored-By: Claude Sonnet 4.6 * docs: add PR description for configurable LA grid slots Co-Authored-By: Claude Sonnet 4.6 * Update PR_configurable_slots.md * chore: remove PR notes from tracking, keep docs/LiveActivity.md only - Untrack docs/PR_configurable_slots.md (local-only reference doc) - Add docs/PR_configurable_slots.md and docs/LiveActivityTestPlan.md to .gitignore Co-Authored-By: Claude Sonnet 4.6 * fix: include all extended InfoType fields in APNs push payload buildPayload() was only serializing the base fields (glucose, delta, trend, updatedAt, unit, iob, cob, projected). All extended fields added with the configurable grid slots (battery, sageInsertTime, pumpBattery, basalRate, autosens, tdd, targets, isf, carbRatio, carbsToday, profileName, CAGE, IAGE, minBg, maxBg, override, recBolus) were missing from the APNs payload, causing the extension to decode them as nil/0 and display '--' on every push-driven refresh. Co-Authored-By: Claude Sonnet 4.6 * feat: add small family view for CarPlay Dashboard and Watch Smart Stack Registers the Live Activity for the small activity family via .supplementalActivityFamilies([.small]), enabling automatic display on: - CarPlay Dashboard (iOS 26+) - Apple Watch Smart Stack (watchOS 11+) The small view is hardcoded to the essentials appropriate for a driving context: glucose value, trend arrow, delta, and time since last reading. Background tint matches the existing threshold-based color logic. The lock screen layout (full grid with configurable slots) is unchanged. Co-Authored-By: Claude Sonnet 4.6 * fix: guard CarPlay/Watch small family behind iOS 18 availability; increase renewal overlay opacity - supplementalActivityFamilies and activityFamily require iOS 18.0+; restructured into two Widget structs sharing a makeDynamicIsland() helper: - LoopFollowLiveActivityWidget (iOS 16.1+): lock screen + Dynamic Island - LoopFollowLiveActivityWidgetWithCarPlay (iOS 18.0+): adds .supplementalActivityFamilies([.small]) - Bundle uses if #available(iOS 18.0, *) to select the right variant - Podfile: set minimum deployment target to 16.6 for all pods - Increase 'Tap to update' renewal overlay opacity from 60% to 90% Co-Authored-By: Claude Sonnet 4.6 * fix: move if #available into Widget.body to avoid WidgetBundleBuilder limitation @WidgetBundleBuilder does not support if #available { } else { }, but @WidgetConfigurationBuilder (used by Widget.body) does. Collapsed back to a single LoopFollowLiveActivityWidget struct with the iOS 18 conditional inside body — iOS 18+ branch adds .supplementalActivityFamilies([.small]) for CarPlay and Watch Smart Stack; else branch uses the plain lock screen view. Bundle reverts to the original single if #available(iOS 16.1, *) pattern. Co-Authored-By: Claude Sonnet 4.6 * fix: use two separate single-branch if #available in bundle for CarPlay support @WidgetConfigurationBuilder's buildEither requires both branches to return the same concrete type, making if/else with supplementalActivityFamilies impossible (it wraps to a different opaque type). @WidgetBundleBuilder does not support if #available { } else { } at all. Solution: two separate single-branch if #available blocks in the bundle — the pattern that @WidgetBundleBuilder already supported in the original code: - LoopFollowLiveActivityWidget (iOS 16.1+): primary, lock screen + Dynamic Island - LoopFollowLiveActivityWidgetWithCarPlay (iOS 18.0+): supplemental, adds .supplementalActivityFamilies([.small]) for CarPlay Dashboard + Watch Smart Stack ActivityKit uses the supplemental widget for small-family surfaces and the primary widget for lock screen / Dynamic Island, keeping iOS 16.6+ support intact. Co-Authored-By: Claude Sonnet 4.6 * fix: restore two-widget bundle; guard supplementalActivityFamilies and activityFamily behind iOS 18 Upstream's single-widget approach placed iOS 18+ APIs (supplementalActivityFamilies, activityFamily, ActivityFamily) behind @available(iOS 16.1, *), which fails to compile at the 16.6 deployment target. Restoring the two-widget pattern: - LoopFollowLiveActivityWidget (@available iOS 16.1): lock screen + DI, uses LockScreenLiveActivityView, no supplementalActivityFamilies - LoopFollowLiveActivityWidgetWithCarPlay (@available iOS 18.0): adds CarPlay Dashboard + Watch Smart Stack via supplementalActivityFamilies([.small]), uses LockScreenFamilyAdaptiveView (also @available iOS 18.0) - SmallFamilyView availability corrected to @available(iOS 18.0, *) - Bundle registers both via separate if #available blocks Co-Authored-By: Claude Sonnet 4.6 * fix: extension version inherits from parent; remove spurious await in slot config - LoopFollowLAExtension MARKETING_VERSION now uses "$(MARKETING_VERSION)" to match the parent app version automatically, resolving CFBundleShortVersionString mismatch warning - Remove unnecessary Task/await wrapping of refreshFromCurrentState in LiveActivitySettingsView — the method is not async Co-Authored-By: Claude Sonnet 4.6 * fix: prevent glucose + trend arrow clipping on wide mmol/L values At 46pt, a 4-character mmol/L value ("10.5") plus "↑↑" overflowed the 168pt left column, truncating the glucose reading. Fix: reduce trend arrow to 32pt and add minimumScaleFactor(0.7) + lineLimit(1) to the glucose text so values above 10 mmol/L render correctly. Co-Authored-By: Claude Sonnet 4.6 * chore: remove redundant @available(iOS 16.1) guards The app's minimum deployment target is iOS 16.6, making all iOS 16.1 availability checks redundant. Removed @available(iOS 16.1, *) annotations from all types and the if #available(iOS 16.1, *) wrapper in the bundle. Co-Authored-By: Claude Sonnet 4.6 * Fix Live Activity glucose overflow with flexible layout and tighter grid spacing * Fix Live Activity glucose overflow with flexible layout and tighter grid spacing Co-Authored-By: Claude Sonnet 4.6 * fix: restart LA on foreground when renewal overlay is showing Previously handleForeground() only restarted the LA when laRenewalFailed=true, but the renewal overlay also appears as a warning before renewal is attempted (while laRenewalFailed is still false). Users who foregrounded during the warning window saw the overlay persist with no restart occurring. Now triggers a restart whenever the overlay is showing (within the warning window before the deadline) OR renewal previously failed. Co-Authored-By: Claude Sonnet 4.6 * fix: recover from audio session failure and alert user via LA overlay When AVAudioSession.setActive() fails (e.g. another app holds the session exclusively), the app loses its background keep-alive with no recovery path. Two bugs fixed and recovery logic added: 1. interruptedAudio handler was calling playAudio() on interruption *began* (intValue == 1) instead of *ended* — corrected to restart on .ended only. 2. playAudio() catch block now retries up to 3 times (2s apart). After all retries are exhausted it posts a BackgroundAudioFailed notification. 3. LiveActivityManager observes BackgroundAudioFailed and immediately sets laRenewBy to now (making showRenewalOverlay = true) then pushes a refresh so the lock screen overlay tells the user to foreground the app. Co-Authored-By: Claude Sonnet 4.6 * Update BackgroundTaskAudio.swift --------- Co-authored-by: Claude Sonnet 4.6 --- CLAUDE.md | 479 ++++++++++++++++++ LoopFollow/Helpers/BackgroundTaskAudio.swift | 75 ++- .../LiveActivity/LiveActivityManager.swift | 30 +- .../LoopFollowLABundle.swift | 5 +- .../LoopFollowLiveActivity.swift | 31 +- 5 files changed, 580 insertions(+), 40 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..0f9f62f04 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,479 @@ +# LoopFollow Live Activity — Project Context for Claude Code + +## Who you're working with + +This codebase is being developed by **Philippe** (GitHub: `MtlPhil`), contributing to +`loopandlearn/LoopFollow` — an open-source iOS app that lets parents and caregivers of T1D +Loop users monitor glucose and loop status in real time. + +- **Upstream repo:** `https://github.com/loopandlearn/LoopFollow` +- **Philippe's fork:** `https://github.com/achkars-org/LoopFollow` +- **Local clone:** `/Users/philippe/Documents/GitHub/LoopFollowLA/` +- **Active upstream branch:** `live-activity` (PR #537, draft, targeting `dev`) +- **Philippe's original PR:** `#534` (closed, superseded by #537) +- **Maintainer:** `bjorkert` (Jonas Björkert) + +--- + +## What this feature is + +A **Live Activity** for LoopFollow that displays real-time glucose data on the iOS lock screen +and in the Dynamic Island. The feature uses **APNs self-push** — the app sends a push +notification to itself — to drive reliable background updates without interfering with the +background audio session LoopFollow uses to stay alive. + +### What the Live Activity shows +- Current glucose value + trend arrow +- Delta (change since last reading) +- IOB, COB, projected BG (optional — omitted gracefully for Dexcom-only users) +- Time since last reading +- "Not Looping" red banner when Loop hasn't reported in 15+ minutes +- Threshold-driven background color (green / orange / red) +- Dynamic Island: compact, expanded, and minimal presentations + +--- + +## Architecture overview (current state in PR #537) + +### Data flow +``` +BGData / DeviceStatusLoop / DeviceStatusOpenAPS + → write canonical values to Storage.shared + → GlucoseSnapshotBuilder reads Storage + → builds GlucoseSnapshot + → LiveActivityManager pushes via APNSClient + → LoopFollowLAExtension renders the UI +``` + +### Key files + +| File | Purpose | +|------|---------| +| `LiveActivity/LiveActivityManager.swift` | Orchestrates start/stop/refresh of the Live Activity; called from `MainViewController` | +| `LiveActivity/APNSClient.swift` | Sends the APNs self-push; uses `JWTManager.shared` for JWT; reads credentials from `Storage.shared` | +| `Helpers/JWTManager.swift` | **bjorkert addition** — replaces `APNSJWTGenerator`; uses CryptoKit (P256/ES256); multi-slot in-memory cache keyed by `keyId:teamId`, 55-min TTL | +| `LiveActivity/GlucoseSnapshot.swift` | The value-type snapshot passed to the extension; timestamp stored as Unix epoch seconds (UTC) — **timezone bug was fixed here** | +| `LiveActivity/GlucoseSnapshotBuilder.swift` | Reads from Storage, constructs GlucoseSnapshot | +| `LiveActivity/GlucoseSnapshotStore.swift` | In-memory store; debounces rapid successive refreshes | +| `LiveActivity/GlucoseLiveActivityAttributes.swift` | ActivityKit attributes struct | +| `LiveActivity/AppGroupID.swift` | Derives App Group ID dynamically from bundle identifier — no hardcoded team IDs | +| `LiveActivity/LAAppGroupSettings.swift` | Persists LA-specific settings to the shared App Group container | +| `LiveActivity/LAFormat.swift` | **bjorkert addition** — display formatting for LA values; uses `NumberFormatter` with `Locale.current` so decimal separators match device locale (e.g. "5,6" in Swedish) | +| `LiveActivity/PreferredGlucoseUnit.swift` | Reads preferred unit; delegates to `Localizer.getPreferredUnit()` — no longer duplicates unit detection logic | +| `GlucoseConversion.swift` | **Replaces `GlucoseUnitConversion.swift`** — unified constant `18.01559`; `mgDlToMmolL` is a computed reciprocal. Note: the old file used `18.0182` — do not use that constant anywhere | +| `LiveActivity/StorageCurrentGlucoseStateProvider.swift` | Protocol adapter between Storage and LiveActivityManager | +| `LoopFollowLAExtension/LoopFollowLiveActivity.swift` | SwiftUI widget views for lock screen + Dynamic Island | +| `LoopFollowLAExtension/LoopFollowLABundle.swift` | Extension bundle entry point | +| `Settings/APNSettingsView.swift` | **bjorkert addition** — dedicated settings screen for LoopFollow's own APNs key ID and key | +| `Storage/Storage.swift` | Added: `lastBgReadingTimeSeconds`, `lastDeltaMgdl`, `lastTrendCode`, `lastIOB`, `lastCOB`, `projectedBgMgdl` | +| `Storage/Observable.swift` | Added: `isNotLooping` | +| `Storage/Storage+Migrate.swift` | Added: `migrateStep5` — migrates legacy APNs credential keys to new split format | + +--- + +## The core design decisions Philippe made (and why) + +### 1. APNs self-push for background updates +LoopFollow uses a background audio session to stay alive in the background. Initially, the +temptation was to use `ActivityKit` updates directly from the app. The self-push approach was +chosen because it is more reliable and doesn't create timing conflicts with the audio session. +The app sends a push to itself using its own APNs key; the system delivers it with high +priority, waking the extension. + +### 2. Dynamic App Group ID (no hardcoded team IDs) +`AppGroupID.swift` derives the App Group ID from the bundle identifier at runtime. This makes +the feature work across all fork/build configurations without embedding any team-specific +identifiers in code. + +### 3. Single source of truth in Storage +All glucose and loop state is written to `Storage.shared` (and `Observable`) by the existing +data-fetching controllers (BGData, DeviceStatusLoop, DeviceStatusOpenAPS). The Live Activity +layer is purely a consumer — it never fetches its own data. This keeps the architecture clean +and source-agnostic. + +### 4. GlucoseSnapshot stores glucose in mg/dL only — conversion at display time only +The snapshot is a simple struct with no dependencies, designed to be safe to pass across the +app/extension boundary. All glucose values in `GlucoseSnapshot` are stored as **mg/dL**. +Conversion to mmol/L happens exclusively at display time inside `LAFormat`. This eliminates +the previous round-trip (mg/dL → mmol/L at snapshot creation, then mmol/L → mg/dL for +threshold comparison) that bjorkert identified and removed. + +**Rule for all future code:** anything writing a glucose value into a `GlucoseSnapshot` must +supply mg/dL. Anything reading a glucose value from a snapshot for display must convert via +`GlucoseConversion.mgDlToMmolL` if the user's preferred unit is mmol/L. + +### 5. Unix epoch timestamps (UTC) in GlucoseSnapshot +**Critical bug that was discovered and fixed:** ActivityKit operates in UTC epoch seconds, +but the original code was constructing timestamps using local time offsets, causing DST +errors of ±1 hour. The fix ensures all timestamps in `GlucoseSnapshot` are stored as +`TimeInterval` (seconds since Unix epoch, UTC) and converted to display strings only in the +extension, using the device's local calendar. This fix is in the codebase. + +### 6. Debounce on rapid refreshes +A coalescing `DispatchWorkItem` pattern is used in `GlucoseSnapshotStore` to debounce +rapid successive calls to refresh (e.g., when multiple Storage values update in quick +succession during a data fetch). Only one APNs push is sent per update cycle. + +### 7. APNs key injected via xcconfig/Info.plist (Philippe's original approach) +In Philippe's original PR #534, the APNs key was injected at build time via +`xcconfig` / `Info.plist`, sourced from a GitHub Actions secret. This meant credentials were +baked into the build and never committed. + +--- + +## What bjorkert changed (and why it differs from Philippe's approach) + +### Change 1: SwiftJWT → CryptoKit (`JWTManager.swift`) +**Philippe used:** `SwiftJWT` + `swift-crypto` SPM packages for JWT signing. +**bjorkert replaced with:** Apple's built-in `CryptoKit` (P256/ES256) via a new +`JWTManager.swift`. +**Rationale:** Eliminates two third-party dependencies. `JWTManager` adds a multi-slot +in-memory cache (keyed by `keyId:teamId`, 55-min TTL) instead of persisting JWT tokens to +UserDefaults. +**Impact:** `APNSJWTGenerator.swift` is deleted. All JWT logic lives in `JWTManager.shared`. + +### Change 2: Split APNs credentials (lf vs remote) +**Philippe's approach:** One set of APNs credentials shared between Live Activity and remote +commands. +**bjorkert's approach:** Two distinct credential sets: +- `lfApnsKey` / `lfKeyId` — for LoopFollow's own Live Activity self-push +- `remoteApnsKey` / `remoteKeyId` — for remote commands to Loop/Trio + +**Rationale:** Users who don't use remote commands shouldn't need to configure remote +credentials to get Live Activity working. Users who use both (different team IDs for Loop +vs LoopFollow) previously saw confusing "Return Notification Settings" UI that's now removed. +**Migration:** `migrateStep5` in `Storage+Migrate.swift` handles migrating the legacy keys. + +### Change 3: Runtime credential entry via APNSettingsView +**Philippe's approach:** APNs key injected at build time via xcconfig / CI secret. +**bjorkert's approach:** User enters APNs Key ID and Key at runtime via a new +`APNSettingsView` (under Settings menu). +**Rationale:** Removes the `Inject APNs Key Content` CI step entirely. No credentials are +baked into the build or present in `Info.plist`. Browser Build users don't need to manage +GitHub secrets for APNs. Credentials stored in `Storage.shared` at runtime. +**Impact:** `APNSKeyContent`, `APNSKeyID`, `APNSTeamID` removed from `Info.plist`. The CI +workflow no longer has an APNs key injection step. + +### Change 4: APNSClient reads from Storage instead of Info.plist +Follows directly from Change 3. `APNSClient` now calls `Storage.shared` for credentials +and uses `JWTManager.shared` instead of `APNSJWTGenerator`. Sandbox vs production APNs +host selection is based on `BuildDetails.isTestFlightBuild()`. + +### Change 5: Remote command settings UI simplification +The old "Return Notification Settings" section (which appeared when team IDs differed) is +removed. Remote credential fields only appear when team IDs differ. The new `APNSettingsView` +is always the place to enter LoopFollow's own credentials. + +### Change 6: CI / build updates +- `runs-on` updated from `macos-15` to `macos-26` +- Xcode version updated to `Xcode_26.2` +- APNs key injection step removed from `build_LoopFollow.yml` + +### Change 8: Consolidation pass (post-PR-#534 cleanup) +This batch of changes was made by bjorkert after integrating Philippe's code, to reduce +duplication and fix several bugs found during review. + +**mg/dL-only snapshot storage:** +All glucose values in `GlucoseSnapshot` are now stored in mg/dL. The previous code converted +to mmol/L at snapshot creation time, then converted back to mg/dL for threshold comparison — +a pointless round-trip. Conversion now happens only in `LAFormat` at display time. + +**Unified conversion constant:** +`GlucoseUnitConversion.swift` (used `18.0182`) is deleted. +`GlucoseConversion.swift` (uses `18.01559`) is the single source. Do not use `18.0182` anywhere. + +**Deduplicated unit detection:** +`PreferredGlucoseUnit.hkUnit()` now delegates to `Localizer.getPreferredUnit()` instead of +reimplementing the same logic. + +**New trend cases (↗ / ↘):** +`GlucoseSnapshot` trend now includes `upSlight` / `downSlight` cases (FortyFiveUp/Down), +rendering as `↗` / `↘` instead of collapsing to `↑` / `↓`. All trend switch statements +must handle these cases. + +**Locale bug fixed in `LAFormat`:** +`LAFormat` now uses `NumberFormatter` with `Locale.current` so decimal separators match +the device locale. Do not format glucose floats with string interpolation directly — +always go through `LAFormat`. + +**`LAThresholdSync.swift` deleted:** +Was never called. Removed as dead code. Do not re-introduce it. + +**APNs payload fix — `isNotLooping`:** +The APNs push payload was missing the `isNotLooping` field, so push-based updates never +showed the "Not Looping" overlay. Now fixed — the field is included in every push. + + +bjorkert ran swiftformat across all Live Activity files: standardized file headers, +alphabetized imports, added trailing commas, cleaned whitespace. No logic changes. + +--- + +## What was preserved from Philippe's PR intact + +- All `LiveActivity/` Swift files except those explicitly deleted: + - **Deleted:** `APNSJWTGenerator.swift` (replaced by `JWTManager.swift`) + - **Deleted:** `GlucoseUnitConversion.swift` (replaced by `GlucoseConversion.swift`) + - **Deleted:** `LAThresholdSync.swift` (dead code) +- The `LoopFollowLAExtension/` files (both `LoopFollowLiveActivity.swift` and + `LoopFollowLABundle.swift`) +- The data flow architecture (Storage → SnapshotBuilder → LiveActivityManager → APNSClient) +- The DST/timezone fix in `GlucoseSnapshot.swift` +- The debounce pattern in `GlucoseSnapshotStore.swift` +- The `AppGroupID` dynamic derivation approach +- The "Not Looping" detection via `Observable.isNotLooping` +- The Storage fields added for Live Activity data +- The `docs/LiveActivity.md` architecture + APNs setup guide +- The Fastfile changes for the extension App ID and provisioning profile + +--- + +## Current task: Live Activity auto-renewal (8-hour limit workaround) + +### Background +Apple enforces an **8-hour maximum lifetime** on Live Activities in the Dynamic Island +(12 hours on the Lock Screen, but the DA kills at 8). For a continuous glucose monitor +follower app used overnight or during long days, this is a hard UX problem: the LA simply +disappears mid-use without warning. + +bjorkert has asked Philippe to implement a workaround. + +### Apple's constraints (confirmed) +- 8 hours from `Activity.request()` call — not from last update +- System terminates the LA hard at that point; no callback before termination +- The app **can** call `Activity.end()` + `Activity.request()` from the background via + the existing audio session LoopFollow already holds +- `Activity.end(dismissalPolicy: .immediate)` removes the card from the Lock Screen + immediately — critical to avoid two cards appearing simultaneously during renewal +- There is no built-in Apple API to query an LA's remaining lifetime + +### Design decision: piggyback on the existing refresh heartbeat +**Rejected approach:** A standalone `Timer` or `DispatchQueue.asyncAfter` set for 7.5 hrs. +This is fragile — timers don't survive suspension, and adding a separate scheduling +mechanism is complexity for no benefit when a natural heartbeat already exists. + +**Chosen approach:** Check LA age on every call to `refreshFromCurrentState(reason:)`. +Since this is called on every glucose update (~every 5 minutes via LoopFollow's existing +BGData polling cycle), the worst-case gap before renewal is one polling interval. The +check is cheap (one subtraction). If age ≥ threshold, end the current LA and immediately +re-request before doing the normal refresh. + +### Files to change +| File | Change | +|------|--------| +| `Storage/Storage.swift` | Add `laStartTime: TimeInterval` stored property (UserDefaults-backed, default 0) | +| `LiveActivity/LiveActivityManager.swift` | Record `laStartTime` on every successful `Activity.request()`; check age in `refreshFromCurrentState(reason:)`; add `renewIfNeeded()` helper | + +No other files need to change. The renewal is fully encapsulated in `LiveActivityManager`. + +### Key constants +```swift +static let renewalThreshold: TimeInterval = 7.5 * 3600 // 27,000 s — renew at 7.5 hrs +static let storageKey = "laStartTime" // key in Storage/UserDefaults +``` + +### Behaviour spec +1. On every `refreshFromCurrentState(reason:)` call, before building the snapshot: + - Compute `age = now - Storage.shared.laStartTime` + - If `age >= renewalThreshold` AND a live activity is currently active: + - End it with `.immediate` dismissal (clears the Lock Screen card instantly) + - Re-request a new LA with the current snapshot content + - Record new `laStartTime = now` + - Return (the re-request itself sends the first APNs update) +2. On every successful `Activity.request()` (including normal `startFromCurrentState()`): + - Set `Storage.shared.laStartTime = Date().timeIntervalSince1970` +3. On `stopLiveActivity()` (user-initiated stop or app termination): + - Reset `Storage.shared.laStartTime = 0` +4. On app launch / `startFromCurrentState()` with an already-running LA (resume path): + - Do NOT reset `laStartTime` — the existing value is the correct age anchor + - This handles the case where the app is killed and relaunched mid-session + +### Edge cases to handle +- **User dismisses the LA manually:** ActivityKit transitions to `.dismissed`. The existing + `activityStateUpdates` observer in `LiveActivityManager` already handles this. `laStartTime` + will be stale but harmless — next call to `startFromCurrentState()` will overwrite it. +- **App is not running at the 8-hr mark:** The system kills the LA. When the app next + becomes active and calls `startFromCurrentState()`, it will detect no active LA and + request a fresh one, resetting `laStartTime`. No special handling needed. +- **Multiple rapid calls to `refreshFromCurrentState` during renewal:** The existing + debounce in `GlucoseSnapshotStore` guards this. The renewal path returns early after + re-requesting, so the debounce never even fires. +- **laStartTime = 0 (never set / first launch):** Age will be enormous (current epoch), + but the guard `currentActivity != nil` prevents a spurious renewal when there's no + active LA. Safe. + +### Full implementation (ready to apply) + +#### `Storage/Storage.swift` addition +Add alongside the other LA-related stored properties: + +```swift +// Live Activity renewal +var laStartTime: TimeInterval { + get { return UserDefaults.standard.double(forKey: "laStartTime") } + set { UserDefaults.standard.set(newValue, forKey: "laStartTime") } +} +``` + +#### `LiveActivity/LiveActivityManager.swift` changes + +Add the constant and the helper near the top of the class: + +```swift +// MARK: - Constants +private static let renewalThreshold: TimeInterval = 7.5 * 3600 + +// MARK: - Renewal + +/// Ends the current Live Activity immediately and re-requests a fresh one, +/// working around Apple's 8-hour maximum LA lifetime. +/// Returns true if renewal was performed (caller should return early). +@discardableResult +private func renewIfNeeded(snapshot: GlucoseSnapshot) async -> Bool { + guard let activity = currentActivity else { return false } + + let age = Date().timeIntervalSince1970 - Storage.shared.laStartTime + guard age >= LiveActivityManager.renewalThreshold else { return false } + + os_log(.info, log: log, "Live Activity age %.0f s >= threshold, renewing", age) + + // End with .immediate so the stale card clears before the new one appears + await activity.end(nil, dismissalPolicy: .immediate) + currentActivity = nil + + // Re-request using the snapshot we already built + await startWithSnapshot(snapshot) + return true +} +``` + +Modify `startFromCurrentState()` to record the start time after a successful request: + +```swift +func startFromCurrentState() async { + guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } + guard currentActivity == nil else { return } + + let snapshot = GlucoseSnapshotBuilder.build() + await startWithSnapshot(snapshot) +} + +/// Internal helper — requests a new LA and records the start time. +private func startWithSnapshot(_ snapshot: GlucoseSnapshot) async { + let attributes = GlucoseLiveActivityAttributes() + let content = ActivityContent(state: snapshot, staleDate: nil) + do { + currentActivity = try Activity.request( + attributes: attributes, + content: content, + pushType: .token + ) + // Record when this LA was started for renewal tracking + Storage.shared.laStartTime = Date().timeIntervalSince1970 + os_log(.info, log: log, "Live Activity started, laStartTime recorded") + + // Observe push token and state updates (existing logic) + observePushTokenUpdates() + observeActivityStateUpdates() + } catch { + os_log(.error, log: log, "Failed to start Live Activity: %@", error.localizedDescription) + } +} +``` + +Modify `refreshFromCurrentState(reason:)` to call `renewIfNeeded` before the normal path: + +```swift +func refreshFromCurrentState(reason: String) async { + guard currentActivity != nil else { + // No active LA — nothing to refresh + return + } + + let snapshot = GlucoseSnapshotBuilder.build() + + // Check if the LA is approaching Apple's 8-hour limit and renew if so. + // renewIfNeeded returns true if it performed a renewal; we return early + // because startWithSnapshot already sent the first update for the new LA. + if await renewIfNeeded(snapshot: snapshot) { return } + + // Normal refresh path — send APNs self-push with updated snapshot + await GlucoseSnapshotStore.shared.update(snapshot: snapshot) +} +``` + +Modify `stopLiveActivity()` to reset the start time: + +```swift +func stopLiveActivity() async { + guard let activity = currentActivity else { return } + await activity.end(nil, dismissalPolicy: .immediate) + currentActivity = nil + Storage.shared.laStartTime = 0 + os_log(.info, log: log, "Live Activity stopped, laStartTime reset") +} +``` + +### Testing checklist +- [ ] Manually set `renewalThreshold` to 60 seconds during testing to verify the + renewal cycle works without waiting 7.5 hours +- [ ] Confirm the old Lock Screen card disappears before the new one appears + (`.immediate` dismissal working correctly) +- [ ] Confirm `laStartTime` is reset to 0 on manual stop +- [ ] Confirm `laStartTime` is NOT reset when the app is relaunched with an existing + active LA (resume path) +- [ ] Confirm no duplicate LAs appear during renewal +- [ ] Restore `renewalThreshold` to `7.5 * 3600` before committing + +--- + +## Known issues / things still in progress + +- PR #537 is currently marked **Draft** as of March 12, 2026 +- bjorkert's last commit (`524b3bb`) was March 11, 2026 +- The PR is targeting `dev` and has 6 commits total (5 from Philippe, 1 from bjorkert) +- **Active task:** LA auto-renewal (8-hour limit workaround) — see section above + +--- + +## APNs self-push mechanics (important context) + +The self-push flow: +1. `LiveActivityManager.refreshFromCurrentState(reason:)` is called (from MainViewController + or on a not-looping state change) +2. It calls `GlucoseSnapshotBuilder` → `GlucoseSnapshotStore` +3. The store debounces and triggers `APNSClient.sendUpdate(snapshot:)` +4. `APNSClient` fetches credentials from `Storage.shared`, calls `JWTManager.shared` for a + signed JWT (cached for 55 min), then POSTs to the APNs HTTP/2 endpoint +5. The system delivers the push to `LoopFollowLAExtension`, which updates the Live Activity UI + +**APNs environments:** +- Sandbox (development/TestFlight): `api.sandbox.push.apple.com` +- Production: `api.push.apple.com` +- Selection is automatic via `BuildDetails.isTestFlightBuild()` + +**Token expiry handling:** APNs self-push token expiry (HTTP 410 / 400 BadDeviceToken) +is handled in `APNSClient` with appropriate error logging. The token is the Live Activity +push token obtained from `ActivityKit`, not a device token. + +--- + +## Repo / branch conventions + +- `main` — released versions only (version ends in `.0`) +- `dev` — integration branch; PR #537 targets this +- `live-activity` — bjorkert's working branch for the feature (upstream) +- Philippe's fork branches: `dev`, `live-activity-pr` (original work) +- Version format: `M.N.P` — P increments on each `dev` merge, N increments on release + +--- + +## Build configuration notes + +- App Group ID is derived dynamically — do not hardcode team IDs anywhere +- APNs credentials are now entered by the user at runtime in APNSettingsView +- No APNs secrets in xcconfig, Info.plist, or CI environment variables (as of bjorkert's + latest commit) +- The extension target is `LoopFollowLAExtension` with its own entitlements file + (`LoopFollowLAExtensionExtension.entitlements`) +- `Package.resolved` has been updated to remove SwiftJWT and swift-crypto dependencies diff --git a/LoopFollow/Helpers/BackgroundTaskAudio.swift b/LoopFollow/Helpers/BackgroundTaskAudio.swift index 91504ab5d..67e76a03e 100755 --- a/LoopFollow/Helpers/BackgroundTaskAudio.swift +++ b/LoopFollow/Helpers/BackgroundTaskAudio.swift @@ -5,38 +5,65 @@ import AVFoundation class BackgroundTask { // MARK: - Vars - + var player = AVAudioPlayer() - var timer = Timer() - + + private var retryCount = 0 + private let maxRetries = 3 + // MARK: - Methods - + func startBackgroundTask() { NotificationCenter.default.addObserver(self, selector: #selector(interruptedAudio), name: AVAudioSession.interruptionNotification, object: AVAudioSession.sharedInstance()) + retryCount = 0 playAudio() } - + func stopBackgroundTask() { NotificationCenter.default.removeObserver(self, name: AVAudioSession.interruptionNotification, object: nil) player.stop() 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() } + 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: + // Check shouldResume hint — skip restart if iOS says not to + if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt { + let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue) + guard options.contains(.shouldResume) else { + LogManager.shared.log(category: .general, message: "[LA] Silent audio interruption ended — shouldResume not set, skipping restart") + return + } + } + 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() { + 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 +72,25 @@ 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”) +} \ No newline at end of file diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 41f129c60..00d230e40 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -32,6 +32,12 @@ final class LiveActivityManager { 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.). @@ -87,13 +93,19 @@ final class LiveActivityManager { @objc private func handleForeground() { guard Storage.shared.laEnabled.value else { return } - LogManager.shared.log(category: .general, message: "[LA] foreground notification received, laRenewalFailed=\(Storage.shared.laRenewalFailed.value)") - guard Storage.shared.laRenewalFailed.value else { return } - // Renewal previously failed — end the stale LA and start a fresh one. + 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 notification received, laRenewalFailed=\(renewalFailed), overlayShowing=\(overlayIsShowing)") + guard renewalFailed || overlayIsShowing else { return } + + // Overlay is showing or renewal previously failed — end the stale LA and start a fresh one. // We cannot call startIfNeeded() here: it finds the existing activity in // Activity.activities and reuses it rather than replacing it. - LogManager.shared.log(category: .general, message: "[LA] ending stale LA and restarting after renewal failure") + LogManager.shared.log(category: .general, message: "[LA] ending stale LA and restarting (renewalFailed=\(renewalFailed), overlayShowing=\(overlayIsShowing))") // Clear state synchronously so any snapshot built between now and when the // new LA is started computes showRenewalOverlay = false. Storage.shared.laRenewBy.value = 0 @@ -128,6 +140,16 @@ final class LiveActivityManager { } } + @objc private func handleBackgroundAudioFailed() { + guard Storage.shared.laEnabled.value, current != nil else { return } + // The background audio session has permanently failed — the app will lose its + // background keep-alive. Immediately push the renewal overlay so the user sees + // "Tap to update" on the lock screen and knows to foreground the app. + LogManager.shared.log(category: .general, message: "[LA] background audio failed — forcing renewal overlay") + Storage.shared.laRenewBy.value = Date().timeIntervalSince1970 + refreshFromCurrentState(reason: "audio-session-failed") + } + static let renewalThreshold: TimeInterval = 7.5 * 3600 static let renewalWarning: TimeInterval = 20 * 60 diff --git a/LoopFollowLAExtension/LoopFollowLABundle.swift b/LoopFollowLAExtension/LoopFollowLABundle.swift index a9f7daf6c..1b058a210 100644 --- a/LoopFollowLAExtension/LoopFollowLABundle.swift +++ b/LoopFollowLAExtension/LoopFollowLABundle.swift @@ -11,8 +11,9 @@ import WidgetKit @main struct LoopFollowLABundle: WidgetBundle { var body: some Widget { - if #available(iOS 16.1, *) { - LoopFollowLiveActivityWidget() + LoopFollowLiveActivityWidget() + if #available(iOS 18.0, *) { + LoopFollowLiveActivityWidgetWithCarPlay() } if #available(iOS 18.0, *) { LoopFollowLiveActivityWidgetWithCarPlay() diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index d681a9368..753402e05 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -6,7 +6,6 @@ import SwiftUI import WidgetKit /// Builds the shared Dynamic Island content used by both widget variants. -@available(iOS 16.1, *) private func makeDynamicIsland(context: ActivityViewContext) -> DynamicIsland { DynamicIsland { DynamicIslandExpandedRegion(.leading) { @@ -43,8 +42,7 @@ private func makeDynamicIsland(context: ActivityViewContext Date: Wed, 18 Mar 2026 11:25:26 -0400 Subject: [PATCH 66/86] Update BackgroundTaskAudio.swift --- LoopFollow/Helpers/BackgroundTaskAudio.swift | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/LoopFollow/Helpers/BackgroundTaskAudio.swift b/LoopFollow/Helpers/BackgroundTaskAudio.swift index 67e76a03e..474363acd 100755 --- a/LoopFollow/Helpers/BackgroundTaskAudio.swift +++ b/LoopFollow/Helpers/BackgroundTaskAudio.swift @@ -3,9 +3,12 @@ import AVFoundation +extension Notification.Name { + static let backgroundAudioFailed = Notification.Name(“BackgroundAudioFailed”) +} + class BackgroundTask { // MARK: - Vars - var player = AVAudioPlayer() private var retryCount = 0 @@ -88,9 +91,4 @@ class BackgroundTask { } } } - -} - -extension Notification.Name { - static let backgroundAudioFailed = Notification.Name(“BackgroundAudioFailed”) } \ No newline at end of file From e8ee8059254321a46014329f99f9d1b2bb7135e8 Mon Sep 17 00:00:00 2001 From: Phil A <76601115+MtlPhil@users.noreply.github.com> Date: Wed, 18 Mar 2026 11:39:36 -0400 Subject: [PATCH 67/86] Update BackgroundTaskAudio.swift --- LoopFollow/Helpers/BackgroundTaskAudio.swift | 27 ++++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/LoopFollow/Helpers/BackgroundTaskAudio.swift b/LoopFollow/Helpers/BackgroundTaskAudio.swift index 474363acd..08e58062d 100755 --- a/LoopFollow/Helpers/BackgroundTaskAudio.swift +++ b/LoopFollow/Helpers/BackgroundTaskAudio.swift @@ -3,42 +3,41 @@ import AVFoundation -extension Notification.Name { - static let backgroundAudioFailed = Notification.Name(“BackgroundAudioFailed”) -} - class BackgroundTask { // MARK: - Vars + var player = AVAudioPlayer() - + private var retryCount = 0 private let maxRetries = 3 - + + static let backgroundAudioFailedNotification = Notification.Name(rawValue: "BackgroundAudioFailed") + // MARK: - Methods - + func startBackgroundTask() { NotificationCenter.default.addObserver(self, selector: #selector(interruptedAudio), name: AVAudioSession.interruptionNotification, object: AVAudioSession.sharedInstance()) retryCount = 0 playAudio() } - + func stopBackgroundTask() { NotificationCenter.default.removeObserver(self, name: AVAudioSession.interruptionNotification, object: nil) player.stop() LogManager.shared.log(category: .general, message: "Silent audio stopped", isDebug: true) } - + @objc fileprivate 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: // Check shouldResume hint — skip restart if iOS says not to if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt { @@ -56,12 +55,12 @@ class BackgroundTask { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in self?.playAudio() } - + @unknown default: break } } - + fileprivate func playAudio() { let attemptDesc = retryCount == 0 ? "initial attempt" : "retry \(retryCount)/\(maxRetries)" do { @@ -87,7 +86,7 @@ class BackgroundTask { } } else { LogManager.shared.log(category: .general, message: "playAudio failed after \(maxRetries) retries — posting BackgroundAudioFailed") - NotificationCenter.default.post(name: .backgroundAudioFailed, object: nil) + NotificationCenter.default.post(name: BackgroundTask.backgroundAudioFailedNotification, object: nil) } } } From cffc043cfa87fba11837b82123df7fa0b6373a70 Mon Sep 17 00:00:00 2001 From: Phil A <76601115+MtlPhil@users.noreply.github.com> Date: Wed, 18 Mar 2026 12:57:13 -0400 Subject: [PATCH 68/86] Update LiveActivityManager.swift --- LoopFollow/LiveActivity/LiveActivityManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 00d230e40..d5cad0589 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -35,7 +35,7 @@ final class LiveActivityManager { NotificationCenter.default.addObserver( self, selector: #selector(handleBackgroundAudioFailed), - name: .backgroundAudioFailed, + name: .backgroundAudioFailedNotification, object: nil ) } From 61a6035b3f9bb95e49bdcab5bb4bd90d299079d2 Mon Sep 17 00:00:00 2001 From: Phil A <76601115+MtlPhil@users.noreply.github.com> Date: Wed, 18 Mar 2026 13:40:52 -0400 Subject: [PATCH 69/86] Update LiveActivityManager.swift --- LoopFollow/LiveActivity/LiveActivityManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index d5cad0589..91159cecc 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -35,7 +35,7 @@ final class LiveActivityManager { NotificationCenter.default.addObserver( self, selector: #selector(handleBackgroundAudioFailed), - name: .backgroundAudioFailedNotification, + name: BackgroundTask.backgroundAudioFailedNotification, object: nil ) } From adbec892e64f10269cda5ad919b1025fe574d21b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Wed, 18 Mar 2026 20:32:00 +0100 Subject: [PATCH 70/86] Linting --- LoopFollow/Helpers/BackgroundTaskAudio.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/LoopFollow/Helpers/BackgroundTaskAudio.swift b/LoopFollow/Helpers/BackgroundTaskAudio.swift index e0c933d2f..e5ddcf988 100755 --- a/LoopFollow/Helpers/BackgroundTaskAudio.swift +++ b/LoopFollow/Helpers/BackgroundTaskAudio.swift @@ -88,7 +88,6 @@ class BackgroundTask { } } } - } extension Notification.Name { From f677b2cc2727eca2bbc0c9d2cdd83ebb04020b58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Wed, 18 Mar 2026 20:34:11 +0100 Subject: [PATCH 71/86] Removed CLAUDE.md --- CLAUDE.md | 479 ------------------------------------------------------ 1 file changed, 479 deletions(-) delete mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 0f9f62f04..000000000 --- a/CLAUDE.md +++ /dev/null @@ -1,479 +0,0 @@ -# LoopFollow Live Activity — Project Context for Claude Code - -## Who you're working with - -This codebase is being developed by **Philippe** (GitHub: `MtlPhil`), contributing to -`loopandlearn/LoopFollow` — an open-source iOS app that lets parents and caregivers of T1D -Loop users monitor glucose and loop status in real time. - -- **Upstream repo:** `https://github.com/loopandlearn/LoopFollow` -- **Philippe's fork:** `https://github.com/achkars-org/LoopFollow` -- **Local clone:** `/Users/philippe/Documents/GitHub/LoopFollowLA/` -- **Active upstream branch:** `live-activity` (PR #537, draft, targeting `dev`) -- **Philippe's original PR:** `#534` (closed, superseded by #537) -- **Maintainer:** `bjorkert` (Jonas Björkert) - ---- - -## What this feature is - -A **Live Activity** for LoopFollow that displays real-time glucose data on the iOS lock screen -and in the Dynamic Island. The feature uses **APNs self-push** — the app sends a push -notification to itself — to drive reliable background updates without interfering with the -background audio session LoopFollow uses to stay alive. - -### What the Live Activity shows -- Current glucose value + trend arrow -- Delta (change since last reading) -- IOB, COB, projected BG (optional — omitted gracefully for Dexcom-only users) -- Time since last reading -- "Not Looping" red banner when Loop hasn't reported in 15+ minutes -- Threshold-driven background color (green / orange / red) -- Dynamic Island: compact, expanded, and minimal presentations - ---- - -## Architecture overview (current state in PR #537) - -### Data flow -``` -BGData / DeviceStatusLoop / DeviceStatusOpenAPS - → write canonical values to Storage.shared - → GlucoseSnapshotBuilder reads Storage - → builds GlucoseSnapshot - → LiveActivityManager pushes via APNSClient - → LoopFollowLAExtension renders the UI -``` - -### Key files - -| File | Purpose | -|------|---------| -| `LiveActivity/LiveActivityManager.swift` | Orchestrates start/stop/refresh of the Live Activity; called from `MainViewController` | -| `LiveActivity/APNSClient.swift` | Sends the APNs self-push; uses `JWTManager.shared` for JWT; reads credentials from `Storage.shared` | -| `Helpers/JWTManager.swift` | **bjorkert addition** — replaces `APNSJWTGenerator`; uses CryptoKit (P256/ES256); multi-slot in-memory cache keyed by `keyId:teamId`, 55-min TTL | -| `LiveActivity/GlucoseSnapshot.swift` | The value-type snapshot passed to the extension; timestamp stored as Unix epoch seconds (UTC) — **timezone bug was fixed here** | -| `LiveActivity/GlucoseSnapshotBuilder.swift` | Reads from Storage, constructs GlucoseSnapshot | -| `LiveActivity/GlucoseSnapshotStore.swift` | In-memory store; debounces rapid successive refreshes | -| `LiveActivity/GlucoseLiveActivityAttributes.swift` | ActivityKit attributes struct | -| `LiveActivity/AppGroupID.swift` | Derives App Group ID dynamically from bundle identifier — no hardcoded team IDs | -| `LiveActivity/LAAppGroupSettings.swift` | Persists LA-specific settings to the shared App Group container | -| `LiveActivity/LAFormat.swift` | **bjorkert addition** — display formatting for LA values; uses `NumberFormatter` with `Locale.current` so decimal separators match device locale (e.g. "5,6" in Swedish) | -| `LiveActivity/PreferredGlucoseUnit.swift` | Reads preferred unit; delegates to `Localizer.getPreferredUnit()` — no longer duplicates unit detection logic | -| `GlucoseConversion.swift` | **Replaces `GlucoseUnitConversion.swift`** — unified constant `18.01559`; `mgDlToMmolL` is a computed reciprocal. Note: the old file used `18.0182` — do not use that constant anywhere | -| `LiveActivity/StorageCurrentGlucoseStateProvider.swift` | Protocol adapter between Storage and LiveActivityManager | -| `LoopFollowLAExtension/LoopFollowLiveActivity.swift` | SwiftUI widget views for lock screen + Dynamic Island | -| `LoopFollowLAExtension/LoopFollowLABundle.swift` | Extension bundle entry point | -| `Settings/APNSettingsView.swift` | **bjorkert addition** — dedicated settings screen for LoopFollow's own APNs key ID and key | -| `Storage/Storage.swift` | Added: `lastBgReadingTimeSeconds`, `lastDeltaMgdl`, `lastTrendCode`, `lastIOB`, `lastCOB`, `projectedBgMgdl` | -| `Storage/Observable.swift` | Added: `isNotLooping` | -| `Storage/Storage+Migrate.swift` | Added: `migrateStep5` — migrates legacy APNs credential keys to new split format | - ---- - -## The core design decisions Philippe made (and why) - -### 1. APNs self-push for background updates -LoopFollow uses a background audio session to stay alive in the background. Initially, the -temptation was to use `ActivityKit` updates directly from the app. The self-push approach was -chosen because it is more reliable and doesn't create timing conflicts with the audio session. -The app sends a push to itself using its own APNs key; the system delivers it with high -priority, waking the extension. - -### 2. Dynamic App Group ID (no hardcoded team IDs) -`AppGroupID.swift` derives the App Group ID from the bundle identifier at runtime. This makes -the feature work across all fork/build configurations without embedding any team-specific -identifiers in code. - -### 3. Single source of truth in Storage -All glucose and loop state is written to `Storage.shared` (and `Observable`) by the existing -data-fetching controllers (BGData, DeviceStatusLoop, DeviceStatusOpenAPS). The Live Activity -layer is purely a consumer — it never fetches its own data. This keeps the architecture clean -and source-agnostic. - -### 4. GlucoseSnapshot stores glucose in mg/dL only — conversion at display time only -The snapshot is a simple struct with no dependencies, designed to be safe to pass across the -app/extension boundary. All glucose values in `GlucoseSnapshot` are stored as **mg/dL**. -Conversion to mmol/L happens exclusively at display time inside `LAFormat`. This eliminates -the previous round-trip (mg/dL → mmol/L at snapshot creation, then mmol/L → mg/dL for -threshold comparison) that bjorkert identified and removed. - -**Rule for all future code:** anything writing a glucose value into a `GlucoseSnapshot` must -supply mg/dL. Anything reading a glucose value from a snapshot for display must convert via -`GlucoseConversion.mgDlToMmolL` if the user's preferred unit is mmol/L. - -### 5. Unix epoch timestamps (UTC) in GlucoseSnapshot -**Critical bug that was discovered and fixed:** ActivityKit operates in UTC epoch seconds, -but the original code was constructing timestamps using local time offsets, causing DST -errors of ±1 hour. The fix ensures all timestamps in `GlucoseSnapshot` are stored as -`TimeInterval` (seconds since Unix epoch, UTC) and converted to display strings only in the -extension, using the device's local calendar. This fix is in the codebase. - -### 6. Debounce on rapid refreshes -A coalescing `DispatchWorkItem` pattern is used in `GlucoseSnapshotStore` to debounce -rapid successive calls to refresh (e.g., when multiple Storage values update in quick -succession during a data fetch). Only one APNs push is sent per update cycle. - -### 7. APNs key injected via xcconfig/Info.plist (Philippe's original approach) -In Philippe's original PR #534, the APNs key was injected at build time via -`xcconfig` / `Info.plist`, sourced from a GitHub Actions secret. This meant credentials were -baked into the build and never committed. - ---- - -## What bjorkert changed (and why it differs from Philippe's approach) - -### Change 1: SwiftJWT → CryptoKit (`JWTManager.swift`) -**Philippe used:** `SwiftJWT` + `swift-crypto` SPM packages for JWT signing. -**bjorkert replaced with:** Apple's built-in `CryptoKit` (P256/ES256) via a new -`JWTManager.swift`. -**Rationale:** Eliminates two third-party dependencies. `JWTManager` adds a multi-slot -in-memory cache (keyed by `keyId:teamId`, 55-min TTL) instead of persisting JWT tokens to -UserDefaults. -**Impact:** `APNSJWTGenerator.swift` is deleted. All JWT logic lives in `JWTManager.shared`. - -### Change 2: Split APNs credentials (lf vs remote) -**Philippe's approach:** One set of APNs credentials shared between Live Activity and remote -commands. -**bjorkert's approach:** Two distinct credential sets: -- `lfApnsKey` / `lfKeyId` — for LoopFollow's own Live Activity self-push -- `remoteApnsKey` / `remoteKeyId` — for remote commands to Loop/Trio - -**Rationale:** Users who don't use remote commands shouldn't need to configure remote -credentials to get Live Activity working. Users who use both (different team IDs for Loop -vs LoopFollow) previously saw confusing "Return Notification Settings" UI that's now removed. -**Migration:** `migrateStep5` in `Storage+Migrate.swift` handles migrating the legacy keys. - -### Change 3: Runtime credential entry via APNSettingsView -**Philippe's approach:** APNs key injected at build time via xcconfig / CI secret. -**bjorkert's approach:** User enters APNs Key ID and Key at runtime via a new -`APNSettingsView` (under Settings menu). -**Rationale:** Removes the `Inject APNs Key Content` CI step entirely. No credentials are -baked into the build or present in `Info.plist`. Browser Build users don't need to manage -GitHub secrets for APNs. Credentials stored in `Storage.shared` at runtime. -**Impact:** `APNSKeyContent`, `APNSKeyID`, `APNSTeamID` removed from `Info.plist`. The CI -workflow no longer has an APNs key injection step. - -### Change 4: APNSClient reads from Storage instead of Info.plist -Follows directly from Change 3. `APNSClient` now calls `Storage.shared` for credentials -and uses `JWTManager.shared` instead of `APNSJWTGenerator`. Sandbox vs production APNs -host selection is based on `BuildDetails.isTestFlightBuild()`. - -### Change 5: Remote command settings UI simplification -The old "Return Notification Settings" section (which appeared when team IDs differed) is -removed. Remote credential fields only appear when team IDs differ. The new `APNSettingsView` -is always the place to enter LoopFollow's own credentials. - -### Change 6: CI / build updates -- `runs-on` updated from `macos-15` to `macos-26` -- Xcode version updated to `Xcode_26.2` -- APNs key injection step removed from `build_LoopFollow.yml` - -### Change 8: Consolidation pass (post-PR-#534 cleanup) -This batch of changes was made by bjorkert after integrating Philippe's code, to reduce -duplication and fix several bugs found during review. - -**mg/dL-only snapshot storage:** -All glucose values in `GlucoseSnapshot` are now stored in mg/dL. The previous code converted -to mmol/L at snapshot creation time, then converted back to mg/dL for threshold comparison — -a pointless round-trip. Conversion now happens only in `LAFormat` at display time. - -**Unified conversion constant:** -`GlucoseUnitConversion.swift` (used `18.0182`) is deleted. -`GlucoseConversion.swift` (uses `18.01559`) is the single source. Do not use `18.0182` anywhere. - -**Deduplicated unit detection:** -`PreferredGlucoseUnit.hkUnit()` now delegates to `Localizer.getPreferredUnit()` instead of -reimplementing the same logic. - -**New trend cases (↗ / ↘):** -`GlucoseSnapshot` trend now includes `upSlight` / `downSlight` cases (FortyFiveUp/Down), -rendering as `↗` / `↘` instead of collapsing to `↑` / `↓`. All trend switch statements -must handle these cases. - -**Locale bug fixed in `LAFormat`:** -`LAFormat` now uses `NumberFormatter` with `Locale.current` so decimal separators match -the device locale. Do not format glucose floats with string interpolation directly — -always go through `LAFormat`. - -**`LAThresholdSync.swift` deleted:** -Was never called. Removed as dead code. Do not re-introduce it. - -**APNs payload fix — `isNotLooping`:** -The APNs push payload was missing the `isNotLooping` field, so push-based updates never -showed the "Not Looping" overlay. Now fixed — the field is included in every push. - - -bjorkert ran swiftformat across all Live Activity files: standardized file headers, -alphabetized imports, added trailing commas, cleaned whitespace. No logic changes. - ---- - -## What was preserved from Philippe's PR intact - -- All `LiveActivity/` Swift files except those explicitly deleted: - - **Deleted:** `APNSJWTGenerator.swift` (replaced by `JWTManager.swift`) - - **Deleted:** `GlucoseUnitConversion.swift` (replaced by `GlucoseConversion.swift`) - - **Deleted:** `LAThresholdSync.swift` (dead code) -- The `LoopFollowLAExtension/` files (both `LoopFollowLiveActivity.swift` and - `LoopFollowLABundle.swift`) -- The data flow architecture (Storage → SnapshotBuilder → LiveActivityManager → APNSClient) -- The DST/timezone fix in `GlucoseSnapshot.swift` -- The debounce pattern in `GlucoseSnapshotStore.swift` -- The `AppGroupID` dynamic derivation approach -- The "Not Looping" detection via `Observable.isNotLooping` -- The Storage fields added for Live Activity data -- The `docs/LiveActivity.md` architecture + APNs setup guide -- The Fastfile changes for the extension App ID and provisioning profile - ---- - -## Current task: Live Activity auto-renewal (8-hour limit workaround) - -### Background -Apple enforces an **8-hour maximum lifetime** on Live Activities in the Dynamic Island -(12 hours on the Lock Screen, but the DA kills at 8). For a continuous glucose monitor -follower app used overnight or during long days, this is a hard UX problem: the LA simply -disappears mid-use without warning. - -bjorkert has asked Philippe to implement a workaround. - -### Apple's constraints (confirmed) -- 8 hours from `Activity.request()` call — not from last update -- System terminates the LA hard at that point; no callback before termination -- The app **can** call `Activity.end()` + `Activity.request()` from the background via - the existing audio session LoopFollow already holds -- `Activity.end(dismissalPolicy: .immediate)` removes the card from the Lock Screen - immediately — critical to avoid two cards appearing simultaneously during renewal -- There is no built-in Apple API to query an LA's remaining lifetime - -### Design decision: piggyback on the existing refresh heartbeat -**Rejected approach:** A standalone `Timer` or `DispatchQueue.asyncAfter` set for 7.5 hrs. -This is fragile — timers don't survive suspension, and adding a separate scheduling -mechanism is complexity for no benefit when a natural heartbeat already exists. - -**Chosen approach:** Check LA age on every call to `refreshFromCurrentState(reason:)`. -Since this is called on every glucose update (~every 5 minutes via LoopFollow's existing -BGData polling cycle), the worst-case gap before renewal is one polling interval. The -check is cheap (one subtraction). If age ≥ threshold, end the current LA and immediately -re-request before doing the normal refresh. - -### Files to change -| File | Change | -|------|--------| -| `Storage/Storage.swift` | Add `laStartTime: TimeInterval` stored property (UserDefaults-backed, default 0) | -| `LiveActivity/LiveActivityManager.swift` | Record `laStartTime` on every successful `Activity.request()`; check age in `refreshFromCurrentState(reason:)`; add `renewIfNeeded()` helper | - -No other files need to change. The renewal is fully encapsulated in `LiveActivityManager`. - -### Key constants -```swift -static let renewalThreshold: TimeInterval = 7.5 * 3600 // 27,000 s — renew at 7.5 hrs -static let storageKey = "laStartTime" // key in Storage/UserDefaults -``` - -### Behaviour spec -1. On every `refreshFromCurrentState(reason:)` call, before building the snapshot: - - Compute `age = now - Storage.shared.laStartTime` - - If `age >= renewalThreshold` AND a live activity is currently active: - - End it with `.immediate` dismissal (clears the Lock Screen card instantly) - - Re-request a new LA with the current snapshot content - - Record new `laStartTime = now` - - Return (the re-request itself sends the first APNs update) -2. On every successful `Activity.request()` (including normal `startFromCurrentState()`): - - Set `Storage.shared.laStartTime = Date().timeIntervalSince1970` -3. On `stopLiveActivity()` (user-initiated stop or app termination): - - Reset `Storage.shared.laStartTime = 0` -4. On app launch / `startFromCurrentState()` with an already-running LA (resume path): - - Do NOT reset `laStartTime` — the existing value is the correct age anchor - - This handles the case where the app is killed and relaunched mid-session - -### Edge cases to handle -- **User dismisses the LA manually:** ActivityKit transitions to `.dismissed`. The existing - `activityStateUpdates` observer in `LiveActivityManager` already handles this. `laStartTime` - will be stale but harmless — next call to `startFromCurrentState()` will overwrite it. -- **App is not running at the 8-hr mark:** The system kills the LA. When the app next - becomes active and calls `startFromCurrentState()`, it will detect no active LA and - request a fresh one, resetting `laStartTime`. No special handling needed. -- **Multiple rapid calls to `refreshFromCurrentState` during renewal:** The existing - debounce in `GlucoseSnapshotStore` guards this. The renewal path returns early after - re-requesting, so the debounce never even fires. -- **laStartTime = 0 (never set / first launch):** Age will be enormous (current epoch), - but the guard `currentActivity != nil` prevents a spurious renewal when there's no - active LA. Safe. - -### Full implementation (ready to apply) - -#### `Storage/Storage.swift` addition -Add alongside the other LA-related stored properties: - -```swift -// Live Activity renewal -var laStartTime: TimeInterval { - get { return UserDefaults.standard.double(forKey: "laStartTime") } - set { UserDefaults.standard.set(newValue, forKey: "laStartTime") } -} -``` - -#### `LiveActivity/LiveActivityManager.swift` changes - -Add the constant and the helper near the top of the class: - -```swift -// MARK: - Constants -private static let renewalThreshold: TimeInterval = 7.5 * 3600 - -// MARK: - Renewal - -/// Ends the current Live Activity immediately and re-requests a fresh one, -/// working around Apple's 8-hour maximum LA lifetime. -/// Returns true if renewal was performed (caller should return early). -@discardableResult -private func renewIfNeeded(snapshot: GlucoseSnapshot) async -> Bool { - guard let activity = currentActivity else { return false } - - let age = Date().timeIntervalSince1970 - Storage.shared.laStartTime - guard age >= LiveActivityManager.renewalThreshold else { return false } - - os_log(.info, log: log, "Live Activity age %.0f s >= threshold, renewing", age) - - // End with .immediate so the stale card clears before the new one appears - await activity.end(nil, dismissalPolicy: .immediate) - currentActivity = nil - - // Re-request using the snapshot we already built - await startWithSnapshot(snapshot) - return true -} -``` - -Modify `startFromCurrentState()` to record the start time after a successful request: - -```swift -func startFromCurrentState() async { - guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } - guard currentActivity == nil else { return } - - let snapshot = GlucoseSnapshotBuilder.build() - await startWithSnapshot(snapshot) -} - -/// Internal helper — requests a new LA and records the start time. -private func startWithSnapshot(_ snapshot: GlucoseSnapshot) async { - let attributes = GlucoseLiveActivityAttributes() - let content = ActivityContent(state: snapshot, staleDate: nil) - do { - currentActivity = try Activity.request( - attributes: attributes, - content: content, - pushType: .token - ) - // Record when this LA was started for renewal tracking - Storage.shared.laStartTime = Date().timeIntervalSince1970 - os_log(.info, log: log, "Live Activity started, laStartTime recorded") - - // Observe push token and state updates (existing logic) - observePushTokenUpdates() - observeActivityStateUpdates() - } catch { - os_log(.error, log: log, "Failed to start Live Activity: %@", error.localizedDescription) - } -} -``` - -Modify `refreshFromCurrentState(reason:)` to call `renewIfNeeded` before the normal path: - -```swift -func refreshFromCurrentState(reason: String) async { - guard currentActivity != nil else { - // No active LA — nothing to refresh - return - } - - let snapshot = GlucoseSnapshotBuilder.build() - - // Check if the LA is approaching Apple's 8-hour limit and renew if so. - // renewIfNeeded returns true if it performed a renewal; we return early - // because startWithSnapshot already sent the first update for the new LA. - if await renewIfNeeded(snapshot: snapshot) { return } - - // Normal refresh path — send APNs self-push with updated snapshot - await GlucoseSnapshotStore.shared.update(snapshot: snapshot) -} -``` - -Modify `stopLiveActivity()` to reset the start time: - -```swift -func stopLiveActivity() async { - guard let activity = currentActivity else { return } - await activity.end(nil, dismissalPolicy: .immediate) - currentActivity = nil - Storage.shared.laStartTime = 0 - os_log(.info, log: log, "Live Activity stopped, laStartTime reset") -} -``` - -### Testing checklist -- [ ] Manually set `renewalThreshold` to 60 seconds during testing to verify the - renewal cycle works without waiting 7.5 hours -- [ ] Confirm the old Lock Screen card disappears before the new one appears - (`.immediate` dismissal working correctly) -- [ ] Confirm `laStartTime` is reset to 0 on manual stop -- [ ] Confirm `laStartTime` is NOT reset when the app is relaunched with an existing - active LA (resume path) -- [ ] Confirm no duplicate LAs appear during renewal -- [ ] Restore `renewalThreshold` to `7.5 * 3600` before committing - ---- - -## Known issues / things still in progress - -- PR #537 is currently marked **Draft** as of March 12, 2026 -- bjorkert's last commit (`524b3bb`) was March 11, 2026 -- The PR is targeting `dev` and has 6 commits total (5 from Philippe, 1 from bjorkert) -- **Active task:** LA auto-renewal (8-hour limit workaround) — see section above - ---- - -## APNs self-push mechanics (important context) - -The self-push flow: -1. `LiveActivityManager.refreshFromCurrentState(reason:)` is called (from MainViewController - or on a not-looping state change) -2. It calls `GlucoseSnapshotBuilder` → `GlucoseSnapshotStore` -3. The store debounces and triggers `APNSClient.sendUpdate(snapshot:)` -4. `APNSClient` fetches credentials from `Storage.shared`, calls `JWTManager.shared` for a - signed JWT (cached for 55 min), then POSTs to the APNs HTTP/2 endpoint -5. The system delivers the push to `LoopFollowLAExtension`, which updates the Live Activity UI - -**APNs environments:** -- Sandbox (development/TestFlight): `api.sandbox.push.apple.com` -- Production: `api.push.apple.com` -- Selection is automatic via `BuildDetails.isTestFlightBuild()` - -**Token expiry handling:** APNs self-push token expiry (HTTP 410 / 400 BadDeviceToken) -is handled in `APNSClient` with appropriate error logging. The token is the Live Activity -push token obtained from `ActivityKit`, not a device token. - ---- - -## Repo / branch conventions - -- `main` — released versions only (version ends in `.0`) -- `dev` — integration branch; PR #537 targets this -- `live-activity` — bjorkert's working branch for the feature (upstream) -- Philippe's fork branches: `dev`, `live-activity-pr` (original work) -- Version format: `M.N.P` — P increments on each `dev` merge, N increments on release - ---- - -## Build configuration notes - -- App Group ID is derived dynamically — do not hardcode team IDs anywhere -- APNs credentials are now entered by the user at runtime in APNSettingsView -- No APNs secrets in xcconfig, Info.plist, or CI environment variables (as of bjorkert's - latest commit) -- The extension target is `LoopFollowLAExtension` with its own entitlements file - (`LoopFollowLAExtensionExtension.entitlements`) -- `Package.resolved` has been updated to remove SwiftJWT and swift-crypto dependencies From 01e2c1bbc2d02750d1b7faa578625347c7720603 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Thu, 19 Mar 2026 18:54:31 +0100 Subject: [PATCH 72/86] Removed duplicate code --- LoopFollowLAExtension/LoopFollowLABundle.swift | 7 ------- 1 file changed, 7 deletions(-) diff --git a/LoopFollowLAExtension/LoopFollowLABundle.swift b/LoopFollowLAExtension/LoopFollowLABundle.swift index 1b058a210..d98475b8e 100644 --- a/LoopFollowLAExtension/LoopFollowLABundle.swift +++ b/LoopFollowLAExtension/LoopFollowLABundle.swift @@ -1,10 +1,6 @@ // LoopFollow // LoopFollowLABundle.swift -// LoopFollowLABundle.swift -// Philippe Achkar -// 2026-03-07 - import SwiftUI import WidgetKit @@ -15,8 +11,5 @@ struct LoopFollowLABundle: WidgetBundle { if #available(iOS 18.0, *) { LoopFollowLiveActivityWidgetWithCarPlay() } - if #available(iOS 18.0, *) { - LoopFollowLiveActivityWidgetWithCarPlay() - } } } From b3f2436d195e712fd47d1a782b5531050140e313 Mon Sep 17 00:00:00 2001 From: Phil A <76601115+MtlPhil@users.noreply.github.com> Date: Fri, 20 Mar 2026 03:31:22 -0400 Subject: [PATCH 73/86] Live activity - final fixes (#557) * Update BackgroundTaskAudio.swift * Update GlucoseLiveActivityAttributes.swift * Update GlucoseLiveActivityAttributes.swift * Restore explanatory comment for 0.5s audio restart delay --- LoopFollow/Helpers/BackgroundTaskAudio.swift | 7 +++---- .../LiveActivity/GlucoseLiveActivityAttributes.swift | 10 ++++++++++ 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/LoopFollow/Helpers/BackgroundTaskAudio.swift b/LoopFollow/Helpers/BackgroundTaskAudio.swift index e5ddcf988..acbf15cbc 100755 --- a/LoopFollow/Helpers/BackgroundTaskAudio.swift +++ b/LoopFollow/Helpers/BackgroundTaskAudio.swift @@ -37,12 +37,10 @@ class BackgroundTask { LogManager.shared.log(category: .general, message: "[LA] Silent audio session interrupted (began)") case .ended: - // Check shouldResume hint — skip restart if iOS says not to if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt { let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue) - guard options.contains(.shouldResume) else { - LogManager.shared.log(category: .general, message: "[LA] Silent audio interruption ended — shouldResume not set, skipping restart") - return + 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") @@ -54,6 +52,7 @@ class BackgroundTask { self?.playAudio() } + @unknown default: break } diff --git a/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift b/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift index b04768fab..e1d6b1332 100644 --- a/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift +++ b/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift @@ -29,6 +29,16 @@ struct GlucoseLiveActivityAttributes: ActivityAttributes { 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 From db5ddbf89970fa8b2ee406fcbf6aa33556383674 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sat, 21 Mar 2026 10:54:36 +0100 Subject: [PATCH 74/86] Remove unnecessary @available(iOS 16.4) checks Deployment target is iOS 16.6, so these annotations are redundant. --- LoopFollow/LiveActivity/RestartLiveActivityIntent.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift b/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift index 9e3179244..da0487ec4 100644 --- a/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift +++ b/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift @@ -4,7 +4,6 @@ import AppIntents import UIKit -@available(iOS 16.4, *) struct RestartLiveActivityIntent: AppIntent { static var title: LocalizedStringResource = "Restart Live Activity" static var description = IntentDescription("Starts or restarts the LoopFollow Live Activity.") @@ -28,7 +27,6 @@ struct RestartLiveActivityIntent: AppIntent { } } -@available(iOS 16.4, *) struct LoopFollowAppShortcuts: AppShortcutsProvider { static var appShortcuts: [AppShortcut] { AppShortcut( From a62595ce0edb94d290b3c1aef713c8fc2900c78a Mon Sep 17 00:00:00 2001 From: Phil A <76601115+MtlPhil@users.noreply.github.com> Date: Sun, 22 Mar 2026 09:54:36 -0400 Subject: [PATCH 75/86] BGAppRefreshTask audio recovery; LA expiry notification; code quality (#570) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update BackgroundTaskAudio.swift * Update GlucoseLiveActivityAttributes.swift * Update GlucoseLiveActivityAttributes.swift * Restore explanatory comment for 0.5s audio restart delay * Add BGAppRefreshTask support for silent audio recovery Registers com.loopfollow.audiorefresh with BGTaskScheduler so iOS can wake the app every ~15 min to check if the silent audio session is still alive and restart it if not. Co-Authored-By: Claude Sonnet 4.6 * Fix BGAppRefreshTask: add fetch background mode, fix duplicate observer - Add 'fetch' to UIBackgroundModes so BGTaskScheduler.submit() doesn't throw notPermitted on every background transition - Call stopBackgroundTask() before startBackgroundTask() in the refresh handler to prevent accumulating duplicate AVAudioSession observers Co-Authored-By: Claude Sonnet 4.6 * Fix duplicate audio observer; add restart confirmation log - startBackgroundTask() now removes the old observer before adding, making it idempotent and preventing duplicate interrupt callbacks - Add 'audio restart initiated' log after restart so success is visible without debug mode - Temporarily make 'Silent audio playing' log always visible for testing Co-Authored-By: Claude Sonnet 4.6 * Delete LiveActivitySlotConfig.swift Forgotten stub. * Update GlucoseSnapshotBuilder.swift * Update StorageCurrentGlucoseStateProvider.swift * Update LiveActivityManager.swift * Update GlucoseSnapshot.swift * Update GlucoseSnapshot.swift * Update LiveActivityManager.swift * Update LiveActivityManager.swift * Update GlucoseLiveActivityAttributes.swift * Update LiveActivityManager.swift * Add LA expiry notification; fix OS-dismissed vs user-dismissed - When renewIfNeeded fails in the background (app can't start a new LA because it's not visible), schedule a local notification on the first failure: "Live Activity Expiring — Open LoopFollow to restart." Subsequent failures in the same cycle are suppressed. Notification is cancelled if renewal later succeeds or forceRestart is called. - In attachStateObserver, distinguish iOS force-dismiss (laRenewalFailed == true) from user swipe (laRenewalFailed == false). OS-dismissed LAs no longer set dismissedByUser, so opening the app triggers auto-restart as expected. Co-Authored-By: Claude Sonnet 4.6 * Remove dead pendingLATapNavigation code Force-quitting an app kills its Live Activities, so cold-launch via LA tap only occurs when iOS terminates the app — in which case scene(_:openURLContexts:) already handles navigation correctly via DispatchQueue.main.async. The flag was never set and never needed. Co-Authored-By: Claude Sonnet 4.6 * Code quality pass: log categories, SwiftFormat, dead code cleanup - BackgroundRefreshManager: all logs → .taskScheduler - AppDelegate: APNs registration/notification logs → .apns - APNSClient: all logs → .apns - BackgroundTaskAudio: restore isDebug:true on silent audio log; fix double blank line - LiveActivityManager: fix trailing whitespace; remove double blank line; SwiftFormat - GlucoseSnapshotBuilder: fix file header (date → standard LoopFollow header) - LoopFollowLiveActivity: remove dead commented-out activityID property - SwiftFormat applied across all reviewed LiveActivity/, Storage/, extension files Co-Authored-By: Claude Sonnet 4.6 * Round prediction value before Int conversion Prevents truncation toward zero (e.g. 179.9 → 179); now correctly rounds to nearest integer. Co-Authored-By: Claude Sonnet 4.6 * Fix double setTaskCompleted race; fix renewal deadline write ordering BackgroundRefreshManager: guard against double setTaskCompleted if the expiration handler fires while the main-queue block is in-flight. Apple documents calling setTaskCompleted more than once as a programming error. LiveActivityManager.renewIfNeeded: write laRenewBy to Storage only after Activity.request succeeds, eliminating the narrow window where a crash between the write and the request could leave the deadline permanently stuck in the future. No rollback needed on failure. The fresh snapshot is built via withRenewalOverlay(false) directly rather than re-running the builder, since the caller already has a current snapshot. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- LoopFollow.xcodeproj/project.pbxproj | 4 + LoopFollow/Application/AppDelegate.swift | 26 +-- LoopFollow/Application/SceneDelegate.swift | 12 +- .../Nightscout/DeviceStatusLoop.swift | 2 +- .../Helpers/BackgroundRefreshManager.swift | 96 ++++++++++ LoopFollow/Helpers/BackgroundTaskAudio.swift | 5 +- LoopFollow/Info.plist | 2 + LoopFollow/LiveActivity/APNSClient.swift | 36 ++-- .../GlucoseLiveActivityAttributes.swift | 2 +- LoopFollow/LiveActivity/GlucoseSnapshot.swift | 75 ++++++-- .../LiveActivity/GlucoseSnapshotBuilder.swift | 164 ++++++++++++------ .../LiveActivity/GlucoseSnapshotStore.swift | 2 +- .../LiveActivity/LAAppGroupSettings.swift | 94 +++++----- .../LiveActivity/LiveActivityManager.swift | 146 ++++++++++------ .../LiveActivity/LiveActivitySlotConfig.swift | 45 ----- .../LiveActivity/PreferredGlucoseUnit.swift | 4 +- .../RestartLiveActivityIntent.swift | 2 +- .../StorageCurrentGlucoseStateProvider.swift | 113 ++++++++++-- LoopFollow/Storage/Storage.swift | 18 +- .../ViewControllers/MainViewController.swift | 1 + .../LoopFollowLiveActivity.swift | 69 ++++---- 21 files changed, 603 insertions(+), 315 deletions(-) create mode 100644 LoopFollow/Helpers/BackgroundRefreshManager.swift delete mode 100644 LoopFollow/LiveActivity/LiveActivitySlotConfig.swift diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 34c2b838c..305f058b3 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -140,6 +140,7 @@ DD608A082C1F584900F91132 /* DeviceStatusLoop.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD608A072C1F584900F91132 /* DeviceStatusLoop.swift */; }; DD608A0A2C23593900F91132 /* SMB.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD608A092C23593900F91132 /* SMB.swift */; }; DD608A0C2C27415C00F91132 /* BackgroundAlertManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD608A0B2C27415C00F91132 /* BackgroundAlertManager.swift */; }; + 66E3D12E66AA4534A144A54B /* BackgroundRefreshManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8CA8BE0B3D247408FE088B4 /* BackgroundRefreshManager.swift */; }; DD6A935E2BFA6FA2003FFB8E /* DeviceStatusOpenAPS.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD6A935D2BFA6FA2003FFB8E /* DeviceStatusOpenAPS.swift */; }; DD7B0D442D730A3B0063DCB6 /* CycleHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7B0D432D730A320063DCB6 /* CycleHelper.swift */; }; DD7E19842ACDA50C00DBD158 /* Overrides.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7E19832ACDA50C00DBD158 /* Overrides.swift */; }; @@ -581,6 +582,7 @@ DD608A072C1F584900F91132 /* DeviceStatusLoop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStatusLoop.swift; sourceTree = ""; }; DD608A092C23593900F91132 /* SMB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMB.swift; sourceTree = ""; }; DD608A0B2C27415C00F91132 /* BackgroundAlertManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundAlertManager.swift; sourceTree = ""; }; + A8CA8BE0B3D247408FE088B4 /* BackgroundRefreshManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundRefreshManager.swift; sourceTree = ""; }; DD6A935D2BFA6FA2003FFB8E /* DeviceStatusOpenAPS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStatusOpenAPS.swift; sourceTree = ""; }; DD7B0D432D730A320063DCB6 /* CycleHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CycleHelper.swift; sourceTree = ""; }; DD7E19832ACDA50C00DBD158 /* Overrides.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Overrides.swift; sourceTree = ""; }; @@ -1655,6 +1657,7 @@ FCC6886A24898FD800A0279D /* ObservationToken.swift */, FCC6886C2489909D00A0279D /* AnyConvertible.swift */, FCC688592489554800A0279D /* BackgroundTaskAudio.swift */, + A8CA8BE0B3D247408FE088B4 /* BackgroundRefreshManager.swift */, FCFEEC9F2488157B00402A7F /* Chart.swift */, FCC0FAC124922A22003E610E /* DictionaryKeyPath.swift */, FC16A98024996C07003D6245 /* DateTime.swift */, @@ -2256,6 +2259,7 @@ 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 */, diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index 81b01cf50..d79de7d18 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -6,7 +6,7 @@ import EventKit import UIKit import UserNotifications -@UIApplicationMain +@main class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? let notificationCenter = UNUserNotificationCenter.current() @@ -45,6 +45,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { DispatchQueue.main.async { UIApplication.shared.registerForRemoteNotifications() } + + BackgroundRefreshManager.shared.register() return true } @@ -56,23 +58,23 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // 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] { @@ -80,7 +82,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) @@ -88,11 +90,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)") } } } @@ -120,7 +122,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) { @@ -176,7 +178,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func userNotificationCenter(_: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { if response.actionIdentifier == "OPEN_APP_ACTION" { - if let window = window { + if let window { window.rootViewController?.dismiss(animated: true, completion: nil) window.rootViewController?.present(MainViewController(), animated: true, completion: nil) } diff --git a/LoopFollow/Application/SceneDelegate.swift b/LoopFollow/Application/SceneDelegate.swift index a8fbb236f..3819a7ac6 100644 --- a/LoopFollow/Application/SceneDelegate.swift +++ b/LoopFollow/Application/SceneDelegate.swift @@ -32,16 +32,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { func sceneDidBecomeActive(_: UIScene) { // Called when the scene has moved from an inactive state to an active state. // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. - if pendingLATapNavigation { - pendingLATapNavigation = false - NotificationCenter.default.post(name: .liveActivityDidForeground, object: nil) - } } - /// Set when loopfollow://la-tap arrives before the scene is fully active. - /// Consumed in sceneDidBecomeActive once the view hierarchy is restored. - private var pendingLATapNavigation = false - func scene(_: UIScene, openURLContexts URLContexts: Set) { guard URLContexts.contains(where: { $0.url.scheme == "loopfollow" && $0.url.host == "la-tap" }) else { return } // scene(_:openURLContexts:) fires after sceneDidBecomeActive when the app @@ -71,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" @@ -84,7 +76,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } } - // The following method is called when the user taps on the Home Screen Quick Action + /// The following method is called when the user taps on the Home Screen Quick Action func windowScene(_: UIWindowScene, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler _: @escaping (Bool) -> Void) { handleShortcutItem(shortcutItem) } diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift index 89c4163cd..daeea40f7 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift @@ -66,7 +66,7 @@ extension MainViewController { if let predictdata = lastLoopRecord["predicted"] as? [String: AnyObject] { let prediction = predictdata["values"] as! [Double] - PredictionLabel.text = Localizer.toDisplayUnits(String(Int(prediction.last!))) + PredictionLabel.text = Localizer.toDisplayUnits(String(Int(round(prediction.last!)))) PredictionLabel.textColor = UIColor.systemPurple if Storage.shared.downloadPrediction.value, previousLastLoopTime < lastLoopTime { predictionData.removeAll() diff --git a/LoopFollow/Helpers/BackgroundRefreshManager.swift b/LoopFollow/Helpers/BackgroundRefreshManager.swift new file mode 100644 index 000000000..bac7e1c8e --- /dev/null +++ b/LoopFollow/Helpers/BackgroundRefreshManager.swift @@ -0,0 +1,96 @@ +// LoopFollow +// BackgroundRefreshManager.swift + +import BackgroundTasks +import UIKit + +class BackgroundRefreshManager { + static let shared = BackgroundRefreshManager() + private init() {} + + private let taskIdentifier = "com.loopfollow.audiorefresh" + + func register() { + BGTaskScheduler.shared.register(forTaskWithIdentifier: taskIdentifier, using: nil) { task in + guard let refreshTask = task as? BGAppRefreshTask else { return } + self.handleRefreshTask(refreshTask) + } + } + + private func handleRefreshTask(_ task: BGAppRefreshTask) { + LogManager.shared.log(category: .taskScheduler, message: "BGAppRefreshTask fired") + + // Guard against double setTaskCompleted if expiration fires while the + // main-queue block is in-flight (Apple documents this as a programming error). + var completed = false + + task.expirationHandler = { + guard !completed else { return } + completed = true + LogManager.shared.log(category: .taskScheduler, message: "BGAppRefreshTask expired") + task.setTaskCompleted(success: false) + self.scheduleRefresh() + } + + DispatchQueue.main.async { + guard !completed else { return } + completed = true + if let mainVC = self.getMainViewController() { + if !mainVC.backgroundTask.player.isPlaying { + LogManager.shared.log(category: .taskScheduler, message: "audio dead, attempting restart") + mainVC.backgroundTask.stopBackgroundTask() + mainVC.backgroundTask.startBackgroundTask() + LogManager.shared.log(category: .taskScheduler, message: "audio restart initiated") + } else { + LogManager.shared.log(category: .taskScheduler, message: "audio alive, no action needed", isDebug: true) + } + } + self.scheduleRefresh() + task.setTaskCompleted(success: true) + } + } + + func scheduleRefresh() { + let request = BGAppRefreshTaskRequest(identifier: taskIdentifier) + request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) + do { + try BGTaskScheduler.shared.submit(request) + } catch { + LogManager.shared.log(category: .taskScheduler, message: "Failed to schedule BGAppRefreshTask: \(error)") + } + } + + private func getMainViewController() -> MainViewController? { + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first, + let rootVC = window.rootViewController + else { + return nil + } + + if let mainVC = rootVC as? MainViewController { + return mainVC + } + + if let navVC = rootVC as? UINavigationController, + let mainVC = navVC.viewControllers.first as? MainViewController + { + return mainVC + } + + if let tabVC = rootVC as? UITabBarController { + for vc in tabVC.viewControllers ?? [] { + if let mainVC = vc as? MainViewController { + return mainVC + } + if let navVC = vc as? UINavigationController, + let mainVC = navVC.viewControllers.first as? MainViewController + { + return mainVC + } + } + } + + return nil + } +} diff --git a/LoopFollow/Helpers/BackgroundTaskAudio.swift b/LoopFollow/Helpers/BackgroundTaskAudio.swift index 3f19ac63c..25aa6b3c8 100755 --- a/LoopFollow/Helpers/BackgroundTaskAudio.swift +++ b/LoopFollow/Helpers/BackgroundTaskAudio.swift @@ -14,6 +14,7 @@ class BackgroundTask { // 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() @@ -25,7 +26,7 @@ class BackgroundTask { LogManager.shared.log(category: .general, message: "Silent audio stopped", isDebug: true) } - @objc fileprivate func interruptedAudio(_ notification: Notification) { + @objc private func interruptedAudio(_ notification: Notification) { guard notification.name == AVAudioSession.interruptionNotification, let userInfo = notification.userInfo, let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt, @@ -57,7 +58,7 @@ class BackgroundTask { } } - 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") diff --git a/LoopFollow/Info.plist b/LoopFollow/Info.plist index 28385ac6e..5cc7f4146 100644 --- a/LoopFollow/Info.plist +++ b/LoopFollow/Info.plist @@ -7,6 +7,7 @@ BGTaskSchedulerPermittedIdentifiers com.$(unique_id).LoopFollow$(app_suffix) + com.loopfollow.audiorefresh CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) @@ -87,6 +88,7 @@ UIBackgroundModes audio + fetch processing bluetooth-central remote-notification diff --git a/LoopFollow/LiveActivity/APNSClient.swift b/LoopFollow/LiveActivity/APNSClient.swift index 94cee2a85..8755b1b27 100644 --- a/LoopFollow/LiveActivity/APNSClient.swift +++ b/LoopFollow/LiveActivity/APNSClient.swift @@ -21,25 +21,33 @@ class APNSClient { : "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 } + 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 + state: GlucoseLiveActivityAttributes.ContentState, ) async { guard let jwt = JWTManager.shared.getOrGenerateJWT(keyId: lfKeyId, teamId: lfTeamId, apnsKey: lfApnsKey) else { - LogManager.shared.log(category: .general, message: "APNs failed to generate JWT for Live Activity push") + 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: .general, message: "APNs invalid URL", isDebug: true) + LogManager.shared.log(category: .apns, message: "APNs invalid URL", isDebug: true) return } @@ -58,38 +66,38 @@ class APNSClient { if let httpResponse = response as? HTTPURLResponse { switch httpResponse.statusCode { case 200: - LogManager.shared.log(category: .general, message: "APNs push sent successfully", isDebug: true) + 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: .general, message: "APNs bad request (400) — malformed payload: \(responseBody)") + 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: .general, message: "APNs JWT rejected (403) — token cache cleared, will regenerate") + 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: .general, message: "APNs token \(reason) — restarting Live Activity") + LogManager.shared.log(category: .apns, message: "APNs token \(reason) — restarting Live Activity") LiveActivityManager.shared.handleExpiredToken() case 429: - LogManager.shared.log(category: .general, message: "APNs rate limited (429) — will retry on next refresh") + 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: .general, message: "APNs server error (\(httpResponse.statusCode)) — will retry on next refresh: \(responseBody)") + 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: .general, message: "APNs push failed status=\(httpResponse.statusCode) body=\(responseBody)") + LogManager.shared.log(category: .apns, message: "APNs push failed status=\(httpResponse.statusCode) body=\(responseBody)") } } } catch { - LogManager.shared.log(category: .general, message: "APNs error: \(error.localizedDescription)") + LogManager.shared.log(category: .apns, message: "APNs error: \(error.localizedDescription)") } } diff --git a/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift b/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift index db4836b88..6d6ddb9a9 100644 --- a/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift +++ b/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift @@ -8,7 +8,7 @@ import ActivityKit import Foundation struct GlucoseLiveActivityAttributes: ActivityAttributes { - public struct ContentState: Codable, Hashable { + struct ContentState: Codable, Hashable { let snapshot: GlucoseSnapshot let seq: Int let reason: String diff --git a/LoopFollow/LiveActivity/GlucoseSnapshot.swift b/LoopFollow/LiveActivity/GlucoseSnapshot.swift index 4e914ab7e..8860391c2 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshot.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshot.swift @@ -111,10 +111,12 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { // MARK: - Renewal - /// True when the Live Activity is within 30 minutes of its renewal deadline. + /// 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, @@ -144,7 +146,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { maxBgMgdl: Double? = nil, unit: Unit, isNotLooping: Bool, - showRenewalOverlay: Bool = false + showRenewalOverlay: Bool = false, ) { self.glucose = glucose self.delta = delta @@ -177,6 +179,52 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { 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) @@ -210,17 +258,6 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { try container.encode(showRenewalOverlay, forKey: .showRenewalOverlay) } - 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: - Codable - init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) glucose = try container.decode(Double.self, forKey: .glucose) @@ -254,11 +291,13 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { showRenewalOverlay = try container.decodeIfPresent(Bool.self, forKey: .showRenewalOverlay) ?? false } - // MARK: - Derived Convenience - - /// Age of reading in seconds. - var age: TimeInterval { - Date().timeIntervalSince(updatedAt) + 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 } } diff --git a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift index dd845b116..40ff076af 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift @@ -3,10 +3,12 @@ import Foundation -/// Provides the *latest* glucose-relevant values from LoopFollow’s single source of truth. -/// This is intentionally provider-agnostic (Nightscout vs Dexcom doesn’t matter). +/// Provides the latest glucose-relevant values from LoopFollow's single source of truth. +/// Intentionally provider-agnostic (Nightscout vs Dexcom doesn't matter). protocol CurrentGlucoseStateProviding { - /// Canonical glucose value in mg/dL (recommended internal canonical form). + // MARK: - Core Glucose + + /// Canonical glucose value in mg/dL. var glucoseMgdl: Double? { get } /// Canonical delta in mg/dL. @@ -15,18 +17,92 @@ protocol CurrentGlucoseStateProviding { /// Canonical projected glucose in mg/dL. var projectedMgdl: Double? { get } - /// Timestamp of the last reading/update. + /// Timestamp of the last reading. var updatedAt: Date? { get } - /// Trend string / code from LoopFollow (we map to GlucoseSnapshot.Trend). + /// Trend string from LoopFollow (mapped to GlucoseSnapshot.Trend by the builder). var trendCode: String? { get } - /// Secondary metrics (typically already unitless) + // 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 } } -/// Builds a GlucoseSnapshot in the user’s preferred unit, without embedding provider logic. +// 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 @@ -34,43 +110,28 @@ enum GlucoseSnapshotBuilder { glucoseMgdl > 0, let updatedAt = provider.updatedAt else { - // Debug-only signal: we’re missing core state. - // (If you prefer no logs here, remove this line.) LogManager.shared.log( category: .general, message: "GlucoseSnapshotBuilder: missing/invalid core values glucoseMgdl=\(provider.glucoseMgdl?.description ?? "nil") updatedAt=\(provider.updatedAt?.description ?? "nil")", - isDebug: true + isDebug: true, ) return nil } let preferredUnit = PreferredGlucoseUnit.snapshotUnit() - let deltaMgdl = provider.deltaMgdl ?? 0.0 - let trend = mapTrend(provider.trendCode) - // Not Looping — read from Observable, set by evaluateNotLooping() in DeviceStatus.swift - let isNotLooping = Observable.shared.isNotLooping.value - - // Renewal overlay — show renewalWarning seconds before the renewal deadline - // so the user knows the LA is about to be replaced. - let renewBy = Storage.shared.laRenewBy.value - let now = Date().timeIntervalSince1970 - let showRenewalOverlay = renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning - - if showRenewalOverlay { - let timeLeft = max(renewBy - now, 0) - LogManager.shared.log(category: .general, message: "[LA] renewal overlay ON — \(Int(timeLeft))s until deadline") + 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 + isDebug: true, ) - let profileNameRaw = Storage.shared.lastProfileName.value return GlucoseSnapshot( glucose: glucoseMgdl, delta: deltaMgdl, @@ -79,31 +140,33 @@ enum GlucoseSnapshotBuilder { iob: provider.iob, cob: provider.cob, projected: provider.projectedMgdl, - override: Observable.shared.override.value, - recBolus: Observable.shared.deviceRecBolus.value, - battery: Observable.shared.deviceBatteryLevel.value, - pumpBattery: Observable.shared.pumpBatteryLevel.value, - basalRate: Storage.shared.lastBasal.value, - pumpReservoirU: Storage.shared.lastPumpReservoirU.value, - autosens: Storage.shared.lastAutosens.value, - tdd: Storage.shared.lastTdd.value, - targetLowMgdl: Storage.shared.lastTargetLowMgdl.value, - targetHighMgdl: Storage.shared.lastTargetHighMgdl.value, - isfMgdlPerU: Storage.shared.lastIsfMgdlPerU.value, - carbRatio: Storage.shared.lastCarbRatio.value, - carbsToday: Storage.shared.lastCarbsToday.value, - profileName: profileNameRaw.isEmpty ? nil : profileNameRaw, - sageInsertTime: Storage.shared.sageInsertTime.value, - cageInsertTime: Storage.shared.cageInsertTime.value, - iageInsertTime: Storage.shared.iageInsertTime.value, - minBgMgdl: Storage.shared.lastMinBgMgdl.value, - maxBgMgdl: Storage.shared.lastMaxBgMgdl.value, + 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: isNotLooping, - showRenewalOverlay: showRenewalOverlay + isNotLooping: provider.isNotLooping, + showRenewalOverlay: provider.showRenewalOverlay, ) } + // MARK: - Trend Mapping + private static func mapTrend(_ code: String?) -> GlucoseSnapshot.Trend { guard let raw = code? @@ -112,11 +175,6 @@ enum GlucoseSnapshotBuilder { !raw.isEmpty else { return .unknown } - // Common Nightscout strings: - // "Flat", "FortyFiveUp", "SingleUp", "DoubleUp", "FortyFiveDown", "SingleDown", "DoubleDown" - // Common variants: - // "rising", "falling", "rapidRise", "rapidFall" - if raw.contains("doubleup") || raw.contains("rapidrise") || raw == "up2" || raw == "upfast" { return .upFast } @@ -126,11 +184,9 @@ enum GlucoseSnapshotBuilder { 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 } diff --git a/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift b/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift index b45a7a0b9..7951e122a 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift @@ -67,7 +67,7 @@ final class GlucoseSnapshotStore { throw NSError( domain: "GlucoseSnapshotStore", code: 1, - userInfo: [NSLocalizedDescriptionKey: "App Group containerURL is nil for id=\(groupID)"] + userInfo: [NSLocalizedDescriptionKey: "App Group containerURL is nil for id=\(groupID)"], ) } return containerURL.appendingPathComponent(fileName, isDirectory: false) diff --git a/LoopFollow/LiveActivity/LAAppGroupSettings.swift b/LoopFollow/LiveActivity/LAAppGroupSettings.swift index 4e1d7b126..8fedeb155 100644 --- a/LoopFollow/LiveActivity/LAAppGroupSettings.swift +++ b/LoopFollow/LiveActivity/LAAppGroupSettings.swift @@ -44,56 +44,56 @@ enum LiveActivitySlotOption: String, CaseIterable, Codable { /// Human-readable label shown in the slot picker in Settings. var displayName: String { switch self { - case .none: return "Empty" - case .delta: return "Delta" - case .projectedBG: return "Projected BG" - case .minMax: return "Min/Max" - case .iob: return "IOB" - case .cob: return "COB" - case .recBolus: return "Rec. Bolus" - case .autosens: return "Autosens" - case .tdd: return "TDD" - case .basal: return "Basal" - case .pump: return "Pump" - case .pumpBattery: return "Pump Battery" - case .battery: return "Battery" - case .target: return "Target" - case .isf: return "ISF" - case .carbRatio: return "CR" - case .sage: return "SAGE" - case .cage: return "CAGE" - case .iage: return "IAGE" - case .carbsToday: return "Carbs today" - case .override: return "Override" - case .profile: return "Profile" + 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: return "" - case .delta: return "Delta" - case .projectedBG: return "Proj" - case .minMax: return "Min/Max" - case .iob: return "IOB" - case .cob: return "COB" - case .recBolus: return "Rec." - case .autosens: return "Sens" - case .tdd: return "TDD" - case .basal: return "Basal" - case .pump: return "Pump" - case .pumpBattery: return "Pump%" - case .battery: return "Bat." - case .target: return "Target" - case .isf: return "ISF" - case .carbRatio: return "CR" - case .sage: return "SAGE" - case .cage: return "CAGE" - case .iage: return "IAGE" - case .carbsToday: return "Carbs" - case .override: return "Ovrd" - case .profile: return "Prof" + 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" } } @@ -101,8 +101,8 @@ enum LiveActivitySlotOption: String, CaseIterable, Codable { /// no Loop data). The widget renders "—" in those cases. var isOptional: Bool { switch self { - case .none, .delta: return false - default: return true + case .none, .delta: false + default: true } } } @@ -162,7 +162,7 @@ enum LAAppGroupSettings { /// - 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 { $0.rawValue } + let raw = slots.prefix(4).map(\.rawValue) defaults?.set(raw, forKey: Keys.slots) } diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 00d230e40..9faa8a41e 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -8,8 +8,9 @@ import Foundation import os import UIKit +import UserNotifications -/// Live Activity manager for LoopFollow. +// Live Activity manager for LoopFollow. final class LiveActivityManager { static let shared = LiveActivityManager() @@ -18,25 +19,25 @@ final class LiveActivityManager { self, selector: #selector(handleForeground), name: UIApplication.willEnterForegroundNotification, - object: nil + object: nil, ) NotificationCenter.default.addObserver( self, selector: #selector(handleDidBecomeActive), name: UIApplication.didBecomeActiveNotification, - object: nil + object: nil, ) NotificationCenter.default.addObserver( self, selector: #selector(handleWillResignActive), name: UIApplication.willResignActiveNotification, - object: nil + object: nil, ) NotificationCenter.default.addObserver( self, selector: #selector(handleBackgroundAudioFailed), name: .backgroundAudioFailed, - object: nil + object: nil, ) } @@ -55,7 +56,7 @@ final class LiveActivityManager { LAAppGroupSettings.setThresholds( lowMgdl: Storage.shared.lowLine.value, - highMgdl: Storage.shared.highLine.value + highMgdl: Storage.shared.highLine.value, ) GlucoseSnapshotStore.shared.save(snapshot) @@ -65,12 +66,12 @@ final class LiveActivityManager { snapshot: snapshot, seq: nextSeq, reason: "resign-active", - producedAt: Date() + producedAt: Date(), ) let content = ActivityContent( state: state, staleDate: Date(timeIntervalSince1970: Storage.shared.laRenewBy.value), - relevanceScore: 100.0 + relevanceScore: 100.0, ) Task { @@ -184,23 +185,29 @@ final class LiveActivityManager { do { let attributes = GlucoseLiveActivityAttributes(title: "LoopFollow") - let seedSnapshot = GlucoseSnapshotStore.shared.load() ?? GlucoseSnapshot( - glucose: 0, - delta: 0, - trend: .unknown, - updatedAt: Date(), - iob: nil, - cob: nil, - projected: nil, - unit: .mgdl, - isNotLooping: false - ) + // 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() + producedAt: Date(), ) let renewDeadline = Date().addingTimeInterval(LiveActivityManager.renewalThreshold) @@ -249,11 +256,11 @@ final class LiveActivityManager { cob: nil, projected: nil, unit: .mgdl, - isNotLooping: false + isNotLooping: false, ), seq: seq, reason: "end", - producedAt: Date() + producedAt: Date(), ) let content = ActivityContent(state: finalState, staleDate: nil) @@ -277,6 +284,7 @@ final class LiveActivityManager { dismissedByUser = false Storage.shared.laRenewBy.value = 0 Storage.shared.laRenewalFailed.value = false + cancelRenewalFailedNotification() current = nil updateTask?.cancel(); updateTask = nil tokenObservationTask?.cancel(); tokenObservationTask = nil @@ -300,7 +308,7 @@ final class LiveActivityManager { if let snapshot = GlucoseSnapshotBuilder.build(from: provider) { LAAppGroupSettings.setThresholds( lowMgdl: Storage.shared.lowLine.value, - highMgdl: Storage.shared.highLine.value + highMgdl: Storage.shared.highLine.value, ) GlucoseSnapshotStore.shared.save(snapshot) } @@ -336,32 +344,24 @@ final class LiveActivityManager { let renewDeadline = Date().addingTimeInterval(LiveActivityManager.renewalThreshold) let attributes = GlucoseLiveActivityAttributes(title: "LoopFollow") - // Strip the overlay flag — the new LA has a fresh deadline so it should - // open clean, without the warning visible from the first frame. - let freshSnapshot = GlucoseSnapshot( - glucose: snapshot.glucose, - delta: snapshot.delta, - trend: snapshot.trend, - updatedAt: snapshot.updatedAt, - iob: snapshot.iob, - cob: snapshot.cob, - projected: snapshot.projected, - unit: snapshot.unit, - isNotLooping: snapshot.isNotLooping, - showRenewalOverlay: false - ) + // 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() + producedAt: Date(), ) let content = ActivityContent(state: state, staleDate: renewDeadline) do { let newActivity = try Activity.request(attributes: attributes, content: content, pushType: .token) - // New LA is live — now it's safe to remove the old card. Task { await oldActivity.end(nil, dismissalPolicy: .immediate) } @@ -374,16 +374,23 @@ final class LiveActivityManager { stateObserverTask = nil pushToken = nil - bind(to: newActivity, logReason: "renew") + // 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 - // Update the store so the next duplicate check has the correct baseline. + 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 } } @@ -415,7 +422,7 @@ final class LiveActivityManager { } LAAppGroupSettings.setThresholds( lowMgdl: Storage.shared.lowLine.value, - highMgdl: Storage.shared.highLine.value + highMgdl: Storage.shared.highLine.value, ) GlucoseSnapshotStore.shared.save(snapshot) guard ActivityAuthorizationInfo().areActivitiesEnabled else { @@ -460,21 +467,21 @@ final class LiveActivityManager { snapshot: snapshot, seq: nextSeq, reason: reason, - producedAt: Date() + producedAt: Date(), ) updateTask = Task { [weak self] in guard let self else { return } if activity.activityState == .ended || activity.activityState == .dismissed { - if self.current?.id == activityID { self.current = nil } + if current?.id == activityID { current = nil } return } let content = ActivityContent( state: state, staleDate: Date(timeIntervalSince1970: Storage.shared.laRenewBy.value), - relevanceScore: 100.0 + relevanceScore: 100.0, ) if Task.isCancelled { return } @@ -495,15 +502,15 @@ final class LiveActivityManager { if Task.isCancelled { return } - guard self.current?.id == activityID else { + guard current?.id == activityID else { LogManager.shared.log(category: .general, message: "Live Activity update — activity ID mismatch, discarding") return } - self.lastUpdateTime = Date() + lastUpdateTime = Date() LogManager.shared.log(category: .general, message: "[LA] updated id=\(activityID) seq=\(nextSeq) reason=\(reason)", isDebug: true) - if let token = self.pushToken { + if let token = pushToken { await APNSClient.shared.sendLiveActivityUpdate(pushToken: token, state: state) } } @@ -548,6 +555,33 @@ final class LiveActivityManager { // Activity will restart on next BG refresh via refreshFromCurrentState() } + // MARK: - Renewal Notifications + + 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: "loopfollow.la.renewal.failed", + 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 = "loopfollow.la.renewal.failed" + UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [id]) + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [id]) + } + private func attachStateObserver(to activity: Activity) { stateObserverTask?.cancel() stateObserverTask = Task { @@ -560,11 +594,17 @@ final class LiveActivityManager { LogManager.shared.log(category: .general, message: "Live Activity cleared id=\(activity.id)", isDebug: true) } if state == .dismissed { - // User manually swiped away the LA. Block auto-restart until - // the user explicitly restarts via button or App Intent. - // laEnabled is left true — the user's preference is preserved. - dismissedByUser = true - LogManager.shared.log(category: .general, message: "Live Activity dismissed by user — auto-restart blocked until explicit restart") + if Storage.shared.laRenewalFailed.value { + // iOS force-dismissed after 8-hour limit with a failed renewal. + // Allow auto-restart when the user opens the app. + LogManager.shared.log(category: .general, message: "Live Activity dismissed by iOS after expiry — auto-restart enabled") + } else { + // User manually swiped away the LA. Block auto-restart until + // the user explicitly restarts via button or App Intent. + // laEnabled is left true — the user's preference is preserved. + dismissedByUser = true + LogManager.shared.log(category: .general, message: "Live Activity dismissed by user — auto-restart blocked until explicit restart") + } } } } diff --git a/LoopFollow/LiveActivity/LiveActivitySlotConfig.swift b/LoopFollow/LiveActivity/LiveActivitySlotConfig.swift deleted file mode 100644 index 10d8b13c3..000000000 --- a/LoopFollow/LiveActivity/LiveActivitySlotConfig.swift +++ /dev/null @@ -1,45 +0,0 @@ -// LoopFollow -// LiveActivitySlotConfig.swift - -// MARK: - Information Display Settings audit - -// -// LoopFollow exposes 20 items in Information Display Settings (InfoType.swift). -// The table below maps each item to its availability as a Live Activity grid slot. -// -// AVAILABLE NOW — value present in GlucoseSnapshot: -// Display name | InfoType case | Snapshot field | Optional (nil for Dexcom-only) -// ───────────────────────────────────────────────────────────────────────────────── -// IOB | .iob | snapshot.iob | YES -// COB | .cob | snapshot.cob | YES -// Projected BG | (none) | snapshot.projected | YES -// Delta | (none) | snapshot.delta | NO (always available) -// -// Note: "Updated" (InfoType.updated) is intentionally excluded — it is displayed -// in the card footer and is not a configurable slot. -// -// NOT YET AVAILABLE — requires adding fields to GlucoseSnapshot, GlucoseSnapshotBuilder, -// and the APNs payload before they can be offered as slot options: -// Display name | InfoType case | Source in app -// ───────────────────────────────────────────────────────────────────────────────── -// Basal | .basal | DeviceStatus basal rate -// Override | .override | DeviceStatus override name -// Battery | .battery | DeviceStatus CGM/device battery % -// Pump | .pump | DeviceStatus pump name / status -// Pump Battery | .pumpBattery | DeviceStatus pump battery % -// SAGE | .sage | DeviceStatus sensor age (hours) -// CAGE | .cage | DeviceStatus cannula age (hours) -// Rec. Bolus | .recBolus | DeviceStatus recommended bolus -// Min/Max | .minMax | Computed from recent BG history -// Carbs today | .carbsToday | Computed from COB history -// Autosens | .autosens | DeviceStatusOpenAPS autosens ratio -// Profile | .profile | DeviceStatus profile name -// Target | .target | DeviceStatus BG target -// ISF | .isf | DeviceStatus insulin sensitivity factor -// CR | .carbRatio | DeviceStatus carb ratio -// TDD | .tdd | DeviceStatus total daily dose -// IAGE | .iage | DeviceStatus insulin/pod age (hours) -// -// The LiveActivitySlotOption enum, LiveActivitySlotDefaults struct, and -// LAAppGroupSettings.setSlots() / slots() storage are defined in -// LAAppGroupSettings.swift (shared between app and extension targets). diff --git a/LoopFollow/LiveActivity/PreferredGlucoseUnit.swift b/LoopFollow/LiveActivity/PreferredGlucoseUnit.swift index eb26b9b54..3ce52f948 100644 --- a/LoopFollow/LiveActivity/PreferredGlucoseUnit.swift +++ b/LoopFollow/LiveActivity/PreferredGlucoseUnit.swift @@ -14,9 +14,9 @@ enum PreferredGlucoseUnit { static func snapshotUnit() -> GlucoseSnapshot.Unit { switch hkUnit() { case .millimolesPerLiter: - return .mmol + .mmol default: - return .mgdl + .mgdl } } } diff --git a/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift b/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift index da0487ec4..cb1f84d18 100644 --- a/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift +++ b/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift @@ -33,7 +33,7 @@ struct LoopFollowAppShortcuts: AppShortcutsProvider { intent: RestartLiveActivityIntent(), phrases: ["Restart Live Activity in \(.applicationName)"], shortTitle: "Restart Live Activity", - systemImageName: "dot.radiowaves.left.and.right" + systemImageName: "dot.radiowaves.left.and.right", ) } } diff --git a/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift b/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift index b5a5cf7ea..90e74f5b8 100644 --- a/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift +++ b/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift @@ -1,19 +1,17 @@ -// LoopFollow // StorageCurrentGlucoseStateProvider.swift +// 2026-03-21 import Foundation -/// Reads the latest glucose state from LoopFollow’s existing single source of truth. -/// Provider remains source-agnostic (Nightscout vs Dexcom). +/// 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 { - var glucoseMgdl: Double? { - guard - let bg = Observable.shared.bg.value, - bg > 0 - else { - return nil - } + // MARK: - Core Glucose + + var glucoseMgdl: Double? { + guard let bg = Observable.shared.bg.value, bg > 0 else { return nil } return Double(bg) } @@ -34,6 +32,8 @@ struct StorageCurrentGlucoseStateProvider: CurrentGlucoseStateProviding { Storage.shared.lastTrendCode.value } + // MARK: - Secondary Metrics + var iob: Double? { Storage.shared.lastIOB.value } @@ -41,4 +41,97 @@ struct StorageCurrentGlucoseStateProvider: CurrentGlucoseStateProviding { 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 { + Observable.shared.isNotLooping.value + } + + // MARK: - Renewal + + var showRenewalOverlay: Bool { + let renewBy = Storage.shared.laRenewBy.value + let now = Date().timeIntervalSince1970 + return renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning + } } diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index fd402c592..efb55b031 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -179,8 +179,8 @@ class Storage { var token = StorageValue(key: "token", defaultValue: "") var units = StorageValue(key: "units", defaultValue: "mg/dL") - var infoSort = StorageValue<[Int]>(key: "infoSort", defaultValue: InfoType.allCases.map { $0.sortOrder }) - var infoVisible = StorageValue<[Bool]>(key: "infoVisible", defaultValue: InfoType.allCases.map { $0.defaultVisible }) + var infoSort = StorageValue<[Int]>(key: "infoSort", defaultValue: InfoType.allCases.map(\.sortOrder)) + var infoVisible = StorageValue<[Bool]>(key: "infoVisible", defaultValue: InfoType.allCases.map(\.defaultVisible)) var url = StorageValue(key: "url", defaultValue: "") var device = StorageValue(key: "device", defaultValue: "") @@ -221,13 +221,13 @@ class Storage { /// Get the position for a given tab item func position(for item: TabItem) -> TabPosition { switch item { - case .home: return homePosition.value - case .alarms: return alarmsPosition.value - case .remote: return remotePosition.value - case .nightscout: return nightscoutPosition.value - case .snoozer: return snoozerPosition.value - case .stats: return statisticsPosition.value - case .treatments: return treatmentsPosition.value + case .home: homePosition.value + case .alarms: alarmsPosition.value + case .remote: remotePosition.value + case .nightscout: nightscoutPosition.value + case .snoozer: snoozerPosition.value + case .stats: statisticsPosition.value + case .treatments: treatmentsPosition.value } } diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 7d6f8bf35..bbf2de63c 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -964,6 +964,7 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele if Storage.shared.backgroundRefreshType.value == .silentTune { backgroundTask.startBackgroundTask() + BackgroundRefreshManager.shared.scheduleRefresh() } if Storage.shared.backgroundRefreshType.value != .none { diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 753402e05..5c28eed3b 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -80,7 +80,7 @@ private extension View { func applyActivityContentMarginsFixIfAvailable() -> some View { if #available(iOS 17.0, *) { // Use the generic SwiftUI API available in iOS 17+ (no placement enum) - self.contentMargins(Edge.Set.all, 0) + contentMargins(Edge.Set.all, 0) } else { self } @@ -151,7 +151,6 @@ private struct SmallFamilyView: View { private struct LockScreenLiveActivityView: View { let state: GlucoseLiveActivityAttributes.ContentState - /* let activityID: String */ var body: some View { let s = state.snapshot @@ -220,7 +219,7 @@ private struct LockScreenLiveActivityView: View { .padding(.bottom, 8) .overlay( RoundedRectangle(cornerRadius: 16, style: .continuous) - .stroke(Color.white.opacity(0.20), lineWidth: 1) + .stroke(Color.white.opacity(0.20), lineWidth: 1), ) .overlay( Group { @@ -234,7 +233,7 @@ private struct LockScreenLiveActivityView: View { .tracking(1.5) } } - } + }, ) .overlay( ZStack { @@ -244,7 +243,7 @@ private struct LockScreenLiveActivityView: View { .font(.system(size: 20, weight: .semibold)) .foregroundStyle(.white) } - .opacity(state.snapshot.showRenewalOverlay ? 1 : 0) + .opacity(state.snapshot.showRenewalOverlay ? 1 : 0), ) } } @@ -307,28 +306,28 @@ private struct SlotView: View { private func value(for option: LiveActivitySlotOption) -> String { switch option { - case .none: return "" - case .delta: return LAFormat.delta(snapshot) - case .projectedBG: return LAFormat.projected(snapshot) - case .minMax: return LAFormat.minMax(snapshot) - case .iob: return LAFormat.iob(snapshot) - case .cob: return LAFormat.cob(snapshot) - case .recBolus: return LAFormat.recBolus(snapshot) - case .autosens: return LAFormat.autosens(snapshot) - case .tdd: return LAFormat.tdd(snapshot) - case .basal: return LAFormat.basal(snapshot) - case .pump: return LAFormat.pump(snapshot) - case .pumpBattery: return LAFormat.pumpBattery(snapshot) - case .battery: return LAFormat.battery(snapshot) - case .target: return LAFormat.target(snapshot) - case .isf: return LAFormat.isf(snapshot) - case .carbRatio: return LAFormat.carbRatio(snapshot) - case .sage: return LAFormat.age(insertTime: snapshot.sageInsertTime) - case .cage: return LAFormat.age(insertTime: snapshot.cageInsertTime) - case .iage: return LAFormat.age(insertTime: snapshot.iageInsertTime) - case .carbsToday: return LAFormat.carbsToday(snapshot) - case .override: return LAFormat.override(snapshot) - case .profile: return LAFormat.profileName(snapshot) + 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) } } } @@ -515,14 +514,14 @@ private enum LAFormat { static func trendArrow(_ s: GlucoseSnapshot) -> String { switch s.trend { - case .upFast: return "↑↑" - case .up: return "↑" - case .upSlight: return "↗" - case .flat: return "→" - case .downSlight: return "↘︎" - case .down: return "↓" - case .downFast: return "↓↓" - case .unknown: return "–" + case .upFast: "↑↑" + case .up: "↑" + case .upSlight: "↗" + case .flat: "→" + case .downSlight: "↘︎" + case .down: "↓" + case .downFast: "↓↓" + case .unknown: "–" } } From 84e1736181fd253598e5b24e28e2e1eb6372cdba Mon Sep 17 00:00:00 2001 From: Phil A <76601115+MtlPhil@users.noreply.github.com> Date: Tue, 24 Mar 2026 09:08:42 -0400 Subject: [PATCH 76/86] Live Activity: CarPlay/Watch Smart Stack widget + BFU crash fix + BGAppRefreshTask (#574) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update BackgroundTaskAudio.swift * Update GlucoseLiveActivityAttributes.swift * Update GlucoseLiveActivityAttributes.swift * Restore explanatory comment for 0.5s audio restart delay * Add BGAppRefreshTask support for silent audio recovery Registers com.loopfollow.audiorefresh with BGTaskScheduler so iOS can wake the app every ~15 min to check if the silent audio session is still alive and restart it if not. Co-Authored-By: Claude Sonnet 4.6 * Fix BGAppRefreshTask: add fetch background mode, fix duplicate observer - Add 'fetch' to UIBackgroundModes so BGTaskScheduler.submit() doesn't throw notPermitted on every background transition - Call stopBackgroundTask() before startBackgroundTask() in the refresh handler to prevent accumulating duplicate AVAudioSession observers Co-Authored-By: Claude Sonnet 4.6 * Fix duplicate audio observer; add restart confirmation log - startBackgroundTask() now removes the old observer before adding, making it idempotent and preventing duplicate interrupt callbacks - Add 'audio restart initiated' log after restart so success is visible without debug mode - Temporarily make 'Silent audio playing' log always visible for testing Co-Authored-By: Claude Sonnet 4.6 * Delete LiveActivitySlotConfig.swift Forgotten stub. * Update GlucoseSnapshotBuilder.swift * Update StorageCurrentGlucoseStateProvider.swift * Update LiveActivityManager.swift * Update GlucoseSnapshot.swift * Update GlucoseSnapshot.swift * Update LiveActivityManager.swift * Update LiveActivityManager.swift * Update GlucoseLiveActivityAttributes.swift * Update LiveActivityManager.swift * Add LA expiry notification; fix OS-dismissed vs user-dismissed - When renewIfNeeded fails in the background (app can't start a new LA because it's not visible), schedule a local notification on the first failure: "Live Activity Expiring — Open LoopFollow to restart." Subsequent failures in the same cycle are suppressed. Notification is cancelled if renewal later succeeds or forceRestart is called. - In attachStateObserver, distinguish iOS force-dismiss (laRenewalFailed == true) from user swipe (laRenewalFailed == false). OS-dismissed LAs no longer set dismissedByUser, so opening the app triggers auto-restart as expected. Co-Authored-By: Claude Sonnet 4.6 * Remove dead pendingLATapNavigation code Force-quitting an app kills its Live Activities, so cold-launch via LA tap only occurs when iOS terminates the app — in which case scene(_:openURLContexts:) already handles navigation correctly via DispatchQueue.main.async. The flag was never set and never needed. Co-Authored-By: Claude Sonnet 4.6 * Code quality pass: log categories, SwiftFormat, dead code cleanup - BackgroundRefreshManager: all logs → .taskScheduler - AppDelegate: APNs registration/notification logs → .apns - APNSClient: all logs → .apns - BackgroundTaskAudio: restore isDebug:true on silent audio log; fix double blank line - LiveActivityManager: fix trailing whitespace; remove double blank line; SwiftFormat - GlucoseSnapshotBuilder: fix file header (date → standard LoopFollow header) - LoopFollowLiveActivity: remove dead commented-out activityID property - SwiftFormat applied across all reviewed LiveActivity/, Storage/, extension files Co-Authored-By: Claude Sonnet 4.6 * Round prediction value before Int conversion Prevents truncation toward zero (e.g. 179.9 → 179); now correctly rounds to nearest integer. Co-Authored-By: Claude Sonnet 4.6 * Fix double setTaskCompleted race; fix renewal deadline write ordering BackgroundRefreshManager: guard against double setTaskCompleted if the expiration handler fires while the main-queue block is in-flight. Apple documents calling setTaskCompleted more than once as a programming error. LiveActivityManager.renewIfNeeded: write laRenewBy to Storage only after Activity.request succeeds, eliminating the narrow window where a crash between the write and the request could leave the deadline permanently stuck in the future. No rollback needed on failure. The fresh snapshot is built via withRenewalOverlay(false) directly rather than re-running the builder, since the caller already has a current snapshot. Co-Authored-By: Claude Sonnet 4.6 * Scope all identifiers to bundle ID for multi-instance support Derive BGTask IDs, notification IDs, URL schemes, and notification categories from Bundle.main.bundleIdentifier so that LoopFollow, LoopFollow_Second, and LoopFollow_Third each get isolated identifiers and don't interfere with each other's background tasks, notifications, or Live Activities. Also show the configured display name in the Live Activity footer (next to the update time) when the existing "Show Display Name" toggle is enabled, so users can identify which instance a LA belongs to. * Linting * Add migration step 7: cancel legacy notification identifiers Users upgrading from the old hardcoded identifiers would have orphaned pending notifications that the new bundle-ID-scoped code can't cancel. This one-time migration cleans them up on first launch. * Increase LA refresh debounce from 5s to 20s to coalesce double push The `bg` and `loopingResumed` refresh triggers fire ~10s apart. With a 5s debounce, `loopingResumed` arrives after the debounce has already executed, causing two APNs pushes per BG cycle instead of one. Widening the window to 20s ensures both events are coalesced into a single push containing the most up-to-date post-loop-cycle state (fresh IOB, predicted BG, etc.). Co-Authored-By: Claude Sonnet 4.6 * Guard migrations against background launch to prevent BFU settings wipe When BGAppRefreshTask fires after a reboot (before the user has unlocked the device), UserDefaults files are still encrypted (Before First Unlock state). Reading migrationStep returns 0, causing all migrations to re-run. migrateStep1 reads old_url from the also-locked App Group suite, gets "", and writes "" to url — wiping Nightscout and other settings. Fix: skip the entire migration block when the app is in background state. Migrations will run correctly on the next foreground open. This is safe since no migration is time-critical and all steps are guarded by version checks. Co-Authored-By: Claude Sonnet 4.6 * Fix BFU migration guard: wrap only migrations, not all of viewDidLoad The previous fix used guard+return which skipped the entire viewDidLoad when the app launched in background (BGAppRefreshTask). viewDidLoad only runs once per VC lifecycle, so the UI was never initialized when the user later foregrounded the app — causing a blank screen. Fix: wrap only the migration block in an if-check, so UI setup always runs. Migrations are still skipped in background to avoid BFU corruption. Co-Authored-By: Claude Sonnet 4.6 * Defer migrations to first foreground after BFU background launch runMigrationsIfNeeded() extracts the migration block and is called from both viewDidLoad (normal launch) and appCameToForeground() (deferred case). The guard skips execution when applicationState == .background to prevent BFU corruption, and appCameToForeground() picks up any deferred migrations the first time the user unlocks after a reboot. The previous fix (wrapping migrations in an if-block inside viewDidLoad) correctly prevented BFU corruption but left migrations permanently unrun after a background cold-start, causing the app to behave as a fresh install and prompt for Nightscout/Dexcom setup. Co-Authored-By: Claude Sonnet 4.6 * Use didBecomeActive (not willEnterForeground) for deferred migration recovery willEnterForegroundNotification fires while applicationState may still be .background, causing the BFU guard in runMigrationsIfNeeded() to skip migrations a second time. didBecomeActiveNotification guarantees applicationState == .active, so the guard always passes. Adds a dedicated appDidBecomeActive() handler that only calls runMigrationsIfNeeded(). Since that function is idempotent (each step checks migrationStep.value < N), calling it on every activation after migrations have already completed is a fast no-op. Co-Authored-By: Claude Sonnet 4.6 * Remove BGAppRefreshTask completely BGAppRefreshTask caused iOS to cold-launch the app in the background after a reboot. In Before-First-Unlock state, UserDefaults is encrypted and all reads return defaults, causing migrations to re-run and wipe settings (Nightscout URL, etc.). Multiple fix attempts could not reliably guard against this without risking the UI never initialising. Removed entirely: - BackgroundRefreshManager.swift (deleted) - AppDelegate: BackgroundRefreshManager.shared.register() - MainViewController: BackgroundRefreshManager.shared.scheduleRefresh() and all migration-guard code added to work around the BFU issue - Info.plist: com.loopfollow.audiorefresh BGTaskSchedulerPermittedIdentifier - Info.plist: fetch UIBackgroundMode - project.pbxproj: all four BackgroundRefreshManager.swift references Migrations restored to their original unconditional form in viewDidLoad. Co-Authored-By: Claude Sonnet 4.6 * Revert "Remove BGAppRefreshTask completely" This reverts commit 7e6b19135ba8079314683cc6f2b6d64707375b45. * Guard migrateStep1 core fields against BFU empty reads The four primary fields (url, device, nsWriteAuth, nsAdminAuth) were unconditionally copied from the App Group suite to UserDefaults.standard with no .exists check — unlike every other field in the same function. When the app launches in the background (remote-notification mode) while the device is in Before-First-Unlock state, the App Group UserDefaults file is encrypted and unreadable. object(forKey:) returns nil, .exists returns false, and .value returns the default ("" / false). Without the guard, "" was written to url in Standard UserDefaults and flushed to disk on first unlock, wiping the Nightscout URL. Adding .exists checks matches the pattern used by all helper migrations in the same function. A fresh install correctly skips (nothing to migrate). An existing user correctly copies (old key still present in App Group since migrateStep1 never removes it). BFU state correctly skips (App Group unreadable, Standard value preserved). Co-Authored-By: Claude Sonnet 4.6 * Fix reboot settings wipe: reload StorageValues on foreground after BFU launch BGAppRefreshTask cold-launches the app while the device is locked (BFU), causing StorageValue to cache empty defaults from encrypted UserDefaults. The scene connects during that background launch, so viewDidLoad does not run again when the user foregrounds — leaving url="" in the @Published cache and the setup screen showing despite correct data on disk. Fix: add StorageValue.reload() (re-reads disk, fires @Published only if changed) and call it for url/shareUserName/sharePassword at the top of appCameToForeground(), correcting the stale cache the first time the user opens the app after a reboot. Co-Authored-By: Claude Sonnet 4.6 * Reload all Nightscout credentials on foreground, not just url/share fields token, sharedSecret, nsWriteAuth, nsAdminAuth would all be stale after a BFU background launch — Nightscout API calls would fail or use wrong auth even if the setup screen was correctly dismissed by the url reload. Co-Authored-By: Claude Sonnet 4.6 * Gate BFU reload behind isProtectedDataAvailable flag; reload all StorageValues Instead of calling individual reloads on every foreground (noisy, unnecessary disk reads, cascade of observers on normal launches), capture whether protected data was unavailable at launch time. On the first foreground after a BFU launch, call Storage.reloadAll() — which reloads every StorageValue, firing @Published only where the cached value actually changed. Normal foregrounds are unaffected. Co-Authored-By: Claude Sonnet 4.6 * Add BFU diagnostic logs to AppDelegate and appCameToForeground Co-Authored-By: Claude Sonnet 4.6 * Reschedule all tasks after BFU reload to fix blank charts on first foreground During BFU viewDidLoad, all tasks fire with url="" and reschedule 60s out. checkTasksNow() on first foreground finds nothing overdue. Fix: call scheduleAllTasks() after reloadAll() so tasks reset to their normal 2-5s initial delay, displacing the stale 60s BFU schedule. Co-Authored-By: Claude Sonnet 4.6 * Show loading overlay during BFU data reload instead of blank charts After BFU reloadAll(), viewDidLoad left isInitialLoad=false and no overlay. Reset loading state and show the overlay so the user sees the same spinner they see on a normal cold launch, rather than blank charts for 2-5 seconds. The overlay auto-hides via the normal markLoaded() path when data arrives. Co-Authored-By: Claude Sonnet 4.6 * Redesign CarPlay SmallFamilyView to match Loop's LA layout Two-column layout: BG + trend arrow + delta/unit on the left (colored by glucose threshold), projected BG + unit label on the right in white. Dynamic Island and lock screen views are unchanged. Co-Authored-By: Claude Sonnet 4.6 * Fix CarPlay: bypass activityFamily detection in supplemental widget LoopFollowLiveActivityWidgetWithCarPlay is declared with .supplementalActivityFamilies([.small]) so it is only ever rendered in .small contexts (CarPlay, Watch Smart Stack). Use SmallFamilyView directly instead of routing through LockScreenFamilyAdaptiveView, which was falling through to LockScreenLiveActivityView when activityFamily wasn't detected as .small. Co-Authored-By: Claude Sonnet 4.6 * Fix Watch/CarPlay: register only one widget per iOS version band Two ActivityConfiguration widgets for the same attributes type were registered simultaneously. The system used the primary widget for all contexts, ignoring the supplemental one. On iOS 18+: register only LoopFollowLiveActivityWidgetWithCarPlay (with .supplementalActivityFamilies([.small]) and family-adaptive routing via LockScreenFamilyAdaptiveView for all contexts). On iOS <18: register only LoopFollowLiveActivityWidget (lock screen and Dynamic Island only). Co-Authored-By: Claude Sonnet 4.6 * Fix build error and harden Watch/CarPlay routing - Revert bundle to if #available without else (WidgetBundleBuilder does not support if/else with #available) - Make primary widget also use LockScreenFamilyAdaptiveView on iOS 18+ so SmallFamilyView renders correctly regardless of which widget the system selects for .small contexts (CarPlay / Watch Smart Stack) Co-Authored-By: Claude Sonnet 4.6 * Watch & CarPlay widget * Update LoopFollowLiveActivity.swift * Update LoopFollowLiveActivity.swift * Update LoopFollowLABundle.swift * Update LoopFollowLABundle.swift * Update LoopFollowLABundle.swift * Update LoopFollowLiveActivity.swift * Update LoopFollowLABundle.swift * Update LoopFollowLiveActivity.swift * Update LoopFollowLiveActivity.swift * Update LoopFollowLiveActivity.swift * Remove docs/ directory from PR scope The LiveActivity.md doc file should not be part of this PR. https://claude.ai/code/session_01WaUhT8PoPNKumX9ZK9jeBy --------- Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Jonas Björkert --- LoopFollow/Application/AppDelegate.swift | 12 +- LoopFollow/Application/SceneDelegate.swift | 2 +- .../Controllers/BackgroundAlertManager.swift | 25 ++- .../Helpers/BackgroundRefreshManager.swift | 2 +- LoopFollow/Info.plist | 4 +- LoopFollow/LiveActivity/AppGroupID.swift | 26 ++- .../LiveActivity/LAAppGroupSettings.swift | 17 ++ .../LiveActivity/LiveActivityManager.swift | 12 +- .../RestartLiveActivityIntent.swift | 2 +- .../StorageCurrentGlucoseStateProvider.swift | 3 +- .../Storage/Framework/StorageValue.swift | 12 ++ LoopFollow/Storage/Storage+Migrate.swift | 35 +++- LoopFollow/Storage/Storage.swift | 185 ++++++++++++++++++ .../ViewControllers/MainViewController.swift | 114 +++++++---- .../LoopFollowLABundle.swift | 8 +- .../LoopFollowLiveActivity.swift | 177 +++++++++-------- docs/LiveActivity.md | 165 ---------------- 17 files changed, 481 insertions(+), 320 deletions(-) delete mode 100644 docs/LiveActivity.md diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index d79de7d18..a6fd9f2b9 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -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 @@ -47,6 +47,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } 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 } @@ -107,7 +115,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // Note: with scene-based lifecycle (iOS 13+), URLs are delivered to // SceneDelegate.scene(_:openURLContexts:) — not here. The scene delegate - // handles loopfollow://la-tap for Live Activity tap navigation. + // handles ://la-tap for Live Activity tap navigation. // MARK: UISceneSession Lifecycle diff --git a/LoopFollow/Application/SceneDelegate.swift b/LoopFollow/Application/SceneDelegate.swift index 3819a7ac6..e702db267 100644 --- a/LoopFollow/Application/SceneDelegate.swift +++ b/LoopFollow/Application/SceneDelegate.swift @@ -35,7 +35,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } func scene(_: UIScene, openURLContexts URLContexts: Set) { - guard URLContexts.contains(where: { $0.url.scheme == "loopfollow" && $0.url.host == "la-tap" }) else { return } + 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. diff --git a/LoopFollow/Controllers/BackgroundAlertManager.swift b/LoopFollow/Controllers/BackgroundAlertManager.swift index d8a80b8f1..0ba3664b1 100644 --- a/LoopFollow/Controllers/BackgroundAlertManager.swift +++ b/LoopFollow/Controllers/BackgroundAlertManager.swift @@ -11,11 +11,24 @@ enum BackgroundAlertDuration: TimeInterval, CaseIterable { case eighteenMinutes = 1080 // 18 minutes in seconds } -/// Enum representing unique identifiers for each background alert. -enum BackgroundAlertIdentifier: String, CaseIterable { - case sixMin = "loopfollow.background.alert.6min" - case twelveMin = "loopfollow.background.alert.12min" - case eighteenMin = "loopfollow.background.alert.18min" +/// Unique identifiers for each background alert, scoped to the current bundle +/// so multiple LoopFollow instances don't interfere with each other's notifications. +enum BackgroundAlertIdentifier: CaseIterable { + case sixMin + case twelveMin + case eighteenMin + + private static let prefix = Bundle.main.bundleIdentifier ?? "loopfollow" + + var rawValue: String { + switch self { + case .sixMin: "\(Self.prefix).background.alert.6min" + case .twelveMin: "\(Self.prefix).background.alert.12min" + case .eighteenMin: "\(Self.prefix).background.alert.18min" + } + } + + static let categoryIdentifier = "\(prefix).background.alert" } class BackgroundAlertManager { @@ -118,7 +131,7 @@ class BackgroundAlertManager { content.title = title content.body = body content.sound = .defaultCritical - content.categoryIdentifier = "loopfollow.background.alert" + content.categoryIdentifier = BackgroundAlertIdentifier.categoryIdentifier return content } diff --git a/LoopFollow/Helpers/BackgroundRefreshManager.swift b/LoopFollow/Helpers/BackgroundRefreshManager.swift index bac7e1c8e..a1168174d 100644 --- a/LoopFollow/Helpers/BackgroundRefreshManager.swift +++ b/LoopFollow/Helpers/BackgroundRefreshManager.swift @@ -8,7 +8,7 @@ class BackgroundRefreshManager { static let shared = BackgroundRefreshManager() private init() {} - private let taskIdentifier = "com.loopfollow.audiorefresh" + private let taskIdentifier = "\(Bundle.main.bundleIdentifier ?? "com.loopfollow").audiorefresh" func register() { BGTaskScheduler.shared.register(forTaskWithIdentifier: taskIdentifier, using: nil) { task in diff --git a/LoopFollow/Info.plist b/LoopFollow/Info.plist index 5cc7f4146..9e0f99340 100644 --- a/LoopFollow/Info.plist +++ b/LoopFollow/Info.plist @@ -7,7 +7,7 @@ BGTaskSchedulerPermittedIdentifiers com.$(unique_id).LoopFollow$(app_suffix) - com.loopfollow.audiorefresh + com.$(unique_id).LoopFollow$(app_suffix).audiorefresh CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) @@ -34,7 +34,7 @@ CFBundleURLSchemes - loopfollow + loopfollow$(app_suffix) diff --git a/LoopFollow/LiveActivity/AppGroupID.swift b/LoopFollow/LiveActivity/AppGroupID.swift index 6fc2bb9a6..5eb1187b8 100644 --- a/LoopFollow/LiveActivity/AppGroupID.swift +++ b/LoopFollow/LiveActivity/AppGroupID.swift @@ -20,19 +20,33 @@ enum AppGroupID { /// to force a shared base bundle id (recommended for reliability). private static let baseBundleIDPlistKey = "LFAppGroupBaseBundleID" - static func current() -> String { + /// 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 "group.\(base)" + return base } - let bundleID = Bundle.main.bundleIdentifier ?? "unknown" + return stripLikelyExtensionSuffixes(from: bundleID) + } - // Heuristic: strip common extension suffixes so the extension can land on the main app’s group id. - let base = 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" + } - return "group.\(base)" + static func current() -> String { + "group.\(baseBundleID)" } private static func stripLikelyExtensionSuffixes(from bundleID: String) -> String { diff --git a/LoopFollow/LiveActivity/LAAppGroupSettings.swift b/LoopFollow/LiveActivity/LAAppGroupSettings.swift index 8fedeb155..b61487f27 100644 --- a/LoopFollow/LiveActivity/LAAppGroupSettings.swift +++ b/LoopFollow/LiveActivity/LAAppGroupSettings.swift @@ -135,6 +135,8 @@ enum LAAppGroupSettings { static let lowLineMgdl = "la.lowLine.mgdl" static let highLineMgdl = "la.highLine.mgdl" static let slots = "la.slots" + static let displayName = "la.displayName" + static let showDisplayName = "la.showDisplayName" } private static var defaults: UserDefaults? { @@ -176,4 +178,19 @@ enum LAAppGroupSettings { } return raw.map { LiveActivitySlotOption(rawValue: $0) ?? .none } } + + // MARK: - Display Name + + static func setDisplayName(_ name: String, show: Bool) { + defaults?.set(name, forKey: Keys.displayName) + defaults?.set(show, forKey: Keys.showDisplayName) + } + + static func displayName() -> String { + defaults?.string(forKey: Keys.displayName) ?? "LoopFollow" + } + + static func showDisplayName() -> Bool { + defaults?.bool(forKey: Keys.showDisplayName) ?? false + } } diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 9faa8a41e..746e5609d 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -310,6 +310,10 @@ final class LiveActivityManager { 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() @@ -322,7 +326,7 @@ final class LiveActivityManager { self?.performRefresh(reason: reason) } refreshWorkItem = workItem - DispatchQueue.main.asyncAfter(deadline: .now() + 5.0, execute: workItem) + DispatchQueue.main.asyncAfter(deadline: .now() + 20.0, execute: workItem) } // MARK: - Renewal @@ -557,6 +561,8 @@ final class LiveActivityManager { // 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" @@ -564,7 +570,7 @@ final class LiveActivityManager { content.sound = .default let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false) let request = UNNotificationRequest( - identifier: "loopfollow.la.renewal.failed", + identifier: LiveActivityManager.renewalNotificationID, content: content, trigger: trigger, ) @@ -577,7 +583,7 @@ final class LiveActivityManager { } private func cancelRenewalFailedNotification() { - let id = "loopfollow.la.renewal.failed" + let id = LiveActivityManager.renewalNotificationID UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [id]) UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [id]) } diff --git a/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift b/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift index cb1f84d18..00740e10e 100644 --- a/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift +++ b/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift @@ -15,7 +15,7 @@ struct RestartLiveActivityIntent: AppIntent { let apnsKey = Storage.shared.lfApnsKey.value if keyId.isEmpty || apnsKey.isEmpty { - if let url = URL(string: "loopfollow://settings/live-activity") { + 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.") diff --git a/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift b/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift index 90e74f5b8..b1a416b97 100644 --- a/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift +++ b/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift @@ -1,5 +1,5 @@ +// LoopFollow // StorageCurrentGlucoseStateProvider.swift -// 2026-03-21 import Foundation @@ -7,7 +7,6 @@ import Foundation /// 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? { diff --git a/LoopFollow/Storage/Framework/StorageValue.swift b/LoopFollow/Storage/Framework/StorageValue.swift index f27f49b17..86faa34da 100644 --- a/LoopFollow/Storage/Framework/StorageValue.swift +++ b/LoopFollow/Storage/Framework/StorageValue.swift @@ -40,4 +40,16 @@ class StorageValue: ObservableObject { func remove() { StorageValue.defaults.removeObject(forKey: key) } + + /// Re-reads the value from UserDefaults, updating the @Published cache. + /// Call this when the app foregrounds after a Before-First-Unlock background launch, + /// where StorageValue was initialized while UserDefaults was locked (returning defaults). + func reload() { + if let data = StorageValue.defaults.data(forKey: key), + let decodedValue = try? JSONDecoder().decode(T.self, from: data), + decodedValue != value + { + value = decodedValue + } + } } diff --git a/LoopFollow/Storage/Storage+Migrate.swift b/LoopFollow/Storage/Storage+Migrate.swift index aa0868543..70cd30dc1 100644 --- a/LoopFollow/Storage/Storage+Migrate.swift +++ b/LoopFollow/Storage/Storage+Migrate.swift @@ -2,6 +2,7 @@ // Storage+Migrate.swift import Foundation +import UserNotifications extension Storage { func migrateStep5() { @@ -32,6 +33,21 @@ 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") @@ -125,10 +141,21 @@ extension Storage { } func migrateStep1() { - Storage.shared.url.value = ObservableUserDefaults.shared.old_url.value - Storage.shared.device.value = ObservableUserDefaults.shared.old_device.value - Storage.shared.nsWriteAuth.value = ObservableUserDefaults.shared.old_nsWriteAuth.value - Storage.shared.nsAdminAuth.value = ObservableUserDefaults.shared.old_nsAdminAuth.value + // Guard each field with .exists so that if the App Group suite is unreadable + // (e.g. Before-First-Unlock state after a reboot), we skip the write rather + // than overwriting the already-migrated Standard value with an empty default. + if ObservableUserDefaults.shared.old_url.exists { + Storage.shared.url.value = ObservableUserDefaults.shared.old_url.value + } + if ObservableUserDefaults.shared.old_device.exists { + Storage.shared.device.value = ObservableUserDefaults.shared.old_device.value + } + if ObservableUserDefaults.shared.old_nsWriteAuth.exists { + Storage.shared.nsWriteAuth.value = ObservableUserDefaults.shared.old_nsWriteAuth.value + } + if ObservableUserDefaults.shared.old_nsAdminAuth.exists { + Storage.shared.nsAdminAuth.value = ObservableUserDefaults.shared.old_nsAdminAuth.value + } // Helper: 1-to-1 type ----------------------------------------------------------------- func move( diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index efb55b031..6e24b3788 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -216,6 +216,191 @@ 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() + + 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 diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index bbf2de63c..1a3b7c03d 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -142,40 +142,12 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele loadDebugData() - // Capture before migrations run: true for existing users, false for fresh installs. - let isExistingUser = Storage.shared.migrationStep.exists - - if Storage.shared.migrationStep.value < 1 { - Storage.shared.migrateStep1() - Storage.shared.migrationStep.value = 1 - } - - if Storage.shared.migrationStep.value < 2 { - Storage.shared.migrateStep2() - Storage.shared.migrationStep.value = 2 - } - - if Storage.shared.migrationStep.value < 3 { - Storage.shared.migrateStep3() - Storage.shared.migrationStep.value = 3 - } - - // TODO: This migration step can be deleted in March 2027. Check the commit for other places to cleanup. - 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 - } - - if Storage.shared.migrationStep.value < 5 { - Storage.shared.migrateStep5() - Storage.shared.migrationStep.value = 5 - } + // Migrations run in foreground only — see runMigrationsIfNeeded() for details. + runMigrationsIfNeeded() - 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 } // Synchronize info types to ensure arrays are the correct size @@ -211,6 +183,10 @@ 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 @@ -972,7 +948,79 @@ 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 + + if Storage.shared.migrationStep.value < 1 { + Storage.shared.migrateStep1() + Storage.shared.migrationStep.value = 1 + } + if Storage.shared.migrationStep.value < 2 { + Storage.shared.migrateStep2() + Storage.shared.migrationStep.value = 2 + } + if Storage.shared.migrationStep.value < 3 { + Storage.shared.migrateStep3() + Storage.shared.migrationStep.value = 3 + } + // TODO: This migration step can be deleted in March 2027. Check the commit for other places to cleanup. + 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 + } + 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 + } + } + + @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 diff --git a/LoopFollowLAExtension/LoopFollowLABundle.swift b/LoopFollowLAExtension/LoopFollowLABundle.swift index d98475b8e..fa75e44e4 100644 --- a/LoopFollowLAExtension/LoopFollowLABundle.swift +++ b/LoopFollowLAExtension/LoopFollowLABundle.swift @@ -1,6 +1,3 @@ -// LoopFollow -// LoopFollowLABundle.swift - import SwiftUI import WidgetKit @@ -8,8 +5,5 @@ import WidgetKit struct LoopFollowLABundle: WidgetBundle { var body: some Widget { LoopFollowLiveActivityWidget() - if #available(iOS 18.0, *) { - LoopFollowLiveActivityWidgetWithCarPlay() - } } -} +} \ No newline at end of file diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 5c28eed3b..30f4e4589 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -5,25 +5,25 @@ import ActivityKit import SwiftUI import WidgetKit -/// Builds the shared Dynamic Island content used by both widget variants. +/// Builds the shared Dynamic Island content used by the Live Activity widget. private func makeDynamicIsland(context: ActivityViewContext) -> DynamicIsland { DynamicIsland { DynamicIslandExpandedRegion(.leading) { - Link(destination: URL(string: "loopfollow://la-tap")!) { + 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: "loopfollow://la-tap")!) { + 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: "loopfollow://la-tap")!) { + Link(destination: URL(string: "\(AppGroupID.urlScheme)://la-tap")!) { DynamicIslandBottomView(snapshot: context.state.snapshot) .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay, showText: true)) } @@ -42,34 +42,37 @@ private func makeDynamicIsland(context: ActivityViewContext some View { if #available(iOS 17.0, *) { - // Use the generic SwiftUI API available in iOS 17+ (no placement enum) contentMargins(Edge.Set.all, 0) } else { self @@ -90,60 +92,72 @@ private extension View { // 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: compact glucose-only view -/// - everything else → full lock screen layout with configurable grid +/// - `.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) var activityFamily + @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) - .activitySystemActionForegroundColor(.white) .activityBackgroundTint(LAColors.backgroundTint(for: state.snapshot)) - .applyActivityContentMarginsFixIfAvailable() - .widgetURL(URL(string: "loopfollow://la-tap")!) } } } // MARK: - Small family view (CarPlay Dashboard + Watch Smart Stack) - -/// Compact view shown on CarPlay Dashboard (iOS 26+) and Apple Watch Smart Stack (watchOS 11+). -/// Hardcoded to glucose + trend arrow + delta + time since last reading. @available(iOS 18.0, *) private struct SmallFamilyView: View { let snapshot: GlucoseSnapshot + private var unitLabel: String { + switch snapshot.unit { + case .mgdl: return "mg/dL" + case .mmol: return "mmol/L" + } + } + var body: some View { - VStack(alignment: .leading, spacing: 4) { - HStack(alignment: .firstTextBaseline, spacing: 6) { - Text(LAFormat.glucose(snapshot)) - .font(.system(size: 28, weight: .bold, design: .rounded)) - .monospacedDigit() - .foregroundStyle(.white) - Text(LAFormat.trendArrow(snapshot)) - .font(.system(size: 22, weight: .semibold, design: .rounded)) - .foregroundStyle(.white.opacity(0.9)) - } - HStack(spacing: 8) { - Text(LAFormat.delta(snapshot)) + 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)) \(unitLabel)") .font(.system(size: 14, weight: .semibold, design: .rounded)) .monospacedDigit() .foregroundStyle(.white.opacity(0.85)) - Text(LAFormat.updated(snapshot)) - .font(.system(size: 14, weight: .regular, design: .rounded)) + } + + Spacer() + + VStack(alignment: .trailing, spacing: 2) { + Text(LAFormat.projected(snapshot)) + .font(.system(size: 28, weight: .bold, design: .rounded)) .monospacedDigit() + .foregroundStyle(.white) + + Text(unitLabel) + .font(.system(size: 14, weight: .regular, design: .rounded)) .foregroundStyle(.white.opacity(0.65)) } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) .padding(10) - .activityBackgroundTint(LAColors.backgroundTint(for: snapshot)) } } @@ -158,7 +172,6 @@ private struct LockScreenLiveActivityView: View { VStack(spacing: 6) { HStack(spacing: 12) { - // LEFT: Glucose + trend arrow, delta below VStack(alignment: .leading, spacing: 4) { HStack(alignment: .firstTextBaseline, spacing: 4) { Text(LAFormat.glucose(s)) @@ -186,13 +199,11 @@ private struct LockScreenLiveActivityView: View { .frame(minWidth: 168, maxWidth: 190, alignment: .leading) .layoutPriority(2) - // Divider Rectangle() .fill(Color.white.opacity(0.20)) .frame(width: 1) .padding(.vertical, 8) - // RIGHT: configurable 2×2 grid VStack(spacing: 8) { HStack(spacing: 12) { SlotView(option: slotConfig[0], snapshot: s) @@ -206,8 +217,9 @@ private struct LockScreenLiveActivityView: View { .frame(maxWidth: .infinity, alignment: .trailing) } - // Footer: last update time - Text("Last Update: \(LAFormat.updated(s))") + 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)) @@ -219,7 +231,7 @@ private struct LockScreenLiveActivityView: View { .padding(.bottom, 8) .overlay( RoundedRectangle(cornerRadius: 16, style: .continuous) - .stroke(Color.white.opacity(0.20), lineWidth: 1), + .stroke(Color.white.opacity(0.20), lineWidth: 1) ) .overlay( Group { @@ -227,23 +239,25 @@ private struct LockScreenLiveActivityView: View { 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), + .opacity(state.snapshot.showRenewalOverlay ? 1 : 0) ) } } @@ -284,19 +298,16 @@ private struct MetricBlock: View { .lineLimit(1) .minimumScaleFactor(0.85) } - .frame(width: 60, alignment: .leading) // slightly tighter columns to free space for glucose + .frame(width: 60, alignment: .leading) } } -/// Renders one configurable slot in the lock screen 2×2 grid. -/// Shows nothing (invisible placeholder) when the slot option is `.none`. private struct SlotView: View { let option: LiveActivitySlotOption let snapshot: GlucoseSnapshot var body: some View { if option == .none { - // Invisible spacer — preserves grid alignment Color.clear .frame(width: 60, height: 36) } else { @@ -336,6 +347,7 @@ private struct SlotView: View { private struct DynamicIslandLeadingView: View { let snapshot: GlucoseSnapshot + var body: some View { if snapshot.isNotLooping { Text("⚠️ Not Looping") @@ -350,14 +362,17 @@ private struct DynamicIslandLeadingView: View { .font(.system(size: 28, weight: .bold, design: .rounded)) .monospacedDigit() .foregroundStyle(.white) + HStack(spacing: 5) { Text(LAFormat.trendArrow(snapshot)) .font(.system(size: 13, weight: .semibold, design: .rounded)) .foregroundStyle(.white.opacity(0.9)) + Text(LAFormat.delta(snapshot)) .font(.system(size: 13, weight: .semibold, design: .rounded)) .monospacedDigit() .foregroundStyle(.white.opacity(0.9)) + Text("Proj: \(LAFormat.projected(snapshot))") .font(.system(size: 13, weight: .semibold, design: .rounded)) .monospacedDigit() @@ -370,6 +385,7 @@ private struct DynamicIslandLeadingView: View { private struct DynamicIslandTrailingView: View { let snapshot: GlucoseSnapshot + var body: some View { if snapshot.isNotLooping { EmptyView() @@ -379,6 +395,7 @@ private struct DynamicIslandTrailingView: View { .font(.system(size: 13, weight: .bold, design: .rounded)) .monospacedDigit() .foregroundStyle(.white.opacity(0.95)) + Text("COB \(LAFormat.cob(snapshot))") .font(.system(size: 13, weight: .bold, design: .rounded)) .monospacedDigit() @@ -391,6 +408,7 @@ private struct DynamicIslandTrailingView: View { private struct DynamicIslandBottomView: View { let snapshot: GlucoseSnapshot + var body: some View { if snapshot.isNotLooping { Text("Loop has not reported in 15+ minutes") @@ -410,6 +428,7 @@ private struct DynamicIslandBottomView: View { private struct DynamicIslandCompactTrailingView: View { let snapshot: GlucoseSnapshot + var body: some View { if snapshot.isNotLooping { Text("Not Looping") @@ -428,6 +447,7 @@ private struct DynamicIslandCompactTrailingView: View { private struct DynamicIslandCompactLeadingView: View { let snapshot: GlucoseSnapshot + var body: some View { if snapshot.isNotLooping { Text("⚠️") @@ -443,6 +463,7 @@ private struct DynamicIslandCompactLeadingView: View { private struct DynamicIslandMinimalView: View { let snapshot: GlucoseSnapshot + var body: some View { if snapshot.isNotLooping { Text("⚠️") @@ -459,8 +480,6 @@ private struct DynamicIslandMinimalView: View { // MARK: - Formatting private enum LAFormat { - // MARK: - NumberFormatters (locale-aware) - private static let mgdlFormatter: NumberFormatter = { let nf = NumberFormatter() nf.numberStyle = .decimal @@ -488,8 +507,6 @@ private enum LAFormat { } } - // MARK: Glucose - static func glucose(_ s: GlucoseSnapshot) -> String { formatGlucoseValue(s.glucose, unit: s.unit) } @@ -500,7 +517,6 @@ private enum LAFormat { 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 @@ -510,8 +526,6 @@ private enum LAFormat { } } - // MARK: Trend - static func trendArrow(_ s: GlucoseSnapshot) -> String { switch s.trend { case .upFast: "↑↑" @@ -525,8 +539,6 @@ private enum LAFormat { } } - // MARK: Secondary - static func iob(_ s: GlucoseSnapshot) -> String { guard let v = s.iob else { return "—" } return String(format: "%.1f", v) @@ -542,8 +554,6 @@ private enum LAFormat { return formatGlucoseValue(v, unit: s.unit) } - // MARK: Extended InfoType formatters - private static let ageFormatter: DateComponentsFormatter = { let f = DateComponentsFormatter() f.unitsStyle = .positional @@ -552,7 +562,6 @@ private enum LAFormat { return f }() - /// Formats an insert-time epoch into "D:HH" age string. Returns "—" if time is 0. static func age(insertTime: TimeInterval) -> String { guard insertTime > 0 else { return "—" } let secondsAgo = Date().timeIntervalSince1970 - insertTime @@ -630,13 +639,11 @@ private enum LAFormat { s.profileName ?? "—" } - // MARK: Update time - private static let hhmmFormatter: DateFormatter = { let df = DateFormatter() df.locale = .current df.timeZone = .current - df.dateFormat = "HH:mm" // 24h format + df.dateFormat = "HH:mm" return df }() @@ -657,12 +664,11 @@ private enum LAFormat { } } -// MARK: - Threshold-driven colors (Option A, App Group-backed) +// 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 @@ -671,12 +677,10 @@ private enum LAColors { 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) } @@ -684,7 +688,6 @@ private enum LAColors { static func keyline(for snapshot: GlucoseSnapshot) -> Color { let mgdl = snapshot.glucose - let t = LAAppGroupSettings.thresholdsMgdl() let low = t.low let high = t.high @@ -697,4 +700,4 @@ private enum LAColors { return Color(uiColor: UIColor.systemGreen) } } -} +} \ No newline at end of file diff --git a/docs/LiveActivity.md b/docs/LiveActivity.md deleted file mode 100644 index 979213a96..000000000 --- a/docs/LiveActivity.md +++ /dev/null @@ -1,165 +0,0 @@ -# LoopFollow Live Activity — Architecture & Design Decisions - -**Author:** Philippe Achkar (supported by Claude) -**Date:** 2026-03-07 - ---- - -## What Is the Live Activity? - -The Live Activity displays real-time glucose data on the iPhone lock screen and in the Dynamic Island. It shows: - -- Current glucose value (mg/dL or mmol/L) -- Trend arrow and delta -- IOB, COB, and projected glucose (when available) -- Threshold-driven background color (red (low) / green (in-range) / orange (high)) with user-set thresholds -- A "Not Looping" overlay when Loop has not reported in 15+ minutes - -It updates every 5 minutes, driven by LoopFollow's existing refresh engine. No separate data pipeline exists — the Live Activity is a rendering surface only. - ---- - -## Core Principles - -### 1. Single Source of Truth - -The Live Activity never fetches data directly from Nightscout or Dexcom. It reads exclusively from LoopFollow's internal storage layer (`Storage.shared`, `Observable.shared`). All glucose values, thresholds, IOB, COB, and loop status flow through the same path as the rest of the app. - -This means: -- No duplicated business logic -- No risk of the Live Activity showing different data than the app -- The architecture is reusable for Apple Watch and CarPlay in future phases - -### 2. Source-Agnostic Design - -LoopFollow supports both Nightscout and Dexcom. IOB, COB, or predicted glucose are modeled as optional (`Double?`) in `GlucoseSnapshot` and the UI renders a dash (—) when they are absent. The Live Activity never assumes these values exist. - -### 3. No Hardcoded Identifiers - -The App Group ID is derived dynamically at runtime: group.. No team-specific bundle IDs or App Group IDs are hardcoded anywhere. This ensures the project is safe to fork, clone, and submit as a pull request by any contributor. - ---- - -## Update Architecture — Why APNs Self-Push? - -This is the most important architectural decision in Phase 1. Understanding it will help you maintain and extend this feature correctly. - -### What We Tried First — Direct ``activity.update()`` - -The obvious approach to updating a Live Activity is to call ``activity.update()`` directly from the app. This works reliably when the app is in the foreground. - -The problem appears when the app is in the background. LoopFollow uses a background audio session (`.playback` category, silent WAV file) to stay alive in the background and continue fetching glucose data. We discovered that _liveactivitiesd_ (the iOS system daemon responsible for rendering Live Activities) refuses to process ``activity.update()`` calls from processes that hold an active background audio session. The update call either hangs indefinitely or is silently dropped. The Live Activity freezes on the lock screen while the app continues running normally. - -We attempted several workarounds; none of these approaches were reliable or production-safe: -- Call ``activity.update()`` while audio is playing | Updates hang or are dropped -- Pause the audio player before updating | Insufficient — iOS checks the process-level audio assertion, not just the player state -- Call `AVAudioSession.setActive(false)` before updating | Intermittently worked, but introduced a race condition and broke the audio session unpredictably -- Add a fixed 3-second wait after deactivation | Fragile, caused background task timeout warnings, and still failed intermittently - -### The Solution — APNs Self-Push - -Our solution is for LoopFollow to send an APNs (Apple Push Notification service) push notification to itself. - -Here is how it works: - -1. When a Live Activity is started, ActivityKit provides a **push token** — a unique identifier for that specific Live Activity instance. -2. LoopFollow captures this token via `activity.pushTokenUpdates`. -3. After each BG refresh, LoopFollow generates a signed JWT using its APNs authentication key and posts an HTTP/2 request directly to Apple's APNs servers. -4. Apple's APNs infrastructure delivers the push to `liveactivitiesd` on the device. -5. `liveactivitiesd` updates the Live Activity directly — the app process is **never involved in the rendering path**. - -Because `liveactivitiesd` receives the update via APNs rather than via an inter-process call from LoopFollow, it does not care that LoopFollow holds a background audio session. The update is processed reliably every time. - -### Why This Is Safe and Appropriate - -- This is an officially supported ActivityKit feature. Apple documents push-token-based Live Activity updates as the **recommended** update mechanism. -- The push is sent from the app itself, to itself. No external server or provider infrastructure is required. -- The APNs authentication key is injected at build time via xcconfig and Info.plist. It is never stored in the repository. -- The JWT is generated on-device using CryptoKit (`P256.Signing`) and cached for 55 minutes (APNs tokens are valid for 60 minutes). - ---- - -## File Map - -### Main App Target - -| File | Responsibility | -|---|---| -| `LiveActivityManager.swift` | Orchestration — start, update, end, bind, observe lifecycle | -| `GlucoseSnapshotBuilder.swift` | Pure data transformation — builds `GlucoseSnapshot` from storage | -| `StorageCurrentGlucoseStateProvider.swift` | Thin abstraction over `Storage.shared` and `Observable.shared` | -| `GlucoseSnapshotStore.swift` | App Group persistence — saves/loads latest snapshot | -| `PreferredGlucoseUnit.swift` | Reads user unit preference, converts mg/dL ↔ mmol/L | -| `APNSClient.swift` | Sends APNs self-push with Live Activity content state | -| `APNSJWTGenerator.swift` | Generates ES256-signed JWT for APNs authentication | - -### Shared (App + Extension) - -| File | Responsibility | -|---|---| -| `GlucoseLiveActivityAttributes.swift` | ActivityKit attributes and content state definition | -| `GlucoseSnapshot.swift` | Canonical cross-platform glucose data struct | -| `GlucoseConversion.swift` | Single source of truth for mg/dL ↔ mmol/L conversion | -| `LAAppGroupSettings.swift` | App Group UserDefaults access | -| `AppGroupID.swift` | Derives App Group ID dynamically from bundle identifier | - -### Extension Target - -| File | Responsibility | -|---|---| -| `LoopFollowLiveActivity.swift` | SwiftUI rendering — lock screen card and Dynamic Island | -| `LoopFollowLABundle.swift` | WidgetBundle entry point | - ---- - -## Update Flow - -``` -LoopFollow BG refresh completes - → Storage.shared updated (glucose, delta, trend, IOB, COB, projected) - → Observable.shared updated (isNotLooping) - → BGData calls LiveActivityManager.refreshFromCurrentState(reason: "bg") - → GlucoseSnapshotBuilder.build() reads from StorageCurrentGlucoseStateProvider - → GlucoseSnapshot constructed (unit-converted, threshold-classified) - → GlucoseSnapshotStore persists snapshot to App Group - → activity.update(content) called (direct update for foreground reliability) - → APNSClient.sendLiveActivityUpdate() sends self-push via APNs - → liveactivitiesd receives push - → Lock screen re-renders -``` - ---- - -## APNs Setup — Required for Contributors - -To build and run the Live Activity locally or via CI, you need an APNs authentication key. The key content is injected at build time via `LoopFollowConfigOverride.xcconfig` and is **never stored in the repository**. - -### What you need - -- An Apple Developer account -- An APNs Auth Key (`.p8` file) with the **Apple Push Notifications service (APNs)** capability enabled -- The 10-character Key ID associated with that key - -### Local Build Setup - -1. Generate or download your `.p8` key from [developer.apple.com](https://developer.apple.com) → Certificates, Identifiers & Profiles → Keys. -2. Open the key file in a text editor. Copy the base64 content between the header and footer lines — **exclude** `-----BEGIN PRIVATE KEY-----` and `-----END PRIVATE KEY-----`. Join all lines into a single unbroken string with no spaces or line breaks. -3. Create or edit `LoopFollowConfigOverride.xcconfig` in the project root (this file is gitignored): - -``` -APNS_KEY_ID = -APNS_KEY_CONTENT = -``` - -4. Build and run. The key is read at runtime from `Info.plist` which resolves `$(APNS_KEY_CONTENT)` from the xcconfig. - -### CI / GitHub Actions Setup - -Add two repository secrets under **Settings → Secrets and variables → Actions**: - -| Secret Name | Value | -|---|---| -| `APNS_KEY_ID` | Your 10-character key ID | -| `APNS_KEY` | Full contents of your `.p8` file including PEM headers | - -The build workflow strips the PEM headers automatically and injects the content into `LoopFollowConfigOverride.xcconfig` before building. From 2576dcacecd82eb1bc196de778a1b8e32e7c0392 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Tue, 24 Mar 2026 17:13:14 +0100 Subject: [PATCH 77/86] Linting --- LoopFollowLAExtension/LoopFollowLABundle.swift | 5 ++++- LoopFollowLAExtension/LoopFollowLiveActivity.swift | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/LoopFollowLAExtension/LoopFollowLABundle.swift b/LoopFollowLAExtension/LoopFollowLABundle.swift index fa75e44e4..1dc9d75ed 100644 --- a/LoopFollowLAExtension/LoopFollowLABundle.swift +++ b/LoopFollowLAExtension/LoopFollowLABundle.swift @@ -1,3 +1,6 @@ +// LoopFollow +// LoopFollowLABundle.swift + import SwiftUI import WidgetKit @@ -6,4 +9,4 @@ struct LoopFollowLABundle: WidgetBundle { var body: some Widget { LoopFollowLiveActivityWidget() } -} \ No newline at end of file +} diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 30f4e4589..f388dfbf9 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -112,6 +112,7 @@ private struct LockScreenFamilyAdaptiveView: View { } // MARK: - Small family view (CarPlay Dashboard + Watch Smart Stack) + @available(iOS 18.0, *) private struct SmallFamilyView: View { let snapshot: GlucoseSnapshot @@ -700,4 +701,4 @@ private enum LAColors { return Color(uiColor: UIColor.systemGreen) } } -} \ No newline at end of file +} From 2a5c1eff74fe0e2dda1ba81b76461d55d51cc65d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Tue, 24 Mar 2026 19:58:39 +0100 Subject: [PATCH 78/86] Fix JWT cache thread-safety to prevent TooManyProviderTokenUpdates Add NSLock to JWTManager to prevent concurrent cache corruption when Live Activity pushes and remote commands race on different threads. Invalidate JWT cache on 403 in all APNs clients. Add logging for JWT generation and cache invalidation. --- LoopFollow/Helpers/JWTManager.swift | 8 ++++++++ LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift | 1 + LoopFollow/Remote/TRC/PushNotificationManager.swift | 1 + 3 files changed, 10 insertions(+) diff --git a/LoopFollow/Helpers/JWTManager.swift b/LoopFollow/Helpers/JWTManager.swift index 06f4a5583..3e847b999 100644 --- a/LoopFollow/Helpers/JWTManager.swift +++ b/LoopFollow/Helpers/JWTManager.swift @@ -15,12 +15,16 @@ class JWTManager { /// 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? { let cacheKey = "\(keyId):\(teamId)" + lock.lock() + defer { lock.unlock() } + if let cached = cache[cacheKey], Date() < cached.expiresAt { return cached.jwt } @@ -41,6 +45,7 @@ class JWTManager { 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)") @@ -49,7 +54,10 @@ class JWTManager { } func invalidateCache() { + lock.lock() + defer { lock.unlock() } cache.removeAll() + LogManager.shared.log(category: .apns, message: "JWT cache invalidated") } // MARK: - Private Helpers diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift index 61aaa6ef4..feca46e0d 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift @@ -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) diff --git a/LoopFollow/Remote/TRC/PushNotificationManager.swift b/LoopFollow/Remote/TRC/PushNotificationManager.swift index e0c70d746..04c9977c2 100644 --- a/LoopFollow/Remote/TRC/PushNotificationManager.swift +++ b/LoopFollow/Remote/TRC/PushNotificationManager.swift @@ -295,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)") From 6792ff6677508d1d115587cc4f1cdc9b60561995 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Wed, 25 Mar 2026 19:22:42 +0100 Subject: [PATCH 79/86] Bug fixes and SmallFamilyView configurable slot (#575) - Make SmallFamilyView right slot configurable via Live Activity settings - Add Unit.displayName to GlucoseSnapshot for consistent unit labelling - Use ViewThatFits for adaptive CarPlay vs Watch Smart Stack layout - Fix APNs push token lost after renewal-overlay foreground restart - Fix Not Looping overlay not showing when app is backgrounded - Rename Live Activity settings section headers --- .../Controllers/Nightscout/DeviceStatus.swift | 1 + LoopFollow/LiveActivity/GlucoseSnapshot.swift | 8 ++ .../LiveActivity/LAAppGroupSettings.swift | 28 ++++ .../LiveActivity/LiveActivityManager.swift | 12 ++ .../StorageCurrentGlucoseStateProvider.swift | 4 +- LoopFollow/LiveActivitySettingsView.swift | 18 ++- LoopFollow/Storage/Storage.swift | 2 + .../LoopFollowLABundle.swift | 5 +- .../LoopFollowLiveActivity.swift | 129 ++++++++++++------ 9 files changed, 157 insertions(+), 50 deletions(-) diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift index ae3967b3e..6c6cac03f 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift @@ -127,6 +127,7 @@ 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 { diff --git a/LoopFollow/LiveActivity/GlucoseSnapshot.swift b/LoopFollow/LiveActivity/GlucoseSnapshot.swift index 8860391c2..c5f5fffcc 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshot.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshot.swift @@ -12,6 +12,14 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { 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 diff --git a/LoopFollow/LiveActivity/LAAppGroupSettings.swift b/LoopFollow/LiveActivity/LAAppGroupSettings.swift index b61487f27..a39d88772 100644 --- a/LoopFollow/LiveActivity/LAAppGroupSettings.swift +++ b/LoopFollow/LiveActivity/LAAppGroupSettings.swift @@ -97,6 +97,15 @@ enum LiveActivitySlotOption: String, CaseIterable, Codable { } } + /// 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 { @@ -118,6 +127,8 @@ enum LiveActivitySlotDefaults { 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] @@ -135,6 +146,7 @@ enum LAAppGroupSettings { 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" } @@ -179,6 +191,22 @@ enum LAAppGroupSettings { 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) { diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 746e5609d..5b8f13bbf 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -87,6 +87,10 @@ final class LiveActivityManager { @objc private func handleDidBecomeActive() { guard Storage.shared.laEnabled.value else { return } + if skipNextDidBecomeActive { + skipNextDidBecomeActive = false + return + } Task { @MainActor in self.startFromCurrentState() } @@ -107,6 +111,11 @@ final class LiveActivityManager { // We cannot call startIfNeeded() here: it finds the existing activity in // Activity.activities and reuses it rather than replacing it. LogManager.shared.log(category: .general, message: "[LA] ending stale LA and restarting (renewalFailed=\(renewalFailed), overlayShowing=\(overlayIsShowing))") + // Suppress the handleDidBecomeActive() call that always fires after willEnterForeground. + // Without this, the two methods race: didBecomeActive binds to the old (dying) activity + // and observes its push token, while handleForeground's async end+restart creates a new + // activity — leaving pushToken nil when the new activity tries to start. + skipNextDidBecomeActive = true // Clear state synchronously so any snapshot built between now and when the // new LA is started computes showRenewalOverlay = false. Storage.shared.laRenewBy.value = 0 @@ -167,6 +176,9 @@ final class LiveActivityManager { /// In-memory only — resets to false on app relaunch, so a kill + relaunch /// starts fresh as expected. private var dismissedByUser = 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 diff --git a/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift b/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift index b1a416b97..38bde98d0 100644 --- a/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift +++ b/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift @@ -123,7 +123,9 @@ struct StorageCurrentGlucoseStateProvider: CurrentGlucoseStateProviding { // MARK: - Loop Status var isNotLooping: Bool { - Observable.shared.isNotLooping.value + 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 diff --git a/LoopFollow/LiveActivitySettingsView.swift b/LoopFollow/LiveActivitySettingsView.swift index efe4ec321..2264881bb 100644 --- a/LoopFollow/LiveActivitySettingsView.swift +++ b/LoopFollow/LiveActivitySettingsView.swift @@ -7,6 +7,7 @@ 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"] @@ -29,7 +30,7 @@ struct LiveActivitySettingsView: View { } } - Section(header: Text("Grid slots")) { + Section(header: Text("Grid Slots - Live Activity")) { ForEach(0 ..< 4, id: \.self) { index in Picker(slotLabels[index], selection: Binding( get: { slots[index] }, @@ -41,6 +42,21 @@ struct LiveActivitySettingsView: View { } } } + + 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 } diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 6e24b3788..51c6acdc6 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -194,6 +194,7 @@ class Storage { var lastLoopingChecked = StorageValue(key: "lastLoopingChecked", defaultValue: nil) var lastBGChecked = StorageValue(key: "lastBGChecked", defaultValue: nil) + var lastLoopTime = StorageValue(key: "lastLoopTime", defaultValue: 0) // Tab positions - which position each item is in (positions 1-4 are customizable, 5 is always Menu) var homePosition = StorageValue(key: "homePosition", defaultValue: .position1) @@ -385,6 +386,7 @@ class Storage { lastLoopingChecked.reload() lastBGChecked.reload() + lastLoopTime.reload() homePosition.reload() alarmsPosition.reload() diff --git a/LoopFollowLAExtension/LoopFollowLABundle.swift b/LoopFollowLAExtension/LoopFollowLABundle.swift index 1dc9d75ed..fa75e44e4 100644 --- a/LoopFollowLAExtension/LoopFollowLABundle.swift +++ b/LoopFollowLAExtension/LoopFollowLABundle.swift @@ -1,6 +1,3 @@ -// LoopFollow -// LoopFollowLABundle.swift - import SwiftUI import WidgetKit @@ -9,4 +6,4 @@ struct LoopFollowLABundle: WidgetBundle { var body: some Widget { LoopFollowLiveActivityWidget() } -} +} \ No newline at end of file diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index f388dfbf9..2c8d66d13 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -117,14 +117,18 @@ private struct LockScreenFamilyAdaptiveView: View { private struct SmallFamilyView: View { let snapshot: GlucoseSnapshot - private var unitLabel: String { - switch snapshot.unit { - case .mgdl: return "mg/dL" - case .mmol: return "mmol/L" - } + /// 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) { @@ -138,23 +142,60 @@ private struct SmallFamilyView: View { .foregroundStyle(LAColors.keyline(for: snapshot)) } - Text("\(LAFormat.delta(snapshot)) \(unitLabel)") + Text("\(LAFormat.delta(snapshot)) \(snapshot.unit.displayName)") .font(.system(size: 14, weight: .semibold, design: .rounded)) .monospacedDigit() .foregroundStyle(.white.opacity(0.85)) } + .layoutPriority(1) Spacer() - VStack(alignment: .trailing, spacing: 2) { - Text(LAFormat.projected(snapshot)) - .font(.system(size: 28, weight: .bold, design: .rounded)) - .monospacedDigit() - .foregroundStyle(.white) - - Text(unitLabel) - .font(.system(size: 14, weight: .regular, design: .rounded)) - .foregroundStyle(.white.opacity(0.65)) + 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) @@ -303,6 +344,33 @@ private struct MetricBlock: View { } } +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 @@ -312,34 +380,7 @@ private struct SlotView: View { Color.clear .frame(width: 60, height: 36) } else { - MetricBlock(label: option.gridLabel, value: value(for: option)) - } - } - - private func value(for option: LiveActivitySlotOption) -> 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) + MetricBlock(label: option.gridLabel, value: slotFormattedValue(option: option, snapshot: snapshot)) } } } @@ -701,4 +742,4 @@ private enum LAColors { return Color(uiColor: UIColor.systemGreen) } } -} +} \ No newline at end of file From 47a9a97070649e82136e6e62c92f1e490ed9a796 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Wed, 25 Mar 2026 19:26:00 +0100 Subject: [PATCH 80/86] Linting --- LoopFollow/LiveActivity/LAAppGroupSettings.swift | 1 - LoopFollowLAExtension/LoopFollowLABundle.swift | 5 ++++- LoopFollowLAExtension/LoopFollowLiveActivity.swift | 3 +-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/LoopFollow/LiveActivity/LAAppGroupSettings.swift b/LoopFollow/LiveActivity/LAAppGroupSettings.swift index a39d88772..6359fe55e 100644 --- a/LoopFollow/LiveActivity/LAAppGroupSettings.swift +++ b/LoopFollow/LiveActivity/LAAppGroupSettings.swift @@ -206,7 +206,6 @@ enum LAAppGroupSettings { return LiveActivitySlotOption(rawValue: raw) ?? LiveActivitySlotDefaults.smallWidgetSlot } - // MARK: - Display Name static func setDisplayName(_ name: String, show: Bool) { diff --git a/LoopFollowLAExtension/LoopFollowLABundle.swift b/LoopFollowLAExtension/LoopFollowLABundle.swift index fa75e44e4..1dc9d75ed 100644 --- a/LoopFollowLAExtension/LoopFollowLABundle.swift +++ b/LoopFollowLAExtension/LoopFollowLABundle.swift @@ -1,3 +1,6 @@ +// LoopFollow +// LoopFollowLABundle.swift + import SwiftUI import WidgetKit @@ -6,4 +9,4 @@ struct LoopFollowLABundle: WidgetBundle { var body: some Widget { LoopFollowLiveActivityWidget() } -} \ No newline at end of file +} diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 2c8d66d13..061c828b8 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -128,7 +128,6 @@ private struct SmallFamilyView: View { var body: some View { let rightSlot = LAAppGroupSettings.smallWidgetSlot() - HStack(alignment: .center, spacing: 0) { VStack(alignment: .leading, spacing: 2) { HStack(alignment: .firstTextBaseline, spacing: 4) { @@ -742,4 +741,4 @@ private enum LAColors { return Color(uiColor: UIColor.systemGreen) } } -} \ No newline at end of file +} From b390eaec98c54ebf2720fdb6e2759af8e58594a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Wed, 25 Mar 2026 22:08:41 +0100 Subject: [PATCH 81/86] Added the projected value for Trio --- LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift index 20827c253..150e5da4f 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift @@ -117,6 +117,9 @@ extension MainViewController { if let eventualBGValue = enactedOrSuggested["eventualBG"] as? Double { let eventualBGQuantity = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: eventualBGValue) PredictionLabel.text = Localizer.formatQuantity(eventualBGQuantity) + Storage.shared.projectedBgMgdl.value = eventualBGValue + } else { + Storage.shared.projectedBgMgdl.value = nil } // Target @@ -243,7 +246,6 @@ extension MainViewController { // Live Activity storage Storage.shared.lastIOB.value = latestIOB?.value Storage.shared.lastCOB.value = latestCOB?.value - Storage.shared.projectedBgMgdl.value = nil } } } From 6f98facc3413690171279797e6adb44eed674b06 Mon Sep 17 00:00:00 2001 From: Phil A <76601115+MtlPhil@users.noreply.github.com> Date: Thu, 26 Mar 2026 08:18:47 -0400 Subject: [PATCH 82/86] Fix LA not refreshing on foreground after stale overlay (#576) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit startIfNeeded() unconditionally reused any existing activity, which meant that on cold start (app killed while stale overlay was showing) willEnterForeground is never sent, handleForeground never runs, and viewDidAppear → startFromCurrentState → startIfNeeded just rebinds to the stale activity — leaving the overlay visible. Fix: before reusing an existing activity in startIfNeeded(), check whether its staleDate has passed or the renewal window is open. If so, end it (awaited) and call startIfNeeded() again so a fresh activity with a new 7.5h deadline is started. Also add cancelRenewalFailedNotification() to handleForeground() so the "Live Activity Expiring" system notification is dismissed whenever the foreground restart path fires, not only via forceRestart(). Co-authored-by: Claude Sonnet 4.6 --- .../LiveActivity/LiveActivityManager.swift | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 5b8f13bbf..9d62140fd 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -120,6 +120,7 @@ final class LiveActivityManager { // new LA is started computes showRenewalOverlay = false. Storage.shared.laRenewBy.value = 0 Storage.shared.laRenewalFailed.value = false + cancelRenewalFailedNotification() guard let activity = current else { startFromCurrentState() @@ -189,6 +190,28 @@ final class LiveActivityManager { } if let existing = Activity.activities.first { + // Before reusing, check whether this activity needs a restart. This covers cold + // starts (app was killed while the overlay was showing — willEnterForeground is + // never sent, so handleForeground never runs) and any other path that lands here + // without first going through handleForeground. + let renewBy = Storage.shared.laRenewBy.value + let now = Date().timeIntervalSince1970 + let staleDatePassed = existing.content.staleDate.map { $0 <= Date() } ?? false + let inRenewalWindow = renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning + let needsRestart = Storage.shared.laRenewalFailed.value || inRenewalWindow || staleDatePassed + + if needsRestart { + LogManager.shared.log(category: .general, message: "[LA] existing activity is stale on startIfNeeded — ending and restarting (staleDatePassed=\(staleDatePassed), inRenewalWindow=\(inRenewalWindow))") + Storage.shared.laRenewBy.value = 0 + Storage.shared.laRenewalFailed.value = false + cancelRenewalFailedNotification() + Task { + await existing.end(nil, dismissalPolicy: .immediate) + await MainActor.run { self.startIfNeeded() } + } + return + } + bind(to: existing, logReason: "reuse") Storage.shared.laRenewalFailed.value = false return From 6de1ae00a90336e060ee0f70a89ee42538102031 Mon Sep 17 00:00:00 2001 From: Phil A <76601115+MtlPhil@users.noreply.github.com> Date: Thu, 26 Mar 2026 14:06:53 -0400 Subject: [PATCH 83/86] Fix stale LA dismissed by iOS incorrectly blocking auto-restart (#577) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix LA not refreshing on foreground after stale overlay startIfNeeded() unconditionally reused any existing activity, which meant that on cold start (app killed while stale overlay was showing) willEnterForeground is never sent, handleForeground never runs, and viewDidAppear → startFromCurrentState → startIfNeeded just rebinds to the stale activity — leaving the overlay visible. Fix: before reusing an existing activity in startIfNeeded(), check whether its staleDate has passed or the renewal window is open. If so, end it (awaited) and call startIfNeeded() again so a fresh activity with a new 7.5h deadline is started. Also add cancelRenewalFailedNotification() to handleForeground() so the "Live Activity Expiring" system notification is dismissed whenever the foreground restart path fires, not only via forceRestart(). Co-Authored-By: Claude Sonnet 4.6 * Fix stale LA dismissed by iOS incorrectly blocking auto-restart When iOS dismisses a Live Activity because its staleDate passed (background stale overlay case), laRenewalFailed is false, so the state observer's else branch fired and set dismissedByUser=true — permanently blocking all auto-restart paths (startFromCurrentState has guard !dismissedByUser). Fix 1: attachStateObserver now checks staleDatePassed alongside laRenewalFailed; both are iOS-initiated dismissals that should allow auto-restart. Fix 2: handleForeground() Task resets dismissedByUser=false before calling startFromCurrentState(), guarding against the race where the state observer fires .dismissed during our own end() call before its Task cancellation takes effect. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- .../LiveActivity/LiveActivityManager.swift | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 9d62140fd..abf9c6518 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -142,6 +142,10 @@ final class LiveActivityManager { // writing a new laRenewBy deadline. await activity.end(nil, dismissalPolicy: .immediate) await MainActor.run { + // Reset dismissedByUser in case the state observer fired .dismissed during + // our own end() call (before its Task cancellation took effect) and + // incorrectly set it to true — startFromCurrentState guards on this flag. + self.dismissedByUser = false // startFromCurrentState rebuilds the snapshot (showRenewalOverlay = false // since laRenewBy is 0), saves it to the store, then calls startIfNeeded() // which finds no existing activity and requests a fresh LA with a new deadline. @@ -635,10 +639,14 @@ final class LiveActivityManager { LogManager.shared.log(category: .general, message: "Live Activity cleared id=\(activity.id)", isDebug: true) } if state == .dismissed { - if Storage.shared.laRenewalFailed.value { - // iOS force-dismissed after 8-hour limit with a failed renewal. - // Allow auto-restart when the user opens the app. - LogManager.shared.log(category: .general, message: "Live Activity dismissed by iOS after expiry — auto-restart enabled") + // Distinguish system-initiated dismissal from a user swipe. + // iOS dismisses the activity when (a) the renewal limit was reached + // with a failed renewal, or (b) the staleDate passed and iOS decided + // to remove the activity. In both cases auto-restart is appropriate. + // Only a true user swipe (activity still fresh) should block restart. + let staleDatePassed = activity.content.staleDate.map { $0 <= Date() } ?? false + if Storage.shared.laRenewalFailed.value || staleDatePassed { + LogManager.shared.log(category: .general, message: "Live Activity dismissed by iOS (renewalFailed=\(Storage.shared.laRenewalFailed.value), staleDatePassed=\(staleDatePassed)) — auto-restart enabled") } else { // User manually swiped away the LA. Block auto-restart until // the user explicitly restarts via button or App Intent. From 3d62d8e8e975fa7c92569df7172592b61a0dcbee Mon Sep 17 00:00:00 2001 From: Phil A <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 28 Mar 2026 17:29:16 -0400 Subject: [PATCH 84/86] Fix stale overlay tap + redesign expanded Dynamic Island (#581) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add separate Watch and CarPlay toggles to Live Activity settings Use GeometryReader in LockScreenFamilyAdaptiveView to distinguish Watch Smart Stack (height ≤ 75 pt) from CarPlay Dashboard (height > 75 pt) at render time — both surfaces share ActivityFamily.small with no API to tell them apart, so canvas height is the only runtime signal. Adds la.watchEnabled and la.carPlayEnabled App Group keys (default true). The Right Slot picker hides when both are disabled. Co-Authored-By: Claude Sonnet 4.6 * Fix Watch/CarPlay toggle detection: use width, not height; use Color.black Two bugs with the height-based threshold: 1. System padding pushes Watch Smart Stack canvas above 75 pt, causing both Watch and CarPlay to be classified as CarPlay (blank on both when CarPlay toggle is off). 2. Color.clear is transparent — cached Watch renders show through it, leaving stale data visible after the toggle is turned off. Fix: switch to width-based detection. Watch Ultra 2 (widest model) is ~183 pt; CarPlay is always at least ~250 pt. A 210 pt threshold gives a ~14% buffer above the max Watch width. Replace Color.clear with Color.black so old frames are fully covered when the widget is disabled. Co-Authored-By: Claude Sonnet 4.6 * Fix stale overlay tap: endingForRestart flag prevents dismissedByUser race Root cause: handleForeground() clears laRenewalFailed=false synchronously before calling activity.end(). When the state observer fires .dismissed, renewalFailed is already false and staleDatePassed may also be false, so it falls into the user-swipe branch and sets dismissedByUser=true. Fix 4 (dismissedByUser=false in the Task) was meant to override this, but the state observer's MainActor write can be queued *after* the Task's reset write, winning the race and leaving dismissedByUser=true. The result: LA stops after tapping the overlay and never restarts. Add endingForRestart flag set synchronously (on the MainActor) before end() is called. The state observer checks it first — before renewalFailed or staleDatePassed — so any .dismissed delivery triggered by our own end() call is never misclassified as a user swipe, regardless of MainActor queue order. Co-Authored-By: Claude Sonnet 4.6 * Use forceRestart on Watch/CarPlay toggle instead of refreshFromCurrentState The LA must be ended and recreated for Watch/CarPlay content changes to take effect immediately. refreshFromCurrentState only sends a content update to the existing activity; forceRestart ends the activity and starts a fresh one, so the widget extension re-evaluates and the black/clear tile appears (or disappears) without APNs latency. Note: true per-surface dismissal (tile fully gone from Watch OR CarPlay while the other remains) requires splitting into two LAs and is a future architectural change. Co-Authored-By: Claude Sonnet 4.6 * Remove Watch/CarPlay toggles; redesign expanded Dynamic Island Watch/CarPlay toggles removed: disfavoredLocations API requires iOS 26 and GeometryReader-based detection is unreliable. Reverts to always showing SmallFamilyView on .small activity family. Expanded Dynamic Island redesigned to match SmallFamilyView layout: - Leading: glucose + trend arrow (colour-keyed, firstTextBaseline), delta below - Trailing: configurable slot (same smallWidgetSlot setting as CarPlay/Watch) with label + value, replaces hardcoded IOB/COB - Bottom: unchanged — "Updated at" or "Not Looping" banner Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- .../LiveActivity/LiveActivityManager.swift | 29 ++++++---- .../LoopFollowLiveActivity.swift | 54 +++++++++---------- 2 files changed, 45 insertions(+), 38 deletions(-) diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index abf9c6518..ca581a7a5 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -136,15 +136,18 @@ final class LiveActivityManager { stateObserverTask = nil pushToken = nil + // Signal the state observer that the upcoming .dismissed event is our own + // end() call, not a user swipe. This must be set synchronously before end() + // is awaited so the observer sees it regardless of MainActor scheduling order. + endingForRestart = true + Task { // Await end so the activity is removed from Activity.activities before // startIfNeeded() runs — otherwise it hits the reuse path and skips // writing a new laRenewBy deadline. await activity.end(nil, dismissalPolicy: .immediate) await MainActor.run { - // Reset dismissedByUser in case the state observer fired .dismissed during - // our own end() call (before its Task cancellation took effect) and - // incorrectly set it to true — startFromCurrentState guards on this flag. + self.endingForRestart = false self.dismissedByUser = false // startFromCurrentState rebuilds the snapshot (showRenewalOverlay = false // since laRenewBy is 0), saves it to the store, then calls startIfNeeded() @@ -181,6 +184,11 @@ final class LiveActivityManager { /// 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 @@ -640,13 +648,16 @@ final class LiveActivityManager { } if state == .dismissed { // Distinguish system-initiated dismissal from a user swipe. - // iOS dismisses the activity when (a) the renewal limit was reached - // with a failed renewal, or (b) the staleDate passed and iOS decided - // to remove the activity. In both cases auto-restart is appropriate. - // Only a true user swipe (activity still fresh) should block restart. + // (a) endingForRestart: we called end() ourselves as part of a restart + // — must be checked first since handleForeground() clears + // laRenewalFailed before calling end(), so renewalFailed would + // read false even though we initiated the dismissal. + // (b) laRenewalFailed: iOS force-dismissed after 8-hour limit. + // (c) staleDatePassed: iOS removed the activity after staleDate. + // Only a true user swipe (none of the above) should block auto-restart. let staleDatePassed = activity.content.staleDate.map { $0 <= Date() } ?? false - if Storage.shared.laRenewalFailed.value || staleDatePassed { - LogManager.shared.log(category: .general, message: "Live Activity dismissed by iOS (renewalFailed=\(Storage.shared.laRenewalFailed.value), staleDatePassed=\(staleDatePassed)) — auto-restart enabled") + if endingForRestart || Storage.shared.laRenewalFailed.value || staleDatePassed { + LogManager.shared.log(category: .general, message: "Live Activity dismissed by iOS (endingForRestart=\(endingForRestart), renewalFailed=\(Storage.shared.laRenewalFailed.value), staleDatePassed=\(staleDatePassed)) — auto-restart enabled") } else { // User manually swiped away the LA. Block auto-restart until // the user explicitly restarts via button or App Intent. diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 061c828b8..14a513674 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -399,26 +399,19 @@ private struct DynamicIslandLeadingView: View { .minimumScaleFactor(0.7) } else { VStack(alignment: .leading, spacing: 2) { - Text(LAFormat.glucose(snapshot)) - .font(.system(size: 28, weight: .bold, design: .rounded)) - .monospacedDigit() - .foregroundStyle(.white) - - HStack(spacing: 5) { - Text(LAFormat.trendArrow(snapshot)) - .font(.system(size: 13, weight: .semibold, design: .rounded)) - .foregroundStyle(.white.opacity(0.9)) - - Text(LAFormat.delta(snapshot)) - .font(.system(size: 13, weight: .semibold, design: .rounded)) - .monospacedDigit() - .foregroundStyle(.white.opacity(0.9)) - - Text("Proj: \(LAFormat.projected(snapshot))") - .font(.system(size: 13, weight: .semibold, design: .rounded)) + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(LAFormat.glucose(snapshot)) + .font(.system(size: 28, weight: .bold, design: .rounded)) .monospacedDigit() - .foregroundStyle(.white.opacity(0.9)) + .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)) } } } @@ -431,18 +424,21 @@ private struct DynamicIslandTrailingView: View { if snapshot.isNotLooping { EmptyView() } else { - VStack(alignment: .trailing, spacing: 3) { - Text("IOB \(LAFormat.iob(snapshot))") - .font(.system(size: 13, weight: .bold, design: .rounded)) - .monospacedDigit() - .foregroundStyle(.white.opacity(0.95)) - - Text("COB \(LAFormat.cob(snapshot))") - .font(.system(size: 13, weight: .bold, design: .rounded)) - .monospacedDigit() - .foregroundStyle(.white.opacity(0.95)) + 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) } - .padding(.trailing, 6) } } } From b0ef1a05ec98c9b33d130f3f9875713d42b2ec47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 29 Mar 2026 14:25:39 +0200 Subject: [PATCH 85/86] Fix for incorrect JWT token cache invalidation --- .../ViewControllers/MainViewController.swift | 33 +++++++++++++++---- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 1a3b7c03d..c23815d93 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -15,6 +15,14 @@ func IsNightscoutEnabled() -> Bool { return !Storage.shared.url.value.isEmpty } +private struct APNSCredentialSnapshot: Equatable { + let remoteApnsKey: String + let teamId: String? + let remoteKeyId: String + let lfApnsKey: String + let lfKeyId: String +} + class MainViewController: UIViewController, UITableViewDataSource, ChartViewDelegate, UNUserNotificationCenterDelegate, UIScrollViewDelegate { var isPresentedAsModal: Bool = false @@ -372,14 +380,25 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele } .store(in: &cancellables) - Publishers.MergeMany( - Storage.shared.remoteApnsKey.$value.map { _ in () }.eraseToAnyPublisher(), - Storage.shared.teamId.$value.map { _ in () }.eraseToAnyPublisher(), - Storage.shared.remoteKeyId.$value.map { _ in () }.eraseToAnyPublisher(), - Storage.shared.lfApnsKey.$value.map { _ in () }.eraseToAnyPublisher(), - Storage.shared.lfKeyId.$value.map { _ in () }.eraseToAnyPublisher() + Publishers.CombineLatest4( + Storage.shared.remoteApnsKey.$value, + Storage.shared.teamId.$value, + Storage.shared.remoteKeyId.$value, + Storage.shared.lfApnsKey.$value ) - .receive(on: DispatchQueue.main) + .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) From b89d3afed27bb6ed42de73ed67966e2a7cc975f9 Mon Sep 17 00:00:00 2001 From: Phil A <76601115+MtlPhil@users.noreply.github.com> Date: Sun, 29 Mar 2026 14:18:17 -0400 Subject: [PATCH 86/86] Fix stale overlay tap: redesign .dismissed state machine (#585) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three clearly separated .dismissed sources: (a) endingForRestart — our own end() during planned restart, ignore (b) iOS system force-dismiss — renewalFailed OR pastDeadline (now >= laRenewBy) → auto-restart on next foreground, laRenewBy preserved (c) User decision — explicit swipe → dismissedByUser=true, laRenewBy=0 (renewal intent cancelled) Remove staleDatePassed: staleDate expiry fires .ended not .dismissed. Preserve laRenewBy on .ended and system .dismissed so handleForeground() detects the renewal window and restarts on next foreground. Only the user-swipe path clears laRenewBy, preventing handleForeground() from re-entering the renewal path after the user explicitly killed the LA. Fix handleForeground() nil-current path: reaching it means iOS ended the LA while the renewal window was open (laRenewBy still set). A user-swipe would have cleared laRenewBy to 0, so overlayIsShowing would be false and this branch would never be reached — startFromCurrentState() is safe. Set renewalWarning to 30 minutes (overlay appears 30 min before 7.5h deadline). Co-authored-by: Claude Sonnet 4.6 --- .../LiveActivity/LiveActivityManager.swift | 73 ++++++++++++++----- 1 file changed, 53 insertions(+), 20 deletions(-) diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index ca581a7a5..7b32adedc 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -88,9 +88,11 @@ final class LiveActivityManager { @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() } @@ -104,8 +106,11 @@ final class LiveActivityManager { let now = Date().timeIntervalSince1970 let overlayIsShowing = renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning - LogManager.shared.log(category: .general, message: "[LA] foreground notification received, laRenewalFailed=\(renewalFailed), overlayShowing=\(overlayIsShowing)") - guard renewalFailed || overlayIsShowing else { return } + LogManager.shared.log(category: .general, message: "[LA] foreground: renewalFailed=\(renewalFailed), overlayShowing=\(overlayIsShowing), current=\(current?.id ?? "nil"), dismissedByUser=\(dismissedByUser), renewBy=\(renewBy), now=\(now)") + guard renewalFailed || overlayIsShowing else { + LogManager.shared.log(category: .general, message: "[LA] foreground: no action needed (not in renewal window)") + return + } // Overlay is showing or renewal previously failed — end the stale LA and start a fresh one. // We cannot call startIfNeeded() here: it finds the existing activity in @@ -123,6 +128,11 @@ final class LiveActivityManager { cancelRenewalFailedNotification() guard let activity = current else { + // LA was already gone (ended by iOS or user). If the user explicitly swiped, + // laRenewBy was cleared to 0 at that point, so overlayIsShowing would be false + // and we would never reach here. Reaching here means iOS ended it while the + // renewal window was open — restart is correct. + LogManager.shared.log(category: .general, message: "[LA] foreground restart: current=nil (iOS-ended during renewal window), dismissedByUser=\(dismissedByUser), restarting") startFromCurrentState() return } @@ -169,7 +179,7 @@ final class LiveActivityManager { } static let renewalThreshold: TimeInterval = 7.5 * 3600 - static let renewalWarning: TimeInterval = 20 * 60 + static let renewalWarning: TimeInterval = 30 * 60 private(set) var current: Activity? private var stateObserverTask: Task? @@ -643,27 +653,50 @@ final class LiveActivityManager { if state == .ended || state == .dismissed { if current?.id == activity.id { current = nil - Storage.shared.laRenewBy.value = 0 - LogManager.shared.log(category: .general, message: "Live Activity cleared id=\(activity.id)", isDebug: true) + // 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 { - // Distinguish system-initiated dismissal from a user swipe. - // (a) endingForRestart: we called end() ourselves as part of a restart - // — must be checked first since handleForeground() clears - // laRenewalFailed before calling end(), so renewalFailed would - // read false even though we initiated the dismissal. - // (b) laRenewalFailed: iOS force-dismissed after 8-hour limit. - // (c) staleDatePassed: iOS removed the activity after staleDate. - // Only a true user swipe (none of the above) should block auto-restart. - let staleDatePassed = activity.content.staleDate.map { $0 <= Date() } ?? false - if endingForRestart || Storage.shared.laRenewalFailed.value || staleDatePassed { - LogManager.shared.log(category: .general, message: "Live Activity dismissed by iOS (endingForRestart=\(endingForRestart), renewalFailed=\(Storage.shared.laRenewalFailed.value), staleDatePassed=\(staleDatePassed)) — auto-restart enabled") + // 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 { - // User manually swiped away the LA. Block auto-restart until - // the user explicitly restarts via button or App Intent. - // laEnabled is left true — the user's preference is preserved. + // (c) User decision — cancel renewal intent, block auto-restart. dismissedByUser = true - LogManager.shared.log(category: .general, message: "Live Activity dismissed by user — auto-restart blocked until explicit restart") + 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") } } }