feat: implement Haptika PPG and Watch V1

This commit is contained in:
Sacha
2026-07-31 16:19:42 +02:00
parent 6b7d4e0728
commit b9930b7378
249 changed files with 46475 additions and 122 deletions
@@ -0,0 +1,65 @@
import Foundation
import React
/// Thin React Native facade over the central WCSession owner. The PPG and
/// Core Haptics objects remain Nitro modules; connectivity is intentionally a
/// process-wide service and therefore must not be instantiated per HybridObject.
@objc(HaptikaWatchConnectivity)
final class HaptikaWatchConnectivityModule: NSObject {
@objc static func requiresMainQueueSetup() -> Bool { false }
@objc func start() {
HaptikaWatchConnectivityService.shared.activate()
}
@objc func getConnectionState(
_ resolve: RCTPromiseResolveBlock,
rejecter reject: RCTPromiseRejectBlock
) {
let state = HaptikaWatchConnectivityService.shared.state()
resolve([
"supported": state.supported,
"paired": state.paired,
"watchAppInstalled": state.installed,
"reachable": state.reachable,
"activationState": state.activationState,
"latestEventId": state.latestEventId ?? NSNull(),
"latestEventAt": state.latestEventAt ?? NSNull()
])
}
@objc func drainEvents(
_ resolve: RCTPromiseResolveBlock,
rejecter reject: RCTPromiseRejectBlock
) {
resolve(HaptikaWatchConnectivityService.shared.drainEvents())
}
@objc func acknowledgeEvents(_ eventIds: [String]) {
HaptikaWatchConnectivityService.shared.acknowledge(eventIds: eventIds)
}
@objc func sendPreferences(
_ json: String,
resolver resolve: RCTPromiseResolveBlock,
rejecter reject: RCTPromiseRejectBlock
) {
resolve(HaptikaWatchConnectivityService.shared.sendPreferences(json: json))
}
@objc func sendCommand(
_ json: String,
resolver resolve: RCTPromiseResolveBlock,
rejecter reject: RCTPromiseRejectBlock
) {
resolve(HaptikaWatchConnectivityService.shared.sendCommand(json: json))
}
@objc func transferEvent(
_ json: String,
resolver resolve: RCTPromiseResolveBlock,
rejecter reject: RCTPromiseRejectBlock
) {
resolve(HaptikaWatchConnectivityService.shared.transferEvent(json: json))
}
}
@@ -0,0 +1,21 @@
#import <React/RCTBridgeModule.h>
@interface RCT_EXTERN_MODULE(HaptikaWatchConnectivity, NSObject)
RCT_EXTERN_METHOD(start)
RCT_EXTERN_METHOD(getConnectionState:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(drainEvents:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(acknowledgeEvents:(NSArray<NSString *> *)eventIds)
RCT_EXTERN_METHOD(sendPreferences:(NSString *)json
resolver:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(sendCommand:(NSString *)json
resolver:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(transferEvent:(NSString *)json
resolver:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
@end
@@ -0,0 +1,490 @@
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
}()
}