diff --git a/apps/ios/Tests/Logic/WatchChatStatusLocalizationTests.swift b/apps/ios/Tests/Logic/WatchChatStatusLocalizationTests.swift new file mode 100644 index 000000000000..890d7f783918 --- /dev/null +++ b/apps/ios/Tests/Logic/WatchChatStatusLocalizationTests.swift @@ -0,0 +1,66 @@ +import Foundation +import OpenClawKit +import Testing + +struct WatchChatStatusLocalizationTests { + @Test func `snapshot parser keeps known status codes`() throws { + let snapshot = try #require(WatchAppSnapshotMessage.parsePayload(Self.payload( + chatStatusCode: OpenClawWatchChatStatusCode.connectIPhone.rawValue, + chatStatusText: "Legacy status"))) + + #expect(snapshot.chatStatusCode == .connectIPhone) + #expect(snapshot.chatStatusText == "Legacy status") + } + + @Test func `snapshot parser preserves legacy fallback for unknown status codes`() throws { + let snapshot = try #require(WatchAppSnapshotMessage.parsePayload(Self.payload( + chatStatusCode: "futureStatus", + chatStatusText: "Future status from iPhone"))) + + #expect(snapshot.chatStatusCode == nil) + #expect(snapshot.chatStatusText == "Future status from iPhone") + } + + @Test func `watch renders known status locally before legacy text`() { + let rendered = WatchAppSnapshotMessage.localizedChatStatusText( + statusCode: .noMessages, + legacyText: "English status from iPhone", + chatCount: 0, + hasAppSnapshot: true) + + #expect(rendered == String(localized: "No chat messages yet")) + } + + @Test func `watch renders legacy text when status code is unknown`() throws { + let snapshot = try #require(WatchAppSnapshotMessage.parsePayload(Self.payload( + chatStatusCode: "futureStatus", + chatStatusText: "Future status from iPhone"))) + let rendered = WatchAppSnapshotMessage.localizedChatStatusText( + statusCode: snapshot.chatStatusCode, + legacyText: snapshot.chatStatusText, + chatCount: 0, + hasAppSnapshot: true) + + #expect(rendered == "Future status from iPhone") + } + + private static func payload( + chatStatusCode: String, + chatStatusText: String) -> [String: Any] + { + [ + "type": OpenClawWatchPayloadType.appSnapshot.rawValue, + "gatewayStatusText": "Connected", + "gatewayConnected": true, + "agentName": "Main", + "sessionKey": "main", + "talkStatusText": "Off", + "talkEnabled": false, + "talkListening": false, + "talkSpeaking": false, + "pendingApprovalCount": 0, + "chatStatusCode": chatStatusCode, + "chatStatusText": chatStatusText, + ] + } +} diff --git a/apps/ios/WatchApp/Sources/WatchConnectivityReceiver.swift b/apps/ios/WatchApp/Sources/WatchConnectivityReceiver.swift index 2916ff21dbc1..f3458595ec1a 100644 --- a/apps/ios/WatchApp/Sources/WatchConnectivityReceiver.swift +++ b/apps/ios/WatchApp/Sources/WatchConnectivityReceiver.swift @@ -537,52 +537,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable { } private static func parseAppSnapshotPayload(_ payload: [String: Any]) -> WatchAppSnapshotMessage? { - guard let type = payload["type"] as? String, - type == WatchPayloadType.appSnapshot.rawValue - else { - return nil - } - let gatewayStatusText = (payload["gatewayStatusText"] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let agentName = (payload["agentName"] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let agentAvatarURL = (payload["agentAvatarUrl"] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines) - let agentAvatarText = (payload["agentAvatarText"] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines) - let sessionKey = (payload["sessionKey"] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let gatewayStableID = WatchGatewayID.exact(payload["gatewayStableID"] as? String) - let talkStatusText = (payload["talkStatusText"] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let pendingApprovalCount = (payload["pendingApprovalCount"] as? Int) - ?? (payload["pendingApprovalCount"] as? NSNumber)?.intValue - ?? 0 - let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value - let snapshotId = (payload["snapshotId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) - let chatItems = (payload["chatItems"] as? [Any])?.compactMap(Self.parseChatItem) - let chatStatusCode = (payload["chatStatusCode"] as? String) - .flatMap(OpenClawWatchChatStatusCode.init(rawValue:)) - let chatStatusText = (payload["chatStatusText"] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines) - return WatchAppSnapshotMessage( - gatewayStatusText: gatewayStatusText.isEmpty ? "Unknown" : gatewayStatusText, - gatewayConnected: Self.boolValue(payload["gatewayConnected"]), - agentName: agentName.isEmpty ? "Main" : agentName, - agentAvatarURL: agentAvatarURL?.isEmpty == false ? agentAvatarURL : nil, - agentAvatarText: agentAvatarText?.isEmpty == false ? agentAvatarText : nil, - sessionKey: sessionKey.isEmpty ? "main" : sessionKey, - gatewayStableID: gatewayStableID, - talkStatusText: talkStatusText.isEmpty ? "Off" : talkStatusText, - talkEnabled: Self.boolValue(payload["talkEnabled"]), - talkListening: Self.boolValue(payload["talkListening"]), - talkSpeaking: Self.boolValue(payload["talkSpeaking"]), - pendingApprovalCount: max(0, pendingApprovalCount), - chatItems: chatItems, - chatStatusCode: chatStatusCode, - chatStatusText: chatStatusText?.isEmpty == false ? chatStatusText : nil, - sentAtMs: sentAtMs, - snapshotId: snapshotId) + WatchAppSnapshotMessage.parsePayload(payload) } private static func parseChatCompletionPayload( @@ -603,34 +558,6 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable { sentAtMs: sentAtMs) } - private static func parseChatItem(_ item: Any) -> WatchChatItem? { - guard let dict = item as? [String: Any] else { return nil } - guard let id = (dict["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), - !id.isEmpty - else { - return nil - } - let trimmedRole = (dict["role"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let text = (dict["text"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) - guard let text, !text.isEmpty else { return nil } - let timestampMs = (dict["timestampMs"] as? NSNumber)?.int64Value - return WatchChatItem( - id: id, - role: trimmedRole.isEmpty ? "assistant" : trimmedRole, - text: text, - timestampMs: timestampMs) - } - - private static func boolValue(_ value: Any?) -> Bool { - if let bool = value as? Bool { - return bool - } - if let number = value as? NSNumber { - return number.boolValue - } - return false - } - private static func encodeAppSnapshotRequestPayload( _ request: WatchAppSnapshotRequestMessage) -> [String: Any] { diff --git a/apps/ios/WatchApp/Sources/WatchInboxMessages.swift b/apps/ios/WatchApp/Sources/WatchInboxMessages.swift index d87c0a5ac556..ecc528ac65d8 100644 --- a/apps/ios/WatchApp/Sources/WatchInboxMessages.swift +++ b/apps/ios/WatchApp/Sources/WatchInboxMessages.swift @@ -87,6 +87,113 @@ struct WatchAppSnapshotMessage: Codable, Equatable { var chatStatusText: String? var sentAtMs: Int64? var snapshotId: String? + + static func parsePayload(_ payload: [String: Any]) -> Self? { + guard let type = payload["type"] as? String, + type == WatchPayloadType.appSnapshot.rawValue + else { + return nil + } + let gatewayStatusText = (payload["gatewayStatusText"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let agentName = (payload["agentName"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let agentAvatarURL = (payload["agentAvatarUrl"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + let agentAvatarText = (payload["agentAvatarText"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + let sessionKey = (payload["sessionKey"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let gatewayStableID = WatchGatewayID.exact(payload["gatewayStableID"] as? String) + let talkStatusText = (payload["talkStatusText"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let pendingApprovalCount = (payload["pendingApprovalCount"] as? Int) + ?? (payload["pendingApprovalCount"] as? NSNumber)?.intValue + ?? 0 + let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value + let snapshotId = (payload["snapshotId"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + let chatItems = (payload["chatItems"] as? [Any])?.compactMap(Self.parseChatItem) + let chatStatusCode = (payload["chatStatusCode"] as? String) + .flatMap(OpenClawWatchChatStatusCode.init(rawValue:)) + let chatStatusText = (payload["chatStatusText"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + return Self( + gatewayStatusText: gatewayStatusText.isEmpty ? "Unknown" : gatewayStatusText, + gatewayConnected: Self.boolValue(payload["gatewayConnected"]), + agentName: agentName.isEmpty ? "Main" : agentName, + agentAvatarURL: agentAvatarURL?.isEmpty == false ? agentAvatarURL : nil, + agentAvatarText: agentAvatarText?.isEmpty == false ? agentAvatarText : nil, + sessionKey: sessionKey.isEmpty ? "main" : sessionKey, + gatewayStableID: gatewayStableID, + talkStatusText: talkStatusText.isEmpty ? "Off" : talkStatusText, + talkEnabled: Self.boolValue(payload["talkEnabled"]), + talkListening: Self.boolValue(payload["talkListening"]), + talkSpeaking: Self.boolValue(payload["talkSpeaking"]), + pendingApprovalCount: max(0, pendingApprovalCount), + chatItems: chatItems, + chatStatusCode: chatStatusCode, + chatStatusText: chatStatusText?.isEmpty == false ? chatStatusText : nil, + sentAtMs: sentAtMs, + snapshotId: snapshotId) + } + + static func localizedChatStatusText( + statusCode: OpenClawWatchChatStatusCode?, + legacyText: String?, + chatCount: Int, + hasAppSnapshot: Bool) -> String + { + if let statusCode { + return switch statusCode { + case .connectIPhone: + String(localized: "Connect iPhone chat to read messages") + case .noMessages: + String(localized: "No chat messages yet") + case .unavailable: + String(localized: "Chat unavailable") + } + } + if let legacyText, !legacyText.isEmpty { + return legacyText + } + if chatCount > 0 { + return String( + AttributedString(localized: "^[\(chatCount) recent message](inflect: true)").characters) + } + return hasAppSnapshot + ? String(localized: "No messages synced") + : String(localized: "Waiting for iPhone") + } + + private static func parseChatItem(_ item: Any) -> WatchChatItem? { + guard let dict = item as? [String: Any] else { return nil } + guard let id = (dict["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), + !id.isEmpty + else { + return nil + } + let trimmedRole = (dict["role"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let text = (dict["text"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + guard let text, !text.isEmpty else { return nil } + let timestampMs = (dict["timestampMs"] as? NSNumber)?.int64Value + return WatchChatItem( + id: id, + role: trimmedRole.isEmpty ? "assistant" : trimmedRole, + text: text, + timestampMs: timestampMs) + } + + private static func boolValue(_ value: Any?) -> Bool { + if let bool = value as? Bool { + return bool + } + if let number = value as? NSNumber { + return number.boolValue + } + return false + } } typealias WatchChatCompletionMessage = OpenClawWatchChatCompletionMessage diff --git a/apps/ios/WatchApp/Sources/WatchInboxView.swift b/apps/ios/WatchApp/Sources/WatchInboxView.swift index a6fde90fac99..f03201e97e11 100644 --- a/apps/ios/WatchApp/Sources/WatchInboxView.swift +++ b/apps/ios/WatchApp/Sources/WatchInboxView.swift @@ -538,27 +538,11 @@ private struct WatchControlSurfaceView: View { } private var chatStatusText: String { - if let statusCode = store.appSnapshot?.chatStatusCode { - return switch statusCode { - case .connectIPhone: - String(localized: "Connect iPhone chat to read messages") - case .noMessages: - String(localized: "No chat messages yet") - case .unavailable: - String(localized: "Chat unavailable") - } - } - if let status = store.appSnapshot?.chatStatusText, !status.isEmpty { - return status - } - if self.chatCount > 0 { - let count = self.chatCount - return String( - AttributedString(localized: "^[\(count) recent message](inflect: true)").characters) - } - return self.store.hasAppSnapshot - ? String(localized: "No messages synced") - : String(localized: "Waiting for iPhone") + WatchAppSnapshotMessage.localizedChatStatusText( + statusCode: self.store.appSnapshot?.chatStatusCode, + legacyText: self.store.appSnapshot?.chatStatusText, + chatCount: self.chatCount, + hasAppSnapshot: self.store.hasAppSnapshot) } private var chatSendStatusText: String? { diff --git a/apps/ios/project.yml b/apps/ios/project.yml index 48fd7066ec79..b47d883836b6 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -421,6 +421,8 @@ targets: group: ShareExtension - path: WatchApp/Sources/WatchVoiceTurnTracker.swift group: WatchApp/Sources + - path: WatchApp/Sources/WatchInboxMessages.swift + group: WatchApp/Sources dependencies: - package: OpenClawKit settings: