feat: implement Haptika PPG and Watch V1
This commit is contained in:
@@ -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
|
||||
}()
|
||||
}
|
||||
Reference in New Issue
Block a user