Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 20 additions & 0 deletions LoopFollow.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@
DD2C2E512D3B8B0C006413A5 /* NightscoutSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2C2E502D3B8B0B006413A5 /* NightscoutSettingsViewModel.swift */; };
DD2C2E542D3C37DC006413A5 /* DexcomSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2C2E532D3C37D7006413A5 /* DexcomSettingsViewModel.swift */; };
DD2C2E562D3C3917006413A5 /* DexcomSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2C2E552D3C3913006413A5 /* DexcomSettingsView.swift */; };
DD485F142E454B2600CE8CBF /* SecureMessenger.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD485F132E454B2600CE8CBF /* SecureMessenger.swift */; };
DD485F162E46631000CE8CBF /* CryptoSwift in Frameworks */ = {isa = PBXBuildFile; productRef = DD485F152E46631000CE8CBF /* CryptoSwift */; };
DD4878032C7B297E0048F05C /* StorageValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878022C7B297E0048F05C /* StorageValue.swift */; };
DD4878052C7B2C970048F05C /* Storage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878042C7B2C970048F05C /* Storage.swift */; };
DD4878082C7B30BF0048F05C /* RemoteSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878072C7B30BF0048F05C /* RemoteSettingsView.swift */; };
Expand Down Expand Up @@ -449,6 +451,7 @@
DD2C2E502D3B8B0B006413A5 /* NightscoutSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutSettingsViewModel.swift; sourceTree = "<group>"; };
DD2C2E532D3C37D7006413A5 /* DexcomSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DexcomSettingsViewModel.swift; sourceTree = "<group>"; };
DD2C2E552D3C3913006413A5 /* DexcomSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DexcomSettingsView.swift; sourceTree = "<group>"; };
DD485F132E454B2600CE8CBF /* SecureMessenger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureMessenger.swift; sourceTree = "<group>"; };
DD4878022C7B297E0048F05C /* StorageValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageValue.swift; sourceTree = "<group>"; };
DD4878042C7B2C970048F05C /* Storage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storage.swift; sourceTree = "<group>"; };
DD4878072C7B30BF0048F05C /* RemoteSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSettingsView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -801,6 +804,7 @@
buildActionMask = 2147483647;
files = (
FCFEEC9E2486E68E00402A7F /* WebKit.framework in Frameworks */,
DD485F162E46631000CE8CBF /* CryptoSwift in Frameworks */,
3F1335F351590E573D8E6962 /* Pods_LoopFollow.framework in Frameworks */,
DD48781C2C7DAF140048F05C /* SwiftJWT in Frameworks */,
);
Expand Down Expand Up @@ -935,6 +939,7 @@
DD4878112C7B74F90048F05C /* TRC */ = {
isa = PBXGroup;
children = (
DD485F132E454B2600CE8CBF /* SecureMessenger.swift */,
DD48781F2C7DAF890048F05C /* PushMessage.swift */,
DD4878122C7B750D0048F05C /* TempTargetView.swift */,
DD48781D2C7DAF2F0048F05C /* PushNotificationManager.swift */,
Expand Down Expand Up @@ -1590,6 +1595,7 @@
name = LoopFollow;
packageProductDependencies = (
DD48781B2C7DAF140048F05C /* SwiftJWT */,
DD485F152E46631000CE8CBF /* CryptoSwift */,
);
productName = LoopFollow;
productReference = FC9788142485969B00A7906C /* Loop Follow.app */;
Expand Down Expand Up @@ -1626,6 +1632,7 @@
packageReferences = (
DD48781A2C7DAF140048F05C /* XCRemoteSwiftPackageReference "Swift-JWT" */,
654132E82E19F0B800BDBE08 /* XCRemoteSwiftPackageReference "swift-crypto" */,
DD485F0B2E4547C800CE8CBF /* XCRemoteSwiftPackageReference "CryptoSwift" */,
);
productRefGroup = FC9788152485969B00A7906C /* Products */;
projectDirPath = "";
Expand Down Expand Up @@ -2120,6 +2127,7 @@
FC3CAB022493B6220068A152 /* BackgroundTaskAudio.swift in Sources */,
DDCC3A582DDC9655006F1C10 /* MissedBolusAlarmEditor.swift in Sources */,
DDEF50402D479B8A00884336 /* LoopAPNSService.swift in Sources */,
DD485F142E454B2600CE8CBF /* SecureMessenger.swift in Sources */,
DDEF50422D479BAA00884336 /* LoopAPNSCarbsView.swift in Sources */,
DDEF50432D479BBA00884336 /* LoopAPNSBolusView.swift in Sources */,
DDEF50452D479BDA00884336 /* LoopAPNSRemoteView.swift in Sources */,
Expand Down Expand Up @@ -2416,6 +2424,14 @@
minimumVersion = 3.12.3;
};
};
DD485F0B2E4547C800CE8CBF /* XCRemoteSwiftPackageReference "CryptoSwift" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/krzyzanowskim/CryptoSwift.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 1.9.0;
};
};
DD48781A2C7DAF140048F05C /* XCRemoteSwiftPackageReference "Swift-JWT" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/Kitura/Swift-JWT.git";
Expand All @@ -2427,6 +2443,10 @@
/* End XCRemoteSwiftPackageReference section */

