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 1/4] =?UTF-8?q?feat:=20Live=20Activity=20=E2=80=94=20Phase?= =?UTF-8?q?=201=20(lock=20screen=20+=20Dynamic=20Island,=20APNs=20self-pus?= =?UTF-8?q?h)?= 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 2/4] 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 3/4] 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 4/4] 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 = (