LocalData/Documentation/PlatformSync.md

6.0 KiB

Platform Sync (iOS + watchOS)

This document describes the end-to-end WatchConnectivity workflow used with LocalData. It covers the responsibilities of the LocalData package versus app-level wiring, and shows how to build a launch-order-safe sync handshake.

Goals

  • Sync selected keys between iOS and watchOS using WCSession.updateApplicationContext.
  • Avoid launch-order dependencies (either app can start first).
  • Keep business data stored in LocalData; the watch app displays data in memory.

What LocalData Provides

LocalData does not activate WCSession for you. It provides the core sync utilities:

  • SyncHelper.syncIfNeeded(...) sends applicationContext for eligible keys.
  • StorageRouter.syncRegisteredKeysIfNeeded() re-sends stored values for all registered keys that are eligible for sync.
  • StorageRouter.syncSnapshot() builds a payload snapshot (key → Data) to reply to watch requests.

These APIs are intentionally low-level so apps can decide when to sync (launch, reachability, user actions, etc.).

What the App Must Provide

Your app owns the WatchConnectivity lifecycle:

  1. Activate WCSession on iOS and watch.
  2. Bootstrap sync at app launch and on watch availability changes.
  3. Handle watch-initiated requests and reply with a snapshot.
  4. Decode payloads on watch and update UI state.

iOS App

  • Create a WatchConnectivityService (or similar) that:
    • Activates WCSession.
    • Calls StorageRouter.syncRegisteredKeysIfNeeded() on:
      • activationDidComplete
      • sessionWatchStateDidChange
      • sessionReachabilityDidChange
    • Handles watch messages:
      • didReceiveMessage(_:replyHandler:) → reply with StorageRouter.syncSnapshot()
      • didReceiveUserInfo → trigger syncRegisteredKeysIfNeeded() for queued requests

watchOS App

  • Create a WatchConnectivityService that:

    • Activates WCSession.
    • Requests sync on launch or reachability changes:
      • If reachable: sendMessage with a request_sync key and use the reply payload.
      • If not reachable: transferUserInfo with the same request key (queued until iOS launches).
    • Loads applicationContext on activation and decodes known keys.
  • Create lightweight handlers (e.g., UserProfileWatchHandler) to decode payloads into in-memory state.

Launch-Order-Safe Handshake

This prevents the "watch first" or "phone first" problem.

  1. Watch launches first
    • Sends request_sync via transferUserInfo if iPhone unreachable.
    • When iPhone launches, it receives the queued request and replies with a snapshot.
  2. iPhone launches first
    • Bootstraps by calling syncRegisteredKeysIfNeeded().
    • When the watch launches, it requests a sync and receives a snapshot immediately.

Either order works with this handshake.

Data Eligibility Rules

A key is eligible for sync when:

  • availability is .all or .phoneWithWatchSync
  • syncPolicy is .manual or .automaticSmall
  • For .automaticSmall, the payload must be <= SyncConfiguration.maxAutoSyncSize

Keys with .never will not sync unless your app explicitly overrides behavior.

Example: iOS Service

@MainActor
final class WatchConnectivityService: NSObject, WCSessionDelegate {
    static let shared = WatchConnectivityService()

    private override init() {
        super.init()
        let session = WCSession.default
        session.delegate = self
        session.activate()
    }

    func session(_ session: WCSession, activationDidCompleteWith: WCSessionActivationState, error: Error?) {
        Task { await StorageRouter.shared.syncRegisteredKeysIfNeeded() }
    }

    func sessionWatchStateDidChange(_ session: WCSession) {
        Task { await StorageRouter.shared.syncRegisteredKeysIfNeeded() }
    }

    func sessionReachabilityDidChange(_ session: WCSession) {
        Task { await StorageRouter.shared.syncRegisteredKeysIfNeeded() }
    }

    func session(
        _ session: WCSession,
        didReceiveMessage message: [String: Any],
        replyHandler: @escaping ([String: Any]) -> Void
    ) {
        Task {
            let snapshot = await StorageRouter.shared.syncSnapshot()
            replyHandler(snapshot.isEmpty ? ["ack": true] : snapshot)
            await StorageRouter.shared.syncRegisteredKeysIfNeeded()
        }
    }
}

Example: watch Service

@MainActor
final class WatchConnectivityService: NSObject, WCSessionDelegate {
    static let shared = WatchConnectivityService()

    private override init() {
        super.init()
        let session = WCSession.default
        session.delegate = self
        session.activate()
    }

    func session(_ session: WCSession, activationDidCompleteWith: WCSessionActivationState, error: Error?) {
        requestSyncIfNeeded()
    }

    func sessionReachabilityDidChange(_ session: WCSession) {
        requestSyncIfNeeded()
    }

    private func requestSyncIfNeeded() {
        let session = WCSession.default
        if session.isReachable {
            session.sendMessage(["request_sync": true]) { reply in
                // Apply payload
            }
        } else {
            session.transferUserInfo(["request_sync": true])
        }
    }
}

Common Pitfalls

  • Simulator unreliability: WatchConnectivity is inconsistent in simulators. Test on real devices.
  • Missing entitlements: Watch app must be embedded and signed correctly.
  • Launch order assumptions: Always use the handshake; do not assume which app starts first.
  • Large payloads: Automatic sync will fail if the payload exceeds the configured max size.

SecureStorageSample Reference

For a working implementation, see:

  • iOS: SecureStorageSample/SecureStorageSample/Services/WatchConnectivityService.swift
  • Watch: SecureStorageSample/SecureStorageSample Watch App/Services/WatchConnectivityService.swift
  • Watch handlers: SecureStorageSample/SecureStorageSample Watch App/Services/Handlers/
  • Bootstrap: SecureStorageSample/SecureStorageSample/SecureStorageSampleApp.swift