feat(android): add Wear OS phone proxy (#109341)

* feat(android): add Wear proxy protocol

Introduce a bounded, versioned phone/watch RPC contract and wire its test, lint, and build gates before either runtime endpoint lands.

Co-authored-by: Sebastian Schubotz <git@sibbl.de>

* test(android): lock Wear wire names

Cover every supported RPC method and event discriminator so phone and watch cannot silently drift.

Co-authored-by: Sebastian Schubotz <git@sibbl.net>

* fix(android): bound Wear JSON depth

Reject excessively nested Data Layer envelopes before kotlinx.serialization can recurse through them.

* fix(android): enforce Wear depth on encode

Keep outbound messages inside the same JSON nesting contract enforced by the decoder.

* fix(android): preflight Wear payload depth

Traverse arbitrary JSON payloads iteratively before serialization so deeply nested local trees fail safely.

* feat(android): add Wear phone proxy

Co-authored-by: Sebastian Schubotz <git@sibbl.net>

* fix(ci): repair Wear and docs sync checks

---------

Co-authored-by: Sebastian Schubotz <git@sibbl.de>
Co-authored-by: Sebastian Schubotz <git@sibbl.net>
This commit is contained in:
Peter Steinberger
2026-07-16 16:08:16 -07:00
committed by GitHub
parent a434ee7d30
commit 87bb99d673
17 changed files with 1408 additions and 112 deletions
+108 -108
View File
@@ -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",
+3
View File
@@ -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)
+14 -1
View File
@@ -1,4 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
@@ -50,6 +51,18 @@
android:name=".NodeForegroundService"
android:exported="false"
android:foregroundServiceType="${nodeForegroundServiceType}" />
<service
android:name=".wear.WearProxyListenerService"
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="com.google.android.gms.wearable.MESSAGE_RECEIVED" />
<data
android:scheme="wear"
android:host="*"
android:pathPrefix="/openclaw/wear/v1/request" />
</intent-filter>
</service>
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
android:enabled="false"
@@ -6,6 +6,8 @@ import ai.openclaw.app.gateway.DeviceAuthStore
import ai.openclaw.app.gateway.DeviceIdentityStore
import ai.openclaw.app.i18n.NativeStringResources
import ai.openclaw.app.i18n.notifyNativeLocaleChanged
import ai.openclaw.app.wear.GoogleWearMessageSender
import ai.openclaw.app.wear.WearProxyBridge
import android.app.Application
import android.content.res.Configuration
import android.os.StrictMode
@@ -31,6 +33,14 @@ class NodeApp : Application() {
private val runtimeLock = Any()
private var runtimeInstance: NodeRuntime? = null
internal val wearProxyBridge: WearProxyBridge by lazy {
WearProxyBridge(
scope = runtimeScope,
sender = GoogleWearMessageSender(this),
handleRequest = { request -> ensureBackgroundRuntime().handleWearProxyRequest(request) },
)
}
/**
* Returns the single NodeRuntime for this process, creating it on first use.
*/
@@ -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(
@@ -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<String, Long>()
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<WearMessage.Event>(
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<PeerRegistration> =
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 <T> Task<T>.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())
}
}
@@ -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
@@ -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) }
}
}
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"
tools:keep="@array/android_wear_capabilities">
<string name="native_android_wear_capability" translatable="false">openclaw_phone_proxy_v1</string>
<string-array name="android_wear_capabilities" translatable="false">
<item tools:ignore="Typos">@string/native_android_wear_capability</item>
</string-array>
</resources>
@@ -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<SentWearMessage>()
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<SentWearMessage>()
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<Int>().awaitWearTask() }.exceptionOrNull()
assertTrue(failure is WearTaskCanceledException)
}
@Test
fun callerCancellationWinsLaterGoogleTaskCompletion() =
runTest {
val source = TaskCompletionSource<Int>()
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<SentWearMessage>()
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<Unit>()
val releaseEvent = CompletableDeferred<Unit>()
val sent = mutableListOf<SentWearMessage>()
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,
)
@@ -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)
}
+2
View File
@@ -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]
@@ -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.
@@ -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
+2
View File
@@ -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, unknown>): 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.
*/
+5 -2
View File
@@ -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)),
);
+15
View File
@@ -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",
`<resources>
<string-array name="capabilities">
<item>@string/native_capability</item>
<item>Visible choice</item>
</string-array>
</resources>`,
);
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));