Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
768106b
Declare bluetooth-peripheral through Info.plist preprocessing
Nov 2, 2026
48bc6d4
Host SmartwatchManager plugins
Nov 2, 2026
5adc79c
Remote actions from watch plugins
Nov 2, 2026
265952c
Host SmartwatchManager plugins: their strings in the string catalog
Nov 2, 2026
7c2999c
Remote actions from watch plugins: their strings in the string catalog
Nov 2, 2026
f532d3d
Settings: make clear that Add Watch is for watches other than Apple W…
Nov 2, 2026
021f6e4
Watch plugins: Loop's bolus recommendation for carbs about to be added
Nov 5, 2026
73e89c6
Watch plugins: carb absorption from the watch, 5 minutes between requ…
Nov 5, 2026
be08347
Watch plugins: end the mute of alerts from the watch
Nov 5, 2026
e539d38
Settings: say what a third-party smartwatch can do, above its permiss…
Nov 5, 2026
2892872
Watch Permissions: shorter texts, and the bolus limits in full
Nov 5, 2026
917e0d2
Merge LoopKit/Loop next-dev (f161c29e) into smartwatch-manager-review
Nov 5, 2026
16eabfc
Watch Permissions: the daily bolus limit is 3 times the maximum bolus…
Nov 5, 2026
818c02f
Watch boluses: a failed one still counts for the time between watch b…
Nov 6, 2026
8486c5b
Merge next-dev (04585567), the Loop that LoopWorkspace next-dev now pins
Nov 9, 2026
cc2a70d
Merge LoopKit/Loop next-dev (7fdb3b8d) into smartwatch-manager-review
Nov 9, 2026
3ac6514
Watch host: a failed pump bolus isn't reported as delivered; context …
Nov 9, 2026
b83a3da
Watch host: the watch plugin's device manager delegate; the plugin re…
Nov 10, 2026
fb392a2
Watch host: remote actions get Loop's own checks only
Nov 10, 2026
fa16d55
Settings: remove Watch Permissions; the watch plugin has its own
Nov 10, 2026
9f9369e
Tests: a watch plugin's alert is its own and at most time-sensitive
Nov 10, 2026
cc37541
Watch plugins: closed loop only while Loop doses automatically
Nov 10, 2026
cd40da8
Watch host tests: plugin alert lookups and the quieter context logging
Nov 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Loop.xcconfig
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited)
// General [DEFAULT]
LOOP_LOCAL_CACHE_DURATION_DAYS = 90

// Background modes [DEFAULT]
// LOOP_BLUETOOTH_PERIPHERAL adds the bluetooth-peripheral background mode to
// Info.plist, which watch plugins that publish a Bluetooth service need to keep
// the watch updated while Loop is in the background. Set
// INFOPLIST_PREPROCESSOR_DEFINITIONS to nothing to leave it out.
INFOPLIST_PREPROCESS = YES
INFOPLIST_PREPROCESSOR_DEFINITIONS = LOOP_BLUETOOTH_PERIPHERAL=1

// Entitlements [DEFAULT]
LOOP_ENTITLEMENTS = Loop/Loop.entitlements

