import Foundation import WatchConnectivity /// The only owner of `WCSession` in the iPhone process. /// /// Durable Watch envelopes are persisted before being exposed to JavaScript. /// JavaScript explicitly acknowledges imported event IDs, after which the /// service removes them and sends an acknowledgement to the paired Watch. final class HaptikaWatchConnectivityService: NSObject, WCSessionDelegate { static let shared = HaptikaWatchConnectivityService() struct ConnectionState { let supported: Bool let paired: Bool let installed: Bool let reachable: Bool let activationState: String let latestEventId: String? let latestEventAt: String? } struct LiveHeartRate { let bpm: Double let timestampSeconds: Double } private static let inboxKey = "HaptikaWatchConnectivity.inbox.v1" private let lock = NSLock() private let defaults = UserDefaults.standard private var memoryLiveEvents: [String] = [] private var latestEventIdValue: String? private var latestEventAtValue: String? private var latestHeartRateValue: LiveHeartRate? private override init() { super.init() } func activate() { guard WCSession.isSupported() else { return } let session = WCSession.default if session.delegate !== self { session.delegate = self } if session.activationState != .activated { session.activate() } } func state() -> ConnectionState { guard WCSession.isSupported() else { return ConnectionState(supported: false, paired: false, installed: false, reachable: false, activationState: "notActivated", latestEventId: nil, latestEventAt: nil) } let session = WCSession.default lock.lock() let eventId = latestEventIdValue let eventAt = latestEventAtValue lock.unlock() let activation: String switch session.activationState { case .activated: activation = "activated" case .inactive: activation = "inactive" case .notActivated: activation = "notActivated" @unknown default: activation = "notActivated" } return ConnectionState( supported: true, paired: session.isPaired, installed: session.isWatchAppInstalled, reachable: session.isReachable, activationState: activation, latestEventId: eventId, latestEventAt: eventAt ) } func latestHeartRate() -> LiveHeartRate? { lock.lock() defer { lock.unlock() } return latestHeartRateValue } func drainEvents() -> [String] { lock.lock() defer { lock.unlock() } let durable = defaults.stringArray(forKey: Self.inboxKey) ?? [] return durable + memoryLiveEvents } func acknowledge(eventIds: [String]) { guard !eventIds.isEmpty else { return } let identifiers = Set(eventIds) lock.lock() let durable = defaults.stringArray(forKey: Self.inboxKey) ?? [] defaults.set(durable.filter { json in guard let eventId = Self.eventId(in: json) else { return false } return !identifiers.contains(eventId) }, forKey: Self.inboxKey) memoryLiveEvents.removeAll { json in guard let eventId = Self.eventId(in: json) else { return true } return identifiers.contains(eventId) } lock.unlock() sendAcknowledgement(eventIds: eventIds) } func sendPreferences(json: String) -> Bool { guard let dictionary = Self.dictionary(from: json), WCSession.isSupported() else { return false } let rawPayload = (dictionary["payload"] as? [String: Any]) ?? dictionary let save = (rawPayload["saveMindAndBodyWorkouts"] as? Bool) ?? true let catalogVersion = (rawPayload["protocolCatalogVersion"] as? String) ?? "1.0.0" let preferences: [String: Any] = [ "schemaVersion": 1, "saveMindAndBodyWorkouts": save, "protocolCatalogVersion": catalogVersion, "updatedAt": (rawPayload["updatedAt"] as? String) ?? Self.iso8601.string(from: Date()) ] guard let data = try? JSONSerialization.data(withJSONObject: preferences, options: [.sortedKeys]) else { return false } activate() do { try WCSession.default.updateApplicationContext(["preferences": data]) return true } catch { return false } } func sendCommand(json: String) -> Bool { guard let dictionary = Self.dictionary(from: json), WCSession.isSupported() else { return false } activate() guard WCSession.default.isReachable else { return false } let payload = (dictionary["payload"] as? [String: Any]) ?? dictionary guard let envelope = Self.watchWireEnvelope(kind: "mirrorCommand", payload: payload), let encoded = try? JSONSerialization.data(withJSONObject: envelope, options: [.sortedKeys]) else { return false } WCSession.default.sendMessageData(encoded, replyHandler: nil, errorHandler: nil) return true } func transferEvent(json: String) -> Bool { guard let dictionary = Self.dictionary(from: json), WCSession.isSupported(), let kind = dictionary["kind"] as? String, let payloadObject = dictionary["payload"], JSONSerialization.isValidJSONObject(payloadObject), let envelope = Self.watchWireEnvelope(kind: kind, payload: payloadObject), let encoded = try? JSONSerialization.data(withJSONObject: envelope, options: [.sortedKeys]) else { return false } activate() WCSession.default.transferUserInfo(["envelope": encoded]) return true } private func receive(_ dictionary: [String: Any], durable: Bool) { guard let normalized = normalize(dictionary), let data = try? JSONSerialization.data(withJSONObject: normalized, options: [.sortedKeys]), let json = String(data: data, encoding: .utf8) else { return } let kind = normalized["kind"] as? String let eventId = normalized["eventId"] as? String let sentAt = normalized["sentAt"] as? String lock.lock() latestEventIdValue = eventId latestEventAtValue = sentAt if kind == "liveHeartRate", let payload = normalized["payload"] as? [String: Any], let bpm = Self.double(payload["bpm"]), bpm >= 35, bpm <= 220, let timestamp = Self.double(payload["timestampSeconds"]), timestamp.isFinite { latestHeartRateValue = LiveHeartRate(bpm: bpm, timestampSeconds: timestamp) } if durable { var inbox = defaults.stringArray(forKey: Self.inboxKey) ?? [] if eventId == nil || !inbox.contains(where: { Self.eventId(in: $0) == eventId }) { inbox.append(json) defaults.set(Array(inbox.suffix(500)), forKey: Self.inboxKey) } } else { memoryLiveEvents.append(json) if memoryLiveEvents.count > 60 { memoryLiveEvents.removeFirst(memoryLiveEvents.count - 60) } } lock.unlock() } private func normalize(_ dictionary: [String: Any]) -> [String: Any]? { if dictionary["kind"] != nil, dictionary["eventId"] != nil || dictionary["eventID"] != nil { return normalizeEnvelopeDictionary(dictionary) } // Compatibility with the early prototype. It remains a live HR sample and // is never treated as a biological heartbeat. guard let bpm = Self.double(dictionary["bpm"]), bpm >= 35, bpm <= 220, let timestamp = Self.double(dictionary["timestampSeconds"]) else { return nil } return [ "schemaVersion": 1, "eventId": "legacy-live-\(UUID().uuidString)", "deviceId": "legacy-watch", "sequence": 0, "sentAt": Self.iso8601.string(from: Date()), "kind": "liveHeartRate", "payload": ["bpm": bpm, "timestampSeconds": timestamp] ] } private func normalizeEnvelopeDictionary(_ dictionary: [String: Any]) -> [String: Any]? { let eventId = (dictionary["eventId"] ?? dictionary["eventID"]) as? String let deviceId = (dictionary["deviceId"] ?? dictionary["deviceID"]) as? String guard let eventId, let deviceId, let kindValue = dictionary["kind"] as? String else { return nil } let kind = kindValue == "acknowledgement" ? "ack" : kindValue let sentAt = (dictionary["sentAt"] as? String) ?? Self.iso8601.string(from: Date()) let sequence = Self.double(dictionary["sequence"]) ?? 0 let payloadObject: Any if let payload = dictionary["payload"] as? [String: Any] { payloadObject = payload } else if let base64 = dictionary["payload"] as? String, let data = Data(base64Encoded: base64), let decoded = try? JSONSerialization.jsonObject(with: data) { payloadObject = decoded } else if let data = dictionary["payload"] as? Data, let decoded = try? JSONSerialization.jsonObject(with: data) { payloadObject = decoded } else { payloadObject = NSNull() } let normalizedPayload = normalizePayload(payloadObject, kind: kind, deviceId: deviceId, sentAt: sentAt) return [ "schemaVersion": 1, "eventId": eventId, "deviceId": deviceId, "sequence": sequence, "sentAt": sentAt, "kind": kind, "payload": normalizedPayload ] } private func normalizePayload(_ payload: Any, kind: String, deviceId: String, sentAt: String) -> Any { guard let value = payload as? [String: Any] else { return payload } // Current watchOS builds encode the exact cross-platform V1 DTO. Do not // reinterpret canonical fields such as `coverage`, `currentBpm` or the // compact sample offsets. if Self.double(value["schemaVersion"]) == 1 { if kind == "measurementUpsert" { var canonical = value for key in ["currentBpm", "averageBpm", "minimumBpm", "maximumBpm", "invalidReason", "feeling"] where canonical[key] == nil { canonical[key] = NSNull() } if var device = canonical["device"] as? [String: Any] { if device["model"] == nil { device["model"] = NSNull() } if device["systemVersion"] == nil { device["systemVersion"] = NSNull() } canonical["device"] = device } if var context = canonical["context"] as? [String: Any] { if context["posture"] == nil { context["posture"] = NSNull() } for key in ["recentExercise", "recentCaffeine", "recentMeal"] where context[key] == nil { context[key] = NSNull() } canonical["context"] = context } return canonical } if kind == "regulationSessionUpsert" { var canonical = value for key in ["beforeMeasurementId", "afterMeasurementId", "endedAt", "workoutUUID", "feelingBefore", "feelingAfter"] where canonical[key] == nil { canonical[key] = NSNull() } return canonical } if ["tombstone", "preferences"].contains(kind) { return value } } switch kind { case "measurementUpsert": guard let id = value["id"] as? String, let startedAt = value["startedAt"] as? String, let endedAt = value["endedAt"] as? String else { return value } let updatedAt = (value["updatedAt"] as? String) ?? sentAt let startedSeconds = Self.timestamp(startedAt) ?? 0 let duration = max(1, (Self.timestamp(endedAt) ?? startedSeconds + 60) - startedSeconds) let coverageSeconds = Self.double(value["coverageSeconds"]) ?? 0 let rawSamples = value["samples"] as? [[String: Any]] ?? [] let samples: [[String: Any]] = rawSamples.compactMap { sample in guard let bpm = Self.double(sample["bpm"]), let date = sample["date"] as? String, let sampleTimestamp = Self.timestamp(date) else { return nil } return ["offsetSeconds": max(0, sampleTimestamp - startedSeconds), "bpm": bpm] } let context = value["context"] as? [String: Any] ?? [:] let postureValue = context["posture"] as? String let posture: Any = ["seated", "lying", "standing"].contains(postureValue ?? "") ? Self.jsonString(postureValue) : NSNull() let reasons = value["invalidReasons"] as? [String] ?? [] let feeling: Any if let score = Self.double(value["feeling"]), (1 ... 5).contains(score) { feeling = ["score": Int(score), "recordedAt": updatedAt] } else { feeling = NSNull() } let devicePayload: [String: Any] = [ "id": deviceId, "name": (value["deviceName"] as? String) ?? "Apple Watch", "model": Self.jsonString(value["deviceModel"]), "systemVersion": NSNull() ] let contextPayload: [String: Any] = [ "atRest": (context["isAtRest"] as? Bool) ?? true, "posture": posture, "recentExercise": (context["recentExercise"] as? Bool) ?? false, "recentCaffeine": (context["recentCaffeine"] as? Bool) ?? false, "recentMeal": (context["recentMeal"] as? Bool) ?? false ] let normalizedCoverage = min(1.0, max(0.0, coverageSeconds / duration)) let normalizedQuality = min(1.0, max(0.0, Self.double(value["quality"]) ?? 0.0)) let invalidReason: Any = reasons.isEmpty ? NSNull() : reasons.joined(separator: ",") var normalized: [String: Any] = [:] normalized["schemaVersion"] = 1 normalized["id"] = id normalized["updatedAt"] = updatedAt normalized["startedAt"] = startedAt normalized["endedAt"] = endedAt normalized["source"] = "appleWatchOptical" normalized["device"] = devicePayload normalized["currentBpm"] = Self.jsonNumber(value["currentBPM"]) normalized["averageBpm"] = Self.jsonNumber(value["averageBPM"]) normalized["minimumBpm"] = Self.jsonNumber(value["minimumBPM"]) normalized["maximumBpm"] = Self.jsonNumber(value["maximumBPM"]) normalized["samples"] = samples normalized["coverage"] = normalizedCoverage normalized["quality"] = normalizedQuality normalized["valid"] = (value["isValid"] as? Bool) ?? false normalized["invalidReason"] = invalidReason normalized["context"] = contextPayload normalized["feeling"] = feeling return normalized case "regulationSessionUpsert": let sourceState = value["state"] as? String let normalizedState = sourceState == "interrupted" ? "cancelled" : (sourceState ?? "cancelled") var normalized: [String: Any] = [:] normalized["schemaVersion"] = 1 normalized["id"] = value["id"] as? String ?? "" normalized["updatedAt"] = value["updatedAt"] as? String ?? sentAt normalized["protocolId"] = value["protocolID"] as? String ?? "" normalized["beforeMeasurementId"] = value["beforeMeasurementID"] ?? NSNull() normalized["afterMeasurementId"] = value["afterMeasurementID"] ?? NSNull() normalized["startedAt"] = value["startedAt"] as? String ?? sentAt normalized["endedAt"] = value["endedAt"] ?? NSNull() normalized["durationSeconds"] = Self.double(value["durationSeconds"]) ?? 0.0 normalized["state"] = normalizedState normalized["workoutUUID"] = value["healthKitWorkoutID"] ?? NSNull() normalized["feelingBefore"] = value["feelingBefore"] ?? NSNull() normalized["feelingAfter"] = value["feelingAfter"] ?? NSNull() return normalized case "tombstone": let deletedAt = value["deletedAt"] as? String ?? sentAt return [ "schemaVersion": 1, "entityId": (value["entityId"] ?? value["entityID"]) as? String ?? "", "entityKind": value["entityKind"] as? String ?? "measurement", "deletedAt": deletedAt, "updatedAt": deletedAt ] case "liveHeartRate": let sampledAt = value["sampledAt"] as? String return [ "bpm": Self.double(value["bpm"]) ?? 0, "timestampSeconds": sampledAt.flatMap(Self.timestamp) ?? Date().timeIntervalSince1970, "measurementId": value["measurementID"] ?? NSNull() ] default: return value } } private func sendAcknowledgement(eventIds: [String]) { guard WCSession.isSupported() else { return } activate() let validEventIds = eventIds.compactMap(UUID.init(uuidString:)).map(\.uuidString) guard !validEventIds.isEmpty else { return } let payload: [String: Any] = ["eventIDs": validEventIds, "receivedAt": Self.iso8601.string(from: Date())] guard let envelope = Self.watchWireEnvelope(kind: "ack", payload: payload), let envelopeData = try? JSONSerialization.data(withJSONObject: envelope, options: [.sortedKeys]) else { return } if WCSession.default.isReachable { WCSession.default.sendMessageData(envelopeData, replyHandler: nil) { _ in WCSession.default.transferUserInfo(["envelope": envelopeData]) } } else { WCSession.default.transferUserInfo(["envelope": envelopeData]) } } func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {} func sessionDidBecomeInactive(_ session: WCSession) {} func sessionDidDeactivate(_ session: WCSession) { session.activate() } func sessionReachabilityDidChange(_ session: WCSession) {} func sessionWatchStateDidChange(_ session: WCSession) {} func session(_ session: WCSession, didReceiveMessage message: [String: Any]) { receive(message, durable: (message["kind"] as? String) != "liveHeartRate") } func session(_ session: WCSession, didReceiveMessageData messageData: Data) { receiveEnvelopeData(messageData, durable: false) } func session(_ session: WCSession, didReceiveMessageData messageData: Data, replyHandler: @escaping (Data) -> Void) { receiveEnvelopeData(messageData, durable: false) replyHandler(Data()) } func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) { receive(message, durable: (message["kind"] as? String) != "liveHeartRate") replyHandler(["accepted": true]) } func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) { if let envelope = userInfo["envelope"] as? Data { receiveEnvelopeData(envelope, durable: true) } else { receive(userInfo, durable: true) } } func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) { receive(applicationContext, durable: true) } private static let iso8601: ISO8601DateFormatter = { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] return formatter }() private static func dictionary(from json: String) -> [String: Any]? { guard let data = json.data(using: .utf8), let object = try? JSONSerialization.jsonObject(with: data), let dictionary = object as? [String: Any], JSONSerialization.isValidJSONObject(dictionary) else { return nil } return dictionary } private static func eventId(in json: String) -> String? { return dictionary(from: json)?["eventId"] as? String } private static func double(_ value: Any?) -> Double? { if let number = value as? NSNumber { return number.doubleValue } return value as? Double } private func receiveEnvelopeData(_ data: Data, durable: Bool) { guard let object = try? JSONSerialization.jsonObject(with: data), let dictionary = object as? [String: Any] else { return } receive(dictionary, durable: durable || (dictionary["kind"] as? String) != "liveHeartRate") } private static func timestamp(_ value: String) -> TimeInterval? { if let date = iso8601.date(from: value) { return date.timeIntervalSince1970 } let fallback = ISO8601DateFormatter() fallback.formatOptions = [.withInternetDateTime] return fallback.date(from: value)?.timeIntervalSince1970 } private static func jsonNumber(_ value: Any?) -> Any { return double(value).map { NSNumber(value: $0) } ?? NSNull() } private static func jsonString(_ value: Any?) -> Any { return (value as? String).map { $0 as Any } ?? NSNull() } private static func watchWireEnvelope(kind: String, payload: Any) -> [String: Any]? { guard JSONSerialization.isValidJSONObject(payload) else { return nil } return [ "schemaVersion": 1, "eventId": UUID().uuidString, "deviceId": UIDeviceIdentifier.current, "sequence": 0, "sentAt": iso8601.string(from: Date()), "kind": kind, "payload": payload ] } } private enum UIDeviceIdentifier { static let current: String = { let key = "HaptikaWatchConnectivity.deviceId.v1" if let existing = UserDefaults.standard.string(forKey: key) { return existing } let generated = "iphone-\(UUID().uuidString)" UserDefaults.standard.set(generated, forKey: key) return generated }() }