SecureStorageSample/SecureStorageSample/Services/WatchConnectivityService.swift

125 lines
4.7 KiB
Swift

import Foundation
import LocalData
import SharedKit
import WatchConnectivity
@MainActor
/// iOS-side WatchConnectivity bridge that keeps the watch app in sync with LocalData.
/// This class focuses on coordination and delegates data payload creation to LocalData.
final class WatchConnectivityService: NSObject, WCSessionDelegate {
static let shared = WatchConnectivityService()
private override init() {
super.init()
activateIfSupported()
}
/// Activates WCSession only on supported devices (no-ops on unsupported hardware).
private func activateIfSupported() {
guard WCSession.isSupported() else { return }
let session = WCSession.default
session.delegate = self
session.activate()
}
func session(
_ session: WCSession,
activationDidCompleteWith activationState: WCSessionActivationState,
error: Error?
) {
if let error {
Logger.error("iOS WCSession activation failed", error: error)
} else {
Logger.debug("iOS WCSession activated with state: \(activationState.rawValue)")
}
// Try to sync any previously registered keys as soon as WCSession is ready.
Task {
await StorageRouter.shared.syncRegisteredKeysIfNeeded()
}
}
func sessionDidBecomeInactive(_ session: WCSession) {
// No-op; required for iOS WCSessionDelegate.
}
func sessionDidDeactivate(_ session: WCSession) {
session.activate()
}
func sessionWatchStateDidChange(_ session: WCSession) {
Logger.debug("iOS WCSession watch state changed: paired=\(session.isPaired) installed=\(session.isWatchAppInstalled)")
// A watch install or pairing change is a good time to re-send syncable keys.
Task {
await StorageRouter.shared.syncRegisteredKeysIfNeeded()
}
}
func sessionReachabilityDidChange(_ session: WCSession) {
Logger.debug("iOS WCSession reachability changed: reachable=\(session.isReachable)")
// When reachability flips, attempt to push any pending sync payloads.
Task {
await StorageRouter.shared.syncRegisteredKeysIfNeeded()
}
}
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
// Watch may request a sync without needing a reply (fire-and-forget message).
if let request = message[WatchSyncMessageKeys.requestSync] as? Bool, request {
Logger.debug("iOS received watch sync request")
Task {
let snapshot = await StorageRouter.shared.syncSnapshot()
if snapshot.isEmpty {
Logger.debug("iOS sync snapshot empty; falling back to application context")
}
await StorageRouter.shared.syncRegisteredKeysIfNeeded()
}
}
}
func session(
_ session: WCSession,
didReceiveMessage message: [String: Any],
replyHandler: @escaping ([String: Any]) -> Void
) {
// Reply-based handshake: watch expects a payload immediately if reachable.
if let request = message[WatchSyncMessageKeys.requestSync] as? Bool, request {
Logger.debug("iOS received watch sync request (reply)")
Task {
let payload = await buildSyncReplyPayload()
replyHandler(payload)
await StorageRouter.shared.syncRegisteredKeysIfNeeded()
}
}
}
/// Attempts to build a snapshot payload, retrying briefly in case keys are still initializing.
private func buildSyncReplyPayload() async -> [String: Any] {
let maxAttempts = 3
for attempt in 1...maxAttempts {
let snapshot = await StorageRouter.shared.syncSnapshot()
if !snapshot.isEmpty {
Logger.debug("iOS sync reply snapshot ready on attempt \(attempt)")
return snapshot
}
if attempt < maxAttempts {
Logger.debug("iOS sync reply snapshot empty; retrying (\(attempt))")
try? await Task.sleep(for: .milliseconds(300))
}
}
Logger.debug("iOS sync reply snapshot empty after retries; replying with ack only")
return ["ack": true]
}
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
// transferUserInfo can deliver requests when the watch was previously unreachable.
if let request = userInfo[WatchSyncMessageKeys.requestSync] as? Bool, request {
Logger.debug("iOS received queued watch sync request")
Task {
await StorageRouter.shared.syncRegisteredKeysIfNeeded()
}
}
}
}