/* Begin XCSwiftPackageProductDependency section */
DD485F152E46631000CE8CBF /* CryptoSwift */ = {
isa = XCSwiftPackageProductDependency;
productName = CryptoSwift;
};
DD48781B2C7DAF140048F05C /* SwiftJWT */ = {
isa = XCSwiftPackageProductDependency;
package = DD48781A2C7DAF140048F05C /* XCRemoteSwiftPackageReference "Swift-JWT" */;
Expand Down
11 changes: 10 additions & 1 deletion LoopFollow.xcworkspace/xcshareddata/swiftpm/Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

61 changes: 58 additions & 3 deletions LoopFollow/Application/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,65 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
UNUserNotificationCenter.current().delegate = self

_ = BLEManager.shared

// Ensure VolumeButtonHandler is initialized so it can receive alarm notifications
_ = VolumeButtonHandler.shared

// Register for remote notifications
DispatchQueue.main.async {
UIApplication.shared.registerForRemoteNotifications()
}
return true
}

func applicationWillTerminate(_: UIApplication) {}

// MARK: - Remote Notifications

// Called when successfully registered for remote notifications
func application(_: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
let tokenString = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()

Observable.shared.loopFollowDeviceToken.value = tokenString

LogManager.shared.log(category: .general, message: "Successfully registered for remote notifications with token: \(tokenString)")
}

// Called when failed to register for remote notifications
func application(_: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
LogManager.shared.log(category: .general, message: "Failed to register for remote notifications: \(error.localizedDescription)")
}

// Called when a remote notification is received
func application(_: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
LogManager.shared.log(category: .general, message: "Received remote notification: \(userInfo)")

// Check if this is a notification from Trio with status update
if let aps = userInfo["aps"] as? [String: Any] {
// Handle visible notification (alert, sound, badge)
if let alert = aps["alert"] as? [String: Any] {
let title = alert["title"] as? String ?? ""
let body = alert["body"] as? String ?? ""
LogManager.shared.log(category: .general, message: "Notification - Title: \(title), Body: \(body)")
}

// Handle silent notification (content-available)
if let contentAvailable = aps["content-available"] as? Int, contentAvailable == 1 {
// This is a silent push, nothing implemented but logging for now

if let commandStatus = userInfo["command_status"] as? String {
LogManager.shared.log(category: .general, message: "Command status: \(commandStatus)")
}

if let commandType = userInfo["command_type"] as? String {
LogManager.shared.log(category: .general, message: "Command type: \(commandType)")
}
}
}

// Call completion handler
completionHandler(.newData)
}

// MARK: UISceneSession Lifecycle

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

extension AppDelegate: UNUserNotificationCenterDelegate {
func userNotificationCenter(_: UNUserNotificationCenter,
willPresent _: UNNotification,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void)
{
completionHandler(.alert)
// Log the notification
let userInfo = notification.request.content.userInfo
LogManager.shared.log(category: .general, message: "Will present notification: \(userInfo)")

// Show the notification even when app is in foreground
completionHandler([.banner, .sound, .badge])
}
}
4 changes: 4 additions & 0 deletions LoopFollow/Helpers/BuildDetails.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ class BuildDetails {
dict = parsed
}

var teamID: String? {
dict["com-LoopFollow-development-team"] as? String
}

var buildDateString: String? {
return dict["com-LoopFollow-build-date"] as? String
}
Expand Down
5 changes: 3 additions & 2 deletions LoopFollow/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,12 @@
<string>Loop Follow would like to access your calendar to update BG readings</string>
<key>NSCalendarsUsageDescription</key>
<string>Loop Follow would like to access your calendar to save BG readings</string>
<key>NSCameraUsageDescription</key>
<string>Used for scanning QR codes for remote authentication</string>
<key>NSContactsUsageDescription</key>
<string>This app requires access to contacts to update a contact image with real-time blood glucose information.</string>
<key>NSFaceIDUsageDescription</key>
<string>This app requires Face ID for secure authentication.</string>
<key>NSCameraUsageDescription</key>
<string>Used for scanning QR codes for remote authentication</string>
<key>NSHumanReadableCopyright</key>
<string></string>
<key>UIApplicationSceneManifest</key>
Expand All @@ -85,6 +85,7 @@
<string>audio</string>
<string>processing</string>
<string>bluetooth-central</string>
<string>remote-notification</string>
</array>
<key>UIFileSharingEnabled</key>
<true/>
Expand Down
4 changes: 4 additions & 0 deletions LoopFollow/Loop Follow.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.aps-environment</key>
<string>development</string>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.device.bluetooth</key>
Expand Down
78 changes: 75 additions & 3 deletions LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,51 @@ class LoopAPNSService {
}
}

private func createReturnNotificationInfo() -> [String: Any]? {
let loopFollowDeviceToken = Observable.shared.loopFollowDeviceToken.value
guard !loopFollowDeviceToken.isEmpty else { return nil }

// Get LoopFollow's own Team ID from BuildDetails.
guard let loopFollowTeamID = BuildDetails.default.teamID, !loopFollowTeamID.isEmpty else {
LogManager.shared.log(category: .apns, message: "LoopFollow Team ID not found in BuildDetails.plist. Cannot create return notification info.")
return nil
}

// Get the target Loop app's Team ID from storage.
let targetTeamId = storage.teamId.value ?? ""
let teamIdsAreDifferent = loopFollowTeamID != targetTeamId

let keyIdForReturn: String
let apnsKeyForReturn: String

if teamIdsAreDifferent {
// Team IDs differ, use the separate return credentials.
keyIdForReturn = storage.returnKeyId.value
apnsKeyForReturn = storage.returnApnsKey.value
} else {
// Team IDs are the same, use the primary credentials.
keyIdForReturn = storage.keyId.value
apnsKeyForReturn = storage.apnsKey.value
}

// Ensure we have the necessary credentials.
guard !keyIdForReturn.isEmpty, !apnsKeyForReturn.isEmpty else {
LogManager.shared.log(category: .apns, message: "Missing required return APNS credentials. Check Remote Settings.")
return nil
}

let returnInfo: [String: Any] = [
"production_environment": BuildDetails.default.isTestFlightBuild(),
"device_token": loopFollowDeviceToken,
"bundle_id": Bundle.main.bundleIdentifier ?? "",
"team_id": loopFollowTeamID,
"key_id": keyIdForReturn,
"apns_key": apnsKeyForReturn,
]

return returnInfo
}

