diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index d56b32573896..1897d78a6056 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -563,7 +563,7 @@ }, { "kind": "ui-call", - "line": 218, + "line": 216, "path": "apps/android/app/src/main/java/ai/openclaw/app/MainActivity.kt", "source": "Too many shares are waiting to be added.", "surface": "android", @@ -571,7 +571,7 @@ }, { "kind": "ui-named-argument", - "line": 341, + "line": 376, "path": "apps/android/app/src/main/java/ai/openclaw/app/MainActivity.kt", "source": "OPENCLAW", "surface": "android", @@ -579,7 +579,7 @@ }, { "kind": "ui-state-text", - "line": 478, + "line": 504, "path": "apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt", "source": "Searching…", "surface": "android", @@ -587,7 +587,7 @@ }, { "kind": "ui-state-text", - "line": 614, + "line": 640, "path": "apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt", "source": "Mic off", "surface": "android", @@ -595,7 +595,7 @@ }, { "kind": "ui-state-text", - "line": 628, + "line": 654, "path": "apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt", "source": "Off", "surface": "android", @@ -731,7 +731,7 @@ }, { "kind": "ui-call", - "line": 184, + "line": 186, "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", @@ -739,7 +739,7 @@ }, { "kind": "ui-call", - "line": 186, + "line": 188, "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", @@ -747,7 +747,7 @@ }, { "kind": "ui-call", - "line": 188, + "line": 190, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load approval details. Refresh and try again.", "surface": "android", @@ -755,7 +755,7 @@ }, { "kind": "ui-call", - "line": 190, + "line": 192, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load approvals.", "surface": "android", @@ -763,7 +763,7 @@ }, { "kind": "ui-call", - "line": 192, + "line": 194, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not resolve approval. Refresh and try again.", "surface": "android", @@ -771,7 +771,7 @@ }, { "kind": "ui-call", - "line": 289, + "line": 321, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Device approved.", "surface": "android", @@ -779,7 +779,7 @@ }, { "kind": "ui-call", - "line": 290, + "line": 322, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Pairing request rejected.", "surface": "android", @@ -787,7 +787,7 @@ }, { "kind": "ui-call", - "line": 291, + "line": 323, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Paired device removed.", "surface": "android", @@ -795,7 +795,7 @@ }, { "kind": "ui-call", - "line": 352, + "line": 384, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Proposal applied.", "surface": "android", @@ -803,7 +803,7 @@ }, { "kind": "ui-call", - "line": 352, + "line": 384, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "apply", "surface": "android", @@ -811,7 +811,7 @@ }, { "kind": "ui-call", - "line": 353, + "line": 385, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Proposal rejected.", "surface": "android", @@ -819,7 +819,7 @@ }, { "kind": "ui-call", - "line": 353, + "line": 385, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "reject", "surface": "android", @@ -827,7 +827,7 @@ }, { "kind": "ui-call", - "line": 354, + "line": 386, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Proposal quarantined.", "surface": "android", @@ -835,7 +835,7 @@ }, { "kind": "ui-call", - "line": 354, + "line": 386, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "quarantine", "surface": "android", @@ -843,7 +843,7 @@ }, { "kind": "ui-call", - "line": 361, + "line": 393, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "unknown", "surface": "android", @@ -851,7 +851,7 @@ }, { "kind": "ui-call", - "line": 362, + "line": 394, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Gateway returned status '$statusLabel' after ${action.verb}.", "surface": "android", @@ -859,7 +859,7 @@ }, { "kind": "ui-call", - "line": 370, + "line": 402, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not ${action.verb} Skill Workshop proposal.", "surface": "android", @@ -867,7 +867,7 @@ }, { "kind": "ui-call", - "line": 552, + "line": 584, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connected (node offline)", "surface": "android", @@ -875,7 +875,7 @@ }, { "kind": "ui-call", - "line": 553, + "line": 585, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connected (operator offline)", "surface": "android", @@ -883,7 +883,7 @@ }, { "kind": "ui-call", - "line": 555, + "line": 587, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Reconnecting…", "surface": "android", @@ -891,7 +891,7 @@ }, { "kind": "ui-call", - "line": 563, + "line": 595, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connected (operator: $operator)", "surface": "android", @@ -899,7 +899,7 @@ }, { "kind": "ui-call", - "line": 2295, + "line": 2386, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "This automation already has a queued run.", "surface": "android", @@ -907,7 +907,7 @@ }, { "kind": "ui-call", - "line": 2325, + "line": 2416, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Automation run queued.", "surface": "android", @@ -915,7 +915,7 @@ }, { "kind": "ui-call", - "line": 2325, + "line": 2416, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Automation started.", "surface": "android", @@ -923,7 +923,7 @@ }, { "kind": "ui-call", - "line": 2338, + "line": 2429, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Gateway rejected the automation run.", "surface": "android", @@ -931,7 +931,7 @@ }, { "kind": "ui-call", - "line": 2369, + "line": 2460, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Automation enabled.", "surface": "android", @@ -939,7 +939,7 @@ }, { "kind": "ui-call", - "line": 2369, + "line": 2460, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Automation paused.", "surface": "android", @@ -947,7 +947,7 @@ }, { "kind": "ui-call", - "line": 2391, + "line": 2482, "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", @@ -955,7 +955,7 @@ }, { "kind": "ui-call", - "line": 2397, + "line": 2488, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Automation updated.", "surface": "android", @@ -963,7 +963,7 @@ }, { "kind": "ui-call", - "line": 2412, + "line": 2503, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Automation deleted.", "surface": "android", @@ -971,7 +971,7 @@ }, { "kind": "ui-call", - "line": 2627, + "line": 2718, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Node offline. Reconnect and retry.", "surface": "android", @@ -979,7 +979,7 @@ }, { "kind": "ui-named-argument-concatenated", - "line": 2638, + "line": 2729, "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", @@ -987,7 +987,7 @@ }, { "kind": "ui-call", - "line": 2659, + "line": 2750, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Failed to request restore. Tap to retry.", "surface": "android", @@ -995,7 +995,7 @@ }, { "kind": "ui-call", - "line": 2670, + "line": 2761, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "No canvas update yet. Tap to retry.", "surface": "android", @@ -1003,7 +1003,7 @@ }, { "kind": "ui-call", - "line": 3767, + "line": 3868, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect to a Gateway to save wake words", "surface": "android", @@ -1011,7 +1011,7 @@ }, { "kind": "ui-call", - "line": 3795, + "line": 3896, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Wake words saved", "surface": "android", @@ -1019,7 +1019,7 @@ }, { "kind": "ui-call", - "line": 3804, + "line": 3905, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not save wake words", "surface": "android", @@ -1027,7 +1027,7 @@ }, { "kind": "ui-call", - "line": 4515, + "line": 4618, "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", @@ -1035,7 +1035,7 @@ }, { "kind": "ui-call", - "line": 4519, + "line": 4622, "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", @@ -1043,7 +1043,7 @@ }, { "kind": "ui-call", - "line": 4523, + "line": 4626, "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", @@ -1051,7 +1051,7 @@ }, { "kind": "ui-named-argument", - "line": 4573, + "line": 4676, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connecting…", "surface": "android", @@ -1059,7 +1059,7 @@ }, { "kind": "ui-named-argument", - "line": 4777, + "line": 4880, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Offline", "surface": "android", @@ -1067,7 +1067,7 @@ }, { "kind": "ui-call", - "line": 5590, + "line": 5693, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load provider catalog.", "surface": "android", @@ -1075,7 +1075,7 @@ }, { "kind": "ui-call", - "line": 5621, + "line": 5724, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Update your Gateway to view provider model config.", "surface": "android", @@ -1083,7 +1083,7 @@ }, { "kind": "ui-call", - "line": 5623, + "line": 5726, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load provider model config.", "surface": "android", @@ -1091,7 +1091,7 @@ }, { "kind": "ui-call", - "line": 5639, + "line": 5742, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Provider models loaded, but readiness is unavailable.", "surface": "android", @@ -1099,7 +1099,7 @@ }, { "kind": "ui-call", - "line": 5726, + "line": 5829, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load automations.", "surface": "android", @@ -1107,7 +1107,7 @@ }, { "kind": "ui-call", - "line": 5812, + "line": 5915, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to inspect automations.", "surface": "android", @@ -1115,7 +1115,7 @@ }, { "kind": "ui-call", - "line": 5822, + "line": 5925, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Gateway returned an invalid automation.", "surface": "android", @@ -1123,7 +1123,7 @@ }, { "kind": "ui-call", - "line": 5826, + "line": 5929, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load automation.", "surface": "android", @@ -1131,7 +1131,7 @@ }, { "kind": "ui-call", - "line": 5838, + "line": 5941, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to inspect automation run history.", "surface": "android", @@ -1139,7 +1139,7 @@ }, { "kind": "ui-call", - "line": 5869, + "line": 5972, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load automation run history.", "surface": "android", @@ -1147,7 +1147,7 @@ }, { "kind": "ui-call", - "line": 5886, + "line": 5989, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Cron changes require operator.admin access.", "surface": "android", @@ -1155,7 +1155,7 @@ }, { "kind": "ui-call", - "line": 5895, + "line": 5998, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to manage automations.", "surface": "android", @@ -1163,7 +1163,7 @@ }, { "kind": "ui-call", - "line": 5907, + "line": 6010, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Another cron action is still finishing.", "surface": "android", @@ -1171,7 +1171,7 @@ }, { "kind": "ui-call", - "line": 5954, + "line": 6057, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Cron action failed.", "surface": "android", @@ -1179,7 +1179,7 @@ }, { "kind": "ui-call", - "line": 6066, + "line": 6169, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load usage.", "surface": "android", @@ -1187,7 +1187,7 @@ }, { "kind": "ui-call", - "line": 6081, + "line": 6184, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load skills.", "surface": "android", @@ -1195,7 +1195,7 @@ }, { "kind": "ui-call", - "line": 6101, + "line": 6204, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to update skills.", "surface": "android", @@ -1203,7 +1203,7 @@ }, { "kind": "ui-call", - "line": 6105, + "line": 6208, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "This gateway connection needs operator.admin to update skills.", "surface": "android", @@ -1211,7 +1211,7 @@ }, { "kind": "conditional-branch", - "line": 6120, + "line": 6223, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not disable skill.", "surface": "android", @@ -1219,7 +1219,7 @@ }, { "kind": "conditional-branch", - "line": 6120, + "line": 6223, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not enable skill.", "surface": "android", @@ -1227,7 +1227,7 @@ }, { "kind": "ui-call", - "line": 6138, + "line": 6241, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to search ClawHub skills.", "surface": "android", @@ -1235,7 +1235,7 @@ }, { "kind": "ui-call", - "line": 6184, + "line": 6287, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not search ClawHub skills.", "surface": "android", @@ -1243,7 +1243,7 @@ }, { "kind": "ui-call", - "line": 6197, + "line": 6300, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to inspect ClawHub skills.", "surface": "android", @@ -1251,7 +1251,7 @@ }, { "kind": "conditional-branch", - "line": 6230, + "line": 6333, "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", @@ -1259,7 +1259,7 @@ }, { "kind": "ui-call", - "line": 6246, + "line": 6349, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load ClawHub details for ${skill.slug}.", "surface": "android", @@ -1267,7 +1267,7 @@ }, { "kind": "ui-call", - "line": 6262, + "line": 6365, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to install ClawHub skills.", "surface": "android", @@ -1275,7 +1275,7 @@ }, { "kind": "ui-call", - "line": 6278, + "line": 6381, "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", @@ -1283,7 +1283,7 @@ }, { "kind": "conditional-branch", - "line": 6368, + "line": 6471, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Installed $slug.", "surface": "android", @@ -1291,7 +1291,7 @@ }, { "kind": "ui-call", - "line": 6375, + "line": 6478, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not install ${slug} from ClawHub.", "surface": "android", @@ -1299,7 +1299,7 @@ }, { "kind": "ui-call", - "line": 6415, + "line": 6518, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to load Skill Workshop proposals.", "surface": "android", @@ -1307,7 +1307,7 @@ }, { "kind": "ui-call", - "line": 6455, + "line": 6558, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load Skill Workshop proposals.", "surface": "android", @@ -1315,7 +1315,7 @@ }, { "kind": "ui-call", - "line": 6475, + "line": 6578, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to inspect Skill Workshop proposals.", "surface": "android", @@ -1323,7 +1323,7 @@ }, { "kind": "ui-call", - "line": 6527, + "line": 6630, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not inspect Skill Workshop proposal.", "surface": "android", @@ -1331,7 +1331,7 @@ }, { "kind": "ui-call", - "line": 6547, + "line": 6650, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Skill Workshop proposal actions require operator.admin scope.", "surface": "android", @@ -1339,7 +1339,7 @@ }, { "kind": "ui-call", - "line": 6552, + "line": 6655, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to update Skill Workshop proposals.", "surface": "android", @@ -1347,7 +1347,7 @@ }, { "kind": "ui-call", - "line": 6742, + "line": 6845, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not verify the device pairing change. Refresh and try again.", "surface": "android", @@ -1355,7 +1355,7 @@ }, { "kind": "ui-named-argument", - "line": 6824, + "line": 6927, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connected", "surface": "android", @@ -1363,7 +1363,7 @@ }, { "kind": "ui-call", - "line": 6854, + "line": 6957, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load nodes and devices.", "surface": "android", @@ -1371,7 +1371,7 @@ }, { "kind": "ui-call", - "line": 7504, + "line": 7607, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load channels.", "surface": "android", @@ -1379,7 +1379,7 @@ }, { "kind": "ui-call", - "line": 7522, + "line": 7625, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load dreaming.", "surface": "android", @@ -1387,7 +1387,7 @@ }, { "kind": "ui-call", - "line": 7537, + "line": 7640, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load gateway logs.", "surface": "android", @@ -1395,7 +1395,7 @@ }, { "kind": "ui-call", - "line": 8020, + "line": 8123, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "One time", "surface": "android", @@ -1403,7 +1403,7 @@ }, { "kind": "ui-call", - "line": 8029, + "line": 8132, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Cron", "surface": "android", @@ -1411,7 +1411,7 @@ }, { "kind": "ui-call", - "line": 8030, + "line": 8133, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Scheduled", "surface": "android", @@ -1419,7 +1419,7 @@ }, { "kind": "ui-call", - "line": 8038, + "line": 8141, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Every ${days}d", "surface": "android", @@ -1427,7 +1427,7 @@ }, { "kind": "ui-call", - "line": 8039, + "line": 8142, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Every ${hours}h", "surface": "android", @@ -1435,7 +1435,7 @@ }, { "kind": "ui-call", - "line": 8040, + "line": 8143, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Every ${minutes}m", "surface": "android", @@ -1443,7 +1443,7 @@ }, { "kind": "ui-call", - "line": 8041, + "line": 8144, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Repeating", "surface": "android", @@ -1451,7 +1451,7 @@ }, { "kind": "ui-call", - "line": 8057, + "line": 8160, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "No prompt", "surface": "android", @@ -1459,7 +1459,7 @@ }, { "kind": "ui-call", - "line": 8074, + "line": 8177, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Gateway", "surface": "android", @@ -1467,7 +1467,7 @@ }, { "kind": "ui-call", - "line": 8082, + "line": 8185, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connected to $gatewayLabel", "surface": "android", @@ -1475,7 +1475,7 @@ }, { "kind": "ui-call", - "line": 8083, + "line": 8186, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Your agents are ready", "surface": "android", @@ -1483,7 +1483,7 @@ }, { "kind": "ui-call", - "line": 8085, + "line": 8188, "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", @@ -1491,7 +1491,7 @@ }, { "kind": "ui-call", - "line": 8089, + "line": 8192, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Selected on this phone", "surface": "android", @@ -1499,7 +1499,7 @@ }, { "kind": "ui-call", - "line": 8092, + "line": 8195, "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", @@ -1507,7 +1507,7 @@ }, { "kind": "ui-call", - "line": 8097, + "line": 8200, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Reconnecting", "surface": "android", @@ -1515,7 +1515,7 @@ }, { "kind": "ui-call", - "line": 8098, + "line": 8201, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "OpenClaw is syncing back up", "surface": "android", @@ -1523,7 +1523,7 @@ }, { "kind": "ui-call", - "line": 8100, + "line": 8203, "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", @@ -1531,7 +1531,7 @@ }, { "kind": "ui-call", - "line": 8104, + "line": 8207, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Gateway session in progress", "surface": "android", @@ -1539,7 +1539,7 @@ }, { "kind": "ui-call", - "line": 8107, + "line": 8210, "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", @@ -1547,7 +1547,7 @@ }, { "kind": "ui-call", - "line": 8112, + "line": 8215, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Welcome to OpenClaw", "surface": "android", @@ -1555,7 +1555,7 @@ }, { "kind": "ui-call", - "line": 8113, + "line": 8216, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Your phone stays quiet until it is needed", "surface": "android", @@ -1563,7 +1563,7 @@ }, { "kind": "ui-call", - "line": 8115, + "line": 8218, "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", @@ -1571,7 +1571,7 @@ }, { "kind": "ui-call", - "line": 8119, + "line": 8222, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect to load your agents", "surface": "android", @@ -1579,7 +1579,7 @@ }, { "kind": "ui-call", - "line": 8122, + "line": 8225, "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", @@ -1587,7 +1587,7 @@ }, { "kind": "ui-call", - "line": 8154, + "line": 8257, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Main", "surface": "android", @@ -1595,7 +1595,7 @@ }, { "kind": "ui-call", - "line": 8169, + "line": 8272, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Active on this phone", "surface": "android", @@ -1603,7 +1603,7 @@ }, { "kind": "ui-call", - "line": 8170, + "line": 8273, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Default agent", "surface": "android", @@ -1611,7 +1611,7 @@ }, { "kind": "ui-call", - "line": 8171, + "line": 8274, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Ready", "surface": "android", @@ -1619,7 +1619,7 @@ }, { "kind": "ui-call", - "line": 8826, + "line": 8929, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Dream", "surface": "android", @@ -1627,7 +1627,7 @@ }, { "kind": "ui-call", - "line": 199, + "line": 368, "path": "apps/android/app/src/main/java/ai/openclaw/app/PermissionRequester.kt", "source": "Permission required", "surface": "android", @@ -1635,7 +1635,7 @@ }, { "kind": "ui-call", - "line": 201, + "line": 370, "path": "apps/android/app/src/main/java/ai/openclaw/app/PermissionRequester.kt", "source": "Continue", "surface": "android", @@ -1643,7 +1643,7 @@ }, { "kind": "ui-call", - "line": 202, + "line": 371, "path": "apps/android/app/src/main/java/ai/openclaw/app/PermissionRequester.kt", "source": "Not now", "surface": "android", @@ -1651,7 +1651,7 @@ }, { "kind": "ui-call", - "line": 229, + "line": 429, "path": "apps/android/app/src/main/java/ai/openclaw/app/PermissionRequester.kt", "source": "Enable permission in Settings", "surface": "android", @@ -1659,7 +1659,7 @@ }, { "kind": "ui-call", - "line": 231, + "line": 431, "path": "apps/android/app/src/main/java/ai/openclaw/app/PermissionRequester.kt", "source": "Open Settings", "surface": "android", @@ -1667,7 +1667,7 @@ }, { "kind": "ui-call", - "line": 239, + "line": 443, "path": "apps/android/app/src/main/java/ai/openclaw/app/PermissionRequester.kt", "source": "Cancel", "surface": "android", @@ -1675,7 +1675,7 @@ }, { "kind": "ui-call", - "line": 246, + "line": 453, "path": "apps/android/app/src/main/java/ai/openclaw/app/PermissionRequester.kt", "source": "OpenClaw needs ${labels.joinToString(\", \")} permissions to continue.", "surface": "android", @@ -1683,7 +1683,7 @@ }, { "kind": "ui-call", - "line": 254, + "line": 461, "path": "apps/android/app/src/main/java/ai/openclaw/app/PermissionRequester.kt", "source": "Please enable ${labels.joinToString(\", \")} in Android Settings to continue.", "surface": "android", @@ -1691,7 +1691,7 @@ }, { "kind": "ui-call", - "line": 262, + "line": 469, "path": "apps/android/app/src/main/java/ai/openclaw/app/PermissionRequester.kt", "source": "Camera", "surface": "android", @@ -1699,7 +1699,7 @@ }, { "kind": "ui-call", - "line": 263, + "line": 470, "path": "apps/android/app/src/main/java/ai/openclaw/app/PermissionRequester.kt", "source": "Microphone", "surface": "android", @@ -1707,7 +1707,7 @@ }, { "kind": "ui-call", - "line": 264, + "line": 471, "path": "apps/android/app/src/main/java/ai/openclaw/app/PermissionRequester.kt", "source": "Send SMS", "surface": "android", @@ -1715,7 +1715,7 @@ }, { "kind": "ui-call", - "line": 265, + "line": 472, "path": "apps/android/app/src/main/java/ai/openclaw/app/PermissionRequester.kt", "source": "Read SMS", "surface": "android", @@ -1723,7 +1723,7 @@ }, { "kind": "ui-call", - "line": 266, + "line": 473, "path": "apps/android/app/src/main/java/ai/openclaw/app/PermissionRequester.kt", "source": "Read Contacts", "surface": "android", @@ -1731,7 +1731,7 @@ }, { "kind": "ui-call", - "line": 267, + "line": 474, "path": "apps/android/app/src/main/java/ai/openclaw/app/PermissionRequester.kt", "source": "Write Contacts", "surface": "android", @@ -1739,7 +1739,7 @@ }, { "kind": "ui-call", - "line": 268, + "line": 475, "path": "apps/android/app/src/main/java/ai/openclaw/app/PermissionRequester.kt", "source": "Read Calendar", "surface": "android", @@ -1747,7 +1747,7 @@ }, { "kind": "ui-call", - "line": 269, + "line": 476, "path": "apps/android/app/src/main/java/ai/openclaw/app/PermissionRequester.kt", "source": "Write Calendar", "surface": "android", @@ -1755,7 +1755,7 @@ }, { "kind": "ui-call", - "line": 270, + "line": 477, "path": "apps/android/app/src/main/java/ai/openclaw/app/PermissionRequester.kt", "source": "Read Call Log", "surface": "android", @@ -1763,7 +1763,7 @@ }, { "kind": "ui-call", - "line": 271, + "line": 478, "path": "apps/android/app/src/main/java/ai/openclaw/app/PermissionRequester.kt", "source": "Motion Activity", "surface": "android", @@ -1771,7 +1771,7 @@ }, { "kind": "ui-call", - "line": 274, + "line": 481, "path": "apps/android/app/src/main/java/ai/openclaw/app/PermissionRequester.kt", "source": "Photos", "surface": "android", @@ -12955,7 +12955,7 @@ }, { "kind": "ui-call", - "line": 409, + "line": 418, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Model", "surface": "android", @@ -12963,7 +12963,7 @@ }, { "kind": "ui-call", - "line": 532, + "line": 541, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Some shared images were omitted or could not be added.", "surface": "android", @@ -12971,7 +12971,7 @@ }, { "kind": "ui-call", - "line": 688, + "line": 697, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Chat needs attention", "surface": "android", @@ -12979,7 +12979,7 @@ }, { "kind": "ui-call", - "line": 1038, + "line": 1047, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "All", "surface": "android", @@ -12987,7 +12987,7 @@ }, { "kind": "ui-call", - "line": 1118, + "line": 1127, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Show Sidebar", "surface": "android", @@ -12995,7 +12995,7 @@ }, { "kind": "ui-call", - "line": 1134, + "line": 1143, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Working", "surface": "android", @@ -13003,7 +13003,7 @@ }, { "kind": "ui-call", - "line": 1135, + "line": 1144, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Ready", "surface": "android", @@ -13011,7 +13011,7 @@ }, { "kind": "ui-call", - "line": 1136, + "line": 1145, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Offline", "surface": "android", @@ -13019,7 +13019,7 @@ }, { "kind": "ui-call", - "line": 1149, + "line": 1158, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Chat actions", "surface": "android", @@ -13027,7 +13027,7 @@ }, { "kind": "ui-call", - "line": 1154, + "line": 1163, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Refresh chat", "surface": "android", @@ -13035,7 +13035,7 @@ }, { "kind": "ui-call", - "line": 1173, + "line": 1182, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Dashboard", "surface": "android", @@ -13043,7 +13043,7 @@ }, { "kind": "ui-call", - "line": 1181, + "line": 1190, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Background tasks", "surface": "android", @@ -13051,7 +13051,7 @@ }, { "kind": "ui-call", - "line": 1202, + "line": 1211, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Chat", "surface": "android", @@ -13059,7 +13059,7 @@ }, { "kind": "ui-call", - "line": 1395, + "line": 1407, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Messages to recover", "surface": "android", @@ -13067,7 +13067,7 @@ }, { "kind": "ui-call", - "line": 1397, + "line": 1409, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "${item.count} message(s) need recovery. Re-enter anything you want to keep, then delete these rows.", "surface": "android", @@ -13075,7 +13075,7 @@ }, { "kind": "ui-call", - "line": 1442, + "line": 1454, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Loading thread", "surface": "android", @@ -13083,7 +13083,7 @@ }, { "kind": "ui-call", - "line": 1475, + "line": 1487, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Jump to latest", "surface": "android", @@ -13091,7 +13091,7 @@ }, { "kind": "ui-call", - "line": 1549, + "line": 1561, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Ready when you are", "surface": "android", @@ -13099,7 +13099,7 @@ }, { "kind": "ui-call", - "line": 1553, + "line": 1565, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Start with a prompt, or use voice.", "surface": "android", @@ -13107,7 +13107,7 @@ }, { "kind": "ui-call", - "line": 1555, + "line": 1567, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Use the recovery options below to reconnect.", "surface": "android", @@ -13115,7 +13115,7 @@ }, { "kind": "ui-call", - "line": 1557, + "line": 1569, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Chat is checking Gateway health.", "surface": "android", @@ -13123,7 +13123,7 @@ }, { "kind": "ui-call", - "line": 1580, + "line": 1592, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Fix connection", "surface": "android", @@ -13131,7 +13131,7 @@ }, { "kind": "ui-call", - "line": 1581, + "line": 1593, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Copy diagnostics", "surface": "android", @@ -13139,7 +13139,7 @@ }, { "kind": "ui-call", - "line": 1640, + "line": 1652, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Catch me up", "surface": "android", @@ -13147,7 +13147,7 @@ }, { "kind": "ui-call", - "line": 1641, + "line": 1653, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Summarize recent threads and next steps.", "surface": "android", @@ -13155,7 +13155,7 @@ }, { "kind": "ui-call", - "line": 1642, + "line": 1654, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Catch me up on my recent OpenClaw threads and suggest next steps.", "surface": "android", @@ -13163,7 +13163,7 @@ }, { "kind": "ui-call", - "line": 1646, + "line": 1658, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Plan the work", "surface": "android", @@ -13171,7 +13171,7 @@ }, { "kind": "ui-call", - "line": 1647, + "line": 1659, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Turn a goal into an actionable checklist.", "surface": "android", @@ -13179,7 +13179,7 @@ }, { "kind": "ui-call", - "line": 1648, + "line": 1660, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Help me turn this goal into a practical checklist: ", "surface": "android", @@ -13187,7 +13187,7 @@ }, { "kind": "ui-call", - "line": 1652, + "line": 1664, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Use this phone", "surface": "android", @@ -13195,7 +13195,7 @@ }, { "kind": "ui-call", - "line": 1653, + "line": 1665, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Ask OpenClaw to use Android capabilities.", "surface": "android", @@ -13203,7 +13203,7 @@ }, { "kind": "ui-call", - "line": 1654, + "line": 1666, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "What can you help me do from this phone right now?", "surface": "android", @@ -13211,7 +13211,7 @@ }, { "kind": "ui-call", - "line": 1738, + "line": 1750, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "OpenClaw · Live", "surface": "android", @@ -13219,7 +13219,7 @@ }, { "kind": "ui-call", - "line": 1739, + "line": 1751, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "You", "surface": "android", @@ -13227,7 +13227,7 @@ }, { "kind": "ui-call", - "line": 1740, + "line": 1752, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "System", "surface": "android", @@ -13235,7 +13235,7 @@ }, { "kind": "ui-call", - "line": 1741, + "line": 1753, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "OpenClaw", "surface": "android", @@ -13243,7 +13243,7 @@ }, { "kind": "ui-call", - "line": 1780, + "line": 1792, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Image", "surface": "android", @@ -13251,7 +13251,7 @@ }, { "kind": "ui-call", - "line": 1796, + "line": 1808, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Additional images hidden: ${omittedImageCount}", "surface": "android", @@ -13259,7 +13259,7 @@ }, { "kind": "ui-call", - "line": 1851, + "line": 1863, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Preparing audio…", "surface": "android", @@ -13267,7 +13267,7 @@ }, { "kind": "ui-call", - "line": 1851, + "line": 1863, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Speaking…", "surface": "android", @@ -13275,7 +13275,7 @@ }, { "kind": "ui-call", - "line": 1882, + "line": 1894, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Close", "surface": "android", @@ -13283,7 +13283,7 @@ }, { "kind": "ui-call", - "line": 1882, + "line": 1894, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "View all", "surface": "android", @@ -13291,7 +13291,7 @@ }, { "kind": "ui-call", - "line": 1912, + "line": 1924, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Tools running", "surface": "android", @@ -13299,7 +13299,7 @@ }, { "kind": "ui-call", - "line": 1914, + "line": 1926, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "OpenClaw is working", "surface": "android", @@ -13307,7 +13307,7 @@ }, { "kind": "ui-call", - "line": 1917, + "line": 1929, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "+${toolCalls.size - 4} more", "surface": "android", @@ -13315,7 +13315,7 @@ }, { "kind": "ui-named-argument", - "line": 1983, + "line": 1995, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "$completedCount/${steps.size}", "surface": "android", @@ -13323,7 +13323,7 @@ }, { "kind": "ui-call", - "line": 1990, + "line": 2002, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Collapse plan checklist", "surface": "android", @@ -13331,7 +13331,7 @@ }, { "kind": "ui-call", - "line": 1990, + "line": 2002, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Expand plan checklist", "surface": "android", @@ -13339,7 +13339,7 @@ }, { "kind": "ui-call", - "line": 2123, + "line": 2135, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Dismiss shared-image warning", "surface": "android", @@ -13347,7 +13347,7 @@ }, { "kind": "ui-call", - "line": 2227, + "line": 2239, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Stop", "surface": "android", @@ -13355,7 +13355,7 @@ }, { "kind": "ui-call", - "line": 2314, + "line": 2326, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Switch branch", "surface": "android", @@ -13363,7 +13363,7 @@ }, { "kind": "ui-call", - "line": 2338, + "line": 2350, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Untitled branch", "surface": "android", @@ -13371,7 +13371,7 @@ }, { "kind": "ui-call", - "line": 2353, + "line": 2365, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Current branch", "surface": "android", @@ -13379,7 +13379,7 @@ }, { "kind": "ui-call", - "line": 2365, + "line": 2377, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Messages: $count", "surface": "android", @@ -13387,7 +13387,7 @@ }, { "kind": "ui-call", - "line": 2373, + "line": 2385, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "$count · $updated", "surface": "android", @@ -13395,7 +13395,7 @@ }, { "kind": "ui-call", - "line": 2402, + "line": 2414, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Default", "surface": "android", @@ -13403,7 +13403,7 @@ }, { "kind": "ui-call", - "line": 2468, + "line": 2480, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Pin model", "surface": "android", @@ -13411,7 +13411,7 @@ }, { "kind": "ui-call", - "line": 2468, + "line": 2480, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Unpin model", "surface": "android", @@ -13419,7 +13419,7 @@ }, { "kind": "ui-call", - "line": 2485, + "line": 2497, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "No commands found", "surface": "android", @@ -13427,7 +13427,7 @@ }, { "kind": "ui-call", - "line": 2527, + "line": 2539, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Command", "surface": "android", @@ -13435,7 +13435,7 @@ }, { "kind": "ui-call", - "line": 2547, + "line": 2559, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Gateway offline", "surface": "android", @@ -13443,7 +13443,7 @@ }, { "kind": "ui-call", - "line": 2593, + "line": 2605, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Close thinking level selector", "surface": "android", @@ -13451,7 +13451,7 @@ }, { "kind": "ui-call", - "line": 2593, + "line": 2605, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Open thinking level selector", "surface": "android", @@ -13459,7 +13459,7 @@ }, { "kind": "ui-call", - "line": 2659, + "line": 2671, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Attach image", "surface": "android", @@ -13467,7 +13467,7 @@ }, { "kind": "ui-call", - "line": 2664, + "line": 2676, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Attachment", "surface": "android", @@ -13475,7 +13475,7 @@ }, { "kind": "ui-call", - "line": 2669, + "line": 2681, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Attach video", "surface": "android", @@ -13483,7 +13483,7 @@ }, { "kind": "ui-call", - "line": 2700, + "line": 2712, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Message OpenClaw", "surface": "android", @@ -13491,7 +13491,7 @@ }, { "kind": "ui-call", - "line": 2729, + "line": 2741, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "End Talk", "surface": "android", @@ -13499,7 +13499,7 @@ }, { "kind": "ui-call", - "line": 2729, + "line": 2741, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Start Talk", "surface": "android", @@ -13507,7 +13507,7 @@ }, { "kind": "ui-call", - "line": 2829, + "line": 2841, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Voice note · ${formatVoiceNoteDuration(duration)}", "surface": "android", @@ -13515,7 +13515,7 @@ }, { "kind": "ui-call", - "line": 2838, + "line": 2850, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Remove attachment", "surface": "android", @@ -13523,7 +13523,7 @@ }, { "kind": "ui-call", - "line": 2850, + "line": 2862, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "New chat", "surface": "android", @@ -13531,7 +13531,7 @@ }, { "kind": "ui-call", - "line": 2859, + "line": 2871, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Main", "surface": "android", @@ -13539,7 +13539,7 @@ }, { "kind": "ui-call", - "line": 2860, + "line": 2872, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Current", "surface": "android", @@ -13547,7 +13547,7 @@ }, { "kind": "ui-call", - "line": 2867, + "line": 2879, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "$emoji $name", "surface": "android", @@ -13555,7 +13555,7 @@ }, { "kind": "ui-call", - "line": 2926, + "line": 2938, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Send", "surface": "android", @@ -13563,7 +13563,7 @@ }, { "kind": "ui-call", - "line": 2937, + "line": 2949, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Chat is still checking Gateway health.", "surface": "android", @@ -13571,7 +13571,7 @@ }, { "kind": "ui-call", - "line": 2938, + "line": 2950, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Gateway is offline. Fix the connection below or copy diagnostics.", "surface": "android", @@ -13579,7 +13579,7 @@ }, { "kind": "ui-call", - "line": 2939, + "line": 2951, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Gateway authentication needs attention.", "surface": "android", @@ -13587,7 +13587,7 @@ }, { "kind": "ui-call", - "line": 2958, + "line": 2970, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Context ${(it * 100).roundToInt()}%", "surface": "android", @@ -13595,7 +13595,7 @@ }, { "kind": "ui-call", - "line": 2959, + "line": 2971, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Context --", "surface": "android", @@ -13603,7 +13603,7 @@ }, { "kind": "ui-call", - "line": 2960, + "line": 2972, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "$contextLabel · ${contextMeterThinkingLabel(thinkingLevel)}", "surface": "android", @@ -13611,7 +13611,7 @@ }, { "kind": "ui-call", - "line": 2999, + "line": 3011, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Off", "surface": "android", @@ -13619,7 +13619,7 @@ }, { "kind": "ui-call", - "line": 3000, + "line": 3012, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Minimal", "surface": "android", @@ -13627,7 +13627,7 @@ }, { "kind": "ui-call", - "line": 3001, + "line": 3013, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Low", "surface": "android", @@ -13635,7 +13635,7 @@ }, { "kind": "ui-call", - "line": 3002, + "line": 3014, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Medium", "surface": "android", @@ -13643,7 +13643,7 @@ }, { "kind": "ui-call", - "line": 3003, + "line": 3015, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "High", "surface": "android", @@ -13651,7 +13651,7 @@ }, { "kind": "ui-call", - "line": 3004, + "line": 3016, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Xhigh", "surface": "android", @@ -13659,7 +13659,7 @@ }, { "kind": "ui-call", - "line": 3005, + "line": 3017, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Adaptive", "surface": "android", @@ -13667,7 +13667,7 @@ }, { "kind": "ui-call", - "line": 3006, + "line": 3018, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Max", "surface": "android", diff --git a/apps/android/README.md b/apps/android/README.md index 371ab7ff7d56..55c30a1b21b0 100644 --- a/apps/android/README.md +++ b/apps/android/README.md @@ -367,7 +367,9 @@ What it does: - Reads `node.describe` command list from the selected Android node. - Invokes advertised non-interactive commands. -- Skips `screen.record` in this suite (Android requires interactive per-invocation screen-capture consent). +- Skips `screen.record` and `talk.ptt.*` in this suite because they require + interactive capture. Use `apps/android/scripts/voice-e2e.sh` for microphone + and voice-path proof. - Asserts command contracts (success or expected deterministic error for safe-invalid calls like `sms.send` and `notifications.actions`). Common failure quick-fixes: diff --git a/apps/android/app/src/debug/java/ai/openclaw/app/VoiceE2eReceiver.kt b/apps/android/app/src/debug/java/ai/openclaw/app/VoiceE2eReceiver.kt index 832e79b1c675..eeeb3ff8e8c0 100644 --- a/apps/android/app/src/debug/java/ai/openclaw/app/VoiceE2eReceiver.kt +++ b/apps/android/app/src/debug/java/ai/openclaw/app/VoiceE2eReceiver.kt @@ -10,6 +10,7 @@ import android.util.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -164,10 +165,22 @@ class VoiceE2eService : Service() { runtime: NodeRuntime, timeoutMs: Long, ) { - withTimeout(timeoutMs) { - while (!runtime.isConnected.value) { - delay(100L) + try { + withTimeout(timeoutMs) { + while (!runtime.isConnected.value) { + voiceE2eTerminalGatewayFailure(runtime.gatewayConnectionProblem.value)?.let { error(it) } + delay(100L) + } } + } catch (err: TimeoutCancellationException) { + throw IllegalStateException( + voiceE2eGatewayTimeoutMessage( + timeoutMs = timeoutMs, + statusText = runtime.statusText.value, + problem = runtime.gatewayConnectionProblem.value, + ), + err, + ) } } @@ -198,3 +211,32 @@ private fun Intent.getDecodedStringExtra(name: String): String? { } return getStringExtra(name) } + +internal fun voiceE2eTerminalGatewayFailure(problem: GatewayConnectionProblem?): String? = + problem + ?.takeIf { it.pauseReconnect && !it.canAutoRetry } + ?.message + ?.trim() + ?.takeIf { it.isNotEmpty() } + +internal fun voiceE2eGatewayTimeoutMessage( + timeoutMs: Long, + statusText: String, + problem: GatewayConnectionProblem?, +): String { + val detail = + problem + ?.message + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?: statusText.trim().takeIf { it.isNotEmpty() } + return buildString { + append("Gateway connection timed out after ") + append(timeoutMs) + append(" ms") + if (detail != null) { + append(": ") + append(detail) + } + } +} diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml index 7986485f8308..2b6c953e8320 100644 --- a/apps/android/app/src/main/AndroidManifest.xml +++ b/apps/android/app/src/main/AndroidManifest.xml @@ -69,6 +69,13 @@ android:host="*" android:path="/openclaw/wear/v1/realtime/audio" /> + + + + initializedViewModel?.let { handleLaunchIntent(viewModel = it, intent = routedIntent) } } - if (!accepted) { - Toast.makeText(this, nativeString("Too many shares are waiting to be added."), Toast.LENGTH_SHORT).show() - } + if (!accepted) return } override fun onRequestPermissionsResult( @@ -135,9 +147,8 @@ class MainActivity : AppCompatActivity() { ) { // AppCompatActivity marks this callback @CallSuper; it preserves Fragment and ActivityResult dispatch. super.onRequestPermissionsResult(requestCode, permissions, grantResults) - if (::permissionRequester.isInitialized) { - permissionRequester.onRequestPermissionsResult(requestCode, permissions, grantResults) - } + permissionRequester.onRequestPermissionsResult(requestCode, permissions, grantResults) + initializedViewModel?.refreshNodePermissionSurface() } /** @@ -154,6 +165,7 @@ class MainActivity : AppCompatActivity() { pendingIntentRouter.activate { initialIntent -> handleLaunchIntent(viewModel = readyViewModel, intent = initialIntent) } + readyViewModel.reportShareLaunchOverflow(pendingIntentRouter.takeShareOverflowCount()) } /** @@ -192,6 +204,22 @@ class MainActivity : AppCompatActivity() { } } } + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + readyViewModel.shareLaunchOverflowRevision.collect { revision -> + if (revision == 0L) return@collect + repeat(readyViewModel.takeShareLaunchOverflowCount()) { + Toast + .makeText( + this@MainActivity, + nativeString("Too many shares are waiting to be added."), + Toast.LENGTH_SHORT, + ).show() + } + } + } + } } /** @@ -202,23 +230,7 @@ class MainActivity : AppCompatActivity() { intent: Intent?, ) { if (intent?.isShareLaunchIntent() == true) { - if (!shareLaunchSlots.tryAcquire()) { - Toast.makeText(this, nativeString("Too many shares are waiting to be added."), Toast.LENGTH_SHORT).show() - return - } - val owner = viewModel.captureChatShareOwner() - lifecycleScope - .launch { - shareLaunchMutex.withLock { - val request = - withContext(Dispatchers.IO) { - parseShareLaunchIntent(intent, contentResolver::getType) - } ?: return@withLock - if (!viewModel.handleShareLaunch(request, owner)) { - Toast.makeText(this@MainActivity, nativeString("Too many shares are waiting to be added."), Toast.LENGTH_SHORT).show() - } - } - }.invokeOnCompletion { shareLaunchSlots.release() } + viewModel.handleShareLaunchIntent(intent) return } parseHomeDestinationIntent(intent)?.let { destination -> @@ -242,6 +254,7 @@ internal class MainActivityPendingIntentRouter { private var sequence = 0L private val pendingShareIntents = ArrayDeque() private var pendingNonShareIntent: PendingLaunchIntent? = null + private var shareOverflowCount = 0 fun setInitialIntent(intent: Intent?) { if (!activated && intent != null) store(intent = intent, initial = true) @@ -275,6 +288,11 @@ internal class MainActivityPendingIntentRouter { return true } + fun takeShareOverflowCount(): Int = + shareOverflowCount.also { + shareOverflowCount = 0 + } + private fun store( intent: Intent, initial: Boolean, @@ -284,7 +302,10 @@ internal class MainActivityPendingIntentRouter { pendingNonShareIntent = pending return true } - if (pendingShareIntents.size >= MAX_PENDING_CHAT_SHARES) return false + if (pendingShareIntents.size >= MAX_PENDING_CHAT_SHARES) { + shareOverflowCount += 1 + return false + } pendingShareIntents.addLast(pending) return true } @@ -305,6 +326,20 @@ internal class MainActivityInitialIntentGate { internal fun shouldNotifyRuntimeBackgrounded(isChangingConfigurations: Boolean): Boolean = !isChangingConfigurations +internal fun updateTopResumedPermissionHost( + isTopResumedActivity: Boolean, + activate: () -> Unit, + deactivate: () -> Unit, + refreshPermissionSurface: () -> Unit, +) { + if (isTopResumedActivity) { + activate() + refreshPermissionSurface() + } else { + deactivate() + } +} + /** Preserves one-shot runtime UI startup while allowing screenshot fixtures to skip side effects. */ internal class MainActivityRuntimeUiStarter { private var completed = false diff --git a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt index 23499aeb11d5..e88dd45e62f3 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt @@ -47,6 +47,8 @@ import ai.openclaw.app.voice.VoiceConversationEntry import ai.openclaw.app.voice.VoiceWakePreferences import android.Manifest import android.app.Application +import android.content.Intent +import android.net.Uri import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.SavedStateHandle @@ -65,7 +67,9 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext import java.util.UUID import java.util.concurrent.atomic.AtomicLong @@ -276,17 +280,33 @@ class MainViewModel private constructor( app: Application, private val prefs: SecurePrefs, savedStateHandle: SavedStateHandle, + private val resolveShareMimeType: (Uri) -> String?, + shareLaunchCapacity: Int, ) : AndroidViewModel(app) { constructor( app: Application, savedStateHandle: SavedStateHandle, - ) : this(app, (app as NodeApp).prefs, savedStateHandle) + ) : this( + app = app, + prefs = (app as NodeApp).prefs, + savedStateHandle = savedStateHandle, + resolveShareMimeType = app.contentResolver::getType, + shareLaunchCapacity = MAX_PENDING_CHAT_SHARES, + ) internal constructor( app: NodeApp, prefs: SecurePrefs, savedStateHandle: SavedStateHandle, - ) : this(app as Application, prefs, savedStateHandle) + resolveShareMimeType: (Uri) -> String? = app.contentResolver::getType, + shareLaunchCapacity: Int = MAX_PENDING_CHAT_SHARES, + ) : this( + app = app as Application, + prefs = prefs, + savedStateHandle = savedStateHandle, + resolveShareMimeType = resolveShareMimeType, + shareLaunchCapacity = shareLaunchCapacity, + ) private val nodeApp = app as NodeApp private val runtimeRef = MutableStateFlow(null) @@ -296,6 +316,10 @@ class MainViewModel private constructor( // Multiple MainActivity instances can overlap across sender tasks; the process owns one queue. private val chatShareDraftSeq = nodeApp.chatShareDraftSeq private val chatShareDraftQueue = nodeApp.chatShareDraftQueue + private val shareLaunchMutex = Mutex() + private val shareLaunchSlots = Semaphore(shareLaunchCapacity) + private val shareLaunchOverflowLock = Any() + private var pendingShareLaunchOverflowCount = 0 // One bounded heap-only slot follows the ViewModel across Activity recreation. // Detail disposal clears it; process death drops it with the ViewModel. @@ -352,6 +376,8 @@ class MainViewModel private constructor( val chatShareDraft: StateFlow = chatShareDraftQueue.head internal val chatShareDrafts: StateFlow> = chatShareDraftQueue.queued internal val chatShareDraftOwnerRevision: StateFlow = chatShareDraftQueue.ownerRevision + private val shareLaunchOverflowRevisionMutable = MutableStateFlow(0L) + internal val shareLaunchOverflowRevision: StateFlow = shareLaunchOverflowRevisionMutable.asStateFlow() private val pendingAssistantAutoSendMutable = MutableStateFlow(null) internal val pendingAssistantAutoSend: StateFlow = pendingAssistantAutoSendMutable private val _assistantAutoSendInFlight = MutableStateFlow(false) @@ -706,6 +732,10 @@ class MainViewModel private constructor( runtimeRef.value?.setForeground(value) } + fun refreshNodePermissionSurface() { + runtimeRef.value?.refreshNodePermissionSurface() + } + fun setDisplayName(value: String) { prefs.setDisplayName(value) } @@ -964,8 +994,49 @@ class MainViewModel private constructor( setChatDraft(request.prompt?.let { ChatDraft(text = it, placement = ChatDraftPlacement.Replace, owner = owner) }) } + /** + * Owns share admission through queue insertion so Activity recreation cannot cancel accepted work. + */ + internal fun handleShareLaunchIntent(intent: Intent): Boolean { + if (!shareLaunchSlots.tryAcquire()) { + reportShareLaunchOverflow() + return false + } + val retainedIntent = Intent(intent) + val owner = captureChatShareOwner() + viewModelScope.launch(start = CoroutineStart.UNDISPATCHED) { + try { + shareLaunchMutex.withLock { + val request = + withContext(Dispatchers.IO) { + parseShareLaunchIntent(retainedIntent, resolveShareMimeType) + } ?: return@withLock + if (!enqueueShareLaunch(request, owner)) reportShareLaunchOverflow() + } + } finally { + shareLaunchSlots.release() + } + } + return true + } + + internal fun reportShareLaunchOverflow(count: Int = 1) { + if (count <= 0) return + synchronized(shareLaunchOverflowLock) { + pendingShareLaunchOverflowCount += count + shareLaunchOverflowRevisionMutable.value += 1 + } + } + + internal fun takeShareLaunchOverflowCount(): Int = + synchronized(shareLaunchOverflowLock) { + pendingShareLaunchOverflowCount.also { + pendingShareLaunchOverflowCount = 0 + } + } + /** Opens shared content as a fresh composer draft; sending still requires an explicit tap. */ - internal fun handleShareLaunch( + private fun enqueueShareLaunch( request: ShareLaunchRequest, owner: ChatComposerOwner, ): Boolean { diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeApp.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeApp.kt index bb644120230e..c24f2954b88e 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeApp.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeApp.kt @@ -24,6 +24,7 @@ class NodeApp : Application() { // System share senders can create overlapping Activity tasks; keep one bounded process queue. internal val chatShareDraftSeq = AtomicLong() internal val chatShareDraftQueue = ChatShareDraftQueue() + internal val permissionRequester by lazy { PermissionRequester(this) } private val runtimeScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val runtimeLock = Any() diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt index 8bf1a3af817d..cd49a6f4b670 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt @@ -96,6 +96,7 @@ import ai.openclaw.app.node.asObjectOrNull import ai.openclaw.app.node.asStringOrNull import ai.openclaw.app.node.invokeErrorFromThrowable import ai.openclaw.app.node.parseHexColorArgb +import ai.openclaw.app.node.readAndroidPermissionSnapshot import ai.openclaw.app.protocol.OpenClawCanvasA2UIAction import ai.openclaw.app.systemagent.SystemAgentChatController import ai.openclaw.app.systemagent.SystemAgentChatState @@ -119,6 +120,7 @@ import ai.openclaw.app.wear.WearProxyBridge import ai.openclaw.app.wear.WearProxyController import ai.openclaw.app.wear.WearProxyGatewayException import ai.openclaw.app.wear.WearProxyModel +import ai.openclaw.app.wear.WearRealtimeAttemptOwner import ai.openclaw.app.wear.WearRealtimeTalkController import ai.openclaw.app.wear.wearConnectionFailure import ai.openclaw.wear.shared.WearMessage @@ -194,6 +196,36 @@ private fun execApprovalResolveFailureMessage(): String = nativeText("Could not internal typealias GatewayDataRequestOverride = suspend (stableId: String, method: String, paramsJson: String?) -> String +internal suspend fun startWearRealtimeTalkWhileCurrent( + owner: WearRealtimeAttemptOwner, + isCurrent: suspend (WearRealtimeAttemptOwner) -> Boolean, + start: suspend (onSessionActivated: () -> Unit) -> Boolean, + stop: suspend (WearRealtimeAttemptOwner) -> Unit, +): Boolean { + if (!isCurrent(owner)) return false + var relayStarted = false + var committed = false + try { + val startReturned = + start { + // The controller invokes this synchronously at activation, before a + // canceled caller can lose the successful suspend result. + relayStarted = true + } + if (!startReturned || !isCurrent(owner)) return false + committed = true + return true + } finally { + // Relay creation suspends outside the channel registry. Never leave a late + // session alive when replacement or cancellation wins before commit. + if (relayStarted && !committed) { + withContext(NonCancellable) { + stop(owner) + } + } + } +} + private class ExecApprovalWriteOutcomeUnknown : IllegalStateException("approval resolve response was not authoritative") private class GatewayApprovalRpcUnavailable : IllegalStateException("Gateway approval RPC catalog is inconsistent") @@ -907,11 +939,23 @@ class NodeRuntime private constructor( locationPreciseEnabled = { locationPreciseEnabled.value }, ) + private val permissionSnapshot = { + readAndroidPermissionSnapshot( + context = appContext, + smsEnabled = SensitiveFeatureConfig.smsEnabled, + callLogEnabled = SensitiveFeatureConfig.callLogEnabled, + photosEnabled = SensitiveFeatureConfig.photosEnabled, + backgroundLocationEnabled = SensitiveFeatureConfig.backgroundLocationEnabled, + ) + } + private val deviceHandler: DeviceHandler = - DeviceHandler( + DeviceHandler.withPermissionSnapshot( appContext = appContext, smsEnabled = SensitiveFeatureConfig.smsEnabled, callLogEnabled = SensitiveFeatureConfig.callLogEnabled, + photosEnabled = SensitiveFeatureConfig.photosEnabled, + permissionSnapshot = permissionSnapshot, ) private val notificationsHandler: NotificationsHandler = @@ -985,12 +1029,14 @@ class NodeRuntime private constructor( SensitiveFeatureConfig.accessibilityControlEnabled && mobileUiHandler.isConnected.value }, inlineWidgetsAvailable = { WebViewFeature.isFeatureSupported(WebViewFeature.MULTI_PROFILE) }, + permissionSnapshot = permissionSnapshot, manualTls = { endpoint -> prefs.gatewayRegistry.entries.value .firstOrNull { it.stableId == endpoint.stableId } ?.tls ?: manualTls.value }, ) + private var lastNodePermissions = connectionManager.buildPermissions() private var lastVoiceWakeCapabilityEnabled = isVoiceWakeCapabilityEnabled() private val invokeDispatcher: InvokeDispatcher = @@ -1510,8 +1556,8 @@ class NodeRuntime private constructor( }, connectGateway = { refreshGatewayConnection() }, disconnectGateway = { disconnect() }, - startRealtimeTalk = { nodeId, sessionKey, attemptId, language -> - if (startWearRealtimeTalk(nodeId, sessionKey, attemptId, language)) wearRealtimeTalkSnapshot.value else null + startRealtimeTalk = { nodeId, sessionKey, attemptId, language, attemptScopedAudio -> + if (startWearRealtimeTalk(nodeId, sessionKey, attemptId, language, attemptScopedAudio)) wearRealtimeTalkSnapshot.value else null }, stopRealtimeTalk = { nodeId, attemptId -> if (stopWearRealtimeTalk(nodeId, attemptId)) wearRealtimeTalkSnapshot.value else null @@ -2042,6 +2088,8 @@ class NodeRuntime private constructor( val talkModeConversation: StateFlow> get() = talkMode.conversation + private val wearRealtimeLifecycleMutex = Mutex() + private val wearRealtimeTalkControllerLazy: Lazy = lazy { WearRealtimeTalkController( @@ -2057,15 +2105,17 @@ class NodeRuntime private constructor( onError(error.message) } }, - sendWatchFrame = { nodeId, type, payload -> + sendWatchFrame = { owner, type, payload -> val app = appContext as? NodeApp ?: error("Wear channel owner is unavailable") - app.wearRealtimeChannels.send(nodeId, type, payload) + app.wearRealtimeChannels.send(owner, type, payload) }, onSnapshot = { snapshot -> wearProxyBridge()?.publishTalk(WearRealtimeTalkCodec.encode(snapshot)) }, - onForceCloseWatchChannel = { nodeId -> - scope.launch { (appContext as? NodeApp)?.wearRealtimeChannels?.close(nodeId) } + onForceCloseWatchChannel = { owner -> + scope.launch { + (appContext as? NodeApp)?.wearRealtimeChannels?.close(owner) + } }, ) } @@ -2081,27 +2131,68 @@ class NodeRuntime private constructor( sessionKey: String, attemptId: String, language: String?, + attemptScopedAudio: Boolean, ): Boolean { if (talkModeEnabled.value || micEnabled.value || micCooldown.value) return false + val app = appContext as? NodeApp ?: return false + val claim = + app.wearRealtimeChannels.claim( + nodeId = nodeId, + attemptId = attemptId, + attemptScopedAudio = attemptScopedAudio, + ) ?: return false + val owner = claim.owner val resolvedLanguage = talkMode.resolveRealtimeLanguageHint(language) - return wearRealtimeTalkController.start(nodeId, sessionKey, attemptId, resolvedLanguage) + var started = false + return try { + started = + wearRealtimeLifecycleMutex.withLock { + if (talkModeEnabled.value || micEnabled.value || micCooldown.value) { + return@withLock false + } + startWearRealtimeTalkWhileCurrent( + owner = owner, + isCurrent = app.wearRealtimeChannels::isCurrent, + start = { onSessionActivated -> + wearRealtimeTalkController.start( + owner = owner, + sessionKey = sessionKey, + language = resolvedLanguage, + onSessionActivated = onSessionActivated, + ) + }, + stop = { staleOwner -> + wearRealtimeTalkController.stop(staleOwner) + }, + ) + } + started + } finally { + if (!started && claim.newlyAcquired) app.wearRealtimeChannels.release(owner) + } } internal suspend fun stopWearRealtimeTalk( nodeId: String? = null, attemptId: String? = null, - ): Boolean { - // The watch closes its channel after receiving the stop response. Closing - // here races the response and makes a normal stop look like link failure. - return wearRealtimeTalkController.stop(nodeId, attemptId) - } + ): Boolean = + wearRealtimeLifecycleMutex.withLock { + // The watch closes its channel after receiving the stop response. Closing + // here races the response and makes a normal stop look like link failure. + wearRealtimeTalkController.stop(nodeId, attemptId) + } + + internal suspend fun stopWearRealtimeTalk(owner: WearRealtimeAttemptOwner): Boolean = + wearRealtimeLifecycleMutex.withLock { + wearRealtimeTalkController.stop(owner) + } internal fun appendWearRealtimeAudio( - nodeId: String, + owner: WearRealtimeAttemptOwner, payload: ByteArray, ) { if (wearRealtimeTalkControllerLazy.isInitialized()) { - wearRealtimeTalkController.appendAudio(nodeId, payload) + wearRealtimeTalkController.appendAudio(owner, payload) } } @@ -2991,6 +3082,7 @@ class NodeRuntime private constructor( voiceLifecycleEpoch.incrementAndGet() } if (value) { + refreshNodePermissionSurface() refreshVoiceWakeCapabilitySurfaceIfChanged() reconnectPreferredGatewayOnForeground() scope.launch { @@ -3263,6 +3355,15 @@ class NodeRuntime private constructor( resolvePreferredGatewayEndpoint()?.let(::connect) } + /** + * Reconnect a live node only when Android authority changed since its last connect. + */ + fun refreshNodePermissionSurface() { + val permissions = connectionManager.buildPermissions() + if (permissions == lastNodePermissions) return + refreshNodeSurfaceAfterSettingsChange() + } + fun setDisplayName(value: String) { prefs.setDisplayName(value) } @@ -4247,12 +4348,14 @@ class NodeRuntime private constructor( tls, ) } + val nodeConnectOptions = connectionManager.buildNodeConnectOptions() + lastNodePermissions = nodeConnectOptions.permissions nodeSession.connect( endpoint, auth.token, auth.bootstrapToken, auth.password, - connectionManager.buildNodeConnectOptions(), + nodeConnectOptions, tls, ) if (reconnect && operatorAuth != null) { diff --git a/apps/android/app/src/main/java/ai/openclaw/app/PermissionRequester.kt b/apps/android/app/src/main/java/ai/openclaw/app/PermissionRequester.kt index e6685593cceb..ed4088cfa7e9 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/PermissionRequester.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/PermissionRequester.kt @@ -2,6 +2,7 @@ package ai.openclaw.app import ai.openclaw.app.i18n.nativeString import android.Manifest +import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.net.Uri @@ -15,12 +16,20 @@ import androidx.core.content.ContextCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout +import java.util.IdentityHashMap import java.util.concurrent.atomic.AtomicBoolean import kotlin.coroutines.resume @@ -28,28 +37,83 @@ import kotlin.coroutines.resume * Serializes Android runtime-permission prompts behind coroutine-friendly request calls. */ class PermissionRequester internal constructor( - private val activity: ComponentActivity, - private val permissionRequestLauncher: (Array, Int) -> Unit, + context: Context, private val requestCodeAllocator: PermissionRequestCodeAllocator = PermissionRequestCodeAllocator(), ) { + private data class ActivityHost( + val activity: ComponentActivity, + val permissionRequestLauncher: (Array, Int) -> Unit, + ) + + private data class ActiveActivityHost( + val host: ActivityHost, + val activation: Long, + ) + private data class PendingPermissionRequest( val requestCode: Int, val permissions: List, val deferred: CompletableDeferred>, ) - constructor(activity: ComponentActivity) : this( - activity = activity, - permissionRequestLauncher = { permissions, requestCode -> - ActivityCompat.requestPermissions(activity, permissions, requestCode) - }, - ) + private enum class RationaleResult { + Proceed, + Decline, + HostLost, + } + private enum class SettingsResult { + Shown, + HostLost, + } + + private val appContext = context.applicationContext private val mutex = Mutex() + private val activityHostLock = Any() private val permissionRequestsLock = Any() private val mainHandler = Handler(Looper.getMainLooper()) + private val activityHosts = IdentityHashMap() + private val activeActivitySequences = IdentityHashMap() + private val activeActivityHost = MutableStateFlow(null) + private var nextActivityActivation = 0L private val pendingPermissionRequests = mutableMapOf() + internal fun attach( + activity: ComponentActivity, + permissionRequestLauncher: (Array, Int) -> Unit = { permissions, requestCode -> + ActivityCompat.requestPermissions(activity, permissions, requestCode) + }, + ) { + synchronized(activityHostLock) { + activityHosts[activity] = ActivityHost(activity, permissionRequestLauncher) + publishActiveActivityHostLocked() + } + } + + internal fun activate(activity: ComponentActivity) { + synchronized(activityHostLock) { + check(activityHosts.containsKey(activity)) { "permission Activity must attach before activation" } + nextActivityActivation += 1 + activeActivitySequences[activity] = nextActivityActivation + publishActiveActivityHostLocked() + } + } + + internal fun deactivate(activity: ComponentActivity) { + synchronized(activityHostLock) { + activeActivitySequences.remove(activity) + publishActiveActivityHostLocked() + } + } + + internal fun detach(activity: ComponentActivity) { + synchronized(activityHostLock) { + activeActivitySequences.remove(activity) + activityHosts.remove(activity) + publishActiveActivityHostLocked() + } + } + /** * Request missing Android runtime permissions and return the final grant state for every requested permission. */ @@ -61,29 +125,22 @@ class PermissionRequester internal constructor( while (true) { val missing = permissions.filter { perm -> - ContextCompat.checkSelfPermission(activity, perm) != PackageManager.PERMISSION_GRANTED + ContextCompat.checkSelfPermission(appContext, perm) != PackageManager.PERMISSION_GRANTED } if (missing.isEmpty()) { return permissions.associateWith { true } } - val needsRationale = - missing.any { ActivityCompat.shouldShowRequestPermissionRationale(activity, it) } - if (needsRationale) { - val proceed = showRationaleDialog(missing) - if (!proceed) { - return permissions.associateWith { perm -> - ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED - } + if (!confirmRationaleIfNeeded(missing, timeoutMs)) { + return permissions.associateWith { perm -> + ContextCompat.checkSelfPermission(appContext, perm) == PackageManager.PERMISSION_GRANTED } } val deferred = CompletableDeferred>() val request = reservePermissionRequest(missing, deferred) try { - withContext(Dispatchers.Main) { - permissionRequestLauncher(missing.toTypedArray(), request.requestCode) - } + launchPermissionRequest(missing, request.requestCode, timeoutMs) } catch (err: Throwable) { clearPermissionRequest(request) throw err @@ -100,17 +157,11 @@ class PermissionRequester internal constructor( val merged = permissions.associateWith { perm -> val nowGranted = - ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED + ContextCompat.checkSelfPermission(appContext, perm) == PackageManager.PERMISSION_GRANTED result[perm] == true || nowGranted } - val denied = - merged.filterValues { !it }.keys.filter { - !ActivityCompat.shouldShowRequestPermissionRationale(activity, it) - } - if (denied.isNotEmpty()) { - showSettingsDialog(denied) - } + showSettingsForPermanentDenials(merged, timeoutMs) return merged } @@ -157,23 +208,135 @@ class PermissionRequester internal constructor( } } - private suspend fun showRationaleDialog(permissions: List): Boolean = - withContext(Dispatchers.Main) { - if (activity.isFinishing || activity.isDestroyed) { - return@withContext false + private fun publishActiveActivityHostLocked() { + val active = + activeActivitySequences.entries.maxByOrNull { it.value }?.let { entry -> + activityHosts[entry.key]?.let { host -> + ActiveActivityHost(host = host, activation = entry.value) + } } + activeActivityHost.value = active + } + + private suspend fun awaitActiveActivityHost(timeoutMs: Long): ActiveActivityHost = + withTimeout(timeoutMs) { + activeActivityHost + .filterNotNull() + .first { active -> + !active.host.activity.isFinishing && !active.host.activity.isDestroyed + } + } + + private suspend fun launchPermissionRequest( + permissions: List, + requestCode: Int, + timeoutMs: Long, + ) { + withTimeout(timeoutMs) { + var rejected: ActiveActivityHost? = null + while (true) { + val active = + activeActivityHost + .filterNotNull() + .first { candidate -> + candidate != rejected && + !candidate.host.activity.isFinishing && + !candidate.host.activity.isDestroyed + } + val launched = + withContext(Dispatchers.Main) { + if (activeActivityHost.value != active) return@withContext false + val host = active.host + if (host.activity.isFinishing || host.activity.isDestroyed) return@withContext false + host.permissionRequestLauncher(permissions.toTypedArray(), requestCode) + true + } + if (launched) return@withTimeout + rejected = active + } + } + } + + private suspend fun confirmRationaleIfNeeded( + permissions: List, + timeoutMs: Long, + ): Boolean = + withTimeout(timeoutMs) { + while (true) { + val active = awaitActiveActivityHost(timeoutMs) + val needsRationale = + withContext(Dispatchers.Main) { + if (!isCurrentActiveHost(active)) return@withContext null + permissions.any { permission -> + ActivityCompat.shouldShowRequestPermissionRationale(active.host.activity, permission) + } + } ?: continue + if (!needsRationale) return@withTimeout true + when (showRationaleDialog(active, permissions)) { + RationaleResult.Proceed -> return@withTimeout true + RationaleResult.Decline -> return@withTimeout false + RationaleResult.HostLost -> Unit + } + } + error("unreachable") + } + + private suspend fun showSettingsForPermanentDenials( + grants: Map, + timeoutMs: Long, + ) { + if (grants.values.none { granted -> !granted }) return + withTimeout(timeoutMs) { + while (true) { + val active = awaitActiveActivityHost(timeoutMs) + val denied = + withContext(Dispatchers.Main) { + if (!isCurrentActiveHost(active)) return@withContext null + grants + .filterValues { granted -> !granted } + .keys + .filter { permission -> + !ActivityCompat.shouldShowRequestPermissionRationale(active.host.activity, permission) + } + } ?: continue + if (denied.isEmpty()) return@withTimeout + when (showSettingsDialog(active, denied)) { + SettingsResult.Shown -> return@withTimeout + SettingsResult.HostLost -> Unit + } + } + } + } + + private fun isCurrentActiveHost(active: ActiveActivityHost): Boolean = + activeActivityHost.value == active && + !active.host.activity.isFinishing && + !active.host.activity.isDestroyed + + private suspend fun showRationaleDialog( + active: ActiveActivityHost, + permissions: List, + ): RationaleResult = + withContext(Dispatchers.Main) { + if (!isCurrentActiveHost(active)) { + return@withContext RationaleResult.HostLost + } + val activity = active.host.activity suspendCancellableCoroutine { cont -> val lifecycle = activity.lifecycle var dialog: AlertDialog? = null var observer: LifecycleEventObserver? = null + var hostLossJob: Job? = null val finished = AtomicBoolean(false) val removeObserver = { observer?.let(lifecycle::removeObserver) observer = null } - fun finish(result: Boolean?) { + fun finish(result: RationaleResult?) { if (!finished.compareAndSet(false, true)) return + hostLossJob?.cancel() + hostLossJob = null removeObserver() dialog?.dismiss() if (result != null) { @@ -183,62 +346,106 @@ class PermissionRequester internal constructor( val actualObserver = LifecycleEventObserver { _, event -> if (event != Lifecycle.Event.ON_DESTROY) return@LifecycleEventObserver - // Do not resume a destroyed Activity with a positive result. - finish(false) + finish(RationaleResult.HostLost) } observer = actualObserver lifecycle.addObserver(actualObserver) + hostLossJob = + CoroutineScope(cont.context) + .launch(start = CoroutineStart.LAZY) { + activeActivityHost.first { current -> current != active } + finish(RationaleResult.HostLost) + }.also(Job::start) cont.invokeOnCancellation { mainHandler.post { finish(null) } } + if (finished.get()) return@suspendCancellableCoroutine dialog = AlertDialog .Builder(activity) .setTitle(nativeString("Permission required")) .setMessage(buildRationaleMessage(permissions)) - .setPositiveButton(nativeString("Continue")) { _, _ -> finish(true) } - .setNegativeButton(nativeString("Not now")) { _, _ -> finish(false) } - .setOnCancelListener { finish(false) } + .setPositiveButton(nativeString("Continue")) { _, _ -> finish(RationaleResult.Proceed) } + .setNegativeButton(nativeString("Not now")) { _, _ -> finish(RationaleResult.Decline) } + .setOnCancelListener { finish(RationaleResult.Decline) } .show() } } - private suspend fun showSettingsDialog(permissions: List) = + private suspend fun showSettingsDialog( + active: ActiveActivityHost, + permissions: List, + ): SettingsResult = withContext(Dispatchers.Main) { - if (activity.isFinishing || activity.isDestroyed) return@withContext - val lifecycle = activity.lifecycle - var dialog: AlertDialog? = null - var observer: LifecycleEventObserver? = null - val removeObserver = { - observer?.let(lifecycle::removeObserver) - observer = null + if (!isCurrentActiveHost(active)) { + return@withContext SettingsResult.HostLost } - val actualObserver = - LifecycleEventObserver { _, event -> - if (event != Lifecycle.Event.ON_DESTROY) return@LifecycleEventObserver + val activity = active.host.activity + suspendCancellableCoroutine { cont -> + val lifecycle = activity.lifecycle + var dialog: AlertDialog? = null + var observer: LifecycleEventObserver? = null + var hostLossJob: Job? = null + val finished = AtomicBoolean(false) + val removeObserver = { + observer?.let(lifecycle::removeObserver) + observer = null + } + + fun finish(result: SettingsResult?) { + if (!finished.compareAndSet(false, true)) return + hostLossJob?.cancel() + hostLossJob = null removeObserver() dialog?.dismiss() + if (result != null) { + cont.resume(result) + } } - observer = actualObserver - lifecycle.addObserver(actualObserver) - dialog = - AlertDialog - .Builder(activity) - .setTitle(nativeString("Enable permission in Settings")) - .setMessage(buildSettingsMessage(permissions)) - .setPositiveButton(nativeString("Open Settings")) { _, _ -> - if (activity.isFinishing || activity.isDestroyed) return@setPositiveButton - val intent = - Intent( - Settings.ACTION_APPLICATION_DETAILS_SETTINGS, - Uri.fromParts("package", activity.packageName, null), - ) - activity.startActivity(intent) - }.setNegativeButton(nativeString("Cancel"), null) - .setOnDismissListener { removeObserver() } - .show() + val actualObserver = + LifecycleEventObserver { _, event -> + if (event != Lifecycle.Event.ON_DESTROY) return@LifecycleEventObserver + finish(SettingsResult.HostLost) + } + observer = actualObserver + lifecycle.addObserver(actualObserver) + hostLossJob = + CoroutineScope(cont.context) + .launch(start = CoroutineStart.LAZY) { + activeActivityHost.first { current -> current != active } + finish(SettingsResult.HostLost) + }.also(Job::start) + cont.invokeOnCancellation { + mainHandler.post { + finish(null) + } + } + if (finished.get()) return@suspendCancellableCoroutine + dialog = + AlertDialog + .Builder(activity) + .setTitle(nativeString("Enable permission in Settings")) + .setMessage(buildSettingsMessage(permissions)) + .setPositiveButton(nativeString("Open Settings")) { _, _ -> + if (!isCurrentActiveHost(active)) { + finish(SettingsResult.HostLost) + return@setPositiveButton + } + val intent = + Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts("package", activity.packageName, null), + ) + activity.startActivity(intent) + finish(SettingsResult.Shown) + }.setNegativeButton(nativeString("Cancel")) { _, _ -> + finish(SettingsResult.Shown) + }.setOnCancelListener { finish(SettingsResult.Shown) } + .setOnDismissListener { finish(SettingsResult.Shown) } + .show() + } } private fun buildRationaleMessage(permissions: List): String { diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/AndroidPermissionSnapshot.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/AndroidPermissionSnapshot.kt new file mode 100644 index 000000000000..40fd9108f6d5 --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/AndroidPermissionSnapshot.kt @@ -0,0 +1,91 @@ +package ai.openclaw.app.node + +import ai.openclaw.app.hasPhotoReadPermission +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.content.ContextCompat + +/** + * Canonical Android authority snapshot shared by node approval and device.permissions. + */ +internal data class AndroidPermissionSnapshot( + val camera: Boolean, + val microphone: Boolean, + val location: Boolean, + val locationPrecise: Boolean, + val locationBackground: Boolean, + val smsSend: Boolean, + val smsRead: Boolean, + val notificationListener: Boolean, + val notifications: Boolean, + val photos: Boolean, + val contactsRead: Boolean, + val contactsWrite: Boolean, + val calendarRead: Boolean, + val calendarWrite: Boolean, + val callLog: Boolean, + val motion: Boolean, +) { + /** + * Keep independently grantable authority separate so any widening requires node reapproval. + */ + fun gatewayPermissions(): Map = + linkedMapOf( + "camera" to camera, + "microphone" to microphone, + "location" to location, + "locationPrecise" to locationPrecise, + "locationBackground" to locationBackground, + "smsSend" to smsSend, + "smsRead" to smsRead, + "notificationListener" to notificationListener, + "notifications" to notifications, + "photos" to photos, + "contactsRead" to contactsRead, + "contactsWrite" to contactsWrite, + "calendarRead" to calendarRead, + "calendarWrite" to calendarWrite, + "callLog" to callLog, + "motion" to motion, + ) +} + +internal fun readAndroidPermissionSnapshot( + context: Context, + smsEnabled: Boolean, + callLogEnabled: Boolean, + photosEnabled: Boolean, + backgroundLocationEnabled: Boolean, +): AndroidPermissionSnapshot { + fun hasPermission(permission: String): Boolean = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED + + val locationFine = hasPermission(Manifest.permission.ACCESS_FINE_LOCATION) + val locationCoarse = hasPermission(Manifest.permission.ACCESS_COARSE_LOCATION) + val telephonyAvailable = context.packageManager.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) + + return AndroidPermissionSnapshot( + camera = hasPermission(Manifest.permission.CAMERA), + microphone = hasPermission(Manifest.permission.RECORD_AUDIO), + location = locationFine || locationCoarse, + locationPrecise = locationFine, + locationBackground = + backgroundLocationEnabled && + (locationFine || locationCoarse) && + hasPermission(Manifest.permission.ACCESS_BACKGROUND_LOCATION), + smsSend = smsEnabled && telephonyAvailable && hasPermission(Manifest.permission.SEND_SMS), + smsRead = smsEnabled && telephonyAvailable && hasPermission(Manifest.permission.READ_SMS), + notificationListener = DeviceNotificationListenerService.isAccessEnabled(context), + notifications = + Build.VERSION.SDK_INT < 33 || + hasPermission(Manifest.permission.POST_NOTIFICATIONS), + photos = photosEnabled && hasPhotoReadPermission(context), + contactsRead = hasPermission(Manifest.permission.READ_CONTACTS), + contactsWrite = hasPermission(Manifest.permission.WRITE_CONTACTS), + calendarRead = hasPermission(Manifest.permission.READ_CALENDAR), + calendarWrite = hasPermission(Manifest.permission.WRITE_CALENDAR), + callLog = callLogEnabled && hasPermission(Manifest.permission.READ_CALL_LOG), + motion = hasPermission(Manifest.permission.ACTIVITY_RECOGNITION), + ) +} diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/ConnectionManager.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/ConnectionManager.kt index 1cd2f9a4d5a2..2e2ee1f3247d 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/ConnectionManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/ConnectionManager.kt @@ -14,7 +14,7 @@ import android.os.Build /** * Builds gateway connect metadata from current Android permissions, settings, and device identity. */ -class ConnectionManager( +class ConnectionManager internal constructor( private val prefs: SecurePrefs, private val cameraEnabled: () -> Boolean, private val locationMode: () -> LocationMode, @@ -29,6 +29,7 @@ class ConnectionManager( private val voiceWakeAvailable: () -> Boolean, private val mobileUiAvailable: () -> Boolean, private val inlineWidgetsAvailable: () -> Boolean, + private val permissionSnapshot: () -> AndroidPermissionSnapshot, private val manualTls: (GatewayEndpoint) -> Boolean, ) { companion object { @@ -158,6 +159,9 @@ class ConnectionManager( /** Builds the gateway-advertised capability list from current permission and feature state. */ fun buildCapabilities(): List = InvokeCommandRegistry.advertisedCapabilities(runtimeFlags()) + /** Builds the current independently grantable Android permission surface. */ + fun buildPermissions(): Map = permissionSnapshot().gatewayPermissions() + /** * Debug Android builds advertise a dev version so gateway logs do not look like release clients. */ @@ -213,7 +217,7 @@ class ConnectionManager( scopes = emptyList(), caps = buildCapabilities(), commands = buildInvokeCommands(), - permissions = emptyMap(), + permissions = buildPermissions(), client = buildClientInfo(clientId = "openclaw-android", clientMode = "node"), userAgent = buildUserAgent(), ) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/DeviceHandler.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/DeviceHandler.kt index 30eb6009bbd8..66012f099ad8 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/DeviceHandler.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/DeviceHandler.kt @@ -3,8 +3,6 @@ package ai.openclaw.app.node import ai.openclaw.app.BuildConfig import ai.openclaw.app.SensitiveFeatureConfig import ai.openclaw.app.gateway.GatewaySession -import ai.openclaw.app.hasPhotoReadPermission -import android.Manifest import android.annotation.SuppressLint import android.app.ActivityManager import android.content.Context @@ -20,7 +18,6 @@ import android.os.Environment import android.os.PowerManager import android.os.StatFs import android.os.SystemClock -import androidx.core.content.ContextCompat import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonObject @@ -118,27 +115,56 @@ class DeviceHandler private constructor( private val callLogEnabled: Boolean = SensitiveFeatureConfig.callLogEnabled, private val photosEnabled: Boolean = SensitiveFeatureConfig.photosEnabled, private val appSource: DeviceAppSource = AndroidDeviceAppSource(appContext), + private val permissionSnapshot: () -> AndroidPermissionSnapshot, ) { constructor( appContext: Context, smsEnabled: Boolean = SensitiveFeatureConfig.smsEnabled, callLogEnabled: Boolean = SensitiveFeatureConfig.callLogEnabled, photosEnabled: Boolean = SensitiveFeatureConfig.photosEnabled, + backgroundLocationEnabled: Boolean = SensitiveFeatureConfig.backgroundLocationEnabled, ) : this( appContext = appContext, smsEnabled = smsEnabled, callLogEnabled = callLogEnabled, photosEnabled = photosEnabled, appSource = AndroidDeviceAppSource(appContext), + permissionSnapshot = { + readAndroidPermissionSnapshot( + context = appContext, + smsEnabled = smsEnabled, + callLogEnabled = callLogEnabled, + photosEnabled = photosEnabled, + backgroundLocationEnabled = backgroundLocationEnabled, + ) + }, ) companion object { + internal fun withPermissionSnapshot( + appContext: Context, + smsEnabled: Boolean, + callLogEnabled: Boolean, + photosEnabled: Boolean, + permissionSnapshot: () -> AndroidPermissionSnapshot, + ): DeviceHandler = + DeviceHandler( + appContext = appContext, + smsEnabled = smsEnabled, + callLogEnabled = callLogEnabled, + photosEnabled = photosEnabled, + appSource = AndroidDeviceAppSource(appContext), + permissionSnapshot = permissionSnapshot, + ) + internal fun forTesting( appContext: Context, appSource: DeviceAppSource, smsEnabled: Boolean = SensitiveFeatureConfig.smsEnabled, callLogEnabled: Boolean = SensitiveFeatureConfig.callLogEnabled, photosEnabled: Boolean = SensitiveFeatureConfig.photosEnabled, + backgroundLocationEnabled: Boolean = SensitiveFeatureConfig.backgroundLocationEnabled, + permissionSnapshot: (() -> AndroidPermissionSnapshot)? = null, ): DeviceHandler = DeviceHandler( appContext = appContext, @@ -146,6 +172,16 @@ class DeviceHandler private constructor( callLogEnabled = callLogEnabled, photosEnabled = photosEnabled, appSource = appSource, + permissionSnapshot = + permissionSnapshot ?: { + readAndroidPermissionSnapshot( + context = appContext, + smsEnabled = smsEnabled, + callLogEnabled = callLogEnabled, + photosEnabled = photosEnabled, + backgroundLocationEnabled = backgroundLocationEnabled, + ) + }, ) /** @@ -316,24 +352,8 @@ class DeviceHandler private constructor( } private fun permissionsPayloadJson(): String { + val snapshot = permissionSnapshot() val canSendSms = appContext.packageManager.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) - val smsSendGranted = hasPermission(Manifest.permission.SEND_SMS) - val smsReadGranted = hasPermission(Manifest.permission.READ_SMS) - val notificationAccess = DeviceNotificationListenerService.isAccessEnabled(appContext) - val photosGranted = - if (!photosEnabled) { - false - } else { - hasPhotoReadPermission(appContext) - } - val motionGranted = hasPermission(Manifest.permission.ACTIVITY_RECOGNITION) - val notificationsGranted = - if (Build.VERSION.SDK_INT >= 33) { - // POST_NOTIFICATIONS exists only on Android 13+. - hasPermission(Manifest.permission.POST_NOTIFICATIONS) - } else { - true - } return buildJsonObject { put( "permissions", @@ -341,23 +361,21 @@ class DeviceHandler private constructor( put( "camera", permissionStateJson( - granted = hasPermission(Manifest.permission.CAMERA), + granted = snapshot.camera, promptableWhenDenied = true, ), ) put( "microphone", permissionStateJson( - granted = hasPermission(Manifest.permission.RECORD_AUDIO), + granted = snapshot.microphone, promptableWhenDenied = true, ), ) put( "location", permissionStateJson( - granted = - hasPermission(Manifest.permission.ACCESS_FINE_LOCATION) || - hasPermission(Manifest.permission.ACCESS_COARSE_LOCATION), + granted = snapshot.location, promptableWhenDenied = true, ), ) @@ -370,8 +388,8 @@ class DeviceHandler private constructor( if (hasAnySmsCapability( smsEnabled, canSendSms, - smsSendGranted, - smsReadGranted, + snapshot.smsSend, + snapshot.smsRead, ) ) { "granted" @@ -380,21 +398,31 @@ class DeviceHandler private constructor( }, ), ) - put("promptable", JsonPrimitive(isSmsPromptable(smsEnabled, canSendSms, smsSendGranted, smsReadGranted))) + put( + "promptable", + JsonPrimitive( + isSmsPromptable( + smsEnabled, + canSendSms, + snapshot.smsSend, + snapshot.smsRead, + ), + ), + ) put( "capabilities", buildJsonObject { put( "send", permissionStateJson( - granted = smsEnabled && smsSendGranted && canSendSms, + granted = snapshot.smsSend, promptableWhenDenied = smsEnabled && canSendSms, ), ) put( "read", permissionStateJson( - granted = smsEnabled && smsReadGranted && canSendSms, + granted = snapshot.smsRead, promptableWhenDenied = smsEnabled && canSendSms, ), ) @@ -405,53 +433,49 @@ class DeviceHandler private constructor( put( "notificationListener", permissionStateJson( - granted = notificationAccess, + granted = snapshot.notificationListener, promptableWhenDenied = true, ), ) put( "notifications", permissionStateJson( - granted = notificationsGranted, + granted = snapshot.notifications, promptableWhenDenied = true, ), ) put( "photos", permissionStateJson( - granted = photosGranted, + granted = snapshot.photos, promptableWhenDenied = photosEnabled, ), ) put( "contacts", permissionStateJson( - granted = - hasPermission(Manifest.permission.READ_CONTACTS) && - hasPermission(Manifest.permission.WRITE_CONTACTS), + granted = snapshot.contactsRead && snapshot.contactsWrite, promptableWhenDenied = true, ), ) put( "calendar", permissionStateJson( - granted = - hasPermission(Manifest.permission.READ_CALENDAR) && - hasPermission(Manifest.permission.WRITE_CALENDAR), + granted = snapshot.calendarRead && snapshot.calendarWrite, promptableWhenDenied = true, ), ) put( "callLog", permissionStateJson( - granted = callLogEnabled && hasPermission(Manifest.permission.READ_CALL_LOG), + granted = snapshot.callLog, promptableWhenDenied = callLogEnabled, ), ) put( "motion", permissionStateJson( - granted = motionGranted, + granted = snapshot.motion, promptableWhenDenied = true, ), ) @@ -617,11 +641,6 @@ class DeviceHandler private constructor( put("promptable", JsonPrimitive(!granted && promptableWhenDenied)) } - private fun hasPermission(permission: String): Boolean = - ( - ContextCompat.checkSelfPermission(appContext, permission) == PackageManager.PERMISSION_GRANTED - ) - private fun mapMemoryPressure( totalBytes: Long, availableBytes: Long, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt index f187564d3099..979c4dbf2e05 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt @@ -159,6 +159,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.Lifecycle @@ -199,6 +200,14 @@ internal fun resolveInitialChatLoadSessionKey( return main } +/** Reserves a viewport strip so the jump-to-latest target never covers chat content. */ +internal fun chatReaderListBottomInset(showJumpToLatest: Boolean): Dp = + if (showJumpToLatest) { + 56.dp + } else { + 0.dp + } + internal enum class ChatComposerTrailingAction { StartTalk, StopTalk, @@ -1349,7 +1358,10 @@ private fun ChatMessageList( Box(modifier = modifier.fillMaxWidth()) { LazyColumn( - modifier = Modifier.fillMaxSize(), + modifier = + Modifier + .fillMaxSize() + .padding(bottom = chatReaderListBottomInset(readerScroll.showJumpToLatest)), state = readerScroll.listState, reverseLayout = true, verticalArrangement = Arrangement.spacedBy(5.dp), diff --git a/apps/android/app/src/main/java/ai/openclaw/app/wear/WearProxyController.kt b/apps/android/app/src/main/java/ai/openclaw/app/wear/WearProxyController.kt index f655915d26ac..58087457336d 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/wear/WearProxyController.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/wear/WearProxyController.kt @@ -51,7 +51,7 @@ internal class WearProxyController( private val connectGateway: suspend () -> Unit = {}, private val disconnectGateway: suspend () -> Unit = {}, private val startRealtimeTalk: - suspend (nodeId: String, sessionKey: String, attemptId: String, language: String?) -> WearRealtimeTalkSnapshot? = { _, _, _, _ -> null }, + suspend (nodeId: String, sessionKey: String, attemptId: String, language: String?, attemptScopedAudio: Boolean) -> WearRealtimeTalkSnapshot? = { _, _, _, _, _ -> null }, private val stopRealtimeTalk: suspend (nodeId: String, attemptId: String) -> WearRealtimeTalkSnapshot? = { _, _ -> null }, ) { suspend fun handle( @@ -91,7 +91,7 @@ internal class WearProxyController( params: JsonObject, ): JsonElement { if (sourceNodeId.isBlank()) throw WearProxyInvalidRequest("Missing Watch node") - params.requireOnly("sessionKey", "attemptId", "language") + params.requireOnly("sessionKey", "attemptId", "language", "attemptScopedAudio") val sessionKey = params.stringParam("sessionKey", MAX_SESSION_KEY_CHARS) val attemptId = params.stringParam("attemptId", MAX_ATTEMPT_ID_CHARS) val language = @@ -100,8 +100,9 @@ internal class WearProxyController( ?.lowercase(Locale.ROOT) ?.takeIf { value -> value.length == 2 && value.all { it in 'a'..'z' } } ?: if ("language" in params) throw WearProxyInvalidRequest("Invalid language") else null + val attemptScopedAudio = params.optionalBooleanParam("attemptScopedAudio") ?: false val snapshot = - startRealtimeTalk(sourceNodeId, sessionKey, attemptId, language) + startRealtimeTalk(sourceNodeId, sessionKey, attemptId, language, attemptScopedAudio) ?: throw WearProxyGatewayException("action_rejected", "Real-Time Talk is unavailable") return WearRealtimeTalkCodec.encode(snapshot) } @@ -580,6 +581,12 @@ private fun JsonObject.optionalIntParam( return value } +private fun JsonObject.optionalBooleanParam(name: String): Boolean? { + if (name !in this) return null + return this[name].booleanPrimitiveOrNull() + ?: throw WearProxyInvalidRequest("Invalid $name") +} + 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 diff --git a/apps/android/app/src/main/java/ai/openclaw/app/wear/WearProxyListenerService.kt b/apps/android/app/src/main/java/ai/openclaw/app/wear/WearProxyListenerService.kt index 33557be77d91..b9af82612f10 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/wear/WearProxyListenerService.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/wear/WearProxyListenerService.kt @@ -19,6 +19,10 @@ class WearProxyListenerService : WearableListenerService() { override fun onChannelOpened(channel: ChannelClient.Channel) { val app = application as? NodeApp ?: return - app.wearRealtimeChannels.accept(channel, app::ensureBackgroundRuntime) + app.wearRealtimeChannels.accept( + channel = channel, + appendAudio = { owner, payload -> app.ensureBackgroundRuntime().appendWearRealtimeAudio(owner, payload) }, + stopTalk = { owner -> app.ensureBackgroundRuntime().stopWearRealtimeTalk(owner) }, + ) } } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/wear/WearRealtimeChannelRegistry.kt b/apps/android/app/src/main/java/ai/openclaw/app/wear/WearRealtimeChannelRegistry.kt index bd26bf2154cf..bd7cea108148 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/wear/WearRealtimeChannelRegistry.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/wear/WearRealtimeChannelRegistry.kt @@ -1,6 +1,5 @@ package ai.openclaw.app.wear -import ai.openclaw.app.NodeRuntime import ai.openclaw.wear.shared.WearProtocol import ai.openclaw.wear.shared.WearRealtimeAudioFrameType import ai.openclaw.wear.shared.WearRealtimeAudioFraming @@ -8,8 +7,11 @@ import android.content.Context import com.google.android.gms.wearable.ChannelClient import com.google.android.gms.wearable.Wearable import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive @@ -20,80 +22,539 @@ import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import java.io.InputStream import java.io.OutputStream -import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong + +internal data class WearRealtimeAttemptOwner( + val nodeId: String, + val attemptId: String, + val channelGeneration: Long, +) + +internal data class WearRealtimeChannelClaim( + val owner: WearRealtimeAttemptOwner, + val newlyAcquired: Boolean, +) + +internal data class WearRealtimeChannelResources( + val input: InputStream, + val output: OutputStream, +) + +internal interface WearRealtimeChannelTransport { + suspend fun open(channel: ChannelClient.Channel): WearRealtimeChannelResources? + + suspend fun close( + channel: ChannelClient.Channel, + resources: WearRealtimeChannelResources?, + ) +} + +private class GoogleWearRealtimeChannelTransport( + context: Context, +) : WearRealtimeChannelTransport { + private val client = Wearable.getChannelClient(context.applicationContext) + + override suspend fun open(channel: ChannelClient.Channel): WearRealtimeChannelResources? { + val input = runCatching { client.getInputStream(channel).awaitWearTask() }.getOrNull() + val output = runCatching { client.getOutputStream(channel).awaitWearTask() }.getOrNull() + if (input == null || output == null) { + input.closeQuietly() + output.closeQuietly() + runCatching { client.close(channel).awaitWearTask() } + return null + } + return WearRealtimeChannelResources(input, output) + } + + override suspend fun close( + channel: ChannelClient.Channel, + resources: WearRealtimeChannelResources?, + ) { + resources?.input.closeQuietly() + resources?.output.closeQuietly() + runCatching { client.close(channel).awaitWearTask() } + } +} internal class WearRealtimeChannelRegistry( - context: Context, private val scope: CoroutineScope, + private val transport: WearRealtimeChannelTransport, + private val connectionReadyTimeoutMillis: Long = DEFAULT_CONNECTION_READY_TIMEOUT_MILLIS, + private val pendingConnectionTimeoutMillis: Long = WearProtocol.REALTIME_AUDIO_PENDING_CHANNEL_TIMEOUT_MILLIS, + private val retireCallbackTimeoutMillis: Long = DEFAULT_RETIRE_CALLBACK_TIMEOUT_MILLIS, + private val maxStagedConnectionsPerNode: Int = DEFAULT_MAX_STAGED_CONNECTIONS_PER_NODE, + private val maxStagedConnections: Int = DEFAULT_MAX_STAGED_CONNECTIONS, ) { - private val channelClient = Wearable.getChannelClient(context.applicationContext) - private val connections = ConcurrentHashMap() + private data class ChannelKey( + val nodeId: String, + val path: String, + ) + + private data class ChannelPromotion( + val connection: Connection, + val displaced: Connection?, + val claimSequence: Long, + ) + + private sealed interface ChannelClaimSelection { + data class Claimed( + val claim: WearRealtimeChannelClaim, + ) : ChannelClaimSelection + + data class Promote( + val promotion: ChannelPromotion, + ) : ChannelClaimSelection + + data object Wait : ChannelClaimSelection + + data object Superseded : ChannelClaimSelection + } + + constructor(context: Context, scope: CoroutineScope) : this( + scope, + GoogleWearRealtimeChannelTransport(context), + ) + + private val lifecycleMutex = Mutex() + private val channelGeneration = AtomicLong() + private val claimSequence = AtomicLong() + private val connections = mutableMapOf() + private val cleanupScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + // Channel receipt is not attempt order. Stage exact paths until a start claim reserves promotion. + private val pendingConnections = mutableMapOf() + private val promotingConnections = mutableMapOf() + private val latestClaimSequences = mutableMapOf() + private val openingConnectionsByNode = mutableMapOf() + private var openingConnectionCount = 0 fun accept( channel: ChannelClient.Channel, - runtime: () -> NodeRuntime, + appendAudio: (owner: WearRealtimeAttemptOwner, payload: ByteArray) -> Unit, + stopTalk: suspend (owner: WearRealtimeAttemptOwner) -> Unit, ) { - if (channel.path != WearProtocol.REALTIME_AUDIO_CHANNEL_PATH || channel.nodeId.isBlank()) { - scope.launch { runCatching { channelClient.close(channel).awaitWearTask() } } + if (!WearProtocol.isRealtimeAudioChannelPath(channel.path) || channel.nodeId.isBlank()) { + scope.launch { transport.close(channel, null) } return } + val generation = channelGeneration.incrementAndGet() scope.launch(Dispatchers.IO) { - val input = runCatching { channelClient.getInputStream(channel).awaitWearTask() }.getOrNull() - val output = runCatching { channelClient.getOutputStream(channel).awaitWearTask() }.getOrNull() - if (input == null || output == null) { - input.closeQuietly() - output.closeQuietly() - runCatching { channelClient.close(channel).awaitWearTask() } + if (!reserveOpeningSlot(channel.nodeId)) { + runCatching { transport.close(channel, null) } return@launch } - val connection = Connection(channel, input, output) - connections.put(channel.nodeId, connection)?.close(channelClient) + var openingSlotReserved = true try { - while (connections[channel.nodeId] === connection) { - val frame = WearRealtimeAudioFraming.read(input) ?: break - if (frame.type != WearRealtimeAudioFrameType.INPUT_PCM) break - runtime().appendWearRealtimeAudio(channel.nodeId, frame.payload) + val resources = transport.open(channel) ?: return@launch + val connection = Connection(channel, resources, generation, stopTalk) + var published = false + var activated = false + var displacedActive: Connection? = null + val displacedPending = + lifecycleMutex.withLock { + releaseOpeningSlotLocked(channel.nodeId) + openingSlotReserved = false + val active = connections[channel.nodeId] + val pending = pendingConnections[connection.key] + if ( + active?.generation?.let { it > generation } == true || + pending?.generation?.let { it > generation } == true + ) { + return@withLock null + } + published = true + connection.ready = true + if ( + WearProtocol.isAttemptScopedRealtimeAudioChannelPath(channel.path) && + active?.takeIf { it.ready && !it.retirementStarted.get() }?.channel?.path == channel.path + ) { + // Attempt-scoped reconnects can inherit the live owner before the old reader reports EOF. + active.ready = false + connection.owner = active.owner + connection.claimSequence = active.claimSequence + active.owner = null + connections[channel.nodeId] = connection + displacedActive = active + activated = true + pendingConnections.remove(connection.key) + } else { + pendingConnections.put(connection.key, connection) + } + } + if (!published) { + connection.retire(transport) + return@launch + } + displacedPending?.retire(transport) + displacedActive?.retire(transport) + if (activated) { + connection.activation.complete(true) + } else { + schedulePendingExpiry(connection) + } + try { + // The Watch starts capture after the start RPC; do not consume its PCM before that claim owns this path. + if (!connection.activation.await()) return@launch + while (isKnown(connection)) { + val frame = WearRealtimeAudioFraming.read(resources.input) ?: break + if (frame.type != WearRealtimeAudioFrameType.INPUT_PCM) break + val owner = + lifecycleMutex.withLock { + connection.owner.takeIf { + connection.ready && connections[channel.nodeId] === connection + } + } + if (owner != null) appendAudio(owner, frame.payload) + } + } catch (err: CancellationException) { + currentCoroutineContext().ensureActive() + } catch (_: Throwable) { + // A malformed frame or transport failure owns this channel only. + } finally { + retireKnownConnection(connection) } - } catch (err: CancellationException) { - currentCoroutineContext().ensureActive() - } catch (_: Throwable) { - // A malformed frame or transport failure owns this channel only. } finally { - if (connections.remove(channel.nodeId, connection)) { - connection.close(channelClient) - runCatching { runtime().stopWearRealtimeTalk(channel.nodeId) } + if (openingSlotReserved) { + withContext(NonCancellable) { + releaseOpeningSlot(channel.nodeId) + } } } } } + suspend fun claim( + nodeId: String, + attemptId: String, + attemptScopedAudio: Boolean = true, + ): WearRealtimeChannelClaim? { + val expectedPath = + if (attemptScopedAudio) { + WearProtocol.realtimeAudioChannelPath(attemptId) + } else { + WearProtocol.LEGACY_REALTIME_AUDIO_CHANNEL_PATH + } + val key = ChannelKey(nodeId, expectedPath) + val sequence = claimSequence.incrementAndGet() + lifecycleMutex.withLock { + latestClaimSequences[nodeId] = sequence + } + val deadlineNanos = + System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(connectionReadyTimeoutMillis) + while (true) { + currentCoroutineContext().ensureActive() + when (val selection = reserveClaim(nodeId, attemptId, expectedPath, key, sequence)) { + is ChannelClaimSelection.Claimed -> return selection.claim + is ChannelClaimSelection.Promote -> + return completePromotion(nodeId, attemptId, key, selection.promotion) + ChannelClaimSelection.Superseded -> return null + ChannelClaimSelection.Wait -> { + val remainingNanos = deadlineNanos - System.nanoTime() + if (remainingNanos <= 0L) return null + val waitMillis = + TimeUnit.NANOSECONDS + .toMillis(remainingNanos) + .coerceIn(1L, CONNECTION_POLL_MILLIS) + delay(waitMillis) + } + } + } + } + + private suspend fun reserveClaim( + nodeId: String, + attemptId: String, + expectedPath: String, + key: ChannelKey, + sequence: Long, + ): ChannelClaimSelection = + lifecycleMutex.withLock { + if (latestClaimSequences[nodeId] != sequence) return@withLock ChannelClaimSelection.Superseded + if (promotingConnections.keys.any { promotingKey -> promotingKey.nodeId == nodeId }) { + return@withLock ChannelClaimSelection.Wait + } + val connection = pendingConnections.remove(key) + if (connection != null) { + val displaced = connections[nodeId] + // Reservation is the ordering boundary. Later claims wait for this bounded handoff, then may replace it. + connection.ready = false + displaced?.ready = false + promotingConnections[key] = connection + return@withLock ChannelClaimSelection.Promote( + ChannelPromotion( + connection = connection, + displaced = displaced, + claimSequence = sequence, + ), + ) + } + connections[nodeId]?.let { active -> + if (active.claimSequence > sequence) return@withLock ChannelClaimSelection.Superseded + val current = active.owner + if (active.ready && active.channel.path == expectedPath) { + if (current?.attemptId == attemptId) { + active.claimSequence = sequence + return@withLock ChannelClaimSelection.Claimed( + WearRealtimeChannelClaim(current, newlyAcquired = false), + ) + } + if (current == null) { + val owner = WearRealtimeAttemptOwner(nodeId, attemptId, active.generation) + active.owner = owner + active.claimSequence = sequence + return@withLock ChannelClaimSelection.Claimed( + WearRealtimeChannelClaim(owner, newlyAcquired = true), + ) + } + } + } + ChannelClaimSelection.Wait + } + + private suspend fun reserveOpeningSlot(nodeId: String): Boolean = + lifecycleMutex.withLock { + val stagedForNode = + pendingConnections.keys.count { key -> key.nodeId == nodeId } + + promotingConnections.keys.count { key -> key.nodeId == nodeId } + + (openingConnectionsByNode[nodeId] ?: 0) + val stagedTotal = pendingConnections.size + promotingConnections.size + openingConnectionCount + if ( + stagedForNode >= maxStagedConnectionsPerNode || + stagedTotal >= maxStagedConnections + ) { + return@withLock false + } + openingConnectionsByNode[nodeId] = (openingConnectionsByNode[nodeId] ?: 0) + 1 + openingConnectionCount += 1 + true + } + + private suspend fun releaseOpeningSlot(nodeId: String) { + lifecycleMutex.withLock { + releaseOpeningSlotLocked(nodeId) + } + } + + private fun releaseOpeningSlotLocked(nodeId: String) { + val count = checkNotNull(openingConnectionsByNode[nodeId]) + if (count == 1) { + openingConnectionsByNode.remove(nodeId) + } else { + openingConnectionsByNode[nodeId] = count - 1 + } + openingConnectionCount -= 1 + } + + private suspend fun completePromotion( + nodeId: String, + attemptId: String, + key: ChannelKey, + promotion: ChannelPromotion, + ): WearRealtimeChannelClaim? { + var connection = promotion.connection + var promoted = false + try { + // Discovery already succeeded, so finish this bounded handoff even if the polling deadline has elapsed. + promotion.displaced?.let { retireCurrentConnection(it) } + while (true) { + currentCoroutineContext().ensureActive() + var superseded: Connection? = null + var owner: WearRealtimeAttemptOwner? = null + val valid = + lifecycleMutex.withLock { + if (promotingConnections[key] !== connection || connection.retirementStarted.get()) { + return@withLock false + } + val replacement = + pendingConnections[key]?.takeIf { + it.generation > connection.generation && !it.retirementStarted.get() + } + if (replacement != null) { + pendingConnections.remove(key, replacement) + replacement.ready = false + promotingConnections[key] = replacement + superseded = connection + connection = replacement + } else { + promotingConnections.remove(key, connection) + owner = WearRealtimeAttemptOwner(nodeId, attemptId, connection.generation) + connection.owner = owner + connection.claimSequence = promotion.claimSequence + connection.ready = true + connections[nodeId] = connection + promoted = true + } + true + } + if (!valid) return null + val retired = superseded + if (retired != null) { + // A reconnect can arrive while owner cleanup runs. Publish the newest exact-path channel. + retired.retire(transport) + continue + } + val committedOwner = checkNotNull(owner) + connection.activation.complete(true) + return WearRealtimeChannelClaim(committedOwner, newlyAcquired = true) + } + } finally { + if (!promoted) retirePromotingConnection(connection) + } + } + suspend fun send( - nodeId: String, + owner: WearRealtimeAttemptOwner, type: WearRealtimeAudioFrameType, payload: ByteArray, ) { val connection = - withTimeoutOrNull(CONNECTION_READY_TIMEOUT_MILLIS) { - var current = connections[nodeId] - while (current == null) { - delay(CONNECTION_POLL_MILLIS) - current = connections[nodeId] - } - current + lifecycleMutex.withLock { + connections[owner.nodeId]?.takeIf { it.owner == owner } } ?: error("Wear realtime audio channel is unavailable") connection.write(type, payload) } - suspend fun close(nodeId: String) { - connections.remove(nodeId)?.close(channelClient) + suspend fun release(owner: WearRealtimeAttemptOwner) { + lifecycleMutex.withLock { + connections[owner.nodeId] + ?.takeIf { it.owner == owner } + ?.owner = null + } + } + + suspend fun isCurrent(owner: WearRealtimeAttemptOwner): Boolean = + lifecycleMutex.withLock { + connections[owner.nodeId]?.let { connection -> + connection.ready && + !connection.retirementStarted.get() && + connection.owner == owner + } == true + } + + suspend fun close(owner: WearRealtimeAttemptOwner) { + val connection = + lifecycleMutex.withLock { + connections[owner.nodeId] + ?.takeIf { it.owner == owner } + ?.also { it.ready = false } + } + connection?.let { retireCurrentConnection(it) } + } + + private fun schedulePendingExpiry(connection: Connection) { + scope.launch { + delay(pendingConnectionTimeoutMillis) + val expired = + lifecycleMutex.withLock { + pendingConnections.remove(connection.key, connection) + } + if (expired) connection.retire(transport) + } + } + + private suspend fun isKnown(item: Connection): Boolean = lifecycleMutex.withLock { isKnownLocked(item) } + + private fun isKnownLocked(item: Connection): Boolean = + connections[item.channel.nodeId] === item || + pendingConnections[item.key] === item || + promotingConnections[item.key] === item + + private fun isCurrentLocked(item: Connection): Boolean = connections[item.channel.nodeId] === item + + private suspend fun retireCurrentConnection(connection: Connection) { + withContext(NonCancellable) { + lifecycleMutex.withLock { + if (isCurrentLocked(connection)) connection.ready = false + } + connection.retire(transport) + lifecycleMutex.withLock { + if (isCurrentLocked(connection)) connections.remove(connection.channel.nodeId) + } + } + } + + private suspend fun retirePromotingConnection(connection: Connection) { + withContext(NonCancellable) { + lifecycleMutex.withLock { + promotingConnections.remove(connection.key, connection) + } + connection.retire(transport) + } + } + + private suspend fun retireKnownConnection(connection: Connection) { + withContext(NonCancellable) { + lifecycleMutex.withLock { + if (isCurrentLocked(connection)) connection.ready = false + pendingConnections.remove(connection.key, connection) + promotingConnections.remove(connection.key, connection) + } + connection.retire(transport) + lifecycleMutex.withLock { + if (isCurrentLocked(connection)) connections.remove(connection.channel.nodeId) + } + } + } + + private suspend fun Connection.retire(transport: WearRealtimeChannelTransport) { + if (retirementStarted.compareAndSet(false, true)) { + val retiringOwner = owner + withContext(NonCancellable) { + try { + activation.complete(false) + retiringOwner?.let { owner -> + val stopJob = + cleanupScope.launch { + runCatching { stopTalk(owner) } + } + withTimeoutOrNull(retireCallbackTimeoutMillis) { + stopJob.join() + } + } + val closedWithinHandoff = + withTimeoutOrNull(retireCallbackTimeoutMillis) { + runCatching { close(transport) }.isSuccess + } == true + if (!closedWithinHandoff) { + // Keep transport cleanup alive after the bounded owner handoff returns. + cleanupScope.launch { + runCatching { close(transport) } + } + } + } finally { + retirementComplete.complete(Unit) + } + } + } else { + withContext(NonCancellable) { + withTimeoutOrNull(RETIRE_COMPLETION_TIMEOUT_MILLIS) { + retirementComplete.await() + } + } + } } private class Connection( val channel: ChannelClient.Channel, - val input: InputStream, - val output: OutputStream, + val resources: WearRealtimeChannelResources, + val generation: Long, + val stopTalk: suspend (owner: WearRealtimeAttemptOwner) -> Unit, ) { + val key = ChannelKey(channel.nodeId, channel.path) private val writeMutex = Mutex() + private val closeMutex = Mutex() + private var closed = false + val retirementStarted = AtomicBoolean() + val retirementComplete = CompletableDeferred() + val activation = CompletableDeferred() + + @Volatile var owner: WearRealtimeAttemptOwner? = null + + @Volatile var claimSequence: Long = 0L + + @Volatile var ready: Boolean = false suspend fun write( type: WearRealtimeAudioFrameType, @@ -101,21 +562,30 @@ internal class WearRealtimeChannelRegistry( ) { writeMutex.withLock { withContext(Dispatchers.IO) { - WearRealtimeAudioFraming.write(output, type, payload) + WearRealtimeAudioFraming.write(resources.output, type, payload) } } } - suspend fun close(client: ChannelClient) { - input.closeQuietly() - output.closeQuietly() - runCatching { client.close(channel).awaitWearTask() } + suspend fun close(transport: WearRealtimeChannelTransport) { + // Retirement must not close the stream beneath a frame already selected for this connection. + writeMutex.withLock { + closeMutex.withLock { + if (closed) return + transport.close(channel, resources) + closed = true + } + } } } private companion object { const val CONNECTION_POLL_MILLIS = 25L - const val CONNECTION_READY_TIMEOUT_MILLIS = 3_000L + const val DEFAULT_CONNECTION_READY_TIMEOUT_MILLIS = 3_000L + const val DEFAULT_RETIRE_CALLBACK_TIMEOUT_MILLIS = 1_000L + const val DEFAULT_MAX_STAGED_CONNECTIONS_PER_NODE = 4 + const val DEFAULT_MAX_STAGED_CONNECTIONS = 16 + const val RETIRE_COMPLETION_TIMEOUT_MILLIS = 2_500L } } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/wear/WearRealtimeTalkController.kt b/apps/android/app/src/main/java/ai/openclaw/app/wear/WearRealtimeTalkController.kt index bf1eb3edd22b..9cb572619f91 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/wear/WearRealtimeTalkController.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/wear/WearRealtimeTalkController.kt @@ -18,6 +18,8 @@ import android.util.Log import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay @@ -97,9 +99,14 @@ internal class WearRealtimeTalkController( timeoutMs: Long, onError: (String) -> Unit, ) -> Unit, - private val sendWatchFrame: suspend (nodeId: String, type: WearRealtimeAudioFrameType, payload: ByteArray) -> Unit, + private val sendWatchFrame: + suspend ( + owner: WearRealtimeAttemptOwner, + type: WearRealtimeAudioFrameType, + payload: ByteArray, + ) -> Unit, private val onSnapshot: (WearRealtimeTalkSnapshot) -> Unit = {}, - private val onForceCloseWatchChannel: (String) -> Unit = {}, + private val onForceCloseWatchChannel: (WearRealtimeAttemptOwner) -> Unit = {}, ) { private val json = Json { ignoreUnknownKeys = true } private val lifecycleMutex = Mutex() @@ -111,18 +118,18 @@ internal class WearRealtimeTalkController( @Volatile private var sessionId: String? = null - @Volatile private var ownerNodeId: String? = null + @Volatile private var activeOwner: WearRealtimeAttemptOwner? = null @Volatile private var ownerSessionKey: String? = null - @Volatile private var ownerAttemptId: String? = null - private var audioFrames: Channel? = null private var appendJob: Job? = null private val outputQueueLock = Any() private var outputQueue: WearRealtimeOutputQueue? = null private var outputJob: Job? = null private var playbackIdleJob: Job? = null + private var eventDispatchScope: CoroutineScope? = null + private var playbackEndsAtMillis = 0L private var userEntryId: String? = null private var assistantEntryId: String? = null @@ -147,26 +154,29 @@ internal class WearRealtimeTalkController( ) suspend fun start( - nodeId: String, + owner: WearRealtimeAttemptOwner, sessionKey: String, - attemptId: String, language: String?, + onSessionActivated: () -> Unit = {}, ): Boolean = lifecycleMutex.withLock { + var existingSession = false val startGeneration = synchronized(lifecycleStateLock) { if (!isConnected()) return@withLock false - if (WearRealtimeAttemptKey(nodeId, attemptId) in canceledAttempts) return@withLock false + if (WearRealtimeAttemptKey(owner.nodeId, owner.attemptId) in canceledAttempts) { + return@withLock false + } if (sessionId != null) { - if (ownerNodeId != nodeId || ownerSessionKey != sessionKey || ownerAttemptId != attemptId) { + if (activeOwner != owner || ownerSessionKey != sessionKey) { return@withLock false } - return@withLock true + existingSession = true + return@synchronized lifecycleGeneration.get() } - ownerNodeId = nodeId + activeOwner = owner ownerSessionKey = sessionKey - ownerAttemptId = attemptId val generation = lifecycleGeneration.get() updateState( active = true, @@ -177,20 +187,23 @@ internal class WearRealtimeTalkController( ) generation } + if (existingSession) { + onSessionActivated() + return@withLock true + } fun startIsStale(): Boolean = startGeneration != lifecycleGeneration.get() || !isConnected() || - ownerNodeId != nodeId || - ownerSessionKey != sessionKey || - ownerAttemptId != attemptId + activeOwner != owner || + ownerSessionKey != sessionKey val payload = try { requestRealtimeSession(sessionKey, language) } catch (err: Throwable) { synchronized(lifecycleStateLock) { - if (!startIsStale()) fail(err.message ?: "Unable to start Real-Time Talk") + if (!startIsStale()) fail(err.message ?: "Unable to start Real-Time Talk", expectedOwner = owner) } return@withLock false } @@ -209,10 +222,10 @@ internal class WearRealtimeTalkController( val activated = synchronized(lifecycleStateLock) { if (startIsStale()) { - if (ownerAttemptId == attemptId) resetLocked() + if (activeOwner == owner) resetLocked() false } else if (createdSessionId.isNullOrBlank()) { - fail("Real-Time Talk returned no session") + fail("Real-Time Talk returned no session", expectedOwner = owner) false } else { realtimeAgentCoordinator.beginSession( @@ -222,8 +235,10 @@ internal class WearRealtimeTalkController( ), ) sessionId = createdSessionId - startOutputLoop(createdSessionId) - startAppendLoop(createdSessionId) + eventDispatchScope?.cancel() + eventDispatchScope = CoroutineScope(scope.coroutineContext + SupervisorJob(scope.coroutineContext[Job])) + startOutputLoop(owner, createdSessionId) + startAppendLoop(owner, createdSessionId) updateState( active = true, listening = true, @@ -243,6 +258,7 @@ internal class WearRealtimeTalkController( } return@withLock false } + onSessionActivated() true } @@ -290,24 +306,39 @@ internal class WearRealtimeTalkController( val closingSession = synchronized(lifecycleStateLock) { if (nodeId != null && attemptId != null) rememberCanceledAttemptLocked(nodeId, attemptId) - if ( - (nodeId != null && ownerNodeId != null && ownerNodeId != nodeId) || - (attemptId != null && ownerAttemptId != null && ownerAttemptId != attemptId) - ) { + val owner = activeOwner + val identityMatches = + when { + owner != null -> + (nodeId == null || owner.nodeId == nodeId) && + (attemptId == null || owner.attemptId == attemptId) + nodeId != null && attemptId != null -> true + nodeId == null && attemptId == null -> true + else -> false + } + if (!identityMatches) { null } else { accepted = true sessionId.also { resetLocked() } } } - if (!accepted) { - return@withLock false - } + if (!accepted) return@withLock false if (!closingSession.isNullOrBlank()) { - runCatching { - val params = buildJsonObject { put("sessionId", JsonPrimitive(closingSession)) } - requestGateway("talk.session.close", params.toString(), 5_000L) + runCatching { closeGatewaySession(closingSession) } + } + true + } + + suspend fun stop(owner: WearRealtimeAttemptOwner): Boolean = + lifecycleMutex.withLock { + val closingSession = + synchronized(lifecycleStateLock) { + if (activeOwner != owner) return@withLock false + sessionId.also { resetLocked() } } + if (!closingSession.isNullOrBlank()) { + scope.launch { runCatching { closeGatewaySession(closingSession) } } } true } @@ -325,24 +356,40 @@ internal class WearRealtimeTalkController( } fun abort() { - val closingNodeId = + val closingOwner = synchronized(lifecycleStateLock) { lifecycleGeneration.incrementAndGet() - val nodeId = ownerNodeId + val owner = activeOwner resetLocked() - nodeId + owner } - closingNodeId?.let(onForceCloseWatchChannel) + closingOwner?.let(onForceCloseWatchChannel) + } + + private fun abort( + expectedOwner: WearRealtimeAttemptOwner?, + expectedSessionId: String?, + ) { + val closingOwner = + synchronized(lifecycleStateLock) { + if (expectedOwner != null && activeOwner != expectedOwner) return + if (expectedSessionId != null && sessionId != expectedSessionId) return + lifecycleGeneration.incrementAndGet() + val owner = activeOwner + resetLocked() + owner + } + closingOwner?.let(onForceCloseWatchChannel) } fun appendAudio( - nodeId: String, + owner: WearRealtimeAttemptOwner, payload: ByteArray, ) { if ( payload.isEmpty() || payload.size > WearProtocol.MAX_REALTIME_AUDIO_FRAME_BYTES || - ownerNodeId != nodeId || + activeOwner != owner || sessionId == null || _snapshot.value.speaking ) { @@ -350,7 +397,11 @@ internal class WearRealtimeTalkController( } val activeSessionId = sessionId ?: return if (audioFrames?.trySend(payload.copyOf())?.isSuccess != true) { - fail("Watch audio input is unavailable", expectedSessionId = activeSessionId) + fail( + "Watch audio input is unavailable", + expectedOwner = owner, + expectedSessionId = activeSessionId, + ) } } @@ -371,12 +422,21 @@ internal class WearRealtimeTalkController( val eventSessionId = obj["relaySessionId"].asStringOrNull() ?: obj["sessionId"].asStringOrNull() - val currentSessionId = sessionId - if (currentSessionId == null || eventSessionId != currentSessionId) return + // The gateway creates every relaySessionId with randomUUID(); it is the canonical + // correlation token for rejecting events from a retired session. + val (owner, currentSessionId) = + synchronized(lifecycleStateLock) { + val currentSessionId = sessionId + if (currentSessionId == null || eventSessionId != currentSessionId) return + val owner = activeOwner ?: return + owner to currentSessionId + } when (obj["type"].asStringOrNull()) { "ready", "inputAudio" -> - updateState( + updateStateIfCurrent( + owner = owner, + sessionId = currentSessionId, active = true, listening = true, speaking = false, @@ -386,7 +446,11 @@ internal class WearRealtimeTalkController( "audio" -> { val encoded = obj["audioBase64"].asStringOrNull() ?: return if (encoded.length > OUTPUT_QUEUE_BASE64_CHAR_CAPACITY) { - fail("Watch audio output exceeds the relay buffer") + fail( + "Watch audio output exceeds the relay buffer", + expectedOwner = owner, + expectedSessionId = currentSessionId, + ) return } val bytes = @@ -395,13 +459,15 @@ internal class WearRealtimeTalkController( ?.takeIf(ByteArray::isNotEmpty) ?: return if (bytes.size % PCM_16_BYTES != 0) { - fail("Invalid Watch audio frame") + fail("Invalid Watch audio frame", expectedOwner = owner, expectedSessionId = currentSessionId) return } - if (!enqueueOutput(WearRealtimeAudioFrameType.OUTPUT_PCM, bytes)) { + if (!enqueueOutput(owner, currentSessionId, WearRealtimeAudioFrameType.OUTPUT_PCM, bytes)) { return } - updateState( + updateStateIfCurrent( + owner = owner, + sessionId = currentSessionId, active = true, listening = false, speaking = true, @@ -410,53 +476,91 @@ internal class WearRealtimeTalkController( ) } "clear" -> { - enqueueOutput(WearRealtimeAudioFrameType.CLEAR_OUTPUT, byteArrayOf()) + enqueueOutput(owner, currentSessionId, WearRealtimeAudioFrameType.CLEAR_OUTPUT, byteArrayOf()) } "mark" -> { val markName = obj["markName"].asStringOrNull()?.trim()?.takeIf(String::isNotEmpty) ?: return - scope.launch { - runCatching { - val params = - buildJsonObject { - put("sessionId", JsonPrimitive(currentSessionId)) - put("markName", JsonPrimitive(markName)) - } - requestGateway("talk.session.acknowledgeMark", params.toString(), 8_000L) - } - } - } - "transcript" -> { - val text = obj["text"].asStringOrNull()?.trim()?.takeIf(String::isNotEmpty) ?: return - val final = obj["final"].asBooleanOrNull() == true - when (obj["role"].asStringOrNull()) { - "user" -> { - upsertConversation(WearRealtimeTalkRole.USER, text, final) - if (final) { - updateState( - active = true, - listening = false, - speaking = false, - status = WearRealtimeTalkStatus.THINKING, - statusText = "Agent working", - ) - } - } - "assistant" -> upsertConversation(WearRealtimeTalkRole.ASSISTANT, text, final) - } - } - "toolCall" -> { - val callId = obj["callId"].asStringOrNull() ?: return - val name = obj["name"].asStringOrNull() ?: return - realtimeAgentCoordinator.handleToolCall( - callId = callId, - name = name, - args = obj["args"], - forced = obj["forced"].asBooleanOrNull() == true, - ) + acknowledgeMark(owner, currentSessionId, markName) } + "transcript" -> handleTranscriptEvent(owner, currentSessionId, obj) + "toolCall" -> handleToolCallEvent(owner, currentSessionId, obj) "toolResult" -> Unit - "error" -> fail(obj["message"].asStringOrNull() ?: "Real-Time Talk failed") - "close" -> abort() + "error" -> + fail( + obj["message"].asStringOrNull() ?: "Real-Time Talk failed", + expectedOwner = owner, + expectedSessionId = currentSessionId, + ) + "close" -> abort(owner, currentSessionId) + } + } + + private fun acknowledgeMark( + owner: WearRealtimeAttemptOwner, + activeSessionId: String, + markName: String, + ) { + val ownerScope = + synchronized(lifecycleStateLock) { + eventDispatchScope.takeIf { isCurrent(owner, activeSessionId) } + } ?: return + ownerScope.launch { + synchronized(lifecycleStateLock) { + if (!isCurrent(owner, activeSessionId)) return@launch + } + runCatching { + val params = + buildJsonObject { + put("sessionId", JsonPrimitive(activeSessionId)) + put("markName", JsonPrimitive(markName)) + } + requestGateway("talk.session.acknowledgeMark", params.toString(), 8_000L) + } + } + } + + private fun handleTranscriptEvent( + owner: WearRealtimeAttemptOwner, + activeSessionId: String, + obj: JsonObject, + ) { + synchronized(lifecycleStateLock) { + if (!isCurrent(owner, activeSessionId)) return + val text = obj["text"].asStringOrNull()?.trim()?.takeIf(String::isNotEmpty) ?: return + val final = obj["final"].asBooleanOrNull() == true + when (obj["role"].asStringOrNull()) { + "user" -> { + upsertConversation(WearRealtimeTalkRole.USER, text, final) + if (final) { + updateState( + active = true, + listening = false, + speaking = false, + status = WearRealtimeTalkStatus.THINKING, + statusText = "Agent working", + ) + } + } + "assistant" -> upsertConversation(WearRealtimeTalkRole.ASSISTANT, text, final) + } + } + } + + private fun handleToolCallEvent( + owner: WearRealtimeAttemptOwner, + activeSessionId: String, + obj: JsonObject, + ) { + synchronized(lifecycleStateLock) { + if (!isCurrent(owner, activeSessionId)) return + val callId = obj["callId"].asStringOrNull() ?: return + val name = obj["name"].asStringOrNull() ?: return + realtimeAgentCoordinator.handleToolCall( + callId = callId, + name = name, + args = obj["args"], + forced = obj["forced"].asBooleanOrNull() == true, + ) } } @@ -472,7 +576,10 @@ internal class WearRealtimeTalkController( ) } - private fun startOutputLoop(activeSessionId: String) { + private fun startOutputLoop( + owner: WearRealtimeAttemptOwner, + activeSessionId: String, + ) { val messages = Channel(capacity = OUTPUT_QUEUE_CAPACITY) val queue = WearRealtimeOutputQueue(messages) synchronized(outputQueueLock) { outputQueue.also { outputQueue = queue } } @@ -484,18 +591,17 @@ internal class WearRealtimeTalkController( for (message in messages) { var delivered = false try { - if (sessionId != activeSessionId) continue - val nodeId = ownerNodeId ?: continue + if (!isCurrent(owner, activeSessionId)) continue when (message.type) { WearRealtimeAudioFrameType.OUTPUT_PCM -> { delivered = true for (chunk in chunkWearRealtimeOutput(message.payload)) { - if (!isCurrentOutput(activeSessionId, nodeId)) { + if (!isCurrentOutput(owner, activeSessionId)) { delivered = false break } - sendWatchFrame(nodeId, message.type, chunk) - if (!isCurrentOutput(activeSessionId, nodeId)) { + sendWatchFrame(owner, message.type, chunk) + if (!isCurrentOutput(owner, activeSessionId)) { delivered = false break } @@ -506,13 +612,13 @@ internal class WearRealtimeTalkController( audioByteCount = chunk.size, ) } - if (!isCurrentOutput(activeSessionId, nodeId)) { + if (!isCurrentOutput(owner, activeSessionId)) { delivered = false } } WearRealtimeAudioFrameType.CLEAR_OUTPUT -> { - sendWatchFrame(nodeId, message.type, message.payload) - delivered = isCurrentOutput(activeSessionId, nodeId) + sendWatchFrame(owner, message.type, message.payload) + delivered = isCurrentOutput(owner, activeSessionId) } WearRealtimeAudioFrameType.INPUT_PCM -> error("Phone cannot emit Watch input audio") } @@ -520,6 +626,7 @@ internal class WearRealtimeTalkController( if (err is CancellationException) throw err fail( "Unable to send audio to Watch", + expectedOwner = owner, expectedSessionId = activeSessionId, ) break @@ -534,12 +641,14 @@ internal class WearRealtimeTalkController( if (!delivered) continue when (message.type) { WearRealtimeAudioFrameType.OUTPUT_PCM -> { - schedulePlaybackIdle() + schedulePlaybackIdle(owner, activeSessionId) } WearRealtimeAudioFrameType.CLEAR_OUTPUT -> { playbackEndsAtMillis = 0L playbackIdleJob?.cancel() - updateState( + updateStateIfCurrent( + owner = owner, + sessionId = activeSessionId, active = true, listening = true, speaking = false, @@ -554,19 +663,22 @@ internal class WearRealtimeTalkController( } private suspend fun isCurrentOutput( + owner: WearRealtimeAttemptOwner, activeSessionId: String, - nodeId: String, ): Boolean = currentCoroutineContext().isActive && sessionId == activeSessionId && - ownerNodeId == nodeId + activeOwner == owner private fun enqueueOutput( + owner: WearRealtimeAttemptOwner, + activeSessionId: String, type: WearRealtimeAudioFrameType, payload: ByteArray, ): Boolean { val accepted = synchronized(outputQueueLock) { + if (!isCurrent(owner, activeSessionId)) return@synchronized false val queue = outputQueue ?: return@synchronized false val audioBytes = payload.size.takeIf { type == WearRealtimeAudioFrameType.OUTPUT_PCM } ?: 0 if (audioBytes > OUTPUT_QUEUE_BYTE_CAPACITY - queue.retainedAudioBytes) { @@ -578,13 +690,20 @@ internal class WearRealtimeTalkController( } } if (!accepted) { - fail("Watch audio link is unavailable") + fail( + "Watch audio link is unavailable", + expectedOwner = owner, + expectedSessionId = activeSessionId, + ) return false } return true } - private fun startAppendLoop(activeSessionId: String) { + private fun startAppendLoop( + owner: WearRealtimeAttemptOwner, + activeSessionId: String, + ) { audioFrames?.close() appendJob?.cancel() val frames = Channel(capacity = INPUT_QUEUE_CAPACITY) @@ -592,7 +711,7 @@ internal class WearRealtimeTalkController( appendJob = scope.launch { for (frame in frames) { - if (sessionId != activeSessionId) continue + if (!isCurrent(owner, activeSessionId)) continue val params = buildJsonObject { put("sessionId", JsonPrimitive(activeSessionId)) @@ -607,11 +726,18 @@ internal class WearRealtimeTalkController( "talk.session.appendAudio", params.toString(), 8_000L, - ) { message -> fail(message, expectedSessionId = activeSessionId) } + ) { message -> + fail( + message, + expectedOwner = owner, + expectedSessionId = activeSessionId, + ) + } } catch (err: Throwable) { if (err is CancellationException) throw err fail( err.message ?: "Unable to send Watch audio", + expectedOwner = owner, expectedSessionId = activeSessionId, ) } @@ -619,15 +745,20 @@ internal class WearRealtimeTalkController( } } - private fun schedulePlaybackIdle() { + private fun schedulePlaybackIdle( + owner: WearRealtimeAttemptOwner, + activeSessionId: String, + ) { playbackIdleJob?.cancel() playbackIdleJob = scope.launch { while (SystemClock.elapsedRealtime() < playbackEndsAtMillis) { delay(20L) } - if (sessionId != null) { - updateState( + if (isCurrent(owner, activeSessionId)) { + updateStateIfCurrent( + owner = owner, + sessionId = activeSessionId, active = true, listening = true, speaking = false, @@ -688,24 +819,48 @@ internal class WearRealtimeTalkController( speaking = speaking, status = status, statusText = statusText, - attemptId = ownerAttemptId, + attemptId = activeOwner?.attemptId, ), ) } + private fun updateStateIfCurrent( + owner: WearRealtimeAttemptOwner, + sessionId: String, + active: Boolean, + listening: Boolean, + speaking: Boolean, + status: WearRealtimeTalkStatus, + statusText: String, + ) { + synchronized(lifecycleStateLock) { + if (!isCurrent(owner, sessionId)) return + updateState(active, listening, speaking, status, statusText) + } + } + + private fun isCurrent( + owner: WearRealtimeAttemptOwner, + expectedSessionId: String, + ): Boolean = activeOwner == owner && sessionId == expectedSessionId + private fun fail( message: String, + expectedOwner: WearRealtimeAttemptOwner? = null, expectedSessionId: String? = null, ) { - val (closingSession, closingNodeId) = + var closingSession: String? = null + val closingOwner = synchronized(lifecycleStateLock) { // Transport callbacks and non-cancellable I/O can outlive their relay. // Only that relay may own teardown, or a late error can stop its replacement. + if (expectedOwner != null && activeOwner != expectedOwner) return if (expectedSessionId != null && sessionId != expectedSessionId) return Log.w(TAG, message) val currentSession = sessionId - val currentNodeId = ownerNodeId - realtimeAgentCoordinator.endSession(currentSession) + val currentOwner = activeOwner + closingSession = currentSession + realtimeAgentCoordinator.resetTransport() setSnapshot( _snapshot.value.copy( active = false, @@ -716,9 +871,8 @@ internal class WearRealtimeTalkController( ), ) sessionId = null - ownerNodeId = null + activeOwner = null ownerSessionKey = null - ownerAttemptId = null audioFrames?.close() audioFrames = null appendJob?.cancel() @@ -728,29 +882,30 @@ internal class WearRealtimeTalkController( ?.close() outputJob?.cancel() outputJob = null + eventDispatchScope?.cancel() + eventDispatchScope = null playbackIdleJob?.cancel() playbackIdleJob = null playbackEndsAtMillis = 0L - currentSession to currentNodeId - } - if (!closingSession.isNullOrBlank()) { - scope.launch { - runCatching { - val params = buildJsonObject { put("sessionId", JsonPrimitive(closingSession)) } - requestGateway("talk.session.close", params.toString(), 5_000L) - } + currentOwner } + closingSession?.takeIf(String::isNotBlank)?.let { session -> + scope.launch { runCatching { closeGatewaySession(session) } } } - closingNodeId?.let(onForceCloseWatchChannel) + closingOwner?.let(onForceCloseWatchChannel) + } + + private suspend fun closeGatewaySession(closingSession: String) { + val params = buildJsonObject { put("sessionId", JsonPrimitive(closingSession)) } + requestGateway("talk.session.close", params.toString(), 5_000L) } private fun resetLocked() { - val closingAttemptId = ownerAttemptId - realtimeAgentCoordinator.endSession(sessionId) + val closingAttemptId = activeOwner?.attemptId + realtimeAgentCoordinator.resetTransport() sessionId = null - ownerNodeId = null + activeOwner = null ownerSessionKey = null - ownerAttemptId = null audioFrames?.close() audioFrames = null appendJob?.cancel() @@ -760,6 +915,8 @@ internal class WearRealtimeTalkController( ?.close() outputJob?.cancel() outputJob = null + eventDispatchScope?.cancel() + eventDispatchScope = null playbackIdleJob?.cancel() playbackIdleJob = null playbackEndsAtMillis = 0L diff --git a/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt index 599777af0eb4..5cb56da09b99 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt @@ -737,6 +737,32 @@ class GatewayBootstrapAuthTest { assertTrue(locationOptions.commands.contains(OpenClawLocationCommand.Get.rawValue)) } + @Test + fun permissionSurfaceReconnectsOnlyAfterAndroidAuthorityChanges() { + val app: android.app.Application = RuntimeEnvironment.getApplication() + shadowOf(app).denyPermissions(Manifest.permission.CAMERA) + val (runtime, prefs) = createNeutralizedRuntime() + armSavedActiveManualGateway(prefs) + writeField( + runtime, + "connectedEndpoint", + GatewayEndpoint.manual(host = "127.0.0.1", port = 18789), + ) + + runtime.refreshNodePermissionSurface() + assertNull(desiredConnection(runtime, "nodeSession")) + + shadowOf(app).grantPermissions(Manifest.permission.CAMERA) + runtime.refreshNodePermissionSurface() + + val options = + readField( + waitForDesiredConnection(runtime, "nodeSession"), + "options", + ) + assertTrue(options.permissions.getValue("camera")) + } + @Test fun connect_showsSecureEndpointGuidanceWhenTlsProbeFails() { val app = RuntimeEnvironment.getApplication() diff --git a/apps/android/app/src/test/java/ai/openclaw/app/MainActivityLifecycleTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/MainActivityLifecycleTest.kt index 621537c8c205..b1c3737ae018 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/MainActivityLifecycleTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/MainActivityLifecycleTest.kt @@ -1,16 +1,28 @@ package ai.openclaw.app +import android.content.ContentProvider +import android.content.ContentValues +import android.content.Context import android.content.Intent +import android.database.Cursor +import android.net.Uri +import android.os.Looper +import androidx.lifecycle.SavedStateHandle import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith +import org.robolectric.Robolectric import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowContentResolver +import java.util.UUID +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit @RunWith(RobolectricTestRunner::class) @Config(sdk = [34]) @@ -84,6 +96,32 @@ class MainActivityLifecycleTest { assertEquals(listOf(replacement), routed) } + @Test + fun pendingIntentRouterRetainsShareOverflowUntilViewModelActivation() { + val router = MainActivityPendingIntentRouter() + val routed = mutableListOf() + repeat(MAX_PENDING_CHAT_SHARES) { index -> + val share = Intent(Intent.ACTION_SEND).setType("text/plain").putExtra(Intent.EXTRA_TEXT, "share-$index") + if (index == 0) { + router.setInitialIntent(share) + } else { + assertTrue(router.onNewIntent(share, routed::add)) + } + } + + assertFalse( + router.onNewIntent( + Intent(Intent.ACTION_SEND).setType("text/plain").putExtra(Intent.EXTRA_TEXT, "overflow"), + routed::add, + ), + ) + + assertTrue(router.activate(routed::add)) + assertEquals(MAX_PENDING_CHAT_SHARES, routed.size) + assertEquals(1, router.takeShareOverflowCount()) + assertEquals(0, router.takeShareOverflowCount()) + } + @Test fun initialIntentGateDistinguishesRecreationFromProcessRestoration() { val retainedGate = MainActivityInitialIntentGate() @@ -93,12 +131,84 @@ class MainActivityLifecycleTest { assertTrue(MainActivityInitialIntentGate().claim()) } + @Test + fun blockedShareMimeResolutionSurvivesActivityRecreation() { + val app = RuntimeEnvironment.getApplication() as NodeApp + app.chatShareDraftQueue.clear() + val resolverEntered = CountDownLatch(1) + val releaseResolver = CountDownLatch(1) + ShadowContentResolver.registerProviderInternal( + "blocked-share", + BlockingMimeProvider(resolverEntered, releaseResolver), + ) + val sharedUri = Uri.parse("content://blocked-share/document") + val shareIntent = + Intent(Intent.ACTION_SEND) + .setType("*/*") + .putExtra(Intent.EXTRA_STREAM, sharedUri) + val controller = + Robolectric + .buildActivity(MainActivity::class.java) + .create() + .start() + .resume() + val activity = controller.get() + val prefs = + SecurePrefs( + app, + securePrefsOverride = + app.getSharedPreferences( + "share-recreation-test-${UUID.randomUUID()}", + Context.MODE_PRIVATE, + ), + ) + val viewModel = MainViewModel(app, prefs, SavedStateHandle()) + val expectedOwner = viewModel.captureChatShareOwner() + assertTrue(viewModel.claimInitialIntentRouting()) + val handleLaunchIntent = + MainActivity::class.java + .getDeclaredMethod("handleLaunchIntent", MainViewModel::class.java, Intent::class.java) + .apply { isAccessible = true } + + handleLaunchIntent.invoke(activity, viewModel, shareIntent) + assertTrue(resolverEntered.await(5, TimeUnit.SECONDS)) + + controller.pause().stop().destroy() + assertFalse(viewModel.claimInitialIntentRouting()) + releaseResolver.countDown() + + assertTrue(waitUntil { app.chatShareDraftQueue.size() == 1 }) + val draft = requireNotNull(app.chatShareDraftQueue.head.value) + assertEquals(listOf(sharedUri), draft.attachments.map(SharedAttachment::uri)) + assertEquals(expectedOwner, app.chatShareDraftQueue.ownerOf(draft.id)) + } + @Test fun runtimeStaysForegroundAcrossConfigurationRecreation() { assertFalse(shouldNotifyRuntimeBackgrounded(isChangingConfigurations = true)) assertTrue(shouldNotifyRuntimeBackgrounded(isChangingConfigurations = false)) } + @Test + fun topResumedPermissionHostRefreshesAuthorityAfterActivation() { + val events = mutableListOf() + + updateTopResumedPermissionHost( + isTopResumedActivity = true, + activate = { events += "activate" }, + deactivate = { events += "deactivate" }, + refreshPermissionSurface = { events += "refresh" }, + ) + updateTopResumedPermissionHost( + isTopResumedActivity = false, + activate = { events += "activate" }, + deactivate = { events += "deactivate" }, + refreshPermissionSurface = { events += "refresh" }, + ) + + assertEquals(listOf("activate", "refresh", "deactivate"), events) + } + @Test fun runtimeUiStarterWaitsForReadinessAndStartsOnce() { val starter = MainActivityRuntimeUiStarter() @@ -178,4 +288,57 @@ class MainActivityLifecycleTest { NodeForegroundService.resume(app, startNow = false) } } + + private fun waitUntil( + timeoutMillis: Long = 2_000, + predicate: () -> Boolean, + ): Boolean { + val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMillis) + while (System.nanoTime() < deadline) { + shadowOf(Looper.getMainLooper()).idle() + if (predicate()) return true + Thread.sleep(10) + } + shadowOf(Looper.getMainLooper()).idle() + return predicate() + } + + private class BlockingMimeProvider( + private val entered: CountDownLatch, + private val release: CountDownLatch, + ) : ContentProvider() { + override fun onCreate(): Boolean = true + + override fun getType(uri: Uri): String { + entered.countDown() + check(release.await(5, TimeUnit.SECONDS)) + return "application/pdf" + } + + override fun query( + uri: Uri, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String?, + ): Cursor? = null + + override fun insert( + uri: Uri, + values: ContentValues?, + ): Uri? = null + + override fun delete( + uri: Uri, + selection: String?, + selectionArgs: Array?, + ): Int = 0 + + override fun update( + uri: Uri, + values: ContentValues?, + selection: String?, + selectionArgs: Array?, + ): Int = 0 + } } diff --git a/apps/android/app/src/test/java/ai/openclaw/app/MainViewModelTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/MainViewModelTest.kt index 8c101c64a4f2..523a90453ea9 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/MainViewModelTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/MainViewModelTest.kt @@ -7,6 +7,8 @@ import ai.openclaw.app.ui.chat.ChatComposerStateStore import ai.openclaw.app.ui.chat.PendingAttachment import android.content.Context import android.content.Intent +import android.net.Uri +import android.os.Looper import androidx.lifecycle.SavedStateHandle import kotlinx.coroutines.runBlocking import org.junit.After @@ -22,6 +24,8 @@ import org.robolectric.RuntimeEnvironment import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config import java.util.UUID +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit @RunWith(RobolectricTestRunner::class) @Config(sdk = [34]) @@ -30,6 +34,7 @@ class MainViewModelTest { fun resetNodeServiceStartSuppression() { val app = RuntimeEnvironment.getApplication() NodeForegroundService.resume(app, startNow = false) + (app as NodeApp).chatShareDraftQueue.clear() val appShadow = shadowOf(app) while (appShadow.nextStartedService != null) { // Drain queued service intents so each test owns its lifecycle assertions. @@ -231,6 +236,66 @@ class MainViewModelTest { assertNotNull(state.tryBeginTrackedSend(owner)) } + @Test + fun warmShareIntentsQueueOnceInArrivalOrderWithCapturedOwner() { + val (viewModel, _) = createViewModel(resolveShareMimeType = { "application/pdf" }) + val firstUri = Uri.parse("content://share/first") + val secondUri = Uri.parse("content://share/second") + val owner = viewModel.captureChatShareOwner() + + assertTrue(viewModel.handleShareLaunchIntent(shareIntent(firstUri, "first"))) + assertTrue(viewModel.handleShareLaunchIntent(shareIntent(secondUri, "second"))) + + assertTrue(waitUntil { viewModel.chatShareDrafts.value.size == 2 }) + val drafts = viewModel.chatShareDrafts.value + assertEquals(listOf("first", "second"), drafts.map(ChatShareDraft::text)) + assertEquals(listOf(firstUri, secondUri), drafts.map { draft -> draft.attachments.single().uri }) + assertTrue(drafts.all { draft -> viewModel.chatShareDraftTargetsOwner(draft.id, owner, owner.sessionKey) }) + } + + @Test + fun blockedShareReportsRetainedOverflowAndReleasesItsSlot() { + val resolverEntered = CountDownLatch(1) + val releaseResolver = CountDownLatch(1) + val blockedUri = Uri.parse("content://share/blocked") + val nextUri = Uri.parse("content://share/next") + val (viewModel, _) = + createViewModel( + resolveShareMimeType = { uri -> + if (uri == blockedUri) { + resolverEntered.countDown() + check(releaseResolver.await(5, TimeUnit.SECONDS)) + } + "application/pdf" + }, + shareLaunchCapacity = 1, + ) + + assertTrue(viewModel.handleShareLaunchIntent(shareIntent(blockedUri, "blocked"))) + assertTrue(resolverEntered.await(5, TimeUnit.SECONDS)) + assertFalse(viewModel.handleShareLaunchIntent(shareIntent(nextUri, "overflow"))) + assertEquals(1L, viewModel.shareLaunchOverflowRevision.value) + + releaseResolver.countDown() + assertTrue(waitUntil { viewModel.chatShareDrafts.value.size == 1 }) + assertEquals(1, viewModel.takeShareLaunchOverflowCount()) + assertEquals(0, viewModel.takeShareLaunchOverflowCount()) + viewModel.reportShareLaunchOverflow(2) + viewModel.reportShareLaunchOverflow() + assertEquals(3L, viewModel.shareLaunchOverflowRevision.value) + assertEquals(3, viewModel.takeShareLaunchOverflowCount()) + + (RuntimeEnvironment.getApplication() as NodeApp).chatShareDraftQueue.clear() + assertTrue(viewModel.handleShareLaunchIntent(shareIntent(nextUri, "next"))) + assertTrue( + waitUntil { + viewModel.chatShareDrafts.value + .singleOrNull() + ?.text == "next" + }, + ) + } + @Test fun gatewayAuthResetCleanupPurgesOnlyThatGatewaysComposerState() = runBlocking { @@ -380,7 +445,10 @@ class MainViewModelTest { assertEquals("ai.openclaw.app.action.RESUME", intent?.action) } - private fun createViewModel(): Pair { + private fun createViewModel( + resolveShareMimeType: (Uri) -> String? = { null }, + shareLaunchCapacity: Int = MAX_PENDING_CHAT_SHARES, + ): Pair { val app = RuntimeEnvironment.getApplication() as NodeApp val prefs = SecurePrefs( @@ -391,7 +459,39 @@ class MainViewModelTest { Context.MODE_PRIVATE, ), ) - return MainViewModel(app, prefs, SavedStateHandle()) to prefs + return ( + MainViewModel( + app = app, + prefs = prefs, + savedStateHandle = SavedStateHandle(), + resolveShareMimeType = resolveShareMimeType, + shareLaunchCapacity = shareLaunchCapacity, + ) to + prefs + ) + } + + private fun shareIntent( + uri: Uri, + text: String, + ): Intent = + Intent(Intent.ACTION_SEND) + .setType("*/*") + .putExtra(Intent.EXTRA_TEXT, text) + .putExtra(Intent.EXTRA_STREAM, uri) + + private fun waitUntil( + timeoutMillis: Long = 2_000, + predicate: () -> Boolean, + ): Boolean { + val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMillis) + while (System.nanoTime() < deadline) { + shadowOf(Looper.getMainLooper()).idle() + if (predicate()) return true + Thread.sleep(10) + } + shadowOf(Looper.getMainLooper()).idle() + return predicate() } private fun draft(name: String): CronEditorDraftState { diff --git a/apps/android/app/src/test/java/ai/openclaw/app/NodeRuntimeWearRealtimeTalkTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/NodeRuntimeWearRealtimeTalkTest.kt new file mode 100644 index 000000000000..27c344a1cc43 --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/NodeRuntimeWearRealtimeTalkTest.kt @@ -0,0 +1,72 @@ +package ai.openclaw.app + +import ai.openclaw.app.wear.WearRealtimeAttemptOwner +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +class NodeRuntimeWearRealtimeTalkTest { + @Test + fun `replacement during relay creation stops the stale session and rejects start`() = + runTest { + val owner = WearRealtimeAttemptOwner("watch-a", "attempt-a", 1L) + val startEntered = CompletableDeferred() + val releaseStart = CompletableDeferred() + val stoppedOwners = mutableListOf() + var currentOwner: WearRealtimeAttemptOwner? = owner + + val result = + async { + startWearRealtimeTalkWhileCurrent( + owner = owner, + isCurrent = { candidate -> currentOwner == candidate }, + start = { onSessionActivated -> + startEntered.complete(Unit) + releaseStart.await() + onSessionActivated() + true + }, + stop = { staleOwner -> stoppedOwners += staleOwner }, + ) + } + + startEntered.await() + currentOwner = WearRealtimeAttemptOwner("watch-a", "attempt-b", 2L) + releaseStart.complete(Unit) + + assertFalse(result.await()) + assertEquals(listOf(owner), stoppedOwners) + } + + @Test + fun `cancellation after relay activation still stops the uncommitted session`() = + runTest { + val owner = WearRealtimeAttemptOwner("watch-a", "attempt-a", 1L) + val relayActivated = CompletableDeferred() + val stoppedOwners = mutableListOf() + + val result = + async { + startWearRealtimeTalkWhileCurrent( + owner = owner, + isCurrent = { true }, + start = { onSessionActivated -> + onSessionActivated() + relayActivated.complete(Unit) + awaitCancellation() + }, + stop = { staleOwner -> stoppedOwners += staleOwner }, + ) + } + + relayActivated.await() + result.cancelAndJoin() + + assertEquals(listOf(owner), stoppedOwners) + } +} diff --git a/apps/android/app/src/test/java/ai/openclaw/app/PermissionRequesterTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/PermissionRequesterTest.kt index 277c4d98caa2..be398012739a 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/PermissionRequesterTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/PermissionRequesterTest.kt @@ -1,6 +1,7 @@ package ai.openclaw.app import android.Manifest +import android.app.Dialog import android.content.pm.PackageManager import androidx.activity.ComponentActivity import kotlinx.coroutines.Dispatchers @@ -21,7 +22,9 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.Robolectric import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowDialog @RunWith(RobolectricTestRunner::class) @Config(sdk = [34]) @@ -32,7 +35,7 @@ class PermissionRequesterTest { runTest { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) val requests = FakePermissionRequests() - val requester = PermissionRequester(activity(), requests::request) + val requester = requester(activity(), requests) try { val first = async { requester.requestIfMissing(listOf(Manifest.permission.CAMERA), timeoutMs = 10) } @@ -68,7 +71,7 @@ class PermissionRequesterTest { runTest { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) val requests = FakePermissionRequests() - val requester = PermissionRequester(activity(), requests::request) + val requester = requester(activity(), requests) try { repeat(4) { index -> @@ -103,7 +106,7 @@ class PermissionRequesterTest { runTest { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) val requests = FakePermissionRequests() - val requester = PermissionRequester(activity(), requests::request) + val requester = requester(activity(), requests) try { val cancelled = async { requester.requestIfMissing(listOf(Manifest.permission.CAMERA), timeoutMs = 1_000) } @@ -132,7 +135,7 @@ class PermissionRequesterTest { runTest { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) val requests = FakePermissionRequests() - val requester = PermissionRequester(activity(), requests::request) + val requester = requester(activity(), requests) try { val pending = async { requester.requestIfMissing(listOf(Manifest.permission.CAMERA), timeoutMs = 1_000) } @@ -147,12 +150,218 @@ class PermissionRequesterTest { ) runCurrent() + cancelDialog(checkNotNull(ShadowDialog.getLatestDialog())) + runCurrent() assertEquals(mapOf(Manifest.permission.CAMERA to false), pending.await()) } finally { Dispatchers.resetMain() } } + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun replacementActivityCompletesPendingRequestAndOwnsLaterPrompts() = + runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val originalActivity = activity() + val originalRequests = FakePermissionRequests() + val requester = requester(originalActivity, originalRequests) + + try { + val pending = async { requester.requestIfMissing(listOf(Manifest.permission.CAMERA), timeoutMs = 1_000) } + runCurrent() + assertEquals(1, originalRequests.size) + + val replacementActivity = activity() + val replacementRequests = FakePermissionRequests() + requester.attach(replacementActivity, replacementRequests::request) + requester.activate(replacementActivity) + requester.deactivate(originalActivity) + requester.detach(originalActivity) + + assertTrue(originalRequests.deliver(requester, 0, mapOf(Manifest.permission.CAMERA to true))) + runCurrent() + assertEquals(mapOf(Manifest.permission.CAMERA to true), pending.await()) + + val replacementPrompt = + async { requester.requestIfMissing(listOf(Manifest.permission.RECORD_AUDIO), timeoutMs = 1_000) } + runCurrent() + assertEquals(1, replacementRequests.size) + replacementPrompt.cancelAndJoin() + } finally { + Dispatchers.resetMain() + } + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun requestWaitsForReplacementActivityAcrossRecreationGap() = + runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val originalActivity = activity() + val originalRequests = FakePermissionRequests() + val requester = requester(originalActivity, originalRequests) + + try { + requester.deactivate(originalActivity) + requester.detach(originalActivity) + + val pending = async { requester.requestIfMissing(listOf(Manifest.permission.CAMERA), timeoutMs = 1_000) } + runCurrent() + assertEquals(0, originalRequests.size) + assertFalse(pending.isCompleted) + + val replacementActivity = activity() + val replacementRequests = FakePermissionRequests() + requester.attach(replacementActivity, replacementRequests::request) + requester.activate(replacementActivity) + runCurrent() + + assertEquals(1, replacementRequests.size) + assertFalse(pending.isCompleted) + assertTrue(replacementRequests.deliver(requester, 0, mapOf(Manifest.permission.CAMERA to true))) + runCurrent() + assertEquals(mapOf(Manifest.permission.CAMERA to true), pending.await()) + } finally { + Dispatchers.resetMain() + } + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun resumedEarlierTaskReclaimsPermissionPromptOwnership() = + runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val firstActivity = activity() + val firstRequests = FakePermissionRequests() + val requester = requester(firstActivity, firstRequests) + val secondActivity = activity() + val secondRequests = FakePermissionRequests() + + try { + requester.deactivate(firstActivity) + requester.attach(secondActivity, secondRequests::request) + requester.activate(secondActivity) + requester.deactivate(secondActivity) + requester.activate(firstActivity) + + val pending = async { requester.requestIfMissing(listOf(Manifest.permission.CAMERA), timeoutMs = 1_000) } + runCurrent() + + assertEquals(1, firstRequests.size) + assertEquals(0, secondRequests.size) + pending.cancelAndJoin() + } finally { + Dispatchers.resetMain() + } + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun permanentDenialWaitsForReplacementActivity() = + runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val originalActivity = activity() + val originalRequests = FakePermissionRequests() + val requester = requester(originalActivity, originalRequests) + + try { + val pending = async { requester.requestIfMissing(listOf(Manifest.permission.CAMERA), timeoutMs = 1_000) } + runCurrent() + assertTrue(originalRequests.deliver(requester, 0, mapOf(Manifest.permission.CAMERA to false))) + requester.deactivate(originalActivity) + requester.detach(originalActivity) + runCurrent() + assertFalse(pending.isCompleted) + + val replacementActivity = activity() + val replacementRequests = FakePermissionRequests() + requester.attach(replacementActivity, replacementRequests::request) + requester.activate(replacementActivity) + runCurrent() + + val settingsDialog = checkNotNull(ShadowDialog.getLatestDialog()) + assertTrue(settingsDialog.isShowing) + assertFalse(pending.isCompleted) + + cancelDialog(settingsDialog) + runCurrent() + assertEquals(mapOf(Manifest.permission.CAMERA to false), pending.await()) + } finally { + Dispatchers.resetMain() + } + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun permanentDenialPromptMovesToNewActiveActivity() = + runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val originalActivity = activity() + val originalRequests = FakePermissionRequests() + val requester = requester(originalActivity, originalRequests) + + try { + val pending = async { requester.requestIfMissing(listOf(Manifest.permission.CAMERA), timeoutMs = 1_000) } + runCurrent() + assertTrue(originalRequests.deliver(requester, 0, mapOf(Manifest.permission.CAMERA to false))) + runCurrent() + + val originalDialog = checkNotNull(ShadowDialog.getLatestDialog()) + assertTrue(originalDialog.isShowing) + assertFalse(pending.isCompleted) + + val replacementActivity = activity() + val replacementRequests = FakePermissionRequests() + requester.attach(replacementActivity, replacementRequests::request) + requester.activate(replacementActivity) + runCurrent() + + val replacementDialog = checkNotNull(ShadowDialog.getLatestDialog()) + assertFalse(originalDialog.isShowing) + assertTrue(replacementDialog !== originalDialog) + assertTrue(replacementDialog.isShowing) + assertFalse(pending.isCompleted) + + cancelDialog(replacementDialog) + runCurrent() + assertEquals(mapOf(Manifest.permission.CAMERA to false), pending.await()) + } finally { + Dispatchers.resetMain() + } + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun rationaleHostLossRetriesOnReplacementActivity() = + runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val rationaleActivity = rationaleActivity() + val rationaleRequests = FakePermissionRequests() + val requester = requester(rationaleActivity, rationaleRequests) + + try { + val pending = async { requester.requestIfMissing(listOf(Manifest.permission.CAMERA), timeoutMs = 1_000) } + runCurrent() + assertEquals(0, rationaleRequests.size) + assertFalse(pending.isCompleted) + + val replacementActivity = activity() + val replacementRequests = FakePermissionRequests() + requester.attach(replacementActivity, replacementRequests::request) + requester.activate(replacementActivity) + runCurrent() + + assertEquals(0, rationaleRequests.size) + assertEquals(1, replacementRequests.size) + assertTrue(replacementRequests.deliver(requester, 0, mapOf(Manifest.permission.CAMERA to true))) + runCurrent() + assertEquals(mapOf(Manifest.permission.CAMERA to true), pending.await()) + } finally { + Dispatchers.resetMain() + } + } + @Test fun requestCodeAllocatorWrapsWithinLegacyRangeAndSkipsLiveCodes() { val allocator = @@ -172,6 +381,29 @@ class PermissionRequesterTest { .buildActivity(ComponentActivity::class.java) .setup() .get() + + private fun rationaleActivity(): ComponentActivity = + Robolectric + .buildActivity(PermissionRationaleActivity::class.java) + .setup() + .get() + + private fun cancelDialog(dialog: Dialog) { + checkNotNull(shadowOf(dialog).onCancelListener).onCancel(dialog) + } + + private fun requester( + activity: ComponentActivity, + requests: FakePermissionRequests, + ): PermissionRequester = + PermissionRequester(activity.applicationContext).also { requester -> + requester.attach(activity, requests::request) + requester.activate(activity) + } +} + +class PermissionRationaleActivity : ComponentActivity() { + override fun shouldShowRequestPermissionRationale(permission: String): Boolean = true } private class FakePermissionRequest( diff --git a/apps/android/app/src/test/java/ai/openclaw/app/node/AndroidPermissionSnapshotTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/AndroidPermissionSnapshotTest.kt new file mode 100644 index 000000000000..958b371a0a8f --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/node/AndroidPermissionSnapshotTest.kt @@ -0,0 +1,150 @@ +package ai.openclaw.app.node + +import android.Manifest +import android.app.Application +import android.content.pm.PackageManager +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class AndroidPermissionSnapshotTest { + @Test + fun gatewayPermissions_keepIndependentlyGrantableAuthoritySeparate() { + val app = appContext() + shadowOf(app.packageManager).setSystemFeature(PackageManager.FEATURE_TELEPHONY, true) + shadowOf(app).grantPermissions( + Manifest.permission.CAMERA, + Manifest.permission.ACCESS_COARSE_LOCATION, + Manifest.permission.SEND_SMS, + Manifest.permission.READ_CONTACTS, + Manifest.permission.WRITE_CALENDAR, + ) + + val permissions = + readAndroidPermissionSnapshot( + context = app, + smsEnabled = true, + callLogEnabled = true, + photosEnabled = true, + backgroundLocationEnabled = true, + ).gatewayPermissions() + + assertTrue(permissions.getValue("camera")) + assertTrue(permissions.getValue("location")) + assertFalse(permissions.getValue("locationPrecise")) + assertFalse(permissions.getValue("locationBackground")) + assertTrue(permissions.getValue("smsSend")) + assertFalse(permissions.getValue("smsRead")) + assertTrue(permissions.getValue("contactsRead")) + assertFalse(permissions.getValue("contactsWrite")) + assertFalse(permissions.getValue("calendarRead")) + assertTrue(permissions.getValue("calendarWrite")) + } + + @Test + fun snapshotGatesVariantSpecificPermissionsByAvailableFeature() { + val app = appContext() + shadowOf(app.packageManager).setSystemFeature(PackageManager.FEATURE_TELEPHONY, true) + shadowOf(app).grantPermissions( + Manifest.permission.SEND_SMS, + Manifest.permission.READ_SMS, + Manifest.permission.READ_CALL_LOG, + Manifest.permission.READ_MEDIA_IMAGES, + ) + + val snapshot = + readAndroidPermissionSnapshot( + context = app, + smsEnabled = false, + callLogEnabled = false, + photosEnabled = false, + backgroundLocationEnabled = false, + ) + + assertFalse(snapshot.smsSend) + assertFalse(snapshot.smsRead) + assertFalse(snapshot.callLog) + assertFalse(snapshot.photos) + } + + @Test + fun backgroundLocationRequiresFeatureAndForegroundAuthority() { + val app = appContext() + shadowOf(app).grantPermissions(Manifest.permission.ACCESS_BACKGROUND_LOCATION) + + val withoutForeground = + readAndroidPermissionSnapshot( + context = app, + smsEnabled = false, + callLogEnabled = false, + photosEnabled = false, + backgroundLocationEnabled = true, + ) + assertFalse(withoutForeground.locationBackground) + + shadowOf(app).grantPermissions(Manifest.permission.ACCESS_COARSE_LOCATION) + val featureDisabled = + readAndroidPermissionSnapshot( + context = app, + smsEnabled = false, + callLogEnabled = false, + photosEnabled = false, + backgroundLocationEnabled = false, + ) + assertFalse(featureDisabled.locationBackground) + + val available = + readAndroidPermissionSnapshot( + context = app, + smsEnabled = false, + callLogEnabled = false, + photosEnabled = false, + backgroundLocationEnabled = true, + ) + assertTrue(available.locationBackground) + } + + @Test + fun gatewayPermissionOrderIsStable() { + val permissions = + readAndroidPermissionSnapshot( + context = appContext(), + smsEnabled = false, + callLogEnabled = false, + photosEnabled = false, + backgroundLocationEnabled = false, + ).gatewayPermissions() + + assertEquals( + listOf( + "camera", + "microphone", + "location", + "locationPrecise", + "locationBackground", + "smsSend", + "smsRead", + "notificationListener", + "notifications", + "photos", + "contactsRead", + "contactsWrite", + "calendarRead", + "calendarWrite", + "callLog", + "motion", + ), + permissions.keys.toList(), + ) + } + + private fun appContext(): Application = RuntimeEnvironment.getApplication() +} diff --git a/apps/android/app/src/test/java/ai/openclaw/app/node/ConnectionManagerTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/ConnectionManagerTest.kt index 40b5f09e2b08..6d517bce41d3 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/node/ConnectionManagerTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/node/ConnectionManagerTest.kt @@ -635,6 +635,21 @@ class ConnectionManagerTest { assertFalse(options.caps.contains(OpenClawCapability.Motion.rawValue)) } + @Test + fun buildNodeConnectOptions_advertisesCurrentPermissionSnapshot() { + val permissionSnapshot = + emptyPermissionSnapshot().copy( + camera = true, + location = true, + locationPrecise = false, + smsSend = true, + ) + + val options = newManager(permissionSnapshot = permissionSnapshot).buildNodeConnectOptions() + + assertEquals(permissionSnapshot.gatewayPermissions(), options.permissions) + } + private fun newManager( cameraEnabled: Boolean = false, locationMode: LocationMode = LocationMode.Off, @@ -650,6 +665,7 @@ class ConnectionManagerTest { voiceWakeAvailable: Boolean = true, mobileUiAvailable: Boolean = false, inlineWidgetsAvailable: Boolean = true, + permissionSnapshot: AndroidPermissionSnapshot = emptyPermissionSnapshot(), ): ConnectionManager { val context = RuntimeEnvironment.getApplication() context @@ -679,7 +695,28 @@ class ConnectionManagerTest { voiceWakeAvailable = { voiceWakeAvailable }, mobileUiAvailable = { mobileUiAvailable }, inlineWidgetsAvailable = { inlineWidgetsAvailable }, + permissionSnapshot = { permissionSnapshot }, manualTls = { false }, ) } + + private fun emptyPermissionSnapshot(): AndroidPermissionSnapshot = + AndroidPermissionSnapshot( + camera = false, + microphone = false, + location = false, + locationPrecise = false, + locationBackground = false, + smsSend = false, + smsRead = false, + notificationListener = false, + notifications = false, + photos = false, + contactsRead = false, + contactsWrite = false, + calendarRead = false, + calendarWrite = false, + callLog = false, + motion = false, + ) } diff --git a/apps/android/app/src/test/java/ai/openclaw/app/node/DeviceHandlerTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/DeviceHandlerTest.kt index 9ca306987985..89f419109f8a 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/node/DeviceHandlerTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/node/DeviceHandlerTest.kt @@ -3,6 +3,7 @@ package ai.openclaw.app.node import android.Manifest import android.app.Application import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.boolean @@ -158,6 +159,56 @@ class DeviceHandlerTest { } } + @Test + fun handleDevicePermissions_derivesCompositeStatesFromCanonicalSnapshot() { + val app = appContext() + shadowOf(app.packageManager).setSystemFeature(PackageManager.FEATURE_TELEPHONY, true) + val snapshot = + emptyPermissionSnapshot().copy( + smsSend = true, + contactsRead = true, + calendarRead = true, + calendarWrite = true, + ) + val handler = + DeviceHandler.forTesting( + appContext = app, + appSource = FakeDeviceAppSource(emptyList()), + smsEnabled = true, + permissionSnapshot = { snapshot }, + ) + + val payload = handler.handleDevicePermissions(null).payloadJson + + assertEquals("granted", permissionStatus(payload, "sms")) + assertEquals("denied", permissionStatus(payload, "contacts")) + assertEquals("granted", permissionStatus(payload, "calendar")) + val smsCapabilities = + parsePayload(payload) + .getValue("permissions") + .jsonObject + .getValue("sms") + .jsonObject + .getValue("capabilities") + .jsonObject + assertEquals( + "granted", + smsCapabilities + .getValue("send") + .jsonObject + .getValue("status") + .jsonPrimitive.content, + ) + assertEquals( + "denied", + smsCapabilities + .getValue("read") + .jsonObject + .getValue("status") + .jsonPrimitive.content, + ) + } + @Test fun smsTopLevelStatusTreatsSendOnlyPartialGrantAsGranted() { assertTrue( @@ -301,6 +352,26 @@ class DeviceHandlerTest { } } + private fun emptyPermissionSnapshot(): AndroidPermissionSnapshot = + AndroidPermissionSnapshot( + camera = false, + microphone = false, + location = false, + locationPrecise = false, + locationBackground = false, + smsSend = false, + smsRead = false, + notificationListener = false, + notifications = false, + photos = false, + contactsRead = false, + contactsWrite = false, + calendarRead = false, + calendarWrite = false, + callLog = false, + motion = false, + ) + @Test fun handleDeviceHealth_returnsExpectedShape() { val handler = DeviceHandler(appContext()) diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatScreenTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatScreenTest.kt index b99cff876e51..188b3fd960fe 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatScreenTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatScreenTest.kt @@ -5,6 +5,7 @@ import ai.openclaw.app.PendingAssistantAutoSend import ai.openclaw.app.chat.ChatComposerOwner import ai.openclaw.app.chat.ChatMessageContent import ai.openclaw.app.chat.SessionBranch +import androidx.compose.ui.unit.dp import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -12,6 +13,12 @@ import org.junit.Assert.assertTrue import org.junit.Test class ChatScreenTest { + @Test + fun jumpToLatestReservesItsTouchTargetBelowMessages() { + assertEquals(0.dp, chatReaderListBottomInset(showJumpToLatest = false)) + assertEquals(56.dp, chatReaderListBottomInset(showJumpToLatest = true)) + } + @Test fun branchMessageCountUsesCountNeutralCopy() { assertEquals("Messages: 1", branchMessageCountText(1)) diff --git a/apps/android/app/src/test/java/ai/openclaw/app/wear/WearProxyControllerTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/wear/WearProxyControllerTest.kt index 3f7ee83fe40c..acb2cec1157d 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/wear/WearProxyControllerTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/wear/WearProxyControllerTest.kt @@ -406,8 +406,8 @@ class WearProxyControllerTest { requestGateway = { _, _ -> buildJsonObject {} }, isGatewayConnected = { true }, gatewayStatusText = { "Connected" }, - startRealtimeTalk = { nodeId, sessionKey, attemptId, language -> - startArgs = listOf(nodeId, sessionKey, attemptId, language) + startRealtimeTalk = { nodeId, sessionKey, attemptId, language, attemptScopedAudio -> + startArgs = listOf(nodeId, sessionKey, attemptId, language, attemptScopedAudio.toString()) WearRealtimeTalkSnapshot(attemptId = attemptId, active = true) }, ) @@ -420,13 +420,14 @@ class WearProxyControllerTest { put("sessionKey", "agent:main:thread-7") put("attemptId", "attempt-7") put("language", "DE") + put("attemptScopedAudio", true) }, ), sourceNodeId = "watch-a", ) assertTrue(response.ok) - assertEquals(listOf("watch-a", "agent:main:thread-7", "attempt-7", "de"), startArgs) + assertEquals(listOf("watch-a", "agent:main:thread-7", "attempt-7", "de", "true"), startArgs) assertTrue( checkNotNull(response.result) .jsonObject @@ -446,7 +447,7 @@ class WearProxyControllerTest { requestGateway = { _, _ -> buildJsonObject {} }, isGatewayConnected = { true }, gatewayStatusText = { "Connected" }, - startRealtimeTalk = { _, _, _, _ -> + startRealtimeTalk = { _, _, _, _, _ -> starts += 1 WearRealtimeTalkSnapshot(active = true) }, @@ -459,6 +460,37 @@ class WearProxyControllerTest { assertEquals(0, starts) } + @Test + fun legacyTalkStartDefaultsToTheFixedAudioChannel() = + runTest { + var attemptScopedAudio: Boolean? = null + val controller = + WearProxyController( + requestGateway = { _, _ -> buildJsonObject {} }, + isGatewayConnected = { true }, + gatewayStatusText = { "Connected" }, + startRealtimeTalk = { _, _, attemptId, _, scoped -> + attemptScopedAudio = scoped + WearRealtimeTalkSnapshot(attemptId = attemptId, active = true) + }, + ) + + val response = + controller.handle( + request( + WearRpcMethod.TalkStart, + buildJsonObject { + put("sessionKey", "agent:main:thread-7") + put("attemptId", "attempt-7") + }, + ), + sourceNodeId = "watch-a", + ) + + assertTrue(response.ok) + assertEquals(false, attemptScopedAudio) + } + @Test fun talkStopBindsTheWatchNodeAndAttempt() = runTest { diff --git a/apps/android/app/src/test/java/ai/openclaw/app/wear/WearProxyListenerManifestTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/wear/WearProxyListenerManifestTest.kt index 9bd60262e57a..1ce2d58a32e3 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/wear/WearProxyListenerManifestTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/wear/WearProxyListenerManifestTest.kt @@ -32,7 +32,19 @@ class WearProxyListenerManifestTest { assertTrue( resolvesToBridgeService( action = ChannelClient.ACTION_CHANNEL_EVENT, - path = WearProtocol.REALTIME_AUDIO_CHANNEL_PATH, + path = WearProtocol.realtimeAudioChannelPath("attempt-7"), + ), + ) + assertTrue( + resolvesToBridgeService( + action = ChannelClient.ACTION_CHANNEL_EVENT, + path = WearProtocol.LEGACY_REALTIME_AUDIO_CHANNEL_PATH, + ), + ) + assertFalse( + resolvesToBridgeService( + action = ChannelClient.ACTION_CHANNEL_EVENT, + path = "${WearProtocol.LEGACY_REALTIME_AUDIO_CHANNEL_PATH}-invalid", ), ) } diff --git a/apps/android/app/src/test/java/ai/openclaw/app/wear/WearRealtimeChannelRegistryTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/wear/WearRealtimeChannelRegistryTest.kt new file mode 100644 index 000000000000..61e36d851e46 --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/wear/WearRealtimeChannelRegistryTest.kt @@ -0,0 +1,1072 @@ +package ai.openclaw.app.wear + +import ai.openclaw.wear.shared.WearProtocol +import ai.openclaw.wear.shared.WearRealtimeAudioFrameType +import ai.openclaw.wear.shared.WearRealtimeTalkStatus +import android.os.Parcel +import com.google.android.gms.wearable.ChannelClient +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancel +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.yield +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.ByteArrayOutputStream +import java.io.InputStream +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +class WearRealtimeChannelRegistryTest { + @Test + fun `replacement stops displaced owner once before its finalizer`() = + runBlocking { + val scope = kotlinx.coroutines.CoroutineScope(SupervisorJob() + Dispatchers.IO) + val transport = FakeChannelTransport() + val registry = WearRealtimeChannelRegistry(scope, transport) + val stoppedOwners = mutableListOf() + val stopTalk: suspend (WearRealtimeAttemptOwner) -> Unit = { owner -> + synchronized(stoppedOwners) { stoppedOwners += owner } + } + val first = FakeChannel("watch-a", "channel-a", "attempt-a") + val second = FakeChannel("watch-a", "channel-b", "attempt-b") + + try { + registry.accept(first, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(first) + val firstClaim = checkNotNull(registry.claim("watch-a", "attempt-a")) + val firstOwner = firstClaim.owner + assertTrue(firstClaim.newlyAcquired) + + registry.accept(second, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(second) + assertTrue(registry.isCurrent(firstOwner)) + assertTrue(synchronized(stoppedOwners) { stoppedOwners.isEmpty() }) + + val secondClaim = checkNotNull(registry.claim("watch-a", "attempt-b")) + withTimeout(2_000L) { + while (transport.closeCount(first) != 1 || synchronized(stoppedOwners) { stoppedOwners.size } != 1) { + kotlinx.coroutines.yield() + } + } + + val secondOwner = secondClaim.owner + assertTrue(secondClaim.newlyAcquired) + assertEquals(listOf(firstOwner), synchronized(stoppedOwners) { stoppedOwners.toList() }) + assertEquals(1, transport.closeCount(first)) + + registry.release(firstOwner) + val repeatedClaim = checkNotNull(registry.claim("watch-a", "attempt-b")) + assertFalse(repeatedClaim.newlyAcquired) + assertSame(secondOwner, repeatedClaim.owner) + + registry.release(secondOwner) + val staleClaim = async { registry.claim("watch-a", "attempt-c") } + delay(100L) + assertFalse(staleClaim.isCompleted) + staleClaim.cancel() + val retryClaim = checkNotNull(registry.claim("watch-a", "attempt-b")) + val retryOwner = retryClaim.owner + assertTrue(retryClaim.newlyAcquired) + assertEquals(secondOwner.channelGeneration, retryOwner.channelGeneration) + + registry.close(retryOwner) + assertEquals(listOf(firstOwner, retryOwner), synchronized(stoppedOwners) { stoppedOwners.toList() }) + } finally { + scope.cancel() + } + } + + @Test + fun `replacement claim waits for owner retirement but not delayed gateway close`() = + runBlocking { + val scope = kotlinx.coroutines.CoroutineScope(SupervisorJob() + Dispatchers.IO) + val transport = FakeChannelTransport() + val registry = WearRealtimeChannelRegistry(scope, transport) + val closeStarted = CompletableDeferred() + val releaseClose = CompletableDeferred() + val closeFinished = CompletableDeferred() + var createCount = 0 + val controller = + WearRealtimeTalkController( + scope = scope, + isConnected = { true }, + requestGateway = { method, _, _ -> + when (method) { + "talk.session.create" -> { + createCount += 1 + """{"relaySessionId":"relay-$createCount"}""" + } + "talk.session.close" -> { + closeStarted.complete(Unit) + releaseClose.await() + closeFinished.complete(Unit) + """{"ok":true}""" + } + else -> """{"ok":true}""" + } + }, + sendGatewayFrame = { _, _, _, _ -> }, + sendWatchFrame = { _, _, _ -> }, + ) + val stopTalk: suspend (WearRealtimeAttemptOwner) -> Unit = { owner -> + controller.stop(owner) + } + val first = FakeChannel("watch-a", "channel-a", "attempt-a") + val second = FakeChannel("watch-a", "channel-b", "attempt-b") + + try { + registry.accept(first, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(first) + val firstOwner = checkNotNull(registry.claim("watch-a", "attempt-a")).owner + assertTrue(controller.start(firstOwner, "session-main", "de")) + + registry.accept(second, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(second) + val secondClaim = async { registry.claim("watch-a", "attempt-b") } + withTimeout(1_000L) { closeStarted.await() } + + val secondOwner = withTimeout(1_000L) { secondClaim.await() }?.owner + assertNotNull(secondOwner) + assertFalse(releaseClose.isCompleted) + assertEquals(WearRealtimeTalkStatus.OFF, controller.snapshot.value.status) + + val replacementStart = + async { + controller.start(checkNotNull(secondOwner), "session-main", "de") + } + assertTrue(withTimeout(1_000L) { replacementStart.await() }) + assertFalse(releaseClose.isCompleted) + controller.handleGatewayEvent( + "talk.event", + """{"relaySessionId":"relay-1","type":"close"}""", + ) + assertEquals(WearRealtimeTalkStatus.LISTENING, controller.snapshot.value.status) + assertEquals("attempt-b", controller.snapshot.value.attemptId) + + releaseClose.complete(Unit) + withTimeout(1_000L) { closeFinished.await() } + registry.close(checkNotNull(secondOwner)) + } finally { + releaseClose.complete(Unit) + scope.cancel() + } + } + + @Test + fun `reader retirement blocks replacement claim until owner stops`() = + runBlocking { + val scope = kotlinx.coroutines.CoroutineScope(SupervisorJob() + Dispatchers.IO) + val transport = FakeChannelTransport() + val registry = WearRealtimeChannelRegistry(scope, transport) + val stopStarted = CompletableDeferred() + val releaseStop = CompletableDeferred() + val first = FakeChannel("watch-a", "channel-a", "attempt-a") + val second = FakeChannel("watch-a", "channel-b", "attempt-b") + val stopTalk: suspend (WearRealtimeAttemptOwner) -> Unit = { + stopStarted.complete(Unit) + releaseStop.await() + } + + try { + registry.accept(first, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(first) + checkNotNull(registry.claim("watch-a", "attempt-a")) + + transport.finishInput(first) + withTimeout(1_000L) { stopStarted.await() } + registry.accept(second, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(second) + val replacementClaim = async { registry.claim("watch-a", "attempt-b") } + delay(100L) + assertFalse(replacementClaim.isCompleted) + + releaseStop.complete(Unit) + val replacementOwner = checkNotNull(withTimeout(1_000L) { replacementClaim.await() }).owner + registry.close(replacementOwner) + } finally { + releaseStop.complete(Unit) + scope.cancel() + } + } + + @Test + fun `rapid replacements preserve transitive owner retirement`() = + runBlocking { + val scope = kotlinx.coroutines.CoroutineScope(SupervisorJob() + Dispatchers.IO) + val transport = FakeChannelTransport() + val registry = WearRealtimeChannelRegistry(scope, transport) + val stopStarted = CompletableDeferred() + val releaseStop = CompletableDeferred() + val first = FakeChannel("watch-a", "channel-a", "attempt-a") + val second = FakeChannel("watch-a", "channel-b", "attempt-b") + val third = FakeChannel("watch-a", "channel-c", "attempt-c") + val stopTalk: suspend (WearRealtimeAttemptOwner) -> Unit = { + stopStarted.complete(Unit) + releaseStop.await() + } + + try { + registry.accept(first, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(first) + checkNotNull(registry.claim("watch-a", "attempt-a")) + + registry.accept(second, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(second) + registry.accept(third, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(third) + val newestClaim = async { registry.claim("watch-a", "attempt-c") } + withTimeout(1_000L) { stopStarted.await() } + delay(100L) + assertFalse(newestClaim.isCompleted) + + releaseStop.complete(Unit) + val newestOwner = checkNotNull(withTimeout(1_000L) { newestClaim.await() }).owner + registry.close(newestOwner) + } finally { + releaseStop.complete(Unit) + scope.cancel() + } + } + + @Test + fun `delayed unclaimed channel cannot retire the active attempt`() = + runBlocking { + val scope = kotlinx.coroutines.CoroutineScope(SupervisorJob() + Dispatchers.IO) + val transport = FakeChannelTransport() + val registry = WearRealtimeChannelRegistry(scope, transport) + val stoppedOwners = mutableListOf() + val stopTalk: suspend (WearRealtimeAttemptOwner) -> Unit = { owner -> + synchronized(stoppedOwners) { stoppedOwners += owner } + } + val active = FakeChannel("watch-a", "channel-b", "attempt-b") + val delayed = FakeChannel("watch-a", "channel-a", "attempt-a") + + try { + registry.accept(active, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(active) + val activeClaim = checkNotNull(registry.claim("watch-a", "attempt-b")) + + registry.accept(delayed, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(delayed) + delay(100L) + + assertTrue(registry.isCurrent(activeClaim.owner)) + assertTrue(synchronized(stoppedOwners) { stoppedOwners.isEmpty() }) + val repeated = checkNotNull(registry.claim("watch-a", "attempt-b")) + assertFalse(repeated.newlyAcquired) + assertSame(activeClaim.owner, repeated.owner) + registry.close(activeClaim.owner) + } finally { + scope.cancel() + } + } + + @Test + fun `same path reconnect inherits the active attempt owner`() = + runBlocking { + val scope = kotlinx.coroutines.CoroutineScope(SupervisorJob() + Dispatchers.IO) + val transport = FakeChannelTransport() + val registry = WearRealtimeChannelRegistry(scope, transport) + val stoppedOwners = mutableListOf() + val stopTalk: suspend (WearRealtimeAttemptOwner) -> Unit = { owner -> + synchronized(stoppedOwners) { stoppedOwners += owner } + } + val active = FakeChannel("watch-a", "channel-a", "attempt-a") + val reconnect = FakeChannel("watch-a", "channel-a-reconnect", "attempt-a") + + try { + registry.accept(active, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(active) + val owner = checkNotNull(registry.claim("watch-a", "attempt-a")).owner + + registry.accept(reconnect, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(reconnect) + withTimeout(1_000L) { + while (transport.closeCount(active) != 1 || !transport.hasStartedReading(reconnect)) yield() + } + + val repeated = checkNotNull(registry.claim("watch-a", "attempt-a")) + assertFalse(repeated.newlyAcquired) + assertSame(owner, repeated.owner) + assertTrue(registry.isCurrent(owner)) + assertTrue(synchronized(stoppedOwners) { stoppedOwners.isEmpty() }) + registry.close(owner) + } finally { + scope.cancel() + } + } + + @Test + fun `same path reconnect waits for an in flight frame before retiring its channel`() = + runBlocking { + val scope = kotlinx.coroutines.CoroutineScope(SupervisorJob() + Dispatchers.IO) + val transport = FakeChannelTransport() + val registry = WearRealtimeChannelRegistry(scope, transport) + val active = FakeChannel("watch-a", "channel-a", "attempt-a") + val reconnect = FakeChannel("watch-a", "channel-a-reconnect", "attempt-a") + val releaseWrite = transport.holdWrite(active) + + try { + registry.accept(active, appendAudio = { _, _ -> }, stopTalk = {}) + transport.awaitOpened(active) + val owner = checkNotNull(registry.claim("watch-a", "attempt-a")).owner + val send = + async(Dispatchers.IO) { + registry.send(owner, WearRealtimeAudioFrameType.OUTPUT_PCM, byteArrayOf(1, 2, 3, 4)) + } + transport.awaitWriteStarted(active) + + registry.accept(reconnect, appendAudio = { _, _ -> }, stopTalk = {}) + transport.awaitOpened(reconnect) + delay(100L) + assertFalse(send.isCompleted) + assertEquals(0, transport.closeCount(active)) + + releaseWrite.countDown() + withTimeout(1_000L) { send.await() } + withTimeout(1_000L) { + while (transport.closeCount(active) != 1 || !transport.hasStartedReading(reconnect)) yield() + } + assertTrue(registry.isCurrent(owner)) + registry.close(owner) + } finally { + releaseWrite.countDown() + scope.cancel() + } + } + + @Test + fun `missing stale claim does not block a valid attempt on the same watch`() = + runBlocking { + val scope = kotlinx.coroutines.CoroutineScope(SupervisorJob() + Dispatchers.IO) + val transport = FakeChannelTransport() + val registry = WearRealtimeChannelRegistry(scope, transport) + val current = FakeChannel("watch-a", "channel-b", "attempt-b") + + try { + registry.accept(current, appendAudio = { _, _ -> }, stopTalk = {}) + transport.awaitOpened(current) + val missingClaim = async { registry.claim("watch-a", "attempt-a") } + delay(100L) + + val currentOwner = + withTimeout(1_000L) { + checkNotNull(registry.claim("watch-a", "attempt-b")).owner + } + assertFalse(missingClaim.isCompleted) + missingClaim.cancel() + registry.close(currentOwner) + } finally { + scope.cancel() + } + } + + @Test + fun `replacement claim bounds a stalled owner stop callback`() = + runBlocking { + val scope = kotlinx.coroutines.CoroutineScope(SupervisorJob() + Dispatchers.IO) + val transport = FakeChannelTransport() + val registry = WearRealtimeChannelRegistry(scope, transport) + val first = FakeChannel("watch-a", "channel-a", "attempt-a") + val second = FakeChannel("watch-a", "channel-b", "attempt-b") + val stopStarted = CompletableDeferred() + val releaseStop = CompletableDeferred() + val stopFinished = CompletableDeferred() + val stopTalk: suspend (WearRealtimeAttemptOwner) -> Unit = { owner -> + if (owner.attemptId == "attempt-a") { + stopStarted.complete(Unit) + releaseStop.await() + stopFinished.complete(Unit) + } + } + + try { + registry.accept(first, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(first) + checkNotNull(registry.claim("watch-a", "attempt-a")) + registry.accept(second, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(second) + + val replacementClaim = async { registry.claim("watch-a", "attempt-b") } + withTimeout(1_000L) { stopStarted.await() } + val replacement = checkNotNull(withTimeout(2_500L) { replacementClaim.await() }).owner + assertFalse(stopFinished.isCompleted) + releaseStop.complete(Unit) + withTimeout(1_000L) { stopFinished.await() } + registry.close(replacement) + } finally { + releaseStop.complete(Unit) + scope.cancel() + } + } + + @Test + fun `retirement timeout keeps transport cleanup running`() = + runBlocking { + val scope = kotlinx.coroutines.CoroutineScope(SupervisorJob() + Dispatchers.IO) + val transport = FakeChannelTransport() + val registry = + WearRealtimeChannelRegistry( + scope = scope, + transport = transport, + retireCallbackTimeoutMillis = 100L, + ) + val channel = FakeChannel("watch-a", "channel-a", "attempt-a") + val releaseClose = transport.holdClose(channel) + + try { + registry.accept(channel, appendAudio = { _, _ -> }, stopTalk = {}) + transport.awaitOpened(channel) + val owner = checkNotNull(registry.claim("watch-a", "attempt-a")).owner + + withTimeout(1_000L) { registry.close(owner) } + assertEquals(0, transport.closeCount(channel)) + scope.cancel() + releaseClose.complete(Unit) + withTimeout(1_000L) { + while (transport.closeCount(channel) != 1) yield() + } + } finally { + releaseClose.complete(Unit) + scope.cancel() + } + } + + @Test + fun `old path reconnect cannot inherit an owner reserved for retirement`() = + runBlocking { + val scope = kotlinx.coroutines.CoroutineScope(SupervisorJob() + Dispatchers.IO) + val transport = FakeChannelTransport() + val registry = + WearRealtimeChannelRegistry( + scope = scope, + transport = transport, + pendingConnectionTimeoutMillis = 100L, + ) + val stopStarted = CompletableDeferred() + val releaseStop = CompletableDeferred() + val stoppedOwners = mutableListOf() + val active = FakeChannel("watch-a", "channel-a", "attempt-a") + val replacement = FakeChannel("watch-a", "channel-b", "attempt-b") + val staleReconnect = FakeChannel("watch-a", "channel-a-reconnect", "attempt-a") + val stopTalk: suspend (WearRealtimeAttemptOwner) -> Unit = { owner -> + stopStarted.complete(Unit) + releaseStop.await() + synchronized(stoppedOwners) { stoppedOwners += owner } + } + + try { + registry.accept(active, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(active) + val activeOwner = checkNotNull(registry.claim("watch-a", "attempt-a")).owner + registry.accept(replacement, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(replacement) + val replacementClaim = async { registry.claim("watch-a", "attempt-b") } + withTimeout(1_000L) { stopStarted.await() } + + registry.accept(staleReconnect, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(staleReconnect) + releaseStop.complete(Unit) + + val replacementOwner = checkNotNull(withTimeout(1_000L) { replacementClaim.await() }).owner + assertEquals(listOf(activeOwner), synchronized(stoppedOwners) { stoppedOwners.toList() }) + assertTrue(registry.isCurrent(replacementOwner)) + withTimeout(1_000L) { + while (transport.closeCount(staleReconnect) != 1) yield() + } + registry.close(replacementOwner) + } finally { + releaseStop.complete(Unit) + scope.cancel() + } + } + + @Test + fun `promotion reserved before discovery timeout commits after bounded cleanup`() = + runBlocking { + val scope = kotlinx.coroutines.CoroutineScope(SupervisorJob() + Dispatchers.IO) + val transport = FakeChannelTransport() + val registry = + WearRealtimeChannelRegistry( + scope = scope, + transport = transport, + connectionReadyTimeoutMillis = 200L, + pendingConnectionTimeoutMillis = 1_000L, + retireCallbackTimeoutMillis = 100L, + ) + val first = FakeChannel("watch-a", "channel-a", "attempt-a") + val replacement = FakeChannel("watch-a", "channel-b", "attempt-b") + val releaseReplacement = transport.holdOpen(replacement) + val stopTalk: suspend (WearRealtimeAttemptOwner) -> Unit = { owner -> + if (owner.attemptId == "attempt-a") awaitCancellation() + } + + try { + registry.accept(first, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(first) + checkNotNull(registry.claim("watch-a", "attempt-a")) + registry.accept(replacement, appendAudio = { _, _ -> }, stopTalk = stopTalk) + val replacementClaim = async { registry.claim("watch-a", "attempt-b") } + delay(150L) + releaseReplacement.complete(Unit) + transport.awaitOpened(replacement) + + val replacementOwner = + withTimeout(1_000L) { + checkNotNull(replacementClaim.await()).owner + } + assertTrue(registry.isCurrent(replacementOwner)) + registry.close(replacementOwner) + } finally { + releaseReplacement.complete(Unit) + scope.cancel() + } + } + + @Test + fun `claim keeps polling after an unclaimed channel expires`() = + runBlocking { + val scope = kotlinx.coroutines.CoroutineScope(SupervisorJob() + Dispatchers.IO) + val transport = FakeChannelTransport() + val registry = + WearRealtimeChannelRegistry( + scope = scope, + transport = transport, + connectionReadyTimeoutMillis = 1_000L, + pendingConnectionTimeoutMillis = 100L, + ) + val expired = FakeChannel("watch-a", "channel-a-expired", "attempt-a") + val replacement = FakeChannel("watch-a", "channel-a-replacement", "attempt-a") + + try { + registry.accept(expired, appendAudio = { _, _ -> }, stopTalk = {}) + transport.awaitOpened(expired) + withTimeout(1_000L) { + while (transport.closeCount(expired) != 1) yield() + } + + val claim = async { registry.claim("watch-a", "attempt-a") } + delay(100L) + assertFalse(claim.isCompleted) + registry.accept(replacement, appendAudio = { _, _ -> }, stopTalk = {}) + transport.awaitOpened(replacement) + + val owner = checkNotNull(withTimeout(1_000L) { claim.await() }).owner + assertTrue(registry.isCurrent(owner)) + registry.close(owner) + } finally { + scope.cancel() + } + } + + @Test + fun `older polling claim cannot replace a newer active attempt`() = + runBlocking { + val scope = kotlinx.coroutines.CoroutineScope(SupervisorJob() + Dispatchers.IO) + val transport = FakeChannelTransport() + val registry = WearRealtimeChannelRegistry(scope, transport) + val stoppedOwners = mutableListOf() + val stopTalk: suspend (WearRealtimeAttemptOwner) -> Unit = { owner -> + synchronized(stoppedOwners) { stoppedOwners += owner } + } + val newer = FakeChannel("watch-a", "channel-b", "attempt-b") + val delayedOlder = FakeChannel("watch-a", "channel-a", "attempt-a") + + try { + val olderClaim = async { registry.claim("watch-a", "attempt-a") } + delay(100L) + registry.accept(newer, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(newer) + val newerOwner = checkNotNull(registry.claim("watch-a", "attempt-b")).owner + + registry.accept(delayedOlder, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(delayedOlder) + + assertEquals(null, withTimeout(1_000L) { olderClaim.await() }) + assertTrue(registry.isCurrent(newerOwner)) + assertTrue(synchronized(stoppedOwners) { stoppedOwners.isEmpty() }) + registry.close(newerOwner) + } finally { + scope.cancel() + } + } + + @Test + fun `newer waiting channel survives an older promotion`() = + runBlocking { + val scope = kotlinx.coroutines.CoroutineScope(SupervisorJob() + Dispatchers.IO) + val transport = FakeChannelTransport() + val registry = WearRealtimeChannelRegistry(scope, transport) + val stopStarted = CompletableDeferred() + val releaseStop = CompletableDeferred() + val first = FakeChannel("watch-a", "channel-a", "attempt-a") + val second = FakeChannel("watch-a", "channel-b", "attempt-b") + val third = FakeChannel("watch-a", "channel-c", "attempt-c") + val stopTalk: suspend (WearRealtimeAttemptOwner) -> Unit = { owner -> + if (owner.attemptId == "attempt-a") { + stopStarted.complete(Unit) + releaseStop.await() + } + } + + try { + registry.accept(first, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(first) + checkNotNull(registry.claim("watch-a", "attempt-a")) + registry.accept(second, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(second) + val secondClaim = async { registry.claim("watch-a", "attempt-b") } + withTimeout(1_000L) { stopStarted.await() } + + registry.accept(third, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(third) + val thirdClaim = async { registry.claim("watch-a", "attempt-c") } + releaseStop.complete(Unit) + + assertNotNull(withTimeout(1_000L) { secondClaim.await() }) + val thirdOwner = checkNotNull(withTimeout(1_000L) { thirdClaim.await() }).owner + assertTrue(registry.isCurrent(thirdOwner)) + registry.close(thirdOwner) + } finally { + releaseStop.complete(Unit) + scope.cancel() + } + } + + @Test + fun `same path reconnect replaces a reserved channel before promotion`() = + runBlocking { + val scope = kotlinx.coroutines.CoroutineScope(SupervisorJob() + Dispatchers.IO) + val transport = FakeChannelTransport() + val registry = WearRealtimeChannelRegistry(scope, transport) + val stopStarted = CompletableDeferred() + val releaseStop = CompletableDeferred() + val active = FakeChannel("watch-a", "channel-a", "attempt-a") + val reserved = FakeChannel("watch-a", "channel-b-reserved", "attempt-b") + val reconnect = FakeChannel("watch-a", "channel-b-reconnect", "attempt-b") + val stopTalk: suspend (WearRealtimeAttemptOwner) -> Unit = { owner -> + if (owner.attemptId == "attempt-a") { + stopStarted.complete(Unit) + releaseStop.await() + } + } + + try { + registry.accept(active, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(active) + checkNotNull(registry.claim("watch-a", "attempt-a")) + registry.accept(reserved, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(reserved) + val replacementClaim = async { registry.claim("watch-a", "attempt-b") } + withTimeout(1_000L) { stopStarted.await() } + + registry.accept(reconnect, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(reconnect) + releaseStop.complete(Unit) + + val owner = checkNotNull(withTimeout(1_000L) { replacementClaim.await() }).owner + assertEquals(3L, owner.channelGeneration) + assertEquals(1, transport.closeCount(reserved)) + assertEquals(0, transport.closeCount(reconnect)) + assertTrue(registry.isCurrent(owner)) + registry.close(owner) + } finally { + releaseStop.complete(Unit) + scope.cancel() + } + } + + @Test + fun `latest reconnect wins while an older promotion candidate retires`() = + runBlocking { + val scope = kotlinx.coroutines.CoroutineScope(SupervisorJob() + Dispatchers.IO) + val transport = FakeChannelTransport() + val registry = WearRealtimeChannelRegistry(scope, transport) + val stopStarted = CompletableDeferred() + val releaseStop = CompletableDeferred() + val active = FakeChannel("watch-a", "channel-a", "attempt-a") + val reserved = FakeChannel("watch-a", "channel-b-reserved", "attempt-b") + val firstReconnect = FakeChannel("watch-a", "channel-b-first-reconnect", "attempt-b") + val latestReconnect = FakeChannel("watch-a", "channel-b-latest-reconnect", "attempt-b") + val releaseReservedClose = transport.holdClose(reserved) + val stopTalk: suspend (WearRealtimeAttemptOwner) -> Unit = { owner -> + if (owner.attemptId == "attempt-a") { + stopStarted.complete(Unit) + releaseStop.await() + } + } + + try { + registry.accept(active, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(active) + checkNotNull(registry.claim("watch-a", "attempt-a")) + registry.accept(reserved, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(reserved) + val replacementClaim = async { registry.claim("watch-a", "attempt-b") } + withTimeout(1_000L) { stopStarted.await() } + + registry.accept(firstReconnect, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(firstReconnect) + releaseStop.complete(Unit) + transport.awaitCloseStarted(reserved) + + registry.accept(latestReconnect, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(latestReconnect) + releaseReservedClose.complete(Unit) + + val owner = checkNotNull(withTimeout(1_000L) { replacementClaim.await() }).owner + withTimeout(1_000L) { + while ( + transport.closeCount(reserved) != 1 || + transport.closeCount(firstReconnect) != 1 || + !transport.hasStartedReading(latestReconnect) + ) { + yield() + } + } + assertEquals("attempt-b", owner.attemptId) + assertEquals(0, transport.closeCount(latestReconnect)) + assertTrue(registry.isCurrent(owner)) + registry.close(owner) + } finally { + releaseStop.complete(Unit) + releaseReservedClose.complete(Unit) + scope.cancel() + } + } + + @Test + fun `cancelled promotion retires its reserved channel before retry`() = + runBlocking { + val scope = kotlinx.coroutines.CoroutineScope(SupervisorJob() + Dispatchers.IO) + val transport = FakeChannelTransport() + val registry = + WearRealtimeChannelRegistry( + scope = scope, + transport = transport, + connectionReadyTimeoutMillis = 500L, + pendingConnectionTimeoutMillis = 1_000L, + retireCallbackTimeoutMillis = 100L, + ) + val stopStarted = CompletableDeferred() + val first = FakeChannel("watch-a", "channel-a", "attempt-a") + val cancelled = FakeChannel("watch-a", "channel-b", "attempt-b") + val retry = FakeChannel("watch-a", "channel-b-retry", "attempt-b") + val stopTalk: suspend (WearRealtimeAttemptOwner) -> Unit = { owner -> + if (owner.attemptId == "attempt-a") { + stopStarted.complete(Unit) + awaitCancellation() + } + } + + try { + registry.accept(first, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(first) + checkNotNull(registry.claim("watch-a", "attempt-a")) + registry.accept(cancelled, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(cancelled) + + val cancelledClaim = async { registry.claim("watch-a", "attempt-b") } + withTimeout(1_000L) { stopStarted.await() } + cancelledClaim.cancelAndJoin() + withTimeout(1_000L) { + while (transport.closeCount(cancelled) != 1) yield() + } + + registry.accept(retry, appendAudio = { _, _ -> }, stopTalk = stopTalk) + transport.awaitOpened(retry) + val retryOwner = checkNotNull(withTimeout(1_000L) { registry.claim("watch-a", "attempt-b") }).owner + registry.close(retryOwner) + } finally { + scope.cancel() + } + } + + @Test + fun `pending channel does not read pcm before promotion`() = + runBlocking { + val scope = kotlinx.coroutines.CoroutineScope(SupervisorJob() + Dispatchers.IO) + val transport = FakeChannelTransport() + val registry = WearRealtimeChannelRegistry(scope, transport) + val channel = FakeChannel("watch-a", "channel-a", "attempt-a") + + try { + registry.accept(channel, appendAudio = { _, _ -> }, stopTalk = {}) + transport.awaitOpened(channel) + delay(100L) + assertFalse(transport.hasStartedReading(channel)) + + val owner = checkNotNull(registry.claim("watch-a", "attempt-a")).owner + withTimeout(1_000L) { + while (!transport.hasStartedReading(channel)) yield() + } + registry.close(owner) + } finally { + scope.cancel() + } + } + + @Test + fun `older channel setup cannot displace a newer published channel`() = + runBlocking { + val scope = kotlinx.coroutines.CoroutineScope(SupervisorJob() + Dispatchers.IO) + val transport = FakeChannelTransport() + val registry = WearRealtimeChannelRegistry(scope, transport) + val first = FakeChannel("watch-a", "channel-a", "attempt-a") + val second = FakeChannel("watch-a", "channel-b", "attempt-b") + val releaseFirst = transport.holdOpen(first) + + try { + registry.accept(first, appendAudio = { _, _ -> }, stopTalk = {}) + registry.accept(second, appendAudio = { _, _ -> }, stopTalk = {}) + transport.awaitOpened(second) + val secondOwner = checkNotNull(registry.claim("watch-a", "attempt-b")).owner + + releaseFirst.complete(Unit) + transport.awaitOpened(first) + withTimeout(1_000L) { + while (transport.closeCount(first) != 1) yield() + } + + val repeated = checkNotNull(registry.claim("watch-a", "attempt-b")) + assertFalse(repeated.newlyAcquired) + assertSame(secondOwner, repeated.owner) + registry.close(secondOwner) + } finally { + releaseFirst.complete(Unit) + scope.cancel() + } + } + + @Test + fun `legacy channel replacement binds the new attempt instead of inheriting the old owner`() = + runBlocking { + val scope = kotlinx.coroutines.CoroutineScope(SupervisorJob() + Dispatchers.IO) + val transport = FakeChannelTransport() + val registry = WearRealtimeChannelRegistry(scope, transport) + val stoppedOwners = mutableListOf() + val first = + FakeChannel( + nodeId = "watch-a", + label = "legacy-a", + attemptId = "ignored-a", + pathOverride = WearProtocol.LEGACY_REALTIME_AUDIO_CHANNEL_PATH, + ) + val second = + FakeChannel( + nodeId = "watch-a", + label = "legacy-b", + attemptId = "ignored-b", + pathOverride = WearProtocol.LEGACY_REALTIME_AUDIO_CHANNEL_PATH, + ) + + try { + registry.accept(first, appendAudio = { _, _ -> }, stopTalk = { stoppedOwners += it }) + transport.awaitOpened(first) + val firstOwner = + checkNotNull( + registry.claim( + nodeId = "watch-a", + attemptId = "attempt-a", + attemptScopedAudio = false, + ), + ).owner + + registry.accept(second, appendAudio = { _, _ -> }, stopTalk = { stoppedOwners += it }) + transport.awaitOpened(second) + val secondOwner = + checkNotNull( + registry.claim( + nodeId = "watch-a", + attemptId = "attempt-b", + attemptScopedAudio = false, + ), + ).owner + + assertEquals("attempt-b", secondOwner.attemptId) + assertTrue(secondOwner.channelGeneration > firstOwner.channelGeneration) + assertEquals(listOf(firstOwner), stoppedOwners) + registry.close(secondOwner) + } finally { + scope.cancel() + } + } + + @Test + fun `staged channel limits reject excess connections before opening streams`() = + runBlocking { + val scope = kotlinx.coroutines.CoroutineScope(SupervisorJob() + Dispatchers.IO) + val transport = FakeChannelTransport() + val registry = + WearRealtimeChannelRegistry( + scope = scope, + transport = transport, + maxStagedConnectionsPerNode = 1, + maxStagedConnections = 2, + ) + val first = FakeChannel("watch-a", "first", "attempt-a") + val sameNodeExcess = FakeChannel("watch-a", "same-node-excess", "attempt-b") + val secondNode = FakeChannel("watch-b", "second-node", "attempt-c") + val globalExcess = FakeChannel("watch-c", "global-excess", "attempt-d") + + try { + registry.accept(first, appendAudio = { _, _ -> }, stopTalk = {}) + transport.awaitOpened(first) + + registry.accept(sameNodeExcess, appendAudio = { _, _ -> }, stopTalk = {}) + transport.awaitCloseStarted(sameNodeExcess) + assertFalse(transport.wasOpened(sameNodeExcess)) + assertEquals(1, transport.closeCount(sameNodeExcess)) + + registry.accept(secondNode, appendAudio = { _, _ -> }, stopTalk = {}) + transport.awaitOpened(secondNode) + + registry.accept(globalExcess, appendAudio = { _, _ -> }, stopTalk = {}) + transport.awaitCloseStarted(globalExcess) + assertFalse(transport.wasOpened(globalExcess)) + assertEquals(1, transport.closeCount(globalExcess)) + } finally { + scope.cancel() + } + } +} + +private class FakeChannelTransport : WearRealtimeChannelTransport { + private val opened = ConcurrentHashMap>() + private val openGates = ConcurrentHashMap>() + private val closeGates = ConcurrentHashMap>() + private val closeStarted = ConcurrentHashMap>() + private val closeCounts = ConcurrentHashMap() + private val writeGates = ConcurrentHashMap() + private val writeStarted = ConcurrentHashMap>() + private val openedResources = ConcurrentHashMap() + + override suspend fun open(channel: ChannelClient.Channel): WearRealtimeChannelResources { + openGates[channel]?.await() + val resources = + WearRealtimeChannelResources( + ClosingInputStream(), + GatedOutputStream( + gate = writeGates[channel], + started = writeStarted.computeIfAbsent(channel) { CompletableDeferred() }, + ), + ) + openedResources[channel] = resources + opened.computeIfAbsent(channel) { CompletableDeferred() }.complete(Unit) + return resources + } + + override suspend fun close( + channel: ChannelClient.Channel, + resources: WearRealtimeChannelResources?, + ) { + closeStarted.computeIfAbsent(channel) { CompletableDeferred() }.complete(Unit) + closeGates[channel]?.await() + resources?.input?.close() + resources?.output?.close() + closeCounts.compute(channel) { _, count -> (count ?: 0) + 1 } + } + + suspend fun awaitOpened(channel: ChannelClient.Channel) { + opened.computeIfAbsent(channel) { CompletableDeferred() }.await() + } + + fun holdOpen(channel: ChannelClient.Channel): CompletableDeferred { + val gate = CompletableDeferred() + openGates[channel] = gate + return gate + } + + fun holdClose(channel: ChannelClient.Channel): CompletableDeferred { + val gate = CompletableDeferred() + closeGates[channel] = gate + return gate + } + + suspend fun awaitCloseStarted(channel: ChannelClient.Channel) { + closeStarted.computeIfAbsent(channel) { CompletableDeferred() }.await() + } + + fun holdWrite(channel: ChannelClient.Channel): CountDownLatch = + CountDownLatch(1).also { gate -> + writeGates[channel] = gate + } + + suspend fun awaitWriteStarted(channel: ChannelClient.Channel) { + writeStarted.computeIfAbsent(channel) { CompletableDeferred() }.await() + } + + fun finishInput(channel: ChannelClient.Channel) { + openedResources[channel]?.input?.close() + } + + fun closeCount(channel: ChannelClient.Channel): Int = closeCounts[channel] ?: 0 + + fun wasOpened(channel: ChannelClient.Channel): Boolean = openedResources.containsKey(channel) + + fun hasStartedReading(channel: ChannelClient.Channel): Boolean = (openedResources[channel]?.input as? ClosingInputStream)?.hasStartedReading() == true +} + +private class ClosingInputStream : InputStream() { + private val started = CountDownLatch(1) + private val closed = CountDownLatch(1) + + override fun read(): Int { + started.countDown() + closed.await() + return -1 + } + + override fun close() { + closed.countDown() + } + + fun hasStartedReading(): Boolean = started.count == 0L +} + +private class GatedOutputStream( + private val gate: CountDownLatch?, + private val started: CompletableDeferred, +) : ByteArrayOutputStream() { + override fun write( + buffer: ByteArray, + offset: Int, + length: Int, + ) { + started.complete(Unit) + gate?.let { check(it.await(5, TimeUnit.SECONDS)) } + super.write(buffer, offset, length) + } +} + +private data class FakeChannel( + private val nodeId: String, + private val label: String, + private val attemptId: String, + private val pathOverride: String? = null, +) : ChannelClient.Channel { + override fun getNodeId(): String = nodeId + + override fun getPath(): String = pathOverride ?: WearProtocol.realtimeAudioChannelPath(attemptId) + + override fun describeContents(): Int = 0 + + override fun writeToParcel( + dest: Parcel, + flags: Int, + ) { + dest.writeString(label) + } +} diff --git a/apps/android/app/src/test/java/ai/openclaw/app/wear/WearRealtimeTalkControllerTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/wear/WearRealtimeTalkControllerTest.kt index 2f10da12a0f4..733c27f6da3c 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/wear/WearRealtimeTalkControllerTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/wear/WearRealtimeTalkControllerTest.kt @@ -13,6 +13,8 @@ import kotlinx.coroutines.async import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -63,6 +65,24 @@ class WearRealtimeTalkControllerTest { assertEquals(0, gatewayCalls) } + @Test + fun `partial scoped stop rejects when no active owner can match`() = + runTest { + val controller = + WearRealtimeTalkController( + scope = this, + isConnected = { true }, + requestGateway = { _, _, _ -> """{"relaySessionId":"relay-late"}""" }, + sendGatewayFrame = { _, _, _, _ -> }, + sendWatchFrame = { _, _, _ -> }, + ) + + assertFalse(controller.stop(nodeId = "watch-a")) + assertFalse(controller.stop(attemptId = "attempt-a")) + assertTrue(controller.start("watch-b", "session-b", "attempt-b", "de")) + assertTrue(controller.stop("watch-b", "attempt-b")) + } + @Test fun `abort during connecting keeps a missing late session off`() = runTest { @@ -78,7 +98,7 @@ class WearRealtimeTalkControllerTest { onSnapshot = { snapshot -> if (snapshot.status == WearRealtimeTalkStatus.CONNECTING) controller.abort() }, - onForceCloseWatchChannel = { nodeId -> forcedChannelCloses += nodeId }, + onForceCloseWatchChannel = { owner -> forcedChannelCloses += owner.nodeId }, ) assertFalse( @@ -117,7 +137,7 @@ class WearRealtimeTalkControllerTest { }, sendGatewayFrame = { _, _, _, _ -> }, sendWatchFrame = { _, _, _ -> }, - onForceCloseWatchChannel = { nodeId -> forcedChannelCloses += nodeId }, + onForceCloseWatchChannel = { owner -> forcedChannelCloses += owner.nodeId }, ) val startResult = @@ -158,7 +178,7 @@ class WearRealtimeTalkControllerTest { }, sendGatewayFrame = { _, _, _, _ -> }, sendWatchFrame = { _, _, _ -> }, - onForceCloseWatchChannel = { nodeId -> forcedChannelCloses += nodeId }, + onForceCloseWatchChannel = { owner -> forcedChannelCloses += owner.nodeId }, ) assertTrue( @@ -195,16 +215,16 @@ class WearRealtimeTalkControllerTest { @Test fun `late append error from a stopped session does not fail its replacement`() = runTest { - var relaySequence = 0 var staleAppendError: ((String) -> Unit)? = null + var createCount = 0 val controller = WearRealtimeTalkController( scope = this, isConnected = { true }, requestGateway = { method, _, _ -> if (method == "talk.session.create") { - relaySequence += 1 - """{"relaySessionId":"relay-$relaySequence"}""" + createCount += 1 + """{"relaySessionId":"relay-$createCount"}""" } else { """{"ok":true}""" } @@ -232,17 +252,17 @@ class WearRealtimeTalkControllerTest { @Test fun `late Watch output error from a stopped session does not fail its replacement`() = runTest { - var relaySequence = 0 val outputStarted = CompletableDeferred() val releaseOutput = CompletableDeferred() + var createCount = 0 val controller = WearRealtimeTalkController( scope = this, isConnected = { true }, requestGateway = { method, _, _ -> if (method == "talk.session.create") { - relaySequence += 1 - """{"relaySessionId":"relay-$relaySequence"}""" + createCount += 1 + """{"relaySessionId":"relay-$createCount"}""" } else { """{"ok":true}""" } @@ -280,6 +300,176 @@ class WearRealtimeTalkControllerTest { assertTrue(controller.stop("watch-a", "attempt-b")) } + @Test + fun `stale close callback cannot abort replacement`() = + runTest { + var createCount = 0 + val controller = + WearRealtimeTalkController( + scope = this, + isConnected = { true }, + requestGateway = { method, _, _ -> + if (method == "talk.session.create") { + createCount += 1 + """{"relaySessionId":"relay-$createCount"}""" + } else { + """{"ok":true}""" + } + }, + sendGatewayFrame = { _, _, _, _ -> }, + sendWatchFrame = { _, _, _ -> }, + ) + val staleOwner = WearRealtimeAttemptOwner("watch-a", "attempt-a", 1L) + val replacementOwner = WearRealtimeAttemptOwner("watch-a", "attempt-b", 2L) + + assertTrue(controller.start(staleOwner, "session-a", "de")) + controller.abort() + assertTrue(controller.start(replacementOwner, "session-b", "de")) + + WearRealtimeTalkController::class.java + .getDeclaredMethod( + "abort", + WearRealtimeAttemptOwner::class.java, + String::class.java, + ).apply { isAccessible = true } + .invoke(controller, staleOwner, "relay-1") + + assertEquals(WearRealtimeTalkStatus.LISTENING, controller.snapshot.value.status) + assertEquals("attempt-b", controller.snapshot.value.attemptId) + assertTrue(controller.stop(replacementOwner)) + } + + @Test + fun `stale relay events cannot mutate or dispatch into replacement`() = + runTest { + val gatewayMethods = mutableListOf() + var createCount = 0 + val controller = + WearRealtimeTalkController( + scope = this, + isConnected = { true }, + requestGateway = { method, _, _ -> + gatewayMethods += method + if (method == "talk.session.create") { + createCount += 1 + """{"relaySessionId":"relay-$createCount"}""" + } else { + """{"ok":true}""" + } + }, + sendGatewayFrame = { _, _, _, _ -> }, + sendWatchFrame = { _, _, _ -> }, + ) + val staleOwner = WearRealtimeAttemptOwner("watch-a", "attempt-a", 1L) + val replacementOwner = WearRealtimeAttemptOwner("watch-a", "attempt-b", 2L) + + assertTrue(controller.start(staleOwner, "session-a", "de")) + controller.handleGatewayEvent( + "talk.event", + """{"relaySessionId":"relay-1","type":"mark","markName":"stale-mark"}""", + ) + controller.abort() + assertTrue(controller.start(replacementOwner, "session-b", "de")) + + controller.invokePrivate( + "handleTranscriptEvent", + staleOwner, + "relay-1", + buildJsonObject { + put("role", JsonPrimitive("user")) + put("text", JsonPrimitive("stale transcript")) + put("final", JsonPrimitive(true)) + }, + ) + controller.invokePrivate( + "handleToolCallEvent", + staleOwner, + "relay-1", + buildJsonObject { + put("callId", JsonPrimitive("stale-call")) + put("name", JsonPrimitive("stale-tool")) + }, + ) + runCurrent() + + val snapshot = controller.snapshot.value + assertTrue(snapshot.conversation.isEmpty()) + assertEquals(WearRealtimeTalkStatus.LISTENING, snapshot.status) + assertEquals("attempt-b", snapshot.attemptId) + assertFalse("talk.session.acknowledgeMark" in gatewayMethods) + assertFalse("talk.client.toolCall" in gatewayMethods) + assertTrue(controller.stop(replacementOwner)) + } + + @Test + fun `replacement cancels delayed tool correlation when the session key is reused`() = + runTest { + val gatewayMethods = mutableListOf() + val oldToolStarted = CompletableDeferred() + val oldToolResponse = CompletableDeferred() + var createCount = 0 + val controller = + WearRealtimeTalkController( + scope = this, + isConnected = { true }, + requestGateway = { method, _, _ -> + gatewayMethods += method + when (method) { + "talk.session.create" -> { + createCount += 1 + """{"relaySessionId":"relay-$createCount"}""" + } + "talk.client.toolCall" -> { + oldToolStarted.complete(Unit) + oldToolResponse.await() + } + else -> """{"ok":true}""" + } + }, + sendGatewayFrame = { _, _, _, _ -> }, + sendWatchFrame = { _, _, _ -> }, + ) + val staleOwner = WearRealtimeAttemptOwner("watch-a", "attempt-a", 1L) + val replacementOwner = WearRealtimeAttemptOwner("watch-a", "attempt-b", 2L) + + assertTrue(controller.start(staleOwner, "session-main", "de")) + controller.handleGatewayEvent( + "talk.event", + """ + { + "relaySessionId":"relay-1", + "type":"toolCall", + "callId":"old-call", + "name":"openclaw_agent_consult" + } + """.trimIndent(), + ) + runCurrent() + oldToolStarted.await() + + assertTrue(controller.stop(staleOwner)) + assertTrue(controller.start(replacementOwner, "session-main", "de")) + oldToolResponse.complete("""{"runId":"old-run"}""") + runCurrent() + controller.handleGatewayEvent( + "chat", + """ + { + "sessionKey":"session-main", + "runId":"old-run", + "state":"final", + "message":{"role":"assistant","content":"stale"} + } + """.trimIndent(), + ) + runCurrent() + + assertEquals(0, gatewayMethods.count { it == "talk.session.submitToolResult" }) + assertEquals(WearRealtimeTalkStatus.LISTENING, controller.snapshot.value.status) + assertEquals("attempt-b", controller.snapshot.value.attemptId) + assertTrue(controller.stop(replacementOwner)) + } + @Test fun `retries without language when an older gateway rejects only that field`() = runTest { @@ -729,7 +919,7 @@ class WearRealtimeTalkControllerTest { releaseOutput.await() } }, - onForceCloseWatchChannel = { forcedChannelCloses += it }, + onForceCloseWatchChannel = { forcedChannelCloses += it.nodeId }, ) assertTrue(controller.start("watch-a", "session-a", "attempt-a", "de")) val audio = ByteArray(WearProtocol.MAX_REALTIME_AUDIO_FRAME_BYTES * 65) @@ -769,7 +959,7 @@ class WearRealtimeTalkControllerTest { }, sendGatewayFrame = { _, _, _, _ -> }, sendWatchFrame = { _, _, _ -> }, - onForceCloseWatchChannel = forcedChannelCloses::add, + onForceCloseWatchChannel = { forcedChannelCloses += it.nodeId }, ) assertTrue(controller.start("watch-a", "session-a", "attempt-a", "de")) @@ -801,7 +991,7 @@ class WearRealtimeTalkControllerTest { }, sendGatewayFrame = { _, _, _, _ -> }, sendWatchFrame = { _, _, _ -> error("wear link down") }, - onForceCloseWatchChannel = { nodeId -> forcedChannelCloses += nodeId }, + onForceCloseWatchChannel = { owner -> forcedChannelCloses += owner.nodeId }, ) assertTrue( controller.start( @@ -830,3 +1020,46 @@ class WearRealtimeTalkControllerTest { assertEquals(listOf("watch-a"), forcedChannelCloses) } } + +private suspend fun WearRealtimeTalkController.start( + nodeId: String, + sessionKey: String, + attemptId: String, + language: String?, +): Boolean = + start( + owner = testWearRealtimeOwner(nodeId, attemptId), + sessionKey = sessionKey, + language = language, + ) + +private fun WearRealtimeTalkController.appendAudio( + nodeId: String, + payload: ByteArray, +) { + val attemptId = snapshot.value.attemptId ?: return + appendAudio(testWearRealtimeOwner(nodeId, attemptId), payload) +} + +private fun testWearRealtimeOwner( + nodeId: String, + attemptId: String, +): WearRealtimeAttemptOwner = + WearRealtimeAttemptOwner( + nodeId = nodeId, + attemptId = attemptId, + channelGeneration = attemptId.hashCode().toLong(), + ) + +private fun WearRealtimeTalkController.invokePrivate( + name: String, + vararg args: Any, +) { + javaClass.declaredMethods + .single { method -> + method.name == name && + method.parameterTypes.size == args.size && + method.parameterTypes.zip(args).all { (type, arg) -> type.isAssignableFrom(arg.javaClass) } + }.apply { isAccessible = true } + .invoke(this, *args) +} diff --git a/apps/android/app/src/testDebug/java/ai/openclaw/app/VoiceE2eReceiverTest.kt b/apps/android/app/src/testDebug/java/ai/openclaw/app/VoiceE2eReceiverTest.kt new file mode 100644 index 000000000000..f87121a71578 --- /dev/null +++ b/apps/android/app/src/testDebug/java/ai/openclaw/app/VoiceE2eReceiverTest.kt @@ -0,0 +1,91 @@ +package ai.openclaw.app + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class VoiceE2eReceiverTest { + @Test + fun terminalAuthFailureStopsWaiting() { + val problem = + problem( + code = "AUTH_TOKEN_MISSING", + message = "unauthorized: gateway token missing", + pauseReconnect = true, + retryable = false, + ) + + assertEquals( + "unauthorized: gateway token missing", + voiceE2eTerminalGatewayFailure(problem), + ) + } + + @Test + fun pairingApprovalKeepsWaiting() { + val problem = + problem( + code = "PAIRING_REQUIRED", + message = "pairing approval required", + pauseReconnect = true, + retryable = true, + ) + + assertNull(voiceE2eTerminalGatewayFailure(problem)) + } + + @Test + fun retryableTransportFailureKeepsWaiting() { + val problem = + problem( + code = "UNAVAILABLE", + message = "gateway unavailable", + pauseReconnect = false, + retryable = true, + ) + + assertNull(voiceE2eTerminalGatewayFailure(problem)) + } + + @Test + fun timeoutIncludesLatestConnectionDetail() { + assertEquals( + "Gateway connection timed out after 12000 ms: pairing approval required", + voiceE2eGatewayTimeoutMessage( + timeoutMs = 12_000L, + statusText = "Reconnecting...", + problem = + problem( + code = "PAIRING_REQUIRED", + message = "pairing approval required", + pauseReconnect = true, + retryable = true, + ), + ), + ) + assertEquals( + "Gateway connection timed out after 12000 ms: Connecting...", + voiceE2eGatewayTimeoutMessage( + timeoutMs = 12_000L, + statusText = "Connecting...", + problem = null, + ), + ) + } + + private fun problem( + code: String, + message: String, + pauseReconnect: Boolean, + retryable: Boolean, + ): GatewayConnectionProblem = + GatewayConnectionProblem( + code = code, + message = message, + reason = null, + requestId = null, + recommendedNextStep = null, + pauseReconnect = pauseReconnect, + retryable = retryable, + ) +} diff --git a/apps/android/benchmark/build.gradle.kts b/apps/android/benchmark/build.gradle.kts index a3a3654451df..73245841fa62 100644 --- a/apps/android/benchmark/build.gradle.kts +++ b/apps/android/benchmark/build.gradle.kts @@ -11,6 +11,7 @@ android { defaultConfig { minSdk = 31 targetSdk = 36 + missingDimensionStrategy("store", "play") testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunnerArguments["androidx.benchmark.suppressErrors"] = "DEBUGGABLE,EMULATOR" } diff --git a/apps/android/benchmark/src/main/java/ai/openclaw/app/benchmark/CronJobNavigationTest.kt b/apps/android/benchmark/src/main/java/ai/openclaw/app/benchmark/CronJobNavigationTest.kt index 55ce817903e0..2eb5a7fedc1e 100644 --- a/apps/android/benchmark/src/main/java/ai/openclaw/app/benchmark/CronJobNavigationTest.kt +++ b/apps/android/benchmark/src/main/java/ai/openclaw/app/benchmark/CronJobNavigationTest.kt @@ -39,10 +39,9 @@ class CronJobNavigationTest { .firstOrNull { it.isClickable }, ) { "Cron fixture row must expose a click action" } assertTrue("Cron fixture row must expose a click action", cronJobRow.isClickable) - assertFalse(device.hasObject(By.text("Inspect scheduled gateway work."))) + assertFalse(device.hasObject(By.text("Run Now"))) cronJobRow.click() - assertNotNull(device.wait(Until.findObject(By.text("Inspect scheduled gateway work.")), waitTimeoutMs)) assertNotNull(findTextAfterScrolling("Run Now")) assertNotNull(findTextAfterScrolling("Recent Runs")) assertNotNull(findTextAfterScrolling("Release checklist ready", exact = false)) diff --git a/apps/android/scripts/perf-online-benchmark.sh b/apps/android/scripts/perf-online-benchmark.sh index 8bcef77382b1..94f0857168f7 100755 --- a/apps/android/scripts/perf-online-benchmark.sh +++ b/apps/android/scripts/perf-online-benchmark.sh @@ -29,7 +29,7 @@ Options: --device adb device serial --package package name (default: ai.openclaw.app) --activity launch activity (default: .MainActivity) - --skip-install skip :app:installDebug + --skip-install skip :app:installPlayDebug --launch-runs launch-to-connected runs (default: 4) --screen-loops screen benchmark loops (default: 6) --chat-loops chat benchmark loops (default: 8) @@ -145,7 +145,7 @@ trap cleanup EXIT if [[ "$INSTALL_APP" == "1" ]]; then ( cd "$ANDROID_DIR" - ./gradlew :app:installDebug --console=plain >"$run_dir/install.log" 2>&1 + ./gradlew :app:installPlayDebug --console=plain >"$run_dir/install.log" 2>&1 ) fi diff --git a/apps/android/scripts/perf-startup-hotspots.sh b/apps/android/scripts/perf-startup-hotspots.sh index ab34b7913d48..ae06f0e2b44c 100755 --- a/apps/android/scripts/perf-startup-hotspots.sh +++ b/apps/android/scripts/perf-startup-hotspots.sh @@ -99,7 +99,7 @@ symbols_csv="$tmp_dir/symbols.csv" children_txt="$tmp_dir/children.txt" cd "$ANDROID_DIR" -./gradlew :app:installDebug --console=plain >"$tmp_dir/install.log" 2>&1 +./gradlew :app:installPlayDebug --console=plain >"$tmp_dir/install.log" 2>&1 if ! uv run --no-project python3 "$app_profiler" \ -p "$PACKAGE" \ diff --git a/apps/android/wear-shared/src/main/java/ai/openclaw/wear/shared/WearProtocol.kt b/apps/android/wear-shared/src/main/java/ai/openclaw/wear/shared/WearProtocol.kt index 369c9e8a2a00..3a3bd714cd25 100644 --- a/apps/android/wear-shared/src/main/java/ai/openclaw/wear/shared/WearProtocol.kt +++ b/apps/android/wear-shared/src/main/java/ai/openclaw/wear/shared/WearProtocol.kt @@ -12,13 +12,15 @@ import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.jsonObject import java.nio.charset.CharacterCodingException +import java.security.MessageDigest object WearProtocol { const val VERSION = 1 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 REALTIME_AUDIO_CHANNEL_PATH = "/openclaw/wear/v1/realtime/audio" + const val LEGACY_REALTIME_AUDIO_CHANNEL_PATH = "/openclaw/wear/v1/realtime/audio" + const val REALTIME_AUDIO_CHANNEL_PATH_PREFIX = "/openclaw/wear/v1/realtime/audio/" const val PHONE_CAPABILITY = "openclaw_phone_proxy_v1" const val WATCH_CAPABILITY = "openclaw_wear_companion_v1" @@ -28,9 +30,42 @@ object WearProtocol { const val MAX_REALTIME_AUDIO_FRAME_BYTES = 8 * 1024 const val REALTIME_AUDIO_SAMPLE_RATE_HZ = 24_000 const val REALTIME_AUDIO_FRAME_MILLIS = 20 + const val RPC_REQUEST_TIMEOUT_MILLIS = 10_000L + + // The Watch opens the audio channel before sending talk.start. Keep the + // pending phone-side channel through that RPC deadline plus setup margin. + const val REALTIME_AUDIO_PENDING_CHANNEL_TIMEOUT_MILLIS = RPC_REQUEST_TIMEOUT_MILLIS + 5_000L // Bound recursive JSON parsing at the untrusted Data Layer boundary. const val MAX_JSON_DEPTH = 32 + + fun realtimeAudioChannelPath(attemptId: String): String { + require(attemptId.isNotBlank()) + val digest = MessageDigest.getInstance("SHA-256").digest(attemptId.encodeToByteArray()) + return buildString(REALTIME_AUDIO_CHANNEL_PATH_PREFIX.length + digest.size * 2) { + append(REALTIME_AUDIO_CHANNEL_PATH_PREFIX) + digest.forEach { byte -> + val value = byte.toInt() and 0xff + append(LOWER_HEX[value ushr 4]) + append(LOWER_HEX[value and 0x0f]) + } + } + } + + fun isRealtimeAudioChannelPath(path: String): Boolean { + if (path == LEGACY_REALTIME_AUDIO_CHANNEL_PATH) return true + return isAttemptScopedRealtimeAudioChannelPath(path) + } + + fun isAttemptScopedRealtimeAudioChannelPath(path: String): Boolean { + if (!path.startsWith(REALTIME_AUDIO_CHANNEL_PATH_PREFIX)) return false + val token = path.substring(REALTIME_AUDIO_CHANNEL_PATH_PREFIX.length) + return token.length == REALTIME_AUDIO_ATTEMPT_TOKEN_CHARS && + token.all { char -> char in '0'..'9' || char in 'a'..'f' } + } + + private const val REALTIME_AUDIO_ATTEMPT_TOKEN_CHARS = 64 + private const val LOWER_HEX = "0123456789abcdef" } enum class WearProxyCapability( @@ -40,6 +75,7 @@ enum class WearProxyCapability( GatewayControls(wireValue = "gateway-controls"), ModelControls(wireValue = "model-controls"), SessionSelectionLookup(wireValue = "session-selection-lookup"), + AttemptScopedRealtimeAudio(wireValue = "attempt-scoped-realtime-audio"), ; companion object { diff --git a/apps/android/wear-shared/src/test/java/ai/openclaw/wear/shared/WearProtocolTest.kt b/apps/android/wear-shared/src/test/java/ai/openclaw/wear/shared/WearProtocolTest.kt index 82a36250a2ea..50bb5e47720c 100644 --- a/apps/android/wear-shared/src/test/java/ai/openclaw/wear/shared/WearProtocolTest.kt +++ b/apps/android/wear-shared/src/test/java/ai/openclaw/wear/shared/WearProtocolTest.kt @@ -10,7 +10,9 @@ import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue import org.junit.Test class WearProtocolTest { @@ -103,7 +105,31 @@ 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/wear/v1/realtime/audio", WearProtocol.REALTIME_AUDIO_CHANNEL_PATH) + assertEquals(10_000L, WearProtocol.RPC_REQUEST_TIMEOUT_MILLIS) + assertEquals(15_000L, WearProtocol.REALTIME_AUDIO_PENDING_CHANNEL_TIMEOUT_MILLIS) + assertTrue( + WearProtocol.REALTIME_AUDIO_PENDING_CHANNEL_TIMEOUT_MILLIS > + WearProtocol.RPC_REQUEST_TIMEOUT_MILLIS, + ) + val realtimePath = WearProtocol.realtimeAudioChannelPath("attempt-7") + assertEquals( + "/openclaw/wear/v1/realtime/audio", + WearProtocol.LEGACY_REALTIME_AUDIO_CHANNEL_PATH, + ) + assertEquals( + "/openclaw/wear/v1/realtime/audio/9804dc90c374fd8e83c9b95a75611f9bec6e0c6ecdcbed5319d6491208417521", + realtimePath, + ) + assertEquals(realtimePath, WearProtocol.realtimeAudioChannelPath("attempt-7")) + assertTrue(WearProtocol.isRealtimeAudioChannelPath(realtimePath)) + assertTrue(WearProtocol.isAttemptScopedRealtimeAudioChannelPath(realtimePath)) + assertTrue(WearProtocol.isRealtimeAudioChannelPath(WearProtocol.LEGACY_REALTIME_AUDIO_CHANNEL_PATH)) + assertFalse( + WearProtocol.isAttemptScopedRealtimeAudioChannelPath( + WearProtocol.LEGACY_REALTIME_AUDIO_CHANNEL_PATH, + ), + ) + assertFalse(WearProtocol.isRealtimeAudioChannelPath("$realtimePath/extra")) assertEquals("openclaw_phone_proxy_v1", WearProtocol.PHONE_CAPABILITY) assertEquals("openclaw_wear_companion_v1", WearProtocol.WATCH_CAPABILITY) assertEquals("gateway_offline", WearConnectionFailure.GatewayOffline.wireValue) @@ -117,6 +143,10 @@ class WearProtocolTest { assertEquals("gateway-controls", WearProxyCapability.GatewayControls.wireValue) assertEquals("model-controls", WearProxyCapability.ModelControls.wireValue) assertEquals("session-selection-lookup", WearProxyCapability.SessionSelectionLookup.wireValue) + assertEquals( + "attempt-scoped-realtime-audio", + WearProxyCapability.AttemptScopedRealtimeAudio.wireValue, + ) assertEquals(WearProxyCapability.AgentControls, WearProxyCapability.fromWireValue("agent-controls")) assertEquals(null, WearProxyCapability.fromWireValue("future-capability")) } diff --git a/apps/android/wear/src/main/java/ai/openclaw/wear/WearGatewayRepository.kt b/apps/android/wear/src/main/java/ai/openclaw/wear/WearGatewayRepository.kt index af3ceedf2ed8..11b2bcce1e6e 100644 --- a/apps/android/wear/src/main/java/ai/openclaw/wear/WearGatewayRepository.kt +++ b/apps/android/wear/src/main/java/ai/openclaw/wear/WearGatewayRepository.kt @@ -377,6 +377,7 @@ internal class WearGatewayRepository( attemptId: String, language: String?, phoneNodeId: String, + attemptScopedAudio: Boolean, ): WearRealtimeTalkSnapshot { val response = requester.request( @@ -385,6 +386,7 @@ internal class WearGatewayRepository( put("sessionKey", sessionKey) put("attemptId", attemptId) language?.let { put("language", it) } + if (attemptScopedAudio) put("attemptScopedAudio", true) }, phoneNodeId, requirePreferredNode = true, diff --git a/apps/android/wear/src/main/java/ai/openclaw/wear/WearProxyClient.kt b/apps/android/wear/src/main/java/ai/openclaw/wear/WearProxyClient.kt index 3cff872c752c..a199991ba67d 100644 --- a/apps/android/wear/src/main/java/ai/openclaw/wear/WearProxyClient.kt +++ b/apps/android/wear/src/main/java/ai/openclaw/wear/WearProxyClient.kt @@ -98,7 +98,7 @@ internal class WearProxyClient private constructor( ): WearRpcResult { var attemptedPreferredPhone: PreferredPhoneRegistration? = null val result = - withTimeoutOrNull(REQUEST_TIMEOUT_MS) { + withTimeoutOrNull(WearProtocol.RPC_REQUEST_TIMEOUT_MILLIS) { requestBeforeDeadline(method, params, expectedNodeId, requirePreferredNode) { registration -> attemptedPreferredPhone = registration } @@ -337,7 +337,6 @@ internal class WearProxyClient private constructor( ) companion object { - private const val REQUEST_TIMEOUT_MS = 10_000L private const val MAX_BUFFERED_EVENTS = 64 fun create(context: Context): WearProxyClient { diff --git a/apps/android/wear/src/main/java/ai/openclaw/wear/WearRealtimeTalkClient.kt b/apps/android/wear/src/main/java/ai/openclaw/wear/WearRealtimeTalkClient.kt index 49493cc92b0b..0561dd9f0a24 100644 --- a/apps/android/wear/src/main/java/ai/openclaw/wear/WearRealtimeTalkClient.kt +++ b/apps/android/wear/src/main/java/ai/openclaw/wear/WearRealtimeTalkClient.kt @@ -1,6 +1,7 @@ package ai.openclaw.wear import ai.openclaw.wear.shared.WearProtocol +import ai.openclaw.wear.shared.WearProxyCapability import ai.openclaw.wear.shared.WearRealtimeAudioFrameType import ai.openclaw.wear.shared.WearRealtimeAudioFraming import ai.openclaw.wear.shared.WearRealtimeTalkSnapshot @@ -16,6 +17,7 @@ import com.google.android.gms.wearable.ChannelClient import com.google.android.gms.wearable.Wearable import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable @@ -37,6 +39,7 @@ import kotlinx.coroutines.yield import java.io.InputStream import java.io.OutputStream import java.util.Locale +import java.util.concurrent.atomic.AtomicLong import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException import kotlin.math.ceil @@ -51,7 +54,10 @@ internal class WearRealtimeTalkClient( private val lifecycleLock = Mutex() private val channelLock = Mutex() private val audioLock = Any() - private val audioFocus = WearAudioFocusController(context) { scope.launch { clearOutput(resumeCapture = true) } } + private val audioFocus = + WearAudioFocusController(context) { + activeAttempt?.let { attempt -> scope.launch { clearOutput(attempt, resumeCapture = true) } } + } private val _isCapturing = MutableStateFlow(false) val isCapturing: StateFlow = _isCapturing private val _isPlaying = MutableStateFlow(false) @@ -61,14 +67,11 @@ internal class WearRealtimeTalkClient( private val _channelFailed = MutableStateFlow(false) val channelFailed: StateFlow = _channelFailed - @Volatile private var activeNodeId: String? = null + private val attemptGeneration = AtomicLong() - @Volatile private var activeAttemptId: String? = null + @Volatile private var activeAttempt: ActiveAttempt? = null @Volatile private var audioRecord: AudioRecord? = null - private var channel: ChannelClient.Channel? = null - private var channelInput: InputStream? = null - private var channelOutput: OutputStream? = null private var captureJob: Job? = null private var readJob: Job? = null private var playbackIdleJob: Job? = null @@ -78,22 +81,32 @@ internal class WearRealtimeTalkClient( private var audioTrack: AudioTrack? = null private var playbackEndsAtMillis = 0L - private data class ChannelResources( + internal data class ChannelResources( val channel: ChannelClient.Channel, val input: InputStream, val output: OutputStream, ) + internal data class ActiveAttempt( + val nodeId: String, + val attemptId: String, + val generation: Long, + val resources: ChannelResources, + ) + suspend fun start( session: WearSession, attemptId: String, + capabilities: Set, ): WearRealtimeTalkSnapshot = lifecycleLock.withLock { - _channelFailed.value = false val nodeId = session.phoneNodeId + val attemptScopedAudio = WearProxyCapability.AttemptScopedRealtimeAudio in capabilities + var resources: ChannelResources? = null var channelOpened = false + var activatedAttempt: ActiveAttempt? = null try { - openChannel(nodeId) + resources = openChannel(nodeId, attemptId, attemptScopedAudio) channelOpened = true val language = Locale @@ -101,14 +114,30 @@ internal class WearRealtimeTalkClient( .language .lowercase(Locale.ROOT) .takeIf { value -> value.length == ISO_639_1_LANGUAGE_LENGTH } - val snapshot = repository.startRealtimeTalk(session.key, attemptId, language, nodeId) - activeNodeId = nodeId - activeAttemptId = attemptId - startReader(nodeId, attemptId) - startCapture(nodeId) + val snapshot = + repository.startRealtimeTalk( + sessionKey = session.key, + attemptId = attemptId, + language = language, + phoneNodeId = nodeId, + attemptScopedAudio = attemptScopedAudio, + ) + val attempt = + ActiveAttempt( + nodeId = nodeId, + attemptId = attemptId, + generation = attemptGeneration.incrementAndGet(), + resources = checkNotNull(resources), + ) + activate(attempt) + activatedAttempt = attempt + resources = null + startReader(attempt) + startCapture(attempt) snapshot } catch (err: Throwable) { - closeLocal() + closeChannel(resources) + activatedAttempt?.let(::closeLocal) if (channelOpened) { // Finish ambiguous-start cleanup before another attempt can acquire // the lifecycle lock and create a replacement relay for this Watch. @@ -120,16 +149,15 @@ internal class WearRealtimeTalkClient( suspend fun stop(): WearRealtimeTalkSnapshot = lifecycleLock.withLock { - val nodeId = activeNodeId - val attemptId = activeAttemptId + val attempt = activeAttempt try { - if (nodeId == null || attemptId == null) { + if (attempt == null) { WearRealtimeTalkSnapshot() } else { - repository.stopRealtimeTalk(nodeId, attemptId) + repository.stopRealtimeTalk(attempt.nodeId, attempt.attemptId) } } finally { - closeLocal() + closeLocal(attempt) } } @@ -142,65 +170,79 @@ internal class WearRealtimeTalkClient( closeLocal() } - private suspend fun openChannel(nodeId: String) { + private suspend fun openChannel( + nodeId: String, + attemptId: String, + attemptScopedAudio: Boolean, + ): ChannelResources { var lastError: Throwable? = null repeat(CHANNEL_OPEN_ATTEMPTS) { attempt -> var opened: ChannelClient.Channel? = null var input: InputStream? = null var output: OutputStream? = null try { - opened = channelClient.openChannel(nodeId, WearProtocol.REALTIME_AUDIO_CHANNEL_PATH).awaitRealtimeTask() + opened = + channelClient + .openChannel(nodeId, wearRealtimeAudioChannelPath(attemptId, attemptScopedAudio)) + .awaitRealtimeTask() input = channelClient.getInputStream(opened).awaitRealtimeTask() output = channelClient.getOutputStream(opened).awaitRealtimeTask() - installChannel(opened, input, output) - return + return ChannelResources(opened, input, output) } catch (err: Throwable) { + withContext(NonCancellable) { + input.closeQuietly() + output.closeQuietly() + opened?.let { channel -> runCatching { channelClient.close(channel).awaitRealtimeTask() } } + } if (err is CancellationException) throw err lastError = err - input.closeQuietly() - output.closeQuietly() - opened?.let { channel -> runCatching { channelClient.close(channel).awaitRealtimeTask() } } if (attempt + 1 < CHANNEL_OPEN_ATTEMPTS) delay(CHANNEL_RETRY_DELAY_MILLIS) } } throw WearProxyException("phone_unavailable", lastError?.message ?: "Unable to open Watch audio channel") } - private fun startReader( - nodeId: String, - attemptId: String, - ) { - val input = checkNotNull(channelInput) - readJob?.cancel() - readJob = - scope.launch { + private fun startReader(attempt: ActiveAttempt) { + val reader = + scope.launch(start = CoroutineStart.LAZY) { try { - while (activeNodeId == nodeId && activeAttemptId == attemptId) { - val frame = WearRealtimeAudioFraming.read(input) ?: break - if (activeNodeId != nodeId || activeAttemptId != attemptId) break + while (isCurrent(attempt)) { + val frame = WearRealtimeAudioFraming.read(attempt.resources.input) ?: break + if (!isCurrent(attempt)) break when (frame.type) { - WearRealtimeAudioFrameType.OUTPUT_PCM -> writeOutput(frame.payload) - WearRealtimeAudioFrameType.CLEAR_OUTPUT -> clearOutput(resumeCapture = true) + WearRealtimeAudioFrameType.OUTPUT_PCM -> writeOutput(attempt, frame.payload) + WearRealtimeAudioFrameType.CLEAR_OUTPUT -> clearOutput(attempt, resumeCapture = true) WearRealtimeAudioFrameType.INPUT_PCM -> error("Phone sent an invalid Watch audio frame") } } - if (activeNodeId == nodeId) handleChannelFailure(nodeId) + handleChannelFailure(attempt) } catch (err: CancellationException) { throw err } catch (_: Throwable) { - if (activeNodeId == nodeId) handleChannelFailure(nodeId) + handleChannelFailure(attempt) } } + val installed = + synchronized(audioLock) { + if (!isCurrent(attempt)) { + false + } else { + readJob?.cancel() + readJob = reader + true + } + } + if (installed) reader.start() else reader.cancel() } @SuppressLint("MissingPermission") - private fun startCapture(nodeId: String) { - synchronized(audioLock) { startCaptureLocked(nodeId) } + private fun startCapture(attempt: ActiveAttempt) { + synchronized(audioLock) { startCaptureLocked(attempt) } } @SuppressLint("MissingPermission") - private fun startCaptureLocked(nodeId: String) { - if (_isCapturing.value || _isPlaying.value || activeNodeId != nodeId) return + private fun startCaptureLocked(attempt: ActiveAttempt) { + if (_isCapturing.value || _isPlaying.value || !isCurrent(attempt)) return val frameBytes = WearProtocol.REALTIME_AUDIO_SAMPLE_RATE_HZ * PCM_16_BYTES * WearProtocol.REALTIME_AUDIO_FRAME_MILLIS / 1_000 @@ -236,7 +278,7 @@ internal class WearRealtimeTalkClient( currentCoroutineContext().isActive && _isCapturing.value && audioRecord === recorder && - activeNodeId == nodeId + isCurrent(attempt) ) { val read = recorder.read(buffer, 0, buffer.size) val evenBytes = read - (read and 1) @@ -246,12 +288,12 @@ internal class WearRealtimeTalkClient( yield() continue } - sendInputFrame(buffer.copyOf(evenBytes)) + sendInputFrame(attempt, buffer.copyOf(evenBytes)) } } catch (err: CancellationException) { throw err } catch (_: Throwable) { - if (activeNodeId == nodeId) handleChannelFailure(nodeId) + handleChannelFailure(attempt) } finally { runCatching { recorder.stop() } runCatching { recorder.release() } @@ -260,18 +302,24 @@ internal class WearRealtimeTalkClient( } } - private suspend fun sendInputFrame(payload: ByteArray) { + private suspend fun sendInputFrame( + attempt: ActiveAttempt, + payload: ByteArray, + ) { channelLock.withLock { - val output = channelOutput ?: error("Wear audio channel is closed") + if (!isCurrent(attempt)) return withContext(Dispatchers.IO) { - WearRealtimeAudioFraming.write(output, WearRealtimeAudioFrameType.INPUT_PCM, payload) + WearRealtimeAudioFraming.write(attempt.resources.output, WearRealtimeAudioFrameType.INPUT_PCM, payload) } } } - private fun writeOutput(bytes: ByteArray) { + private fun writeOutput( + attempt: ActiveAttempt, + bytes: ByteArray, + ) { synchronized(audioLock) { - if (activeNodeId == null) return + if (!isCurrent(attempt)) return if (!_isPlaying.value) { pauseCaptureLocked() check(audioFocus.request()) @@ -296,7 +344,7 @@ internal class WearRealtimeTalkClient( .toLong() .coerceAtLeast(1L) playbackEndsAtMillis = maxOf(SystemClock.elapsedRealtime(), playbackEndsAtMillis) + durationMillis - schedulePlaybackIdle() + schedulePlaybackIdle(attempt) } } @@ -346,7 +394,7 @@ internal class WearRealtimeTalkClient( .also { check(it.state == AudioTrack.STATE_INITIALIZED) } } - private fun schedulePlaybackIdle() { + private fun schedulePlaybackIdle(attempt: ActiveAttempt) { playbackIdleJob?.cancel() val scheduledPlaybackEndMillis = playbackEndsAtMillis val finalFrameDurationMillis = mouthLevelAccumulator.pendingFrameDurationMillis() @@ -358,7 +406,7 @@ internal class WearRealtimeTalkClient( // discards it only when the matching AudioTrack tail is also discarded. while (SystemClock.elapsedRealtime() < finalFrameStartsAtMillis) delay(MOUTH_FRAME_MILLIS.toLong()) synchronized(audioLock) { - if (playbackEndsAtMillis == scheduledPlaybackEndMillis) { + if (isCurrent(attempt) && playbackEndsAtMillis == scheduledPlaybackEndMillis) { mouthLevelAccumulator.flush().forEach { level -> mouthTimelineLocked().trySend(level) } } } @@ -367,20 +415,29 @@ internal class WearRealtimeTalkClient( delay(PLAYBACK_DRAIN_GRACE_MILLIS) synchronized(audioLock) { if ( + isCurrent(attempt) && playbackEndsAtMillis == scheduledPlaybackEndMillis && SystemClock.elapsedRealtime() >= scheduledPlaybackEndMillis ) { - clearOutputLocked(resumeCapture = true) + clearOutputLocked(attempt, resumeCapture = true) } } } } - private fun clearOutput(resumeCapture: Boolean) { - synchronized(audioLock) { clearOutputLocked(resumeCapture) } + private fun clearOutput( + attempt: ActiveAttempt, + resumeCapture: Boolean, + ) { + synchronized(audioLock) { + if (isCurrent(attempt)) clearOutputLocked(attempt, resumeCapture) + } } - private fun clearOutputLocked(resumeCapture: Boolean) { + private fun clearOutputLocked( + attempt: ActiveAttempt?, + resumeCapture: Boolean, + ) { playbackIdleJob?.cancel() playbackIdleJob = null playbackEndsAtMillis = 0L @@ -400,7 +457,9 @@ internal class WearRealtimeTalkClient( audioTrack = null _isPlaying.value = false audioFocus.abandon() - if (resumeCapture) activeNodeId?.let { nodeId -> runCatching { startCaptureLocked(nodeId) } } + if (resumeCapture && attempt != null && isCurrent(attempt)) { + runCatching { startCaptureLocked(attempt) } + } } private fun pauseCaptureLocked() { @@ -413,56 +472,37 @@ internal class WearRealtimeTalkClient( runCatching { recorder?.release() } } - private fun handleChannelFailure(nodeId: String) { - val attemptId = activeAttemptId ?: return - _channelFailed.value = true - closeLocal() - scope.launch { runCatching { repository.stopRealtimeTalk(nodeId, attemptId) } } + private fun handleChannelFailure(attempt: ActiveAttempt) { + if (!closeLocal(attempt, failed = true)) return + scope.launch { runCatching { repository.stopRealtimeTalk(attempt.nodeId, attempt.attemptId) } } } - private fun closeLocal() { - activeNodeId = null - activeAttemptId = null + private fun activate(attempt: ActiveAttempt) { synchronized(audioLock) { - pauseCaptureLocked() - clearOutputLocked(resumeCapture = false) + check(activeAttempt == null) + _channelFailed.value = false + activeAttempt = attempt } - readJob?.cancel() - readJob = null - val resources = detachChannel() - scope.launch { closeChannel(resources) } } - private suspend fun closeChannel() { - closeChannel(detachChannel()) - } - - @Synchronized - private fun installChannel( - opened: ChannelClient.Channel, - input: InputStream, - output: OutputStream, - ) { - check(channel == null) - channel = opened - channelInput = input - channelOutput = output - } - - @Synchronized - private fun detachChannel(): ChannelResources? { - val current = channel ?: return null - val input = channelInput - val output = channelOutput - channel = null - channelInput = null - channelOutput = null - if (input == null || output == null) { - input.closeQuietly() - output.closeQuietly() - return null - } - return ChannelResources(current, input, output) + private fun closeLocal( + expected: ActiveAttempt? = activeAttempt, + failed: Boolean = false, + ): Boolean { + val attempt = + synchronized(audioLock) { + val current = activeAttempt ?: return false + if (expected != null && current.generation != expected.generation) return false + activeAttempt = null + if (failed) _channelFailed.value = true + readJob?.cancel() + readJob = null + pauseCaptureLocked() + clearOutputLocked(attempt = null, resumeCapture = false) + current + } + scope.launch { closeChannel(attempt.resources) } + return true } private suspend fun closeChannel(resources: ChannelResources?) { @@ -472,6 +512,8 @@ internal class WearRealtimeTalkClient( runCatching { channelClient.close(resources.channel).awaitRealtimeTask() } } + private fun isCurrent(attempt: ActiveAttempt): Boolean = activeAttempt?.generation == attempt.generation + private companion object { const val CHANNEL_OPEN_ATTEMPTS = 2 const val CHANNEL_RETRY_DELAY_MILLIS = 250L @@ -482,6 +524,17 @@ internal class WearRealtimeTalkClient( } } +internal fun wearRealtimeAudioChannelPath( + attemptId: String, + attemptScopedAudio: Boolean, +): String = + if (attemptScopedAudio) { + WearProtocol.realtimeAudioChannelPath(attemptId) + } else { + // v2026.7.2 shipped the fixed path. Keep it for staggered phone/Watch updates. + WearProtocol.LEGACY_REALTIME_AUDIO_CHANNEL_PATH + } + internal fun pcm16LeMouthLevels( pcm: ByteArray, sampleRateHz: Int = WearProtocol.REALTIME_AUDIO_SAMPLE_RATE_HZ, diff --git a/apps/android/wear/src/main/java/ai/openclaw/wear/WearViewModel.kt b/apps/android/wear/src/main/java/ai/openclaw/wear/WearViewModel.kt index a64f42028a6c..bdbb8e81ca90 100644 --- a/apps/android/wear/src/main/java/ai/openclaw/wear/WearViewModel.kt +++ b/apps/android/wear/src/main/java/ai/openclaw/wear/WearViewModel.kt @@ -184,6 +184,7 @@ internal class WearViewModel( private val resyncEventBuffer = WearEventResyncBuffer() private val historyLoadTracker = WearHistoryLoadTracker() private val sendAttemptTracker = WearSendAttemptTracker() + private val controlBusyOwner = WearControlBusyOwner() private var loadJob: Job? = null private var phoneRouteGeneration = 0L @@ -290,15 +291,22 @@ internal class WearViewModel( } fun startRealtimeTalk() { - val selectedSession = mutableState.value.selectedSession ?: return - if (mutableState.value.talkBusy || mutableState.value.realtimeTalk.active) return + val current = mutableState.value + val selectedSession = current.selectedSession ?: return + if (current.talkBusy || current.realtimeTalk.active) return + val capabilities = current.proxyCapabilities val attemptId = "wear-${UUID.randomUUID()}" talkAttemptId = attemptId val startJob = viewModelScope.launch(start = CoroutineStart.LAZY) { mutableState.update { it.copy(talkBusy = true, failure = null) } try { - val snapshot = realtimeTalkClient.start(selectedSession, attemptId) + val snapshot = + realtimeTalkClient.start( + selectedSession, + attemptId, + capabilities, + ) if (talkAttemptId != attemptId) return@launch mutableState.update { it.copy(realtimeTalk = snapshot, talkBusy = false) } } catch (err: CancellationException) { @@ -397,6 +405,7 @@ internal class WearViewModel( fun selectAgent(agentId: String) { val current = mutableState.value val phoneNodeId = current.phoneNodeId ?: return + val routeGeneration = phoneRouteGeneration if ( current.controlBusy || current.talkBusy || @@ -408,18 +417,26 @@ internal class WearViewModel( ) { return } + val controlAction = beginControlAction(phoneNodeId, routeGeneration) ?: return viewModelScope.launch { - mutableState.update { it.copy(controlBusy = true, failure = null) } try { + if (!isCurrentControlRoute(phoneNodeId, routeGeneration)) return@launch repository.selectAgent(agentId, phoneNodeId, current.proxyCapabilities) - mutableState.update { it.switchAgentContext(agentId) } + if (!isCurrentControlRoute(phoneNodeId, routeGeneration)) return@launch + mutableState.update { state -> + if (isCurrentControlRoute(phoneNodeId, routeGeneration, state)) { + state.switchAgentContext(agentId) + } else { + state + } + } refresh() } catch (err: CancellationException) { throw err } catch (err: Throwable) { - recordFailure(err, loading = false) + recordFailureForControlRoute(err, phoneNodeId, routeGeneration, loading = false) } finally { - mutableState.update { it.copy(controlBusy = false) } + finishControlAction(controlAction) } } } @@ -428,6 +445,7 @@ internal class WearViewModel( val current = mutableState.value val phoneNodeId = current.phoneNodeId ?: return val session = current.selectedSession ?: return + val routeGeneration = phoneRouteGeneration if ( current.controlBusy || current.talkBusy || @@ -440,9 +458,10 @@ internal class WearViewModel( ) { return } + val controlAction = beginControlAction(phoneNodeId, routeGeneration) ?: return viewModelScope.launch { - mutableState.update { it.copy(controlBusy = true, failure = null) } try { + if (!isCurrentControlRoute(phoneNodeId, routeGeneration)) return@launch cancelModelLoad() val responseRequest = eventSequenceTracker.beginResponseRequest() val selection = @@ -452,6 +471,7 @@ internal class WearViewModel( phoneNodeId = phoneNodeId, capabilities = current.proxyCapabilities, ) + if (!isCurrentControlRoute(phoneNodeId, routeGeneration)) return@launch val currentSession = mutableState.value.selectedSession ?: return@launch if (!wearSessionRequestIsCurrent(session, currentSession, selection.phoneNodeId)) return@launch if ( @@ -468,17 +488,20 @@ internal class WearViewModel( val acceptedModelRef = selection.selectedModelRef val updatedSession = currentSession.copy(modelRef = acceptedModelRef) mutableState.update { state -> + if (!isCurrentControlRoute(phoneNodeId, routeGeneration, state)) return@update state val selectedSession = state.selectedSession ?: return@update state if (!wearSessionRequestIsCurrent(session, selectedSession, selection.phoneNodeId)) return@update state state.switchModelContext(acceptedModelRef) } - loadModels(updatedSession) + if (isCurrentControlRoute(phoneNodeId, routeGeneration)) { + loadModels(updatedSession) + } } catch (err: CancellationException) { throw err } catch (err: Throwable) { - recordFailure(err) + recordFailureForControlRoute(err, phoneNodeId, routeGeneration) } finally { - mutableState.update { it.copy(controlBusy = false) } + finishControlAction(controlAction) } } } @@ -486,6 +509,7 @@ internal class WearViewModel( fun setGatewayEnabled(enabled: Boolean) { val current = mutableState.value val phoneNodeId = current.phoneNodeId ?: return + val routeGeneration = phoneRouteGeneration if ( current.controlBusy || current.connected == enabled || @@ -493,9 +517,10 @@ internal class WearViewModel( ) { return } + val controlAction = beginControlAction(phoneNodeId, routeGeneration) ?: return viewModelScope.launch { - mutableState.update { it.copy(controlBusy = true, failure = null) } try { + if (!isCurrentControlRoute(phoneNodeId, routeGeneration)) return@launch if (!enabled) { talkStartJob?.cancel() talkStartJob = null @@ -503,28 +528,21 @@ internal class WearViewModel( realtimeTalkClient.disconnectLocal() } val status = repository.setGatewayEnabled(enabled, phoneNodeId, current.proxyCapabilities) - mutableState.update { - it.copy( - connected = status.connected, - phoneNodeId = status.phoneNodeId, - activeAgentId = status.activeAgentId ?: it.activeAgentId, - selectedModelRef = - wearSelectedModelRef( - it.selectedSession?.key, - status.activeSessionKey, - status.selectedModelRef ?: it.selectedModelRef, - ), - proxyCapabilities = status.capabilities, - realtimeTalk = if (enabled) it.realtimeTalk else WearRealtimeTalkSnapshot(), - ) + if (!isCurrentControlRoute(phoneNodeId, routeGeneration)) return@launch + mutableState.update { state -> + if (!isCurrentControlRoute(phoneNodeId, routeGeneration, state)) { + state + } else { + applyWearGatewayControlStatus(state, status, enabled) + } } refresh() } catch (err: CancellationException) { throw err } catch (err: Throwable) { - recordFailure(err, loading = false) + recordFailureForControlRoute(err, phoneNodeId, routeGeneration, loading = false) } finally { - mutableState.update { it.copy(controlBusy = false) } + finishControlAction(controlAction) } } } @@ -915,6 +933,7 @@ internal class WearViewModel( private fun resetForPhoneRouteChange() { phoneRouteGeneration += 1 + controlBusyOwner.reset() mutableState.update(WearUiState::resetForPhoneChange) } @@ -1031,6 +1050,40 @@ internal class WearViewModel( currentRouteGeneration = phoneRouteGeneration, ) + private fun isCurrentControlRoute( + phoneNodeId: String, + routeGeneration: Long, + state: WearUiState = mutableState.value, + ): Boolean = + wearControlRouteIsCurrent( + requestedPhoneNodeId = phoneNodeId, + currentState = state, + requestedRouteGeneration = routeGeneration, + currentRouteGeneration = phoneRouteGeneration, + ) + + private fun beginControlAction( + phoneNodeId: String, + routeGeneration: Long, + ): Long? { + val owner = controlBusyOwner.claim() ?: return null + while (true) { + val state = mutableState.value + if (state.controlBusy || !isCurrentControlRoute(phoneNodeId, routeGeneration, state)) { + controlBusyOwner.release(owner) + return null + } + if (mutableState.compareAndSet(state, state.copy(controlBusy = true, failure = null))) { + return owner + } + } + } + + private fun finishControlAction(owner: Long) { + if (!controlBusyOwner.release(owner)) return + mutableState.update { state -> state.copy(controlBusy = false) } + } + private fun recordFailure( error: Throwable, loading: Boolean = mutableState.value.loading, @@ -1067,6 +1120,16 @@ internal class WearViewModel( } } + private fun recordFailureForControlRoute( + error: Throwable, + phoneNodeId: String, + routeGeneration: Long, + loading: Boolean = mutableState.value.loading, + ) { + if (!isCurrentControlRoute(phoneNodeId, routeGeneration)) return + recordFailure(error, loading) + } + override fun onCleared() { modelLoadJob?.cancel() talkStartJob?.cancel() @@ -1130,6 +1193,55 @@ internal fun wearSessionActionIsCurrent( requestedSession.phoneNodeId, ) +internal fun wearControlRouteIsCurrent( + requestedPhoneNodeId: String, + currentState: WearUiState, + requestedRouteGeneration: Long, + currentRouteGeneration: Long, +): Boolean = + requestedRouteGeneration == currentRouteGeneration && + currentState.phoneNodeId == requestedPhoneNodeId + +internal class WearControlBusyOwner { + private var nextOwner = 0L + private var activeOwner: Long? = null + + fun claim(): Long? { + if (activeOwner != null) return null + nextOwner += 1 + return nextOwner.also { owner -> activeOwner = owner } + } + + fun release(owner: Long): Boolean { + if (activeOwner != owner) return false + activeOwner = null + return true + } + + fun reset() { + activeOwner = null + } +} + +internal fun applyWearGatewayControlStatus( + state: WearUiState, + status: WearProxyStatus, + enabled: Boolean, +): WearUiState = + state.copy( + connected = status.connected, + phoneNodeId = status.phoneNodeId, + activeAgentId = status.activeAgentId ?: state.activeAgentId, + selectedModelRef = + wearSelectedModelRef( + state.selectedSession?.key, + status.activeSessionKey, + status.selectedModelRef ?: state.selectedModelRef, + ), + proxyCapabilities = status.capabilities, + realtimeTalk = if (enabled) state.realtimeTalk else WearRealtimeTalkSnapshot(), + ) + internal fun wearSnapshotSourcesMatch( firstPhoneNodeId: String, firstStreamId: String?, diff --git a/apps/android/wear/src/test/java/ai/openclaw/wear/WearGatewayRepositoryTest.kt b/apps/android/wear/src/test/java/ai/openclaw/wear/WearGatewayRepositoryTest.kt index b6b4764aeec6..7c203e060fe0 100644 --- a/apps/android/wear/src/test/java/ai/openclaw/wear/WearGatewayRepositoryTest.kt +++ b/apps/android/wear/src/test/java/ai/openclaw/wear/WearGatewayRepositoryTest.kt @@ -86,7 +86,7 @@ class WearGatewayRepositoryTest { WearRpcMethod.AgentsSelect -> JsonObject(emptyMap()) WearRpcMethod.GatewayDisconnect -> json.parseToJsonElement( - """{"connected":false,"status":"Offline","activeAgentId":"main","selectedModelRef":"openai/gpt-test","capabilities":["agent-controls","gateway-controls","model-controls","session-selection-lookup"]}""", + """{"connected":false,"status":"Offline","activeAgentId":"main","selectedModelRef":"openai/gpt-test","capabilities":["agent-controls","gateway-controls","model-controls","session-selection-lookup","attempt-scoped-realtime-audio"]}""", ) else -> error("unexpected $method") } @@ -154,7 +154,7 @@ class WearGatewayRepositoryTest { val requester = RecordingRequester { _, _ -> json.parseToJsonElement( - """{"connected":true,"status":"Connected","capabilities":["agent-controls","future-capability","gateway-controls","model-controls","session-selection-lookup"]}""", + """{"connected":true,"status":"Connected","capabilities":["agent-controls","future-capability","gateway-controls","model-controls","session-selection-lookup","attempt-scoped-realtime-audio"]}""", ) } @@ -292,13 +292,14 @@ class WearGatewayRepositoryTest { attemptId = "attempt-7", language = "de", phoneNodeId = "phone-a", + attemptScopedAudio = true, ) assertTrue(snapshot.active) assertEquals( json .parseToJsonElement( - """{"sessionKey":"agent:main:thread-7","attemptId":"attempt-7","language":"de"}""", + """{"sessionKey":"agent:main:thread-7","attemptId":"attempt-7","language":"de","attemptScopedAudio":true}""", ).jsonObject, requester.calls.single().second, ) @@ -306,6 +307,31 @@ class WearGatewayRepositoryTest { assertTrue(requester.requirePreferredNodes.single()) } + @Test + fun realtimeTalkStartOmitsAttemptScopedAudioForLegacyPhones() = + runTest { + val requester = + RecordingRequester { _, _ -> + json.parseToJsonElement("""{"active":true}""") + } + + WearGatewayRepository(requester).startRealtimeTalk( + sessionKey = "agent:main:thread-7", + attemptId = "attempt-7", + language = null, + phoneNodeId = "phone-a", + attemptScopedAudio = false, + ) + + assertEquals( + json + .parseToJsonElement( + """{"sessionKey":"agent:main:thread-7","attemptId":"attempt-7"}""", + ).jsonObject, + requester.calls.single().second, + ) + } + @Test fun observedFinalMessageSurvivesAnOlderSnapshotWithoutDuplication() { val older = WearChatMessage(id = "m1", role = "assistant", text = "older", timestamp = 1) diff --git a/apps/android/wear/src/test/java/ai/openclaw/wear/WearSessionScopeTest.kt b/apps/android/wear/src/test/java/ai/openclaw/wear/WearSessionScopeTest.kt index 802411554076..0893af798539 100644 --- a/apps/android/wear/src/test/java/ai/openclaw/wear/WearSessionScopeTest.kt +++ b/apps/android/wear/src/test/java/ai/openclaw/wear/WearSessionScopeTest.kt @@ -1,5 +1,6 @@ package ai.openclaw.wear +import ai.openclaw.wear.shared.WearProxyCapability import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -153,6 +154,86 @@ class WearSessionScopeTest { ) } + @Test + fun delayedControlsRequireTheOriginalPhoneRouteGeneration() { + val phoneA = WearUiState(phoneNodeId = "phone-a", controlBusy = true) + + assertTrue( + wearControlRouteIsCurrent( + requestedPhoneNodeId = "phone-a", + currentState = phoneA, + requestedRouteGeneration = 3, + currentRouteGeneration = 3, + ), + ) + assertFalse( + wearControlRouteIsCurrent( + requestedPhoneNodeId = "phone-a", + currentState = WearUiState(phoneNodeId = "phone-b", controlBusy = true), + requestedRouteGeneration = 3, + currentRouteGeneration = 4, + ), + ) + assertFalse( + wearControlRouteIsCurrent( + requestedPhoneNodeId = "phone-a", + currentState = phoneA, + requestedRouteGeneration = 3, + currentRouteGeneration = 5, + ), + ) + } + + @Test + fun staleControlCompletionCannotClearReplacementBusyOwner() { + val owners = WearControlBusyOwner() + val staleOwner = checkNotNull(owners.claim()) + + owners.reset() + val replacementOwner = checkNotNull(owners.claim()) + + assertFalse(owners.release(staleOwner)) + assertTrue(owners.release(replacementOwner)) + } + + @Test + fun abandonedControlActionReleasesItsOwnBusyOwner() { + val owners = WearControlBusyOwner() + val owner = checkNotNull(owners.claim()) + + assertTrue(owners.release(owner)) + assertTrue(owners.claim() != null) + } + + @Test + fun gatewayControlResponseKeepsBusyUntilItsOwnerFinalizes() { + val updated = + applyWearGatewayControlStatus( + state = + WearUiState( + phoneNodeId = "phone-a", + controlBusy = true, + activeAgentId = "agent-a", + ), + status = + WearProxyStatus( + connected = true, + activeAgentId = "agent-b", + activeSessionKey = null, + selectedModelRef = null, + capabilities = setOf(WearProxyCapability.GatewayControls), + eventStreamId = null, + eventSequence = null, + phoneNodeId = "phone-b", + ), + enabled = true, + ) + + assertTrue(updated.controlBusy) + assertEquals("phone-b", updated.phoneNodeId) + assertEquals("agent-b", updated.activeAgentId) + } + @Test fun snapshotResponsesRequireTheSamePhoneAndEventStream() { assertEquals(true, wearSnapshotSourcesMatch("phone-a", "stream-a", "phone-a", "stream-a")) diff --git a/apps/android/wear/src/test/java/ai/openclaw/wear/WearTalkAvatarTest.kt b/apps/android/wear/src/test/java/ai/openclaw/wear/WearTalkAvatarTest.kt index fb92852830c6..a5d8ff8b9a73 100644 --- a/apps/android/wear/src/test/java/ai/openclaw/wear/WearTalkAvatarTest.kt +++ b/apps/android/wear/src/test/java/ai/openclaw/wear/WearTalkAvatarTest.kt @@ -5,6 +5,7 @@ import ai.openclaw.wear.shared.WearRpcMethod import android.animation.ValueAnimator import android.content.Intent import android.os.Looper +import android.os.Parcel import android.os.PowerManager import android.provider.Settings import androidx.activity.ComponentActivity @@ -17,9 +18,13 @@ import androidx.compose.ui.graphics.Color import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry +import com.google.android.gms.wearable.ChannelClient +import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel import kotlinx.serialization.json.JsonObject import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith @@ -30,6 +35,8 @@ import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config import org.robolectric.shadows.ShadowSystemClock import org.robolectric.shadows.ShadowValueAnimator +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream import java.time.Duration @RunWith(RobolectricTestRunner::class) @@ -73,13 +80,19 @@ class WearTalkAvatarTest { ) val client = realtimeTalkClient() val queuedLevels = Channel(Channel.UNLIMITED) - client.setPrivateField("activeNodeId", "watch-a") + val attempt = realtimeAttempt(generation = 1L) + client.setPrivateField("activeAttempt", attempt) client.setPrivateField("mouthFrames", queuedLevels) try { - val writeOutput = WearRealtimeTalkClient::class.java.getDeclaredMethod("writeOutput", ByteArray::class.java) + val writeOutput = + WearRealtimeTalkClient::class.java.getDeclaredMethod( + "writeOutput", + WearRealtimeTalkClient.ActiveAttempt::class.java, + ByteArray::class.java, + ) writeOutput.isAccessible = true - chunks.forEach { chunk -> writeOutput.invoke(client, chunk) } + chunks.forEach { chunk -> writeOutput.invoke(client, attempt, chunk) } awaitPlaybackTeardown(client) val actualLevels = @@ -92,6 +105,47 @@ class WearTalkAvatarTest { } } + @Test + fun staleAttemptCallbacksCannotMutateReplacement() { + val client = realtimeTalkClient() + val stale = realtimeAttempt(generation = 1L) + val replacement = realtimeAttempt(generation = 2L) + + try { + client.invokePrivate("activate", stale) + client.invokePrivate("handleChannelFailure", stale) + assertTrue(client.channelFailed.value) + assertEquals(null, client.privateField("activeAttempt")) + + client.invokePrivate("activate", replacement) + val replacementReader = Job() + client.setPrivateField("readJob", replacementReader) + assertFalse(client.channelFailed.value) + client.invokePrivate("writeOutput", stale, pcm16Le(samplesForFrames(1), sample = 20_000)) + client.invokePrivate("handleChannelFailure", stale) + client.invokePrivate("closeLocal", stale, false) + + assertFalse(client.isPlaying.value) + assertFalse(client.channelFailed.value) + assertTrue(replacementReader.isActive) + assertSame(replacement, client.privateField("activeAttempt")) + } finally { + client.shutdown() + } + } + + @Test + fun realtimeAudioPathUsesNegotiatedAttemptScope() { + assertEquals( + WearProtocol.LEGACY_REALTIME_AUDIO_CHANNEL_PATH, + wearRealtimeAudioChannelPath("attempt-7", attemptScopedAudio = false), + ) + assertEquals( + WearProtocol.realtimeAudioChannelPath("attempt-7"), + wearRealtimeAudioChannelPath("attempt-7", attemptScopedAudio = true), + ) + } + @Test fun mouthEnvelopeUsesFastAttackAndSoftReleaseWithoutOvershoot() { val attack = smoothAvatarMouth(current = 0f, target = 1f, deltaSeconds = 0.02f) @@ -360,6 +414,19 @@ class WearTalkAvatarTest { return WearRealtimeTalkClient(RuntimeEnvironment.getApplication(), WearGatewayRepository(requester)) } + private fun realtimeAttempt(generation: Long): WearRealtimeTalkClient.ActiveAttempt = + WearRealtimeTalkClient.ActiveAttempt( + nodeId = "watch-a", + attemptId = "attempt-$generation", + generation = generation, + resources = + WearRealtimeTalkClient.ChannelResources( + channel = FakeRealtimeChannel("watch-a", "channel-$generation"), + input = ByteArrayInputStream(byteArrayOf()), + output = ByteArrayOutputStream(), + ), + ) + private fun awaitPlaybackTeardown(client: WearRealtimeTalkClient) { ShadowSystemClock.advanceBy(Duration.ofSeconds(1L)) val deadlineNanos = System.nanoTime() + 2_000_000_000L @@ -388,6 +455,28 @@ class WearTalkAvatarTest { } } + private fun Any.privateField(name: String): Any? = + javaClass.getDeclaredField(name).run { + isAccessible = true + get(this@privateField) + } + + private fun WearRealtimeTalkClient.invokePrivate( + name: String, + vararg args: Any, + ) { + javaClass.declaredMethods + .single { method -> + method.name == name && + method.parameterTypes.size == args.size && + method.parameterTypes.zip(args).all { (type, arg) -> + type.isAssignableFrom(arg.javaClass) || + (type == Boolean::class.javaPrimitiveType && arg is Boolean) + } + }.apply { isAccessible = true } + .invoke(this, *args) + } + private companion object { const val WEAR_REALTIME_SAMPLE_RATE_HZ = 24_000 } @@ -447,3 +536,21 @@ class WearTalkAvatarTest { override val lifecycle: Lifecycle = registry } } + +private data class FakeRealtimeChannel( + private val nodeId: String, + private val label: String, +) : ChannelClient.Channel { + override fun getNodeId(): String = nodeId + + override fun getPath(): String = WearProtocol.realtimeAudioChannelPath("attempt-$label") + + override fun describeContents(): Int = 0 + + override fun writeToParcel( + dest: Parcel, + flags: Int, + ) { + dest.writeString(label) + } +} diff --git a/scripts/android-screenshots.sh b/scripts/android-screenshots.sh index 2ef62c2d62c4..ba4fbee069cf 100644 --- a/scripts/android-screenshots.sh +++ b/scripts/android-screenshots.sh @@ -292,10 +292,6 @@ avdmanager_bin() { printf '%s\n' "$AVDMANAGER" return fi - if command -v avdmanager >/dev/null 2>&1; then - command -v avdmanager - return - fi for sdk_root in "${ANDROID_HOME:-}" "${ANDROID_SDK_ROOT:-}" "$HOME/Library/Android/sdk"; do for relative_path in cmdline-tools/latest/bin/avdmanager cmdline-tools/bin/avdmanager tools/bin/avdmanager; do if [[ -n "$sdk_root" && -x "$sdk_root/$relative_path" ]]; then @@ -304,6 +300,10 @@ avdmanager_bin() { fi done done + if command -v avdmanager >/dev/null 2>&1; then + command -v avdmanager + return + fi echo "avdmanager not found. Install Android SDK command-line tools or set AVDMANAGER." >&2 return 127 } diff --git a/src/gateway/android-node.capabilities.live.test.ts b/src/gateway/android-node.capabilities.live.test.ts index a1aff1b05a2e..430bd310f4cf 100644 --- a/src/gateway/android-node.capabilities.live.test.ts +++ b/src/gateway/android-node.capabilities.live.test.ts @@ -22,7 +22,13 @@ import { resolveNodeCommandAllowlist } from "./node-command-policy.js"; const LIVE = isLiveTestEnabled(); const LIVE_ANDROID_NODE = isTruthyEnvValue(process.env.OPENCLAW_LIVE_ANDROID_NODE); const describeLive = LIVE && LIVE_ANDROID_NODE ? describe : describe.skip; -const SKIPPED_INTERACTIVE_COMMANDS = new Set(); +const SKIPPED_INTERACTIVE_COMMANDS = new Set([ + "screen.record", + "talk.ptt.start", + "talk.ptt.stop", + "talk.ptt.cancel", + "talk.ptt.once", +]); type CommandOutcome = "success" | "error"; @@ -296,12 +302,44 @@ const COMMAND_PROFILES: Record = { buildParams: () => ({}), timeoutMs: 20_000, outcome: "success", + allowedErrorCodes: ["SMS_PERMISSION_REQUIRED"], onSuccess: (payload) => { const obj = assertObjectPayload("sms.search", payload); expect(["number", "string"]).toContain(typeof obj.count); expect(Array.isArray(obj.messages)).toBe(true); }, }, + "system.notify": { + buildParams: () => ({ + title: "OpenClaw Android E2E", + body: "Live node integration check", + sound: "none", + priority: "passive", + }), + timeoutMs: 20_000, + outcome: "success", + allowedErrorCodes: ["NOT_AUTHORIZED"], + }, + "contacts.search": { + buildParams: () => ({ query: "__openclaw_live_no_match__", limit: 1 }), + timeoutMs: 20_000, + outcome: "success", + allowedErrorCodes: ["CONTACTS_PERMISSION_REQUIRED"], + onSuccess: (payload) => { + const obj = assertObjectPayload("contacts.search", payload); + expect(Array.isArray(obj.contacts)).toBe(true); + }, + }, + "calendar.events": { + buildParams: () => ({ limit: 1 }), + timeoutMs: 20_000, + outcome: "success", + allowedErrorCodes: ["CALENDAR_PERMISSION_REQUIRED"], + onSuccess: (payload) => { + const obj = assertObjectPayload("calendar.events", payload); + expect(Array.isArray(obj.events)).toBe(true); + }, + }, "debug.logs": { buildParams: () => ({}), timeoutMs: 20_000, @@ -546,16 +584,60 @@ function evaluateCommandResult(params: { } const code = result.errorCode ?? "UNKNOWN"; - if (profile.outcome === "success") { - return `expected success, got ${code}: ${result.errorMessage ?? "unknown error"}`; - } const allowed = new Set(profile.allowedErrorCodes ?? []); if (allowed.has(code)) { return null; } + if (profile.outcome === "success") { + return `expected success, got ${code}: ${result.errorMessage ?? "unknown error"}`; + } return `unexpected error ${code}: ${result.errorMessage ?? "unknown error"}`; } +describe("android node command profiles", () => { + it("accepts declared environment errors for success profiles", () => { + const profile = expectDefined(COMMAND_PROFILES["contacts.search"], "contacts.search profile"); + expect( + evaluateCommandResult({ + result: { + command: "contacts.search", + ok: false, + errorCode: "CONTACTS_PERMISSION_REQUIRED", + errorMessage: "grant Contacts permission", + durationMs: 1, + }, + profile, + ctx: { notifications: [] }, + }), + ).toBeNull(); + }); + + it("still rejects undeclared errors for success profiles", () => { + const profile = expectDefined(COMMAND_PROFILES["contacts.search"], "contacts.search profile"); + expect( + evaluateCommandResult({ + result: { + command: "contacts.search", + ok: false, + errorCode: "INVALID_REQUEST", + errorMessage: "invalid request", + durationMs: 1, + }, + profile, + ctx: { notifications: [] }, + }), + ).toContain("expected success"); + }); + + it("keeps microphone capture commands out of the non-interactive matrix", () => { + expect( + ["talk.ptt.start", "talk.ptt.stop", "talk.ptt.cancel", "talk.ptt.once"].every((command) => + SKIPPED_INTERACTIVE_COMMANDS.has(command), + ), + ).toBe(true); + }); +}); + describeLive("android node capability integration (preconditioned)", () => { let client: GatewayClient | null = null; let nodeId = ""; diff --git a/test/scripts/android-perf-scripts.test.ts b/test/scripts/android-perf-scripts.test.ts new file mode 100644 index 000000000000..9366e0adf0af --- /dev/null +++ b/test/scripts/android-perf-scripts.test.ts @@ -0,0 +1,16 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const PERF_SCRIPTS = [ + "apps/android/scripts/perf-online-benchmark.sh", + "apps/android/scripts/perf-startup-hotspots.sh", +]; + +describe("Android performance scripts", () => { + it.each(PERF_SCRIPTS)("installs the Play debug variant in %s", (scriptPath) => { + const script = readFileSync(scriptPath, "utf8"); + + expect(script).toContain(":app:installPlayDebug"); + expect(script).not.toContain(":app:installDebug"); + }); +}); diff --git a/test/scripts/android-screenshots.test.ts b/test/scripts/android-screenshots.test.ts index 74620fb6c34c..f4db5132d6c7 100644 --- a/test/scripts/android-screenshots.test.ts +++ b/test/scripts/android-screenshots.test.ts @@ -113,6 +113,20 @@ describe("android screenshots script", () => { expect(script).toContain("is not the screenshot AVD"); }); + it("prefers avdmanager from the configured SDK over an unrelated PATH install", () => { + const script = readFileSync(SCRIPT, "utf8"); + const functionStart = script.indexOf("avdmanager_bin() {"); + const sdkLookup = script.indexOf( + 'for sdk_root in "${ANDROID_HOME:-}" "${ANDROID_SDK_ROOT:-}" "$HOME/Library/Android/sdk"; do', + functionStart, + ); + const pathLookup = script.indexOf("if command -v avdmanager", functionStart); + + expect(functionStart).toBeGreaterThan(-1); + expect(sdkLookup).toBeGreaterThan(functionStart); + expect(pathLookup).toBeGreaterThan(sdkLookup); + }); + it.each(["../escape", "en/US", ".hidden", "en..US", ""])( "rejects locale path escapes before dry-run output: %j", (locale) => {