Skip to content
Open
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
4 changes: 4 additions & 0 deletions LoopFollow.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
656F8C102E49F36F0008DC1D /* QRCodeDisplayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 656F8C0F2E49F36F0008DC1D /* QRCodeDisplayView.swift */; };
656F8C122E49F3780008DC1D /* QRCodeGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 656F8C112E49F3780008DC1D /* QRCodeGenerator.swift */; };
656F8C142E49F3D20008DC1D /* RemoteCommandSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 656F8C132E49F3D20008DC1D /* RemoteCommandSettings.swift */; };
657F99E92F0BC81500F732BD /* OTPSecureMessenger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 657F99E82F0BC81500F732BD /* OTPSecureMessenger.swift */; };
6584B1012E4A263900135D4D /* TOTPService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6584B1002E4A263900135D4D /* TOTPService.swift */; };
6589CC622E9E7D1600BB18FE /* ImportExportSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC532E9E7D1600BB18FE /* ImportExportSettingsView.swift */; };
6589CC632E9E7D1600BB18FE /* GeneralSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC5D2E9E7D1600BB18FE /* GeneralSettingsView.swift */; };
Expand Down Expand Up @@ -416,6 +417,7 @@
656F8C0F2E49F36F0008DC1D /* QRCodeDisplayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeDisplayView.swift; sourceTree = "<group>"; };
656F8C112E49F3780008DC1D /* QRCodeGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeGenerator.swift; sourceTree = "<group>"; };
656F8C132E49F3D20008DC1D /* RemoteCommandSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteCommandSettings.swift; sourceTree = "<group>"; };
657F99E82F0BC81500F732BD /* OTPSecureMessenger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPSecureMessenger.swift; sourceTree = "<group>"; };
6584B1002E4A263900135D4D /* TOTPService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOTPService.swift; sourceTree = "<group>"; };
6589CC522E9E7D1600BB18FE /* ExportableSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExportableSettings.swift; sourceTree = "<group>"; };
6589CC532E9E7D1600BB18FE /* ImportExportSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportExportSettingsView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1241,6 +1243,7 @@
DDEF503E2D479B8A00884336 /* LoopAPNS */ = {
isa = PBXGroup;
children = (
657F99E82F0BC81500F732BD /* OTPSecureMessenger.swift */,
6584B1002E4A263900135D4D /* TOTPService.swift */,
DDEF503F2D479B8A00884336 /* LoopAPNSService.swift */,
DDEF50412D479BAA00884336 /* LoopAPNSCarbsView.swift */,
Expand Down Expand Up @@ -2108,6 +2111,7 @@
DD5DA27C2DC930D6003D44FC /* GlucoseValue.swift in Sources */,
DD9ACA062D32AF7900415D8A /* TreatmentsTask.swift in Sources */,
DD98F54424BCEFEE0007425A /* ShareClientExtension.swift in Sources */,
657F99E92F0BC81500F732BD /* OTPSecureMessenger.swift in Sources */,
DD9ED0CE2D35587A000D2A63 /* LogEntry.swift in Sources */,
DD0C0C602C415B9D00DBADDF /* ProfileManager.swift in Sources */,
DD83164C2DE4DB3A004467AA /* BinaryFloatingPoint+localized.swift in Sources */,
Expand Down
21 changes: 20 additions & 1 deletion LoopFollow/Application/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,32 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
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
// Check if this is a response notification from Loop or Trio
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)")

// Check if this is a command response from Loop
if let commandStatus = userInfo["command_status"] as? String,
let commandType = userInfo["command_type"] as? String
{
LogManager.shared.log(category: .apns, message: "Loop command response - Type: \(commandType), Status: \(commandStatus), Message: \(body)")

// Post notification for UI to handle if needed
NotificationCenter.default.post(
name: NSNotification.Name("LoopCommandResponse"),
object: nil,
userInfo: [
"commandType": commandType,
"commandStatus": commandStatus,
"message": body,
"title": title,
]
)
}
}

// Handle silent notification (content-available)
Expand Down
96 changes: 74 additions & 22 deletions LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ class LoopAPNSService {
}
}

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

Expand Down Expand Up @@ -80,16 +80,29 @@ class LoopAPNSService {
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 ReturnNotificationInfo(
productionEnvironment: BuildDetails.default.isTestFlightBuild(),
deviceToken: loopFollowDeviceToken,
bundleId: Bundle.main.bundleIdentifier ?? "",
teamId: loopFollowTeamID,
keyId: keyIdForReturn,
apnsKey: apnsKeyForReturn
)
}

/// Encrypts return notification info using OTP code
private func encryptReturnNotificationInfo(returnInfo: ReturnNotificationInfo, otpCode: String) -> String? {
guard let messenger = OTPSecureMessenger(otpCode: otpCode) else {
LogManager.shared.log(category: .apns, message: "Failed to create OTP secure messenger")
return nil
}

return returnInfo
do {
return try messenger.encrypt(returnInfo)
} catch {
LogManager.shared.log(category: .apns, message: "Failed to encrypt return notification info: \(error.localizedDescription)")
return nil
}
}

/// Validates the Loop APNS setup by checking all required fields
Expand Down Expand Up @@ -150,11 +163,18 @@ 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
}
*/
// Encrypt and include return notification info using OTP
if let returnInfo = createReturnNotificationInfo() {
LogManager.shared.log(category: .apns, message: "Created return notification info for carbs - deviceToken: \(returnInfo.deviceToken.prefix(8))..., bundleId: \(returnInfo.bundleId)")
if let encryptedReturnInfo = encryptReturnNotificationInfo(returnInfo: returnInfo, otpCode: String(payload.otp)) {
finalPayload["encrypted_return_notification"] = encryptedReturnInfo
LogManager.shared.log(category: .apns, message: "Added encrypted_return_notification to carbs payload, length: \(encryptedReturnInfo.count)")
} else {
LogManager.shared.log(category: .apns, message: "Failed to encrypt return notification info for carbs command")
}
} else {
LogManager.shared.log(category: .apns, message: "Failed to create return notification info for carbs command")
}

// 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)")
Expand Down Expand Up @@ -208,11 +228,18 @@ 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
}
*/
// Encrypt and include return notification info using OTP
if let returnInfo = createReturnNotificationInfo() {
LogManager.shared.log(category: .apns, message: "Created return notification info for carbs - deviceToken: \(returnInfo.deviceToken.prefix(8))..., bundleId: \(returnInfo.bundleId)")
if let encryptedReturnInfo = encryptReturnNotificationInfo(returnInfo: returnInfo, otpCode: String(payload.otp)) {
finalPayload["encrypted_return_notification"] = encryptedReturnInfo
LogManager.shared.log(category: .apns, message: "Added encrypted_return_notification to carbs payload, length: \(encryptedReturnInfo.count)")
} else {
LogManager.shared.log(category: .apns, message: "Failed to encrypt return notification info for carbs command")
}
} else {
LogManager.shared.log(category: .apns, message: "Failed to create return notification info for carbs command")
}

// 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)")
Expand Down Expand Up @@ -362,6 +389,13 @@ class LoopAPNSService {
// Remove nil values to clean up the payload
let cleanPayload = apnsPayload.compactMapValues { $0 }

// Log if encrypted_return_notification is in the payload
if cleanPayload["encrypted_return_notification"] != nil {
LogManager.shared.log(category: .apns, message: "encrypted_return_notification is present in final APNS payload")
} else {
LogManager.shared.log(category: .apns, message: "WARNING: encrypted_return_notification is NOT in final APNS payload. Available keys: \(Array(cleanPayload.keys).joined(separator: ", "))")
}

do {
let jsonData = try JSONSerialization.data(withJSONObject: cleanPayload)

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

// For override commands, we can include return notification info unencrypted
// since override commands don't require OTP validation in Loop
if let returnInfo = createReturnNotificationInfo() {
payload["return_notification"] = returnInfo
payload["return_notification"] = [
"production_environment": returnInfo.productionEnvironment,
"device_token": returnInfo.deviceToken,
"bundle_id": returnInfo.bundleId,
"team_id": returnInfo.teamId,
"key_id": returnInfo.keyId,
"apns_key": returnInfo.apnsKey,
]
}

// Send the notification using the existing APNS infrastructure
Expand Down Expand Up @@ -696,8 +739,17 @@ class LoopAPNSService {
"alert": "Cancel Temporary Override",
]

// For override commands, we can include return notification info unencrypted
// since override commands don't require OTP validation in Loop
if let returnInfo = createReturnNotificationInfo() {
payload["return_notification"] = returnInfo
payload["return_notification"] = [
"production_environment": returnInfo.productionEnvironment,
"device_token": returnInfo.deviceToken,
"bundle_id": returnInfo.bundleId,
"team_id": returnInfo.teamId,
"key_id": returnInfo.keyId,
"apns_key": returnInfo.apnsKey,
]
}

// Send the notification using the existing APNS infrastructure
Expand Down
57 changes: 57 additions & 0 deletions LoopFollow/Remote/LoopAPNS/OTPSecureMessenger.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// LoopFollow
// OTPSecureMessenger.swift

import CryptoKit
import Foundation

struct OTPSecureMessenger {
private let encryptionKey: SymmetricKey

/// Initialize with OTP code. The OTP code is hashed to create the encryption key.
init?(otpCode: String) {
guard let otpData = otpCode.data(using: .utf8) else {
return nil
}
// Use SHA256 hash of OTP code as the encryption key
let hashed = SHA256.hash(data: otpData)
encryptionKey = SymmetricKey(data: hashed)
}

/// Encrypt an encodable object using AES-GCM with OTP-derived key
func encrypt<T: Encodable>(_ object: T) throws -> String {
let dataToEncrypt = try JSONEncoder().encode(object)

// Generate a random nonce (12 bytes for GCM)
let nonce = AES.GCM.Nonce()

// Encrypt using AES-GCM
let sealedBox = try AES.GCM.seal(dataToEncrypt, using: encryptionKey, nonce: nonce)

// Format: nonce (12 bytes) + ciphertext + tag (16 bytes)
let nonceData = Data(nonce)
let ciphertext = sealedBox.ciphertext
let tag = sealedBox.tag
let combinedData = nonceData + ciphertext + tag

return combinedData.base64EncodedString()
}
}

/// Information needed to send a response notification back via APNS
struct ReturnNotificationInfo: Codable {
let productionEnvironment: Bool
let deviceToken: String
let bundleId: String
let teamId: String
let keyId: String
let apnsKey: String

enum CodingKeys: String, CodingKey {
case productionEnvironment = "production_environment"
case deviceToken = "device_token"
case bundleId = "bundle_id"
case teamId = "team_id"
case keyId = "key_id"
case apnsKey = "apns_key"
}
}