491 lines
20 KiB
Swift
491 lines
20 KiB
Swift
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
|
|
}()
|
|
}
|