Skip to content

Commit ea69e65

Browse files
committed
Merge PR loopandlearn#445: Enable APNS feedback for remote commands
2 parents 2e0cdcc + 93b0b0d commit ea69e65

File tree

16 files changed

+409
-123
lines changed

16 files changed

+409
-123
lines changed

LoopFollow.xcodeproj/project.pbxproj

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@
5353
DD2C2E512D3B8B0C006413A5 /* NightscoutSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2C2E502D3B8B0B006413A5 /* NightscoutSettingsViewModel.swift */; };
5454
DD2C2E542D3C37DC006413A5 /* DexcomSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2C2E532D3C37D7006413A5 /* DexcomSettingsViewModel.swift */; };
5555
DD2C2E562D3C3917006413A5 /* DexcomSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2C2E552D3C3913006413A5 /* DexcomSettingsView.swift */; };
56+
DD485F142E454B2600CE8CBF /* SecureMessenger.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD485F132E454B2600CE8CBF /* SecureMessenger.swift */; };
57+
DD485F162E46631000CE8CBF /* CryptoSwift in Frameworks */ = {isa = PBXBuildFile; productRef = DD485F152E46631000CE8CBF /* CryptoSwift */; };
5658
DD4878032C7B297E0048F05C /* StorageValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878022C7B297E0048F05C /* StorageValue.swift */; };
5759
DD4878052C7B2C970048F05C /* Storage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878042C7B2C970048F05C /* Storage.swift */; };
5860
DD4878082C7B30BF0048F05C /* RemoteSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878072C7B30BF0048F05C /* RemoteSettingsView.swift */; };
@@ -439,6 +441,7 @@
439441
DD2C2E502D3B8B0B006413A5 /* NightscoutSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutSettingsViewModel.swift; sourceTree = "<group>"; };
440442
DD2C2E532D3C37D7006413A5 /* DexcomSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DexcomSettingsViewModel.swift; sourceTree = "<group>"; };
441443
DD2C2E552D3C3913006413A5 /* DexcomSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DexcomSettingsView.swift; sourceTree = "<group>"; };
444+
DD485F132E454B2600CE8CBF /* SecureMessenger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureMessenger.swift; sourceTree = "<group>"; };
442445
DD4878022C7B297E0048F05C /* StorageValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageValue.swift; sourceTree = "<group>"; };
443446
DD4878042C7B2C970048F05C /* Storage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storage.swift; sourceTree = "<group>"; };
444447
DD4878072C7B30BF0048F05C /* RemoteSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSettingsView.swift; sourceTree = "<group>"; };
@@ -791,6 +794,7 @@
791794
buildActionMask = 2147483647;
792795
files = (
793796
FCFEEC9E2486E68E00402A7F /* WebKit.framework in Frameworks */,
797+
DD485F162E46631000CE8CBF /* CryptoSwift in Frameworks */,
794798
3F1335F351590E573D8E6962 /* Pods_LoopFollow.framework in Frameworks */,
795799
DD48781C2C7DAF140048F05C /* SwiftJWT in Frameworks */,
796800
);
@@ -923,6 +927,7 @@
923927
DD4878112C7B74F90048F05C /* TRC */ = {
924928
isa = PBXGroup;
925929
children = (
930+
DD485F132E454B2600CE8CBF /* SecureMessenger.swift */,
926931
DD48781F2C7DAF890048F05C /* PushMessage.swift */,
927932
DD4878122C7B750D0048F05C /* TempTargetView.swift */,
928933
DD48781D2C7DAF2F0048F05C /* PushNotificationManager.swift */,
@@ -1575,6 +1580,7 @@
15751580
name = LoopFollow;
15761581
packageProductDependencies = (
15771582
DD48781B2C7DAF140048F05C /* SwiftJWT */,
1583+
DD485F152E46631000CE8CBF /* CryptoSwift */,
15781584
);
15791585
productName = LoopFollow;
15801586
productReference = FC9788142485969B00A7906C /* Loop Follow.app */;
@@ -1611,6 +1617,7 @@
16111617
packageReferences = (
16121618
DD48781A2C7DAF140048F05C /* XCRemoteSwiftPackageReference "Swift-JWT" */,
16131619
654132E82E19F0B800BDBE08 /* XCRemoteSwiftPackageReference "swift-crypto" */,
1620+
DD485F0B2E4547C800CE8CBF /* XCRemoteSwiftPackageReference "CryptoSwift" */,
16141621
);
16151622
productRefGroup = FC9788152485969B00A7906C /* Products */;
16161623
projectDirPath = "";
@@ -2100,6 +2107,7 @@
21002107
FC3CAB022493B6220068A152 /* BackgroundTaskAudio.swift in Sources */,
21012108
DDCC3A582DDC9655006F1C10 /* MissedBolusAlarmEditor.swift in Sources */,
21022109
DDEF50402D479B8A00884336 /* LoopAPNSService.swift in Sources */,
2110+
DD485F142E454B2600CE8CBF /* SecureMessenger.swift in Sources */,
21032111
DDEF50422D479BAA00884336 /* LoopAPNSCarbsView.swift in Sources */,
21042112
DDEF50432D479BBA00884336 /* LoopAPNSBolusView.swift in Sources */,
21052113
DDEF50452D479BDA00884336 /* LoopAPNSRemoteView.swift in Sources */,
@@ -2396,6 +2404,14 @@
23962404
minimumVersion = 3.12.3;
23972405
};
23982406
};
2407+
DD485F0B2E4547C800CE8CBF /* XCRemoteSwiftPackageReference "CryptoSwift" */ = {
2408+
isa = XCRemoteSwiftPackageReference;
2409+
repositoryURL = "https://github.com/krzyzanowskim/CryptoSwift.git";
2410+
requirement = {
2411+
kind = upToNextMajorVersion;
2412+
minimumVersion = 1.9.0;
2413+
};
2414+
};
23992415
DD48781A2C7DAF140048F05C /* XCRemoteSwiftPackageReference "Swift-JWT" */ = {
24002416
isa = XCRemoteSwiftPackageReference;
24012417
repositoryURL = "https://github.com/Kitura/Swift-JWT.git";
@@ -2407,6 +2423,10 @@
24072423
/* End XCRemoteSwiftPackageReference section */
24082424

24092425
/* Begin XCSwiftPackageProductDependency section */
2426+
DD485F152E46631000CE8CBF /* CryptoSwift */ = {
2427+
isa = XCSwiftPackageProductDependency;
2428+
productName = CryptoSwift;
2429+
};
24102430
DD48781B2C7DAF140048F05C /* SwiftJWT */ = {
24112431
isa = XCSwiftPackageProductDependency;
24122432
package = DD48781A2C7DAF140048F05C /* XCRemoteSwiftPackageReference "Swift-JWT" */;

LoopFollow.xcworkspace/xcshareddata/swiftpm/Package.resolved

Lines changed: 10 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

LoopFollow/Application/AppDelegate.swift

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,63 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
3939

4040
_ = BLEManager.shared
4141

42+
// Register for remote notifications
43+
DispatchQueue.main.async {
44+
UIApplication.shared.registerForRemoteNotifications()
45+
}
46+
4247
return true
4348
}
4449

4550
func applicationWillTerminate(_: UIApplication) {}
4651

52+
// MARK: - Remote Notifications
53+
54+
// Called when successfully registered for remote notifications
55+
func application(_: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
56+
let tokenString = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
57+
58+
Observable.shared.loopFollowDeviceToken.value = tokenString
59+
60+
LogManager.shared.log(category: .general, message: "Successfully registered for remote notifications with token: \(tokenString)")
61+
}
62+
63+
// Called when failed to register for remote notifications
64+
func application(_: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
65+
LogManager.shared.log(category: .general, message: "Failed to register for remote notifications: \(error.localizedDescription)")
66+
}
67+
68+
// Called when a remote notification is received
69+
func application(_: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
70+
LogManager.shared.log(category: .general, message: "Received remote notification: \(userInfo)")
71+
72+
// Check if this is a notification from Trio with status update
73+
if let aps = userInfo["aps"] as? [String: Any] {
74+
// Handle visible notification (alert, sound, badge)
75+
if let alert = aps["alert"] as? [String: Any] {
76+
let title = alert["title"] as? String ?? ""
77+
let body = alert["body"] as? String ?? ""
78+
LogManager.shared.log(category: .general, message: "Notification - Title: \(title), Body: \(body)")
79+
}
80+
81+
// Handle silent notification (content-available)
82+
if let contentAvailable = aps["content-available"] as? Int, contentAvailable == 1 {
83+
// This is a silent push, nothing implemented but logging for now
84+
85+
if let commandStatus = userInfo["command_status"] as? String {
86+
LogManager.shared.log(category: .general, message: "Command status: \(commandStatus)")
87+
}
88+
89+
if let commandType = userInfo["command_type"] as? String {
90+
LogManager.shared.log(category: .general, message: "Command type: \(commandType)")
91+
}
92+
}
93+
}
94+
95+
// Call completion handler
96+
completionHandler(.newData)
97+
}
98+
4799
// MARK: UISceneSession Lifecycle
48100

49101
func application(_: UIApplication, willFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
@@ -139,9 +191,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
139191

140192
extension AppDelegate: UNUserNotificationCenterDelegate {
141193
func userNotificationCenter(_: UNUserNotificationCenter,
142-
willPresent _: UNNotification,
194+
willPresent notification: UNNotification,
143195
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void)
144196
{
145-
completionHandler(.alert)
197+
// Log the notification
198+
let userInfo = notification.request.content.userInfo
199+
LogManager.shared.log(category: .general, message: "Will present notification: \(userInfo)")
200+
201+
// Show the notification even when app is in foreground
202+
completionHandler([.banner, .sound, .badge])
146203
}
147204
}

LoopFollow/Helpers/BuildDetails.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ class BuildDetails {
1919
dict = parsed
2020
}
2121

22+
var teamID: String? {
23+
dict["com-LoopFollow-development-team"] as? String
24+
}
25+
2226
var buildDateString: String? {
2327
return dict["com-LoopFollow-build-date"] as? String
2428
}

LoopFollow/Info.plist

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,12 @@
5353
<string>Loop Follow would like to access your calendar to update BG readings</string>
5454
<key>NSCalendarsUsageDescription</key>
5555
<string>Loop Follow would like to access your calendar to save BG readings</string>
56+
<key>NSCameraUsageDescription</key>
57+
<string>Used for scanning QR codes for remote authentication</string>
5658
<key>NSContactsUsageDescription</key>
5759
<string>This app requires access to contacts to update a contact image with real-time blood glucose information.</string>
5860
<key>NSFaceIDUsageDescription</key>
5961
<string>This app requires Face ID for secure authentication.</string>
60-
<key>NSCameraUsageDescription</key>
61-
<string>Used for scanning QR codes for remote authentication</string>
6262
<key>NSHumanReadableCopyright</key>
6363
<string></string>
6464
<key>UIApplicationSceneManifest</key>
@@ -85,6 +85,7 @@
8585
<string>audio</string>
8686
<string>processing</string>
8787
<string>bluetooth-central</string>
88+
<string>remote-notification</string>
8889
</array>
8990
<key>UIFileSharingEnabled</key>
9091
<true/>

LoopFollow/Loop Follow.entitlements

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
33
<plist version="1.0">
44
<dict>
5+
<key>aps-environment</key>
6+
<string>development</string>
7+
<key>com.apple.developer.aps-environment</key>
8+
<string>development</string>
59
<key>com.apple.security.app-sandbox</key>
610
<true/>
711
<key>com.apple.security.device.bluetooth</key>

LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,51 @@ class LoopAPNSService {
4747
}
4848
}
4949

50+
private func createReturnNotificationInfo() -> [String: Any]? {
51+
let loopFollowDeviceToken = Observable.shared.loopFollowDeviceToken.value
52+
guard !loopFollowDeviceToken.isEmpty else { return nil }
53+
54+
// Get LoopFollow's own Team ID from BuildDetails.
55+
guard let loopFollowTeamID = BuildDetails.default.teamID, !loopFollowTeamID.isEmpty else {
56+
LogManager.shared.log(category: .apns, message: "LoopFollow Team ID not found in BuildDetails.plist. Cannot create return notification info.")
57+
return nil
58+
}
59+
60+
// Get the target Loop app's Team ID from storage.
61+
let targetTeamId = storage.teamId.value ?? ""
62+
let teamIdsAreDifferent = loopFollowTeamID != targetTeamId
63+
64+
let keyIdForReturn: String
65+
let apnsKeyForReturn: String
66+
67+
if teamIdsAreDifferent {
68+
// Team IDs differ, use the separate return credentials.
69+
keyIdForReturn = storage.returnKeyId.value
70+
apnsKeyForReturn = storage.returnApnsKey.value
71+
} else {
72+
// Team IDs are the same, use the primary credentials.
73+
keyIdForReturn = storage.keyId.value
74+
apnsKeyForReturn = storage.apnsKey.value
75+
}
76+
77+
// Ensure we have the necessary credentials.
78+
guard !keyIdForReturn.isEmpty, !apnsKeyForReturn.isEmpty else {
79+
LogManager.shared.log(category: .apns, message: "Missing required return APNS credentials. Check Remote Settings.")
80+
return nil
81+
}
82+
83+
let returnInfo: [String: Any] = [
84+
"production_environment": BuildDetails.default.isTestFlightBuild(),
85+
"device_token": loopFollowDeviceToken,
86+
"bundle_id": Bundle.main.bundleIdentifier ?? "",
87+
"team_id": loopFollowTeamID,
88+
"key_id": keyIdForReturn,
89+
"apns_key": apnsKeyForReturn,
90+
]
91+
92+
return returnInfo
93+
}
94+
5095
/// Validates the Loop APNS setup by checking all required fields
5196
/// - Returns: True if setup is valid, false otherwise
5297
func validateSetup() -> Bool {
@@ -87,7 +132,7 @@ class LoopAPNSService {
87132
let carbsAmount = payload.carbsAmount ?? 0.0
88133
let absorptionTime = payload.absorptionTime ?? 3.0
89134
let startTime = payload.consumedDate ?? now
90-
let finalPayload = [
135+
var finalPayload = [
91136
"carbs-entry": carbsAmount,
92137
"absorption-time": absorptionTime,
93138
"otp": String(payload.otp),
@@ -100,6 +145,12 @@ class LoopAPNSService {
100145
"alert": "Remote Carbs Entry: \(String(format: "%.1f", carbsAmount)) grams\nAbsorption Time: \(String(format: "%.1f", absorptionTime)) hours",
101146
] as [String: Any]
102147

148+
/* Let's wait with this until we have an encryption solution for LRC
149+
if let returnInfo = createReturnNotificationInfo() {
150+
finalPayload["return_notification"] = returnInfo
151+
}
152+
*/
153+
103154
// Log the exact carbs amount for debugging precision issues
104155
LogManager.shared.log(category: .apns, message: "Carbs amount - Raw: \(payload.carbsAmount ?? 0.0), Formatted: \(String(format: "%.1f", carbsAmount)), JSON: \(carbsAmount)")
105156
LogManager.shared.log(category: .apns, message: "Absorption time - Raw: \(payload.absorptionTime ?? 3.0), Formatted: \(String(format: "%.1f", absorptionTime)), JSON: \(absorptionTime)")
@@ -138,7 +189,7 @@ class LoopAPNSService {
138189
// Create the complete notification payload (matching Nightscout's exact format)
139190
// Based on Nightscout's loop.js implementation
140191
let bolusAmount = payload.bolusAmount ?? 0.0
141-
let finalPayload = [
192+
var finalPayload = [
142193
"bolus-entry": bolusAmount,
143194
"otp": String(payload.otp),
144195
"remote-address": "LoopFollow",
@@ -149,6 +200,10 @@ class LoopAPNSService {
149200
"alert": "Remote Bolus Entry: \(String(format: "%.2f", bolusAmount)) U",
150201
] as [String: Any]
151202

203+
if let returnInfo = createReturnNotificationInfo() {
204+
finalPayload["return_notification"] = returnInfo
205+
}
206+
152207
// Log the exact bolus amount for debugging precision issues
153208
LogManager.shared.log(category: .apns, message: "Bolus amount - Raw: \(payload.bolusAmount ?? 0.0), Formatted: \(String(format: "%.2f", bolusAmount)), JSON: \(bolusAmount)")
154209

@@ -504,6 +559,10 @@ class LoopAPNSService {
504559
payload["override-duration-minutes"] = Int(duration / 60)
505560
}
506561

562+
if let returnInfo = createReturnNotificationInfo() {
563+
payload["return_notification"] = returnInfo
564+
}
565+
507566
// Send the notification using the existing APNS infrastructure
508567
try await sendAPNSNotification(
509568
deviceToken: deviceToken,
@@ -529,7 +588,7 @@ class LoopAPNSService {
529588
let now = Date()
530589
let expiration = Date(timeIntervalSinceNow: 5 * 60) // 5 minutes from now
531590

532-
let payload: [String: Any] = [
591+
var payload: [String: Any] = [
533592
"cancel-temporary-override": "true",
534593
"remote-address": "LoopFollow",
535594
"entered-by": "LoopFollow",
@@ -538,6 +597,10 @@ class LoopAPNSService {
538597
"alert": "Cancel Temporary Override",
539598
]
540599

600+
if let returnInfo = createReturnNotificationInfo() {
601+
payload["return_notification"] = returnInfo
602+
}
603+
541604
// Send the notification using the existing APNS infrastructure
542605
try await sendAPNSNotification(
543606
deviceToken: deviceToken,

LoopFollow/Remote/Settings/RemoteSettingsView.swift

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,29 @@ struct RemoteSettingsView: View {
227227
.foregroundColor(.red)
228228
}
229229
}
230+
231+
if viewModel.areTeamIdsDifferent {
232+
Section(header: Text("Return Notification Settings"), footer: Text("Because LoopFollow and the target app were built with different Team IDs, you must provide the APNS credentials for LoopFollow below.").font(.caption)) {
233+
HStack {
234+
Text("Return APNS Key ID")
235+
TogglableSecureInput(
236+
placeholder: "Enter Key ID for LoopFollow",
237+
text: $viewModel.returnKeyId,
238+
style: .singleLine
239+
)
240+
}
241+
242+
VStack(alignment: .leading) {
243+
Text("Return APNS Key")
244+
TogglableSecureInput(
245+
placeholder: "Paste APNS Key for LoopFollow",
246+
text: $viewModel.returnApnsKey,
247+
style: .multiLine
248+
)
249+
.frame(minHeight: 110)
250+
}
251+
}
252+
}
230253
}
231254
}
232255
.alert(isPresented: $showAlert) {

0 commit comments

Comments
 (0)