fix(watch-connectivity): improve WCSession lifecycle and message reliability

This commit is contained in:
2026-08-07 15:32:40 +02:00
parent e64e2353d9
commit 00a8220096
8 changed files with 266 additions and 71 deletions
+118 -24
View File
@@ -1,64 +1,158 @@
import ExpoModulesCore import ExpoModulesCore
import WatchConnectivity 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 { 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 { public func definition() -> ModuleDefinition {
Name("WatchConnectivity") 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 AsyncFunction("isSupported") { () -> Bool in
return WCSession.isSupported() return WCSession.isSupported()
} }
AsyncFunction("isPaired") { () -> Bool in AsyncFunction("isPaired") { () -> Bool in
return WCSession.default.isPaired guard let session = self.session else { return false }
return session.isPaired
} }
AsyncFunction("isWatchAppInstalled") { () -> Bool in AsyncFunction("isWatchAppInstalled") { () -> Bool in
return WCSession.default.isWatchAppInstalled guard let session = self.session else { return false }
return session.isWatchAppInstalled
} }
AsyncFunction("isReachable") { () -> Bool in AsyncFunction("isReachable") { () -> Bool in
return WCSession.default.isReachable guard let session = self.session else { return false }
return session.isReachable
} }
AsyncFunction("sendWorkoutPlan") { (plan: [String: Any], promise: Promise) in AsyncFunction("sendWorkoutPlan") { (plan: [String: Any], promise: Promise) in
var message = plan guard let session = self.session else {
message["type"] = "workoutPlan" print("[WatchConnectivity] sendWorkoutPlan: NO_SESSION")
promise.reject("NO_SESSION", "WCSession not available")
return
}
if WCSession.default.isReachable { func doSend() {
WCSession.default.sendMessage(message, replyHandler: { reply in var message = plan
promise.resolve(reply) message["type"] = "workoutPlan"
}, errorHandler: { error in
promise.reject(error) 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 { } else {
WCSession.default.transferUserInfo(message) print("[WatchConnectivity] Session not activated yet (state=\(session.activationState.rawValue)), queuing...")
promise.resolve(["status": "queued"]) self.pendingPromises.append(doSend)
} }
} }
AsyncFunction("sendWorkoutCompletion") { (data: [String: Any], promise: Promise) in AsyncFunction("sendWorkoutCompletion") { (data: [String: Any], promise: Promise) in
var message = data guard let session = self.session else {
message["type"] = "workoutCompleted" promise.reject("NO_SESSION", "WCSession not available")
return
}
if WCSession.default.isReachable { func doSend() {
WCSession.default.sendMessage(message, replyHandler: { reply in var message = data
promise.resolve(reply) message["type"] = "workoutCompleted"
}, errorHandler: { error in
promise.reject(error) 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 { } else {
WCSession.default.transferUserInfo(message) self.pendingPromises.append(doSend)
promise.resolve(["status": "queued"])
} }
} }
AsyncFunction("getApplicationContext") { () -> [String: Any]? in AsyncFunction("getApplicationContext") { () -> [String: Any]? in
return WCSession.default.receivedApplicationContext as [String: Any] return self.session?.receivedApplicationContext as? [String: Any]
} }
AsyncFunction("updateApplicationContext") { (context: [String: Any]) in AsyncFunction("updateApplicationContext") { (context: [String: Any]) in
try? WCSession.default.updateApplicationContext(context) try? self.session?.updateApplicationContext(context)
} }
} }
} }
@@ -23,8 +23,9 @@ export function PreSwimScreen() {
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>(); const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const route = useRoute<RouteProp<RootStackParamList, "PreSwim">>(); const route = useRoute<RouteProp<RootStackParamList, "PreSwim">>();
const [workout, setWorkout] = useState<WorkoutWithSets | null>(null); const [workout, setWorkout] = useState<WorkoutWithSets | null>(null);
const [syncResult, setSyncResult] = useState<"sent" | "error" | null>(null);
const { isSyncing, sendWorkoutToWatch } = useSyncWorkout(); const { isSyncing, sendWorkoutToWatch } = useSyncWorkout();
const { status, isPaired, isInstalled } = useWatchConnection(); const { status, isPaired, isInstalled, isReady } = useWatchConnection();
useEffect(() => { useEffect(() => {
loadWorkout(); loadWorkout();
@@ -38,7 +39,7 @@ export function PreSwimScreen() {
const handleStartSwim = async () => { const handleStartSwim = async () => {
if (!workout) return; if (!workout) return;
if (status !== "connected") { if (!isReady) {
navigation.navigate("PostSwim", { workoutId: route.params.workoutId }); navigation.navigate("PostSwim", { workoutId: route.params.workoutId });
return; return;
} }
@@ -51,11 +52,11 @@ export function PreSwimScreen() {
); );
if ("error" in result) { if ("error" in result) {
navigation.navigate("PostSwim", { workoutId: route.params.workoutId }); setSyncResult("error");
return; return;
} }
navigation.navigate("PostSwim", { workoutId: route.params.workoutId }); setSyncResult("sent");
}; };
if (!workout) { if (!workout) {
@@ -66,6 +67,46 @@ export function PreSwimScreen() {
); );
} }
if (syncResult === "sent") {
return (
<View className="flex-1 bg-white p-4 items-center justify-center">
<View className="bg-green-50 rounded-2xl p-6 items-center mb-6 w-full">
<Text className="text-4xl mb-3">⌚</Text>
<Text className="text-xl font-bold text-green-800 mb-2">Workout sent to Watch!</Text>
<Text className="text-green-600 text-center">
Open the SwimBuddy app on your Apple Watch to start tracking.
</Text>
</View>
<Pressable
onPress={() => navigation.popToTop()}
className="bg-slate-900 rounded-2xl py-4 items-center w-full"
>
<Text className="text-white font-bold text-lg">Done</Text>
</Pressable>
</View>
);
}
if (syncResult === "error") {
return (
<View className="flex-1 bg-white p-4 items-center justify-center">
<View className="bg-red-50 rounded-2xl p-6 items-center mb-6 w-full">
<Text className="text-4xl mb-3">⚠️</Text>
<Text className="text-xl font-bold text-red-800 mb-2">Failed to send</Text>
<Text className="text-red-600 text-center">
Could not send workout to Watch. Starting without Watch instead.
</Text>
</View>
<Pressable
onPress={() => navigation.navigate("PostSwim", { workoutId: route.params.workoutId })}
className="bg-sky-500 rounded-2xl py-4 items-center w-full"
>
<Text className="text-white font-bold text-lg">Start Without Watch</Text>
</Pressable>
</View>
);
}
return ( return (
<View className="flex-1 bg-white p-4"> <View className="flex-1 bg-white p-4">
<View className="items-center mb-6"> <View className="items-center mb-6">
@@ -112,14 +153,16 @@ export function PreSwimScreen() {
<View className="bg-slate-50 rounded-2xl p-3 mb-6 flex-row items-center gap-3"> <View className="bg-slate-50 rounded-2xl p-3 mb-6 flex-row items-center gap-3">
<View <View
className={`w-3 h-3 rounded-full ${ className={`w-3 h-3 rounded-full ${
status === "connected" ? "bg-green-500" : "bg-slate-300" isReady ? "bg-green-500" : isPaired ? "bg-yellow-500" : "bg-slate-300"
}`} }`}
/> />
<Text className="text-sm text-slate-600"> <Text className="text-sm text-slate-600">
{status === "connected" {isReady
? "Apple Watch connected" ? "Apple Watch ready"
: isPaired : isPaired
? "Watch paired but not connected" ? isInstalled
? "Watch paired but not in range"
: "Watch paired, app not installed"
: "No Watch paired"} : "No Watch paired"}
</Text> </Text>
</View> </View>
@@ -135,14 +178,14 @@ export function PreSwimScreen() {
<ActivityIndicator color="#fff" /> <ActivityIndicator color="#fff" />
) : ( ) : (
<Text className="text-white font-bold text-lg"> <Text className="text-white font-bold text-lg">
{status === "connected" ? "Send to Watch & Start" : "Start Without Watch"} {isReady ? "Send to Watch & Start" : "Start Without Watch"}
</Text> </Text>
)} )}
</Pressable> </Pressable>
<Text className="text-slate-400 text-xs text-center mt-3"> <Text className="text-slate-400 text-xs text-center mt-3">
{status === "connected" {isReady
? "Workout will be sent to your Apple Watch" ? "Workout will be synced to your Apple Watch"
: "Workout will be tracked on this device"} : "Workout will be tracked on this device"}
</Text> </Text>
</View> </View>
@@ -15,6 +15,20 @@ export function useSyncWorkout() {
): Promise<SyncResult> => { ): Promise<SyncResult> => {
setIsSyncing(true); setIsSyncing(true);
try { try {
console.log("[useSyncWorkout] Checking module availability...");
const supported = await WatchConnectivity.isSupported();
console.log("[useSyncWorkout] isSupported:", supported);
const paired = await WatchConnectivity.isPaired();
console.log("[useSyncWorkout] isPaired:", paired);
const installed = await WatchConnectivity.isWatchAppInstalled();
console.log("[useSyncWorkout] isWatchAppInstalled:", installed);
const reachable = await WatchConnectivity.isReachable();
console.log("[useSyncWorkout] isReachable:", reachable);
const plan = { const plan = {
id: workoutId, id: workoutId,
name: name ?? "Workout", name: name ?? "Workout",
@@ -29,9 +43,12 @@ export function useSyncWorkout() {
})), })),
}; };
console.log("[useSyncWorkout] Sending plan:", JSON.stringify(plan).slice(0, 200));
const result = await WatchConnectivity.sendWorkoutPlan(plan); const result = await WatchConnectivity.sendWorkoutPlan(plan);
console.log("[useSyncWorkout] Result:", JSON.stringify(result));
return result as SyncResult; return result as SyncResult;
} catch (error: any) { } catch (error: any) {
console.log("[useSyncWorkout] Error:", error.message, error.code);
return { error: error.message ?? "Failed to sync" }; return { error: error.message ?? "Failed to sync" };
} finally { } finally {
setIsSyncing(false); setIsSyncing(false);
@@ -28,11 +28,13 @@ export function useWatchConnection() {
setIsPaired(paired); setIsPaired(paired);
setIsInstalled(installed); setIsInstalled(installed);
setStatus(reachable ? "connected" : "disconnected"); setStatus(reachable ? "connected" : paired ? "disconnected" : "disconnected");
} catch { } catch {
setStatus("disconnected"); setStatus("disconnected");
} }
}; };
return { status, isPaired, isInstalled }; const isReady = isPaired && isInstalled;
return { status, isPaired, isInstalled, isReady };
} }
+52 -18
View File
@@ -3,21 +3,20 @@ import WatchConnectivity
class PhoneConnector: NSObject, ObservableObject, WCSessionDelegate { class PhoneConnector: NSObject, ObservableObject, WCSessionDelegate {
static let shared = PhoneConnector() static let shared = PhoneConnector()
private var workoutManager: WorkoutManager? var workoutManager: WorkoutManager? {
didSet {
if let pending = pendingWorkouts {
pending.forEach { workoutManager?.addWorkoutFromPhone($0) }
pendingWorkouts = nil
}
}
}
private var pendingWorkouts: [StoredWorkout]?
private override init() { private override init() {
super.init() super.init()
} }
func setup(workoutManager: WorkoutManager) {
self.workoutManager = workoutManager
if WCSession.isSupported() {
let session = WCSession.default
session.delegate = self
session.activate()
}
}
// Send completed workout data to phone // Send completed workout data to phone
static func sendCompletedWorkout(_ laps: [[String: Any]], duration: TimeInterval, workoutId: String) { static func sendCompletedWorkout(_ laps: [[String: Any]], duration: TimeInterval, workoutId: String) {
let message: [String: Any] = [ let message: [String: Any] = [
@@ -29,7 +28,8 @@ class PhoneConnector: NSObject, ObservableObject, WCSessionDelegate {
if WCSession.default.isReachable { if WCSession.default.isReachable {
WCSession.default.sendMessage(message, replyHandler: nil) { error in WCSession.default.sendMessage(message, replyHandler: nil) { error in
print("Failed to send: \(error)") print("[PhoneConnector] sendMessage failed, falling back to transferUserInfo: \(error)")
WCSession.default.transferUserInfo(message)
} }
} else { } else {
WCSession.default.transferUserInfo(message) WCSession.default.transferUserInfo(message)
@@ -39,32 +39,66 @@ class PhoneConnector: NSObject, ObservableObject, WCSessionDelegate {
// MARK: - WCSessionDelegate // MARK: - WCSessionDelegate
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
print("WatchConnectivity activated: \(activationState.rawValue)") print("[PhoneConnector] Activated: state=\(activationState.rawValue), isReachable=\(session.isReachable)")
}
func sessionReachabilityDidChange(_ session: WCSession) {
print("[PhoneConnector] Reachability changed: isReachable=\(session.isReachable)")
} }
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) { func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
print("[PhoneConnector] didReceiveMessage (no reply)")
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
self?.handleMessage(message) self?.handleMessage(message)
} }
} }
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) { func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) {
print("[PhoneConnector] didReceiveMessage (with reply)")
replyHandler(["status": "received"])
DispatchQueue.main.async { [weak self] in
self?.handleMessage(message)
}
}
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
print("[PhoneConnector] didReceiveUserInfo")
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
self?.handleMessage(userInfo) self?.handleMessage(userInfo)
} }
} }
private func handleMessage(_ message: [String: Any]) { private func handleMessage(_ message: [String: Any]) {
guard let type = message["type"] as? String else { return } guard let type = message["type"] as? String else {
print("[PhoneConnector] No type in message: \(message.keys)")
return
}
print("[PhoneConnector] Received message type: \(type)")
switch type { switch type {
case "workoutPlan": case "workoutPlan":
if let data = try? JSONSerialization.data(withJSONObject: message), guard let data = try? JSONSerialization.data(withJSONObject: message) else {
let workout = try? JSONDecoder().decode(StoredWorkout.self, from: data) { print("[PhoneConnector] Failed to serialize message")
workoutManager?.addWorkoutFromPhone(workout) return
}
print("[PhoneConnector] JSON data: \(String(data: data, encoding: .utf8) ?? "nil")")
do {
let workout = try JSONDecoder().decode(StoredWorkout.self, from: data)
print("[PhoneConnector] Decoded workout: \(workout.name ?? "nil"), sets: \(workout.sets.count)")
if let manager = workoutManager {
manager.addWorkoutFromPhone(workout)
} else {
print("[PhoneConnector] workoutManager not set, queuing workout")
if pendingWorkouts == nil { pendingWorkouts = [] }
pendingWorkouts?.append(workout)
}
} catch {
print("[PhoneConnector] Decode error: \(error)")
} }
default: default:
break print("[PhoneConnector] Unknown type: \(type)")
} }
} }
} }
+1 -1
View File
@@ -1,7 +1,7 @@
import SwiftUI import SwiftUI
struct ContentView: View { struct ContentView: View {
@StateObject private var workoutManager = WorkoutManager() @EnvironmentObject var workoutManager: WorkoutManager
var body: some View { var body: some View {
NavigationView { NavigationView {
+10 -14
View File
@@ -25,8 +25,6 @@ class WorkoutManager: ObservableObject {
private var lapStartTime: Date? private var lapStartTime: Date?
private var completedLaps: [CompletedLap] = [] private var completedLaps: [CompletedLap] = []
private let suiteName = "group.com.swimbuddy.app"
init() { init() {
healthStore = HKHealthStore() healthStore = HKHealthStore()
} }
@@ -34,7 +32,7 @@ class WorkoutManager: ObservableObject {
// MARK: - Data Persistence // MARK: - Data Persistence
func loadSavedWorkouts() { func loadSavedWorkouts() {
guard let defaults = UserDefaults(suiteName: suiteName) else { return } let defaults = UserDefaults.standard
if let data = defaults.data(forKey: "savedWorkouts"), if let data = defaults.data(forKey: "savedWorkouts"),
let workouts = try? JSONDecoder().decode([StoredWorkout].self, from: data) { let workouts = try? JSONDecoder().decode([StoredWorkout].self, from: data) {
@@ -48,7 +46,7 @@ class WorkoutManager: ObservableObject {
} }
func saveWorkoutsToStorage() { func saveWorkoutsToStorage() {
guard let defaults = UserDefaults(suiteName: suiteName) else { return } let defaults = UserDefaults.standard
if let data = try? JSONEncoder().encode(savedWorkouts) { if let data = try? JSONEncoder().encode(savedWorkouts) {
defaults.set(data, forKey: "savedWorkouts") defaults.set(data, forKey: "savedWorkouts")
} }
@@ -234,16 +232,14 @@ class WorkoutManager: ObservableObject {
"duration": elapsedSeconds, "duration": elapsedSeconds,
] ]
if let defaults = UserDefaults(suiteName: suiteName) { var completed = completedWorkouts
var completed = completedWorkouts var summary = selectedWorkout
var summary = selectedWorkout summary?.completedAt = ISO8601DateFormatter().string(from: Date())
summary?.completedAt = ISO8601DateFormatter().string(from: Date()) if let s = summary {
if let s = summary { completed.insert(s, at: 0)
completed.insert(s, at: 0) }
} if let data = try? JSONEncoder().encode(completed) {
if let data = try? JSONEncoder().encode(completed) { UserDefaults.standard.set(data, forKey: "completedWorkouts")
defaults.set(data, forKey: "completedWorkouts")
}
} }
if WCSession.default.isReachable { if WCSession.default.isReachable {
+10 -1
View File
@@ -1,15 +1,24 @@
import SwiftUI import SwiftUI
import WatchConnectivity
@main @main
struct SwimTrackerWatchApp: App { struct SwimTrackerWatchApp: App {
@StateObject private var workoutManager = WorkoutManager() @StateObject private var workoutManager = WorkoutManager()
init() {
if WCSession.isSupported() {
let session = WCSession.default
session.delegate = PhoneConnector.shared
session.activate()
}
}
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
ContentView() ContentView()
.environmentObject(workoutManager) .environmentObject(workoutManager)
.onAppear { .onAppear {
PhoneConnector.shared.setup(workoutManager: workoutManager) PhoneConnector.shared.workoutManager = workoutManager
} }
} }
} }