LocalData/Sources/LocalData/Helpers/SyncHelper.swift
Matt Bruce 66001439e3 Update Configuration, Helpers, Models (+1 more) + tests
Summary:
- Sources: Configuration, Helpers, Models, Services
- Tests: AppGroupTests.swift, FileStorageHelperExpansionTests.swift, FileStorageHelperTests.swift, MigrationTests.swift, RouterConfigurationTests.swift (+5 more)
- Added symbols: func resolveDirectoryURL, func handleReceivedContext, enum KeychainAccessControl, enum KeychainAccessibility, enum SecurityPolicy, enum EncryptionPolicy (+5 more)
- Removed symbols: func resolveDirectoryURL, func handleReceivedContext, enum KeychainAccessControl, enum KeychainAccessibility, enum SecurityPolicy, enum EncryptionPolicy (+1 more)

Stats:
- 18 files changed, 306 insertions(+), 58 deletions(-)
2026-01-18 14:53:29 -06:00

156 lines
5.2 KiB
Swift

import Foundation
import WatchConnectivity
/// Actor that handles WatchConnectivity sync operations.
/// Manages data synchronization between iPhone and Apple Watch.
actor SyncHelper {
public static let shared = SyncHelper()
private var configuration: SyncConfiguration
internal init(configuration: SyncConfiguration = .default) {
self.configuration = configuration
}
/// Updates the sync configuration.
public func updateConfiguration(_ configuration: SyncConfiguration) {
self.configuration = configuration
}
// MARK: - Public Interface
/// Syncs data to the paired device if appropriate.
/// - Parameters:
/// - data: The data to sync.
/// - keyName: The key name for the application context.
/// - availability: The platform availability setting.
/// - syncPolicy: The sync policy setting.
/// - Throws: `StorageError.dataTooLargeForSync` if data exceeds size limit for automatic sync.
public func syncIfNeeded(
data: Data,
keyName: String,
availability: PlatformAvailability,
syncPolicy: SyncPolicy
) throws {
// Only sync for appropriate availability settings
guard availability == .all || availability == .phoneWithWatchSync else {
return
}
switch syncPolicy {
case .never:
return
case .automaticSmall:
guard data.count <= configuration.maxAutoSyncSize else {
throw StorageError.dataTooLargeForSync
}
try performSync(data: data, keyName: keyName)
case .manual:
try performSync(data: data, keyName: keyName)
}
}
/// Manually triggers a sync for the given data.
/// - Parameters:
/// - data: The data to sync.
/// - keyName: The key name for the application context.
/// - Throws: Various errors if sync fails.
public func manualSync(data: Data, keyName: String) throws {
try performSync(data: data, keyName: keyName)
}
/// Checks if sync is available.
/// - Returns: True if WatchConnectivity is supported and active.
public func isSyncAvailable() -> Bool {
guard WCSession.isSupported() else { return false }
let session = WCSession.default
guard session.activationState == .activated else { return false }
#if os(iOS)
return session.isPaired && session.isWatchAppInstalled
#else
return true
#endif
}
/// Gets the current application context.
/// - Returns: The current application context dictionary.
public func currentContext() -> [String: Any] {
guard WCSession.isSupported() else { return [:] }
return WCSession.default.applicationContext
}
// MARK: - Private Helpers
private func performSync(data: Data, keyName: String) throws {
guard WCSession.isSupported() else { return }
let session = WCSession.default
if session.delegate == nil {
setupSession()
}
guard session.activationState == .activated else { return }
#if os(iOS)
guard session.isPaired, session.isWatchAppInstalled else { return }
#endif
try session.updateApplicationContext([keyName: data])
}
private func setupSession() {
let session = WCSession.default
session.delegate = SessionDelegateProxy.shared
session.activate()
}
/// Handles received application context from the paired device.
/// This is called by the delegate proxy.
internal func handleReceivedContext(_ context: [String: Any]) async {
Logger.info(">>> [SYNC] Received application context with \(context.count) keys")
for (key, value) in context {
guard let data = value as? Data else {
continue
}
Logger.debug(">>> [SYNC] Processing received data for key: \(key)")
do {
try await StorageRouter.shared.updateFromSync(keyName: key, data: data)
} catch {
Logger.error("Failed to update storage from sync for key: \(key)", error: error)
}
}
}
}
/// A private proxy class to handle WCSessionDelegate callbacks and route them to the SyncHelper actor.
private final class SessionDelegateProxy: NSObject, WCSessionDelegate {
static let shared = SessionDelegateProxy()
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
if let error = error {
Logger.error("WCSession activation failed: \(error.localizedDescription)")
} else {
Logger.info("WCSession activated with state: \(activationState.rawValue)")
}
}
func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
Task {
await SyncHelper.shared.handleReceivedContext(applicationContext)
}
}
#if os(iOS)
func sessionDidBecomeInactive(_ session: WCSession) {}
func sessionDidDeactivate(_ session: WCSession) {
session.activate()
}
#endif
}