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 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
return WCSession.default.isPaired
guard let session = self.session else { return false }
return session.isPaired
}
AsyncFunction("isWatchAppInstalled") { () -> Bool in
return WCSession.default.isWatchAppInstalled
guard let session = self.session else { return false }
return session.isWatchAppInstalled
}
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
var message = plan
message["type"] = "workoutPlan"
guard let session = self.session else {
print("[WatchConnectivity] sendWorkoutPlan: NO_SESSION")
promise.reject("NO_SESSION", "WCSession not available")
return
}
if WCSession.default.isReachable {
WCSession.default.sendMessage(message, replyHandler: { reply in
promise.resolve(reply)
}, errorHandler: { error in
promise.reject(error)
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 {
WCSession.default.transferUserInfo(message)
promise.resolve(["status": "queued"])
print("[WatchConnectivity] Session not activated yet (state=\(session.activationState.rawValue)), queuing...")
self.pendingPromises.append(doSend)
}
}
AsyncFunction("sendWorkoutCompletion") { (data: [String: Any], promise: Promise) in
var message = data
message["type"] = "workoutCompleted"
guard let session = self.session else {
promise.reject("NO_SESSION", "WCSession not available")
return
}
if WCSession.default.isReachable {
WCSession.default.sendMessage(message, replyHandler: { reply in
promise.resolve(reply)
}, errorHandler: { error in
promise.reject(error)
})
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 {
WCSession.default.transferUserInfo(message)
promise.resolve(["status": "queued"])
self.pendingPromises.append(doSend)
}
}
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
try? WCSession.default.updateApplicationContext(context)
try? self.session?.updateApplicationContext(context)
}
}
}
@@ -23,8 +23,9 @@ export function PreSwimScreen() {
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const route = useRoute<RouteProp<RootStackParamList, "PreSwim">>();
const [workout, setWorkout] = useState<WorkoutWithSets | null>(null);
const [syncResult, setSyncResult] = useState<"sent" | "error" | null>(null);
const { isSyncing, sendWorkoutToWatch } = useSyncWorkout();
const { status, isPaired, isInstalled } = useWatchConnection();
const { status, isPaired, isInstalled, isReady } = useWatchConnection();
useEffect(() => {
loadWorkout();
@@ -38,7 +39,7 @@ export function PreSwimScreen() {
const handleStartSwim = async () => {
if (!workout) return;
if (status !== "connected") {
if (!isReady) {
navigation.navigate("PostSwim", { workoutId: route.params.workoutId });
return;
}
@@ -51,11 +52,11 @@ export function PreSwimScreen() {
);
if ("error" in result) {
navigation.navigate("PostSwim", { workoutId: route.params.workoutId });
setSyncResult("error");
return;
}
navigation.navigate("PostSwim", { workoutId: route.params.workoutId });
setSyncResult("sent");
};
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 (
<View className="flex-1 bg-white p-4">
<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={`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">
{status === "connected"
? "Apple Watch connected"
{isReady
? "Apple Watch ready"
: isPaired
? "Watch paired but not connected"
? isInstalled
? "Watch paired but not in range"
: "Watch paired, app not installed"
: "No Watch paired"}
</Text>
</View>
@@ -135,14 +178,14 @@ export function PreSwimScreen() {
<ActivityIndicator color="#fff" />
) : (
<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>
)}
</Pressable>
<Text className="text-slate-400 text-xs text-center mt-3">
{status === "connected"
? "Workout will be sent to your Apple Watch"
{isReady
? "Workout will be synced to your Apple Watch"
: "Workout will be tracked on this device"}
</Text>
</View>
@@ -15,6 +15,20 @@ export function useSyncWorkout() {
): Promise<SyncResult> => {
setIsSyncing(true);
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 = {
id: workoutId,
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);
console.log("[useSyncWorkout] Result:", JSON.stringify(result));
return result as SyncResult;
} catch (error: any) {
console.log("[useSyncWorkout] Error:", error.message, error.code);
return { error: error.message ?? "Failed to sync" };
} finally {
setIsSyncing(false);
@@ -28,11 +28,13 @@ export function useWatchConnection() {
setIsPaired(paired);
setIsInstalled(installed);
setStatus(reachable ? "connected" : "disconnected");
setStatus(reachable ? "connected" : paired ? "disconnected" : "disconnected");
} catch {
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 {
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() {
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
static func sendCompletedWorkout(_ laps: [[String: Any]], duration: TimeInterval, workoutId: String) {
let message: [String: Any] = [
@@ -29,7 +28,8 @@ class PhoneConnector: NSObject, ObservableObject, WCSessionDelegate {
if WCSession.default.isReachable {
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 {
WCSession.default.transferUserInfo(message)
@@ -39,32 +39,66 @@ class PhoneConnector: NSObject, ObservableObject, WCSessionDelegate {
// MARK: - WCSessionDelegate
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]) {
print("[PhoneConnector] didReceiveMessage (no reply)")
DispatchQueue.main.async { [weak self] in
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
self?.handleMessage(userInfo)
}
}
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 {
case "workoutPlan":
if let data = try? JSONSerialization.data(withJSONObject: message),
let workout = try? JSONDecoder().decode(StoredWorkout.self, from: data) {
workoutManager?.addWorkoutFromPhone(workout)
guard let data = try? JSONSerialization.data(withJSONObject: message) else {
print("[PhoneConnector] Failed to serialize message")
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:
break
print("[PhoneConnector] Unknown type: \(type)")
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
import SwiftUI
struct ContentView: View {
@StateObject private var workoutManager = WorkoutManager()
@EnvironmentObject var workoutManager: WorkoutManager
var body: some View {
NavigationView {
+10 -14
View File
@@ -25,8 +25,6 @@ class WorkoutManager: ObservableObject {
private var lapStartTime: Date?
private var completedLaps: [CompletedLap] = []
private let suiteName = "group.com.swimbuddy.app"
init() {
healthStore = HKHealthStore()
}
@@ -34,7 +32,7 @@ class WorkoutManager: ObservableObject {
// MARK: - Data Persistence
func loadSavedWorkouts() {
guard let defaults = UserDefaults(suiteName: suiteName) else { return }
let defaults = UserDefaults.standard
if let data = defaults.data(forKey: "savedWorkouts"),
let workouts = try? JSONDecoder().decode([StoredWorkout].self, from: data) {
@@ -48,7 +46,7 @@ class WorkoutManager: ObservableObject {
}
func saveWorkoutsToStorage() {
guard let defaults = UserDefaults(suiteName: suiteName) else { return }
let defaults = UserDefaults.standard
if let data = try? JSONEncoder().encode(savedWorkouts) {
defaults.set(data, forKey: "savedWorkouts")
}
@@ -234,16 +232,14 @@ class WorkoutManager: ObservableObject {
"duration": elapsedSeconds,
]
if let defaults = UserDefaults(suiteName: suiteName) {
var completed = completedWorkouts
var summary = selectedWorkout
summary?.completedAt = ISO8601DateFormatter().string(from: Date())
if let s = summary {
completed.insert(s, at: 0)
}
if let data = try? JSONEncoder().encode(completed) {
defaults.set(data, forKey: "completedWorkouts")
}
var completed = completedWorkouts
var summary = selectedWorkout
summary?.completedAt = ISO8601DateFormatter().string(from: Date())
if let s = summary {
completed.insert(s, at: 0)
}
if let data = try? JSONEncoder().encode(completed) {
UserDefaults.standard.set(data, forKey: "completedWorkouts")
}
if WCSession.default.isReachable {
+10 -1
View File
@@ -1,15 +1,24 @@
import SwiftUI
import WatchConnectivity
@main
struct SwimTrackerWatchApp: App {
@StateObject private var workoutManager = WorkoutManager()
init() {
if WCSession.isSupported() {
let session = WCSession.default
session.delegate = PhoneConnector.shared
session.activate()
}
}
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(workoutManager)
.onAppear {
PhoneConnector.shared.setup(workoutManager: workoutManager)
PhoneConnector.shared.workoutManager = workoutManager
}
}
}