fix(watch-connectivity): improve WCSession lifecycle and message reliability
This commit is contained in:
@@ -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
|
||||
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"
|
||||
|
||||
if WCSession.default.isReachable {
|
||||
WCSession.default.sendMessage(message, replyHandler: { reply in
|
||||
promise.resolve(reply)
|
||||
}, 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 {
|
||||
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
|
||||
guard let session = self.session else {
|
||||
promise.reject("NO_SESSION", "WCSession not available")
|
||||
return
|
||||
}
|
||||
|
||||
func doSend() {
|
||||
var message = data
|
||||
message["type"] = "workoutCompleted"
|
||||
|
||||
if WCSession.default.isReachable {
|
||||
WCSession.default.sendMessage(message, replyHandler: { reply in
|
||||
if session.isReachable {
|
||||
session.sendMessage(message, replyHandler: { reply in
|
||||
promise.resolve(reply)
|
||||
}, errorHandler: { error in
|
||||
promise.reject(error)
|
||||
print("[WatchConnectivity] sendMessage failed, falling back to transferUserInfo: \(error)")
|
||||
session.transferUserInfo(message)
|
||||
promise.resolve(["status": "queued"])
|
||||
})
|
||||
} else {
|
||||
WCSession.default.transferUserInfo(message)
|
||||
session.transferUserInfo(message)
|
||||
promise.resolve(["status": "queued"])
|
||||
}
|
||||
}
|
||||
|
||||
if self.isActivated {
|
||||
doSend()
|
||||
} else {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@StateObject private var workoutManager = WorkoutManager()
|
||||
@EnvironmentObject var workoutManager: WorkoutManager
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
|
||||
@@ -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,7 +232,6 @@ class WorkoutManager: ObservableObject {
|
||||
"duration": elapsedSeconds,
|
||||
]
|
||||
|
||||
if let defaults = UserDefaults(suiteName: suiteName) {
|
||||
var completed = completedWorkouts
|
||||
var summary = selectedWorkout
|
||||
summary?.completedAt = ISO8601DateFormatter().string(from: Date())
|
||||
@@ -242,8 +239,7 @@ class WorkoutManager: ObservableObject {
|
||||
completed.insert(s, at: 0)
|
||||
}
|
||||
if let data = try? JSONEncoder().encode(completed) {
|
||||
defaults.set(data, forKey: "completedWorkouts")
|
||||
}
|
||||
UserDefaults.standard.set(data, forKey: "completedWorkouts")
|
||||
}
|
||||
|
||||
if WCSession.default.isReachable {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user