diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json
index ea53a8da1533..3c54d312ea5d 100644
--- a/apps/.i18n/native-source.json
+++ b/apps/.i18n/native-source.json
@@ -1067,7 +1067,7 @@
},
{
"kind": "ui-call",
- "line": 5589,
+ "line": 5590,
"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": 5620,
+ "line": 5621,
"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": 5622,
+ "line": 5623,
"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": 5638,
+ "line": 5639,
"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": 5725,
+ "line": 5726,
"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": 5811,
+ "line": 5812,
"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": 5821,
+ "line": 5822,
"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": 5825,
+ "line": 5826,
"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": 5837,
+ "line": 5838,
"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": 5868,
+ "line": 5869,
"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": 5885,
+ "line": 5886,
"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": 5894,
+ "line": 5895,
"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": 5906,
+ "line": 5907,
"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": 5953,
+ "line": 5954,
"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": 6065,
+ "line": 6066,
"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": 6080,
+ "line": 6081,
"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": 6100,
+ "line": 6101,
"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": 6104,
+ "line": 6105,
"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": 6119,
+ "line": 6120,
"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": 6119,
+ "line": 6120,
"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": 6137,
+ "line": 6138,
"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": 6183,
+ "line": 6184,
"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": 6196,
+ "line": 6197,
"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": 6229,
+ "line": 6230,
"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": 6245,
+ "line": 6246,
"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": 6261,
+ "line": 6262,
"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": 6277,
+ "line": 6278,
"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": 6367,
+ "line": 6368,
"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": 6374,
+ "line": 6375,
"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": 6414,
+ "line": 6415,
"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": 6454,
+ "line": 6455,
"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": 6474,
+ "line": 6475,
"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": 6526,
+ "line": 6527,
"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": 6546,
+ "line": 6547,
"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": 6551,
+ "line": 6552,
"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": 6741,
+ "line": 6742,
"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": 6823,
+ "line": 6824,
"path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt",
"source": "Connected",
"surface": "android",
@@ -1363,7 +1363,7 @@
},
{
"kind": "ui-call",
- "line": 6853,
+ "line": 6854,
"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": 7503,
+ "line": 7504,
"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": 7521,
+ "line": 7522,
"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": 7536,
+ "line": 7537,
"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": 8019,
+ "line": 8020,
"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": 8028,
+ "line": 8029,
"path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt",
"source": "Cron",
"surface": "android",
@@ -1411,7 +1411,7 @@
},
{
"kind": "ui-call",
- "line": 8029,
+ "line": 8030,
"path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt",
"source": "Scheduled",
"surface": "android",
@@ -1419,7 +1419,7 @@
},
{
"kind": "ui-call",
- "line": 8037,
+ "line": 8038,
"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": 8038,
+ "line": 8039,
"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": 8039,
+ "line": 8040,
"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": 8040,
+ "line": 8041,
"path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt",
"source": "Repeating",
"surface": "android",
@@ -1451,7 +1451,7 @@
},
{
"kind": "ui-call",
- "line": 8056,
+ "line": 8057,
"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": 8073,
+ "line": 8074,
"path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt",
"source": "Gateway",
"surface": "android",
@@ -1467,7 +1467,7 @@
},
{
"kind": "ui-call",
- "line": 8081,
+ "line": 8082,
"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": 8082,
+ "line": 8083,
"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": 8084,
+ "line": 8085,
"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": 8088,
+ "line": 8089,
"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": 8091,
+ "line": 8092,
"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": 8096,
+ "line": 8097,
"path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt",
"source": "Reconnecting",
"surface": "android",
@@ -1515,7 +1515,7 @@
},
{
"kind": "ui-call",
- "line": 8097,
+ "line": 8098,
"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": 8099,
+ "line": 8100,
"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": 8103,
+ "line": 8104,
"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": 8106,
+ "line": 8107,
"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": 8111,
+ "line": 8112,
"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": 8112,
+ "line": 8113,
"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": 8114,
+ "line": 8115,
"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": 8118,
+ "line": 8119,
"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": 8121,
+ "line": 8122,
"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": 8153,
+ "line": 8154,
"path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt",
"source": "Main",
"surface": "android",
@@ -1595,7 +1595,7 @@
},
{
"kind": "ui-call",
- "line": 8168,
+ "line": 8169,
"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": 8169,
+ "line": 8170,
"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": 8170,
+ "line": 8171,
"path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt",
"source": "Ready",
"surface": "android",
@@ -1619,7 +1619,7 @@
},
{
"kind": "ui-call",
- "line": 8825,
+ "line": 8826,
"path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt",
"source": "Dream",
"surface": "android",
@@ -1795,7 +1795,7 @@
},
{
"kind": "ui-call",
- "line": 1866,
+ "line": 1869,
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
"source": "Wait for the current response to finish before starting a new chat.",
"surface": "android",
@@ -1803,7 +1803,7 @@
},
{
"kind": "ui-call",
- "line": 1999,
+ "line": 2002,
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
"source": "Could not update model.",
"surface": "android",
@@ -1811,7 +1811,7 @@
},
{
"kind": "ui-call",
- "line": 2064,
+ "line": 2067,
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
"source": "Could not update thinking level.",
"surface": "android",
@@ -1819,7 +1819,7 @@
},
{
"kind": "ui-call",
- "line": 2578,
+ "line": 2581,
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
"source": "Chat failed before the run started; try again.",
"surface": "android",
@@ -1827,7 +1827,7 @@
},
{
"kind": "ui-call",
- "line": 4440,
+ "line": 4443,
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
"source": "Could not stage an attachment for sending.",
"surface": "android",
@@ -1835,7 +1835,7 @@
},
{
"kind": "ui-call",
- "line": 4473,
+ "line": 4476,
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
"source": "Offline queue is full ($OUTBOX_MAX_QUEUED messages); delete queued items first.",
"surface": "android",
@@ -1843,7 +1843,7 @@
},
{
"kind": "ui-call",
- "line": 4479,
+ "line": 4482,
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
"source": "Attachments are too large to queue for one message; remove some and try again.",
"surface": "android",
@@ -1851,7 +1851,7 @@
},
{
"kind": "ui-call",
- "line": 4485,
+ "line": 4488,
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
"source": "Offline attachment storage is full; delete queued items first.",
"surface": "android",
@@ -1859,7 +1859,7 @@
},
{
"kind": "ui-call",
- "line": 4490,
+ "line": 4493,
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
"source": "Gateway health not OK; cannot send",
"surface": "android",
@@ -1867,7 +1867,7 @@
},
{
"kind": "ui-call",
- "line": 4497,
+ "line": 4500,
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
"source": "Could not queue message for later delivery.",
"surface": "android",
@@ -1875,7 +1875,7 @@
},
{
"kind": "ui-call",
- "line": 5403,
+ "line": 5406,
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
"source": "Chat failed",
"surface": "android",
@@ -1883,7 +1883,7 @@
},
{
"kind": "ui-call",
- "line": 5707,
+ "line": 5710,
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
"source": "Event stream interrupted; try refreshing.",
"surface": "android",
@@ -1891,7 +1891,7 @@
},
{
"kind": "ui-call",
- "line": 5854,
+ "line": 5857,
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
"source": "Timed out waiting for a reply; try again or refresh.",
"surface": "android",
@@ -1899,7 +1899,7 @@
},
{
"kind": "ui-call",
- "line": 6071,
+ "line": 6074,
"path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
"source": "Timed out confirming the sent message; refresh to check delivery.",
"surface": "android",
@@ -1915,7 +1915,7 @@
},
{
"kind": "ui-call",
- "line": 972,
+ "line": 1034,
"path": "apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt",
"source": "Accept",
"surface": "android",
@@ -1923,7 +1923,7 @@
},
{
"kind": "conditional-branch",
- "line": 1752,
+ "line": 1834,
"path": "apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt",
"source": "Connecting…",
"surface": "android",
@@ -1931,7 +1931,7 @@
},
{
"kind": "conditional-branch",
- "line": 1752,
+ "line": 1834,
"path": "apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt",
"source": "Reconnecting…",
"surface": "android",
@@ -12235,7 +12235,7 @@
},
{
"kind": "ui-call",
- "line": 392,
+ "line": 463,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMediaPlayer.kt",
"source": "Media unavailable",
"surface": "android",
@@ -12243,7 +12243,7 @@
},
{
"kind": "ui-call",
- "line": 408,
+ "line": 479,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMediaPlayer.kt",
"source": "Audio playback is unavailable",
"surface": "android",
@@ -12251,7 +12251,7 @@
},
{
"kind": "ui-call",
- "line": 515,
+ "line": 589,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMediaPlayer.kt",
"source": "Pause audio",
"surface": "android",
@@ -12259,7 +12259,7 @@
},
{
"kind": "ui-call",
- "line": 515,
+ "line": 589,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMediaPlayer.kt",
"source": "Play audio",
"surface": "android",
@@ -12267,7 +12267,7 @@
},
{
"kind": "ui-call",
- "line": 595,
+ "line": 676,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMediaPlayer.kt",
"source": "Play video",
"surface": "android",
@@ -12275,7 +12275,15 @@
},
{
"kind": "ui-call",
- "line": 605,
+ "line": 689,
+ "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMediaPlayer.kt",
+ "source": "Preparing playback…",
+ "surface": "android",
+ "id": "native.android.8665d9ac83473ac9"
+ },
+ {
+ "kind": "ui-call",
+ "line": 690,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMediaPlayer.kt",
"source": "Paused for voice playback",
"surface": "android",
@@ -12283,7 +12291,7 @@
},
{
"kind": "ui-call",
- "line": 723,
+ "line": 823,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMediaPlayer.kt",
"source": "Video",
"surface": "android",
@@ -12291,7 +12299,7 @@
},
{
"kind": "ui-call",
- "line": 723,
+ "line": 823,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMediaPlayer.kt",
"source": "Voice note",
"surface": "android",
@@ -12763,7 +12771,7 @@
},
{
"kind": "ui-call",
- "line": 400,
+ "line": 406,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Model",
"surface": "android",
@@ -12771,7 +12779,7 @@
},
{
"kind": "ui-call",
- "line": 523,
+ "line": 529,
"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",
@@ -12779,7 +12787,7 @@
},
{
"kind": "ui-call",
- "line": 677,
+ "line": 683,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Chat needs attention",
"surface": "android",
@@ -12787,7 +12795,7 @@
},
{
"kind": "ui-call",
- "line": 1021,
+ "line": 1033,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "All",
"surface": "android",
@@ -12795,7 +12803,7 @@
},
{
"kind": "ui-call",
- "line": 1108,
+ "line": 1120,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Working",
"surface": "android",
@@ -12803,7 +12811,7 @@
},
{
"kind": "ui-call",
- "line": 1109,
+ "line": 1121,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Ready",
"surface": "android",
@@ -12811,7 +12819,7 @@
},
{
"kind": "ui-call",
- "line": 1110,
+ "line": 1122,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Offline",
"surface": "android",
@@ -12819,7 +12827,7 @@
},
{
"kind": "ui-call",
- "line": 1123,
+ "line": 1135,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Chat actions",
"surface": "android",
@@ -12827,7 +12835,7 @@
},
{
"kind": "ui-call",
- "line": 1128,
+ "line": 1140,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Refresh chat",
"surface": "android",
@@ -12835,7 +12843,7 @@
},
{
"kind": "ui-call",
- "line": 1147,
+ "line": 1159,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Dashboard",
"surface": "android",
@@ -12843,7 +12851,7 @@
},
{
"kind": "ui-call",
- "line": 1155,
+ "line": 1167,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Background tasks",
"surface": "android",
@@ -12851,7 +12859,7 @@
},
{
"kind": "ui-call",
- "line": 1176,
+ "line": 1188,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Chat",
"surface": "android",
@@ -12859,7 +12867,7 @@
},
{
"kind": "ui-call",
- "line": 1369,
+ "line": 1381,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Messages to recover",
"surface": "android",
@@ -12867,7 +12875,7 @@
},
{
"kind": "ui-call",
- "line": 1371,
+ "line": 1383,
"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",
@@ -12875,7 +12883,7 @@
},
{
"kind": "ui-call",
- "line": 1416,
+ "line": 1428,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Loading thread",
"surface": "android",
@@ -12883,7 +12891,7 @@
},
{
"kind": "ui-call",
- "line": 1449,
+ "line": 1461,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Jump to latest",
"surface": "android",
@@ -12891,7 +12899,7 @@
},
{
"kind": "ui-call",
- "line": 1523,
+ "line": 1535,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Ready when you are",
"surface": "android",
@@ -12899,7 +12907,7 @@
},
{
"kind": "ui-call",
- "line": 1527,
+ "line": 1539,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Start with a prompt, or use voice.",
"surface": "android",
@@ -12907,7 +12915,7 @@
},
{
"kind": "ui-call",
- "line": 1529,
+ "line": 1541,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Use the recovery options below to reconnect.",
"surface": "android",
@@ -12915,7 +12923,7 @@
},
{
"kind": "ui-call",
- "line": 1531,
+ "line": 1543,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Chat is checking Gateway health.",
"surface": "android",
@@ -12923,7 +12931,7 @@
},
{
"kind": "ui-call",
- "line": 1554,
+ "line": 1566,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Fix connection",
"surface": "android",
@@ -12931,7 +12939,7 @@
},
{
"kind": "ui-call",
- "line": 1555,
+ "line": 1567,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Copy diagnostics",
"surface": "android",
@@ -12939,7 +12947,7 @@
},
{
"kind": "ui-call",
- "line": 1614,
+ "line": 1626,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Catch me up",
"surface": "android",
@@ -12947,7 +12955,7 @@
},
{
"kind": "ui-call",
- "line": 1615,
+ "line": 1627,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Summarize recent threads and next steps.",
"surface": "android",
@@ -12955,7 +12963,7 @@
},
{
"kind": "ui-call",
- "line": 1616,
+ "line": 1628,
"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",
@@ -12963,7 +12971,7 @@
},
{
"kind": "ui-call",
- "line": 1620,
+ "line": 1632,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Plan the work",
"surface": "android",
@@ -12971,7 +12979,7 @@
},
{
"kind": "ui-call",
- "line": 1621,
+ "line": 1633,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Turn a goal into an actionable checklist.",
"surface": "android",
@@ -12979,7 +12987,7 @@
},
{
"kind": "ui-call",
- "line": 1622,
+ "line": 1634,
"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",
@@ -12987,7 +12995,7 @@
},
{
"kind": "ui-call",
- "line": 1626,
+ "line": 1638,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Use this phone",
"surface": "android",
@@ -12995,7 +13003,7 @@
},
{
"kind": "ui-call",
- "line": 1627,
+ "line": 1639,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Ask OpenClaw to use Android capabilities.",
"surface": "android",
@@ -13003,7 +13011,7 @@
},
{
"kind": "ui-call",
- "line": 1628,
+ "line": 1640,
"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",
@@ -13011,7 +13019,7 @@
},
{
"kind": "ui-call",
- "line": 1712,
+ "line": 1724,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "OpenClaw · Live",
"surface": "android",
@@ -13019,7 +13027,7 @@
},
{
"kind": "ui-call",
- "line": 1713,
+ "line": 1725,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "You",
"surface": "android",
@@ -13027,7 +13035,7 @@
},
{
"kind": "ui-call",
- "line": 1714,
+ "line": 1726,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "System",
"surface": "android",
@@ -13035,7 +13043,7 @@
},
{
"kind": "ui-call",
- "line": 1715,
+ "line": 1727,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "OpenClaw",
"surface": "android",
@@ -13043,7 +13051,7 @@
},
{
"kind": "ui-call",
- "line": 1754,
+ "line": 1766,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Image",
"surface": "android",
@@ -13051,7 +13059,7 @@
},
{
"kind": "ui-call",
- "line": 1770,
+ "line": 1782,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Additional images hidden: ${omittedImageCount}",
"surface": "android",
@@ -13059,7 +13067,7 @@
},
{
"kind": "ui-call",
- "line": 1825,
+ "line": 1837,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Preparing audio…",
"surface": "android",
@@ -13067,7 +13075,7 @@
},
{
"kind": "ui-call",
- "line": 1825,
+ "line": 1837,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Speaking…",
"surface": "android",
@@ -13075,7 +13083,7 @@
},
{
"kind": "ui-call",
- "line": 1856,
+ "line": 1868,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Close",
"surface": "android",
@@ -13083,7 +13091,7 @@
},
{
"kind": "ui-call",
- "line": 1856,
+ "line": 1868,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "View all",
"surface": "android",
@@ -13091,7 +13099,7 @@
},
{
"kind": "ui-call",
- "line": 1886,
+ "line": 1898,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Tools running",
"surface": "android",
@@ -13099,7 +13107,7 @@
},
{
"kind": "ui-call",
- "line": 1888,
+ "line": 1900,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "OpenClaw is working",
"surface": "android",
@@ -13107,7 +13115,7 @@
},
{
"kind": "ui-call",
- "line": 1891,
+ "line": 1903,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "+${toolCalls.size - 4} more",
"surface": "android",
@@ -13115,7 +13123,7 @@
},
{
"kind": "ui-named-argument",
- "line": 1957,
+ "line": 1969,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "$completedCount/${steps.size}",
"surface": "android",
@@ -13123,7 +13131,7 @@
},
{
"kind": "ui-call",
- "line": 1964,
+ "line": 1976,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Collapse plan checklist",
"surface": "android",
@@ -13131,7 +13139,7 @@
},
{
"kind": "ui-call",
- "line": 1964,
+ "line": 1976,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Expand plan checklist",
"surface": "android",
@@ -13139,7 +13147,7 @@
},
{
"kind": "ui-call",
- "line": 2096,
+ "line": 2109,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Dismiss shared-image warning",
"surface": "android",
@@ -13147,7 +13155,7 @@
},
{
"kind": "ui-call",
- "line": 2199,
+ "line": 2213,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Stop",
"surface": "android",
@@ -13155,7 +13163,7 @@
},
{
"kind": "ui-call",
- "line": 2286,
+ "line": 2300,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Switch branch",
"surface": "android",
@@ -13163,7 +13171,7 @@
},
{
"kind": "ui-call",
- "line": 2310,
+ "line": 2324,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Untitled branch",
"surface": "android",
@@ -13171,7 +13179,7 @@
},
{
"kind": "ui-call",
- "line": 2325,
+ "line": 2339,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Current branch",
"surface": "android",
@@ -13179,7 +13187,7 @@
},
{
"kind": "ui-call",
- "line": 2337,
+ "line": 2351,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Messages: $count",
"surface": "android",
@@ -13187,7 +13195,7 @@
},
{
"kind": "ui-call",
- "line": 2345,
+ "line": 2359,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "$count · $updated",
"surface": "android",
@@ -13195,7 +13203,7 @@
},
{
"kind": "ui-call",
- "line": 2374,
+ "line": 2388,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Default",
"surface": "android",
@@ -13203,7 +13211,7 @@
},
{
"kind": "ui-call",
- "line": 2440,
+ "line": 2454,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Pin model",
"surface": "android",
@@ -13211,7 +13219,7 @@
},
{
"kind": "ui-call",
- "line": 2440,
+ "line": 2454,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Unpin model",
"surface": "android",
@@ -13219,7 +13227,7 @@
},
{
"kind": "ui-call",
- "line": 2457,
+ "line": 2471,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "No commands found",
"surface": "android",
@@ -13227,7 +13235,7 @@
},
{
"kind": "ui-call",
- "line": 2499,
+ "line": 2513,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Command",
"surface": "android",
@@ -13235,7 +13243,7 @@
},
{
"kind": "ui-call",
- "line": 2519,
+ "line": 2533,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Gateway offline",
"surface": "android",
@@ -13243,7 +13251,7 @@
},
{
"kind": "ui-call",
- "line": 2565,
+ "line": 2579,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Close thinking level selector",
"surface": "android",
@@ -13251,7 +13259,7 @@
},
{
"kind": "ui-call",
- "line": 2565,
+ "line": 2579,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Open thinking level selector",
"surface": "android",
@@ -13259,7 +13267,7 @@
},
{
"kind": "ui-call",
- "line": 2630,
+ "line": 2645,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Attach image",
"surface": "android",
@@ -13267,7 +13275,7 @@
},
{
"kind": "ui-call",
- "line": 2635,
+ "line": 2650,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Attachment",
"surface": "android",
@@ -13275,7 +13283,15 @@
},
{
"kind": "ui-call",
- "line": 2666,
+ "line": 2655,
+ "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
+ "source": "Attach video",
+ "surface": "android",
+ "id": "native.android.1bf18cef7a720c58"
+ },
+ {
+ "kind": "ui-call",
+ "line": 2686,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Message OpenClaw",
"surface": "android",
@@ -13283,7 +13299,7 @@
},
{
"kind": "ui-call",
- "line": 2695,
+ "line": 2715,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "End Talk",
"surface": "android",
@@ -13291,7 +13307,7 @@
},
{
"kind": "ui-call",
- "line": 2695,
+ "line": 2715,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Start Talk",
"surface": "android",
@@ -13299,7 +13315,7 @@
},
{
"kind": "ui-call",
- "line": 2780,
+ "line": 2815,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Voice note · ${formatVoiceNoteDuration(duration)}",
"surface": "android",
@@ -13307,7 +13323,7 @@
},
{
"kind": "ui-call",
- "line": 2789,
+ "line": 2824,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Remove attachment",
"surface": "android",
@@ -13315,7 +13331,7 @@
},
{
"kind": "ui-call",
- "line": 2801,
+ "line": 2836,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "New chat",
"surface": "android",
@@ -13323,7 +13339,7 @@
},
{
"kind": "ui-call",
- "line": 2810,
+ "line": 2845,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Main",
"surface": "android",
@@ -13331,7 +13347,7 @@
},
{
"kind": "ui-call",
- "line": 2811,
+ "line": 2846,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Current",
"surface": "android",
@@ -13339,7 +13355,7 @@
},
{
"kind": "ui-call",
- "line": 2818,
+ "line": 2853,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "$emoji $name",
"surface": "android",
@@ -13347,7 +13363,7 @@
},
{
"kind": "ui-call",
- "line": 2877,
+ "line": 2912,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Send",
"surface": "android",
@@ -13355,7 +13371,7 @@
},
{
"kind": "ui-call",
- "line": 2888,
+ "line": 2923,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Chat is still checking Gateway health.",
"surface": "android",
@@ -13363,7 +13379,7 @@
},
{
"kind": "ui-call",
- "line": 2889,
+ "line": 2924,
"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",
@@ -13371,7 +13387,7 @@
},
{
"kind": "ui-call",
- "line": 2890,
+ "line": 2925,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Gateway authentication needs attention.",
"surface": "android",
@@ -13379,7 +13395,7 @@
},
{
"kind": "ui-call",
- "line": 2909,
+ "line": 2944,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Context ${(it * 100).roundToInt()}%",
"surface": "android",
@@ -13387,7 +13403,7 @@
},
{
"kind": "ui-call",
- "line": 2910,
+ "line": 2945,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Context --",
"surface": "android",
@@ -13395,7 +13411,7 @@
},
{
"kind": "ui-call",
- "line": 2911,
+ "line": 2946,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "$contextLabel · ${contextMeterThinkingLabel(thinkingLevel)}",
"surface": "android",
@@ -13403,7 +13419,7 @@
},
{
"kind": "ui-call",
- "line": 2950,
+ "line": 2985,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Off",
"surface": "android",
@@ -13411,7 +13427,7 @@
},
{
"kind": "ui-call",
- "line": 2951,
+ "line": 2986,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Minimal",
"surface": "android",
@@ -13419,7 +13435,7 @@
},
{
"kind": "ui-call",
- "line": 2952,
+ "line": 2987,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Low",
"surface": "android",
@@ -13427,7 +13443,7 @@
},
{
"kind": "ui-call",
- "line": 2953,
+ "line": 2988,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Medium",
"surface": "android",
@@ -13435,7 +13451,7 @@
},
{
"kind": "ui-call",
- "line": 2954,
+ "line": 2989,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "High",
"surface": "android",
@@ -13443,7 +13459,7 @@
},
{
"kind": "ui-call",
- "line": 2955,
+ "line": 2990,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Xhigh",
"surface": "android",
@@ -13451,7 +13467,7 @@
},
{
"kind": "ui-call",
- "line": 2956,
+ "line": 2991,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Adaptive",
"surface": "android",
@@ -13459,7 +13475,7 @@
},
{
"kind": "ui-call",
- "line": 2957,
+ "line": 2992,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
"source": "Max",
"surface": "android",
diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts
index 07a3f8f8275a..2ae17d0142e9 100644
--- a/apps/android/app/build.gradle.kts
+++ b/apps/android/app/build.gradle.kts
@@ -338,6 +338,7 @@ dependencies {
implementation(libs.okhttp)
implementation(libs.media3.datasource.okhttp)
implementation(libs.media3.exoplayer)
+ implementation(libs.media3.session)
implementation(libs.media3.ui)
implementation(libs.bcprov)
implementation(libs.coil.compose)
diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml
index 1a910ee0e50f..7986485f8308 100644
--- a/apps/android/app/src/main/AndroidManifest.xml
+++ b/apps/android/app/src/main/AndroidManifest.xml
@@ -131,6 +131,12 @@
+
+
+
+
+
+
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/AssistantLaunch.kt b/apps/android/app/src/main/java/ai/openclaw/app/AssistantLaunch.kt
index 4434f091600c..b0c4823cec7c 100644
--- a/apps/android/app/src/main/java/ai/openclaw/app/AssistantLaunch.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/app/AssistantLaunch.kt
@@ -45,6 +45,7 @@ data class ShareLaunchRequest(
enum class SharedAttachmentKind {
Image,
Audio,
+ Video,
Document,
}
@@ -63,6 +64,7 @@ internal val SHARED_ATTACHMENT_MIME_ALLOWLIST =
setOf(
"image/*",
"audio/*",
+ "video/*",
"application/pdf",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
@@ -71,7 +73,9 @@ internal val SHARED_ATTACHMENT_MIME_ALLOWLIST =
"text/markdown",
)
-internal val SHARED_AUDIO_DOCUMENT_MIME_TYPES = SHARED_ATTACHMENT_MIME_ALLOWLIST.filterNot { it == "image/*" }.toTypedArray()
+internal val SHARED_AUDIO_DOCUMENT_MIME_TYPES =
+ SHARED_ATTACHMENT_MIME_ALLOWLIST.filterNot { it == "image/*" || it == "video/*" }.toTypedArray()
+internal val SHARED_VIDEO_MIME_TYPES = arrayOf("video/*")
/**
* Parses app-owned navigation actions that should open a specific home tab.
@@ -197,6 +201,7 @@ internal fun sharedAttachmentKindForMimeType(mimeType: String?): SharedAttachmen
return when {
normalized.startsWith("image/") -> SharedAttachmentKind.Image
normalized.startsWith("audio/") -> SharedAttachmentKind.Audio
+ normalized.startsWith("video/") -> SharedAttachmentKind.Video
normalized in SHARED_ATTACHMENT_MIME_ALLOWLIST -> SharedAttachmentKind.Document
else -> null
}
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 8b6482136758..23499aeb11d5 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
@@ -1314,7 +1314,8 @@ class MainViewModel private constructor(
internal suspend fun loadChatMediaArtifact(
artifactId: String,
kind: GatewayMediaKind,
- ) = ensureRuntime().loadChatMediaArtifact(artifactId, kind)
+ playbackRendition: Boolean,
+ ) = ensureRuntime().loadChatMediaArtifact(artifactId, kind, playbackRendition)
fun requestCanvasRehydrate(source: String = "screen_tab") {
ensureRuntime().requestCanvasRehydrate(source = source, force = true)
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 6f877c6becfc..8bf1a3af817d 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
@@ -4919,7 +4919,8 @@ class NodeRuntime private constructor(
internal suspend fun loadChatMediaArtifact(
artifactId: String,
kind: GatewayMediaKind,
- ) = chat.loadMediaArtifact(artifactId, kind)
+ playbackRendition: Boolean,
+ ) = chat.loadMediaArtifact(artifactId, kind, playbackRendition)
fun loadChat(
sessionKey: String,
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatCommandOutbox.kt b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatCommandOutbox.kt
index f2a504682b31..02f0de6dc471 100644
--- a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatCommandOutbox.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatCommandOutbox.kt
@@ -50,6 +50,10 @@ internal const val OUTBOX_ATTACHMENT_CHUNK_BYTES = 512 * 1024
/** Upper bound of attachment bytes on one queued command (8 images plus a voice note fit). */
internal const val OUTBOX_MAX_COMMAND_ATTACHMENT_BYTES = 8L * 1024L * 1024L
+// Mirrors DEFAULT_CHAT_ATTACHMENT_MAX_MB in src/gateway/chat-attachments.ts. Only video raises
+// the durable command ceiling; existing image, audio, and document admission stays at 8 MiB.
+internal const val OUTBOX_MAX_VIDEO_COMMAND_ATTACHMENT_BYTES = 20L * 1024L * 1024L
+
/** Upper bound of queued attachment bytes per gateway so the outbox database stays bounded. */
internal const val OUTBOX_MAX_GATEWAY_ATTACHMENT_BYTES = 48L * 1024L * 1024L
@@ -155,6 +159,20 @@ class LoadedOutboxAttachment(
val bytes: ByteArray,
)
+private fun OutboxAttachmentPayload.isVideo(): Boolean = type == "video" || mimeType.startsWith("video/", ignoreCase = true)
+
+internal fun outboxCommandAttachmentsWithinByteLimits(attachments: List): Boolean {
+ val totalBytes = attachments.sumOf { it.bytes.size.toLong() }
+ val nonVideoBytes = attachments.filterNot(OutboxAttachmentPayload::isVideo).sumOf { it.bytes.size.toLong() }
+ val totalLimit =
+ if (attachments.any(OutboxAttachmentPayload::isVideo)) {
+ OUTBOX_MAX_VIDEO_COMMAND_ATTACHMENT_BYTES
+ } else {
+ OUTBOX_MAX_COMMAND_ATTACHMENT_BYTES
+ }
+ return totalBytes <= totalLimit && nonVideoBytes <= OUTBOX_MAX_COMMAND_ATTACHMENT_BYTES
+}
+
sealed interface ChatOutboxEnqueueResult {
data class Queued(
val item: ChatOutboxItem,
@@ -162,7 +180,7 @@ sealed interface ChatOutboxEnqueueResult {
data object QueueFull : ChatOutboxEnqueueResult
- /** One command's attachments exceed [OUTBOX_MAX_COMMAND_ATTACHMENT_BYTES]; deleting rows cannot help. */
+ /** One command's attachments exceed its media-kind byte ceiling; deleting rows cannot help. */
data object AttachmentsTooLarge : ChatOutboxEnqueueResult
/** The per-gateway attachment byte budget is exhausted; deleting queued rows frees space. */
@@ -696,7 +714,7 @@ class RoomChatCommandOutbox internal constructor(
val key = sessionKey.trim().takeIf { it.isNotEmpty() } ?: return ChatOutboxEnqueueResult.Unavailable
val owner = normalizedOutboxOwnerAgentId(ownerAgentId) ?: return ChatOutboxEnqueueResult.Unavailable
val attachmentBytes = attachments.sumOf { it.bytes.size.toLong() }
- if (attachmentBytes > OUTBOX_MAX_COMMAND_ATTACHMENT_BYTES) {
+ if (!outboxCommandAttachmentsWithinByteLimits(attachments)) {
return ChatOutboxEnqueueResult.AttachmentsTooLarge
}
val dao = database.outboxDao()
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt
index b03690bf2e86..8fd1fa93b093 100644
--- a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt
@@ -128,7 +128,8 @@ class ChatController internal constructor(
agentId: String?,
artifactId: String,
kind: GatewayMediaKind,
- ) -> GatewayLoadedMedia? = { _, _, _, _, _ -> null },
+ playbackRendition: Boolean,
+ ) -> GatewayLoadedMedia? = { _, _, _, _, _, _ -> null },
private val commandOutbox: ChatCommandOutbox? = null,
private val recordModelRecent: (String) -> Unit = {},
private val onSessionDeleted: (ChatSessionDeletion) -> Unit = {},
@@ -163,8 +164,8 @@ class ChatController internal constructor(
loadGatewayImageArtifact = { gatewayId, sessionKey, agentId, artifactId ->
session.loadImageArtifact(gatewayId, sessionKey, agentId, artifactId)
},
- loadGatewayMediaArtifact = { gatewayId, sessionKey, agentId, artifactId, kind ->
- session.loadMediaArtifact(gatewayId, sessionKey, agentId, artifactId, kind)
+ loadGatewayMediaArtifact = { gatewayId, sessionKey, agentId, artifactId, kind, playbackRendition ->
+ session.loadMediaArtifact(gatewayId, sessionKey, agentId, artifactId, kind, playbackRendition)
},
commandOutbox = commandOutbox,
recordModelRecent = recordModelRecent,
@@ -186,6 +187,7 @@ class ChatController internal constructor(
suspend fun loadMediaArtifact(
artifactId: String,
kind: GatewayMediaKind,
+ playbackRendition: Boolean,
): GatewayLoadedMedia? {
val normalizedArtifactId = artifactId.trim().takeIf(String::isNotEmpty) ?: return null
if (kind == GatewayMediaKind.Image) return null
@@ -196,6 +198,7 @@ class ChatController internal constructor(
resolveAgentIdForSessionKey(sessionKey),
normalizedArtifactId,
kind,
+ playbackRendition,
)
}
@@ -6718,6 +6721,7 @@ internal fun parseChatMessageContent(el: JsonElement): ChatMessageContent? {
sizeBytes = obj["sizeBytes"].asLongOrNull(),
base64 = inlineContent?.takeIf { type == "image" && it.length <= CHAT_IMAGE_MAX_BASE64_CHARS },
durationMs = obj["durationMs"].asLongOrNull(),
+ playback = obj["playback"].asStringOrNull()?.takeIf { it == "native" || it == "transcode" },
)
}
@@ -6743,6 +6747,7 @@ internal fun parseChatMessageContent(el: JsonElement): ChatMessageContent? {
height = attachment["height"].asLongOrNull()?.toInt(),
sizeBytes = attachment["sizeBytes"].asLongOrNull(),
durationMs = attachment["durationMs"].asLongOrNull(),
+ playback = attachment["playback"].asStringOrNull()?.takeIf { it == "native" || it == "transcode" },
)
}
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatModels.kt b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatModels.kt
index 1b1c7dc91600..8841ff43b5d9 100644
--- a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatModels.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatModels.kt
@@ -79,6 +79,7 @@ data class ChatMessageContent(
val sizeBytes: Long? = null,
val base64: String? = null,
val durationMs: Long? = null,
+ val playback: String? = null,
val widget: ChatWidgetPreview? = null,
)
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatTranscriptCache.kt b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatTranscriptCache.kt
index 6226acb46cbd..59f961dab7e5 100644
--- a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatTranscriptCache.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatTranscriptCache.kt
@@ -32,6 +32,7 @@ private data class CachedMessageContent(
val height: Int? = null,
val sizeBytes: Long? = null,
val durationMs: Long? = null,
+ val playback: String? = null,
)
/**
@@ -320,6 +321,7 @@ class RoomChatTranscriptCache internal constructor(
height = part.height,
sizeBytes = part.sizeBytes,
durationMs = part.durationMs,
+ playback = part.playback,
)
},
timestampMs = row.timestampMs,
@@ -438,6 +440,7 @@ class RoomChatTranscriptCache internal constructor(
height = part.height,
sizeBytes = part.sizeBytes,
durationMs = part.durationMs,
+ playback = part.playback,
)
else -> null
}
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt
index 84d9e10864ad..ca4da20609c6 100644
--- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt
@@ -1,5 +1,6 @@
package ai.openclaw.app.gateway
+import android.os.SystemClock
import android.util.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
@@ -14,6 +15,7 @@ import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
@@ -28,12 +30,14 @@ import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
+import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okio.Buffer
+import java.io.IOException
import java.net.URI
import java.util.Base64
import java.util.Locale
@@ -84,9 +88,63 @@ sealed interface GatewayLoadedMedia {
val headers: Map,
val client: OkHttpClient,
val mimeType: String?,
+ val retryPreparingPlayback: Boolean,
) : GatewayLoadedMedia
}
+internal data class GatewayPlaybackRetryPolicy(
+ val maxElapsedMs: Long = 120_000L,
+ val initialDelayMs: Long = 500L,
+ val maxDelayMs: Long = 5_000L,
+) {
+ init {
+ require(maxElapsedMs > 0L && initialDelayMs >= 0L && maxDelayMs >= initialDelayMs)
+ }
+}
+
+/** Shared bounded backoff for both buffered fetches and Media3's streaming HTTP path. */
+internal class GatewayPlaybackRetryState(
+ private val policy: GatewayPlaybackRetryPolicy = GatewayPlaybackRetryPolicy(),
+ private val startedAtMs: Long = SystemClock.elapsedRealtime(),
+) {
+ private var attempt = 0
+
+ fun canAttempt(nowMs: Long = SystemClock.elapsedRealtime()): Boolean = nowMs - startedAtMs < policy.maxElapsedMs
+
+ fun nextDelayMs(nowMs: Long = SystemClock.elapsedRealtime()): Long? {
+ val remainingMs = policy.maxElapsedMs - (nowMs - startedAtMs).coerceAtLeast(0L)
+ if (remainingMs <= 0L) return null
+ val multiplier = 1L shl attempt.coerceAtMost(30)
+ val delayMs = (policy.initialDelayMs * multiplier).coerceAtMost(policy.maxDelayMs)
+ attempt += 1
+ return delayMs.coerceAtMost(remainingMs)
+ }
+}
+
+/** Keeps Media3's OkHttp data source in its loading state while a rendition is being prepared. */
+internal class GatewayPreparingPlaybackInterceptor(
+ private val policy: GatewayPlaybackRetryPolicy = GatewayPlaybackRetryPolicy(),
+ private val nowMs: () -> Long = SystemClock::elapsedRealtime,
+ private val sleepMs: (Long) -> Unit = Thread::sleep,
+) : Interceptor {
+ override fun intercept(chain: Interceptor.Chain): okhttp3.Response {
+ val retry = GatewayPlaybackRetryState(policy = policy, startedAtMs = nowMs())
+ while (true) {
+ if (!retry.canAttempt(nowMs())) throw IOException("playback preparation timed out")
+ val response = chain.proceed(chain.request())
+ if (response.code != 202) return response
+ val retryDelayMs = retry.nextDelayMs(nowMs()) ?: return response
+ response.close()
+ try {
+ sleepMs(retryDelayMs)
+ } catch (error: InterruptedException) {
+ Thread.currentThread().interrupt()
+ throw IOException("playback preparation interrupted", error)
+ }
+ }
+ }
+}
+
/**
* Role, scopes, commands, and permission snapshot sent with the connect frame.
*/
@@ -673,6 +731,7 @@ class GatewaySession(
agentId: String?,
artifactId: String,
kind: GatewayMediaKind,
+ playbackRendition: Boolean = false,
): GatewayLoadedMedia? {
val conn = readyConnection(expectedEndpointStableId) ?: return null
val params =
@@ -709,9 +768,9 @@ class GatewaySession(
conn.bufferedMedia(bytes = bytes, mimeType = resolvedMimeType)
} ?: payload["url"].asStringOrNull()?.trim()?.takeIf(String::isNotEmpty)?.let { ticketedPath ->
if (kind == GatewayMediaKind.Video) {
- conn.resolveTicketedMediaStream(ticketedPath, mimeType)
+ conn.resolveTicketedMediaStream(ticketedPath, mimeType, playbackRendition)
} else {
- conn.loadTicketedMedia(ticketedPath, kind)
+ conn.loadTicketedMedia(ticketedPath, kind, playbackRendition)
}
} ?: return null
return synchronized(lifecycleLock) {
@@ -941,13 +1000,15 @@ class GatewaySession(
fun resolveTicketedMediaStream(
ticketedPath: String,
mimeType: String?,
+ playbackRendition: Boolean,
): GatewayLoadedMedia.Streaming? {
- val request = resolveTicketedMediaRequest(ticketedPath) ?: return null
+ val request = resolveTicketedMediaRequest(ticketedPath, playbackRendition) ?: return null
return GatewayLoadedMedia.Streaming(
url = request.url,
headers = request.headers + ("Accept" to "video/*"),
client = client,
mimeType = mimeType,
+ retryPreparingPlayback = playbackRendition,
)
}
@@ -965,38 +1026,53 @@ class GatewaySession(
suspend fun loadTicketedMedia(
ticketedPath: String,
kind: GatewayMediaKind,
+ playbackRendition: Boolean,
): GatewayLoadedMedia.Buffered? =
withContext(Dispatchers.IO) {
if (kind == GatewayMediaKind.Video) return@withContext null
- val resolved = resolveTicketedMediaRequest(ticketedPath) ?: return@withContext null
+ val resolved = resolveTicketedMediaRequest(ticketedPath, playbackRendition) ?: return@withContext null
val request = Request.Builder().url(resolved.url).header("Accept", "${kind.wireValue}/*")
for ((name, value) in resolved.headers) {
request.header(name, value)
}
- val call = client.newCall(request.build())
- call.timeout().timeout(20, java.util.concurrent.TimeUnit.SECONDS)
- call.execute().use { response ->
- if (!response.isSuccessful) return@withContext null
- val body = response.body
- val mimeType = body.contentType()?.toString()?.lowercase(Locale.ROOT) ?: return@withContext null
- if (!mimeType.startsWith("${kind.wireValue}/")) return@withContext null
- val maximumBytes = kind.maximumBufferedBytes
- val declaredLength = body.contentLength()
- if (declaredLength > maximumBytes) return@withContext null
- val buffer = Buffer()
- val source = body.source()
- var total = 0L
- while (true) {
- val read = source.read(buffer, minOf(8192L, maximumBytes + 1L - total))
- if (read == -1L) break
- total += read
- if (total > maximumBytes) return@withContext null
+ val retry = GatewayPlaybackRetryState()
+ repeat(Int.MAX_VALUE) {
+ if (!retry.canAttempt()) return@withContext null
+ val call = client.newCall(request.build())
+ call.timeout().timeout(20, java.util.concurrent.TimeUnit.SECONDS)
+ var retryDelayMs: Long? = null
+ call.execute().use { response ->
+ if (response.code == 202 && playbackRendition) {
+ retryDelayMs = retry.nextDelayMs()
+ } else {
+ if (!response.isSuccessful) return@withContext null
+ val body = response.body
+ val mimeType = body.contentType()?.toString()?.lowercase(Locale.ROOT) ?: return@withContext null
+ if (!mimeType.startsWith("${kind.wireValue}/")) return@withContext null
+ val maximumBytes = kind.maximumBufferedBytes
+ val declaredLength = body.contentLength()
+ if (declaredLength > maximumBytes) return@withContext null
+ val buffer = Buffer()
+ val source = body.source()
+ var total = 0L
+ while (true) {
+ val read = source.read(buffer, minOf(8192L, maximumBytes + 1L - total))
+ if (read == -1L) break
+ total += read
+ if (total > maximumBytes) return@withContext null
+ }
+ return@withContext bufferedMedia(bytes = buffer.readByteArray(), mimeType = mimeType)
+ }
}
- bufferedMedia(bytes = buffer.readByteArray(), mimeType = mimeType)
+ delay(retryDelayMs ?: return@withContext null)
}
+ null
}
- private fun resolveTicketedMediaRequest(ticketedPath: String): TicketedMediaRequest? {
+ private fun resolveTicketedMediaRequest(
+ ticketedPath: String,
+ playbackRendition: Boolean = false,
+ ): TicketedMediaRequest? {
val uri = runCatching { URI(ticketedPath) }.getOrNull() ?: return null
val rawPath = uri.rawPath ?: return null
val rawQuery = uri.rawQuery ?: return null
@@ -1007,7 +1083,13 @@ class GatewaySession(
.any { field -> field.substringBefore('=') == "mediaTicket" && field.substringAfter('=', "").isNotEmpty() }
if (!rawPath.startsWith("/api/chat/media/outgoing/") || !hasMediaTicket) return null
val scheme = if (tlsConfig != null) "https" else "http"
- val url = "$scheme://${formatGatewayAuthority(endpoint.host, endpoint.port)}$ticketedPath"
+ val playbackPath =
+ if (playbackRendition && rawQuery.split('&').none { it.substringBefore('=') == "playback" }) {
+ "$ticketedPath&playback=1"
+ } else {
+ ticketedPath
+ }
+ val url = "$scheme://${formatGatewayAuthority(endpoint.host, endpoint.port)}$playbackPath"
val headers = mediaTransportHeaders()
return TicketedMediaRequest(url = url, headers = headers)
}
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt
index d847fba17783..13c58aee1b64 100644
--- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt
@@ -6,6 +6,7 @@ import ai.openclaw.app.ChatShareDraft
import ai.openclaw.app.SharedAttachment
import ai.openclaw.app.chat.ChatComposerOwner
import ai.openclaw.app.chat.OUTBOX_MAX_COMMAND_ATTACHMENT_BYTES
+import ai.openclaw.app.chat.OUTBOX_MAX_VIDEO_COMMAND_ATTACHMENT_BYTES
import ai.openclaw.app.chat.VoiceNoteRecorderState
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.saveable.listSaver
@@ -391,8 +392,9 @@ internal const val CHAT_COMPOSER_MAX_ATTACHMENTS = 8
// Gateway chat attachments default to 20 MiB (images: 6 MiB); Android's outbox further caps audio/documents at 8 MiB.
internal const val CHAT_COMPOSER_MAX_IMAGE_DECODED_BYTES = 6L * 1024L * 1024L
internal const val CHAT_COMPOSER_MAX_AUDIO_DECODED_BYTES = OUTBOX_MAX_COMMAND_ATTACHMENT_BYTES
+internal const val CHAT_COMPOSER_MAX_VIDEO_DECODED_BYTES = OUTBOX_MAX_VIDEO_COMMAND_ATTACHMENT_BYTES
internal const val CHAT_COMPOSER_MAX_DOCUMENT_DECODED_BYTES = OUTBOX_MAX_COMMAND_ATTACHMENT_BYTES
-internal const val CHAT_COMPOSER_MAX_DECODED_ATTACHMENT_BYTES = OUTBOX_MAX_COMMAND_ATTACHMENT_BYTES
+internal const val CHAT_COMPOSER_MAX_DECODED_ATTACHMENT_BYTES = CHAT_COMPOSER_MAX_VIDEO_DECODED_BYTES
internal const val CHAT_COMPOSER_MAX_BASE64_CHARS = ((CHAT_COMPOSER_MAX_DECODED_ATTACHMENT_BYTES + 2) / 3) * 4
internal const val CHAT_COMPOSER_MAX_TOTAL_ATTACHMENTS = 24
internal const val CHAT_COMPOSER_MAX_TOTAL_DECODED_ATTACHMENT_BYTES = CHAT_COMPOSER_MAX_DECODED_ATTACHMENT_BYTES * 3
@@ -409,23 +411,44 @@ internal fun admitChatAttachments(
maxAttachmentCount: Int = CHAT_COMPOSER_MAX_ATTACHMENTS,
maxBase64Chars: Long = CHAT_COMPOSER_MAX_BASE64_CHARS,
maxDecodedBytes: Long = CHAT_COMPOSER_MAX_DECODED_ATTACHMENT_BYTES,
+ maxNonVideoBase64Chars: Long = ((OUTBOX_MAX_COMMAND_ATTACHMENT_BYTES + 2) / 3) * 4,
+ maxNonVideoDecodedBytes: Long = OUTBOX_MAX_COMMAND_ATTACHMENT_BYTES,
): ChatAttachmentAdmission {
- require(maxAttachmentCount >= 0 && maxBase64Chars >= 0 && maxDecodedBytes >= 0)
+ require(
+ maxAttachmentCount >= 0 &&
+ maxBase64Chars >= 0 &&
+ maxDecodedBytes >= 0 &&
+ maxNonVideoBase64Chars >= 0 &&
+ maxNonVideoDecodedBytes >= 0,
+ )
val accepted = mutableListOf()
var base64Chars = currentAttachments.sumOf { it.base64.length.toLong() }
var decodedBytes = currentAttachments.sumOf { decodedBase64ByteCount(it.base64) }
+ var nonVideoBase64Chars =
+ currentAttachments.filterNot { it.mimeType.startsWith("video/", ignoreCase = true) }.sumOf { it.base64.length.toLong() }
+ var nonVideoDecodedBytes =
+ currentAttachments
+ .filterNot { it.mimeType.startsWith("video/", ignoreCase = true) }
+ .sumOf { decodedBase64ByteCount(it.base64) }
var omittedCount = 0
for (candidate in candidates) {
val candidateBase64Chars = candidate.base64.length.toLong()
val candidateDecodedBytes = decodedBase64ByteCount(candidate.base64)
+ val candidateIsVideo = candidate.mimeType.startsWith("video/", ignoreCase = true)
val withinKind = candidateDecodedBytes <= chatComposerAttachmentDecodedByteLimit(candidate.mimeType)
val withinCount = currentAttachments.size + accepted.size < maxAttachmentCount
val withinBase64 = candidateBase64Chars <= maxBase64Chars - base64Chars
val withinDecoded = candidateDecodedBytes <= maxDecodedBytes - decodedBytes
- if (withinKind && withinCount && withinBase64 && withinDecoded) {
+ val withinNonVideoBase64 = candidateIsVideo || candidateBase64Chars <= maxNonVideoBase64Chars - nonVideoBase64Chars
+ val withinNonVideoDecoded = candidateIsVideo || candidateDecodedBytes <= maxNonVideoDecodedBytes - nonVideoDecodedBytes
+ if (withinKind && withinCount && withinBase64 && withinDecoded && withinNonVideoBase64 && withinNonVideoDecoded) {
accepted += candidate
base64Chars += candidateBase64Chars
decodedBytes += candidateDecodedBytes
+ if (!candidateIsVideo) {
+ nonVideoBase64Chars += candidateBase64Chars
+ nonVideoDecodedBytes += candidateDecodedBytes
+ }
} else {
omittedCount += 1
}
@@ -437,6 +460,7 @@ internal fun chatComposerAttachmentDecodedByteLimit(mimeType: String): Long =
when {
mimeType.startsWith("image/", ignoreCase = true) -> CHAT_COMPOSER_MAX_IMAGE_DECODED_BYTES
mimeType.startsWith("audio/", ignoreCase = true) -> CHAT_COMPOSER_MAX_AUDIO_DECODED_BYTES
+ mimeType.startsWith("video/", ignoreCase = true) -> CHAT_COMPOSER_MAX_VIDEO_DECODED_BYTES
else -> CHAT_COMPOSER_MAX_DOCUMENT_DECODED_BYTES
}
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatImageCodec.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatImageCodec.kt
index ba36a65095e1..f658fd21be47 100644
--- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatImageCodec.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatImageCodec.kt
@@ -11,6 +11,7 @@ import android.content.ContentResolver
import android.database.Cursor
import android.graphics.Bitmap
import android.graphics.BitmapFactory
+import android.media.MediaMetadataRetriever
import android.net.Uri
import android.provider.OpenableColumns
import android.util.Base64
@@ -24,6 +25,8 @@ private const val CHAT_ATTACHMENT_MAX_WIDTH = 1600
private const val CHAT_ATTACHMENT_START_QUALITY = 85
private const val CHAT_DECODE_MAX_DIMENSION = 1600
private const val CHAT_IMAGE_CACHE_BYTES = 16 * 1024 * 1024
+private const val VIDEO_THUMBNAIL_MAX_DIMENSION = 192
+private const val VIDEO_THUMBNAIL_QUALITY = 72
private val decodedBitmapCache =
object : LruCache(CHAT_IMAGE_CACHE_BYTES) {
@@ -33,7 +36,7 @@ private val decodedBitmapCache =
): Int = value.byteCount.coerceAtLeast(1)
}
-internal fun loadPickedAudioOrDocumentAttachment(
+internal fun loadPickedMediaOrDocumentAttachment(
resolver: ContentResolver,
uri: Uri,
): PendingAttachment {
@@ -65,9 +68,55 @@ internal fun loadSharedAttachment(
fileName = sharedAttachmentFileName(resolver, attachment.uri),
mimeType = mimeType,
base64 = Base64.encodeToString(bytes, Base64.NO_WRAP),
+ videoThumbnailBase64 =
+ if (kind == SharedAttachmentKind.Video) loadVideoThumbnailBase64(resolver, attachment.uri) else null,
)
}
+/** Thumbnail extraction is presentation-only; an unsupported container still stages as a video. */
+private fun loadVideoThumbnailBase64(
+ resolver: ContentResolver,
+ uri: Uri,
+): String? =
+ runCatching {
+ val retriever = MediaMetadataRetriever()
+ try {
+ resolver.openAssetFileDescriptor(uri, "r")?.use { descriptor ->
+ if (descriptor.declaredLength >= 0L) {
+ retriever.setDataSource(descriptor.fileDescriptor, descriptor.startOffset, descriptor.declaredLength)
+ } else {
+ retriever.setDataSource(descriptor.fileDescriptor)
+ }
+ } ?: return@runCatching null
+ val frame = retriever.getFrameAtTime(-1L, MediaMetadataRetriever.OPTION_CLOSEST_SYNC) ?: return@runCatching null
+ try {
+ val longestEdge = max(frame.width, frame.height)
+ val preview =
+ if (longestEdge <= VIDEO_THUMBNAIL_MAX_DIMENSION) {
+ frame
+ } else {
+ val scale = VIDEO_THUMBNAIL_MAX_DIMENSION.toDouble() / longestEdge.toDouble()
+ frame.scale(
+ max(1, (frame.width * scale).roundToInt()),
+ max(1, (frame.height * scale).roundToInt()),
+ true,
+ )
+ }
+ try {
+ val output = ByteArrayOutputStream()
+ if (!preview.compress(Bitmap.CompressFormat.JPEG, VIDEO_THUMBNAIL_QUALITY, output)) return@runCatching null
+ Base64.encodeToString(output.toByteArray(), Base64.NO_WRAP)
+ } finally {
+ if (preview !== frame) preview.recycle()
+ }
+ } finally {
+ frame.recycle()
+ }
+ } finally {
+ retriever.release()
+ }
+ }.getOrNull()
+
private fun readBoundedAttachmentBytes(
resolver: ContentResolver,
uri: Uri,
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMediaPlayer.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMediaPlayer.kt
index 939146575def..308a1e612455 100644
--- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMediaPlayer.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMediaPlayer.kt
@@ -3,6 +3,7 @@ package ai.openclaw.app.ui.chat
import ai.openclaw.app.chat.ChatMessageContent
import ai.openclaw.app.gateway.GatewayLoadedMedia
import ai.openclaw.app.gateway.GatewayMediaKind
+import ai.openclaw.app.gateway.GatewayPreparingPlaybackInterceptor
import ai.openclaw.app.i18n.nativeString
import ai.openclaw.app.ui.design.ClawTheme
import android.content.Context
@@ -63,6 +64,7 @@ import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
+import androidx.media3.session.MediaSession
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import kotlinx.coroutines.CancellationException
@@ -110,6 +112,60 @@ internal class ChatMediaPlaybackClaims(
}
}
+internal class ChatMediaSessionLifecycle(
+ private val release: (S) -> Unit,
+) {
+ private var activeOwner: T? = null
+ private var activeSession: S? = null
+
+ fun activate(
+ owner: T,
+ create: (T) -> S,
+ ): S {
+ if (activeOwner === owner) return checkNotNull(activeSession)
+ releaseActive()
+ return create(owner).also { session ->
+ activeOwner = owner
+ activeSession = session
+ }
+ }
+
+ fun release(owner: T): Boolean {
+ if (activeOwner !== owner) return false
+ releaseActive()
+ return true
+ }
+
+ private fun releaseActive() {
+ activeSession?.let(release)
+ activeSession = null
+ activeOwner = null
+ }
+}
+
+@OptIn(UnstableApi::class)
+internal fun inlineMediaSessionPlayerCommands(availableCommands: Player.Commands): Player.Commands =
+ availableCommands
+ .buildUpon()
+ .remove(Player.COMMAND_SET_MEDIA_ITEM)
+ .remove(Player.COMMAND_CHANGE_MEDIA_ITEMS)
+ .remove(Player.COMMAND_STOP)
+ .build()
+
+@OptIn(UnstableApi::class)
+private object ChatInlineMediaSessionCallback : MediaSession.Callback {
+ override fun onConnect(
+ session: MediaSession,
+ controller: MediaSession.ControllerInfo,
+ ): MediaSession.ConnectionResult {
+ if (!controller.isTrusted) return MediaSession.ConnectionResult.reject()
+ return MediaSession.ConnectionResult
+ .AcceptedResultBuilder(session)
+ .setAvailablePlayerCommands(inlineMediaSessionPlayerCommands(session.player.availableCommands))
+ .build()
+ }
+}
+
private object ChatMediaPlaybackArbiter {
private data class AudioFocusHandle(
val manager: AudioManager,
@@ -125,6 +181,7 @@ private object ChatMediaPlaybackArbiter {
private val mainHandler = Handler(Looper.getMainLooper())
private val claims = ChatMediaPlaybackClaims(::pauseAndAbandon, ::stopAndRelease)
+ private val sessions = ChatMediaSessionLifecycle(MediaSession::release)
private var playbackIntentGeneration = 0L
@Synchronized
@@ -192,6 +249,17 @@ private object ChatMediaPlaybackArbiter {
}
val playback = existing ?: ActivePlayback(player = player, onReleased = onReleased).also(claims::claim)
playback.audioFocus = AudioFocusHandle(manager = audioManager, request = focusRequest)
+ try {
+ sessions.activate(player) { activePlayer ->
+ MediaSession
+ .Builder(context, activePlayer)
+ .setCallback(ChatInlineMediaSessionCallback)
+ .build()
+ }
+ } catch (_: Throwable) {
+ pauseAndAbandon(playback)
+ return false
+ }
return true
}
@@ -203,6 +271,7 @@ private object ChatMediaPlaybackArbiter {
private fun pauseAndAbandon(playback: ActivePlayback) {
playback.player.pause()
+ sessions.release(playback.player)
playback.audioFocus?.let { focus -> focus.manager.abandonAudioFocusRequest(focus.request) }
playback.audioFocus = null
}
@@ -218,7 +287,7 @@ private object ChatMediaPlaybackArbiter {
internal fun ChatAudioPlayerCard(
content: ChatMessageContent,
playbackBlocked: Boolean,
- loadMedia: suspend (String, GatewayMediaKind) -> GatewayLoadedMedia?,
+ loadMedia: suspend (String, GatewayMediaKind, Boolean) -> GatewayLoadedMedia?,
) {
ChatMediaPlayerCard(
content = content,
@@ -232,7 +301,7 @@ internal fun ChatAudioPlayerCard(
internal fun ChatVideoPlayerCard(
content: ChatMessageContent,
playbackBlocked: Boolean,
- loadMedia: suspend (String, GatewayMediaKind) -> GatewayLoadedMedia?,
+ loadMedia: suspend (String, GatewayMediaKind, Boolean) -> GatewayLoadedMedia?,
) {
ChatMediaPlayerCard(
content = content,
@@ -248,7 +317,7 @@ private fun ChatMediaPlayerCard(
content: ChatMessageContent,
kind: GatewayMediaKind,
playbackBlocked: Boolean,
- loadMedia: suspend (String, GatewayMediaKind) -> GatewayLoadedMedia?,
+ loadMedia: suspend (String, GatewayMediaKind, Boolean) -> GatewayLoadedMedia?,
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
@@ -337,7 +406,7 @@ private fun ChatMediaPlayerCard(
scope.launch {
val loaded =
try {
- loadMedia(artifactId, kind)
+ loadMedia(artifactId, kind, content.playback == "transcode")
} catch (error: CancellationException) {
throw error
} catch (_: Throwable) {
@@ -380,12 +449,14 @@ private fun ChatMediaPlayerCard(
}
override fun onPlaybackStateChanged(playbackState: Int) {
+ if (playbackState == Player.STATE_READY) loading = false
if (playbackState == Player.STATE_ENDED) {
ChatMediaPlaybackArbiter.pause(created)
}
}
override fun onPlayerError(playbackException: PlaybackException) {
+ loading = false
if (!ChatMediaPlaybackArbiter.release(created)) {
disposeUnclaimedPlayer(created, prepared.tempFile)
}
@@ -394,7 +465,7 @@ private fun ChatMediaPlayerCard(
},
)
player = created
- loading = false
+ if (content.playback != "transcode") loading = false
if (!registerPrepared(created, prepared.tempFile, intentGeneration)) {
disposeUnclaimedPlayer(created, prepared.tempFile)
return@launch
@@ -448,6 +519,7 @@ private fun ChatMediaPlayerCard(
content = content,
player = player,
loading = loading,
+ preparingPlayback = loading && content.playback == "transcode",
isPlaying = isPlaying,
playbackBlocked = playbackBlocked,
error = error,
@@ -457,6 +529,7 @@ private fun ChatMediaPlayerCard(
AudioPlayerSurface(
content = content,
loading = loading,
+ preparingPlayback = loading && content.playback == "transcode",
isPlaying = isPlaying,
playbackBlocked = playbackBlocked,
error = error,
@@ -477,6 +550,7 @@ private fun ChatMediaPlayerCard(
private fun AudioPlayerSurface(
content: ChatMessageContent,
loading: Boolean,
+ preparingPlayback: Boolean,
isPlaying: Boolean,
playbackBlocked: Boolean,
error: String?,
@@ -525,7 +599,13 @@ private fun AudioPlayerSurface(
style = ClawTheme.type.body,
color = ClawTheme.colors.text,
)
- val status = error ?: if (playbackBlocked) nativeString("Paused for voice playback") else null
+ val status =
+ when {
+ error != null -> error
+ preparingPlayback -> nativeString("Preparing playback…")
+ playbackBlocked -> nativeString("Paused for voice playback")
+ else -> null
+ }
status?.let { Text(it, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted) }
}
}
@@ -549,6 +629,7 @@ private fun VideoPlayerSurface(
content: ChatMessageContent,
player: ExoPlayer?,
loading: Boolean,
+ preparingPlayback: Boolean,
isPlaying: Boolean,
playbackBlocked: Boolean,
error: String?,
@@ -602,7 +683,14 @@ private fun VideoPlayerSurface(
style = ClawTheme.type.caption,
color = ClawTheme.colors.textMuted,
)
- (error ?: if (playbackBlocked) nativeString("Paused for voice playback") else null)?.let {
+ val status =
+ when {
+ error != null -> error
+ preparingPlayback -> nativeString("Preparing playback…")
+ playbackBlocked -> nativeString("Paused for voice playback")
+ else -> null
+ }
+ status?.let {
Text(it, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
}
}
@@ -627,6 +715,7 @@ private suspend fun prepareMediaSource(
headers = loaded.headers,
client = loaded.client,
tempFile = file,
+ retryPreparingPlayback = false,
)
}
is GatewayLoadedMedia.Streaming ->
@@ -636,6 +725,7 @@ private suspend fun prepareMediaSource(
headers = loaded.headers,
client = loaded.client,
tempFile = null,
+ retryPreparingPlayback = loaded.retryPreparingPlayback,
)
}
}
@@ -666,7 +756,16 @@ private fun buildMediaPlayer(
context: Context,
source: PreparedMediaSource,
): ExoPlayer {
- val httpFactory = OkHttpDataSource.Factory(source.client).setDefaultRequestProperties(source.headers)
+ val client =
+ if (source.retryPreparingPlayback) {
+ source.client
+ .newBuilder()
+ .addInterceptor(GatewayPreparingPlaybackInterceptor())
+ .build()
+ } else {
+ source.client
+ }
+ val httpFactory = OkHttpDataSource.Factory(client).setDefaultRequestProperties(source.headers)
val dataSourceFactory = DefaultDataSource.Factory(context, httpFactory)
val player =
ExoPlayer
@@ -698,6 +797,7 @@ private data class PreparedMediaSource(
val headers: Map,
val client: okhttp3.OkHttpClient,
val tempFile: File?,
+ val retryPreparingPlayback: Boolean,
)
internal fun ChatMessageContent.isVideoAttachment(): Boolean = type == "video" || mimeType?.startsWith("video/") == true
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt
index 192f65a5fe86..5d613df9edec 100644
--- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt
@@ -110,7 +110,7 @@ internal fun ChatMessageBubble(
imageResolverReady: Boolean = false,
loadImageArtifact: suspend (String) -> GatewayLoadedImage? = { null },
inlineMediaPlaybackBlocked: Boolean = false,
- loadMediaArtifact: suspend (String, GatewayMediaKind) -> GatewayLoadedMedia? = { _, _ -> null },
+ loadMediaArtifact: suspend (String, GatewayMediaKind, Boolean) -> GatewayLoadedMedia? = { _, _, _ -> null },
) {
val role = normalizeVisibleChatMessageRole(message.role) ?: return
val style = bubbleStyle(role)
@@ -242,7 +242,7 @@ private fun ChatMessageBody(
imageResolverReady: Boolean,
loadImageArtifact: suspend (String) -> GatewayLoadedImage?,
inlineMediaPlaybackBlocked: Boolean,
- loadMediaArtifact: suspend (String, GatewayMediaKind) -> GatewayLoadedMedia?,
+ loadMediaArtifact: suspend (String, GatewayMediaKind, Boolean) -> GatewayLoadedMedia?,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
for (part in content) {
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 c45d42724376..f9376fb9c6d0 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
@@ -8,6 +8,7 @@ import ai.openclaw.app.MainViewModel
import ai.openclaw.app.PendingAssistantAutoSend
import ai.openclaw.app.R
import ai.openclaw.app.SHARED_AUDIO_DOCUMENT_MIME_TYPES
+import ai.openclaw.app.SHARED_VIDEO_MIME_TYPES
import ai.openclaw.app.chat.ChatCommandEntry
import ai.openclaw.app.chat.ChatComposerOwner
import ai.openclaw.app.chat.ChatMessage
@@ -73,6 +74,7 @@ import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
@@ -116,6 +118,7 @@ import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.StarBorder
+import androidx.compose.material.icons.filled.Videocam
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -138,12 +141,15 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.input.key.onPreInterceptKeyBeforeSoftKeyboard
+import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
@@ -448,7 +454,7 @@ fun ChatScreen(
}
}
}
- val pickAudioOrDocument =
+ val pickMediaOrDocument =
rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
val lease = filePickerOwnerCheckpoint.consume() ?: return@rememberLauncherForActivityResult
if (uri == null) {
@@ -469,7 +475,7 @@ fun ChatScreen(
) {
listOfNotNull(
try {
- loadPickedAudioOrDocumentAttachment(resolver, uri)
+ loadPickedMediaOrDocumentAttachment(resolver, uri)
} catch (err: CancellationException) {
throw err
} catch (_: Throwable) {
@@ -798,7 +804,13 @@ fun ChatScreen(
if (!viewModel.isCurrentChatComposerOwner(composerOwner)) return@ChatComposer
val authorizationId = composerState.beginMediaAcquisition(composerOwner) ?: return@ChatComposer
filePickerOwnerCheckpoint.begin(composerOwner, authorizationId)
- pickAudioOrDocument.launch(SHARED_AUDIO_DOCUMENT_MIME_TYPES)
+ pickMediaOrDocument.launch(SHARED_AUDIO_DOCUMENT_MIME_TYPES)
+ },
+ onPickVideo = {
+ if (!viewModel.isCurrentChatComposerOwner(composerOwner)) return@ChatComposer
+ val authorizationId = composerState.beginMediaAcquisition(composerOwner) ?: return@ChatComposer
+ filePickerOwnerCheckpoint.begin(composerOwner, authorizationId)
+ pickMediaOrDocument.launch(SHARED_VIDEO_MIME_TYPES)
},
onRemoveAttachment = { id -> composerState.removeAttachments(composerOwner, setOf(id)) },
voiceNoteState = voiceNoteState,
@@ -1271,7 +1283,7 @@ private fun ChatMessageList(
inlineMediaPlaybackBlocked: Boolean,
resolveInlineWidgetResource: suspend (String, ChatWidgetResource?) -> ChatWidgetResource?,
loadImageArtifact: suspend (String) -> GatewayLoadedImage?,
- loadMediaArtifact: suspend (String, GatewayMediaKind) -> GatewayLoadedMedia?,
+ loadMediaArtifact: suspend (String, GatewayMediaKind, Boolean) -> GatewayLoadedMedia?,
modifier: Modifier = Modifier,
) {
val baseTimeline =
@@ -1647,7 +1659,7 @@ private fun ChatBubble(
inlineWidgetResolverReady: Boolean,
resolveInlineWidgetResource: suspend (String, ChatWidgetResource?) -> ChatWidgetResource?,
loadImageArtifact: suspend (String) -> GatewayLoadedImage?,
- loadMediaArtifact: suspend (String, GatewayMediaKind) -> GatewayLoadedMedia?,
+ loadMediaArtifact: suspend (String, GatewayMediaKind, Boolean) -> GatewayLoadedMedia?,
) {
val normalizedRole = role.trim().lowercase(Locale.US)
val isUser = normalizedRole == "user"
@@ -2038,6 +2050,7 @@ private fun ChatComposer(
onOpenModelPicker: () -> Unit,
onPickImages: () -> Unit,
onPickAudioOrDocument: () -> Unit,
+ onPickVideo: () -> Unit,
onRemoveAttachment: (String) -> Unit,
voiceNoteState: VoiceNoteRecorderState,
voiceNoteElapsedMs: Long,
@@ -2156,6 +2169,7 @@ private fun ChatComposer(
onValueChange = onValueChange,
onPickImages = onPickImages,
onPickAudioOrDocument = onPickAudioOrDocument,
+ onPickVideo = onPickVideo,
onStartVoiceNote = onStartVoiceNote,
recordVoiceNoteEnabled = recordVoiceNoteEnabled,
dictationActive = dictationActive,
@@ -2600,6 +2614,7 @@ private fun ChatInputPill(
onValueChange: (String) -> Unit,
onPickImages: () -> Unit,
onPickAudioOrDocument: () -> Unit,
+ onPickVideo: () -> Unit,
onStartVoiceNote: () -> Unit,
recordVoiceNoteEnabled: Boolean,
dictationActive: Boolean,
@@ -2635,6 +2650,11 @@ private fun ChatInputPill(
Icon(imageVector = Icons.Default.AttachFile, contentDescription = nativeString("Attachment"), modifier = Modifier.size(20.dp))
}
}
+ Surface(onClick = onPickVideo, modifier = Modifier.size(ClawTheme.spacing.touchTarget), shape = CircleShape, color = ClawTheme.colors.surfaceRaised, contentColor = ClawTheme.colors.text) {
+ Box(contentAlignment = Alignment.Center) {
+ Icon(imageVector = Icons.Default.Videocam, contentDescription = nativeString("Attach video"), modifier = Modifier.size(20.dp))
+ }
+ }
Box(modifier = Modifier.weight(1f)) {
ChatTextFieldValueAdapter(
value = value,
@@ -2761,6 +2781,10 @@ private fun AttachmentChip(
attachment: PendingAttachment,
onRemove: () -> Unit,
) {
+ val videoThumbnail =
+ remember(attachment.videoThumbnailBase64) {
+ attachment.videoThumbnailBase64?.let(::decodeBase64Bitmap)
+ }
Surface(
shape = RoundedCornerShape(ClawTheme.radii.pill),
color = ClawTheme.colors.surfaceRaised,
@@ -2774,6 +2798,17 @@ private fun AttachmentChip(
) {
if (attachment.mimeType.startsWith("audio/")) {
Icon(imageVector = Icons.Default.Mic, contentDescription = null, modifier = Modifier.size(14.dp), tint = ClawTheme.colors.textMuted)
+ } else if (attachment.mimeType.startsWith("video/")) {
+ if (videoThumbnail != null) {
+ Image(
+ bitmap = videoThumbnail.asImageBitmap(),
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.size(28.dp).clip(RoundedCornerShape(5.dp)),
+ )
+ } else {
+ Icon(imageVector = Icons.Default.Videocam, contentDescription = null, modifier = Modifier.size(14.dp), tint = ClawTheme.colors.textMuted)
+ }
}
Text(
text =
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/PendingAttachment.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/PendingAttachment.kt
index 8efedad3ba69..d54476c52f3c 100644
--- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/PendingAttachment.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/PendingAttachment.kt
@@ -19,6 +19,7 @@ data class PendingAttachment(
val mimeType: String,
val base64: String,
val durationMs: Long? = null,
+ val videoThumbnailBase64: String? = null,
)
internal data class ChatComposerAttachmentMigration(
@@ -205,6 +206,7 @@ internal fun List.toPendingAttachments(): List "audio"
+ mimeType.startsWith("video/") -> "video"
mimeType.startsWith("image/") -> "image"
else -> "file"
}
diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ShareLaunchTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ShareLaunchTest.kt
index 22e3799667b1..5082efd5b3b8 100644
--- a/apps/android/app/src/test/java/ai/openclaw/app/ShareLaunchTest.kt
+++ b/apps/android/app/src/test/java/ai/openclaw/app/ShareLaunchTest.kt
@@ -129,6 +129,24 @@ class ShareLaunchTest {
assertTrue(parsed.attachments.all { it.kind == SharedAttachmentKind.Audio })
}
+ @Test
+ fun acceptsVideoShareFromProviderMimeType() {
+ val video = Uri.parse("content://media/shared/video")
+ val parsed =
+ parseShare(
+ intent =
+ Intent(Intent.ACTION_SEND)
+ .setType("video/*")
+ .putExtra(Intent.EXTRA_STREAM, video),
+ mimeTypes = mapOf(video to "video/mp4"),
+ )
+
+ requireNotNull(parsed)
+ assertEquals(SharedAttachmentKind.Video, parsed.attachments.single().kind)
+ assertEquals("video/mp4", parsed.attachments.single().mimeType)
+ assertTrue("video/*" in SHARED_ATTACHMENT_MIME_ALLOWLIST)
+ }
+
@Test
fun acceptsCuratedDocumentShare() {
val document = Uri.parse("content://docs/shared/report")
diff --git a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerOutboxTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerOutboxTest.kt
index a74fcff413fb..47c1ac8df52b 100644
--- a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerOutboxTest.kt
+++ b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerOutboxTest.kt
@@ -107,7 +107,7 @@ class ChatControllerOutboxTest {
enqueueGate?.await()
if (gatewayIds.values.count { it == gatewayId } >= capacity) return ChatOutboxEnqueueResult.QueueFull
val commandBytes = attachments.sumOf { it.bytes.size.toLong() }
- if (commandBytes > OUTBOX_MAX_COMMAND_ATTACHMENT_BYTES) return ChatOutboxEnqueueResult.AttachmentsTooLarge
+ if (!outboxCommandAttachmentsWithinByteLimits(attachments)) return ChatOutboxEnqueueResult.AttachmentsTooLarge
val queuedBytes = attachmentBytes.values.sumOf { list -> list.sumOf { it.size.toLong() } }
if (commandBytes > 0 && queuedBytes + commandBytes > OUTBOX_MAX_GATEWAY_ATTACHMENT_BYTES) {
return ChatOutboxEnqueueResult.StorageFull
diff --git a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatMessageContentParsingTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatMessageContentParsingTest.kt
index 80410f8c7c03..9912e9a3ad8a 100644
--- a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatMessageContentParsingTest.kt
+++ b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatMessageContentParsingTest.kt
@@ -332,6 +332,26 @@ class ChatMessageContentParsingTest {
)
}
+ @Test
+ fun parsesSupportedPlaybackRenditions() {
+ val direct =
+ Json.parseToJsonElement(
+ """{"type":"video","mimeType":"video/mp4","playback":"transcode"}""",
+ )
+ val attachment =
+ Json.parseToJsonElement(
+ """{"type":"attachment","attachment":{"kind":"audio","mimeType":"audio/mp4","playback":"native"}}""",
+ )
+ val unsupported =
+ Json.parseToJsonElement(
+ """{"type":"video","mimeType":"video/mp4","playback":"future"}""",
+ )
+
+ assertEquals("transcode", parseChatMessageContent(direct)?.playback)
+ assertEquals("native", parseChatMessageContent(attachment)?.playback)
+ assertEquals(null, parseChatMessageContent(unsupported)?.playback)
+ }
+
@Test
fun dropsOversizedInlineImageContentBeforeRendering() {
val oversized = "A".repeat(CHAT_IMAGE_MAX_BASE64_CHARS + 1)
diff --git a/apps/android/app/src/test/java/ai/openclaw/app/chat/RoomChatCommandOutboxTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/chat/RoomChatCommandOutboxTest.kt
index 94da84c5c114..6367e59d2249 100644
--- a/apps/android/app/src/test/java/ai/openclaw/app/chat/RoomChatCommandOutboxTest.kt
+++ b/apps/android/app/src/test/java/ai/openclaw/app/chat/RoomChatCommandOutboxTest.kt
@@ -801,6 +801,59 @@ class RoomChatCommandOutboxTest {
assertTrue(store.load("gateway-a").isEmpty())
}
+ @Test
+ fun videoCommandsUseServerAttachmentCapWithoutRaisingOtherMediaCaps() =
+ runTest {
+ val aboveDefaultCap = ByteArray((OUTBOX_MAX_COMMAND_ATTACHMENT_BYTES + 1L).toInt())
+ val video =
+ store.enqueue(
+ gatewayId = "gateway-a",
+ sessionKey = "main",
+ text = "video",
+ thinkingLevel = "off",
+ nowMs = 10,
+ ownerAgentId = "main",
+ attachments = listOf(payload(aboveDefaultCap, type = "video", mimeType = "video/mp4")),
+ )
+ val document =
+ store.enqueue(
+ gatewayId = "gateway-a",
+ sessionKey = "main",
+ text = "document",
+ thinkingLevel = "off",
+ nowMs = 11,
+ ownerAgentId = "main",
+ attachments = listOf(payload(aboveDefaultCap, type = "file", mimeType = "application/pdf")),
+ )
+
+ assertTrue(video is ChatOutboxEnqueueResult.Queued)
+ assertEquals(ChatOutboxEnqueueResult.AttachmentsTooLarge, document)
+ assertEquals(20L * 1024L * 1024L, OUTBOX_MAX_VIDEO_COMMAND_ATTACHMENT_BYTES)
+ }
+
+ @Test
+ fun mixedVideoCommandKeepsNonVideoAggregateCap() =
+ runTest {
+ val document = ByteArray(5 * 1024 * 1024)
+ val refused =
+ store.enqueue(
+ gatewayId = "gateway-a",
+ sessionKey = "main",
+ text = "mixed",
+ thinkingLevel = "off",
+ nowMs = 10,
+ ownerAgentId = "main",
+ attachments =
+ listOf(
+ payload(document, fileName = "one.pdf", type = "file", mimeType = "application/pdf"),
+ payload(document, fileName = "two.pdf", type = "file", mimeType = "application/pdf"),
+ payload(byteArrayOf(1), fileName = "clip.mp4", type = "video", mimeType = "video/mp4"),
+ ),
+ )
+
+ assertEquals(ChatOutboxEnqueueResult.AttachmentsTooLarge, refused)
+ }
+
@Test
fun gatewayAttachmentByteBudgetRefusesWhenExhaustedAndRecoversAfterDelete() =
runTest {
diff --git a/apps/android/app/src/test/java/ai/openclaw/app/chat/RoomChatTranscriptCacheTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/chat/RoomChatTranscriptCacheTest.kt
index 16cf9074e3d8..28371d884732 100644
--- a/apps/android/app/src/test/java/ai/openclaw/app/chat/RoomChatTranscriptCacheTest.kt
+++ b/apps/android/app/src/test/java/ai/openclaw/app/chat/RoomChatTranscriptCacheTest.kt
@@ -95,6 +95,7 @@ class RoomChatTranscriptCacheTest {
fileName = "demo.mp4",
artifactId = "artifact_managed_media_44444444-4444-4444-8444-444444444444",
durationMs = 5_300,
+ playback = "transcode",
width = 1920,
height = 1080,
)
diff --git a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionCustomHeadersTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionCustomHeadersTest.kt
index 791ec4dc22aa..98039b378d07 100644
--- a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionCustomHeadersTest.kt
+++ b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionCustomHeadersTest.kt
@@ -12,6 +12,8 @@ import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
+import okhttp3.OkHttpClient
+import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
@@ -31,6 +33,7 @@ import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import java.util.UUID
import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference
private const val TEST_TIMEOUT_MS = 8_000L
@@ -76,6 +79,12 @@ class GatewaySessionCustomHeadersTest {
val videoAttachmentId = "22222222-2222-4222-8222-222222222222"
val videoArtifactId = "artifact_managed_media_$videoAttachmentId"
val videoPath = "/api/chat/media/outgoing/main/$videoAttachmentId/full?mediaTicket=video-ticket"
+ val audioAttachmentId = "33333333-3333-4333-8333-333333333333"
+ val audioArtifactId = "artifact_managed_media_$audioAttachmentId"
+ val audioPath = "/api/chat/media/outgoing/main/$audioAttachmentId/full?mediaTicket=audio-ticket"
+ val audioPlaybackPath = "$audioPath&playback=1"
+ val audioBytes = byteArrayOf(5, 6, 7, 8)
+ val audioRequestCount = AtomicInteger()
val server =
MockWebServer().apply {
dispatcher =
@@ -87,6 +96,14 @@ class GatewaySessionCustomHeadersTest {
.setHeader("Content-Type", "image/png")
.setBody(Buffer().write(imageBytes))
}
+ if (request.path == audioPlaybackPath) {
+ if (audioRequestCount.incrementAndGet() == 1) {
+ return MockResponse().setResponseCode(202).setBody("""{"status":"preparing"}""")
+ }
+ return MockResponse()
+ .setHeader("Content-Type", "audio/mp4")
+ .setBody(Buffer().write(audioBytes))
+ }
return MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
@@ -118,6 +135,15 @@ class GatewaySessionCustomHeadersTest {
webSocket.send(
"""{"type":"res","id":"$id","ok":true,"payload":{"artifact":{"id":"$videoArtifactId","type":"video","mimeType":"video/mp4","download":{"mode":"url"}},"url":"$videoPath"}}""",
)
+ } else if (frame["params"]
+ ?.jsonObject
+ ?.get("artifactId")
+ ?.jsonPrimitive
+ ?.content == audioArtifactId
+ ) {
+ webSocket.send(
+ """{"type":"res","id":"$id","ok":true,"payload":{"artifact":{"id":"$audioArtifactId","type":"audio","mimeType":"audio/mp4","download":{"mode":"url"}},"url":"$audioPath"}}""",
+ )
} else {
webSocket.send(
"""{"type":"res","id":"$id","ok":true,"payload":{"url":"$imagePath"}}""",
@@ -184,6 +210,17 @@ class GatewaySessionCustomHeadersTest {
assertEquals("http://127.0.0.1:${server.port}$videoPath", streamed.url)
assertEquals("video/*", streamed.headers["Accept"])
assertEquals("video/mp4", streamed.mimeType)
+ assertEquals(false, streamed.retryPreparingPlayback)
+
+ val transcodedVideo =
+ session.loadMediaArtifact(stableId, "main", "main", videoArtifactId, GatewayMediaKind.Video, true) as GatewayLoadedMedia.Streaming
+ assertEquals("http://127.0.0.1:${server.port}$videoPath&playback=1", transcodedVideo.url)
+ assertTrue(transcodedVideo.retryPreparingPlayback)
+
+ val audio =
+ session.loadMediaArtifact(stableId, "main", "main", audioArtifactId, GatewayMediaKind.Audio, true) as GatewayLoadedMedia.Buffered
+ assertArrayEquals(audioBytes, audio.bytes)
+ assertEquals(2, audioRequestCount.get())
} finally {
session.disconnectAndJoin()
scope.cancel()
@@ -191,6 +228,74 @@ class GatewaySessionCustomHeadersTest {
}
}
+ @Test
+ fun preparingPlaybackInterceptorRetries202WithoutSurfacingLoadError() {
+ val server = MockWebServer()
+ server.enqueue(MockResponse().setResponseCode(202).setBody("""{"status":"preparing"}"""))
+ server.enqueue(MockResponse().setResponseCode(200).setBody("ready"))
+ server.start()
+ var nowMs = 0L
+ val client =
+ OkHttpClient
+ .Builder()
+ .addInterceptor(
+ GatewayPreparingPlaybackInterceptor(
+ policy = GatewayPlaybackRetryPolicy(maxElapsedMs = 100L, initialDelayMs = 0L, maxDelayMs = 0L),
+ nowMs = { nowMs++ },
+ sleepMs = {},
+ ),
+ ).build()
+
+ try {
+ client.newCall(Request.Builder().url(server.url("/video?playback=1")).build()).execute().use { response ->
+ assertEquals(200, response.code)
+ assertEquals("ready", response.body.string())
+ }
+ assertEquals(2, server.requestCount)
+ } finally {
+ server.shutdown()
+ }
+ }
+
+ @Test
+ fun preparingPlaybackRetryStopsAtTwoMinuteCap() {
+ val retry = GatewayPlaybackRetryState(startedAtMs = 1_000L)
+
+ assertTrue(retry.canAttempt(nowMs = 1_000L))
+ assertEquals(500L, retry.nextDelayMs(nowMs = 1_000L))
+ assertEquals(false, retry.canAttempt(nowMs = 121_000L))
+ assertNull(retry.nextDelayMs(nowMs = 121_001L))
+ }
+
+ @Test
+ fun preparingPlaybackInterceptorDoesNotStartRequestAfterOvershootingDeadline() {
+ val server = MockWebServer()
+ server.enqueue(MockResponse().setResponseCode(202).setBody("""{"status":"preparing"}"""))
+ server.start()
+ var nowMs = 0L
+ val client =
+ OkHttpClient
+ .Builder()
+ .addInterceptor(
+ GatewayPreparingPlaybackInterceptor(
+ policy = GatewayPlaybackRetryPolicy(maxElapsedMs = 2L, initialDelayMs = 1L, maxDelayMs = 1L),
+ nowMs = { nowMs },
+ sleepMs = { delayMs -> nowMs += delayMs + 1L },
+ ),
+ ).build()
+
+ try {
+ val failure =
+ runCatching {
+ client.newCall(Request.Builder().url(server.url("/video?playback=1")).build()).execute().use { }
+ }.exceptionOrNull()
+ assertTrue(failure is java.io.IOException)
+ assertEquals(1, server.requestCount)
+ } finally {
+ server.shutdown()
+ }
+ }
+
@Test
fun tlsUpgradeRequest_carriesLatestSanitizedHeadersForOnlyThisGateway() {
val app = RuntimeEnvironment.getApplication()
diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatComposerDraftTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatComposerDraftTest.kt
index 3603ce014c83..27bfb2e1d43f 100644
--- a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatComposerDraftTest.kt
+++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatComposerDraftTest.kt
@@ -716,7 +716,29 @@ class ChatComposerDraftTest {
fun attachmentAdmissionUsesPerKindDecodedBudgets() {
assertEquals(CHAT_COMPOSER_MAX_IMAGE_DECODED_BYTES, chatComposerAttachmentDecodedByteLimit("image/png"))
assertEquals(CHAT_COMPOSER_MAX_AUDIO_DECODED_BYTES, chatComposerAttachmentDecodedByteLimit("audio/mpeg"))
+ assertEquals(CHAT_COMPOSER_MAX_VIDEO_DECODED_BYTES, chatComposerAttachmentDecodedByteLimit("video/mp4"))
assertEquals(CHAT_COMPOSER_MAX_DOCUMENT_DECODED_BYTES, chatComposerAttachmentDecodedByteLimit("application/pdf"))
+ assertEquals(20L * 1024L * 1024L, CHAT_COMPOSER_MAX_VIDEO_DECODED_BYTES)
+ }
+
+ @Test
+ fun videoPositionDoesNotRelaxNonVideoAdmissionBudget() {
+ val video = PendingAttachment("video", "clip.mp4", "video/mp4", "AAAA")
+ val document = PendingAttachment("document", "report.pdf", "application/pdf", "AAAAAAAA")
+
+ fun admit(candidates: List) =
+ admitChatAttachments(
+ currentAttachments = emptyList(),
+ candidates = candidates,
+ maxAttachmentCount = 8,
+ maxBase64Chars = 100,
+ maxDecodedBytes = 9,
+ maxNonVideoBase64Chars = 100,
+ maxNonVideoDecodedBytes = 3,
+ )
+
+ assertEquals(listOf(video), admit(listOf(video, document)).accepted)
+ assertEquals(listOf(video), admit(listOf(document, video)).accepted)
}
@Test
diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatMediaPlayerTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatMediaPlayerTest.kt
index 806ea0e04244..53ffd1442332 100644
--- a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatMediaPlayerTest.kt
+++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatMediaPlayerTest.kt
@@ -7,6 +7,7 @@ import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onNodeWithText
+import androidx.media3.common.Player
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertSame
@@ -39,6 +40,10 @@ class ChatMediaPlayerTest {
}
}
+ private class FakeSession {
+ var released = false
+ }
+
@get:Rule
val composeRule = createComposeRule()
@@ -75,6 +80,47 @@ class ChatMediaPlayerTest {
assertSame(player, claims.active)
}
+ @Test
+ fun playbackClaimsCreateAndReleaseOnlyOneMediaSession() {
+ val first = FakePlayer()
+ val second = FakePlayer()
+ val sessions = ChatMediaSessionLifecycle { it.released = true }
+ val claims =
+ ChatMediaPlaybackClaims(
+ pause = { player -> sessions.release(player) },
+ release = { player -> sessions.release(player) },
+ )
+
+ claims.claim(first)
+ val firstSession = sessions.activate(first) { FakeSession() }
+ assertSame(firstSession, sessions.activate(first) { error("duplicate session") })
+
+ claims.claim(second)
+ val secondSession = sessions.activate(second) { FakeSession() }
+ assertTrue(firstSession.released)
+ assertFalse(secondSession.released)
+
+ claims.pauseIf { it === second }
+ assertTrue(secondSession.released)
+ }
+
+ @Test
+ fun mediaSessionControllersCannotReplaceInlineMediaItem() {
+ val commands =
+ inlineMediaSessionPlayerCommands(
+ Player.Commands
+ .Builder()
+ .addAllCommands()
+ .build(),
+ )
+
+ assertTrue(commands.contains(Player.COMMAND_PLAY_PAUSE))
+ assertTrue(commands.contains(Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM))
+ assertFalse(commands.contains(Player.COMMAND_SET_MEDIA_ITEM))
+ assertFalse(commands.contains(Player.COMMAND_CHANGE_MEDIA_ITEMS))
+ assertFalse(commands.contains(Player.COMMAND_STOP))
+ }
+
@Test
fun legacyMediaPartsRenderLabelsWithoutPlayControlsOrClaims() {
val audio =
@@ -102,7 +148,7 @@ class ChatMediaPlayerTest {
content = listOf(audio, video),
timestampMs = 1,
),
- loadMediaArtifact = { _, _ ->
+ loadMediaArtifact = { _, _, _ ->
loadCount += 1
null
},
diff --git a/apps/android/gradle/libs.versions.toml b/apps/android/gradle/libs.versions.toml
index 37d957d85492..ae5759cfd78b 100644
--- a/apps/android/gradle/libs.versions.toml
+++ b/apps/android/gradle/libs.versions.toml
@@ -90,6 +90,7 @@ kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serializa
material = { module = "com.google.android.material:material", version.ref = "material" }
media3-datasource-okhttp = { module = "androidx.media3:media3-datasource-okhttp", version.ref = "media3" }
media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" }
+media3-session = { module = "androidx.media3:media3-session", version.ref = "media3" }
media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3" }
mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" }
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }