| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335 |
- // LoopFollow
- // PushNotificationManager.swift
- import Foundation
- import HealthKit
- class PushNotificationManager {
- private var deviceToken: String
- private var sharedSecret: String
- private var productionEnvironment: Bool
- private var apnsKey: String
- private var teamId: String
- private var keyId: String
- private var user: String
- private var bundleId: String
- init() {
- deviceToken = Storage.shared.deviceToken.value
- sharedSecret = Storage.shared.sharedSecret.value
- productionEnvironment = Storage.shared.productionEnvironment.value
- user = Storage.shared.user.value
- bundleId = Storage.shared.bundleId.value
- let lfTeamId = BuildDetails.default.teamID ?? ""
- let remoteTeamId = Storage.shared.teamId.value ?? ""
- let sameTeam = !lfTeamId.isEmpty && !remoteTeamId.isEmpty && lfTeamId == remoteTeamId
- if sameTeam || remoteTeamId.isEmpty {
- apnsKey = Storage.shared.lfApnsKey.value
- keyId = Storage.shared.lfKeyId.value
- teamId = lfTeamId
- } else {
- apnsKey = Storage.shared.remoteApnsKey.value
- keyId = Storage.shared.remoteKeyId.value
- teamId = remoteTeamId
- }
- }
- private func createReturnNotificationInfo() -> CommandPayload.ReturnNotificationInfo? {
- let loopFollowDeviceToken = Observable.shared.loopFollowDeviceToken.value
- guard !loopFollowDeviceToken.isEmpty else {
- return nil
- }
- 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
- }
- let lfKeyId = Storage.shared.lfKeyId.value
- let lfApnsKey = Storage.shared.lfApnsKey.value
- guard !lfKeyId.isEmpty, !lfApnsKey.isEmpty else {
- LogManager.shared.log(category: .apns, message: "Missing LoopFollow APNS credentials. Configure them in App Settings → APN.")
- return nil
- }
- return CommandPayload.ReturnNotificationInfo(
- productionEnvironment: BuildDetails.default.isTestFlightBuild(),
- deviceToken: loopFollowDeviceToken,
- bundleId: Bundle.main.bundleIdentifier ?? "",
- teamId: loopFollowTeamID,
- keyId: lfKeyId,
- apnsKey: lfApnsKey
- )
- }
- func sendOverridePushNotification(override: ProfileManager.TrioOverride, completion: @escaping (Bool, String?) -> Void) {
- let payload = CommandPayload(
- user: user,
- commandType: .startOverride,
- timestamp: Date().timeIntervalSince1970,
- overrideName: override.name,
- returnNotification: createReturnNotificationInfo()
- )
- sendEncryptedCommand(payload: payload, completion: completion)
- }
- func sendCancelOverridePushNotification(completion: @escaping (Bool, String?) -> Void) {
- let payload = CommandPayload(
- user: user,
- commandType: .cancelOverride,
- timestamp: Date().timeIntervalSince1970,
- overrideName: nil,
- returnNotification: createReturnNotificationInfo()
- )
- sendEncryptedCommand(payload: payload, completion: completion)
- }
- func sendBolusPushNotification(bolusAmount: HKQuantity, completion: @escaping (Bool, String?) -> Void) {
- let bolusAmountDecimal = Decimal(bolusAmount.doubleValue(for: .internationalUnit()))
- let payload = CommandPayload(
- user: user,
- commandType: .bolus,
- timestamp: Date().timeIntervalSince1970,
- bolusAmount: bolusAmountDecimal,
- returnNotification: createReturnNotificationInfo()
- )
- sendEncryptedCommand(payload: payload, completion: completion)
- }
- func sendTempTargetPushNotification(target: HKQuantity, duration: HKQuantity, completion: @escaping (Bool, String?) -> Void) {
- let targetValue = Int(target.doubleValue(for: HKUnit.milligramsPerDeciliter))
- let durationValue = Int(duration.doubleValue(for: HKUnit.minute()))
- let payload = CommandPayload(
- user: user,
- commandType: .tempTarget,
- timestamp: Date().timeIntervalSince1970,
- target: targetValue,
- duration: durationValue,
- returnNotification: createReturnNotificationInfo()
- )
- sendEncryptedCommand(payload: payload, completion: completion)
- }
- func sendCancelTempTargetPushNotification(completion: @escaping (Bool, String?) -> Void) {
- let payload = CommandPayload(
- user: user,
- commandType: .cancelTempTarget,
- timestamp: Date().timeIntervalSince1970,
- returnNotification: createReturnNotificationInfo()
- )
- sendEncryptedCommand(payload: payload, completion: completion)
- }
- func sendMealPushNotification(
- carbs: HKQuantity,
- protein: HKQuantity,
- fat: HKQuantity,
- bolusAmount: HKQuantity,
- scheduledTime: Date?,
- completion: @escaping (Bool, String?) -> Void
- ) {
- func convertToOptionalInt(_ quantity: HKQuantity) -> Int? {
- let valueInGrams = quantity.doubleValue(for: .gram())
- return valueInGrams > 0 ? Int(valueInGrams) : nil
- }
- func convertToOptionalDecimal(_ quantity: HKQuantity?) -> Decimal? {
- guard let quantity = quantity else { return nil }
- let value = quantity.doubleValue(for: .internationalUnit())
- return value > 0 ? Decimal(value) : nil
- }
- let carbsValue = convertToOptionalInt(carbs)
- let proteinValue = convertToOptionalInt(protein)
- let fatValue = convertToOptionalInt(fat)
- let scheduledTimeInterval: TimeInterval? = scheduledTime?.timeIntervalSince1970
- let bolusAmountValue = convertToOptionalDecimal(bolusAmount)
- guard carbsValue != nil || proteinValue != nil || fatValue != nil else {
- completion(false, "No nutrient data provided. At least one of carbs, fat, or protein must be greater than 0.")
- return
- }
- let payload = CommandPayload(
- user: user,
- commandType: .meal,
- timestamp: Date().timeIntervalSince1970,
- bolusAmount: bolusAmountValue,
- carbs: carbsValue,
- protein: proteinValue,
- fat: fatValue,
- scheduledTime: scheduledTimeInterval,
- returnNotification: createReturnNotificationInfo()
- )
- sendEncryptedCommand(payload: payload, completion: completion)
- }
- private func validateCredentials() -> [String]? {
- var errors = [String]()
- let keyIdPattern = "^[A-Z0-9]{10}$"
- if !matchesRegex(keyId, pattern: keyIdPattern) {
- errors.append("APNS Key ID (\(keyId)) must be 10 uppercase alphanumeric characters.")
- }
- let teamIdPattern = "^[A-Z0-9]{10}$"
- if !matchesRegex(teamId, pattern: teamIdPattern) {
- errors.append("Team ID (\(teamId)) must be 10 uppercase alphanumeric characters.")
- }
- if !apnsKey.contains("-----BEGIN PRIVATE KEY-----") || !apnsKey.contains("-----END PRIVATE KEY-----") {
- errors.append("APNS Key must be a valid PEM-formatted private key.")
- } else {
- 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
- }
- 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
- }
- private func extractKeyData(from pemString: String) -> String? {
- let lines = pemString.components(separatedBy: "\n")
- guard let startIndex = lines.firstIndex(of: "-----BEGIN PRIVATE KEY-----"),
- let endIndex = lines.firstIndex(of: "-----END PRIVATE KEY-----"),
- startIndex < endIndex
- else {
- return nil
- }
- let keyLines = lines[(startIndex + 1) ..< endIndex]
- return keyLines.joined()
- }
- private func sendEncryptedCommand(payload: CommandPayload, completion: @escaping (Bool, String?) -> Void) {
- var missingFields = [String]()
- if sharedSecret.isEmpty { missingFields.append("sharedSecret") }
- if apnsKey.isEmpty { missingFields.append("apnsKey") }
- if keyId.isEmpty { missingFields.append("keyId") }
- if user.isEmpty { missingFields.append("user") }
- if deviceToken.isEmpty { missingFields.append("deviceToken") }
- if bundleId.isEmpty { missingFields.append("bundleId") }
- if teamId.isEmpty { missingFields.append("teamId") }
- if !missingFields.isEmpty {
- let errorMessage = "Missing required fields for command: \(missingFields.joined(separator: ", "))"
- LogManager.shared.log(category: .apns, message: errorMessage)
- completion(false, errorMessage)
- return
- }
- if let validationErrors = validateCredentials() {
- let errorMessage = "Credential validation failed: \(validationErrors.joined(separator: ", "))"
- LogManager.shared.log(category: .apns, message: errorMessage)
- completion(false, errorMessage)
- return
- }
- guard let url = constructAPNsURL() else {
- let errorMessage = "Failed to construct APNs URL"
- LogManager.shared.log(category: .apns, message: errorMessage)
- completion(false, errorMessage)
- return
- }
- guard let jwt = JWTManager.shared.getOrGenerateJWT(keyId: keyId, teamId: teamId, apnsKey: apnsKey) else {
- let errorMessage = "Failed to generate JWT, please check that the token is correct."
- LogManager.shared.log(category: .apns, message: errorMessage)
- completion(false, errorMessage)
- return
- }
- do {
- guard let messenger = SecureMessenger(sharedSecret: sharedSecret) else {
- let errorMessage = "Failed to initialize security module. Check shared secret."
- LogManager.shared.log(category: .apns, message: errorMessage)
- completion(false, errorMessage)
- return
- }
- let encryptedDataString = try messenger.encrypt(payload)
- let finalMessage = EncryptedPushMessage(encryptedData: encryptedDataString, commandType: payload.commandType)
- var request = URLRequest(url: url)
- request.httpMethod = "POST"
- request.setValue("bearer \(jwt)", forHTTPHeaderField: "authorization")
- request.setValue("application/json", forHTTPHeaderField: "content-type")
- request.setValue("10", forHTTPHeaderField: "apns-priority")
- request.setValue("600", forHTTPHeaderField: "apns-expiration")
- request.setValue(bundleId, forHTTPHeaderField: "apns-topic")
- request.setValue("alert", forHTTPHeaderField: "apns-push-type")
- request.setValue(payload.commandType.rawValue, forHTTPHeaderField: "apns-collapse-id")
- request.httpBody = try JSONEncoder().encode(finalMessage)
- 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 {
- LogManager.shared.log(category: .apns, message: "Push notification sent. Status code: \(httpResponse.statusCode)", isDebug: true)
- var responseBodyMessage = ""
- if let data = data, let responseBody = String(data: data, encoding: .utf8) {
- LogManager.shared.log(category: .apns, message: "Response body: \(responseBody)", isDebug: true)
- 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:
- completion(true, nil)
- case 400:
- completion(false, "Bad request. The request was invalid or malformed. \(responseBodyMessage)")
- case 403:
- JWTManager.shared.invalidateCache()
- completion(false, "Authentication error. Check your certificate or authentication token. \(responseBodyMessage)")
- case 404:
- completion(false, "Invalid request: The :path value was incorrect. \(responseBodyMessage)")
- case 405:
- completion(false, "Invalid request: Only POST requests are supported. \(responseBodyMessage)")
- case 410:
- completion(false, "The device token is no longer active for the topic. \(responseBodyMessage)")
- case 413:
- completion(false, "Payload too large. The notification payload exceeded the size limit. \(responseBodyMessage)")
- case 429:
- completion(false, "Too many requests. \(responseBodyMessage)")
- case 500:
- completion(false, "Internal server error at APNs. \(responseBodyMessage)")
- case 503:
- completion(false, "Service unavailable. The server is temporarily unavailable. Try again later. \(responseBodyMessage)")
- default:
- completion(false, "Unexpected status code: \(httpResponse.statusCode). \(responseBodyMessage)")
- }
- } else {
- completion(false, "Failed to get a valid HTTP response.")
- }
- }
- task.resume()
- } catch {
- let errorMessage = "Failed to encode or encrypt push message: \(error.localizedDescription)"
- LogManager.shared.log(category: .apns, message: errorMessage)
- completion(false, errorMessage)
- }
- }
- private func constructAPNsURL() -> URL? {
- let host = productionEnvironment ? "api.push.apple.com" : "api.sandbox.push.apple.com"
- let urlString = "https://\(host)/3/device/\(deviceToken)"
- return URL(string: urlString)
- }
- }
|