From e1cc617f86babce842f1fd224009bfb99bcad228 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 29 Jul 2026 15:44:04 -0400 Subject: [PATCH] feat(android): media renditions, MediaSession, video upload (#116037) * feat(android): add media rendition and video support * chore(android): refresh native i18n inventory * fix(android): opt in to Media3 command API * chore(android): sync native i18n source locations --- apps/.i18n/native-source.json | 382 +++++++++--------- apps/android/app/build.gradle.kts | 1 + apps/android/app/src/main/AndroidManifest.xml | 6 + .../java/ai/openclaw/app/AssistantLaunch.kt | 7 +- .../java/ai/openclaw/app/MainViewModel.kt | 3 +- .../main/java/ai/openclaw/app/NodeRuntime.kt | 3 +- .../ai/openclaw/app/chat/ChatCommandOutbox.kt | 22 +- .../ai/openclaw/app/chat/ChatController.kt | 11 +- .../java/ai/openclaw/app/chat/ChatModels.kt | 1 + .../openclaw/app/chat/ChatTranscriptCache.kt | 3 + .../ai/openclaw/app/gateway/GatewaySession.kt | 132 ++++-- .../ai/openclaw/app/ui/chat/ChatComposer.kt | 30 +- .../ai/openclaw/app/ui/chat/ChatImageCodec.kt | 51 ++- .../openclaw/app/ui/chat/ChatMediaPlayer.kt | 116 +++++- .../openclaw/app/ui/chat/ChatMessageViews.kt | 4 +- .../ai/openclaw/app/ui/chat/ChatScreen.kt | 45 ++- .../openclaw/app/ui/chat/PendingAttachment.kt | 2 + .../java/ai/openclaw/app/ShareLaunchTest.kt | 18 + .../app/chat/ChatControllerOutboxTest.kt | 2 +- .../app/chat/ChatMessageContentParsingTest.kt | 20 + .../app/chat/RoomChatCommandOutboxTest.kt | 53 +++ .../app/chat/RoomChatTranscriptCacheTest.kt | 1 + .../GatewaySessionCustomHeadersTest.kt | 105 +++++ .../app/ui/chat/ChatComposerDraftTest.kt | 22 + .../app/ui/chat/ChatMediaPlayerTest.kt | 48 ++- apps/android/gradle/libs.versions.toml | 1 + 26 files changed, 852 insertions(+), 237 deletions(-) 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" }