Expand Down
20 changes: 20 additions & 0 deletions Loop.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,10 @@
439A7944211FE22F0041B75F /* NSUserActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439A7943211FE22F0041B75F /* NSUserActivity.swift */; };
439A7945211FE23A0041B75F /* NSUserActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439A7943211FE22F0041B75F /* NSUserActivity.swift */; };
439BED2A1E76093C00B0AED5 /* CGMManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439BED291E76093C00B0AED5 /* CGMManager.swift */; };
0AB9F65B073904D4F9E5FCB9 /* SmartwatchManagerHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = A35441020C3D0037E9AEB302 /* SmartwatchManagerHost.swift */; };
1ADD1EF77E2083AC7690BD85 /* SmartwatchManagerHost+AlertForwarder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8772495BA2855FC99DDC3EB0 /* SmartwatchManagerHost+AlertForwarder.swift */; };
A018C4AF5E040B0DAC379735 /* SmartwatchManagerHost+RemoteActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EE5E1D046A80EAC16C5514F /* SmartwatchManagerHost+RemoteActions.swift */; };
D23CD09C2E6905F436987257 /* SmartwatchManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3621E1DB280412962D20BE6C /* SmartwatchManager.swift */; };
43A51E1F1EB6D62A000736CC /* CarbAbsorptionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A51E1E1EB6D62A000736CC /* CarbAbsorptionViewController.swift */; };
43A51E211EB6DBDD000736CC /* LoopChartsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A51E201EB6DBDD000736CC /* LoopChartsTableViewController.swift */; };
43A567691C94880B00334FAC /* LoopDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A567681C94880B00334FAC /* LoopDataManager.swift */; };
Expand Down Expand Up @@ -595,6 +599,7 @@
C17DDC9C28AC339E005FBF4C /* PersistedProperty.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1DA986B2843B6F9001D04CC /* PersistedProperty.swift */; };
C17DDC9D28AC33A1005FBF4C /* PersistedProperty.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1DA986B2843B6F9001D04CC /* PersistedProperty.swift */; };
C188599B2AF15E1B0010F21F /* DeviceDataManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C188599A2AF15E1B0010F21F /* DeviceDataManagerTests.swift */; };
22A2B3D7A696722C213DDFCD /* SmartwatchManagerHostTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13E4F9D180117EFDC2A339E1 /* SmartwatchManagerHostTests.swift */; };
C188599E2AF15FAB0010F21F /* AlertMocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = C188599D2AF15FAB0010F21F /* AlertMocks.swift */; };
C18859A02AF1612B0010F21F /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C188599F2AF1612B0010F21F /* PersistenceController.swift */; };
C18859A22AF165130010F21F /* MockPumpManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C18859A12AF165130010F21F /* MockPumpManager.swift */; };
Expand Down Expand Up @@ -1039,6 +1044,10 @@
439A7941211F631C0041B75F /* RootNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootNavigationController.swift; sourceTree = "<group>"; };
439A7943211FE22F0041B75F /* NSUserActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSUserActivity.swift; sourceTree = "<group>"; };
439BED291E76093C00B0AED5 /* CGMManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CGMManager.swift; sourceTree = "<group>"; };
A35441020C3D0037E9AEB302 /* SmartwatchManagerHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmartwatchManagerHost.swift; sourceTree = "<group>"; };
8772495BA2855FC99DDC3EB0 /* SmartwatchManagerHost+AlertForwarder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SmartwatchManagerHost+AlertForwarder.swift"; sourceTree = "<group>"; };
3EE5E1D046A80EAC16C5514F /* SmartwatchManagerHost+RemoteActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SmartwatchManagerHost+RemoteActions.swift"; sourceTree = "<group>"; };
3621E1DB280412962D20BE6C /* SmartwatchManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmartwatchManager.swift; sourceTree = "<group>"; };
43A51E1E1EB6D62A000736CC /* CarbAbsorptionViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbAbsorptionViewController.swift; sourceTree = "<group>"; };
43A51E201EB6DBDD000736CC /* LoopChartsTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoopChartsTableViewController.swift; sourceTree = "<group>"; };
43A567681C94880B00334FAC /* LoopDataManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = LoopDataManager.swift; sourceTree = "<group>"; xcLanguageSpecificationIdentifier = xcode.lang.swift; };
Expand Down Expand Up @@ -1459,6 +1468,7 @@
C17D52072E7F0E18001D2AD2 /* CarbList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbList.swift; sourceTree = "<group>"; };
C1814B85225E507C008D2D8E /* Sequence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sequence.swift; sourceTree = "<group>"; };
C188599A2AF15E1B0010F21F /* DeviceDataManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceDataManagerTests.swift; sourceTree = "<group>"; };
13E4F9D180117EFDC2A339E1 /* SmartwatchManagerHostTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmartwatchManagerHostTests.swift; sourceTree = "<group>"; };
C188599D2AF15FAB0010F21F /* AlertMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertMocks.swift; sourceTree = "<group>"; };
C188599F2AF1612B0010F21F /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = "<group>"; };
C18859A12AF165130010F21F /* MockPumpManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPumpManager.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1824,6 +1834,7 @@
C16575722538AFF6004AE16E /* CGMStalenessMonitorTests.swift */,
A91E4C2224F86F1000BE9213 /* CriticalEventLogExportManagerTests.swift */,
C188599A2AF15E1B0010F21F /* DeviceDataManagerTests.swift */,
13E4F9D180117EFDC2A339E1 /* SmartwatchManagerHostTests.swift */,
C16B983F26B4898800256B05 /* DoseEnactorTests.swift */,
E9C58A7124DB489100487A17 /* LoopDataManagerTests.swift */,
A1B2C3D40000000000000001 /* GlucoseAlertManagerTests.swift */,
Expand Down Expand Up @@ -2287,6 +2298,10 @@
C1F2075B26D6F9B0007AB7EB /* AppExpirationAlerter.swift */,
B4F3D25024AF890C0095CE44 /* BluetoothStateManager.swift */,
439BED291E76093C00B0AED5 /* CGMManager.swift */,
A35441020C3D0037E9AEB302 /* SmartwatchManagerHost.swift */,
8772495BA2855FC99DDC3EB0 /* SmartwatchManagerHost+AlertForwarder.swift */,
3EE5E1D046A80EAC16C5514F /* SmartwatchManagerHost+RemoteActions.swift */,
3621E1DB280412962D20BE6C /* SmartwatchManager.swift */,
C16575702538A36B004AE16E /* CGMStalenessMonitor.swift */,
A977A2F324ACFECF0059C207 /* CriticalEventLogExportManager.swift */,
C148CEE624FD91BD00711B3B /* DeliveryUncertaintyAlertManager.swift */,
Expand Down Expand Up @@ -3821,6 +3836,10 @@
434FF1EE1CF27EEF000DB779 /* UITableViewCell.swift in Sources */,
84DF48C32F6A0AF600BEDB40 /* Image+Crop.swift in Sources */,
439BED2A1E76093C00B0AED5 /* CGMManager.swift in Sources */,
0AB9F65B073904D4F9E5FCB9 /* SmartwatchManagerHost.swift in Sources */,
1ADD1EF77E2083AC7690BD85 /* SmartwatchManagerHost+AlertForwarder.swift in Sources */,
A018C4AF5E040B0DAC379735 /* SmartwatchManagerHost+RemoteActions.swift in Sources */,
D23CD09C2E6905F436987257 /* SmartwatchManager.swift in Sources */,
C16B983E26B4893300256B05 /* DoseEnactor.swift in Sources */,
3ED319962EB65A5C00820BCF /* LiveActivityManagementViewModel.swift in Sources */,
B4001CEE28CBBC82002FB414 /* AlertManagementView.swift in Sources */,
Expand Down Expand Up @@ -4225,6 +4244,7 @@
1DA7A84224476EAD008257F0 /* AlertManagerTests.swift in Sources */,
A91E4C2324F86F1000BE9213 /* CriticalEventLogExportManagerTests.swift in Sources */,
C188599B2AF15E1B0010F21F /* DeviceDataManagerTests.swift in Sources */,
22A2B3D7A696722C213DDFCD /* SmartwatchManagerHostTests.swift in Sources */,
C18859A42AF165330010F21F /* MockCGMManager.swift in Sources */,
E9C58A7324DB4A2700487A17 /* LoopDataManagerTests.swift in Sources */,
A1B2C3D40000000000000002 /* GlucoseAlertManagerTests.swift in Sources */,
Expand Down
3 changes: 3 additions & 0 deletions Loop/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@
<key>UIBackgroundModes</key>
<array>
<string>bluetooth-central</string>
#if LOOP_BLUETOOTH_PERIPHERAL
<string>bluetooth-peripheral</string>
#endif
<string>processing</string>
<string>remote-notification</string>
<string>audio</string>
Expand Down
12 changes: 12 additions & 0 deletions Loop/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -6105,6 +6105,9 @@
}
}
},
"Add Watch" : {
"comment" : "The title of the watch chooser in settings\nTitle text for button to add a watch device"
},
"Adjust Preset Duration" : {

},
Expand Down Expand Up @@ -7932,6 +7935,9 @@
"Apple Health" : {
"comment" : "Apple Health settings button text\nTitle of the Apple Health access screen"
},
"Apple Watch isn't added here. Loop's Apple Watch app is installed from the Watch app on your iPhone." : {
"comment" : "Footer of the watch chooser in settings: Apple Watch is not set up there"
},
"Are you sure you want to delete all history entries?" : {
"comment" : "Action sheet confirmation message for pump history deletion",
"localizations" : {
Expand Down Expand Up @@ -35704,6 +35710,9 @@
}
}
},
"Tap here to set up a watch other than Apple Watch" : {
"comment" : "Descriptive text for button to add a watch device: Apple Watch uses Loop's own watch app"
},
"Tap to Add" : {
"comment" : "The subtitle of the cell displaying an action to add a manually measurement glucose value",
"extractionState" : "stale",
Expand Down Expand Up @@ -40337,6 +40346,9 @@
}
}
},
"Watch" : {
"comment" : "Descriptive text for a watch plugin in Settings → Devices"
},
"What are examples of Critical Alerts and Time Sensitive Notifications?" : {

},
Expand Down
25 changes: 25 additions & 0 deletions Loop/Managers/Alerts/AlertManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,17 @@ public enum AlertUserNotificationUserInfoKey: String {
case alert, alertTimestamp
}

/// A sink that renders Loop's alerts somewhere beyond the phone — such as a
/// watch plugin that buzzes and shows them. Registered with `addAlertForwarder`
/// and called from the same fan-out as the modal and notification schedulers, so
/// it receives the `muted` determination Loop uses for notification sound. That
/// determination covers critical alerts too: Loop's mute delivers them at volume 0.
@MainActor
protocol AlertForwarder: AnyObject {
func alertManager(_ manager: AlertManager, didIssueAlert alert: Alert, muted: Bool)
func alertManager(_ manager: AlertManager, didRetractAlertWithIdentifier identifier: Alert.Identifier)
}

/// Main (singleton-ish) class that is responsible for:
/// - managing the different targets (handlers) that will post alerts
/// - managing the different responders that might acknowledge the alert
Expand All @@ -38,6 +49,9 @@ public final class AlertManager {
private var responders: [String: Weak<AlertResponder>] = [:]
private var soundVendors: [String: Weak<AlertSoundVendor>] = [:]

private struct WeakAlertForwarder { weak var value: AlertForwarder? }
private var alertForwarders: [WeakAlertForwarder] = []

// Defer issuance of new alerts until playback is done
private var deferredAlerts: [Alert] = []
private var deferredRetractions: [Alert.Identifier] = []
Expand Down Expand Up @@ -143,6 +157,11 @@ public final class AlertManager {
soundVendors.removeValue(forKey: managerIdentifier)
}

func addAlertForwarder(_ forwarder: AlertForwarder) {
alertForwarders.removeAll { $0.value == nil || $0.value === forwarder }
alertForwarders.append(WeakAlertForwarder(value: forwarder))
}

// MARK: - Bluetooth alerts

private func onBluetoothPermissionDenied() {
Expand Down Expand Up @@ -446,6 +465,9 @@ extension AlertManager: AlertIssuer {
criticalAlertAudioPlayer.play(soundNamed: inAppSound, boostVolume: false, for: alert.identifier)
}
playCriticalAlertAudioFallbackIfNeeded(for: alert, muted: muted, issuedDate: issuedDate)
for forwarder in alertForwarders.compactMap(\.value) {
forwarder.alertManager(self, didIssueAlert: alert, muted: muted)
}
}

private func inAppSoundThroughSilent(for alert: Alert) -> String? {
Expand All @@ -463,6 +485,9 @@ extension AlertManager: AlertIssuer {
userNotificationAlertScheduler.unscheduleAlert(identifier: identifier)
criticalAlertAlarmScheduler.cancelAlarm(for: identifier)
criticalAlertAudioPlayer.stop(for: identifier)
for forwarder in alertForwarders.compactMap(\.value) {
forwarder.alertManager(self, didRetractAlertWithIdentifier: identifier)
}
}

/// When the system can't deliver a true Critical Alert (no
Expand Down
Loading