diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 9f95a0eb2973..f349e35d2e1d 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -3115,7 +3115,7 @@ }, { "kind": "ui-named-argument", - "line": 237, + "line": 239, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Provider limits and quota health.", "surface": "android", @@ -3123,7 +3123,7 @@ }, { "kind": "ui-named-argument", - "line": 237, + "line": 239, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Usage", "surface": "android", @@ -3131,7 +3131,7 @@ }, { "kind": "ui-named-argument", - "line": 257, + "line": 259, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connect the gateway to load usage.", "surface": "android", @@ -3139,7 +3139,7 @@ }, { "kind": "ui-named-argument", - "line": 262, + "line": 264, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No usage data yet.", "surface": "android", @@ -3147,7 +3147,7 @@ }, { "kind": "ui-named-argument", - "line": 263, + "line": 265, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Provider limits will appear here when your gateway reports them.", "surface": "android", @@ -3155,7 +3155,7 @@ }, { "kind": "ui-named-argument", - "line": 298, + "line": 300, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Cron Jobs", "surface": "android", @@ -3163,7 +3163,7 @@ }, { "kind": "ui-named-argument", - "line": 298, + "line": 300, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Scheduled OpenClaw work from your gateway.", "surface": "android", @@ -3171,7 +3171,7 @@ }, { "kind": "ui-named-argument", - "line": 309, + "line": 311, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Open a job to inspect its configuration and run history. Admin-scoped connections can also run, edit, enable, disable, or delete it.", "surface": "android", @@ -3179,7 +3179,7 @@ }, { "kind": "ui-named-argument", - "line": 319, + "line": 321, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connect the gateway to load cron jobs.", "surface": "android", @@ -3187,7 +3187,7 @@ }, { "kind": "ui-named-argument", - "line": 324, + "line": 326, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No scheduled jobs.", "surface": "android", @@ -3195,7 +3195,7 @@ }, { "kind": "ui-named-argument", - "line": 325, + "line": 327, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Scheduled work created on the gateway will appear here.", "surface": "android", @@ -3203,7 +3203,7 @@ }, { "kind": "ui-named-argument", - "line": 426, + "line": 428, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Inspect scheduled gateway work.", "surface": "android", @@ -3211,7 +3211,7 @@ }, { "kind": "ui-named-argument", - "line": 447, + "line": 449, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connect the gateway to inspect cron jobs.", "surface": "android", @@ -3219,7 +3219,7 @@ }, { "kind": "conditional-branch", - "line": 455, + "line": 457, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Cron job not loaded.", "surface": "android", @@ -3227,7 +3227,7 @@ }, { "kind": "conditional-branch", - "line": 455, + "line": 457, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Loading cron job…", "surface": "android", @@ -3235,7 +3235,7 @@ }, { "kind": "ui-named-argument", - "line": 506, + "line": 508, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Agents", "surface": "android", @@ -3243,7 +3243,7 @@ }, { "kind": "ui-named-argument", - "line": 506, + "line": 508, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Choose and inspect the assistants available on this gateway.", "surface": "android", @@ -3251,7 +3251,7 @@ }, { "kind": "ui-named-argument", - "line": 517, + "line": 519, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connect the gateway to load agents.", "surface": "android", @@ -3259,7 +3259,7 @@ }, { "kind": "ui-named-argument", - "line": 521, + "line": 523, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No agents loaded yet.", "surface": "android", @@ -3267,7 +3267,7 @@ }, { "kind": "ui-named-argument", - "line": 547, + "line": 549, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Approvals", "surface": "android", @@ -3275,7 +3275,7 @@ }, { "kind": "ui-named-argument", - "line": 547, + "line": 549, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Review actions that need your attention.", "surface": "android", @@ -3283,7 +3283,7 @@ }, { "kind": "conditional-branch", - "line": 558, + "line": 560, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Refresh", "surface": "android", @@ -3291,7 +3291,7 @@ }, { "kind": "conditional-branch", - "line": 558, + "line": 560, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Refreshing", "surface": "android", @@ -3299,7 +3299,7 @@ }, { "kind": "ui-named-argument", - "line": 571, + "line": 573, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Gateway disconnected.", "surface": "android", @@ -3307,7 +3307,7 @@ }, { "kind": "ui-named-argument", - "line": 572, + "line": 574, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connect the gateway to load approval requests in the app.", "surface": "android", @@ -3315,7 +3315,7 @@ }, { "kind": "ui-named-argument", - "line": 578, + "line": 580, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No gateway approvals.", "surface": "android", @@ -3323,7 +3323,7 @@ }, { "kind": "ui-named-argument", - "line": 579, + "line": 581, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Exec approval requests will appear here while this phone is connected.", "surface": "android", @@ -3331,7 +3331,7 @@ }, { "kind": "ui-named-argument", - "line": 586, + "line": 588, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Session activity", "surface": "android", @@ -3339,7 +3339,7 @@ }, { "kind": "ui-named-argument", - "line": 587, + "line": 589, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Chat tool calls waiting in the active session remain visible here.", "surface": "android", @@ -3347,7 +3347,7 @@ }, { "kind": "ui-named-argument", - "line": 601, + "line": 603, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "How this phone appears to OpenClaw.", "surface": "android", @@ -3355,7 +3355,7 @@ }, { "kind": "ui-named-argument", - "line": 601, + "line": 603, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Profile", "surface": "android", @@ -3363,7 +3363,7 @@ }, { "kind": "ui-named-argument", - "line": 604, + "line": 606, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Device name", "surface": "android", @@ -3371,7 +3371,7 @@ }, { "kind": "ui-named-argument", - "line": 605, + "line": 607, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Save Profile", "surface": "android", @@ -3379,7 +3379,7 @@ }, { "kind": "ui-named-argument", - "line": 624, + "line": 626, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Configure voice, transport, and playback.", "surface": "android", @@ -3387,7 +3387,7 @@ }, { "kind": "ui-named-argument", - "line": 624, + "line": 626, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Talk Provider Setup", "surface": "android", @@ -3395,7 +3395,7 @@ }, { "kind": "ui-named-argument", - "line": 627, + "line": 629, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Audio Test", "surface": "android", @@ -3403,7 +3403,7 @@ }, { "kind": "ui-named-argument", - "line": 628, + "line": 630, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Check that OpenClaw can speak clearly on this phone.", "surface": "android", @@ -3411,7 +3411,7 @@ }, { "kind": "conditional-branch", - "line": 631, + "line": 633, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Enable speaker", "surface": "android", @@ -3419,7 +3419,7 @@ }, { "kind": "conditional-branch", - "line": 631, + "line": 633, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Mute speaker", "surface": "android", @@ -3427,7 +3427,7 @@ }, { "kind": "conditional-branch", - "line": 632, + "line": 634, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Assistant speech muted", "surface": "android", @@ -3435,7 +3435,7 @@ }, { "kind": "conditional-branch", - "line": 632, + "line": 634, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Replies play aloud", "surface": "android", @@ -3443,7 +3443,7 @@ }, { "kind": "conditional-branch", - "line": 634, + "line": 636, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Muted", "surface": "android", @@ -3451,7 +3451,7 @@ }, { "kind": "conditional-branch", - "line": 634, + "line": 636, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "On", "surface": "android", @@ -3459,7 +3459,7 @@ }, { "kind": "ui-named-argument", - "line": 638, + "line": 640, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Done", "surface": "android", @@ -3467,7 +3467,7 @@ }, { "kind": "ui-named-argument", - "line": 648, + "line": 650, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Realtime Talk", "surface": "android", @@ -3475,7 +3475,7 @@ }, { "kind": "ui-named-argument", - "line": 649, + "line": 651, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Dictation", "surface": "android", @@ -3483,7 +3483,7 @@ }, { "kind": "conditional-branch", - "line": 774, + "line": 776, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Allowlist", "surface": "android", @@ -3491,7 +3491,7 @@ }, { "kind": "conditional-branch", - "line": 774, + "line": 776, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Blocklist", "surface": "android", @@ -3499,7 +3499,7 @@ }, { "kind": "ui-named-argument", - "line": 819, + "line": 821, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Choose what reaches OpenClaw.", "surface": "android", @@ -3507,7 +3507,7 @@ }, { "kind": "ui-named-argument", - "line": 819, + "line": 821, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Notifications", "surface": "android", @@ -3515,7 +3515,7 @@ }, { "kind": "conditional-branch", - "line": 823, + "line": 825, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Alerts stay on this phone.", "surface": "android", @@ -3523,7 +3523,7 @@ }, { "kind": "conditional-branch", - "line": 823, + "line": 825, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "OpenClaw can receive selected alerts.", "surface": "android", @@ -3531,7 +3531,7 @@ }, { "kind": "conditional-branch", - "line": 835, + "line": 837, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Granted", "surface": "android", @@ -3539,7 +3539,7 @@ }, { "kind": "conditional-branch", - "line": 835, + "line": 837, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Setup", "surface": "android", @@ -3547,7 +3547,7 @@ }, { "kind": "conditional-branch", - "line": 840, + "line": 842, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Check Access", "surface": "android", @@ -3555,7 +3555,7 @@ }, { "kind": "conditional-branch", - "line": 840, + "line": 842, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Open System Access", "surface": "android", @@ -3563,7 +3563,7 @@ }, { "kind": "ui-named-argument", - "line": 849, + "line": 851, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Forwarding Mode", "surface": "android", @@ -3571,7 +3571,7 @@ }, { "kind": "ui-named-argument", - "line": 898, + "line": 900, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "App Filter", "surface": "android", @@ -3579,7 +3579,7 @@ }, { "kind": "conditional-branch", - "line": 905, + "line": 907, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Close App Picker", "surface": "android", @@ -3587,7 +3587,7 @@ }, { "kind": "conditional-branch", - "line": 905, + "line": 907, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Open App Picker", "surface": "android", @@ -3595,7 +3595,7 @@ }, { "kind": "ui-named-argument", - "line": 910, + "line": 912, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Search apps", "surface": "android", @@ -3603,7 +3603,7 @@ }, { "kind": "ui-named-argument", - "line": 913, + "line": 915, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Show System Apps", "surface": "android", @@ -3611,7 +3611,7 @@ }, { "kind": "ui-named-argument", - "line": 914, + "line": 916, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Include Android and background packages.", "surface": "android", @@ -3619,7 +3619,7 @@ }, { "kind": "ui-named-argument", - "line": 921, + "line": 923, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No matching apps.", "surface": "android", @@ -3627,7 +3627,7 @@ }, { "kind": "ui-named-argument", - "line": 932, + "line": 934, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Showing ${visibleApps.size} of ${apps.size}. Refine search for more.", "surface": "android", @@ -3635,7 +3635,7 @@ }, { "kind": "ui-named-argument", - "line": 1180, + "line": 1182, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Choose what this phone can share.", "surface": "android", @@ -3643,7 +3643,7 @@ }, { "kind": "ui-named-argument", - "line": 1180, + "line": 1182, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Phone Capabilities", "surface": "android", @@ -3651,7 +3651,7 @@ }, { "kind": "conditional-branch", - "line": 1189, + "line": 1191, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Allow photo library access.", "surface": "android", @@ -3659,7 +3659,7 @@ }, { "kind": "conditional-branch", - "line": 1189, + "line": 1191, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Selected or full photo access granted.", "surface": "android", @@ -3667,7 +3667,7 @@ }, { "kind": "conditional-branch", - "line": 1199, + "line": 1201, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "App list stays on this phone.", "surface": "android", @@ -3675,7 +3675,7 @@ }, { "kind": "conditional-branch", - "line": 1199, + "line": 1201, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "OpenClaw can list launcher-visible apps.", "surface": "android", @@ -3683,7 +3683,7 @@ }, { "kind": "ui-named-argument", - "line": 1210, + "line": 1212, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Location", "surface": "android", @@ -3691,7 +3691,7 @@ }, { "kind": "ui-named-argument", - "line": 1218, + "line": 1220, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Always allows requested location checks while OpenClaw is in the background; Android shows this in the persistent node notification.", "surface": "android", @@ -3699,7 +3699,7 @@ }, { "kind": "ui-call", - "line": 1253, + "line": 1255, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Allow background location?", "surface": "android", @@ -3707,7 +3707,7 @@ }, { "kind": "ui-call", - "line": 1255, + "line": 1257, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "OpenClaw only checks location when your paired Gateway requests it. ", "surface": "android", @@ -3715,7 +3715,7 @@ }, { "kind": "ui-call", - "line": 1268, + "line": 1270, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Open Settings", "surface": "android", @@ -3723,7 +3723,7 @@ }, { "kind": "ui-call", - "line": 1287, + "line": 1289, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Share installed app information?", "surface": "android", @@ -3731,7 +3731,7 @@ }, { "kind": "ui-call", - "line": 1290, + "line": 1292, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "OpenClaw collects and sends the names, package IDs, and status of apps visible on this phone when your paired OpenClaw Gateway asks for them. This lets your assistant answer questions and take actions using installed apps.", "surface": "android", @@ -3739,7 +3739,7 @@ }, { "kind": "ui-call", - "line": 1293, + "line": 1295, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Your phone sends this information to your Gateway, not to a server run by OpenClaw. Your Gateway may include it in requests to the AI provider you chose.", "surface": "android", @@ -3747,7 +3747,7 @@ }, { "kind": "ui-call", - "line": 1300, + "line": 1302, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Agree and Enable", "surface": "android", @@ -3755,7 +3755,7 @@ }, { "kind": "ui-call", - "line": 1305, + "line": 1307, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Not Now", "surface": "android", @@ -3763,7 +3763,7 @@ }, { "kind": "ui-call", - "line": 1352, + "line": 1354, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Replace gateway setup?", "surface": "android", @@ -3771,7 +3771,7 @@ }, { "kind": "ui-call", - "line": 1367, + "line": 1369, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Replace setup", "surface": "android", @@ -3779,7 +3779,7 @@ }, { "kind": "ui-call", - "line": 1383, + "line": 1385, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Forget gateway?", "surface": "android", @@ -3787,7 +3787,7 @@ }, { "kind": "ui-call", - "line": 1396, + "line": 1398, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Cancel", "surface": "android", @@ -3795,7 +3795,7 @@ }, { "kind": "ui-named-argument", - "line": 1402, + "line": 1404, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connection between this phone and OpenClaw.", "surface": "android", @@ -3803,7 +3803,7 @@ }, { "kind": "conditional-branch", - "line": 1406, + "line": 1408, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connected", "surface": "android", @@ -3811,7 +3811,7 @@ }, { "kind": "conditional-branch", - "line": 1406, + "line": 1408, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Offline", "surface": "android", @@ -3819,7 +3819,7 @@ }, { "kind": "conditional-branch", - "line": 1407, + "line": 1409, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Not paired", "surface": "android", @@ -3827,7 +3827,7 @@ }, { "kind": "conditional-branch", - "line": 1407, + "line": 1409, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Online", "surface": "android", @@ -3835,7 +3835,7 @@ }, { "kind": "ui-named-argument", - "line": 1417, + "line": 1419, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Reconnect", "surface": "android", @@ -3843,7 +3843,7 @@ }, { "kind": "ui-named-argument", - "line": 1418, + "line": 1420, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Disconnect", "surface": "android", @@ -3851,7 +3851,7 @@ }, { "kind": "ui-named-argument", - "line": 1422, + "line": 1424, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Gateways", "surface": "android", @@ -3859,7 +3859,7 @@ }, { "kind": "ui-named-argument", - "line": 1424, + "line": 1426, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No paired gateways.", "surface": "android", @@ -3867,7 +3867,7 @@ }, { "kind": "ui-call", - "line": 1444, + "line": 1446, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Forget", "surface": "android", @@ -3875,7 +3875,7 @@ }, { "kind": "ui-named-argument", - "line": 1466, + "line": 1468, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Gateway setup", "surface": "android", @@ -3883,7 +3883,7 @@ }, { "kind": "ui-named-argument", - "line": 1468, + "line": 1470, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Scan or paste a setup code to add another gateway.", "surface": "android", @@ -3891,7 +3891,7 @@ }, { "kind": "ui-named-argument", - "line": 1475, + "line": 1477, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Add gateway", "surface": "android", @@ -3899,7 +3899,7 @@ }, { "kind": "ui-named-argument", - "line": 1476, + "line": 1478, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Setup Code", "surface": "android", @@ -3907,7 +3907,7 @@ }, { "kind": "ui-named-argument", - "line": 1480, + "line": 1482, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Android can scan or paste an existing setup code, but this gateway does not expose setup-code generation to the app yet. Generate the QR/code on the gateway host with openclaw qr, then scan it here or paste the setup code below.", "surface": "android", @@ -3915,7 +3915,7 @@ }, { "kind": "ui-named-argument", - "line": 1489, + "line": 1491, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connection Setup", "surface": "android", @@ -3923,7 +3923,7 @@ }, { "kind": "ui-named-argument", - "line": 1490, + "line": 1492, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Setup code", "surface": "android", @@ -3931,7 +3931,7 @@ }, { "kind": "ui-named-argument", - "line": 1492, + "line": 1494, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Host", "surface": "android", @@ -3939,7 +3939,7 @@ }, { "kind": "ui-named-argument", - "line": 1493, + "line": 1495, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Port", "surface": "android", @@ -3947,7 +3947,7 @@ }, { "kind": "ui-named-argument", - "line": 1495, + "line": 1497, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connection security", "surface": "android", @@ -3955,7 +3955,7 @@ }, { "kind": "conditional-branch", - "line": 1499, + "line": 1501, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Secure (TLS)", "surface": "android", @@ -3963,7 +3963,7 @@ }, { "kind": "conditional-branch", - "line": 1499, + "line": 1501, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Unencrypted", "surface": "android", @@ -3971,7 +3971,7 @@ }, { "kind": "ui-named-argument", - "line": 1516, + "line": 1518, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Token", "surface": "android", @@ -3979,7 +3979,7 @@ }, { "kind": "ui-named-argument", - "line": 1517, + "line": 1519, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Bootstrap", "surface": "android", @@ -3987,7 +3987,7 @@ }, { "kind": "ui-named-argument", - "line": 1519, + "line": 1521, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Password", "surface": "android", @@ -3995,7 +3995,7 @@ }, { "kind": "ui-named-argument", - "line": 1524, + "line": 1526, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Save & Connect", "surface": "android", @@ -4003,7 +4003,7 @@ }, { "kind": "ui-named-argument", - "line": 1541, + "line": 1543, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Enter a valid setup code or gateway address.", "surface": "android", @@ -4011,7 +4011,7 @@ }, { "kind": "ui-named-argument", - "line": 1567, + "line": 1569, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Appearance", "surface": "android", @@ -4019,7 +4019,7 @@ }, { "kind": "ui-named-argument", - "line": 1567, + "line": 1569, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Theme and translated Android text.", "surface": "android", @@ -4027,7 +4027,7 @@ }, { "kind": "ui-named-argument", - "line": 1579, + "line": 1581, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Theme", "surface": "android", @@ -4035,7 +4035,7 @@ }, { "kind": "ui-named-argument", - "line": 1589, + "line": 1591, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "App language", "surface": "android", @@ -4043,7 +4043,7 @@ }, { "kind": "ui-named-argument", - "line": 1591, + "line": 1593, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Changes Android text that OpenClaw has translated. Screens with English-only copy stay unchanged.", "surface": "android", @@ -4051,7 +4051,7 @@ }, { "kind": "ui-named-argument", - "line": 1628, + "line": 1630, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Selected", "surface": "android", @@ -4059,7 +4059,7 @@ }, { "kind": "conditional-branch", - "line": 1674, + "line": 1676, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Ready", "surface": "android", @@ -4067,7 +4067,7 @@ }, { "kind": "ui-named-argument", - "line": 1704, + "line": 1706, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "About", "surface": "android", @@ -4075,7 +4075,7 @@ }, { "kind": "ui-named-argument", - "line": 1704, + "line": 1706, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "OpenClaw for Android.", "surface": "android", @@ -4083,7 +4083,7 @@ }, { "kind": "ui-named-argument", - "line": 1722, + "line": 1724, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Gateway", "surface": "android", @@ -4091,7 +4091,7 @@ }, { "kind": "ui-named-argument", - "line": 1724, + "line": 1726, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Runtime", "surface": "android", @@ -4099,7 +4099,7 @@ }, { "kind": "ui-named-argument", - "line": 1727, + "line": 1729, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Update", "surface": "android", @@ -4107,7 +4107,7 @@ }, { "kind": "ui-named-argument", - "line": 1738, + "line": 1740, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "© 2026 OpenClaw Foundation — MIT License.", "surface": "android", @@ -4115,7 +4115,7 @@ }, { "kind": "ui-named-argument", - "line": 1755, + "line": 1757, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "OpenClaw logo", "surface": "android", @@ -4123,7 +4123,7 @@ }, { "kind": "ui-named-argument", - "line": 1757, + "line": 1759, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "OpenClaw", "surface": "android", @@ -4131,7 +4131,7 @@ }, { "kind": "ui-named-argument", - "line": 1758, + "line": 1760, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Personal AI on your devices", "surface": "android", @@ -4139,7 +4139,7 @@ }, { "kind": "ui-named-argument", - "line": 1817, + "line": 1819, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Licenses", "surface": "android", @@ -4147,7 +4147,7 @@ }, { "kind": "conditional-branch", - "line": 1818, + "line": 1820, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "OpenClaw appreciates its partners in the open-source community.", "surface": "android", @@ -4155,7 +4155,7 @@ }, { "kind": "ui-named-argument", - "line": 1827, + "line": 1829, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No license notices are packaged in this build.", "surface": "android", @@ -4163,7 +4163,7 @@ }, { "kind": "ui-named-argument", - "line": 1853, + "line": 1855, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Open ${license.title}", "surface": "android", @@ -4171,7 +4171,7 @@ }, { "kind": "conditional-branch", - "line": 1888, + "line": 1890, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Check", "surface": "android", @@ -4179,7 +4179,7 @@ }, { "kind": "ui-named-argument", - "line": 1921, + "line": 1923, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Back", "surface": "android", @@ -4187,7 +4187,7 @@ }, { "kind": "conditional-branch", - "line": 1995, + "line": 1997, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Sending", "surface": "android", @@ -4195,7 +4195,7 @@ }, { "kind": "conditional-branch", - "line": 2004, + "line": 2006, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Allow Once", "surface": "android", @@ -4203,7 +4203,7 @@ }, { "kind": "conditional-branch", - "line": 2004, + "line": 2006, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Allowing", "surface": "android", @@ -4211,7 +4211,7 @@ }, { "kind": "conditional-branch", - "line": 2012, + "line": 2014, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Always", "surface": "android", @@ -4219,7 +4219,7 @@ }, { "kind": "conditional-branch", - "line": 2012, + "line": 2014, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Saving", "surface": "android", @@ -4227,7 +4227,7 @@ }, { "kind": "conditional-branch", - "line": 2020, + "line": 2022, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Deny", "surface": "android", @@ -4235,7 +4235,7 @@ }, { "kind": "conditional-branch", - "line": 2020, + "line": 2022, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Denying", "surface": "android", @@ -4243,7 +4243,7 @@ }, { "kind": "conditional-branch", - "line": 2045, + "line": 2047, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Review", "surface": "android", @@ -4251,7 +4251,7 @@ }, { "kind": "conditional-branch", - "line": 2073, + "line": 2075, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Issue", "surface": "android", @@ -4259,7 +4259,7 @@ }, { "kind": "conditional-branch", - "line": 2128, + "line": 2130, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Enabled", "surface": "android", @@ -4267,7 +4267,7 @@ }, { "kind": "conditional-branch", - "line": 2142, + "line": 2144, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No", "surface": "android", @@ -4275,7 +4275,7 @@ }, { "kind": "conditional-branch", - "line": 2142, + "line": 2144, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Yes", "surface": "android", @@ -4283,7 +4283,7 @@ }, { "kind": "ui-named-argument", - "line": 2160, + "line": 2162, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Last Error", "surface": "android", @@ -4291,7 +4291,7 @@ }, { "kind": "ui-named-argument", - "line": 2163, + "line": 2165, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Delivery Error", "surface": "android", @@ -4299,7 +4299,7 @@ }, { "kind": "ui-named-argument", - "line": 2202, + "line": 2204, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Tap to copy", "surface": "android", @@ -4307,7 +4307,7 @@ }, { "kind": "ui-toast", - "line": 2217, + "line": 2219, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "$title copied", "surface": "android", @@ -4315,7 +4315,7 @@ }, { "kind": "conditional-branch", - "line": 2255, + "line": 2257, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Default assistant", "surface": "android", @@ -4323,7 +4323,7 @@ }, { "kind": "conditional-branch", - "line": 2257, + "line": 2263, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Default", "surface": "android", @@ -4331,7 +4331,7 @@ }, { "kind": "conditional-branch", - "line": 2313, + "line": 2319, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Needs attention", "surface": "android", @@ -4339,7 +4339,7 @@ }, { "kind": "conditional-branch", - "line": 2316, + "line": 2322, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Waiting ${minutes}m", "surface": "android", @@ -4347,7 +4347,7 @@ }, { "kind": "conditional-branch", - "line": 2316, + "line": 2322, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Waiting for review", "surface": "android", @@ -4355,7 +4355,7 @@ }, { "kind": "conditional-branch", - "line": 2344, + "line": 2350, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "${job.scheduleLabel} · ${formatCronWake(job.nextRunAtMs)} · ${job.promptPreview}", "surface": "android", @@ -4363,7 +4363,7 @@ }, { "kind": "conditional-branch", - "line": 2351, + "line": 2357, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No limits reported", "surface": "android", @@ -4371,7 +4371,7 @@ }, { "kind": "conditional-branch", - "line": 2382, + "line": 2388, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Off", "surface": "android", @@ -4379,7 +4379,7 @@ }, { "kind": "conditional-branch", - "line": 2403, + "line": 2409, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "System Event Text", "surface": "android", @@ -4387,7 +4387,7 @@ }, { "kind": "conditional-branch", - "line": 2404, + "line": 2410, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Agent Prompt", "surface": "android", @@ -4395,7 +4395,7 @@ }, { "kind": "conditional-branch", - "line": 2405, + "line": 2411, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Command", "surface": "android", @@ -4403,7 +4403,7 @@ }, { "kind": "conditional-branch", - "line": 2406, + "line": 2412, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Payload Text", "surface": "android", @@ -4411,7 +4411,7 @@ }, { "kind": "ui-named-argument", - "line": 345, + "line": 348, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Close Canvas", "surface": "android", @@ -4419,7 +4419,7 @@ }, { "kind": "ui-named-argument", - "line": 488, + "line": 492, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Overview", "surface": "android", @@ -4427,7 +4427,7 @@ }, { "kind": "ui-named-argument", - "line": 533, + "line": 538, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "No recent sessions", "surface": "android", @@ -4435,7 +4435,7 @@ }, { "kind": "ui-named-argument", - "line": 534, + "line": 539, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Start a chat and your active OpenClaw conversations will appear here.", "surface": "android", @@ -4443,7 +4443,7 @@ }, { "kind": "ui-named-argument", - "line": 535, + "line": 540, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Start Chat", "surface": "android", @@ -4451,7 +4451,7 @@ }, { "kind": "ui-named-argument", - "line": 581, + "line": 586, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "OpenClaw", "surface": "android", @@ -4459,7 +4459,7 @@ }, { "kind": "ui-named-argument", - "line": 589, + "line": 594, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Search", "surface": "android", @@ -4467,7 +4467,7 @@ }, { "kind": "ui-named-argument", - "line": 641, + "line": 647, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "ACTIVE AGENT", "surface": "android", @@ -4475,7 +4475,7 @@ }, { "kind": "ui-named-argument", - "line": 650, + "line": 656, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "View", "surface": "android", @@ -4483,7 +4483,7 @@ }, { "kind": "conditional-branch", - "line": 653, + "line": 659, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "$pendingRunCount active", "surface": "android", @@ -4491,7 +4491,7 @@ }, { "kind": "conditional-branch", - "line": 653, + "line": 659, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Idle", "surface": "android", @@ -4499,7 +4499,7 @@ }, { "kind": "ui-named-argument", - "line": 653, + "line": 659, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Runs", "surface": "android", @@ -4507,7 +4507,7 @@ }, { "kind": "conditional-branch", - "line": 654, + "line": 660, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "$sessionCount recent", "surface": "android", @@ -4515,7 +4515,7 @@ }, { "kind": "ui-named-argument", - "line": 654, + "line": 660, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Sessions", "surface": "android", @@ -4523,7 +4523,7 @@ }, { "kind": "ui-named-argument", - "line": 655, + "line": 661, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Cron", "surface": "android", @@ -4531,7 +4531,7 @@ }, { "kind": "ui-named-argument", - "line": 658, + "line": 664, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Chat", "surface": "android", @@ -4539,7 +4539,7 @@ }, { "kind": "ui-named-argument", - "line": 662, + "line": 668, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Reconnect gateway", "surface": "android", @@ -4547,7 +4547,7 @@ }, { "kind": "ui-named-argument", - "line": 810, + "line": 819, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Open ${card.title}", "surface": "android", @@ -4555,7 +4555,7 @@ }, { "kind": "ui-named-argument", - "line": 878, + "line": 887, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Talk", "surface": "android", @@ -4563,7 +4563,7 @@ }, { "kind": "ui-named-argument", - "line": 879, + "line": 888, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Open Talk", "surface": "android", @@ -4571,7 +4571,7 @@ }, { "kind": "ui-named-argument", - "line": 881, + "line": 890, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Talk settings", "surface": "android", @@ -4579,7 +4579,7 @@ }, { "kind": "ui-named-argument", - "line": 889, + "line": 898, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Recent Sessions", "surface": "android", @@ -4587,7 +4587,7 @@ }, { "kind": "ui-named-argument", - "line": 899, + "line": 908, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "View all", "surface": "android", @@ -4595,7 +4595,7 @@ }, { "kind": "conditional-branch", - "line": 1038, + "line": 1047, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "$onlineNodes/$nodeCount", "surface": "android", @@ -4603,7 +4603,7 @@ }, { "kind": "conditional-branch", - "line": 1038, + "line": 1047, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "None", "surface": "android", @@ -4611,7 +4611,7 @@ }, { "kind": "conditional-branch", - "line": 1070, + "line": 1079, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Recent conversations", "surface": "android", @@ -4619,7 +4619,7 @@ }, { "kind": "conditional-branch", - "line": 1077, + "line": 1086, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Browse", "surface": "android", @@ -4627,7 +4627,7 @@ }, { "kind": "conditional-branch", - "line": 1077, + "line": 1086, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Offline", "surface": "android", @@ -4635,7 +4635,7 @@ }, { "kind": "conditional-branch", - "line": 1129, + "line": 1143, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Working · $pendingRunCount active ${pluralize(\"run\", pendingRunCount)}", "surface": "android", @@ -4643,7 +4643,7 @@ }, { "kind": "ui-named-argument", - "line": 1250, + "line": 1264, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Needs attention", "surface": "android", @@ -4651,7 +4651,7 @@ }, { "kind": "ui-named-argument", - "line": 1315, + "line": 1329, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Open ${row.title}", "surface": "android", @@ -4659,7 +4659,7 @@ }, { "kind": "ui-named-argument", - "line": 1423, + "line": 1437, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Open session", "surface": "android", @@ -4667,7 +4667,7 @@ }, { "kind": "ui-named-argument", - "line": 1538, + "line": 1552, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Back", "surface": "android", @@ -4675,7 +4675,7 @@ }, { "kind": "ui-named-argument", - "line": 1541, + "line": 1555, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Settings", "surface": "android", @@ -4683,7 +4683,7 @@ }, { "kind": "ui-named-argument", - "line": 1544, + "line": 1558, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Search settings", "surface": "android", @@ -4691,7 +4691,7 @@ }, { "kind": "conditional-branch", - "line": 1568, + "line": 1582, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "$readyProviderCount ready", "surface": "android", @@ -4699,7 +4699,7 @@ }, { "kind": "conditional-branch", - "line": 1568, + "line": 1582, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Review readiness", "surface": "android", @@ -4707,7 +4707,7 @@ }, { "kind": "conditional-branch", - "line": 1586, + "line": 1600, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Speaker muted", "surface": "android", @@ -4715,7 +4715,7 @@ }, { "kind": "conditional-branch", - "line": 1586, + "line": 1600, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Speaker on", "surface": "android", @@ -4723,7 +4723,7 @@ }, { "kind": "conditional-branch", - "line": 1588, + "line": 1602, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Off", "surface": "android", @@ -4731,7 +4731,7 @@ }, { "kind": "conditional-branch", - "line": 1588, + "line": 1602, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Smart delivery", "surface": "android", @@ -4739,7 +4739,7 @@ }, { "kind": "conditional-branch", - "line": 1589, + "line": 1603, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Camera enabled", "surface": "android", @@ -4747,7 +4747,7 @@ }, { "kind": "conditional-branch", - "line": 1589, + "line": 1603, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Locked", "surface": "android", @@ -4755,7 +4755,7 @@ }, { "kind": "ui-named-argument", - "line": 1636, + "line": 1650, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "OpenClaw ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})", "surface": "android", @@ -4763,7 +4763,7 @@ }, { "kind": "conditional-branch", - "line": 1639, + "line": 1653, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "All systems operational", "surface": "android", @@ -4771,7 +4771,7 @@ }, { "kind": "conditional-branch", - "line": 1639, + "line": 1653, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Gateway not connected", "surface": "android", @@ -4779,7 +4779,7 @@ }, { "kind": "conditional-branch", - "line": 1671, + "line": 1685, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "No provider usage", "surface": "android", @@ -4787,7 +4787,7 @@ }, { "kind": "conditional-branch", - "line": 1672, + "line": 1686, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "1 provider", "surface": "android", @@ -4795,7 +4795,7 @@ }, { "kind": "conditional-branch", - "line": 1673, + "line": 1687, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "$count providers", "surface": "android", @@ -4803,7 +4803,7 @@ }, { "kind": "conditional-branch", - "line": 1679, + "line": 1693, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "$ready/${skills.size} ready", "surface": "android", @@ -4811,7 +4811,7 @@ }, { "kind": "conditional-branch", - "line": 1679, + "line": 1693, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "No skills", "surface": "android", @@ -4819,7 +4819,7 @@ }, { "kind": "conditional-branch", - "line": 1693, + "line": 1707, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "$pending pending", "surface": "android", @@ -4827,7 +4827,7 @@ }, { "kind": "conditional-branch", - "line": 1693, + "line": 1707, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "1 pending", "surface": "android", @@ -4835,7 +4835,7 @@ }, { "kind": "conditional-branch", - "line": 1698, + "line": 1712, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "$held held", "surface": "android", @@ -4843,7 +4843,7 @@ }, { "kind": "conditional-branch", - "line": 1698, + "line": 1712, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "1 held", "surface": "android", @@ -4851,7 +4851,7 @@ }, { "kind": "conditional-branch", - "line": 1699, + "line": 1713, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "$applied applied", "surface": "android", @@ -4859,7 +4859,7 @@ }, { "kind": "conditional-branch", - "line": 1699, + "line": 1713, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "1 applied", "surface": "android", @@ -4867,7 +4867,7 @@ }, { "kind": "ui-named-argument", - "line": 1880, + "line": 1894, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "OpenClaw mobile", "surface": "android", @@ -4875,7 +4875,7 @@ }, { "kind": "ui-named-argument", - "line": 1884, + "line": 1898, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Open profile", "surface": "android", @@ -4883,7 +4883,7 @@ }, { "kind": "conditional-branch", - "line": 1969, + "line": 1983, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt", "source": "Main session", "surface": "android", @@ -5771,7 +5771,7 @@ }, { "kind": "conditional-branch", - "line": 167, + "line": 169, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "Preparing audio…", "surface": "android", @@ -5779,7 +5779,7 @@ }, { "kind": "conditional-branch", - "line": 167, + "line": 169, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "Speaking…", "surface": "android", @@ -5787,7 +5787,7 @@ }, { "kind": "ui-named-argument", - "line": 274, + "line": 276, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "Preview · $domain", "surface": "android", @@ -5795,7 +5795,7 @@ }, { "kind": "ui-named-argument", - "line": 283, + "line": 285, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "Expand link preview", "surface": "android", @@ -5803,7 +5803,7 @@ }, { "kind": "ui-call", - "line": 326, + "line": 328, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "Loading preview…", "surface": "android", @@ -5811,7 +5811,7 @@ }, { "kind": "ui-call", - "line": 327, + "line": 329, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "No preview available", "surface": "android", @@ -5819,7 +5819,7 @@ }, { "kind": "ui-call", - "line": 373, + "line": 375, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "Thinking...", "surface": "android", @@ -5827,7 +5827,7 @@ }, { "kind": "ui-named-argument", - "line": 389, + "line": 391, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "Tools", "surface": "android", @@ -5835,7 +5835,7 @@ }, { "kind": "ui-call", - "line": 392, + "line": 394, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "Running tools...", "surface": "android", @@ -5843,7 +5843,7 @@ }, { "kind": "ui-call", - "line": 395, + "line": 397, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "${display.emoji} ${display.label}", "surface": "android", @@ -5851,7 +5851,7 @@ }, { "kind": "ui-named-argument", - "line": 413, + "line": 415, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "... +${toolCalls.size - 6} more", "surface": "android", @@ -5859,7 +5859,7 @@ }, { "kind": "ui-named-argument", - "line": 445, + "line": 447, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "You", "surface": "android", @@ -5867,7 +5867,7 @@ }, { "kind": "ui-named-argument", - "line": 452, + "line": 454, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "📎 ${attachment.fileName}", "surface": "android", @@ -5875,7 +5875,7 @@ }, { "kind": "ui-named-argument", - "line": 468, + "line": 470, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "Retry", "surface": "android", @@ -5883,7 +5883,7 @@ }, { "kind": "ui-named-argument", - "line": 473, + "line": 475, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "Delete", "surface": "android", @@ -5891,7 +5891,7 @@ }, { "kind": "ui-named-argument", - "line": 505, + "line": 507, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "OpenClaw · Live", "surface": "android", @@ -5899,7 +5899,7 @@ }, { "kind": "conditional-branch", - "line": 542, + "line": 544, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "System", "surface": "android", @@ -5907,7 +5907,7 @@ }, { "kind": "conditional-branch", - "line": 543, + "line": 545, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "OpenClaw", "surface": "android", @@ -5915,7 +5915,7 @@ }, { "kind": "ui-named-argument", - "line": 579, + "line": 581, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "Open image preview", "surface": "android", @@ -5923,7 +5923,7 @@ }, { "kind": "ui-named-argument", - "line": 611, + "line": 613, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "Close image preview", "surface": "android", @@ -5931,7 +5931,7 @@ }, { "kind": "ui-call", - "line": 620, + "line": 622, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt", "source": "Unsupported attachment", "surface": "android", @@ -5939,7 +5939,7 @@ }, { "kind": "ui-named-argument", - "line": 379, + "line": 382, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Chat needs attention", "surface": "android", @@ -5947,7 +5947,7 @@ }, { "kind": "ui-named-argument", - "line": 570, + "line": 574, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "All", "surface": "android", @@ -5955,7 +5955,7 @@ }, { "kind": "ui-named-argument", - "line": 628, + "line": 645, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "OpenClaw", "surface": "android", @@ -5963,7 +5963,7 @@ }, { "kind": "ui-named-argument", - "line": 649, + "line": 666, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "New chat", "surface": "android", @@ -5971,7 +5971,7 @@ }, { "kind": "ui-named-argument", - "line": 654, + "line": 671, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "More new chat options", "surface": "android", @@ -5979,7 +5979,7 @@ }, { "kind": "ui-named-argument", - "line": 669, + "line": 686, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Refresh chat", "surface": "android", @@ -5987,7 +5987,7 @@ }, { "kind": "ui-named-argument", - "line": 672, + "line": 689, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Chat", "surface": "android", @@ -5995,7 +5995,7 @@ }, { "kind": "ui-named-argument", - "line": 820, + "line": 837, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Loading session", "surface": "android", @@ -6003,7 +6003,7 @@ }, { "kind": "ui-named-argument", - "line": 847, + "line": 864, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Jump to latest", "surface": "android", @@ -6011,7 +6011,7 @@ }, { "kind": "conditional-branch", - "line": 873, + "line": 890, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Ready when you are", "surface": "android", @@ -6019,7 +6019,7 @@ }, { "kind": "ui-named-argument", - "line": 904, + "line": 921, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Fix connection", "surface": "android", @@ -6027,7 +6027,7 @@ }, { "kind": "ui-named-argument", - "line": 905, + "line": 922, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Copy diagnostics", "surface": "android", @@ -6035,7 +6035,7 @@ }, { "kind": "conditional-branch", - "line": 1094, + "line": 1111, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Preparing audio…", "surface": "android", @@ -6043,7 +6043,7 @@ }, { "kind": "conditional-branch", - "line": 1094, + "line": 1111, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Speaking…", "surface": "android", @@ -6051,7 +6051,7 @@ }, { "kind": "ui-named-argument", - "line": 1115, + "line": 1132, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Tools running", "surface": "android", @@ -6059,7 +6059,7 @@ }, { "kind": "ui-named-argument", - "line": 1117, + "line": 1134, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "OpenClaw is working", "surface": "android", @@ -6067,7 +6067,7 @@ }, { "kind": "ui-named-argument", - "line": 1120, + "line": 1137, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "+${toolCalls.size - 4} more", "surface": "android", @@ -6075,7 +6075,7 @@ }, { "kind": "ui-named-argument", - "line": 1130, + "line": 1147, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Thinking", "surface": "android", @@ -6083,7 +6083,7 @@ }, { "kind": "ui-named-argument", - "line": 1131, + "line": 1148, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "OpenClaw is preparing a response.", "surface": "android", @@ -6091,7 +6091,7 @@ }, { "kind": "ui-named-argument", - "line": 1231, + "line": 1248, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Dismiss shared-image warning", "surface": "android", @@ -6099,7 +6099,7 @@ }, { "kind": "ui-named-argument", - "line": 1332, + "line": 1349, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Stop", "surface": "android", @@ -6107,7 +6107,7 @@ }, { "kind": "ui-named-argument", - "line": 1429, + "line": 1446, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Default", "surface": "android", @@ -6115,7 +6115,7 @@ }, { "kind": "conditional-branch", - "line": 1495, + "line": 1512, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Pin model", "surface": "android", @@ -6123,7 +6123,7 @@ }, { "kind": "conditional-branch", - "line": 1495, + "line": 1512, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Unpin model", "surface": "android", @@ -6131,7 +6131,7 @@ }, { "kind": "ui-named-argument", - "line": 1512, + "line": 1529, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "No commands found", "surface": "android", @@ -6139,7 +6139,7 @@ }, { "kind": "ui-named-argument", - "line": 1574, + "line": 1591, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Gateway offline", "surface": "android", @@ -6147,7 +6147,7 @@ }, { "kind": "conditional-branch", - "line": 1620, + "line": 1637, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Close thinking level selector", "surface": "android", @@ -6155,7 +6155,7 @@ }, { "kind": "conditional-branch", - "line": 1620, + "line": 1637, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Open thinking level selector", "surface": "android", @@ -6163,7 +6163,7 @@ }, { "kind": "ui-named-argument", - "line": 1680, + "line": 1697, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Attach image", "surface": "android", @@ -6171,7 +6171,7 @@ }, { "kind": "ui-named-argument", - "line": 1715, + "line": 1732, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Message OpenClaw", "surface": "android", @@ -6179,7 +6179,7 @@ }, { "kind": "ui-named-argument", - "line": 1731, + "line": 1748, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Open voice", "surface": "android", @@ -6187,7 +6187,7 @@ }, { "kind": "ui-named-argument", - "line": 1780, + "line": 1797, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Remove attachment", "surface": "android", @@ -6195,7 +6195,7 @@ }, { "kind": "conditional-branch", - "line": 1801, + "line": 1818, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Main", "surface": "android", @@ -6203,7 +6203,7 @@ }, { "kind": "conditional-branch", - "line": 1809, + "line": 1826, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "$emoji $name", "surface": "android", @@ -6211,7 +6211,7 @@ }, { "kind": "ui-named-argument", - "line": 1868, + "line": 1885, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Send", "surface": "android", @@ -6219,7 +6219,7 @@ }, { "kind": "conditional-branch", - "line": 1899, + "line": 1916, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "$contextLabel · ${contextMeterThinkingLabel(thinkingLevel)}", "surface": "android", diff --git a/apps/android/CHANGELOG.md b/apps/android/CHANGELOG.md index 60b0979e5258..da584b68a0c0 100644 --- a/apps/android/CHANGELOG.md +++ b/apps/android/CHANGELOG.md @@ -10,6 +10,8 @@ Requires a clear in-app disclosure and fresh consent before Installed Apps can s Adds an Android system share target that stages bounded text and image shares for review without losing existing composer drafts. Thanks @NianJiuZst. +Displays configured agent avatars across Android overview, settings, and chat, with bounded data and public remote image loading. Thanks @guarismo. + ## 2026.7.1 - 2026-07-08 Adds multi-gateway switching with isolated credentials, history, queues, and notification routing. diff --git a/apps/android/THIRD_PARTY_LICENSES/openclaw/licenses/Coil.txt b/apps/android/THIRD_PARTY_LICENSES/openclaw/licenses/Coil.txt new file mode 100644 index 000000000000..eaa188cdd200 --- /dev/null +++ b/apps/android/THIRD_PARTY_LICENSES/openclaw/licenses/Coil.txt @@ -0,0 +1,19 @@ +Coil +Artifacts: +- io.coil-kt.coil3:coil-compose +- io.coil-kt.coil3:coil-svg +License: Apache License 2.0 + +Copyright 2026 Coil Contributors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index 3be0abc71936..6f4da766cec6 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -330,6 +330,8 @@ dependencies { implementation(libs.androidx.exifinterface) implementation(libs.okhttp) implementation(libs.bcprov) + implementation(libs.coil.compose) + implementation(libs.coil.svg) implementation(libs.commonmark) implementation(libs.commonmark.ext.autolink) implementation(libs.commonmark.ext.gfm.strikethrough) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/GatewayAgentSummary.kt b/apps/android/app/src/main/java/ai/openclaw/app/GatewayAgentSummary.kt new file mode 100644 index 000000000000..7bec04f50053 --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/app/GatewayAgentSummary.kt @@ -0,0 +1,37 @@ +package ai.openclaw.app + +import ai.openclaw.app.node.asObjectOrNull +import ai.openclaw.app.node.asStringOrNull +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +data class GatewayAgentSummary( + val id: String, + val name: String?, + val emoji: String?, + val avatar: String? = null, + val avatarUrl: String? = null, + val workspaceGit: Boolean = false, +) + +/** Parses validated agents.list rows into the smaller Android display model. */ +internal fun parseGatewayAgentSummaries(root: JsonObject): List = (root["agents"] as? JsonArray)?.mapNotNull(::parseGatewayAgentSummary) ?: emptyList() + +private fun parseGatewayAgentSummary(item: JsonElement): GatewayAgentSummary? { + val agent = item.asObjectOrNull() ?: return null + val id = agent["id"].asStringOrNull()?.trim().orEmpty() + if (id.isEmpty()) return null + val identity = agent["identity"].asObjectOrNull() + return GatewayAgentSummary( + id = id, + name = agent["name"].asStringOrNull().normalizedAgentValue(), + emoji = identity?.get("emoji").asStringOrNull().normalizedAgentValue(), + avatar = identity?.get("avatar").asStringOrNull().normalizedAgentValue(), + avatarUrl = identity?.get("avatarUrl").asStringOrNull().normalizedAgentValue(), + workspaceGit = (agent["workspaceGit"] as? JsonPrimitive)?.content?.toBooleanStrictOrNull() == true, + ) +} + +private fun String?.normalizedAgentValue(): String? = this?.trim()?.takeIf { it.isNotEmpty() } 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 d3d0db2dee04..cac472d367ac 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 @@ -3790,25 +3790,7 @@ class NodeRuntime private constructor( val root = json.parseToJsonElement(res).asObjectOrNull() ?: return val defaultAgentId = root["defaultId"].asStringOrNull()?.trim().orEmpty() val mainKey = normalizeMainKey(root["mainKey"].asStringOrNull()) - val agents = - (root["agents"] as? JsonArray)?.mapNotNull { item -> - val obj = item.asObjectOrNull() ?: return@mapNotNull null - val id = obj["id"].asStringOrNull()?.trim().orEmpty() - if (id.isEmpty()) return@mapNotNull null - val name = obj["name"].asStringOrNull()?.trim() - val emoji = - obj["identity"] - .asObjectOrNull() - ?.get("emoji") - .asStringOrNull() - ?.trim() - GatewayAgentSummary( - id = id, - name = name?.takeIf { it.isNotEmpty() }, - emoji = emoji?.takeIf { it.isNotEmpty() }, - workspaceGit = (obj["workspaceGit"] as? JsonPrimitive)?.content?.toBooleanStrictOrNull() == true, - ) - } ?: emptyList() + val agents = parseGatewayAgentSummaries(root) publishGatewayData(gatewayScope) { _gatewayDefaultAgentId.value = defaultAgentId.ifEmpty { null } @@ -5651,13 +5633,6 @@ private enum class HomeCanvasGatewayState { Offline, } -data class GatewayAgentSummary( - val id: String, - val name: String?, - val emoji: String?, - val workspaceGit: Boolean = false, -) - data class GatewayModelSummary( val id: String, val name: String, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt index de13643dd446..ad27c1eae16d 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt @@ -37,6 +37,7 @@ import ai.openclaw.app.node.DeviceNotificationListenerService import ai.openclaw.app.photoReadPermissionsForRequest import ai.openclaw.app.reconcileRestoredAction import ai.openclaw.app.setAppLanguage +import ai.openclaw.app.ui.design.ClawAgentAvatar import ai.openclaw.app.ui.design.ClawDetailRow import ai.openclaw.app.ui.design.ClawIconBadge import ai.openclaw.app.ui.design.ClawListItem @@ -56,6 +57,7 @@ import ai.openclaw.app.ui.design.ClawTheme import ai.openclaw.app.ui.design.OpenClawMascot import ai.openclaw.app.ui.design.TalkWaveform import ai.openclaw.app.ui.design.TalkWaveformPhase +import ai.openclaw.app.ui.design.agentAvatarSource import android.Manifest import android.content.ClipData import android.content.ClipboardManager @@ -2253,7 +2255,11 @@ private fun AgentListRow( ClawDetailRow( title = agent.name?.takeIf { it.isNotBlank() } ?: agent.id, subtitle = if (isDefault) "Default assistant" else "Ready", - leading = { ClawTextBadge(text = agentBadge(agent)) }, + leading = { + ClawAgentAvatar(source = agentAvatarSource(agent), size = 30.dp) { + ClawTextBadge(text = agentBadge(agent)) + } + }, trailing = { ClawStatusPill(text = if (isDefault) "Default" else "Ready", status = ClawStatus.Success) }, ) } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt index 080911703278..a78d137cb36b 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt @@ -18,6 +18,8 @@ import ai.openclaw.app.chat.ChatSessionEntry import ai.openclaw.app.currentAppLanguage import ai.openclaw.app.node.CanvasController import ai.openclaw.app.ui.chat.ChatScreen +import ai.openclaw.app.ui.design.AgentAvatarSource +import ai.openclaw.app.ui.design.ClawAgentAvatar import ai.openclaw.app.ui.design.ClawBottomNav import ai.openclaw.app.ui.design.ClawDesignTheme import ai.openclaw.app.ui.design.ClawEmptyState @@ -31,6 +33,7 @@ import ai.openclaw.app.ui.design.ClawSecondaryButton import ai.openclaw.app.ui.design.ClawStatus import ai.openclaw.app.ui.design.ClawTheme import ai.openclaw.app.ui.design.OpenClawMascot +import ai.openclaw.app.ui.design.agentAvatarSource import androidx.activity.compose.BackHandler import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background @@ -436,6 +439,7 @@ private fun OverviewScreen( val headerRoute = overviewHeaderRoute(attentionRows) val activeAgentName = overviewAgentName(agents = agents, defaultAgentId = defaultAgentId) val activeAgentBadge = overviewAgentBadgeText(agents = agents, defaultAgentId = defaultAgentId) + val activeAgentAvatar = overviewAgentAvatar(agents = agents, defaultAgentId = defaultAgentId) val overviewSessions = overviewRecentSessions(sessions) val overviewSessionCount = overviewSessions.size val candidateRecentRows = @@ -495,6 +499,7 @@ private fun OverviewScreen( OverviewPrimaryPanel( agentName = activeAgentName, agentBadge = activeAgentBadge, + agentAvatarSource = activeAgentAvatar, statusText = gatewaySummary(gatewayConnectionDisplay), isConnected = gatewayConnectionDisplay.isConnected, pendingRunCount = pendingRunCount, @@ -626,6 +631,7 @@ private fun OverviewStatusPill( private fun OverviewPrimaryPanel( agentName: String, agentBadge: String, + agentAvatarSource: AgentAvatarSource?, statusText: String, isConnected: Boolean, pendingRunCount: Int, @@ -640,7 +646,7 @@ private fun OverviewPrimaryPanel( Column(verticalArrangement = Arrangement.spacedBy(ClawTheme.spacing.xs)) { Text(text = "ACTIVE AGENT", style = ClawTheme.type.caption.copy(fontSize = 12.sp, lineHeight = 15.sp), color = ClawTheme.colors.textMuted) Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) { - OverviewAgentBadge(text = agentBadge, active = isConnected) + OverviewAgentBadge(text = agentBadge, active = isConnected, avatarSource = agentAvatarSource) Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) { Text(text = if (pendingRunCount > 0) "$agentName is working" else agentName, style = ClawTheme.type.title.copy(fontSize = 19.sp, lineHeight = 23.sp), color = ClawTheme.colors.text, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false)) @@ -729,6 +735,7 @@ private fun OverviewLayeredPanel( private fun OverviewAgentBadge( text: String, active: Boolean, + avatarSource: AgentAvatarSource?, ) { Surface( modifier = Modifier.size(42.dp), @@ -738,12 +745,14 @@ private fun OverviewAgentBadge( tonalElevation = if (active) 3.dp else 1.dp, shadowElevation = if (active) 5.dp else 1.dp, ) { - Box(contentAlignment = Alignment.Center) { - Text( - text = text, - style = ClawTheme.type.title.copy(fontSize = 16.sp, lineHeight = 20.sp), - maxLines = 1, - ) + ClawAgentAvatar(source = avatarSource, size = 42.dp) { + Box(contentAlignment = Alignment.Center) { + Text( + text = text, + style = ClawTheme.type.title.copy(fontSize = 16.sp, lineHeight = 20.sp), + maxLines = 1, + ) + } } } } @@ -1106,6 +1115,11 @@ internal fun overviewAgentBadgeText( return agentInitials(source) } +internal fun overviewAgentAvatar( + agents: List, + defaultAgentId: String?, +): AgentAvatarSource? = overviewAgent(agents = agents, defaultAgentId = defaultAgentId)?.let(::agentAvatarSource) + private fun overviewAgent( agents: List, defaultAgentId: String?, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatLinkPreview.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatLinkPreview.kt index 4bb196a4aea8..c74cf7dd7779 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatLinkPreview.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatLinkPreview.kt @@ -1,54 +1,25 @@ package ai.openclaw.app.ui.chat import ai.openclaw.app.takeUtf16Safe -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.util.LruCache -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.isActive -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.withContext -import okhttp3.Authenticator -import okhttp3.Call -import okhttp3.CookieJar -import okhttp3.Dns +import ai.openclaw.app.ui.image.SafeWebFetcher +import ai.openclaw.app.ui.image.isPubliclyRoutableHost +import ai.openclaw.app.ui.image.safePublicHttpClient import okhttp3.HttpUrl -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.Response -import okhttp3.ResponseBody -import okio.Buffer import org.commonmark.node.Code import org.commonmark.node.FencedCodeBlock import org.commonmark.node.IndentedCodeBlock import org.commonmark.node.Link import org.commonmark.node.Node -import java.io.IOException -import java.net.Inet4Address -import java.net.Inet6Address -import java.net.InetAddress -import java.net.Proxy import java.net.URI -import java.net.UnknownHostException import java.util.Locale -import java.util.concurrent.TimeUnit -import kotlin.coroutines.resume -import kotlin.math.max internal const val LINK_PREVIEW_TITLE_MAX_CHARS = 120 internal const val LINK_PREVIEW_DESCRIPTION_MAX_CHARS = 200 internal const val LINK_PREVIEW_BODY_MAX_BYTES = 512 * 1024 -internal const val LINK_PREVIEW_IMAGE_BODY_MAX_BYTES = 1024 * 1024 -internal const val LINK_PREVIEW_IMAGE_MAX_DIMENSION = 600 -private const val LINK_PREVIEW_MAX_REDIRECTS = 3 private const val LINK_PREVIEW_TIMEOUT_MILLIS = 6_000L private const val LINK_PREVIEW_CACHE_ENTRIES = 64 -private const val LINK_PREVIEW_IMAGE_CACHE_MAX_BYTES = 8 * 1024 * 1024 -private const val LINK_PREVIEW_IMAGE_CACHE_ENTRIES = 32 private const val LINK_PREVIEW_ACCEPT = "text/html, application/xhtml+xml;q=0.9" -private const val LINK_PREVIEW_IMAGE_ACCEPT = "image/*" -private val LINK_PREVIEW_IMAGE_CONTENT_TYPES = setOf("image/jpeg", "image/png", "image/webp") internal data class LinkPreviewMetadata( val url: String, @@ -65,14 +36,6 @@ internal sealed interface LinkPreviewResult { data object Failed : LinkPreviewResult } -internal sealed interface LinkPreviewImageResult { - data class Loaded( - val bitmap: Bitmap, - ) : LinkPreviewImageResult - - data object Failed : LinkPreviewImageResult -} - /** Returns the first safe web link outside inline and block code. */ internal fun extractFirstBareUrl(markdown: String): String? = findFirstLink(parseChatMarkdown(markdown).firstChild) @@ -128,23 +91,15 @@ internal fun parseOpenGraph( } internal class LinkPreviewFetcher( - private val client: OkHttpClient = defaultLinkPreviewClient, + client: OkHttpClient = safePublicHttpClient, private val timeoutMillis: Long = LINK_PREVIEW_TIMEOUT_MILLIS, private val hostPolicy: (HttpUrl) -> Boolean = ::isPubliclyRoutableHost, ) { - suspend fun fetch(url: String): LinkPreviewResult = - withContext(Dispatchers.IO) { - fetchBlocking(url) - } + private val webFetcher = SafeWebFetcher(client, timeoutMillis, hostPolicy) - suspend fun fetchImage(url: String): LinkPreviewImageResult = - withContext(Dispatchers.IO) { - fetchImageBlocking(url) - } - - private suspend fun fetchBlocking(originalUrl: String): LinkPreviewResult { + suspend fun fetch(originalUrl: String): LinkPreviewResult { val response = - fetchBody( + webFetcher.fetch( originalUrl = originalUrl, accept = LINK_PREVIEW_ACCEPT, allowedContentTypes = setOf("text/html"), @@ -157,188 +112,6 @@ internal class LinkPreviewFetcher( LinkPreviewResult.Failed -> LinkPreviewResult.Failed } } - - private suspend fun fetchImageBlocking(url: String): LinkPreviewImageResult { - val response = - fetchBody( - originalUrl = url, - accept = LINK_PREVIEW_IMAGE_ACCEPT, - allowedContentTypes = LINK_PREVIEW_IMAGE_CONTENT_TYPES, - maxBytes = LINK_PREVIEW_IMAGE_BODY_MAX_BYTES, - rejectOversizedBody = true, - ) ?: return LinkPreviewImageResult.Failed - val bitmap = - decodeLinkPreviewBitmap( - bytes = response.bytes, - expectedContentType = response.contentType, - ) ?: return LinkPreviewImageResult.Failed - return LinkPreviewImageResult.Loaded(bitmap) - } - - private suspend fun fetchBody( - originalUrl: String, - accept: String, - allowedContentTypes: Set, - maxBytes: Int, - rejectOversizedBody: Boolean, - ): LinkPreviewFetchedBody? { - var currentUrl = - originalUrl - .toHttpUrlOrNull() - ?.takeIf(::isSafeWebUrl) - ?.takeIf(hostPolicy) - ?: return null - val deadlineNanos = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMillis) - var redirects = 0 - - while (true) { - val remainingNanos = deadlineNanos - System.nanoTime() - if (remainingNanos <= 0L) return null - val request = - Request - .Builder() - .url(currentUrl) - .header("Accept", accept) - .get() - .build() - val call = client.newCall(request) - call.timeout().timeout(remainingNanos, TimeUnit.NANOSECONDS) - - val response = call.executeCancellable() ?: return null - response.use { - if (it.isRedirect) { - if (redirects >= LINK_PREVIEW_MAX_REDIRECTS) return null - currentUrl = resolveRedirect(currentUrl, it.header("Location"), hostPolicy) ?: return null - redirects += 1 - continue - } - if (!it.isSuccessful) return null - val contentType = it.body.contentType() ?: return null - val contentTypeName = "${contentType.type}/${contentType.subtype}".lowercase(Locale.US) - if (contentTypeName !in allowedContentTypes) return null - - val bytes = call.awaitBodyRead { readBody(it.body, maxBytes, rejectOversizedBody) } ?: return null - return LinkPreviewFetchedBody( - url = currentUrl, - bytes = bytes, - charset = contentType.charset(Charsets.UTF_8) ?: Charsets.UTF_8, - contentType = contentTypeName, - ) - } - } - } -} - -private suspend fun Call.executeCancellable(): Response? = - suspendCancellableCoroutine { continuation -> - // execute() blocks this IO worker, so cancellation must cancel the Call from the cancelling thread. - continuation.invokeOnCancellation { cancel() } - val response = - try { - execute() - } catch (_: IOException) { - null - } - if (response != null) { - continuation.resume(response) { _, cancelledResponse, _ -> - cancelledResponse.close() - } - } else if (continuation.isActive) { - continuation.resume(null) - } - } - -private suspend fun Call.awaitBodyRead(block: () -> T?): T? = - suspendCancellableCoroutine { continuation -> - continuation.invokeOnCancellation { cancel() } - try { - val result = block() - if (continuation.isActive) { - continuation.resume(result) - } - } catch (_: IOException) { - if (continuation.isActive) { - continuation.resume(null) - } - } - } - -private data class LinkPreviewFetchedBody( - val url: HttpUrl, - val bytes: ByteArray, - val charset: java.nio.charset.Charset, - val contentType: String, -) - -private fun readBody( - body: ResponseBody, - maxBytes: Int, - rejectOversizedBody: Boolean, -): ByteArray? { - if (rejectOversizedBody && body.contentLength() > maxBytes) return null - val buffer = Buffer() - val source = body.source() - val readLimit = maxBytes.toLong() + if (rejectOversizedBody) 1L else 0L - while (buffer.size < readLimit) { - val remaining = readLimit - buffer.size - if (source.read(buffer, remaining) == -1L) break - } - if (rejectOversizedBody && buffer.size > maxBytes) return null - return buffer.readByteArray() -} - -internal fun decodeLinkPreviewBitmap( - bytes: ByteArray, - maxDimension: Int = LINK_PREVIEW_IMAGE_MAX_DIMENSION, - expectedContentType: String? = null, -): Bitmap? { - if (bytes.isEmpty() || maxDimension <= 0) return null - val encodedContentType = linkPreviewImageContentType(bytes) ?: return null - if (expectedContentType != null && encodedContentType != expectedContentType) return null - return try { - val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } - BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) - if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null - - BitmapFactory.decodeByteArray( - bytes, - 0, - bytes.size, - BitmapFactory.Options().apply { - inSampleSize = linkPreviewImageSampleSize(bounds.outWidth, bounds.outHeight, maxDimension) - inPreferredConfig = Bitmap.Config.ARGB_8888 - }, - ) - } catch (_: RuntimeException) { - null - } catch (_: OutOfMemoryError) { - null - } -} - -private fun linkPreviewImageContentType(bytes: ByteArray): String? = - when { - bytes.matchesPrefix(0xff, 0xd8, 0xff) -> "image/jpeg" - bytes.matchesPrefix(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a) -> "image/png" - bytes.matchesPrefix(0x52, 0x49, 0x46, 0x46) && bytes.matchesAt(8, 0x57, 0x45, 0x42, 0x50) -> "image/webp" - else -> null - } - -private fun ByteArray.matchesAt( - offset: Int, - vararg expected: Int, -): Boolean = size >= offset + expected.size && expected.indices.all { index -> (this[offset + index].toInt() and 0xff) == expected[index] } - -private fun linkPreviewImageSampleSize( - width: Int, - height: Int, - maxDimension: Int, -): Int { - var sample = 1 - while (max(width / sample, height / sample) > maxDimension && sample <= Int.MAX_VALUE / 2) { - sample *= 2 - } - return sample } internal class LinkPreviewStore( @@ -358,150 +131,8 @@ internal class LinkPreviewStore( } } -internal class LinkPreviewImageStore( - private val fetcher: suspend (String) -> LinkPreviewImageResult, - maxBytes: Int = LINK_PREVIEW_IMAGE_CACHE_MAX_BYTES, -) { - // Every result pays at least one entry share, preserving the entry cap while loaded bitmaps - // also pay their full backing allocation. - private val minimumResultBytes = max(1, maxBytes / LINK_PREVIEW_IMAGE_CACHE_ENTRIES) - private val cache = - object : LruCache(maxBytes) { - override fun sizeOf( - key: String, - value: LinkPreviewImageResult, - ): Int = - when (value) { - is LinkPreviewImageResult.Loaded -> value.bitmap.allocationByteCount.coerceAtLeast(minimumResultBytes) - LinkPreviewImageResult.Failed -> minimumResultBytes - } - } - - suspend fun get(url: String): LinkPreviewImageResult { - cache.get(url)?.let { return it } - val result = fetcher(url) - cache.put(url, result) - return result - } -} - -private val defaultLinkPreviewClient: OkHttpClient = - OkHttpClient - .Builder() - .followRedirects(false) - .followSslRedirects(false) - .retryOnConnectionFailure(false) - .cookieJar(CookieJar.NO_COOKIES) - .authenticator(Authenticator.NONE) - .proxyAuthenticator(Authenticator.NONE) - .proxy(Proxy.NO_PROXY) - // Validate inside Dns so the approved address is the one OkHttp connects to, preventing rebinding/TOCTOU. - .dns(PublicOnlyDns()) - .build() - private val chatLinkPreviewFetcher = LinkPreviewFetcher() internal val chatLinkPreviewStore = LinkPreviewStore(fetcher = chatLinkPreviewFetcher::fetch) -internal val chatLinkPreviewImageStore = LinkPreviewImageStore(fetcher = chatLinkPreviewFetcher::fetchImage) - -internal fun resolveRedirect( - baseUrl: HttpUrl, - location: String?, - hostPolicy: (HttpUrl) -> Boolean = ::isPubliclyRoutableHost, -): HttpUrl? = - location - ?.let(baseUrl::resolve) - ?.takeIf { isSafeWebUrl(it) && hostPolicy(it) } - -private fun isSafeWebUrl(url: HttpUrl): Boolean = url.scheme == "http" || url.scheme == "https" - -internal fun isPubliclyRoutableHost(url: HttpUrl): Boolean { - val host = url.host.trimEnd('.').lowercase(Locale.US) - if (host == "localhost" || host.endsWith(".local")) return false - val address = parseLiteralAddress(host) ?: return true - return isPubliclyRoutableAddress(address) -} - -private fun parseLiteralAddress(host: String): InetAddress? { - if (host.contains(':')) return runCatching { InetAddress.getByName(host) }.getOrNull() - val octets = host.split('.') - if (octets.size != 4) return null - val bytes = - octets.map { octet -> - val value = octet.toIntOrNull()?.takeIf { it in 0..255 } ?: return null - value.toByte() - } - return InetAddress.getByAddress(bytes.toByteArray()) -} - -private fun isPubliclyRoutableAddress(address: InetAddress): Boolean = - !address.isAnyLocalAddress && - !address.isLoopbackAddress && - !address.isSiteLocalAddress && - !address.isLinkLocalAddress && - !address.isMulticastAddress && - !address.isUniqueLocalAddress() && - !address.isLimitedBroadcastAddress() && - !address.isSpecialPurposeAddress() - -private fun InetAddress.isUniqueLocalAddress(): Boolean { - val bytes = address - return bytes.size == 16 && (bytes[0].toInt() and 0xfe) == 0xfc -} - -private fun InetAddress.isLimitedBroadcastAddress(): Boolean = this is Inet4Address && address.all { byte -> byte.toInt() and 0xff == 0xff } - -private fun InetAddress.isSpecialPurposeAddress(): Boolean = - when (this) { - is Inet4Address -> { - val octets = address.map { it.toInt() and 0xff } - val first = octets[0] - val second = octets[1] - val third = octets[2] - first == 0 || - first == 10 || - (first == 100 && second in 64..127) || - first == 127 || - (first == 169 && second == 254) || - (first == 172 && second in 16..31) || - (first == 192 && second == 0 && (third == 0 || third == 2)) || - (first == 192 && second == 88 && third == 99) || - (first == 192 && second == 168) || - (first == 198 && second in 18..19) || - (first == 198 && second == 51 && third == 100) || - (first == 203 && second == 0 && third == 113) || - first >= 224 - } - is Inet6Address -> { - val bytes = address - val first = bytes[0].toInt() and 0xff - val globalUnicast = first and 0xe0 == 0x20 - val special2001Prefix = bytes.matchesPrefix(0x20, 0x01, 0x00) - val fourthHighNibble = bytes[3].toInt() and 0xf0 - val orchid = special2001Prefix && (fourthHighNibble == 0x10 || fourthHighNibble == 0x20) - !globalUnicast || - bytes.matchesPrefix(0x20, 0x01, 0x00, 0x00) || - bytes.matchesPrefix(0x20, 0x01, 0x00, 0x02) || - orchid || - bytes.matchesPrefix(0x20, 0x01, 0x0d, 0xb8) || - bytes.matchesPrefix(0x20, 0x02) || - (bytes.matchesPrefix(0x3f, 0xff) && (bytes[2].toInt() and 0xf0) == 0) - } - else -> true - } - -private fun ByteArray.matchesPrefix(vararg prefix: Int): Boolean = matchesAt(0, *prefix) - -internal class PublicOnlyDns( - private val delegate: Dns = Dns.SYSTEM, -) : Dns { - override fun lookup(hostname: String): List { - val addresses = delegate.lookup(hostname) - if (addresses.any { !isPubliclyRoutableAddress(it) }) { - throw UnknownHostException("$hostname resolved to a non-public address") - } - return addresses - } -} private fun resolveSafeWebUrl( baseUrl: String, 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 eddafa29516b..2f38359cba58 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 @@ -11,6 +11,8 @@ import ai.openclaw.app.chat.normalizeVisibleChatMessageRole import ai.openclaw.app.tools.ToolDisplayRegistry import ai.openclaw.app.ui.MobileColorsAccessor import ai.openclaw.app.ui.design.ClawTheme +import ai.openclaw.app.ui.image.RemoteImageResult +import ai.openclaw.app.ui.image.safeRemoteImageStore import ai.openclaw.app.ui.mobileAccent import ai.openclaw.app.ui.mobileAccentSoft import ai.openclaw.app.ui.mobileBorder @@ -295,9 +297,9 @@ private fun ChatLinkPreview( var previewImage by remember(messageId, url, imageUrl) { mutableStateOf(null) } LaunchedEffect(imageUrl) { previewImage = - when (val image = imageUrl?.let { chatLinkPreviewImageStore.get(it) }) { - is LinkPreviewImageResult.Loaded -> image.bitmap.asImageBitmap() - LinkPreviewImageResult.Failed, null -> null + when (val image = imageUrl?.let { safeRemoteImageStore.get(it) }) { + is RemoteImageResult.Raster -> image.bitmap.asImageBitmap() + is RemoteImageResult.Svg, RemoteImageResult.Failed, null -> null } } val uriHandler = LocalUriHandler.current 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 bd5beda58a96..a672fa785154 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 @@ -17,6 +17,8 @@ import ai.openclaw.app.chat.MessageSpeechState import ai.openclaw.app.chat.VoiceNoteRecorderState import ai.openclaw.app.resolveAgentIdFromMainSessionKey import ai.openclaw.app.ui.copyGatewayDiagnosticsReport +import ai.openclaw.app.ui.design.AgentAvatarSource +import ai.openclaw.app.ui.design.ClawAgentAvatar import ai.openclaw.app.ui.design.ClawListItem import ai.openclaw.app.ui.design.ClawLoadingState import ai.openclaw.app.ui.design.ClawPanel @@ -27,6 +29,7 @@ import ai.openclaw.app.ui.design.ClawStatus import ai.openclaw.app.ui.design.ClawStatusPill import ai.openclaw.app.ui.design.ClawTheme import ai.openclaw.app.ui.design.OpenClawMascot +import ai.openclaw.app.ui.design.agentAvatarSource import ai.openclaw.app.ui.gatewayDiagnosticsEndpoint import ai.openclaw.app.ui.gatewayStatusForDisplay import androidx.activity.compose.rememberLauncherForActivityResult @@ -499,6 +502,7 @@ private fun ChatAgentSelector( agents.forEach { agent -> ChatSessionChip( text = chatAgentChipText(agent), + avatarSource = agentAvatarSource(agent), active = agent.id == activeAgentId, onClick = { onSelectAgent(agent.id) }, ) @@ -577,6 +581,7 @@ private fun ChatSessionSwitcher( @Composable private fun ChatSessionChip( text: String, + avatarSource: AgentAvatarSource? = null, active: Boolean, onClick: () -> Unit, ) { @@ -588,13 +593,25 @@ private fun ChatSessionChip( contentColor = ClawTheme.colors.text, border = BorderStroke(1.dp, if (active) ClawTheme.colors.borderStrong else ClawTheme.colors.border.copy(alpha = 0.7f)), ) { - Text( - text = text, - modifier = Modifier.padding(horizontal = 11.dp, vertical = 7.dp), - style = ClawTheme.type.caption, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Row( + modifier = + Modifier.padding( + horizontal = if (avatarSource == null) 11.dp else 8.dp, + vertical = if (avatarSource == null) 7.dp else 5.dp, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + if (avatarSource != null) { + ClawAgentAvatar(source = avatarSource, size = 20.dp) {} + } + Text( + text = text, + style = ClawTheme.type.caption, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/design/AgentAvatar.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/design/AgentAvatar.kt new file mode 100644 index 000000000000..8c4a34cf4d38 --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/design/AgentAvatar.kt @@ -0,0 +1,204 @@ +package ai.openclaw.app.ui.design + +import ai.openclaw.app.GatewayAgentSummary +import ai.openclaw.app.ui.image.RemoteImageResult +import ai.openclaw.app.ui.image.decodeRemoteImageBitmap +import ai.openclaw.app.ui.image.safeRemoteImageStore +import android.graphics.Bitmap +import android.util.Base64 +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.Dp +import coil3.compose.AsyncImagePainter +import coil3.compose.LocalPlatformContext +import coil3.compose.rememberAsyncImagePainter +import coil3.request.ImageRequest +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.util.Locale + +private const val AGENT_AVATAR_MAX_DIMENSION = 256 +private const val AGENT_AVATAR_MAX_BYTES = 2 * 1024 * 1024 +private const val AGENT_AVATAR_MAX_DATA_URL_PREFIX_CHARS = 26 + +// Keep Android decoding inside the Gateway's shared avatar payload boundary. +private const val AGENT_AVATAR_MAX_DATA_URL_CHARS = + ((AGENT_AVATAR_MAX_BYTES + 2) / 3) * 4 + AGENT_AVATAR_MAX_DATA_URL_PREFIX_CHARS +private val dataImageBase64Prefix = + Regex("^data:(image/[a-z0-9.+-]+);base64,", RegexOption.IGNORE_CASE) +private val remoteImagePrefix = Regex("^https?://", RegexOption.IGNORE_CASE) + +internal sealed interface AgentAvatarSource { + data class Data( + val mimeType: String, + val base64: String, + ) : AgentAvatarSource + + data class Remote( + val url: String, + ) : AgentAvatarSource +} + +/** Returns the authoritative Android-renderable agent avatar source, if present. */ +internal fun agentAvatarSource(agent: GatewayAgentSummary): AgentAvatarSource? { + val candidate = + agent.avatarUrl?.trim()?.takeIf { it.isNotEmpty() } + ?: agent.avatar?.trim()?.takeIf { it.isNotEmpty() } + ?: return null + if (remoteImagePrefix.containsMatchIn(candidate)) { + return AgentAvatarSource.Remote(candidate) + } + if (candidate.length > AGENT_AVATAR_MAX_DATA_URL_CHARS) return null + val prefix = dataImageBase64Prefix.find(candidate) ?: return null + val base64 = candidate.substring(prefix.range.last + 1).trim().takeIf { it.isNotEmpty() } ?: return null + return AgentAvatarSource.Data( + mimeType = prefix.groupValues[1].lowercase(Locale.US), + base64 = base64, + ) +} + +/** Renders an agent image when loading succeeds, otherwise the caller-owned fallback. */ +@Composable +internal fun ClawAgentAvatar( + source: AgentAvatarSource?, + size: Dp, + shape: Shape = CircleShape, + fallback: @Composable () -> Unit, +) { + when (source) { + is AgentAvatarSource.Data -> + if (source.mimeType == "image/svg+xml") { + SvgAgentAvatar(base64 = source.base64, size = size, shape = shape, fallback = fallback) + } else { + RasterDataAgentAvatar(source = source, size = size, shape = shape, fallback = fallback) + } + is AgentAvatarSource.Remote -> RemoteAgentAvatar(source.url, size, shape, fallback) + null -> fallback() + } +} + +@Composable +private fun RasterDataAgentAvatar( + source: AgentAvatarSource.Data, + size: Dp, + shape: Shape, + fallback: @Composable () -> Unit, +) { + var bitmap by remember(source) { mutableStateOf(null) } + LaunchedEffect(source) { + bitmap = + withContext(Dispatchers.Default) { + val bytes = decodeAgentAvatarBase64(source.base64) ?: return@withContext null + decodeRemoteImageBitmap( + bytes = bytes, + maxDimension = AGENT_AVATAR_MAX_DIMENSION, + expectedContentType = source.mimeType, + ) + } + } + val resolved = bitmap + if (resolved == null) { + fallback() + } else { + Image( + bitmap = resolved.asImageBitmap(), + contentDescription = null, + modifier = Modifier.size(size).clip(shape), + contentScale = ContentScale.Crop, + ) + } +} + +@Composable +private fun RemoteAgentAvatar( + url: String, + size: Dp, + shape: Shape, + fallback: @Composable () -> Unit, +) { + var result by remember(url) { mutableStateOf(null) } + LaunchedEffect(url) { + result = safeRemoteImageStore.get(url) + } + when (val image = result) { + is RemoteImageResult.Raster -> + Image( + bitmap = image.bitmap.asImageBitmap(), + contentDescription = null, + modifier = Modifier.size(size).clip(shape), + contentScale = ContentScale.Crop, + ) + is RemoteImageResult.Svg -> SvgAgentAvatar(bytes = image.bytes, size = size, shape = shape, fallback = fallback) + RemoteImageResult.Failed, null -> fallback() + } +} + +@Composable +private fun SvgAgentAvatar( + base64: String, + size: Dp, + shape: Shape, + fallback: @Composable () -> Unit, +) { + var bytes by remember(base64) { mutableStateOf(null) } + LaunchedEffect(base64) { + bytes = + withContext(Dispatchers.Default) { + decodeAgentAvatarBase64(base64) + } + } + val resolved = bytes + if (resolved == null) { + fallback() + } else { + SvgAgentAvatar(bytes = resolved, size = size, shape = shape, fallback = fallback) + } +} + +@Composable +private fun SvgAgentAvatar( + bytes: ByteArray, + size: Dp, + shape: Shape, + fallback: @Composable () -> Unit, +) { + val context = LocalPlatformContext.current + val request = + remember(bytes, context) { + ImageRequest + .Builder(context) + .data(bytes) + .size(AGENT_AVATAR_MAX_DIMENSION) + .build() + } + val painter = rememberAsyncImagePainter(model = request, contentScale = ContentScale.Crop) + val painterState by painter.state.collectAsState() + if (painterState !is AsyncImagePainter.State.Success) { + fallback() + return + } + Image( + painter = painter, + contentDescription = null, + modifier = Modifier.size(size).clip(shape), + contentScale = ContentScale.Crop, + ) +} + +private fun decodeAgentAvatarBase64(base64: String): ByteArray? = + runCatching { Base64.decode(base64, Base64.DEFAULT) } + .getOrNull() + ?.takeIf { it.isNotEmpty() && it.size <= AGENT_AVATAR_MAX_BYTES } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/image/SafeRemoteImage.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/image/SafeRemoteImage.kt new file mode 100644 index 000000000000..740e737e9806 --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/image/SafeRemoteImage.kt @@ -0,0 +1,409 @@ +package ai.openclaw.app.ui.image + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.util.LruCache +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.isActive +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import okhttp3.Authenticator +import okhttp3.Call +import okhttp3.CookieJar +import okhttp3.Dns +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody +import okio.Buffer +import java.io.IOException +import java.net.Inet4Address +import java.net.Inet6Address +import java.net.InetAddress +import java.net.Proxy +import java.net.UnknownHostException +import java.util.Locale +import java.util.concurrent.TimeUnit +import kotlin.coroutines.resume +import kotlin.math.max + +internal const val REMOTE_IMAGE_BODY_MAX_BYTES = 1024 * 1024 +internal const val REMOTE_IMAGE_MAX_DIMENSION = 600 +private const val SAFE_WEB_MAX_REDIRECTS = 3 +private const val SAFE_WEB_TIMEOUT_MILLIS = 6_000L +private const val REMOTE_IMAGE_CACHE_MAX_BYTES = 8 * 1024 * 1024 +private const val REMOTE_IMAGE_CACHE_ENTRIES = 32 +private const val REMOTE_IMAGE_ACCEPT = "image/*" +private val remoteImageContentTypes = + setOf("image/gif", "image/jpeg", "image/png", "image/svg+xml", "image/webp") + +internal data class SafeWebBody( + val url: HttpUrl, + val bytes: ByteArray, + val charset: java.nio.charset.Charset, + val contentType: String, +) + +internal class SafeWebFetcher( + private val client: OkHttpClient = safePublicHttpClient, + private val timeoutMillis: Long = SAFE_WEB_TIMEOUT_MILLIS, + private val hostPolicy: (HttpUrl) -> Boolean = ::isPubliclyRoutableHost, +) { + suspend fun fetch( + originalUrl: String, + accept: String, + allowedContentTypes: Set, + maxBytes: Int, + rejectOversizedBody: Boolean, + ): SafeWebBody? = + withContext(Dispatchers.IO) { + fetchBlocking( + originalUrl = originalUrl, + accept = accept, + allowedContentTypes = allowedContentTypes, + maxBytes = maxBytes, + rejectOversizedBody = rejectOversizedBody, + ) + } + + private suspend fun fetchBlocking( + originalUrl: String, + accept: String, + allowedContentTypes: Set, + maxBytes: Int, + rejectOversizedBody: Boolean, + ): SafeWebBody? { + var currentUrl = + originalUrl + .toHttpUrlOrNull() + ?.takeIf(::isSafeWebUrl) + ?.takeIf(hostPolicy) + ?: return null + val deadlineNanos = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMillis) + var redirects = 0 + + while (true) { + val remainingNanos = deadlineNanos - System.nanoTime() + if (remainingNanos <= 0L) return null + val request = + Request + .Builder() + .url(currentUrl) + .header("Accept", accept) + .get() + .build() + val call = client.newCall(request) + call.timeout().timeout(remainingNanos, TimeUnit.NANOSECONDS) + + val response = call.executeCancellable() ?: return null + response.use { + if (it.isRedirect) { + if (redirects >= SAFE_WEB_MAX_REDIRECTS) return null + currentUrl = resolveRedirect(currentUrl, it.header("Location"), hostPolicy) ?: return null + redirects += 1 + continue + } + if (!it.isSuccessful) return null + val contentType = it.body.contentType() ?: return null + val contentTypeName = "${contentType.type}/${contentType.subtype}".lowercase(Locale.US) + if (contentTypeName !in allowedContentTypes) return null + + val bytes = call.awaitBodyRead { readBody(it.body, maxBytes, rejectOversizedBody) } ?: return null + return SafeWebBody( + url = currentUrl, + bytes = bytes, + charset = contentType.charset(Charsets.UTF_8) ?: Charsets.UTF_8, + contentType = contentTypeName, + ) + } + } + } +} + +internal sealed interface RemoteImageResult { + data class Raster( + val bitmap: Bitmap, + ) : RemoteImageResult + + data class Svg( + val bytes: ByteArray, + ) : RemoteImageResult + + data object Failed : RemoteImageResult +} + +internal class SafeRemoteImageFetcher( + private val webFetcher: SafeWebFetcher = SafeWebFetcher(), +) { + suspend fun fetch(url: String): RemoteImageResult { + val response = + webFetcher.fetch( + originalUrl = url, + accept = REMOTE_IMAGE_ACCEPT, + allowedContentTypes = remoteImageContentTypes, + maxBytes = REMOTE_IMAGE_BODY_MAX_BYTES, + rejectOversizedBody = true, + ) ?: return RemoteImageResult.Failed + if (response.contentType == "image/svg+xml") { + return RemoteImageResult.Svg(response.bytes) + } + val bitmap = + decodeRemoteImageBitmap( + bytes = response.bytes, + expectedContentType = response.contentType, + ) ?: return RemoteImageResult.Failed + return RemoteImageResult.Raster(bitmap) + } +} + +internal class SafeRemoteImageStore( + private val fetcher: suspend (String) -> RemoteImageResult, + maxBytes: Int = REMOTE_IMAGE_CACHE_MAX_BYTES, +) { + private val minimumResultBytes = max(1, maxBytes / REMOTE_IMAGE_CACHE_ENTRIES) + private val cache = + object : LruCache(maxBytes) { + override fun sizeOf( + key: String, + value: RemoteImageResult, + ): Int = + when (value) { + is RemoteImageResult.Raster -> value.bitmap.allocationByteCount.coerceAtLeast(minimumResultBytes) + is RemoteImageResult.Svg -> value.bytes.size.coerceAtLeast(minimumResultBytes) + RemoteImageResult.Failed -> minimumResultBytes + } + } + + suspend fun get(url: String): RemoteImageResult { + cache.get(url)?.let { return it } + val result = fetcher(url) + cache.put(url, result) + return result + } +} + +internal fun decodeRemoteImageBitmap( + bytes: ByteArray, + maxDimension: Int = REMOTE_IMAGE_MAX_DIMENSION, + expectedContentType: String? = null, +): Bitmap? { + if (bytes.isEmpty() || maxDimension <= 0) return null + val encodedContentType = remoteImageContentType(bytes) ?: return null + if (expectedContentType != null && encodedContentType != expectedContentType) return null + return try { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null + + BitmapFactory.decodeByteArray( + bytes, + 0, + bytes.size, + BitmapFactory.Options().apply { + inSampleSize = remoteImageSampleSize(bounds.outWidth, bounds.outHeight, maxDimension) + inPreferredConfig = Bitmap.Config.ARGB_8888 + }, + ) + } catch (_: RuntimeException) { + null + } catch (_: OutOfMemoryError) { + null + } +} + +private fun remoteImageContentType(bytes: ByteArray): String? = + when { + bytes.matchesPrefix(0x47, 0x49, 0x46, 0x38) -> "image/gif" + bytes.matchesPrefix(0xff, 0xd8, 0xff) -> "image/jpeg" + bytes.matchesPrefix(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a) -> "image/png" + bytes.matchesPrefix(0x52, 0x49, 0x46, 0x46) && bytes.matchesAt(8, 0x57, 0x45, 0x42, 0x50) -> "image/webp" + else -> null + } + +private fun remoteImageSampleSize( + width: Int, + height: Int, + maxDimension: Int, +): Int { + var sample = 1 + while (max(width / sample, height / sample) > maxDimension && sample <= Int.MAX_VALUE / 2) { + sample *= 2 + } + return sample +} + +internal fun resolveRedirect( + baseUrl: HttpUrl, + location: String?, + hostPolicy: (HttpUrl) -> Boolean = ::isPubliclyRoutableHost, +): HttpUrl? = + location + ?.let(baseUrl::resolve) + ?.takeIf { isSafeWebUrl(it) && hostPolicy(it) } + +private fun isSafeWebUrl(url: HttpUrl): Boolean = url.scheme == "http" || url.scheme == "https" + +internal fun isPubliclyRoutableHost(url: HttpUrl): Boolean { + val host = url.host.trimEnd('.').lowercase(Locale.US) + if (host == "localhost" || host.endsWith(".local")) return false + val address = parseLiteralAddress(host) ?: return true + return isPubliclyRoutableAddress(address) +} + +private fun parseLiteralAddress(host: String): InetAddress? { + if (host.contains(':')) return runCatching { InetAddress.getByName(host) }.getOrNull() + val octets = host.split('.') + if (octets.size != 4) return null + val bytes = + octets.map { octet -> + val value = octet.toIntOrNull()?.takeIf { it in 0..255 } ?: return null + value.toByte() + } + return InetAddress.getByAddress(bytes.toByteArray()) +} + +private fun isPubliclyRoutableAddress(address: InetAddress): Boolean = + !address.isAnyLocalAddress && + !address.isLoopbackAddress && + !address.isSiteLocalAddress && + !address.isLinkLocalAddress && + !address.isMulticastAddress && + !address.isUniqueLocalAddress() && + !address.isLimitedBroadcastAddress() && + !address.isSpecialPurposeAddress() + +private fun InetAddress.isUniqueLocalAddress(): Boolean { + val bytes = address + return bytes.size == 16 && (bytes[0].toInt() and 0xfe) == 0xfc +} + +private fun InetAddress.isLimitedBroadcastAddress(): Boolean = this is Inet4Address && address.all { byte -> byte.toInt() and 0xff == 0xff } + +private fun InetAddress.isSpecialPurposeAddress(): Boolean = + when (this) { + is Inet4Address -> { + val octets = address.map { it.toInt() and 0xff } + val first = octets[0] + val second = octets[1] + val third = octets[2] + first == 0 || + first == 10 || + (first == 100 && second in 64..127) || + first == 127 || + (first == 169 && second == 254) || + (first == 172 && second in 16..31) || + (first == 192 && second == 0 && (third == 0 || third == 2)) || + (first == 192 && second == 88 && third == 99) || + (first == 192 && second == 168) || + (first == 198 && second in 18..19) || + (first == 198 && second == 51 && third == 100) || + (first == 203 && second == 0 && third == 113) || + first >= 224 + } + is Inet6Address -> { + val bytes = address + val first = bytes[0].toInt() and 0xff + val globalUnicast = first and 0xe0 == 0x20 + val special2001Prefix = bytes.matchesPrefix(0x20, 0x01, 0x00) + val fourthHighNibble = bytes[3].toInt() and 0xf0 + val orchid = special2001Prefix && (fourthHighNibble == 0x10 || fourthHighNibble == 0x20) + !globalUnicast || + bytes.matchesPrefix(0x20, 0x01, 0x00, 0x00) || + bytes.matchesPrefix(0x20, 0x01, 0x00, 0x02) || + orchid || + bytes.matchesPrefix(0x20, 0x01, 0x0d, 0xb8) || + bytes.matchesPrefix(0x20, 0x02) || + (bytes.matchesPrefix(0x3f, 0xff) && (bytes[2].toInt() and 0xf0) == 0) + } + else -> true + } + +private fun ByteArray.matchesPrefix(vararg prefix: Int): Boolean = matchesAt(0, *prefix) + +private fun ByteArray.matchesAt( + offset: Int, + vararg expected: Int, +): Boolean = size >= offset + expected.size && expected.indices.all { index -> (this[offset + index].toInt() and 0xff) == expected[index] } + +internal class PublicOnlyDns( + private val delegate: Dns = Dns.SYSTEM, +) : Dns { + override fun lookup(hostname: String): List { + val addresses = delegate.lookup(hostname) + if (addresses.any { !isPubliclyRoutableAddress(it) }) { + throw UnknownHostException("$hostname resolved to a non-public address") + } + return addresses + } +} + +internal val safePublicHttpClient: OkHttpClient = + OkHttpClient + .Builder() + .followRedirects(false) + .followSslRedirects(false) + .retryOnConnectionFailure(false) + .cookieJar(CookieJar.NO_COOKIES) + .authenticator(Authenticator.NONE) + .proxyAuthenticator(Authenticator.NONE) + .proxy(Proxy.NO_PROXY) + // Pin the validated DNS answer to the connection and reject rebinding into private ranges. + .dns(PublicOnlyDns()) + .build() + +// Build the shared stores only after their public-only client; top-level initialization can +// otherwise re-enter the client field and expose a null default to preview/avatar callers. +private val safeRemoteImageFetcher = SafeRemoteImageFetcher() +internal val safeRemoteImageStore = SafeRemoteImageStore(fetcher = safeRemoteImageFetcher::fetch) + +private suspend fun Call.executeCancellable(): Response? = + suspendCancellableCoroutine { continuation -> + continuation.invokeOnCancellation { cancel() } + val response = + try { + execute() + } catch (_: IOException) { + null + } + if (response != null) { + continuation.resume(response) { _, cancelledResponse, _ -> + cancelledResponse.close() + } + } else if (continuation.isActive) { + continuation.resume(null) + } + } + +private suspend fun Call.awaitBodyRead(block: () -> T?): T? = + suspendCancellableCoroutine { continuation -> + continuation.invokeOnCancellation { cancel() } + try { + val result = block() + if (continuation.isActive) { + continuation.resume(result) + } + } catch (_: IOException) { + if (continuation.isActive) { + continuation.resume(null) + } + } + } + +private fun readBody( + body: ResponseBody, + maxBytes: Int, + rejectOversizedBody: Boolean, +): ByteArray? { + if (rejectOversizedBody && body.contentLength() > maxBytes) return null + val buffer = Buffer() + val source = body.source() + val readLimit = maxBytes.toLong() + if (rejectOversizedBody) 1L else 0L + while (buffer.size < readLimit) { + val remaining = readLimit - buffer.size + if (source.read(buffer, remaining) == -1L) break + } + if (rejectOversizedBody && buffer.size > maxBytes) return null + return buffer.readByteArray() +} diff --git a/apps/android/app/src/test/java/ai/openclaw/app/AndroidLicenseNoticesTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/AndroidLicenseNoticesTest.kt index cd9cde0aa2eb..36780dbf651e 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/AndroidLicenseNoticesTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/AndroidLicenseNoticesTest.kt @@ -38,6 +38,7 @@ class AndroidLicenseNoticesTest { listOf( "AndroidX Room", "Bouncy Castle Provider", + "Coil", "CommonMark Java", "dnsjava", "KaTeX", @@ -56,5 +57,6 @@ class AndroidLicenseNoticesTest { assertTrue(licenses.any { license -> license.text.contains("BSD 3-Clause") }) assertTrue(licenses.any { license -> license.text.contains("MIT License") }) assertTrue(licenses.any { license -> license.text.contains("Bouncy Castle Licence") }) + assertTrue(licenses.any { license -> license.title == "Coil" && license.text.contains("Coil Contributors") }) } } diff --git a/apps/android/app/src/test/java/ai/openclaw/app/GatewayAgentSummaryTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/GatewayAgentSummaryTest.kt new file mode 100644 index 000000000000..1d0557a9ec5e --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/GatewayAgentSummaryTest.kt @@ -0,0 +1,65 @@ +package ai.openclaw.app + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class GatewayAgentSummaryTest { + @Test + fun parsesAvatarAndResolvedAvatarUrlFromAgentsListRow() { + val agent = + parse( + """{"id":"main","name":" Main ","identity":{"emoji":" 🦞 ","avatar":" raw ","avatarUrl":" resolved "},"workspaceGit":true}""", + ) + + assertEquals("main", agent?.id) + assertEquals("Main", agent?.name) + assertEquals("🦞", agent?.emoji) + assertEquals("raw", agent?.avatar) + assertEquals("resolved", agent?.avatarUrl) + assertTrue(agent?.workspaceGit == true) + } + + @Test + fun normalizesMissingAndBlankIdentityValues() { + val missing = parse("""{"id":"main"}""") + val blank = + parse( + """{"id":"blank","identity":{"emoji":" ","avatar":"\n","avatarUrl":"\t"},"workspaceGit":false}""", + ) + + assertNull(missing?.name) + assertNull(missing?.avatar) + assertNull(missing?.avatarUrl) + assertFalse(missing?.workspaceGit == true) + assertNull(blank?.emoji) + assertNull(blank?.avatar) + assertNull(blank?.avatarUrl) + } + + @Test + fun ignoresMalformedIdentityShapesAndRowsWithoutIds() { + val malformedIdentity = parse("""{"id":"main","identity":["not-an-object"]}""") + val malformedAvatarFields = + parse( + """{"id":"main","identity":{"avatar":{"data":"x"},"avatarUrl":["x"]}}""", + ) + + assertNull(malformedIdentity?.avatar) + assertNull(malformedIdentity?.avatarUrl) + assertNull(malformedAvatarFields?.avatar) + assertNull(malformedAvatarFields?.avatarUrl) + assertNull(parse("""{"name":"missing id"}""")) + assertNull(parse("""{"id":" "}""")) + assertNull(parse("[]")) + } + + private fun parse(value: String): GatewayAgentSummary? = + parseGatewayAgentSummaries( + Json.parseToJsonElement("""{"agents":[$value]}""").jsonObject, + ).singleOrNull() +} diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatLinkPreviewTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatLinkPreviewTest.kt index 5a92ada8743b..f0157e7b7d53 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatLinkPreviewTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatLinkPreviewTest.kt @@ -1,5 +1,15 @@ package ai.openclaw.app.ui.chat +import ai.openclaw.app.ui.image.PublicOnlyDns +import ai.openclaw.app.ui.image.REMOTE_IMAGE_BODY_MAX_BYTES +import ai.openclaw.app.ui.image.REMOTE_IMAGE_MAX_DIMENSION +import ai.openclaw.app.ui.image.RemoteImageResult +import ai.openclaw.app.ui.image.SafeRemoteImageFetcher +import ai.openclaw.app.ui.image.SafeRemoteImageStore +import ai.openclaw.app.ui.image.SafeWebFetcher +import ai.openclaw.app.ui.image.decodeRemoteImageBitmap +import ai.openclaw.app.ui.image.isPubliclyRoutableHost +import ai.openclaw.app.ui.image.resolveRedirect import android.graphics.Bitmap import android.graphics.Color import kotlinx.coroutines.Dispatchers @@ -303,7 +313,7 @@ class ChatLinkPreviewTest { .setBody(Buffer().write(pngBytes(width = 10, height = 10))), ) - val imageFetch = async { fetcher(timeoutMillis = 60_000).fetchImage(server.url("/slow-image.png").toString()) } + val imageFetch = async { imageFetcher(timeoutMillis = 60_000).fetch(server.url("/slow-image.png").toString()) } assertTrue(withContext(Dispatchers.IO) { server.takeRequest(1, TimeUnit.SECONDS) } != null) delay(100) @@ -373,12 +383,12 @@ class ChatLinkPreviewTest { fun imageFetchStartsOnlyWhenStoreIsRequestedAndCacheHitAvoidsSecondRequest() = withServer { server -> server.enqueue(imageResponse(pngBytes(width = 120, height = 80))) - val store = LinkPreviewImageStore(fetcher = fetcher()::fetchImage) + val store = SafeRemoteImageStore(fetcher = imageFetcher()::fetch) val imageUrl = server.url("/card.png").toString() assertEquals(0, server.requestCount) - assertTrue(store.get(imageUrl) is LinkPreviewImageResult.Loaded) - assertTrue(store.get(imageUrl) is LinkPreviewImageResult.Loaded) + assertTrue(store.get(imageUrl) is RemoteImageResult.Raster) + assertTrue(store.get(imageUrl) is RemoteImageResult.Raster) assertEquals(1, server.requestCount) assertEquals("image/*", server.takeRequest().getHeader("Accept")) } @@ -390,18 +400,18 @@ class ChatLinkPreviewTest { val second = Bitmap.createBitmap(20, 20, Bitmap.Config.ARGB_8888) val fetchCounts = mutableMapOf() val store = - LinkPreviewImageStore( + SafeRemoteImageStore( fetcher = { url -> fetchCounts[url] = fetchCounts.getOrDefault(url, 0) + 1 - LinkPreviewImageResult.Loaded(if (url == "first") first else second) + RemoteImageResult.Raster(if (url == "first") first else second) }, maxBytes = first.allocationByteCount, ) try { - assertTrue(store.get("first") is LinkPreviewImageResult.Loaded) - assertTrue(store.get("second") is LinkPreviewImageResult.Loaded) - assertTrue(store.get("first") is LinkPreviewImageResult.Loaded) + assertTrue(store.get("first") is RemoteImageResult.Raster) + assertTrue(store.get("second") is RemoteImageResult.Raster) + assertTrue(store.get("first") is RemoteImageResult.Raster) assertEquals(2, fetchCounts["first"]) assertEquals(1, fetchCounts["second"]) @@ -416,18 +426,18 @@ class ChatLinkPreviewTest { runBlocking { val fetchCounts = mutableMapOf() val store = - LinkPreviewImageStore( + SafeRemoteImageStore( fetcher = { url -> fetchCounts[url] = fetchCounts.getOrDefault(url, 0) + 1 - LinkPreviewImageResult.Failed + RemoteImageResult.Failed }, maxBytes = 2, ) - assertSame(LinkPreviewImageResult.Failed, store.get("first")) - assertSame(LinkPreviewImageResult.Failed, store.get("second")) - assertSame(LinkPreviewImageResult.Failed, store.get("third")) - assertSame(LinkPreviewImageResult.Failed, store.get("first")) + assertSame(RemoteImageResult.Failed, store.get("first")) + assertSame(RemoteImageResult.Failed, store.get("second")) + assertSame(RemoteImageResult.Failed, store.get("third")) + assertSame(RemoteImageResult.Failed, store.get("first")) assertEquals(2, fetchCounts["first"]) assertEquals(1, fetchCounts["second"]) @@ -441,19 +451,19 @@ class ChatLinkPreviewTest { val maxEntries = 32 val fetchCounts = mutableMapOf() val store = - LinkPreviewImageStore( + SafeRemoteImageStore( fetcher = { url -> fetchCounts[url] = fetchCounts.getOrDefault(url, 0) + 1 - LinkPreviewImageResult.Loaded(tiny) + RemoteImageResult.Raster(tiny) }, maxBytes = tiny.allocationByteCount * maxEntries * 2, ) try { repeat(maxEntries + 1) { index -> - assertTrue(store.get("image-$index") is LinkPreviewImageResult.Loaded) + assertTrue(store.get("image-$index") is RemoteImageResult.Raster) } - assertTrue(store.get("image-0") is LinkPreviewImageResult.Loaded) + assertTrue(store.get("image-0") is RemoteImageResult.Raster) assertEquals(2, fetchCounts["image-0"]) } finally { @@ -470,13 +480,13 @@ class ChatLinkPreviewTest { server.enqueue( MockResponse() .setHeader("Content-Type", "image/png") - .setBody(Buffer().write(ByteArray(LINK_PREVIEW_IMAGE_BODY_MAX_BYTES + 1))), + .setBody(Buffer().write(ByteArray(REMOTE_IMAGE_BODY_MAX_BYTES + 1))), ) - assertSame(LinkPreviewImageResult.Failed, fetcher().fetchImage(server.url("/animated.gif").toString())) - assertSame(LinkPreviewImageResult.Failed, fetcher().fetchImage(server.url("/vector.svg").toString())) - assertSame(LinkPreviewImageResult.Failed, fetcher().fetchImage(server.url("/spoofed.png").toString())) - assertSame(LinkPreviewImageResult.Failed, fetcher().fetchImage(server.url("/oversized.png").toString())) + assertSame(RemoteImageResult.Failed, imageFetcher().fetch(server.url("/animated.gif").toString())) + assertTrue(imageFetcher().fetch(server.url("/vector.svg").toString()) is RemoteImageResult.Svg) + assertSame(RemoteImageResult.Failed, imageFetcher().fetch(server.url("/spoofed.png").toString())) + assertSame(RemoteImageResult.Failed, imageFetcher().fetch(server.url("/oversized.png").toString())) assertEquals(4, server.requestCount) } @@ -485,7 +495,7 @@ class ChatLinkPreviewTest { withServer { server -> server.enqueue(imageResponse(pngBytes(width = 10, height = 10))) - assertSame(LinkPreviewImageResult.Failed, realPolicyFetcher().fetchImage(server.url("/private.png").toString())) + assertSame(RemoteImageResult.Failed, realPolicyImageFetcher().fetch(server.url("/private.png").toString())) assertEquals(0, server.requestCount) } @@ -495,7 +505,7 @@ class ChatLinkPreviewTest { repeat(3) { index -> server.enqueue(redirect("/image-hop${index + 1}")) } server.enqueue(imageResponse(pngBytes(width = 12, height = 8))) - assertTrue(fetcher().fetchImage(server.url("/image-start").toString()) is LinkPreviewImageResult.Loaded) + assertTrue(imageFetcher().fetch(server.url("/image-start").toString()) is RemoteImageResult.Raster) assertEquals(4, server.requestCount) } @@ -503,36 +513,36 @@ class ChatLinkPreviewTest { repeat(4) { index -> server.enqueue(redirect("/image-hop${index + 1}")) } server.enqueue(imageResponse(pngBytes(width = 12, height = 8))) - assertSame(LinkPreviewImageResult.Failed, fetcher().fetchImage(server.url("/image-start").toString())) + assertSame(RemoteImageResult.Failed, imageFetcher().fetch(server.url("/image-start").toString())) assertEquals(4, server.requestCount) } withServer { server -> server.enqueue(redirect("file:///tmp/private.png")) - assertSame(LinkPreviewImageResult.Failed, fetcher().fetchImage(server.url("/image-start").toString())) + assertSame(RemoteImageResult.Failed, imageFetcher().fetch(server.url("/image-start").toString())) assertEquals(1, server.requestCount) } } @Test fun imageDecodeDownsamplesLargeSource() { - val decoded = decodeLinkPreviewBitmap(pngBytes(width = 2_400, height = 1_200)) + val decoded = decodeRemoteImageBitmap(pngBytes(width = 2_400, height = 1_200)) assertTrue(decoded != null) - assertTrue(checkNotNull(decoded).width <= LINK_PREVIEW_IMAGE_MAX_DIMENSION) - assertTrue(decoded.height <= LINK_PREVIEW_IMAGE_MAX_DIMENSION) + assertTrue(checkNotNull(decoded).width <= REMOTE_IMAGE_MAX_DIMENSION) + assertTrue(decoded.height <= REMOTE_IMAGE_MAX_DIMENSION) } @Test fun corruptImageIsNegativeCachedWithoutRefetch() = withServer { server -> server.enqueue(MockResponse().setHeader("Content-Type", "image/webp").setBody("not an image")) - val store = LinkPreviewImageStore(fetcher = fetcher()::fetchImage) + val store = SafeRemoteImageStore(fetcher = imageFetcher()::fetch) val imageUrl = server.url("/corrupt.webp").toString() - assertSame(LinkPreviewImageResult.Failed, store.get(imageUrl)) - assertSame(LinkPreviewImageResult.Failed, store.get(imageUrl)) + assertSame(RemoteImageResult.Failed, store.get(imageUrl)) + assertSame(RemoteImageResult.Failed, store.get(imageUrl)) assertEquals(1, server.requestCount) } @@ -540,6 +550,10 @@ class ChatLinkPreviewTest { private fun realPolicyFetcher(): LinkPreviewFetcher = LinkPreviewFetcher(baseClient().build()) + private fun imageFetcher(timeoutMillis: Long = 6_000): SafeRemoteImageFetcher = SafeRemoteImageFetcher(SafeWebFetcher(baseClient().build(), timeoutMillis, permissiveHostPolicy)) + + private fun realPolicyImageFetcher(): SafeRemoteImageFetcher = SafeRemoteImageFetcher(SafeWebFetcher(baseClient().build())) + private fun baseClient(): OkHttpClient.Builder = OkHttpClient .Builder() diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/design/AgentAvatarTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/design/AgentAvatarTest.kt new file mode 100644 index 000000000000..cb25debb68fd --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/design/AgentAvatarTest.kt @@ -0,0 +1,82 @@ +package ai.openclaw.app.ui.design + +import ai.openclaw.app.GatewayAgentSummary +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class AgentAvatarTest { + @Test + fun prefersResolvedAvatarUrl() { + val agent = agent(avatar = dataUrl("image/png", "raw"), avatarUrl = dataUrl("image/jpeg", "resolved")) + + assertEquals( + AgentAvatarSource.Data(mimeType = "image/jpeg", base64 = "resolved"), + agentAvatarSource(agent), + ) + } + + @Test + fun fallsBackToRawAvatarOnlyWhenResolvedAvatarIsMissing() { + val raw = AgentAvatarSource.Data(mimeType = "image/png", base64 = "raw") + + assertEquals(raw, agentAvatarSource(agent(avatar = dataUrl("image/png", "raw")))) + assertEquals(raw, agentAvatarSource(agent(avatar = dataUrl("image/png", "raw"), avatarUrl = " "))) + assertNull(agentAvatarSource(agent(avatar = dataUrl("image/png", "raw"), avatarUrl = "not an image"))) + } + + @Test + fun preservesRasterAndSvgMimeTypes() { + assertEquals( + AgentAvatarSource.Data(mimeType = "image/png", base64 = "body"), + agentAvatarSource(agent(avatarUrl = "DATA:IMAGE/PNG;BASE64, body ")), + ) + assertEquals( + AgentAvatarSource.Data(mimeType = "image/svg+xml", base64 = "PHN2Zy8+"), + agentAvatarSource(agent(avatarUrl = dataUrl("image/svg+xml", "PHN2Zy8+"))), + ) + } + + @Test + fun recognizesRemoteHttpSources() { + assertEquals( + AgentAvatarSource.Remote("https://example.com/avatar.png"), + agentAvatarSource(agent(avatarUrl = "https://example.com/avatar.png")), + ) + assertEquals( + AgentAvatarSource.Remote("HTTP://example.com/avatar.svg"), + agentAvatarSource(agent(avatarUrl = "HTTP://example.com/avatar.svg")), + ) + } + + @Test + fun rejectsMalformedOrUnsupportedAvatarValues() { + assertNull(agentAvatarSource(agent(avatarUrl = "data:image/png,raw"))) + assertNull(agentAvatarSource(agent(avatarUrl = "data:text/plain;base64,dGV4dA=="))) + assertNull(agentAvatarSource(agent(avatarUrl = "data:image/png;base64,"))) + assertNull(agentAvatarSource(agent(avatar = "avatars/openclaw.png"))) + assertNull(agentAvatarSource(agent(avatar = "🦞"))) + } + + @Test + fun rejectsMissingAvatarValues() { + assertNull(agentAvatarSource(agent())) + assertNull(agentAvatarSource(agent(avatar = " ", avatarUrl = "\n"))) + } + + private fun agent( + avatar: String? = null, + avatarUrl: String? = null, + ) = GatewayAgentSummary( + id = "main", + name = "Main", + emoji = null, + avatar = avatar, + avatarUrl = avatarUrl, + ) + + private fun dataUrl( + mimeType: String, + body: String, + ): String = "data:$mimeType;base64,$body" +} diff --git a/apps/android/gradle/libs.versions.toml b/apps/android/gradle/libs.versions.toml index 93a16eec80f4..37856ce92355 100644 --- a/apps/android/gradle/libs.versions.toml +++ b/apps/android/gradle/libs.versions.toml @@ -15,6 +15,7 @@ androidx-uiautomator = "2.4.0" androidx-webkit = "1.15.0" bcprov = "1.84" commonmark = "0.29.0" +coil = "3.5.0" coroutines = "1.11.0" dnsjava = "3.6.5" junit = "4.13.2" @@ -62,6 +63,8 @@ commonmark-ext-autolink = { module = "org.commonmark:commonmark-ext-autolink", v commonmark-ext-gfm-strikethrough = { module = "org.commonmark:commonmark-ext-gfm-strikethrough", version.ref = "commonmark" } commonmark-ext-gfm-tables = { module = "org.commonmark:commonmark-ext-gfm-tables", version.ref = "commonmark" } commonmark-ext-task-list-items = { module = "org.commonmark:commonmark-ext-task-list-items", version.ref = "commonmark" } +coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } +coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil" } dnsjava = { module = "dnsjava:dnsjava", version.ref = "dnsjava" } junit = { module = "junit:junit", version.ref = "junit" } junit-vintage-engine = { module = "org.junit.vintage:junit-vintage-engine", version.ref = "junit-vintage" }