From 8446f6869cc901d38d6882cbf62587da18bec96d Mon Sep 17 00:00:00 2001 From: codebymini Date: Sat, 13 Sep 2025 10:51:24 +0200 Subject: [PATCH 1/5] Align errors and clean up logging for loop APNS remote --- .../Remote/LoopAPNS/LoopAPNSBolusView.swift | 51 ++- .../Remote/LoopAPNS/LoopAPNSCarbsView.swift | 55 ++-- .../Remote/LoopAPNS/LoopAPNSService.swift | 305 ++++++++++++------ .../Remote/LoopAPNS/OverridePresetsView.swift | 65 ++-- 4 files changed, 266 insertions(+), 210 deletions(-) diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift index 90fbb797d..c0cfbe612 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift +++ b/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 } } } diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift index 5b8331466..e1ae7ab5d 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift +++ b/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 } } } diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift index 8a2bc27cd..62bbfb4be 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift +++ b/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,72 @@ 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 environment-specific guidance for APNS configuration + /// - Returns: String with guidance based on build configuration + } + /// Sends an APNS notification /// - Parameters: /// - deviceToken: The device token to send to @@ -174,24 +226,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 +269,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 +285,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,30 +293,34 @@ 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 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 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:") @@ -270,38 +334,55 @@ class LoopAPNSService { 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 + 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 +545,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 +592,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 +633,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 ) } } diff --git a/LoopFollow/Remote/LoopAPNS/OverridePresetsView.swift b/LoopFollow/Remote/LoopAPNS/OverridePresetsView.swift index d34d8fffb..a0929d92e 100644 --- a/LoopFollow/Remote/LoopAPNS/OverridePresetsView.swift +++ b/LoopFollow/Remote/LoopAPNS/OverridePresetsView.swift @@ -228,48 +228,38 @@ class OverridePresetsViewModel: ObservableObject { } } - func activateOverride(preset: OverridePreset) async { - await MainActor.run { - isActivating = true - } + func activateOverride(preset: OverridePreset) { + isActivating = true - do { - try await sendOverrideNotification(preset: preset) - await MainActor.run { + sendOverrideNotification(preset: preset) { success, errorMessage in + DispatchQueue.main.async { self.isActivating = false - self.statusMessage = "\(preset.name) override activated successfully." - self.alertType = .statusSuccess - self.showAlert = true - } - } catch { - await MainActor.run { - self.statusMessage = "Failed to activate override: \(error.localizedDescription)" - self.alertType = .statusFailure + if success { + self.statusMessage = "\(preset.name) override activated successfully." + self.alertType = .statusSuccess + } else { + self.statusMessage = errorMessage ?? "Failed to activate override." + self.alertType = .statusFailure + } self.showAlert = true - self.isActivating = false } } } - func cancelOverride() async { - await MainActor.run { - isActivating = true - } + func cancelOverride() { + isActivating = true - do { - try await sendCancelOverrideNotification() - await MainActor.run { + sendCancelOverrideNotification { success, errorMessage in + DispatchQueue.main.async { self.isActivating = false - self.statusMessage = "Active override cancelled successfully." - self.alertType = .statusSuccess - self.showAlert = true - } - } catch { - await MainActor.run { - self.statusMessage = "Failed to cancel override: \(error.localizedDescription)" - self.alertType = .statusFailure + if success { + self.statusMessage = "Active override cancelled successfully." + self.alertType = .statusSuccess + } else { + self.statusMessage = errorMessage ?? "Failed to cancel override." + self.alertType = .statusFailure + } self.showAlert = true - self.isActivating = false } } } @@ -298,17 +288,18 @@ class OverridePresetsViewModel: ObservableObject { } } - private func sendOverrideNotification(preset: OverridePreset) async throws { + private func sendOverrideNotification(preset: OverridePreset, completion: @escaping (Bool, String?) -> Void) { let apnsService = LoopAPNSService() - try await apnsService.sendOverrideNotification( + apnsService.sendOverrideNotification( presetName: preset.name, - duration: preset.duration + duration: preset.duration, + completion: completion ) } - private func sendCancelOverrideNotification() async throws { + private func sendCancelOverrideNotification(completion: @escaping (Bool, String?) -> Void) { let apnsService = LoopAPNSService() - try await apnsService.sendCancelOverrideNotification() + apnsService.sendCancelOverrideNotification(completion: completion) } } From 55b11b2a0000d648e4ba9d586087ff07b0b63f99 Mon Sep 17 00:00:00 2001 From: codebymini Date: Sat, 13 Sep 2025 10:54:06 +0200 Subject: [PATCH 2/5] Add guidance message for production env setting --- .../Remote/LoopAPNS/LoopAPNSService.swift | 41 +++++++++++++------ .../Remote/TRC/PushNotificationManager.swift | 31 +++++++++++++- 2 files changed, 58 insertions(+), 14 deletions(-) diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift index 62bbfb4be..620e9a551 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift @@ -217,6 +217,30 @@ class LoopAPNSService { /// Provides environment-specific guidance for APNS configuration /// - Returns: String with guidance based on build configuration + private func getEnvironmentGuidance() -> String { + #if DEBUG + let buildType = "Xcode" + let recommendedEnvironment = "Development" + let environmentSetting = "Production Environment: OFF" + #else + let buildType = "Browser/TestFlight" + let recommendedEnvironment = "Production" + let environmentSetting = "Production Environment: ON" + #endif + + let currentEnvironment = storage.productionEnvironment.value ? "Production" : "Development" + + return """ + Environment Configuration Help: + + Build Type: \(buildType) + Current Setting: \(currentEnvironment) + Recommended Setting: \(recommendedEnvironment) + + Please check your Loop Remote control settings: + • If you built with Xcode: Set "\(environmentSetting)" + • If you built with Browser/TestFlight: Set "Production Environment: ON" + """ } /// Sends an APNS notification @@ -321,19 +345,10 @@ class LoopAPNSService { LogManager.shared.log(category: .apns, message: "APNS notification sent successfully") completion(true, nil) 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 + 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") diff --git a/LoopFollow/Remote/TRC/PushNotificationManager.swift b/LoopFollow/Remote/TRC/PushNotificationManager.swift index ac586b772..f918595c9 100644 --- a/LoopFollow/Remote/TRC/PushNotificationManager.swift +++ b/LoopFollow/Remote/TRC/PushNotificationManager.swift @@ -287,7 +287,8 @@ class PushNotificationManager { case 200: completion(true, nil) case 400: - completion(false, "Bad request. The request was invalid or malformed. \(responseBodyMessage)") + let environmentGuidance = self.getEnvironmentGuidance() + completion(false, "Bad request. The request was invalid or malformed. \(responseBodyMessage)\n\n\(environmentGuidance)") case 403: completion(false, "Authentication error. Check your certificate or authentication token. \(responseBodyMessage)") case 404: @@ -325,4 +326,32 @@ class PushNotificationManager { let urlString = "https://\(host)/3/device/\(deviceToken)" return URL(string: urlString) } + + /// Provides environment-specific guidance for APNS configuration + /// - Returns: String with guidance based on build configuration + private func getEnvironmentGuidance() -> String { + #if DEBUG + let buildType = "Xcode" + let recommendedEnvironment = "Development" + let environmentSetting = "Production Environment: OFF" + #else + let buildType = "Browser/TestFlight" + let recommendedEnvironment = "Production" + let environmentSetting = "Production Environment: ON" + #endif + + let currentEnvironment = productionEnvironment ? "Production" : "Development" + + return """ + Environment Configuration Help: + + Build Type: \(buildType) + Current Setting: \(currentEnvironment) + Recommended Setting: \(recommendedEnvironment) + + Please check your Trio Remote control settings: + • If you built with Xcode: Set "\(environmentSetting)" + • If you built with Browser/TestFlight: Set "Production Environment: ON" + """ + } } From 3b9b14c6d424f345685491e596c8b5f1f88bf790 Mon Sep 17 00:00:00 2001 From: codebymini Date: Sat, 13 Sep 2025 20:12:56 +0200 Subject: [PATCH 3/5] Revert changes on env guidance for trio --- .../Remote/TRC/PushNotificationManager.swift | 30 +------------------ 1 file changed, 1 insertion(+), 29 deletions(-) diff --git a/LoopFollow/Remote/TRC/PushNotificationManager.swift b/LoopFollow/Remote/TRC/PushNotificationManager.swift index f918595c9..d7816f38e 100644 --- a/LoopFollow/Remote/TRC/PushNotificationManager.swift +++ b/LoopFollow/Remote/TRC/PushNotificationManager.swift @@ -287,8 +287,7 @@ class PushNotificationManager { case 200: completion(true, nil) case 400: - let environmentGuidance = self.getEnvironmentGuidance() - completion(false, "Bad request. The request was invalid or malformed. \(responseBodyMessage)\n\n\(environmentGuidance)") + completion(false, "Bad request. The request was invalid or malformed. \(responseBodyMessage)") case 403: completion(false, "Authentication error. Check your certificate or authentication token. \(responseBodyMessage)") case 404: @@ -327,31 +326,4 @@ class PushNotificationManager { return URL(string: urlString) } - /// Provides environment-specific guidance for APNS configuration - /// - Returns: String with guidance based on build configuration - private func getEnvironmentGuidance() -> String { - #if DEBUG - let buildType = "Xcode" - let recommendedEnvironment = "Development" - let environmentSetting = "Production Environment: OFF" - #else - let buildType = "Browser/TestFlight" - let recommendedEnvironment = "Production" - let environmentSetting = "Production Environment: ON" - #endif - - let currentEnvironment = productionEnvironment ? "Production" : "Development" - - return """ - Environment Configuration Help: - - Build Type: \(buildType) - Current Setting: \(currentEnvironment) - Recommended Setting: \(recommendedEnvironment) - - Please check your Trio Remote control settings: - • If you built with Xcode: Set "\(environmentSetting)" - • If you built with Browser/TestFlight: Set "Production Environment: ON" - """ - } } From 00389a5c389920f0711e749b76b890e166af65d0 Mon Sep 17 00:00:00 2001 From: codebymini Date: Sat, 13 Sep 2025 20:13:20 +0200 Subject: [PATCH 4/5] Simplify logic for env guidance for Loop --- .../Remote/LoopAPNS/LoopAPNSService.swift | 31 ++++--------------- 1 file changed, 6 insertions(+), 25 deletions(-) diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift index 620e9a551..399be43d4 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift @@ -215,32 +215,13 @@ class LoopAPNSService { return regex?.firstMatch(in: text, options: [], range: range) != nil } - /// Provides environment-specific guidance for APNS configuration - /// - Returns: String with guidance based on build configuration + /// Provides simple environment guidance for APNS configuration + /// - Returns: String with simple guidance to try opposite setting private func getEnvironmentGuidance() -> String { - #if DEBUG - let buildType = "Xcode" - let recommendedEnvironment = "Development" - let environmentSetting = "Production Environment: OFF" - #else - let buildType = "Browser/TestFlight" - let recommendedEnvironment = "Production" - let environmentSetting = "Production Environment: ON" - #endif - - let currentEnvironment = storage.productionEnvironment.value ? "Production" : "Development" - - return """ - Environment Configuration Help: - - Build Type: \(buildType) - Current Setting: \(currentEnvironment) - Recommended Setting: \(recommendedEnvironment) - - Please check your Loop Remote control settings: - • If you built with Xcode: Set "\(environmentSetting)" - • If you built with Browser/TestFlight: Set "Production Environment: ON" - """ + 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 From 146d318ea5e36332660df569a22c8258f685d7c3 Mon Sep 17 00:00:00 2001 From: codebymini Date: Wed, 1 Oct 2025 23:19:00 +0200 Subject: [PATCH 5/5] Fix merge conflict for error handling and overrides --- .../Remote/LoopAPNS/LoopAPNSService.swift | 2 +- .../Remote/LoopAPNS/OverridePresetsView.swift | 84 +++++++++++-------- .../Remote/TRC/PushNotificationManager.swift | 1 - 3 files changed, 52 insertions(+), 35 deletions(-) diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift index 399be43d4..eae7bcc01 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift @@ -220,7 +220,7 @@ class LoopAPNSService { 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." } diff --git a/LoopFollow/Remote/LoopAPNS/OverridePresetsView.swift b/LoopFollow/Remote/LoopAPNS/OverridePresetsView.swift index ab5cab2d5..ce88f0b90 100644 --- a/LoopFollow/Remote/LoopAPNS/OverridePresetsView.swift +++ b/LoopFollow/Remote/LoopAPNS/OverridePresetsView.swift @@ -396,45 +396,48 @@ class OverridePresetsViewModel: ObservableObject { } } - func activateOverride(preset: OverridePreset, duration: TimeInterval?) { - isActivating = true + func activateOverride(preset: OverridePreset, duration: TimeInterval?) async { + await MainActor.run { + isActivating = true + } - sendOverrideNotification(preset: preset, duration: duration) { success, errorMessage in - DispatchQueue.main.async { + do { + try await sendOverrideNotification(preset: preset, duration: duration) + await MainActor.run { self.isActivating = false - if success { - self.statusMessage = "\(preset.name) override activated successfully." - self.alertType = .statusSuccess - } else { - self.statusMessage = errorMessage ?? "Failed to activate override." - self.alertType = .statusFailure - } + self.statusMessage = "\(preset.name) override activated successfully." + self.alertType = .statusSuccess + self.showAlert = true + } + } catch { + await MainActor.run { + self.isActivating = false + self.statusMessage = "Failed to activate override: \(error.localizedDescription)" + self.alertType = .statusFailure self.showAlert = true } } } - func cancelOverride() { - isActivating = true + func cancelOverride() async { + await MainActor.run { + isActivating = true + } - sendCancelOverrideNotification { success, errorMessage in - DispatchQueue.main.async { + do { + try await sendCancelOverrideNotification() + await MainActor.run { self.isActivating = false - if success { - self.statusMessage = "Active override cancelled successfully." - self.alertType = .statusSuccess - } else { - self.statusMessage = errorMessage ?? "Failed to cancel override." - self.alertType = .statusFailure - } + self.statusMessage = "Active override cancelled successfully." + self.alertType = .statusSuccess self.showAlert = true } } catch { await MainActor.run { + self.isActivating = false self.statusMessage = "Failed to cancel override: \(error.localizedDescription)" self.alertType = .statusFailure self.showAlert = true - self.isActivating = false } } } @@ -462,18 +465,33 @@ class OverridePresetsViewModel: ObservableObject { } } - private func sendOverrideNotification(preset: OverridePreset, duration: TimeInterval?, completion: @escaping (Bool, String?) -> Void) { - let apnsService = LoopAPNSService() - apnsService.sendOverrideNotification( - presetName: preset.name, - duration: duration, - completion: completion - ) + private func sendOverrideNotification(preset: OverridePreset, duration: TimeInterval?) async throws { + 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(completion: @escaping (Bool, String?) -> Void) { - let apnsService = LoopAPNSService() - apnsService.sendCancelOverrideNotification(completion: completion) + private func sendCancelOverrideNotification() async throws { + 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"])) + } + } + } } } diff --git a/LoopFollow/Remote/TRC/PushNotificationManager.swift b/LoopFollow/Remote/TRC/PushNotificationManager.swift index d7816f38e..ac586b772 100644 --- a/LoopFollow/Remote/TRC/PushNotificationManager.swift +++ b/LoopFollow/Remote/TRC/PushNotificationManager.swift @@ -325,5 +325,4 @@ class PushNotificationManager { let urlString = "https://\(host)/3/device/\(deviceToken)" return URL(string: urlString) } - }