Skip to content

Commit 0154e13

Browse files
authored
Merge pull request #445 from loopandlearn/remote-apns-feedback
Enable APNS feedback to LoopFollow for Trio remote commands
2 parents 16827f8 + a0fde63 commit 0154e13

File tree

16 files changed

+417
-124
lines changed

16 files changed

+417
-124
lines changed

LoopFollow.xcodeproj/project.pbxproj

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@
5858
DD2C2E512D3B8B0C006413A5 /* NightscoutSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2C2E502D3B8B0B006413A5 /* NightscoutSettingsViewModel.swift */; };
5959
DD2C2E542D3C37DC006413A5 /* DexcomSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2C2E532D3C37D7006413A5 /* DexcomSettingsViewModel.swift */; };
6060
DD2C2E562D3C3917006413A5 /* DexcomSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2C2E552D3C3913006413A5 /* DexcomSettingsView.swift */; };
61+
DD485F142E454B2600CE8CBF /* SecureMessenger.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD485F132E454B2600CE8CBF /* SecureMessenger.swift */; };
62+
DD485F162E46631000CE8CBF /* CryptoSwift in Frameworks */ = {isa = PBXBuildFile; productRef = DD485F152E46631000CE8CBF /* CryptoSwift */; };
6163
DD4878032C7B297E0048F05C /* StorageValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878022C7B297E0048F05C /* StorageValue.swift */; };
6264
DD4878052C7B2C970048F05C /* Storage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878042C7B2C970048F05C /* Storage.swift */; };
6365
DD4878082C7B30BF0048F05C /* RemoteSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878072C7B30BF0048F05C /* RemoteSettingsView.swift */; };
@@ -449,6 +451,7 @@
449451
DD2C2E502D3B8B0B006413A5 /* NightscoutSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutSettingsViewModel.swift; sourceTree = "<group>"; };
450452
DD2C2E532D3C37D7006413A5 /* DexcomSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DexcomSettingsViewModel.swift; sourceTree = "<group>"; };
451453
DD2C2E552D3C3913006413A5 /* DexcomSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DexcomSettingsView.swift; sourceTree = "<group>"; };
454+
DD485F132E454B2600CE8CBF /* SecureMessenger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureMessenger.swift; sourceTree = "<group>"; };
452455
DD4878022C7B297E0048F05C /* StorageValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageValue.swift; sourceTree = "<group>"; };
453456
DD4878042C7B2C970048F05C /* Storage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storage.swift; sourceTree = "<group>"; };
454457
DD4878072C7B30BF0048F05C /* RemoteSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSettingsView.swift; sourceTree = "<group>"; };
@@ -801,6 +804,7 @@
801804
buildActionMask = 2147483647;
802805
files = (
803806
FCFEEC9E2486E68E00402A7F /* WebKit.framework in Frameworks */,
807+
DD485F162E46631000CE8CBF /* CryptoSwift in Frameworks */,
804808
3F1335F351590E573D8E6962 /* Pods_LoopFollow.framework in Frameworks */,
805809
DD48781C2C7DAF140048F05C /* SwiftJWT in Frameworks */,
806810
);
@@ -935,6 +939,7 @@
935939
DD4878112C7B74F90048F05C /* TRC */ = {
936940
isa = PBXGroup;
937941
children = (
942+
DD485F132E454B2600CE8CBF /* SecureMessenger.swift */,
938943
DD48781F2C7DAF890048F05C /* PushMessage.swift */,
939944
DD4878122C7B750D0048F05C /* TempTargetView.swift */,
940945
DD48781D2C7DAF2F0048F05C /* PushNotificationManager.swift */,
@@ -1590,6 +1595,7 @@
15901595
name = LoopFollow;
15911596
packageProductDependencies = (
15921597
DD48781B2C7DAF140048F05C /* SwiftJWT */,
1598+
DD485F152E46631000CE8CBF /* CryptoSwift */,
15931599
);
15941600
productName = LoopFollow;
15951601
productReference = FC9788142485969B00A7906C /* Loop Follow.app */;
@@ -1626,6 +1632,7 @@
16261632
packageReferences = (
16271633
DD48781A2C7DAF140048F05C /* XCRemoteSwiftPackageReference "Swift-JWT" */,
16281634
654132E82E19F0B800BDBE08 /* XCRemoteSwiftPackageReference "swift-crypto" */,
1635+
DD485F0B2E4547C800CE8CBF /* XCRemoteSwiftPackageReference "CryptoSwift" */,
16291636
);
16301637
productRefGroup = FC9788152485969B00A7906C /* Products */;
16311638
projectDirPath = "";
@@ -2120,6 +2127,7 @@
21202127
FC3CAB022493B6220068A152 /* BackgroundTaskAudio.swift in Sources */,
21212128
DDCC3A582DDC9655006F1C10 /* MissedBolusAlarmEditor.swift in Sources */,
21222129
DDEF50402D479B8A00884336 /* LoopAPNSService.swift in Sources */,
2130+
DD485F142E454B2600CE8CBF /* SecureMessenger.swift in Sources */,
21232131
DDEF50422D479BAA00884336 /* LoopAPNSCarbsView.swift in Sources */,
21242132
DDEF50432D479BBA00884336 /* LoopAPNSBolusView.swift in Sources */,
21252133
DDEF50452D479BDA00884336 /* LoopAPNSRemoteView.swift in Sources */,
@@ -2416,6 +2424,14 @@
24162424
minimumVersion = 3.12.3;
24172425
};
24182426
};
2427+
DD485F0B2E4547C800CE8CBF /* XCRemoteSwiftPackageReference "CryptoSwift" */ = {
2428+
isa = XCRemoteSwiftPackageReference;
2429+
repositoryURL = "https://github.com/krzyzanowskim/CryptoSwift.git";
2430+
requirement = {
2431+
kind = upToNextMajorVersion;
2432+
minimumVersion = 1.9.0;
2433+
};
2434+
};
24192435
DD48781A2C7DAF140048F05C /* XCRemoteSwiftPackageReference "Swift-JWT" */ = {
24202436
isa = XCRemoteSwiftPackageReference;
24212437
repositoryURL = "https://github.com/Kitura/Swift-JWT.git";
@@ -2427,6 +2443,10 @@
24272443
/* End XCRemoteSwiftPackageReference section */
24282444

24292445
/* Begin XCSwiftPackageProductDependency section */
2446+
DD485F152E46631000CE8CBF /* CryptoSwift */ = {
2447+
isa = XCSwiftPackageProductDependency;
2448+
productName = CryptoSwift;
2449+
};
24302450
DD48781B2C7DAF140048F05C /* SwiftJWT */ = {
24312451
isa = XCSwiftPackageProductDependency;
24322452
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: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,15 +38,65 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
3838
UNUserNotificationCenter.current().delegate = self
3939

4040
_ = BLEManager.shared
41-
4241
// Ensure VolumeButtonHandler is initialized so it can receive alarm notifications
4342
_ = VolumeButtonHandler.shared
4443

44+
// Register for remote notifications
45+
DispatchQueue.main.async {
46+
UIApplication.shared.registerForRemoteNotifications()
47+
}
4548
return true
4649
}
4750

4851
func applicationWillTerminate(_: UIApplication) {}
4952

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

52102
func application(_: UIApplication, willFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
@@ -142,9 +192,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
142192

143193
extension AppDelegate: UNUserNotificationCenterDelegate {
144194
func userNotificationCenter(_: UNUserNotificationCenter,
145-
willPresent _: UNNotification,
195+
willPresent notification: UNNotification,
146196
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void)
147197
{
148-
completionHandler(.alert)
198+
// Log the notification
199+
let userInfo = notification.request.content.userInfo
200+
LogManager.shared.log(category: .general, message: "Will present notification: \(userInfo)")
201+
202+
// Show the notification even when app is in foreground
203+
completionHandler([.banner, .sound, .badge])
149204
}
150205
}

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: 75 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 {
@@ -92,7 +137,7 @@ class LoopAPNSService {
92137
let carbsAmount = payload.carbsAmount ?? 0.0
93138
let absorptionTime = payload.absorptionTime ?? 3.0
94139
let startTime = payload.consumedDate ?? now
95-
let finalPayload = [
140+
var finalPayload = [
96141
"carbs-entry": carbsAmount,
97142
"absorption-time": absorptionTime,
98143
"otp": String(payload.otp),
@@ -105,6 +150,16 @@ class LoopAPNSService {
105150
"alert": "Remote Carbs Entry: \(String(format: "%.1f", carbsAmount)) grams\nAbsorption Time: \(String(format: "%.1f", absorptionTime)) hours",
106151
] as [String: Any]
107152

153+
/* Let's wait with this until we have an encryption solution for LRC
154+
if let returnInfo = createReturnNotificationInfo() {
155+
finalPayload["return_notification"] = returnInfo
156+
}
157+
*/
158+
159+
// Log the exact carbs amount for debugging precision issues
160+
LogManager.shared.log(category: .apns, message: "Carbs amount - Raw: \(payload.carbsAmount ?? 0.0), Formatted: \(String(format: "%.1f", carbsAmount)), JSON: \(carbsAmount)")
161+
LogManager.shared.log(category: .apns, message: "Absorption time - Raw: \(payload.absorptionTime ?? 3.0), Formatted: \(String(format: "%.1f", absorptionTime)), JSON: \(absorptionTime)")
162+
108163
// Log carbs entry attempt
109164
LogManager.shared.log(category: .apns, message: "Sending carbs: \(String(format: "%.1f", carbsAmount))g, absorption: \(String(format: "%.1f", absorptionTime))h")
110165

@@ -142,7 +197,7 @@ class LoopAPNSService {
142197
// Create the complete notification payload (matching Nightscout's exact format)
143198
// Based on Nightscout's loop.js implementation
144199
let bolusAmount = payload.bolusAmount ?? 0.0
145-
let finalPayload = [
200+
var finalPayload = [
146201
"bolus-entry": bolusAmount,
147202
"otp": String(payload.otp),
148203
"remote-address": "LoopFollow",
@@ -153,6 +208,15 @@ class LoopAPNSService {
153208
"alert": "Remote Bolus Entry: \(String(format: "%.2f", bolusAmount)) U",
154209
] as [String: Any]
155210

211+
/* Let's wait with this until we have an encryption solution for LRC
212+
if let returnInfo = createReturnNotificationInfo() {
213+
finalPayload["return_notification"] = returnInfo
214+
}
215+
*/
216+
217+
// Log the exact bolus amount for debugging precision issues
218+
LogManager.shared.log(category: .apns, message: "Bolus amount - Raw: \(payload.bolusAmount ?? 0.0), Formatted: \(String(format: "%.2f", bolusAmount)), JSON: \(bolusAmount)")
219+
156220
// Log bolus entry attempt
157221
LogManager.shared.log(category: .apns, message: "Sending bolus: \(String(format: "%.2f", bolusAmount))U")
158222

@@ -587,6 +651,10 @@ class LoopAPNSService {
587651
payload["override-duration-minutes"] = Int(duration / 60)
588652
}
589653

654+
if let returnInfo = createReturnNotificationInfo() {
655+
payload["return_notification"] = returnInfo
656+
}
657+
590658
// Send the notification using the existing APNS infrastructure
591659
sendAPNSNotification(
592660
deviceToken: deviceToken,
@@ -619,7 +687,7 @@ class LoopAPNSService {
619687
let now = Date()
620688
let expiration = Date(timeIntervalSinceNow: 5 * 60) // 5 minutes from now
621689

622-
let payload: [String: Any] = [
690+
var payload: [String: Any] = [
623691
"cancel-temporary-override": "true",
624692
"remote-address": "LoopFollow",
625693
"entered-by": "LoopFollow",
@@ -628,6 +696,10 @@ class LoopAPNSService {
628696
"alert": "Cancel Temporary Override",
629697
]
630698

699+
if let returnInfo = createReturnNotificationInfo() {
700+
payload["return_notification"] = returnInfo
701+
}
702+
631703
// Send the notification using the existing APNS infrastructure
632704
sendAPNSNotification(
633705
deviceToken: deviceToken,

LoopFollow/Remote/Settings/RemoteSettingsView.swift

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,29 @@ struct RemoteSettingsView: View {
261261
.foregroundColor(.red)
262262
}
263263
}
264+
265+
if viewModel.areTeamIdsDifferent {
266+
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)) {
267+
HStack {
268+
Text("Return APNS Key ID")
269+
TogglableSecureInput(
270+
placeholder: "Enter Key ID for LoopFollow",
271+
text: $viewModel.returnKeyId,
272+
style: .singleLine
273+
)
274+
}
275+
276+
VStack(alignment: .leading) {
277+
Text("Return APNS Key")
278+
TogglableSecureInput(
279+
placeholder: "Paste APNS Key for LoopFollow",
280+
text: $viewModel.returnApnsKey,
281+
style: .multiLine
282+
)
283+
.frame(minHeight: 110)
284+
}
285+
}
286+
}
264287
}
265288
}
266289
.alert(isPresented: $showAlert) {

0 commit comments

Comments
 (0)