/// Validates the Loop APNS setup by checking all required fields
/// - Returns: True if setup is valid, false otherwise
func validateSetup() -> Bool {
Expand Down Expand Up @@ -92,7 +137,7 @@ class LoopAPNSService {
let carbsAmount = payload.carbsAmount ?? 0.0
let absorptionTime = payload.absorptionTime ?? 3.0
let startTime = payload.consumedDate ?? now
let finalPayload = [
var finalPayload = [
"carbs-entry": carbsAmount,
"absorption-time": absorptionTime,
"otp": String(payload.otp),
Expand All @@ -105,6 +150,16 @@ class LoopAPNSService {
"alert": "Remote Carbs Entry: \(String(format: "%.1f", carbsAmount)) grams\nAbsorption Time: \(String(format: "%.1f", absorptionTime)) hours",
] as [String: Any]

/* Let's wait with this until we have an encryption solution for LRC
if let returnInfo = createReturnNotificationInfo() {
finalPayload["return_notification"] = returnInfo
}
*/

// Log the exact carbs amount for debugging precision issues
LogManager.shared.log(category: .apns, message: "Carbs amount - Raw: \(payload.carbsAmount ?? 0.0), Formatted: \(String(format: "%.1f", carbsAmount)), JSON: \(carbsAmount)")
LogManager.shared.log(category: .apns, message: "Absorption time - Raw: \(payload.absorptionTime ?? 3.0), Formatted: \(String(format: "%.1f", absorptionTime)), JSON: \(absorptionTime)")

// Log carbs entry attempt
LogManager.shared.log(category: .apns, message: "Sending carbs: \(String(format: "%.1f", carbsAmount))g, absorption: \(String(format: "%.1f", absorptionTime))h")

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

/* Let's wait with this until we have an encryption solution for LRC
if let returnInfo = createReturnNotificationInfo() {
finalPayload["return_notification"] = returnInfo
}
*/

// Log the exact bolus amount for debugging precision issues
LogManager.shared.log(category: .apns, message: "Bolus amount - Raw: \(payload.bolusAmount ?? 0.0), Formatted: \(String(format: "%.2f", bolusAmount)), JSON: \(bolusAmount)")

// Log bolus entry attempt
LogManager.shared.log(category: .apns, message: "Sending bolus: \(String(format: "%.2f", bolusAmount))U")

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

if let returnInfo = createReturnNotificationInfo() {
payload["return_notification"] = returnInfo
}

// Send the notification using the existing APNS infrastructure
sendAPNSNotification(
deviceToken: deviceToken,
Expand Down Expand Up @@ -619,7 +687,7 @@ class LoopAPNSService {
let now = Date()
let expiration = Date(timeIntervalSinceNow: 5 * 60) // 5 minutes from now

let payload: [String: Any] = [
var payload: [String: Any] = [
"cancel-temporary-override": "true",
"remote-address": "LoopFollow",
"entered-by": "LoopFollow",
Expand All @@ -628,6 +696,10 @@ class LoopAPNSService {
"alert": "Cancel Temporary Override",
]

if let returnInfo = createReturnNotificationInfo() {
payload["return_notification"] = returnInfo
}

// Send the notification using the existing APNS infrastructure
sendAPNSNotification(
deviceToken: deviceToken,
Expand Down
23 changes: 23 additions & 0 deletions LoopFollow/Remote/Settings/RemoteSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,29 @@ struct RemoteSettingsView: View {
.foregroundColor(.red)
}
}

if viewModel.areTeamIdsDifferent {
Section(header: Text("Return Notification Settings"), footer: Text("Because LoopFollow and the target app were built with different Team IDs, you must provide the APNS credentials for LoopFollow below.").font(.caption)) {
HStack {
Text("Return APNS Key ID")
TogglableSecureInput(
placeholder: "Enter Key ID for LoopFollow",
text: $viewModel.returnKeyId,
style: .singleLine
)
}

VStack(alignment: .leading) {
Text("Return APNS Key")
TogglableSecureInput(
placeholder: "Paste APNS Key for LoopFollow",
text: $viewModel.returnApnsKey,
style: .multiLine
)
.frame(minHeight: 110)
}
}
}
}
}
.alert(isPresented: $showAlert) {
Expand Down
Loading