import ExpoModulesCore import WatchConnectivity private class SessionDelegate: NSObject, WCSessionDelegate { var onActivationComplete: ((WCSessionActivationState) -> Void)? func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { if let error = error { print("[WatchConnectivity] Activation failed: \(error)") } else { print("[WatchConnectivity] Activation complete: state=\(activationState.rawValue)") } onActivationComplete?(activationState) } func sessionDidBecomeInactive(_ session: WCSession) {} func sessionDidDeactivate(_ session: WCSession) { session.activate() } } public class WatchConnectivityModule: Module { private var session: WCSession? private let delegate = SessionDelegate() private var isActivated = false private var pendingPromises: [(() -> Void)] = [] // WCSession only supports NSString, NSNumber, NSData, NSArray, NSDictionary private func cleanForWCSession(_ obj: Any) -> Any { if let dict = obj as? [String: Any] { var cleaned: [String: Any] = [:] for (key, value) in dict { if value is NSNull { continue } cleaned[key] = cleanForWCSession(value) } return cleaned } else if let arr = obj as? [Any] { return arr.filter { !($0 is NSNull) }.map { cleanForWCSession($0) } } return obj } public func definition() -> ModuleDefinition { Name("WatchConnectivity") OnCreate { guard WCSession.isSupported() else { return } let session = WCSession.default session.delegate = self.delegate self.delegate.onActivationComplete = { [weak self] state in DispatchQueue.main.async { self?.isActivated = (state == .activated) print("[WatchConnectivity] isActivated = \(self?.isActivated ?? false)") let pending = self?.pendingPromises ?? [] self?.pendingPromises = [] pending.forEach { $0() } } } session.activate() self.session = session } AsyncFunction("isSupported") { () -> Bool in return WCSession.isSupported() } AsyncFunction("isPaired") { () -> Bool in guard let session = self.session else { return false } return session.isPaired } AsyncFunction("isWatchAppInstalled") { () -> Bool in guard let session = self.session else { return false } return session.isWatchAppInstalled } AsyncFunction("isReachable") { () -> Bool in guard let session = self.session else { return false } return session.isReachable } AsyncFunction("sendWorkoutPlan") { (plan: [String: Any], promise: Promise) in guard let session = self.session else { print("[WatchConnectivity] sendWorkoutPlan: NO_SESSION") promise.reject("NO_SESSION", "WCSession not available") return } func doSend() { var message = plan message["type"] = "workoutPlan" print("[WatchConnectivity] sendWorkoutPlan: isReachable=\(session.isReachable), activationState=\(session.activationState.rawValue)") // WCSession only supports NSString, NSNumber, NSData, NSArray, NSDictionary // Remove NSNull values (from JS null/undefined) guard let cleaned = self.cleanForWCSession(message) as? [String: Any] else { promise.resolve(["status": "sent"]) return } session.sendMessage(cleaned, replyHandler: nil, errorHandler: { error in print("[WatchConnectivity] sendMessage failed: \(error), falling back to transferUserInfo") session.transferUserInfo(cleaned) }) promise.resolve(["status": "sent"]) } // Check actual session state, not our flag (which has a race condition) if session.activationState == .activated { doSend() } else { print("[WatchConnectivity] Session not activated yet (state=\(session.activationState.rawValue)), queuing...") self.pendingPromises.append(doSend) } } AsyncFunction("sendWorkoutCompletion") { (data: [String: Any], promise: Promise) in guard let session = self.session else { promise.reject("NO_SESSION", "WCSession not available") return } func doSend() { var message = data message["type"] = "workoutCompleted" if session.isReachable { session.sendMessage(message, replyHandler: { reply in promise.resolve(reply) }, errorHandler: { error in print("[WatchConnectivity] sendMessage failed, falling back to transferUserInfo: \(error)") session.transferUserInfo(message) promise.resolve(["status": "queued"]) }) } else { session.transferUserInfo(message) promise.resolve(["status": "queued"]) } } if self.isActivated { doSend() } else { self.pendingPromises.append(doSend) } } AsyncFunction("getApplicationContext") { () -> [String: Any]? in return self.session?.receivedApplicationContext as? [String: Any] } AsyncFunction("updateApplicationContext") { (context: [String: Any]) in try? self.session?.updateApplicationContext(context) } } }