mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
a434ee7d30
commit
87bb99d673
+108
-108
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user