diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index c34e4f64531c..57841d398e47 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -691,7 +691,7 @@ }, { "kind": "ui-call", - "line": 152, + "line": 156, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Resolution outcome unknown. Actions stay disabled until the Gateway record is verified.", "surface": "android", @@ -699,7 +699,7 @@ }, { "kind": "ui-call", - "line": 154, + "line": 158, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "The Gateway still shows this approval as pending. Review it before trying again.", "surface": "android", @@ -707,7 +707,7 @@ }, { "kind": "ui-call", - "line": 156, + "line": 160, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load approval details. Refresh and try again.", "surface": "android", @@ -715,7 +715,7 @@ }, { "kind": "ui-call", - "line": 158, + "line": 162, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load approvals.", "surface": "android", @@ -723,7 +723,7 @@ }, { "kind": "ui-call", - "line": 160, + "line": 164, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not resolve approval. Refresh and try again.", "surface": "android", @@ -731,7 +731,7 @@ }, { "kind": "ui-call", - "line": 175, + "line": 179, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Proposal applied.", "surface": "android", @@ -739,7 +739,7 @@ }, { "kind": "ui-call", - "line": 175, + "line": 179, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "apply", "surface": "android", @@ -747,7 +747,7 @@ }, { "kind": "ui-call", - "line": 176, + "line": 180, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Proposal rejected.", "surface": "android", @@ -755,7 +755,7 @@ }, { "kind": "ui-call", - "line": 176, + "line": 180, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "reject", "surface": "android", @@ -763,7 +763,7 @@ }, { "kind": "ui-call", - "line": 177, + "line": 181, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Proposal quarantined.", "surface": "android", @@ -771,7 +771,7 @@ }, { "kind": "ui-call", - "line": 177, + "line": 181, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "quarantine", "surface": "android", @@ -779,7 +779,7 @@ }, { "kind": "ui-call", - "line": 184, + "line": 188, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "unknown", "surface": "android", @@ -787,7 +787,7 @@ }, { "kind": "ui-call", - "line": 185, + "line": 189, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Gateway returned status '$statusLabel' after ${action.verb}.", "surface": "android", @@ -795,7 +795,7 @@ }, { "kind": "ui-call", - "line": 193, + "line": 197, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not ${action.verb} Skill Workshop proposal.", "surface": "android", @@ -803,7 +803,7 @@ }, { "kind": "ui-call", - "line": 375, + "line": 379, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connected (node offline)", "surface": "android", @@ -811,7 +811,7 @@ }, { "kind": "ui-call", - "line": 376, + "line": 380, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connected (operator offline)", "surface": "android", @@ -819,7 +819,7 @@ }, { "kind": "ui-call", - "line": 378, + "line": 382, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Reconnecting…", "surface": "android", @@ -827,7 +827,7 @@ }, { "kind": "ui-call", - "line": 386, + "line": 390, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connected (operator: $operator)", "surface": "android", @@ -835,7 +835,7 @@ }, { "kind": "ui-call", - "line": 1753, + "line": 1800, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "This automation already has a queued run.", "surface": "android", @@ -843,7 +843,7 @@ }, { "kind": "ui-call", - "line": 1783, + "line": 1830, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Automation run queued.", "surface": "android", @@ -851,7 +851,7 @@ }, { "kind": "ui-call", - "line": 1783, + "line": 1830, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Automation started.", "surface": "android", @@ -859,7 +859,7 @@ }, { "kind": "ui-call", - "line": 1796, + "line": 1843, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Gateway rejected the automation run.", "surface": "android", @@ -867,7 +867,7 @@ }, { "kind": "ui-call", - "line": 1827, + "line": 1874, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Automation enabled.", "surface": "android", @@ -875,7 +875,7 @@ }, { "kind": "ui-call", - "line": 1827, + "line": 1874, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Automation paused.", "surface": "android", @@ -883,7 +883,7 @@ }, { "kind": "ui-call", - "line": 1849, + "line": 1896, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "This automation changed on the gateway. Review the latest version before saving again.", "surface": "android", @@ -891,7 +891,7 @@ }, { "kind": "ui-call", - "line": 1855, + "line": 1902, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Automation updated.", "surface": "android", @@ -899,7 +899,7 @@ }, { "kind": "ui-call", - "line": 1870, + "line": 1917, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Automation deleted.", "surface": "android", @@ -907,7 +907,7 @@ }, { "kind": "ui-call", - "line": 2075, + "line": 2122, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Node offline. Reconnect and retry.", "surface": "android", @@ -915,7 +915,7 @@ }, { "kind": "ui-named-argument-concatenated", - "line": 2086, + "line": 2133, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Restore canvas now for session=$sessionKey source=$source. If existing A2UI state exists, replay it immediately. If not, create and render a compact mobile-friendly dashboard in Canvas.", "surface": "android", @@ -923,7 +923,7 @@ }, { "kind": "ui-call", - "line": 2107, + "line": 2154, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Failed to request restore. Tap to retry.", "surface": "android", @@ -931,7 +931,7 @@ }, { "kind": "ui-call", - "line": 2118, + "line": 2165, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "No canvas update yet. Tap to retry.", "surface": "android", @@ -939,7 +939,7 @@ }, { "kind": "ui-call", - "line": 3014, + "line": 3061, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect to a Gateway to save wake words", "surface": "android", @@ -947,7 +947,7 @@ }, { "kind": "ui-call", - "line": 3042, + "line": 3089, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Wake words saved", "surface": "android", @@ -955,7 +955,7 @@ }, { "kind": "ui-call", - "line": 3051, + "line": 3098, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not save wake words", "surface": "android", @@ -963,7 +963,7 @@ }, { "kind": "ui-call", - "line": 3735, + "line": 3782, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Failed: no secure gateway endpoint was detected. Enable gateway TLS or Tailscale Serve, or use a trusted private LAN address with Unencrypted selected.", "surface": "android", @@ -971,7 +971,7 @@ }, { "kind": "ui-call", - "line": 3739, + "line": 3786, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Failed: secure endpoint reached, but TLS fingerprint verification timed out. Check Tailscale Serve or gateway TLS and retry.", "surface": "android", @@ -979,7 +979,7 @@ }, { "kind": "ui-call", - "line": 3743, + "line": 3790, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Failed: couldn't reach the secure gateway endpoint for this host.", "surface": "android", @@ -987,7 +987,7 @@ }, { "kind": "ui-named-argument", - "line": 3787, + "line": 3834, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connecting…", "surface": "android", @@ -995,7 +995,7 @@ }, { "kind": "ui-named-argument", - "line": 3968, + "line": 4015, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Offline", "surface": "android", @@ -1003,7 +1003,7 @@ }, { "kind": "ui-call", - "line": 4617, + "line": 4669, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load provider catalog.", "surface": "android", @@ -1011,7 +1011,7 @@ }, { "kind": "ui-call", - "line": 4648, + "line": 4700, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Update your Gateway to view provider model config.", "surface": "android", @@ -1019,7 +1019,7 @@ }, { "kind": "ui-call", - "line": 4650, + "line": 4702, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load provider model config.", "surface": "android", @@ -1027,7 +1027,7 @@ }, { "kind": "ui-call", - "line": 4666, + "line": 4718, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Provider models loaded, but readiness is unavailable.", "surface": "android", @@ -1035,7 +1035,7 @@ }, { "kind": "ui-call", - "line": 4753, + "line": 4805, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load automations.", "surface": "android", @@ -1043,7 +1043,7 @@ }, { "kind": "ui-call", - "line": 4839, + "line": 4891, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to inspect automations.", "surface": "android", @@ -1051,7 +1051,7 @@ }, { "kind": "ui-call", - "line": 4849, + "line": 4901, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Gateway returned an invalid automation.", "surface": "android", @@ -1059,7 +1059,7 @@ }, { "kind": "ui-call", - "line": 4853, + "line": 4905, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load automation.", "surface": "android", @@ -1067,7 +1067,7 @@ }, { "kind": "ui-call", - "line": 4865, + "line": 4917, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to inspect automation run history.", "surface": "android", @@ -1075,7 +1075,7 @@ }, { "kind": "ui-call", - "line": 4896, + "line": 4948, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load automation run history.", "surface": "android", @@ -1083,7 +1083,7 @@ }, { "kind": "ui-call", - "line": 4913, + "line": 4965, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Cron changes require operator.admin access.", "surface": "android", @@ -1091,7 +1091,7 @@ }, { "kind": "ui-call", - "line": 4922, + "line": 4974, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to manage automations.", "surface": "android", @@ -1099,7 +1099,7 @@ }, { "kind": "ui-call", - "line": 4934, + "line": 4986, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Another cron action is still finishing.", "surface": "android", @@ -1107,7 +1107,7 @@ }, { "kind": "ui-call", - "line": 4981, + "line": 5033, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Cron action failed.", "surface": "android", @@ -1115,7 +1115,7 @@ }, { "kind": "ui-call", - "line": 5108, + "line": 5160, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load usage.", "surface": "android", @@ -1123,7 +1123,7 @@ }, { "kind": "ui-call", - "line": 5143, + "line": 5195, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load skills.", "surface": "android", @@ -1131,7 +1131,7 @@ }, { "kind": "ui-call", - "line": 5156, + "line": 5208, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to update skills.", "surface": "android", @@ -1139,7 +1139,7 @@ }, { "kind": "ui-call", - "line": 5160, + "line": 5212, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "This gateway connection needs operator.admin to update skills.", "surface": "android", @@ -1147,7 +1147,7 @@ }, { "kind": "conditional-branch", - "line": 5175, + "line": 5227, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not disable skill.", "surface": "android", @@ -1155,7 +1155,7 @@ }, { "kind": "conditional-branch", - "line": 5175, + "line": 5227, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not enable skill.", "surface": "android", @@ -1163,7 +1163,7 @@ }, { "kind": "ui-call", - "line": 5193, + "line": 5245, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to search ClawHub skills.", "surface": "android", @@ -1171,7 +1171,7 @@ }, { "kind": "ui-call", - "line": 5239, + "line": 5291, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not search ClawHub skills.", "surface": "android", @@ -1179,7 +1179,7 @@ }, { "kind": "ui-call", - "line": 5252, + "line": 5304, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to inspect ClawHub skills.", "surface": "android", @@ -1187,7 +1187,7 @@ }, { "kind": "conditional-branch", - "line": 5285, + "line": 5337, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "ClawHub did not return an installable version for ${skill.slug}.", "surface": "android", @@ -1195,7 +1195,7 @@ }, { "kind": "ui-call", - "line": 5301, + "line": 5353, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load ClawHub details for ${skill.slug}.", "surface": "android", @@ -1203,7 +1203,7 @@ }, { "kind": "ui-call", - "line": 5317, + "line": 5369, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to install ClawHub skills.", "surface": "android", @@ -1211,7 +1211,7 @@ }, { "kind": "ui-call", - "line": 5333, + "line": 5385, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "This gateway connection needs operator.admin to install ClawHub skills.", "surface": "android", @@ -1219,7 +1219,7 @@ }, { "kind": "conditional-branch", - "line": 5423, + "line": 5475, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Installed $slug.", "surface": "android", @@ -1227,7 +1227,7 @@ }, { "kind": "ui-call", - "line": 5430, + "line": 5482, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not install ${slug} from ClawHub.", "surface": "android", @@ -1235,7 +1235,7 @@ }, { "kind": "ui-call", - "line": 5470, + "line": 5522, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to load Skill Workshop proposals.", "surface": "android", @@ -1243,7 +1243,7 @@ }, { "kind": "ui-call", - "line": 5510, + "line": 5562, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load Skill Workshop proposals.", "surface": "android", @@ -1251,7 +1251,7 @@ }, { "kind": "ui-call", - "line": 5530, + "line": 5582, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to inspect Skill Workshop proposals.", "surface": "android", @@ -1259,7 +1259,7 @@ }, { "kind": "ui-call", - "line": 5582, + "line": 5634, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not inspect Skill Workshop proposal.", "surface": "android", @@ -1267,7 +1267,7 @@ }, { "kind": "ui-call", - "line": 5602, + "line": 5654, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Skill Workshop proposal actions require operator.admin scope.", "surface": "android", @@ -1275,7 +1275,7 @@ }, { "kind": "ui-call", - "line": 5607, + "line": 5659, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to update Skill Workshop proposals.", "surface": "android", @@ -1283,7 +1283,7 @@ }, { "kind": "ui-named-argument", - "line": 5766, + "line": 5818, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connected", "surface": "android", @@ -1291,7 +1291,7 @@ }, { "kind": "ui-call", - "line": 5792, + "line": 5844, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load nodes and devices.", "surface": "android", @@ -1299,7 +1299,7 @@ }, { "kind": "ui-call", - "line": 6450, + "line": 6502, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load channels.", "surface": "android", @@ -1307,7 +1307,7 @@ }, { "kind": "ui-call", - "line": 6480, + "line": 6532, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load dreaming.", "surface": "android", @@ -1315,7 +1315,7 @@ }, { "kind": "ui-call", - "line": 6517, + "line": 6569, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load gateway logs.", "surface": "android", @@ -1323,7 +1323,7 @@ }, { "kind": "ui-call", - "line": 6974, + "line": 7026, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "One time", "surface": "android", @@ -1331,7 +1331,7 @@ }, { "kind": "ui-call", - "line": 6983, + "line": 7035, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Cron", "surface": "android", @@ -1339,7 +1339,7 @@ }, { "kind": "ui-call", - "line": 6984, + "line": 7036, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Scheduled", "surface": "android", @@ -1347,7 +1347,7 @@ }, { "kind": "ui-call", - "line": 6992, + "line": 7044, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Every ${days}d", "surface": "android", @@ -1355,7 +1355,7 @@ }, { "kind": "ui-call", - "line": 6993, + "line": 7045, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Every ${hours}h", "surface": "android", @@ -1363,7 +1363,7 @@ }, { "kind": "ui-call", - "line": 6994, + "line": 7046, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Every ${minutes}m", "surface": "android", @@ -1371,7 +1371,7 @@ }, { "kind": "ui-call", - "line": 6995, + "line": 7047, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Repeating", "surface": "android", @@ -1379,7 +1379,7 @@ }, { "kind": "ui-call", - "line": 7011, + "line": 7063, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "No prompt", "surface": "android", @@ -1387,7 +1387,7 @@ }, { "kind": "ui-call", - "line": 7028, + "line": 7080, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Gateway", "surface": "android", @@ -1395,7 +1395,7 @@ }, { "kind": "ui-call", - "line": 7036, + "line": 7088, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connected to $gatewayLabel", "surface": "android", @@ -1403,7 +1403,7 @@ }, { "kind": "ui-call", - "line": 7037, + "line": 7089, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Your agents are ready", "surface": "android", @@ -1411,7 +1411,7 @@ }, { "kind": "ui-call", - "line": 7039, + "line": 7091, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "This phone stays dormant until the gateway needs it, then wakes, syncs, and goes back to sleep.", "surface": "android", @@ -1419,7 +1419,7 @@ }, { "kind": "ui-call", - "line": 7043, + "line": 7095, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Selected on this phone", "surface": "android", @@ -1427,7 +1427,7 @@ }, { "kind": "ui-call", - "line": 7046, + "line": 7098, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "The overview refreshes on reconnect and when this screen opens.", "surface": "android", @@ -1435,7 +1435,7 @@ }, { "kind": "ui-call", - "line": 7051, + "line": 7103, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Reconnecting", "surface": "android", @@ -1443,7 +1443,7 @@ }, { "kind": "ui-call", - "line": 7052, + "line": 7104, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "OpenClaw is syncing back up", "surface": "android", @@ -1451,7 +1451,7 @@ }, { "kind": "ui-call", - "line": 7054, + "line": 7106, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "The gateway session is coming back online. Agent shortcuts should settle automatically in a moment.", "surface": "android", @@ -1459,7 +1459,7 @@ }, { "kind": "ui-call", - "line": 7058, + "line": 7110, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Gateway session in progress", "surface": "android", @@ -1467,7 +1467,7 @@ }, { "kind": "ui-call", - "line": 7061, + "line": 7113, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "If the gateway is reachable, reconnect should complete without intervention.", "surface": "android", @@ -1475,7 +1475,7 @@ }, { "kind": "ui-call", - "line": 7066, + "line": 7118, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Welcome to OpenClaw", "surface": "android", @@ -1483,7 +1483,7 @@ }, { "kind": "ui-call", - "line": 7067, + "line": 7119, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Your phone stays quiet until it is needed", "surface": "android", @@ -1491,7 +1491,7 @@ }, { "kind": "ui-call", - "line": 7069, + "line": 7121, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Pair this device to your gateway to wake it only for real work, keep a live agent overview handy, and avoid battery-draining background loops.", "surface": "android", @@ -1499,7 +1499,7 @@ }, { "kind": "ui-call", - "line": 7073, + "line": 7125, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect to load your agents", "surface": "android", @@ -1507,7 +1507,7 @@ }, { "kind": "ui-call", - "line": 7076, + "line": 7128, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "When connected, the gateway can wake the phone with a silent push instead of holding an always-on session.", "surface": "android", @@ -1515,7 +1515,7 @@ }, { "kind": "ui-call", - "line": 7108, + "line": 7160, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Main", "surface": "android", @@ -1523,7 +1523,7 @@ }, { "kind": "ui-call", - "line": 7123, + "line": 7175, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Active on this phone", "surface": "android", @@ -1531,7 +1531,7 @@ }, { "kind": "ui-call", - "line": 7124, + "line": 7176, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Default agent", "surface": "android", @@ -1539,7 +1539,7 @@ }, { "kind": "ui-call", - "line": 7125, + "line": 7177, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Ready", "surface": "android", @@ -1547,7 +1547,7 @@ }, { "kind": "ui-call", - "line": 7684, + "line": 7736, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Dream", "surface": "android", diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index 6f4da766cec6..bd1421a146c7 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -301,6 +301,9 @@ dependencies { implementation(composeBom) androidTestImplementation(composeBom) + implementation(project(":wear-shared")) + implementation(libs.play.services.wearable) + implementation(libs.androidx.core.ktx) // AppCompat owns per-app locale persistence and Activity recreation on API 31-32. implementation(libs.androidx.appcompat) diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml index aa4acbe594b8..6375bb927714 100644 --- a/apps/android/app/src/main/AndroidManifest.xml +++ b/apps/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ - + @@ -50,6 +51,18 @@ android:name=".NodeForegroundService" android:exported="false" android:foregroundServiceType="${nodeForegroundServiceType}" /> + + + + + + ensureBackgroundRuntime().handleWearProxyRequest(request) }, + ) + } + /** * Returns the single NodeRuntime for this process, creating it on first use. */ diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt index 1042ffef0c37..4e59c908e9cc 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt @@ -94,6 +94,10 @@ import ai.openclaw.app.voice.VoiceWakeManager import ai.openclaw.app.voice.VoiceWakeMatch import ai.openclaw.app.voice.VoiceWakePreferences import ai.openclaw.app.voice.VoiceWakeSuppressionReason +import ai.openclaw.app.wear.WearProxyBridge +import ai.openclaw.app.wear.WearProxyController +import ai.openclaw.app.wear.WearProxyGatewayException +import ai.openclaw.wear.shared.WearMessage import android.Manifest import android.content.Context import android.content.pm.PackageManager @@ -1060,6 +1064,7 @@ class NodeRuntime private constructor( operatorStatusText = "Connected" } micCapture.onGatewayConnectionChanged(true) + wearProxyBridge()?.publishConnection(connected = true, status = "Connected") scope.launch { subscribeOperatorSessionEvents() refreshWakeWordsFromGateway() @@ -1080,11 +1085,17 @@ class NodeRuntime private constructor( operatorConnectionProblem = gatewayProblemAfterDisconnect(operatorConnectionProblem, message) } micCapture.onGatewayConnectionChanged(false) + wearProxyBridge()?.publishConnection(connected = false, status = message) }, onConnectFailure = { error, pauseReconnect -> + val problem = gatewayConnectionProblem(error, pauseReconnect) updateStatus { - operatorConnectionProblem = gatewayConnectionProblem(error, pauseReconnect) + operatorConnected = false + operatorStatusText = problem.message + operatorConnectionProblem = problem } + micCapture.onGatewayConnectionChanged(false) + wearProxyBridge()?.publishConnection(connected = false, status = problem.message) }, onEvent = { event, payloadJson -> handleGatewayEvent(event, payloadJson) @@ -1092,6 +1103,42 @@ class NodeRuntime private constructor( customHeadersProvider = prefs::loadGatewayCustomHeaders, ) + private val wearProxyController by lazy { + WearProxyController( + requestGateway = ::requestWearGateway, + isGatewayConnected = operatorSession::isReady, + gatewayStatusText = { synchronized(gatewayStatusLock) { operatorStatusText } }, + ) + } + + internal suspend fun handleWearProxyRequest(request: WearMessage.Request): WearMessage.Response = wearProxyController.handle(request) + + private suspend fun requestWearGateway( + method: String, + params: JsonObject, + ): JsonElement { + val lease = + operatorSession.captureRequestLease() + ?: throw WearProxyGatewayException("unavailable", "Phone gateway is offline") + val response = + try { + lease.request(method, params.toString()) + } catch (err: GatewayRequestRejected) { + throw WearProxyGatewayException(err.gatewayError.code, err.gatewayError.message) + } catch (_: GatewayRequestNotEnqueued) { + throw WearProxyGatewayException("unavailable", "Phone gateway is offline") + } catch (_: GatewayRequestOutcomeUnknown) { + throw WearProxyGatewayException("unavailable", "Phone gateway request outcome is unknown") + } + return try { + json.parseToJsonElement(response) + } catch (_: Throwable) { + throw WearProxyGatewayException("invalid_response", "$method returned invalid JSON") + } + } + + private fun wearProxyBridge(): WearProxyBridge? = (appContext as? NodeApp)?.wearProxyBridge + private fun clearOperatorGatewayState(retirePendingCronRuns: Boolean) { invalidateNodeCapabilityApprovalState() _serverName.value = null @@ -4197,6 +4244,11 @@ class NodeRuntime private constructor( micCapture.handleGatewayEvent(event, payloadJson) talkMode.handleGatewayEvent(event, payloadJson) chat.handleGatewayEvent(event, payloadJson) + if (event == "chat" && !payloadJson.isNullOrBlank()) { + runCatching { json.parseToJsonElement(payloadJson) } + .getOrNull() + ?.let { wearProxyBridge()?.publishChat(it) } + } } private fun handleNodeGatewayEvent( diff --git a/apps/android/app/src/main/java/ai/openclaw/app/wear/WearProxyBridge.kt b/apps/android/app/src/main/java/ai/openclaw/app/wear/WearProxyBridge.kt new file mode 100644 index 000000000000..a9924e3b7b39 --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/app/wear/WearProxyBridge.kt @@ -0,0 +1,212 @@ +package ai.openclaw.app.wear + +import ai.openclaw.wear.shared.WearDecodeResult +import ai.openclaw.wear.shared.WearEventType +import ai.openclaw.wear.shared.WearMessage +import ai.openclaw.wear.shared.WearProtocol +import ai.openclaw.wear.shared.WearProtocolCodec +import ai.openclaw.wear.shared.WearRpcError +import android.content.Context +import com.google.android.gms.tasks.Task +import com.google.android.gms.wearable.Wearable +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import java.util.concurrent.Executor +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +internal fun interface WearMessageSender { + suspend fun send( + nodeId: String, + path: String, + data: ByteArray, + ) +} + +internal class GoogleWearMessageSender( + context: Context, +) : WearMessageSender { + private val messageClient = Wearable.getMessageClient(context.applicationContext) + + override suspend fun send( + nodeId: String, + path: String, + data: ByteArray, + ) { + messageClient.sendMessage(nodeId, path, data).awaitWearTask() + } +} + +internal class WearProxyBridge( + private val scope: CoroutineScope, + private val sender: WearMessageSender, + private val handleRequest: suspend (WearMessage.Request) -> WearMessage.Response, +) { + private val peerLock = Any() + private val peers = LinkedHashMap() + private var peerGeneration = 0L + private val eventPublishLock = Any() + private var sequence = 0L + + // One bounded consumer preserves event order. DROP_OLDEST becomes a visible sequence + // gap, so the watch can recover from chat.history instead of applying stale deltas. + private val events = + Channel( + capacity = MAX_BUFFERED_EVENTS, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + init { + scope.launch { + for (event in events) sendEvent(event) + } + } + + suspend fun handleMessage( + sourceNodeId: String, + data: ByteArray, + ) { + if (sourceNodeId.isBlank()) return + val request = + (WearProtocolCodec.decode(data) as? WearDecodeResult.Success) + ?.message as? WearMessage.Request ?: return + val peer = rememberPeer(sourceNodeId) + val response = handleRequest(request) + val encoded = encodeResponse(response) + sendToPeer(peer, WearProtocol.RESPONSE_PATH, encoded) + } + + fun publishConnection( + connected: Boolean, + status: String, + ) { + publishEvent( + WearEventType.Connection, + buildJsonObject { + put("connected", connected) + put("status", status) + }, + ) + } + + fun publishChat(payload: JsonElement) { + projectWearChatEvent(payload)?.let { publishEvent(WearEventType.Chat, it) } + } + + private fun publishEvent( + type: WearEventType, + payload: JsonElement, + ) { + if (!hasPeers()) return + // Sequence allocation and channel insertion are one ordered operation. Splitting them lets + // concurrent connection/chat callbacks enqueue N+1 before N and manufacture a false gap. + synchronized(eventPublishLock) { + sequence += 1 + events.trySend( + WearMessage.Event( + sequence = sequence, + event = type, + payload = payload, + ), + ) + } + } + + private suspend fun sendEvent(event: WearMessage.Event) { + val encoded = runCatching { WearProtocolCodec.encode(event) }.getOrNull() ?: return + for (peer in peerSnapshot()) { + sendToPeer(peer, WearProtocol.EVENT_PATH, encoded) + } + } + + private suspend fun sendToPeer( + peer: PeerRegistration, + path: String, + data: ByteArray, + ) { + try { + sender.send(peer.nodeId, path, data) + } catch (err: CancellationException) { + throw err + } catch (_: Throwable) { + forgetPeer(peer) + } + } + + private fun encodeResponse(response: WearMessage.Response): ByteArray = + runCatching { WearProtocolCodec.encode(response) } + .getOrElse { + WearProtocolCodec.encode( + WearMessage.Response( + requestId = response.requestId, + ok = false, + error = WearRpcError(code = "response_too_large", message = "Phone response exceeds Wear transport limits"), + ), + ) + } + + private fun rememberPeer(nodeId: String): PeerRegistration = + synchronized(peerLock) { + peerGeneration += 1 + peers.remove(nodeId) + peers[nodeId] = peerGeneration + while (peers.size > MAX_PEERS) { + peers.remove(peers.keys.first()) + } + PeerRegistration(nodeId = nodeId, generation = peerGeneration) + } + + private fun forgetPeer(peer: PeerRegistration) { + synchronized(peerLock) { + // An older event send may fail after a new request refreshed the same watch. Remove only + // the registration that owned this send, or the successful request would lose its events. + if (peers[peer.nodeId] == peer.generation) peers.remove(peer.nodeId) + } + } + + private fun hasPeers(): Boolean = synchronized(peerLock) { peers.isNotEmpty() } + + private fun peerSnapshot(): List = + synchronized(peerLock) { + peers.map { (nodeId, generation) -> PeerRegistration(nodeId = nodeId, generation = generation) } + } + + internal fun peerCountForTests(): Int = synchronized(peerLock) { peers.size } + + private companion object { + const val MAX_PEERS = 8 + const val MAX_BUFFERED_EVENTS = 32 + } +} + +private data class PeerRegistration( + val nodeId: String, + val generation: Long, +) + +internal class WearTaskCanceledException : IllegalStateException("Wear message task was canceled") + +private val directTaskExecutor = Executor(Runnable::run) + +internal suspend fun Task.awaitWearTask(): T = + suspendCancellableCoroutine { continuation -> + addOnSuccessListener(directTaskExecutor) { value -> + continuation.resume(value) + } + addOnFailureListener(directTaskExecutor) { error -> + continuation.resumeWithException(error) + } + // Google Tasks do not invoke failure listeners for cancellation. Treat the Task's canceled + // state as a send failure; cancellation of the calling coroutine remains owned by the + // cancellable continuation and is still propagated as CancellationException. + addOnCanceledListener(directTaskExecutor) { + continuation.resumeWithException(WearTaskCanceledException()) + } + } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/wear/WearProxyController.kt b/apps/android/app/src/main/java/ai/openclaw/app/wear/WearProxyController.kt new file mode 100644 index 000000000000..c7b9c7ba107e --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/app/wear/WearProxyController.kt @@ -0,0 +1,385 @@ +package ai.openclaw.app.wear + +import ai.openclaw.wear.shared.WearMessage +import ai.openclaw.wear.shared.WearRpcError +import ai.openclaw.wear.shared.WearRpcMethod +import kotlinx.coroutines.CancellationException +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.longOrNull +import kotlinx.serialization.json.put + +internal class WearProxyGatewayException( + val code: String, + override val message: String, +) : IllegalStateException(message) + +internal class WearProxyController( + private val requestGateway: suspend (method: String, params: JsonObject) -> JsonElement, + private val isGatewayConnected: () -> Boolean, + private val gatewayStatusText: () -> String, +) { + suspend fun handle(request: WearMessage.Request): WearMessage.Response = + try { + val result = + when (request.method) { + WearRpcMethod.ProxyStatus -> proxyStatus(request.params) + WearRpcMethod.SessionsList -> listSessions(request.params) + WearRpcMethod.ChatHistory -> chatHistory(request.params) + WearRpcMethod.ChatSend -> sendChat(request.params) + WearRpcMethod.ChatAbort -> abortChat(request.params) + } + WearMessage.Response(requestId = request.requestId, ok = true, result = result) + } catch (err: WearProxyInvalidRequest) { + failure(request.requestId, code = "invalid_request", message = err.message ?: "Invalid Wear request") + } catch (err: WearProxyGatewayException) { + failure(request.requestId, code = err.code, message = err.message) + } catch (err: CancellationException) { + throw err + } catch (_: Throwable) { + failure(request.requestId, code = "unavailable", message = "Phone gateway request failed") + } + + private fun proxyStatus(params: JsonObject): JsonObject { + params.requireOnly() + return buildJsonObject { + put("connected", isGatewayConnected()) + put("status", gatewayStatusText().takeCodePoints(MAX_STATUS_CHARS)) + } + } + + private suspend fun listSessions(params: JsonObject): JsonObject { + params.requireOnly("limit") + val limit = params.intParam("limit", default = DEFAULT_SESSION_LIMIT, range = 1..MAX_SESSION_LIMIT) + val gatewayResult = + requestGateway( + "sessions.list", + buildJsonObject { + put("limit", limit) + put("includeGlobal", false) + put("includeUnknown", false) + }, + ).asObject("sessions.list") + val sessions = + gatewayResult["sessions"] + .asArrayOrNull() + ?.mapNotNull(::projectSession) + .orEmpty() + return buildJsonObject { + put("sessions", JsonArray(sessions)) + gatewayResult["hasMore"].booleanPrimitiveOrNull()?.let { put("hasMore", it) } + gatewayResult["totalCount"].longPrimitiveOrNull()?.let { put("totalCount", it) } + } + } + + private suspend fun chatHistory(params: JsonObject): JsonObject { + params.requireOnly("sessionKey", "limit", "maxChars", "offset") + val sessionKey = params.stringParam("sessionKey", MAX_SESSION_KEY_CHARS) + val limit = params.intParam("limit", default = DEFAULT_HISTORY_LIMIT, range = 1..MAX_HISTORY_LIMIT) + val maxChars = params.intParam("maxChars", default = DEFAULT_HISTORY_CHARS, range = 1..MAX_HISTORY_CHARS) + val offset = params.optionalIntParam("offset", range = 0..MAX_HISTORY_OFFSET) + val result = + requestGateway( + "chat.history", + buildJsonObject { + put("sessionKey", sessionKey) + put("limit", limit) + put("maxChars", maxChars) + offset?.let { put("offset", it) } + }, + ).asObject("chat.history") + return projectHistory(result) + } + + private suspend fun sendChat(params: JsonObject): JsonObject { + params.requireOnly("sessionKey", "message", "idempotencyKey") + val result = + requestGateway( + "chat.send", + buildJsonObject { + put("sessionKey", params.stringParam("sessionKey", MAX_SESSION_KEY_CHARS)) + put("message", params.stringParam("message", MAX_MESSAGE_CHARS)) + put("idempotencyKey", params.stringParam("idempotencyKey", MAX_IDEMPOTENCY_KEY_CHARS)) + put("deliver", false) + }, + ).asObject("chat.send") + return projectAck(result) + } + + private suspend fun abortChat(params: JsonObject): JsonObject { + params.requireOnly("sessionKey", "runId") + val result = + requestGateway( + "chat.abort", + buildJsonObject { + put("sessionKey", params.stringParam("sessionKey", MAX_SESSION_KEY_CHARS)) + params.optionalStringParam("runId", MAX_RUN_ID_CHARS)?.let { put("runId", it) } + }, + ).asObject("chat.abort") + return projectAck(result) + } + + private fun failure( + requestId: String, + code: String, + message: String, + ): WearMessage.Response = + WearMessage.Response( + requestId = requestId, + ok = false, + error = WearRpcError(code = code.takeCodePoints(MAX_ERROR_CODE_CHARS), message = message.takeCodePoints(MAX_ERROR_MESSAGE_CHARS)), + ) + + private companion object { + const val DEFAULT_SESSION_LIMIT = 20 + const val MAX_SESSION_LIMIT = 50 + const val DEFAULT_HISTORY_LIMIT = 20 + const val MAX_HISTORY_LIMIT = 20 + const val DEFAULT_HISTORY_CHARS = 2_000 + const val MAX_HISTORY_CHARS = 2_000 + const val MAX_HISTORY_OFFSET = 100_000 + const val MAX_SESSION_KEY_CHARS = 512 + const val MAX_MESSAGE_CHARS = 4_000 + const val MAX_IDEMPOTENCY_KEY_CHARS = 128 + const val MAX_RUN_ID_CHARS = 128 + const val MAX_STATUS_CHARS = 200 + const val MAX_SESSION_LABEL_CHARS = 200 + const val MAX_EVENT_TEXT_CHARS = 2_000 + const val MAX_ERROR_CODE_CHARS = 64 + const val MAX_ERROR_MESSAGE_CHARS = 300 + } +} + +internal fun projectWearChatEvent(payload: JsonElement): JsonObject? { + val source = payload as? JsonObject ?: return null + return buildJsonObject { + copyString(source, "runId", MAX_RUN_ID_CHARS) + copyString(source, "sessionKey", MAX_SESSION_KEY_CHARS) + copyString(source, "agentId", MAX_SESSION_KEY_CHARS) + copyLong(source, "seq") + copyString(source, "state", 32) + copyString(source, "deltaText", MAX_EVENT_TEXT_CHARS) + copyBoolean(source, "replace") + copyString(source, "errorMessage", MAX_ERROR_MESSAGE_CHARS) + copyString(source, "stopReason", 100) + projectMessage(source["message"])?.let { put("message", it) } + } +} + +private fun projectHistory(source: JsonObject): JsonObject = + buildJsonObject { + copyString(source, "sessionKey", MAX_SESSION_KEY_CHARS) + copyString(source, "sessionId", MAX_SESSION_KEY_CHARS) + val messages = source["messages"].asArrayOrNull()?.mapNotNull(::projectMessage).orEmpty() + put("messages", JsonArray(messages)) + copyLong(source, "offset") + copyLong(source, "nextOffset") + copyLong(source, "totalMessages") + copyBoolean(source, "hasMore") + val inFlight = source["inFlightRun"] as? JsonObject + if (inFlight != null) { + put( + "inFlightRun", + buildJsonObject { + copyString(inFlight, "runId", MAX_RUN_ID_CHARS) + copyString(inFlight, "text", MAX_EVENT_TEXT_CHARS) + }, + ) + } + } + +private fun projectSession(element: JsonElement): JsonObject? { + val source = element as? JsonObject ?: return null + val key = source.stringOrNull("key") ?: source.stringOrNull("sessionKey") ?: return null + return buildJsonObject { + put("key", key.takeCodePoints(MAX_SESSION_KEY_CHARS)) + copyString(source, "displayName", MAX_SESSION_LABEL_CHARS) + copyString(source, "label", MAX_SESSION_LABEL_CHARS) + copyLong(source, "updatedAt") + copyLong(source, "lastActivityAt") + copyBoolean(source, "pinned") + copyBoolean(source, "unread") + copyBoolean(source, "hasActiveRun") + } +} + +private fun projectMessage(element: JsonElement?): JsonObject? { + val source = element as? JsonObject ?: return null + val role = source.stringOrNull("role") ?: return null + return buildJsonObject { + copyString(source, "id", MAX_RUN_ID_CHARS) + put("role", role.takeCodePoints(32)) + copyLong(source, "timestamp") + copyString(source, "idempotencyKey", MAX_IDEMPOTENCY_KEY_CHARS) + projectContent(source["content"])?.let { put("content", it) } + } +} + +private fun projectContent(content: JsonElement?): JsonElement? = + when (content) { + is JsonPrimitive -> content.contentOrNull?.let { JsonPrimitive(it.takeUtf8Bytes(MAX_PROJECTED_CONTENT_BYTES)) } + is JsonArray -> + buildJsonArray { + var remainingBytes = MAX_PROJECTED_CONTENT_BYTES + var partCount = 0 + for (part in content) { + if (remainingBytes == 0 || partCount == MAX_PROJECTED_CONTENT_PARTS) break + val text = + when (part) { + is JsonPrimitive -> part.contentOrNull + is JsonObject -> { + val type = part.stringOrNull("type") + if (type != null && type != "text") continue + part.stringOrNull("text") + } + else -> null + } ?: continue + val projectedText = text.takeUtf8Bytes(remainingBytes) + if (projectedText.isEmpty() && text.isNotEmpty()) break + + // The Wear transport has one byte ceiling for the complete event. Bound text across + // all parts so a terminal event remains encodable and can still carry its final state. + if (part is JsonObject) { + add( + buildJsonObject { + put("type", "text") + put("text", projectedText) + }, + ) + } else { + add(JsonPrimitive(projectedText)) + } + remainingBytes -= projectedText.toByteArray(Charsets.UTF_8).size + partCount += 1 + } + } + else -> null + } + +private fun projectAck(source: JsonObject): JsonObject = + buildJsonObject { + copyString(source, "runId", MAX_RUN_ID_CHARS) + copyString(source, "status", 64) + copyBoolean(source, "aborted") + } + +private class WearProxyInvalidRequest( + message: String, +) : IllegalArgumentException(message) + +private fun JsonObject.requireOnly(vararg allowed: String) { + val allowedNames = allowed.toSet() + if (keys.any { it !in allowedNames }) throw WearProxyInvalidRequest("Unsupported Wear request field") +} + +private fun JsonObject.stringParam( + name: String, + maxChars: Int, +): String { + val value = stringOrNull(name) ?: throw WearProxyInvalidRequest("Missing $name") + if (value.isBlank() || value.codePointCount() > maxChars) { + throw WearProxyInvalidRequest("Invalid $name") + } + return value +} + +private fun JsonObject.optionalStringParam( + name: String, + maxChars: Int, +): String? { + if (name !in this) return null + return stringParam(name, maxChars) +} + +private fun JsonObject.intParam( + name: String, + default: Int, + range: IntRange, +): Int = optionalIntParam(name, range) ?: default + +private fun JsonObject.optionalIntParam( + name: String, + range: IntRange, +): Int? { + if (name !in this) return null + val primitive = this[name] as? JsonPrimitive + val value = primitive?.takeUnless { it.isString }?.intOrNull + if (value == null || value !in range) throw WearProxyInvalidRequest("Invalid $name") + return value +} + +private fun JsonElement.asObject(method: String): JsonObject = this as? JsonObject ?: throw WearProxyGatewayException("invalid_response", "$method returned an invalid response") + +private fun JsonElement?.asArrayOrNull(): JsonArray? = this as? JsonArray + +private fun JsonObject.stringOrNull(name: String): String? = (this[name] as? JsonPrimitive)?.takeIf { it.isString }?.contentOrNull + +private fun JsonElement?.booleanPrimitiveOrNull(): Boolean? = (this as? JsonPrimitive)?.takeUnless { it.isString }?.booleanOrNull + +private fun JsonElement?.longPrimitiveOrNull(): Long? = (this as? JsonPrimitive)?.takeUnless { it.isString }?.longOrNull + +private fun kotlinx.serialization.json.JsonObjectBuilder.copyString( + source: JsonObject, + name: String, + maxChars: Int, +) { + source.stringOrNull(name)?.let { put(name, it.takeCodePoints(maxChars)) } +} + +private fun kotlinx.serialization.json.JsonObjectBuilder.copyLong( + source: JsonObject, + name: String, +) { + source[name].longPrimitiveOrNull()?.let { put(name, it) } +} + +private fun kotlinx.serialization.json.JsonObjectBuilder.copyBoolean( + source: JsonObject, + name: String, +) { + source[name].booleanPrimitiveOrNull()?.let { put(name, it) } +} + +private fun String.codePointCount(): Int = codePointCount(0, length) + +private fun String.takeCodePoints(maxCodePoints: Int): String { + if (codePointCount() <= maxCodePoints) return this + return substring(0, offsetByCodePoints(0, maxCodePoints)) +} + +private fun String.takeUtf8Bytes(maxBytes: Int): String { + var end = 0 + var usedBytes = 0 + while (end < length) { + val codePoint = codePointAt(end) + val charCount = Character.charCount(codePoint) + val byteCount = + when { + codePoint <= 0x7f -> 1 + codePoint <= 0x7ff -> 2 + codePoint <= 0xffff -> 3 + else -> 4 + } + if (usedBytes + byteCount > maxBytes) break + usedBytes += byteCount + end += charCount + } + if (end == length) return this + return substring(0, end) +} + +private const val MAX_SESSION_KEY_CHARS = 512 +private const val MAX_RUN_ID_CHARS = 128 +private const val MAX_IDEMPOTENCY_KEY_CHARS = 128 +private const val MAX_SESSION_LABEL_CHARS = 200 +private const val MAX_EVENT_TEXT_CHARS = 2_000 +private const val MAX_PROJECTED_CONTENT_BYTES = 1_024 +private const val MAX_PROJECTED_CONTENT_PARTS = 20 +private const val MAX_ERROR_MESSAGE_CHARS = 300 diff --git a/apps/android/app/src/main/java/ai/openclaw/app/wear/WearProxyListenerService.kt b/apps/android/app/src/main/java/ai/openclaw/app/wear/WearProxyListenerService.kt new file mode 100644 index 000000000000..e7cfddd04bd1 --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/app/wear/WearProxyListenerService.kt @@ -0,0 +1,18 @@ +package ai.openclaw.app.wear + +import ai.openclaw.app.NodeApp +import ai.openclaw.wear.shared.WearProtocol +import com.google.android.gms.wearable.MessageEvent +import com.google.android.gms.wearable.WearableListenerService +import kotlinx.coroutines.runBlocking + +class WearProxyListenerService : WearableListenerService() { + override fun onMessageReceived(messageEvent: MessageEvent) { + if (messageEvent.path != WearProtocol.REQUEST_PATH) return + val app = application as? NodeApp ?: return + // Google's Data Layer contract dispatches this callback on a background handler thread + // and explicitly recommends runBlocking there. Returning early can end the bound service + // lifetime before the correlated response is sent. + runBlocking { app.wearProxyBridge.handleMessage(messageEvent.sourceNodeId, messageEvent.data) } + } +} diff --git a/apps/android/app/src/main/res/values/wear.xml b/apps/android/app/src/main/res/values/wear.xml new file mode 100644 index 000000000000..149f63fc45eb --- /dev/null +++ b/apps/android/app/src/main/res/values/wear.xml @@ -0,0 +1,8 @@ + + + openclaw_phone_proxy_v1 + + @string/native_android_wear_capability + + diff --git a/apps/android/app/src/test/java/ai/openclaw/app/wear/WearProxyBridgeTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/wear/WearProxyBridgeTest.kt new file mode 100644 index 000000000000..fa0d0357005a --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/wear/WearProxyBridgeTest.kt @@ -0,0 +1,199 @@ +package ai.openclaw.app.wear + +import ai.openclaw.wear.shared.WearDecodeResult +import ai.openclaw.wear.shared.WearEventType +import ai.openclaw.wear.shared.WearMessage +import ai.openclaw.wear.shared.WearProtocol +import ai.openclaw.wear.shared.WearProtocolCodec +import ai.openclaw.wear.shared.WearRpcMethod +import com.google.android.gms.tasks.TaskCompletionSource +import com.google.android.gms.tasks.Tasks +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class WearProxyBridgeTest { + @Test + fun validRequestRegistersPeerAndReturnsCorrelatedResponse() = + runTest { + val sent = mutableListOf() + val bridge = + WearProxyBridge( + scope = backgroundScope, + sender = WearMessageSender { nodeId, path, data -> sent += SentWearMessage(nodeId, path, data) }, + handleRequest = { request -> + WearMessage.Response( + requestId = request.requestId, + ok = true, + result = buildJsonObject { put("connected", true) }, + ) + }, + ) + + bridge.handleMessage("watch-1", WearProtocolCodec.encode(request("req-1"))) + + assertEquals(1, bridge.peerCountForTests()) + assertEquals(1, sent.size) + assertEquals("watch-1", sent.single().nodeId) + assertEquals(WearProtocol.RESPONSE_PATH, sent.single().path) + val response = (WearProtocolCodec.decode(sent.single().data) as WearDecodeResult.Success).message as WearMessage.Response + assertEquals("req-1", response.requestId) + } + + @Test + fun malformedMessageDoesNotRegisterPeer() = + runTest { + val sent = mutableListOf() + val bridge = + WearProxyBridge( + scope = backgroundScope, + sender = WearMessageSender { nodeId, path, data -> sent += SentWearMessage(nodeId, path, data) }, + handleRequest = { error("must not run") }, + ) + + bridge.handleMessage("watch-1", "not-json".encodeToByteArray()) + + assertEquals(0, bridge.peerCountForTests()) + assertTrue(sent.isEmpty()) + } + + @Test + fun responseSendPreservesCancellation() = + runTest { + val bridge = + WearProxyBridge( + scope = backgroundScope, + sender = WearMessageSender { _, _, _ -> throw CancellationException("stopping") }, + handleRequest = { request -> WearMessage.Response(requestId = request.requestId, ok = true) }, + ) + + var cancelled = false + try { + bridge.handleMessage("watch-1", WearProtocolCodec.encode(request("req-1"))) + } catch (_: CancellationException) { + cancelled = true + } + + assertTrue(cancelled) + assertEquals(1, bridge.peerCountForTests()) + } + + @Test + fun canceledGoogleTaskResumesAsSendFailure() = + runTest { + val failure = runCatching { Tasks.forCanceled().awaitWearTask() }.exceptionOrNull() + + assertTrue(failure is WearTaskCanceledException) + } + + @Test + fun callerCancellationWinsLaterGoogleTaskCompletion() = + runTest { + val source = TaskCompletionSource() + val awaiting = backgroundScope.async { source.task.awaitWearTask() } + runCurrent() + + awaiting.cancel() + runCurrent() + source.setResult(1) + runCurrent() + + assertTrue(awaiting.isCancelled) + } + + @Test + fun eventQueuePreservesSequenceAndBoundsPeers() = + runTest { + val sent = mutableListOf() + val bridge = + WearProxyBridge( + scope = backgroundScope, + sender = WearMessageSender { nodeId, path, data -> sent += SentWearMessage(nodeId, path, data) }, + handleRequest = { request -> WearMessage.Response(requestId = request.requestId, ok = true) }, + ) + repeat(9) { index -> bridge.handleMessage("watch-$index", WearProtocolCodec.encode(request("req-$index"))) } + sent.clear() + + bridge.publishConnection(connected = true, status = "Connected") + bridge.publishChat( + buildJsonObject { + put("runId", "run-1") + put("sessionKey", "main") + put("seq", 1) + put("state", "delta") + put("deltaText", "hello") + put("privateField", "drop me") + }, + ) + runCurrent() + + assertEquals(8, bridge.peerCountForTests()) + val events = + sent + .filter { it.path == WearProtocol.EVENT_PATH } + .map { (WearProtocolCodec.decode(it.data) as WearDecodeResult.Success).message as WearMessage.Event } + assertEquals(16, events.size) + assertEquals(setOf(1L, 2L), events.map { it.sequence }.toSet()) + assertEquals(setOf(WearEventType.Connection, WearEventType.Chat), events.map { it.event }.toSet()) + val chat = events.first { it.event == WearEventType.Chat } + val payload = checkNotNull(chat.payload).jsonObject + assertEquals(setOf("runId", "sessionKey", "seq", "state", "deltaText"), payload.keys) + assertEquals("hello", payload.getValue("deltaText").jsonPrimitive.content) + } + + @Test + fun staleEventFailureDoesNotRemoveRefreshedPeer() = + runTest { + val eventStarted = CompletableDeferred() + val releaseEvent = CompletableDeferred() + val sent = mutableListOf() + var failFirstEvent = true + val bridge = + WearProxyBridge( + scope = backgroundScope, + sender = + WearMessageSender { nodeId, path, data -> + if (path == WearProtocol.EVENT_PATH && failFirstEvent) { + failFirstEvent = false + eventStarted.complete(Unit) + releaseEvent.await() + error("stale send failed") + } + sent += SentWearMessage(nodeId, path, data) + }, + handleRequest = { request -> WearMessage.Response(requestId = request.requestId, ok = true) }, + ) + bridge.handleMessage("watch-1", WearProtocolCodec.encode(request("req-1"))) + + bridge.publishConnection(connected = true, status = "Connected") + eventStarted.await() + bridge.handleMessage("watch-1", WearProtocolCodec.encode(request("req-2"))) + releaseEvent.complete(Unit) + runCurrent() + + bridge.publishConnection(connected = false, status = "Offline") + runCurrent() + + assertEquals(1, bridge.peerCountForTests()) + assertEquals(1, sent.count { it.path == WearProtocol.EVENT_PATH }) + } + + private fun request(requestId: String): WearMessage.Request = WearMessage.Request(requestId = requestId, method = WearRpcMethod.ProxyStatus) +} + +private data class SentWearMessage( + val nodeId: String, + val path: String, + val data: ByteArray, +) diff --git a/apps/android/app/src/test/java/ai/openclaw/app/wear/WearProxyControllerTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/wear/WearProxyControllerTest.kt new file mode 100644 index 000000000000..d4e8d7344945 --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/wear/WearProxyControllerTest.kt @@ -0,0 +1,372 @@ +package ai.openclaw.app.wear + +import ai.openclaw.wear.shared.WearEventType +import ai.openclaw.wear.shared.WearMessage +import ai.openclaw.wear.shared.WearProtocolCodec +import ai.openclaw.wear.shared.WearRpcMethod +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class WearProxyControllerTest { + private val json = Json + + @Test + fun statusDoesNotTouchGateway() = + runTest { + var gatewayCalls = 0 + val controller = + WearProxyController( + requestGateway = { _, _ -> + gatewayCalls += 1 + buildJsonObject {} + }, + isGatewayConnected = { false }, + gatewayStatusText = { "Offline" }, + ) + + val response = controller.handle(request(WearRpcMethod.ProxyStatus)) + + assertTrue(response.ok) + assertEquals(0, gatewayCalls) + val result = checkNotNull(response.result).jsonObject + assertEquals( + false, + result + .getValue("connected") + .jsonPrimitive + .content + .toBoolean(), + ) + assertEquals("Offline", result.getValue("status").jsonPrimitive.content) + } + + @Test + fun sessionsListBuildsFixedGatewayScopeAndProjectsRows() = + runTest { + var requestedMethod: String? = null + var requestedParams: JsonObject? = null + val controller = + controller { method, params -> + requestedMethod = method + requestedParams = params + json.parseToJsonElement( + """{"sessions":[{"key":"agent:main","displayName":"Main","updatedAt":7,"model":"secret-model","lastMessage":"hidden"}],"hasMore":true,"totalCount":9}""", + ) + } + + val response = + controller.handle( + request( + WearRpcMethod.SessionsList, + buildJsonObject { put("limit", 5) }, + ), + ) + + assertEquals("sessions.list", requestedMethod) + assertEquals( + json + .parseToJsonElement("""{"limit":5,"includeGlobal":false,"includeUnknown":false}""") + .jsonObject, + requestedParams, + ) + val result = checkNotNull(response.result).jsonObject + val session = + result + .getValue("sessions") + .jsonArray + .single() + .jsonObject + assertEquals(setOf("key", "displayName", "updatedAt"), session.keys) + assertEquals( + true, + result + .getValue("hasMore") + .jsonPrimitive + .content + .toBoolean(), + ) + } + + @Test + fun historyBoundsGatewayRequestAndDropsBinaryContent() = + runTest { + var requestedParams: JsonObject? = null + val controller = + controller { method, params -> + assertEquals("chat.history", method) + requestedParams = params + json.parseToJsonElement( + """{"sessionKey":"main","messages":[{"id":"m1","role":"assistant","content":[{"type":"text","text":"hello 😀"},{"type":"image","base64":"private"}],"timestamp":9}],"defaults":{"token":"hidden"},"offset":40,"nextOffset":60,"totalMessages":80,"hasMore":true}""", + ) + } + + val response = + controller.handle( + request( + WearRpcMethod.ChatHistory, + buildJsonObject { + put("sessionKey", "main") + put("limit", 20) + put("maxChars", 2_000) + put("offset", 40) + }, + ), + ) + + assertEquals( + json + .parseToJsonElement("""{"sessionKey":"main","limit":20,"maxChars":2000,"offset":40}""") + .jsonObject, + requestedParams, + ) + val result = checkNotNull(response.result).jsonObject + assertFalse("defaults" in result) + assertEquals( + 40, + result + .getValue("offset") + .jsonPrimitive + .content + .toInt(), + ) + assertEquals( + 60, + result + .getValue("nextOffset") + .jsonPrimitive + .content + .toInt(), + ) + assertEquals( + 80, + result + .getValue("totalMessages") + .jsonPrimitive + .content + .toInt(), + ) + assertTrue( + result + .getValue("hasMore") + .jsonPrimitive + .content + .toBoolean(), + ) + val content = + result + .getValue("messages") + .jsonArray + .single() + .jsonObject + .getValue("content") + .jsonArray + assertEquals(1, content.size) + assertEquals( + "hello 😀", + content + .single() + .jsonObject + .getValue("text") + .jsonPrimitive + .content, + ) + assertTrue(WearProtocolCodec.encode(response).isNotEmpty()) + } + + @Test + fun sendForwardsOnlyApprovedFields() = + runTest { + var requestedParams: JsonObject? = null + val controller = + controller { method, params -> + assertEquals("chat.send", method) + requestedParams = params + json.parseToJsonElement("""{"runId":"run-1","status":"started","internal":true}""") + } + + val response = + controller.handle( + request( + WearRpcMethod.ChatSend, + buildJsonObject { + put("sessionKey", "main") + put("message", "reply") + put("idempotencyKey", "wear-1") + }, + ), + ) + + assertEquals( + json + .parseToJsonElement( + """{"sessionKey":"main","message":"reply","idempotencyKey":"wear-1","deliver":false}""", + ).jsonObject, + requestedParams, + ) + assertEquals(setOf("runId", "status"), checkNotNull(response.result).jsonObject.keys) + } + + @Test + fun rejectsUnknownOrOversizedWatchFieldsBeforeGateway() = + runTest { + var gatewayCalls = 0 + val controller = + controller { _, _ -> + gatewayCalls += 1 + buildJsonObject {} + } + val unknownField = + controller.handle( + request( + WearRpcMethod.ChatSend, + buildJsonObject { + put("sessionKey", "main") + put("message", "reply") + put("idempotencyKey", "wear-1") + put("attachments", "not allowed") + }, + ), + ) + val oversized = + controller.handle( + request( + WearRpcMethod.ChatSend, + buildJsonObject { + put("sessionKey", "main") + put("message", "x".repeat(4_001)) + put("idempotencyKey", "wear-2") + }, + ), + ) + + assertEquals(0, gatewayCalls) + assertEquals("invalid_request", unknownField.error?.code) + assertEquals("invalid_request", oversized.error?.code) + } + + @Test + fun preservesBoundedGatewayError() = + runTest { + val controller = + controller { _, _ -> throw WearProxyGatewayException("INVALID_REQUEST", "session unavailable") } + + val response = + controller.handle( + request( + WearRpcMethod.ChatAbort, + buildJsonObject { put("sessionKey", "main") }, + ), + ) + + assertFalse(response.ok) + assertEquals("INVALID_REQUEST", response.error?.code) + assertEquals("session unavailable", response.error?.message) + assertNotNull(WearProtocolCodec.encode(response)) + } + + @Test + fun chatEventPreservesReplacementSemantics() { + val payload = + checkNotNull( + projectWearChatEvent( + json.parseToJsonElement( + """{"runId":"run-1","state":"delta","deltaText":"replacement","replace":true,"privateField":"drop"}""", + ), + ), + ) + + assertEquals(setOf("runId", "state", "deltaText", "replace"), payload.keys) + assertTrue( + payload + .getValue("replace") + .jsonPrimitive + .content + .toBoolean(), + ) + } + + @Test + fun chatEventBoundsAggregateContentAndPreservesTerminalState() { + val payload = + checkNotNull( + projectWearChatEvent( + buildJsonObject { + put("runId", "run-1") + put("state", "final") + put( + "message", + buildJsonObject { + put("role", "assistant") + put( + "content", + buildJsonArray { + repeat(100) { + add( + buildJsonObject { + put("type", "text") + put("text", "😀".repeat(2_000)) + }, + ) + } + }, + ) + }, + ) + }, + ), + ) + + assertEquals("final", payload.getValue("state").jsonPrimitive.content) + val content = + payload + .getValue("message") + .jsonObject + .getValue("content") + .jsonArray + val projectedBytes = + content.sumOf { part -> + part.jsonObject + .getValue("text") + .jsonPrimitive.content + .toByteArray(Charsets.UTF_8) + .size + } + assertTrue(projectedBytes <= 1_024) + assertTrue(content.size < 100) + assertTrue( + WearProtocolCodec + .encode( + WearMessage.Event( + sequence = 1, + event = WearEventType.Chat, + payload = payload, + ), + ).isNotEmpty(), + ) + } + + private fun controller(requestGateway: suspend (String, JsonObject) -> JsonElement): WearProxyController = + WearProxyController( + requestGateway = requestGateway, + isGatewayConnected = { true }, + gatewayStatusText = { "Connected" }, + ) + + private fun request( + method: WearRpcMethod, + params: JsonObject = buildJsonObject {}, + ): WearMessage.Request = WearMessage.Request(requestId = "req-1", method = method, params = params) +} diff --git a/apps/android/gradle/libs.versions.toml b/apps/android/gradle/libs.versions.toml index 125350e1b852..a90b15fe8fa1 100644 --- a/apps/android/gradle/libs.versions.toml +++ b/apps/android/gradle/libs.versions.toml @@ -26,6 +26,7 @@ ktlint-gradle = "14.2.0" kotlin = "2.4.0" material = "1.14.0" okhttp = "5.4.0" +play-services-wearable = "20.0.1" barcode-scanning = "17.3.0" robolectric = "4.16.1" room = "2.8.4" @@ -76,6 +77,7 @@ kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serializa material = { module = "com.google.android.material:material", version.ref = "material" } mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +play-services-wearable = { module = "com.google.android.gms:play-services-wearable", version.ref = "play-services-wearable" } robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } [plugins] diff --git a/apps/android/wear-shared/src/main/java/ai/openclaw/wear/shared/WearProtocol.kt b/apps/android/wear-shared/src/main/java/ai/openclaw/wear/shared/WearProtocol.kt index 53d5e1bcb70b..e85f485ea867 100644 --- a/apps/android/wear-shared/src/main/java/ai/openclaw/wear/shared/WearProtocol.kt +++ b/apps/android/wear-shared/src/main/java/ai/openclaw/wear/shared/WearProtocol.kt @@ -18,6 +18,7 @@ object WearProtocol { const val REQUEST_PATH = "/openclaw/wear/v1/request" const val RESPONSE_PATH = "/openclaw/wear/v1/response" const val EVENT_PATH = "/openclaw/wear/v1/event" + const val PHONE_CAPABILITY = "openclaw_phone_proxy_v1" // MessageClient has a 100 KiB ceiling. Keep headroom for transport metadata and // force transcript pagination instead of depending on an edge-sized message. diff --git a/apps/android/wear-shared/src/test/java/ai/openclaw/wear/shared/WearProtocolTest.kt b/apps/android/wear-shared/src/test/java/ai/openclaw/wear/shared/WearProtocolTest.kt index 8ddc91f7a865..da3446f305c0 100644 --- a/apps/android/wear-shared/src/test/java/ai/openclaw/wear/shared/WearProtocolTest.kt +++ b/apps/android/wear-shared/src/test/java/ai/openclaw/wear/shared/WearProtocolTest.kt @@ -77,6 +77,7 @@ class WearProtocolTest { assertEquals("/openclaw/wear/v1/request", WearProtocol.REQUEST_PATH) assertEquals("/openclaw/wear/v1/response", WearProtocol.RESPONSE_PATH) assertEquals("/openclaw/wear/v1/event", WearProtocol.EVENT_PATH) + assertEquals("openclaw_phone_proxy_v1", WearProtocol.PHONE_CAPABILITY) } @Test diff --git a/scripts/docs-sync-publish.d.mts b/scripts/docs-sync-publish.d.mts index 44a434714bc6..221070573587 100644 --- a/scripts/docs-sync-publish.d.mts +++ b/scripts/docs-sync-publish.d.mts @@ -12,6 +12,8 @@ export function pruneOrphanLocaleDocs(targetDocsDir: string): void; * Resolves the local ClawHub repository path used for docs mirroring. */ export function resolveClawHubRepoPath(value?: string, options?: Record): string; +/** Removes locale pages whose canonical source page no longer exists. */ +export function pruneOrphanLocaleDocs(targetDocsDir: string): void; /** * Mirrors ClawHub docs into the target docs tree. */ diff --git a/scripts/native-app-i18n.ts b/scripts/native-app-i18n.ts index f5847d89ab04..be38361a924d 100644 --- a/scripts/native-app-i18n.ts +++ b/scripts/native-app-i18n.ts @@ -1059,12 +1059,15 @@ export function extractNativeI18nCandidates( } const bodyOffset = (collection.index ?? 0) + collection[0].indexOf(body); for (const item of body.matchAll(ANDROID_RESOURCE_ITEMS)) { - if (item[1]) { + const value = item[1]?.trim(); + // Resource references inherit translatability from their target. Harvesting the + // reference name itself creates a fake user-facing string such as @string/foo. + if (value && !value.startsWith("@")) { addCandidate( entries, surface, repoPath, - item[1], + value, "resource-item", lineNumber(source, bodyOffset + (item.index ?? 0)), ); diff --git a/test/scripts/native-app-i18n.test.ts b/test/scripts/native-app-i18n.test.ts index 4540f683f3ed..f2a6db3e6df8 100644 --- a/test/scripts/native-app-i18n.test.ts +++ b/test/scripts/native-app-i18n.test.ts @@ -275,6 +275,21 @@ describe("native app i18n inventory", () => { expect(entries.map((entry) => entry.source)).toEqual(["Gateway ready"]); }); + it("ignores Android collection resource references", () => { + const entries = extractNativeI18nCandidates( + "android", + "apps/android/app/src/main/res/values/wear.xml", + ` + + @string/native_capability + Visible choice + + `, + ); + + expect(entries.map((entry) => entry.source)).toEqual(["Visible choice"]); + }); + it("collects stable Android and Apple UI entries", async () => { const entries = await collectNativeI18nEntries(); const surfaces = new Set(entries.map((entry) => entry.surface));