Просмотр исходного кода

Merge pull request #462 from loopandlearn/align-remote-errors

Align remote errors
Marion Barker 10 месяцев назад
Родитель
Сommit
2becf97409

+ 18 - 33
LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift

@@ -352,43 +352,28 @@ struct LoopAPNSBolusView: View {
             otp: otpCode
         )
 
-        Task {
-            do {
-                let apnsService = LoopAPNSService()
-                let success = try await apnsService.sendBolusViaAPNS(payload: payload)
-
-                DispatchQueue.main.async {
-                    isLoading = false
-                    if success {
-                        // Mark TOTP code as used
-                        TOTPService.shared.markTOTPAsUsed(qrCodeURL: Storage.shared.loopAPNSQrCodeURL.value)
-                        alertMessage = "Insulin sent successfully!"
-                        alertType = .success
-                        LogManager.shared.log(
-                            category: .apns,
-                            message: "Insulin sent - Amount: \(insulinAmount.doubleValue(for: .internationalUnit()))U"
-                        )
-                    } else {
-                        alertMessage = "Failed to send insulin. Check your Loop APNS configuration."
-                        alertType = .error
-                        LogManager.shared.log(
-                            category: .apns,
-                            message: "Failed to send insulin"
-                        )
-                    }
-                    showAlert = true
-                }
-            } catch {
-                DispatchQueue.main.async {
-                    isLoading = false
-                    alertMessage = "Error sending insulin: \(error.localizedDescription)"
-                    alertType = .error
+        let apnsService = LoopAPNSService()
+        apnsService.sendBolusViaAPNS(payload: payload) { success, errorMessage in
+            DispatchQueue.main.async {
+                self.isLoading = false
+                if success {
+                    // Mark TOTP code as used
+                    TOTPService.shared.markTOTPAsUsed(qrCodeURL: Storage.shared.loopAPNSQrCodeURL.value)
+                    self.alertMessage = "Insulin sent successfully!"
+                    self.alertType = .success
                     LogManager.shared.log(
                         category: .apns,
-                        message: "APNS insulin error: \(error.localizedDescription)"
+                        message: "Insulin sent - Amount: \(insulinAmount.doubleValue(for: .internationalUnit()))U"
+                    )
+                } else {
+                    self.alertMessage = errorMessage ?? "Failed to send insulin. Check your Loop APNS configuration."
+                    self.alertType = .error
+                    LogManager.shared.log(
+                        category: .apns,
+                        message: "Failed to send insulin: \(errorMessage ?? "unknown error")"
                     )
-                    showAlert = true
                 }
+                self.showAlert = true
             }
         }
     }

+ 20 - 35
LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift

@@ -382,45 +382,30 @@ struct LoopAPNSCarbsView: View {
             otp: otpCode
         )
 
-        Task {
-            do {
-                let apnsService = LoopAPNSService()
-                let success = try await apnsService.sendCarbsViaAPNS(payload: payload)
-
-                DispatchQueue.main.async {
-                    isLoading = false
-                    if success {
-                        // Mark TOTP code as used
-                        TOTPService.shared.markTOTPAsUsed(qrCodeURL: Storage.shared.loopAPNSQrCodeURL.value)
-                        let timeFormatter = DateFormatter()
-                        timeFormatter.timeStyle = .short
-                        alertMessage = "Carbs sent successfully for \(timeFormatter.string(from: adjustedConsumedDate))!"
-                        alertType = .success
-                        LogManager.shared.log(
-                            category: .apns,
-                            message: "Carbs sent - Amount: \(carbsAmount.doubleValue(for: .gram()))g, Absorption: \(absorptionTimeString)h, Time: \(adjustedConsumedDate)"
-                        )
-                    } else {
-                        alertMessage = "Failed to send carbs. Check your Loop APNS configuration."
-                        alertType = .error
-                        LogManager.shared.log(
-                            category: .apns,
-                            message: "Failed to send carbs"
-                        )
-                    }
-                    showAlert = true
-                }
-            } catch {
-                DispatchQueue.main.async {
-                    isLoading = false
-                    alertMessage = "Error sending carbs: \(error.localizedDescription)"
-                    alertType = .error
+        let apnsService = LoopAPNSService()
+        apnsService.sendCarbsViaAPNS(payload: payload) { success, errorMessage in
+            DispatchQueue.main.async {
+                self.isLoading = false
+                if success {
+                    // Mark TOTP code as used
+                    TOTPService.shared.markTOTPAsUsed(qrCodeURL: Storage.shared.loopAPNSQrCodeURL.value)
+                    let timeFormatter = DateFormatter()
+                    timeFormatter.timeStyle = .short
+                    self.alertMessage = "Carbs sent successfully for \(timeFormatter.string(from: adjustedConsumedDate))!"
+                    self.alertType = .success
                     LogManager.shared.log(
                         category: .apns,
-                        message: "APNS carbs error: \(error.localizedDescription)"
+                        message: "Carbs sent - Amount: \(carbsAmount.doubleValue(for: .gram()))g, Absorption: \(absorptionTimeString)h, Time: \(adjustedConsumedDate)"
+                    )
+                } else {
+                    self.alertMessage = errorMessage ?? "Failed to send carbs. Check your Loop APNS configuration."
+                    self.alertType = .error
+                    LogManager.shared.log(
+                        category: .apns,
+                        message: "Failed to send carbs: \(errorMessage ?? "unknown error")"
                     )
-                    showAlert = true
                 }
+                self.showAlert = true
             }
         }
     }

+ 209 - 118
LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift

@@ -67,12 +67,17 @@ class LoopAPNSService {
     }
 
     /// Sends carbs via APNS push notification
-    /// - Parameter payload: The carbs payload to send
-    /// - Returns: True if successful, false otherwise
-    func sendCarbsViaAPNS(payload: LoopAPNSPayload) async throws -> Bool {
+    /// - Parameters:
+    ///   - payload: The carbs payload to send
+    ///   - completion: Completion handler with success status and error message
+    func sendCarbsViaAPNS(payload: LoopAPNSPayload, completion: @escaping (Bool, String?) -> Void) {
         guard validateSetup() else {
-            throw LoopAPNSError.invalidConfiguration
+            let errorMessage = "Loop APNS Configuration not valid"
+            LogManager.shared.log(category: .apns, message: errorMessage)
+            completion(false, errorMessage)
+            return
         }
+
         let deviceToken = Storage.shared.deviceToken.value
         let bundleIdentifier = Storage.shared.bundleId.value
         let keyId = storage.keyId.value
@@ -100,32 +105,31 @@ class LoopAPNSService {
             "alert": "Remote Carbs Entry: \(String(format: "%.1f", carbsAmount)) grams\nAbsorption Time: \(String(format: "%.1f", absorptionTime)) hours",
         ] as [String: Any]
 
-        // 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")
 
-        // Log the final payload for debugging
-        if let payloadData = try? JSONSerialization.data(withJSONObject: finalPayload),
-           let payloadString = String(data: payloadData, encoding: .utf8)
-        {
-            LogManager.shared.log(category: .apns, message: "Final payload being sent: \(payloadString)")
-        }
-        return try await sendAPNSNotification(
+        sendAPNSNotification(
             deviceToken: deviceToken,
             bundleIdentifier: bundleIdentifier,
             keyId: keyId,
             apnsKey: apnsKey,
-            payload: finalPayload
+            payload: finalPayload,
+            completion: completion
         )
     }
 
     /// Sends bolus via APNS push notification
-    /// - Parameter payload: The bolus payload to send
-    /// - Returns: True if successful, false otherwise
-    func sendBolusViaAPNS(payload: LoopAPNSPayload) async throws -> Bool {
+    /// - Parameters:
+    ///   - payload: The bolus payload to send
+    ///   - completion: Completion handler with success status and error message
+    func sendBolusViaAPNS(payload: LoopAPNSPayload, completion: @escaping (Bool, String?) -> Void) {
         guard validateSetup() else {
-            throw LoopAPNSError.invalidConfiguration
+            let errorMessage = "Loop APNS Configuration not valid"
+            LogManager.shared.log(category: .apns, message: errorMessage)
+            completion(false, errorMessage)
+            return
         }
+
         let deviceToken = Storage.shared.deviceToken.value
         let bundleIdentifier = Storage.shared.bundleId.value
         let keyId = storage.keyId.value
@@ -149,24 +153,77 @@ class LoopAPNSService {
             "alert": "Remote Bolus Entry: \(String(format: "%.2f", bolusAmount)) U",
         ] as [String: Any]
 
-        // 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")
 
-        // Log the final payload for debugging
-        if let payloadData = try? JSONSerialization.data(withJSONObject: finalPayload),
-           let payloadString = String(data: payloadData, encoding: .utf8)
-        {
-            LogManager.shared.log(category: .apns, message: "Final payload being sent: \(payloadString)")
-        }
-        return try await sendAPNSNotification(
+        sendAPNSNotification(
             deviceToken: deviceToken,
             bundleIdentifier: bundleIdentifier,
             keyId: keyId,
             apnsKey: apnsKey,
-            payload: finalPayload
+            payload: finalPayload,
+            completion: completion
         )
     }
 
+    /// Validates APNS credentials similar to PushNotificationManager
+    /// - Returns: Array of validation error messages, or nil if valid
+    private func validateCredentials() -> [String]? {
+        var errors = [String]()
+
+        let keyId = storage.keyId.value
+        let teamId = Storage.shared.teamId.value ?? ""
+        let apnsKey = storage.apnsKey.value
+
+        // Validate keyId (should be 10 alphanumeric characters)
+        let keyIdPattern = "^[A-Z0-9]{10}$"
+        if !matchesRegex(keyId, pattern: keyIdPattern) {
+            errors.append("APNS Key ID (\(keyId)) must be 10 uppercase alphanumeric characters.")
+        }
+
+        // Validate teamId (should be 10 alphanumeric characters)
+        let teamIdPattern = "^[A-Z0-9]{10}$"
+        if !matchesRegex(teamId, pattern: teamIdPattern) {
+            errors.append("Team ID (\(teamId)) must be 10 uppercase alphanumeric characters.")
+        }
+
+        // Validate apnsKey (should contain the BEGIN and END PRIVATE KEY markers)
+        if !apnsKey.contains("-----BEGIN PRIVATE KEY-----") || !apnsKey.contains("-----END PRIVATE KEY-----") {
+            errors.append("APNS Key must be a valid PEM-formatted private key.")
+        } else {
+            // Validate that the key data between the markers is valid Base64
+            if let keyData = extractKeyData(from: apnsKey) {
+                if Data(base64Encoded: keyData) == nil {
+                    errors.append("APNS Key contains invalid Base64 key data.")
+                }
+            } else {
+                errors.append("APNS Key has invalid formatting.")
+            }
+        }
+
+        return errors.isEmpty ? nil : errors
+    }
+
+    /// Helper method to match regex patterns
+    /// - Parameters:
+    ///   - text: Text to match
+    ///   - pattern: Regex pattern
+    /// - Returns: True if pattern matches
+    private func matchesRegex(_ text: String, pattern: String) -> Bool {
+        let regex = try? NSRegularExpression(pattern: pattern)
+        let range = NSRange(location: 0, length: text.utf16.count)
+        return regex?.firstMatch(in: text, options: [], range: range) != nil
+    }
+
+    /// Provides simple environment guidance for APNS configuration
+    /// - Returns: String with simple guidance to try opposite setting
+    private func getEnvironmentGuidance() -> String {
+        let currentSetting = storage.productionEnvironment.value ? "ON" : "OFF"
+        let trySetting = storage.productionEnvironment.value ? "OFF" : "ON"
+
+        return "Try changing Production Environment from \(currentSetting) to \(trySetting) in your Loop APNS settings."
+    }
+
     /// Sends an APNS notification
     /// - Parameters:
     ///   - deviceToken: The device token to send to
@@ -174,24 +231,41 @@ class LoopAPNSService {
     ///   - keyId: The APNS key ID
     ///   - apnsKey: The APNS key
     ///   - payload: The notification payload
-    /// - Returns: True if successful, false otherwise
+    ///   - completion: Completion handler with success status and error message
     private func sendAPNSNotification(
         deviceToken: String,
         bundleIdentifier: String,
         keyId: String,
         apnsKey: String,
-        payload: [String: Any]
-    ) async throws -> Bool {
+        payload: [String: Any],
+        completion: @escaping (Bool, String?) -> Void
+    ) {
+        // Validate credentials first
+        if let validationErrors = validateCredentials() {
+            let errorMessage = "Credential validation failed: \(validationErrors.joined(separator: ", "))"
+            LogManager.shared.log(category: .apns, message: errorMessage)
+            completion(false, errorMessage)
+            return
+        }
+
         // Create JWT token for APNS authentication
         guard let jwt = JWTManager.shared.getOrGenerateJWT(keyId: keyId, teamId: Storage.shared.teamId.value ?? "", apnsKey: apnsKey) else {
-            LogManager.shared.log(category: .apns, message: "Failed to create JWT using JWTManager. Check APNS credentials.")
-            throw LoopAPNSError.jwtError
+            let errorMessage = "Failed to generate JWT, please check that the APNS Key ID, APNS Key and Team ID are correct."
+            LogManager.shared.log(category: .apns, message: errorMessage)
+            completion(false, errorMessage)
+            return
         }
 
         // Determine APNS environment
         let isProduction = storage.productionEnvironment.value
         let apnsURL = isProduction ? "https://api.push.apple.com" : "https://api.sandbox.push.apple.com"
-        let requestURL = URL(string: "\(apnsURL)/3/device/\(deviceToken)")!
+        guard let requestURL = URL(string: "\(apnsURL)/3/device/\(deviceToken)") else {
+            let errorMessage = "Failed to construct APNs URL"
+            LogManager.shared.log(category: .apns, message: errorMessage)
+            completion(false, errorMessage)
+            return
+        }
+
         var request = URLRequest(url: requestURL)
         request.httpMethod = "POST"
         request.setValue("application/json", forHTTPHeaderField: "content-type")
@@ -200,20 +274,11 @@ class LoopAPNSService {
         request.setValue("alert", forHTTPHeaderField: "apns-push-type")
         request.setValue("10", forHTTPHeaderField: "apns-priority") // High priority
 
-        // Log request details for debugging
-        LogManager.shared.log(category: .apns, message: "APNS Request URL: \(requestURL)")
-        LogManager.shared.log(category: .apns, message: "APNS Request Headers - Authorization: Bearer \(jwt.prefix(50))..., Topic: \(bundleIdentifier)")
-
         // Validate bundle identifier format
         if !bundleIdentifier.contains(".") {
             LogManager.shared.log(category: .apns, message: "Warning: Bundle identifier may be in wrong format: \(bundleIdentifier)")
         }
 
-        // Validate device token format (should be 64 hex characters)
-        let deviceTokenLength = deviceToken.count
-        let isHexToken = deviceToken.range(of: "^[0-9A-Fa-f]{64}$", options: .regularExpression) != nil
-        LogManager.shared.log(category: .apns, message: "Device token validation - Length: \(deviceTokenLength), Is hex: \(isHexToken)")
-
         // Create the proper APNS payload structure (matching @parse/node-apn format)
         var apnsPayload: [String: Any] = [
             "aps": [
@@ -225,7 +290,7 @@ class LoopAPNSService {
 
         // Add all the custom payload fields (excluding APNS-specific fields)
         for (key, value) in payload {
-            if key != "alert" && key != "content-available" && key != "interruption-level" {
+            if key != "alert", key != "content-available", key != "interruption-level" {
                 apnsPayload[key] = value
             }
         }
@@ -233,75 +298,87 @@ class LoopAPNSService {
         // Remove nil values to clean up the payload
         let cleanPayload = apnsPayload.compactMapValues { $0 }
 
-        let jsonData: Data
         do {
-            jsonData = try JSONSerialization.data(withJSONObject: cleanPayload)
-            LogManager.shared.log(category: .apns, message: "APNS payload serialized successfully, size: \(jsonData.count) bytes")
+            let jsonData = try JSONSerialization.data(withJSONObject: cleanPayload)
 
-            // Log the actual payload being sent
-            if let payloadString = String(data: jsonData, encoding: .utf8) {
-                LogManager.shared.log(category: .apns, message: "APNS payload being sent: \(payloadString)")
-            }
-        } catch {
-            LogManager.shared.log(category: .apns, message: "Failed to serialize APNS payload: \(error.localizedDescription)")
-            throw LoopAPNSError.invalidConfiguration
-        }
-        request.httpBody = jsonData
+            request.httpBody = jsonData
 
-        do {
-            let (data, response) = try await URLSession.shared.data(for: request)
-
-            if let httpResponse = response as? HTTPURLResponse {
-                switch httpResponse.statusCode {
-                case 200:
-                    LogManager.shared.log(category: .apns, message: "APNS notification sent successfully")
-                    return true
-                case 400:
-                    let errorResponse = String(data: data, encoding: .utf8) ?? "Unknown error"
-                    LogManager.shared.log(category: .apns, message: "APNS error 400: \(errorResponse)")
-                    LogManager.shared.log(category: .apns, message: "BadDeviceToken error - this usually means:")
-                    LogManager.shared.log(category: .apns, message: "1. Device token is expired or invalid")
-                    LogManager.shared.log(category: .apns, message: "2. Device token is from different environment (dev vs prod)")
-                    LogManager.shared.log(category: .apns, message: "3. Device token is not registered for this bundle identifier")
-                    LogManager.shared.log(category: .apns, message: "Troubleshooting steps:")
-                    LogManager.shared.log(category: .apns, message: "1. Refresh device token from Loop app")
-                    LogManager.shared.log(category: .apns, message: "2. Check if Loop app is using same environment (dev/prod)")
-                    LogManager.shared.log(category: .apns, message: "3. Verify device token is for bundle ID: \(bundleIdentifier)")
-                    LogManager.shared.log(category: .apns, message: "4. Check if device token is from production environment")
-                    LogManager.shared.log(category: .apns, message: "Current environment: \(storage.productionEnvironment.value ? "Production" : "Development")")
-                    throw LoopAPNSError.invalidResponse
-                case 403:
-                    let errorResponse = String(data: data, encoding: .utf8) ?? "Unknown error"
-                    LogManager.shared.log(category: .apns, message: "APNS error 403: Forbidden - \(errorResponse)")
-                    LogManager.shared.log(category: .apns, message: "This usually means the APNS key doesn't have permissions for this bundle ID")
-                    LogManager.shared.log(category: .apns, message: "Troubleshooting steps:")
-                    LogManager.shared.log(category: .apns, message: "1. Check that APNS key \(keyId) has 'Apple Push Notifications service (APNs)' capability enabled")
-                    LogManager.shared.log(category: .apns, message: "2. Check that bundle ID \(bundleIdentifier) has 'Push Notifications' capability enabled")
-                    LogManager.shared.log(category: .apns, message: "3. Verify the APNS key is associated with the bundle ID in Apple Developer account")
-                    throw LoopAPNSError.unauthorized
-                case 410:
-                    LogManager.shared.log(category: .apns, message: "APNS error 410: Device token is invalid or expired")
-                    throw LoopAPNSError.noDeviceToken
-                case 429:
-                    let errorResponse = String(data: data, encoding: .utf8) ?? "Unknown error"
-                    LogManager.shared.log(category: .apns, message: "APNS error 429: Too Many Requests - \(errorResponse)")
-                    LogManager.shared.log(category: .apns, message: "Rate limiting error - Apple is throttling APNS requests")
-                    LogManager.shared.log(category: .apns, message: "Troubleshooting steps:")
-                    LogManager.shared.log(category: .apns, message: "1. Wait a few minutes before trying again")
-                    LogManager.shared.log(category: .apns, message: "2. Check if you're sending too many notifications too quickly")
-                    LogManager.shared.log(category: .apns, message: "3. Consider implementing exponential backoff")
-                    throw LoopAPNSError.rateLimited
-                default:
-                    let errorResponse = String(data: data, encoding: .utf8) ?? "Unknown error"
-                    LogManager.shared.log(category: .apns, message: "APNS error \(httpResponse.statusCode): \(errorResponse)")
-                    throw LoopAPNSError.networkError
+            let task = URLSession.shared.dataTask(with: request) { data, response, error in
+                if let error = error {
+                    let errorMessage = "Failed to send push notification: \(error.localizedDescription)"
+                    LogManager.shared.log(category: .apns, message: errorMessage)
+                    completion(false, errorMessage)
+                    return
+                }
+
+                if let httpResponse = response as? HTTPURLResponse {
+                    var responseBodyMessage = ""
+                    if let data = data, let responseBody = String(data: data, encoding: .utf8) {
+                        if let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
+                           let reason = json["reason"] as? String
+                        {
+                            responseBodyMessage = reason
+                        }
+                    }
+
+                    switch httpResponse.statusCode {
+                    case 200:
+                        LogManager.shared.log(category: .apns, message: "APNS notification sent successfully")
+                        completion(true, nil)
+                    case 400:
+                        let environmentGuidance = self.getEnvironmentGuidance()
+                        let errorMessage = "Bad request. The request was invalid or malformed. \(responseBodyMessage)\n\n\(environmentGuidance)"
+                        LogManager.shared.log(category: .apns, message: "APNS error 400: \(responseBodyMessage) - Check device token and environment settings")
+                        completion(false, errorMessage)
+                    case 403:
+                        let errorMessage = "Authentication error. Check your certificate or authentication token. \(responseBodyMessage)"
+                        LogManager.shared.log(category: .apns, message: "APNS error 403: \(responseBodyMessage) - Check APNS key permissions for bundle ID")
+                        completion(false, errorMessage)
+                    case 404:
+                        let errorMessage = "Invalid request: The :path value was incorrect. \(responseBodyMessage)"
+                        LogManager.shared.log(category: .apns, message: "APNS error 404: \(responseBodyMessage)")
+                        completion(false, errorMessage)
+                    case 405:
+                        let errorMessage = "Invalid request: Only POST requests are supported. \(responseBodyMessage)"
+                        LogManager.shared.log(category: .apns, message: "APNS error 405: \(responseBodyMessage)")
+                        completion(false, errorMessage)
+                    case 410:
+                        let errorMessage = "The device token is no longer active for the topic. \(responseBodyMessage)"
+                        LogManager.shared.log(category: .apns, message: "APNS error 410: Device token is invalid or expired")
+                        completion(false, errorMessage)
+                    case 413:
+                        let errorMessage = "Payload too large. The notification payload exceeded the size limit. \(responseBodyMessage)"
+                        LogManager.shared.log(category: .apns, message: "APNS error 413: \(responseBodyMessage)")
+                        completion(false, errorMessage)
+                    case 429:
+                        let errorMessage = "Too many requests. \(responseBodyMessage)"
+                        LogManager.shared.log(category: .apns, message: "APNS error 429: Rate limited - wait before retrying")
+                        completion(false, errorMessage)
+                    case 500:
+                        let errorMessage = "Internal server error at APNs. \(responseBodyMessage)"
+                        LogManager.shared.log(category: .apns, message: "APNS error 500: \(responseBodyMessage)")
+                        completion(false, errorMessage)
+                    case 503:
+                        let errorMessage = "Service unavailable. The server is temporarily unavailable. Try again later. \(responseBodyMessage)"
+                        LogManager.shared.log(category: .apns, message: "APNS error 503: \(responseBodyMessage)")
+                        completion(false, errorMessage)
+                    default:
+                        let errorMessage = "Unexpected status code: \(httpResponse.statusCode). \(responseBodyMessage)"
+                        LogManager.shared.log(category: .apns, message: "APNS error \(httpResponse.statusCode): \(responseBodyMessage)")
+                        completion(false, errorMessage)
+                    }
+                } else {
+                    let errorMessage = "Failed to get a valid HTTP response."
+                    LogManager.shared.log(category: .apns, message: errorMessage)
+                    completion(false, errorMessage)
                 }
-            } else {
-                throw LoopAPNSError.networkError
             }
+            task.resume()
+
         } catch {
-            LogManager.shared.log(category: .apns, message: "APNS request failed: \(error.localizedDescription)")
-            throw LoopAPNSError.networkError
+            let errorMessage = "Failed to serialize APNS payload: \(error.localizedDescription)"
+            LogManager.shared.log(category: .apns, message: errorMessage)
+            completion(false, errorMessage)
         }
     }
 
@@ -464,15 +541,21 @@ class LoopAPNSService {
 
     // MARK: - Override Methods
 
-    func sendOverrideNotification(presetName: String, duration: TimeInterval? = nil) async throws {
+    func sendOverrideNotification(presetName: String, duration: TimeInterval? = nil, completion: @escaping (Bool, String?) -> Void) {
         let deviceToken = Storage.shared.deviceToken.value
         guard !deviceToken.isEmpty else {
-            throw LoopAPNSError.deviceTokenNotConfigured
+            let errorMessage = "Device token not configured"
+            LogManager.shared.log(category: .apns, message: errorMessage)
+            completion(false, errorMessage)
+            return
         }
 
         let bundleIdentifier = Storage.shared.bundleId.value
         guard !bundleIdentifier.isEmpty else {
-            throw LoopAPNSError.bundleIdentifierNotConfigured
+            let errorMessage = "Bundle identifier not configured"
+            LogManager.shared.log(category: .apns, message: errorMessage)
+            completion(false, errorMessage)
+            return
         }
 
         // Create APNS notification payload (matching Loop's expected format)
@@ -505,24 +588,31 @@ class LoopAPNSService {
         }
 
         // Send the notification using the existing APNS infrastructure
-        try await sendAPNSNotification(
+        sendAPNSNotification(
             deviceToken: deviceToken,
             bundleIdentifier: bundleIdentifier,
             keyId: storage.keyId.value,
             apnsKey: storage.apnsKey.value,
-            payload: payload
+            payload: payload,
+            completion: completion
         )
     }
 
-    func sendCancelOverrideNotification() async throws {
+    func sendCancelOverrideNotification(completion: @escaping (Bool, String?) -> Void) {
         let deviceToken = Storage.shared.deviceToken.value
         guard !deviceToken.isEmpty else {
-            throw LoopAPNSError.deviceTokenNotConfigured
+            let errorMessage = "Device token not configured"
+            LogManager.shared.log(category: .apns, message: errorMessage)
+            completion(false, errorMessage)
+            return
         }
 
         let bundleIdentifier = Storage.shared.bundleId.value
         guard !bundleIdentifier.isEmpty else {
-            throw LoopAPNSError.bundleIdentifierNotConfigured
+            let errorMessage = "Bundle identifier not configured"
+            LogManager.shared.log(category: .apns, message: errorMessage)
+            completion(false, errorMessage)
+            return
         }
 
         // Create APNS notification payload (matching Loop's expected format)
@@ -539,12 +629,13 @@ class LoopAPNSService {
         ]
 
         // Send the notification using the existing APNS infrastructure
-        try await sendAPNSNotification(
+        sendAPNSNotification(
             deviceToken: deviceToken,
             bundleIdentifier: bundleIdentifier,
             keyId: storage.keyId.value,
             apnsKey: storage.apnsKey.value,
-            payload: payload
+            payload: payload,
+            completion: completion
         )
     }
 }

+ 25 - 9
LoopFollow/Remote/LoopAPNS/OverridePresetsView.swift

@@ -411,10 +411,10 @@ class OverridePresetsViewModel: ObservableObject {
             }
         } catch {
             await MainActor.run {
+                self.isActivating = false
                 self.statusMessage = "Failed to activate override: \(error.localizedDescription)"
                 self.alertType = .statusFailure
                 self.showAlert = true
-                self.isActivating = false
             }
         }
     }
@@ -434,10 +434,10 @@ class OverridePresetsViewModel: ObservableObject {
             }
         } catch {
             await MainActor.run {
+                self.isActivating = false
                 self.statusMessage = "Failed to cancel override: \(error.localizedDescription)"
                 self.alertType = .statusFailure
                 self.showAlert = true
-                self.isActivating = false
             }
         }
     }
@@ -466,16 +466,32 @@ class OverridePresetsViewModel: ObservableObject {
     }
 
     private func sendOverrideNotification(preset: OverridePreset, duration: TimeInterval?) async throws {
-        let apnsService = LoopAPNSService()
-        try await apnsService.sendOverrideNotification(
-            presetName: preset.name,
-            duration: duration
-        )
+        return try await withCheckedThrowingContinuation { continuation in
+            let apnsService = LoopAPNSService()
+            apnsService.sendOverrideNotification(
+                presetName: preset.name,
+                duration: duration
+            ) { success, errorMessage in
+                if success {
+                    continuation.resume()
+                } else {
+                    continuation.resume(throwing: NSError(domain: "OverrideError", code: 0, userInfo: [NSLocalizedDescriptionKey: errorMessage ?? "Unknown error"]))
+                }
+            }
+        }
     }
 
     private func sendCancelOverrideNotification() async throws {
-        let apnsService = LoopAPNSService()
-        try await apnsService.sendCancelOverrideNotification()
+        return try await withCheckedThrowingContinuation { continuation in
+            let apnsService = LoopAPNSService()
+            apnsService.sendCancelOverrideNotification { success, errorMessage in
+                if success {
+                    continuation.resume()
+                } else {
+                    continuation.resume(throwing: NSError(domain: "OverrideError", code: 0, userInfo: [NSLocalizedDescriptionKey: errorMessage ?? "Unknown error"]))
+                }
+            }
+        }
     }
 }