From 4a39ca6539cc5cb7555ec5652be106faf93fd1e7 Mon Sep 17 00:00:00 2001 From: Ivan Kuznetsov Date: Sun, 20 Sep 2026 16:13:46 +0100 Subject: [PATCH] feat(android): simplify chat input and unify attachment selection (#143099) Unify the Android chat composer and attachment picker while preserving conversation-bound drafts, media ownership and location precision. Retain selection and page navigation, explicit voice controls, updated Android guidance and regression coverage for the corrected boundaries. Closes #143044. Co-authored-by: Ivan Kuznetsov Co-authored-by: IWhatsskill <284122573+IWhatsskill@users.noreply.github.com> --- apps/.i18n/native-source.json | 23 +- apps/android/README.md | 16 +- .../openclaw/app/node/LocationDisclosure.kt | 40 ++ .../ai/openclaw/app/node/LocationHandler.kt | 23 +- .../app/ui/chat/ChatAttachmentSheet.kt | 237 ++++++++++ .../ai/openclaw/app/ui/chat/ChatDictation.kt | 20 +- .../ai/openclaw/app/ui/chat/ChatImageCodec.kt | 2 +- .../ai/openclaw/app/ui/chat/ChatScreen.kt | 431 ++++++++++------- .../app/ui/chat/ChatComposerLayoutTest.kt | 434 +++++++++++++++--- .../app/ui/chat/ChatEffortPositionTest.kt | 9 +- .../app/ui/chat/ChatImageCodecTest.kt | 4 +- .../ai/openclaw/app/ui/chat/ChatScreenTest.kt | 2 +- .../app/ui/chat/LocationAttachmentTest.kt | 150 ++++++ docs/platforms/android.md | 11 +- .../register.backup.product-path.test.ts | 48 +- ...ate-managed-service-triage.test-support.ts | 25 +- .../update-managed-service-triage.test.ts | 29 +- .../worker-deploy-build-plugin.test.ts | 13 +- 18 files changed, 1220 insertions(+), 297 deletions(-) create mode 100644 apps/android/app/src/main/java/ai/openclaw/app/node/LocationDisclosure.kt create mode 100644 apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatAttachmentSheet.kt create mode 100644 apps/android/app/src/test/java/ai/openclaw/app/ui/chat/LocationAttachmentTest.kt diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 5ab29b5ba9cd..76d9d636bc84 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -139,6 +139,7 @@ {"id":"native.android.d116cefbb5d344ab","source":"Add attachment","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"}]}, {"id":"native.android.f2a6acfa3cdc099b","source":"Add one wake word or phrase per field. Then say one before your command.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"}]}, {"id":"native.android.6ee85e83929b808f","source":"Add wake phrase","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"}]}, + {"id":"native.android.6c1dbafd33c7b8e9","source":"Add your current location to the draft. Review it before sending.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatAttachmentSheet.kt"}]}, {"id":"native.android.45ef19160aa90e3f","source":"Additional images hidden: ${omittedImageCount}","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"}]}, {"id":"native.android.26eeb49a3ab31d30","source":"Address","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"}]}, {"id":"native.android.7271a2beeb0f5d46","source":"Admin","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/NodesDevicesSettingsScreen.kt"}]}, @@ -312,7 +313,7 @@ {"id":"native.android.0a774596d8810f70","source":"Choose and inspect the assistants available on this gateway.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"}]}, {"id":"native.android.8fa35b45a2badc3f","source":"Choose another image","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt"}]}, {"id":"native.android.8446b9497ac41a24","source":"Choose device permissions","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt"}]}, - {"id":"native.android.b4aa1d6dd4f70982","source":"Choose from gallery","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayAdditionDialog.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt"}]}, + {"id":"native.android.b4aa1d6dd4f70982","source":"Choose from gallery","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayAdditionDialog.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatAttachmentSheet.kt"}]}, {"id":"native.android.0c642036b88b0c1c","source":"Choose what reaches OpenClaw.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"}]}, {"id":"native.android.efe2ac7561682d4d","source":"Choose what this phone can share.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"}]}, {"id":"native.android.3c87c390a52ad030","source":"ClawHub did not return an installable version for ${skill.reference}.","surface":"android","sites":[{"kind":"conditional-branch","path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt"}]}, @@ -404,7 +405,10 @@ {"id":"native.android.6179bf99c7c727da","source":"Consecutive Errors","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"}]}, {"id":"native.android.6ee64e9492085d54","source":"Consecutive Skips","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"}]}, {"id":"native.android.2654611c5a5a572c","source":"Contacts","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt"}]}, + {"id":"native.android.6f72735e3fd76d72","source":"Context","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"}]}, + {"id":"native.android.fc78f76570e54463","source":"Context $percent","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"}]}, {"id":"native.android.0c6684020b20cf89","source":"Context window","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"}]}, + {"id":"native.android.d7ca7ec0ab428568","source":"Context –","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"}]}, {"id":"native.android.990da7a25dabb002","source":"Context: $detail","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"}]}, {"id":"native.android.d6d703d18a7af3cf","source":"Continue","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/PermissionRequester.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayAdditionDialog.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/ProviderSignInDialog.kt"}]}, {"id":"native.android.f87c89d944f56c38","source":"Control other apps","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/thirdParty/java/ai/openclaw/app/ui/SensitivePhoneCapabilitiesSettings.kt"}]}, @@ -427,6 +431,7 @@ {"id":"native.android.e74d1437a5e050af","source":"Could not copy widget image","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatInlineWidgetView.kt"}]}, {"id":"native.android.0a354500882ec0d6","source":"Could not disable skill.","surface":"android","sites":[{"kind":"conditional-branch","path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt"}]}, {"id":"native.android.f79aec3ad54a77c4","source":"Could not enable skill.","surface":"android","sites":[{"kind":"conditional-branch","path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt"}]}, + {"id":"native.android.c93bb38744c9ad16","source":"Could not get your location. Check device location settings and try again.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatAttachmentSheet.kt"}]}, {"id":"native.android.b23629d72bfebc10","source":"Could not inspect Skill Workshop proposal.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt"}]}, {"id":"native.android.d37c22282a1c0b5c","source":"Could not install ${slug} from ClawHub.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt"}]}, {"id":"native.android.cee7ae53f86ff1c8","source":"Could not load ClawHub details for ${skill.reference}.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt"}]}, @@ -539,7 +544,7 @@ {"id":"native.android.a6e7139eff76416a","source":"Diagram temporarily unavailable. Open Diagram options and choose Retry diagram. You can still read or copy its source.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMermaidBlock.kt"}]}, {"id":"native.android.d2ac215d97d143cf","source":"Diagram unavailable. Check the syntax or simplify the diagram. You can still read or copy its source.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMermaidBlock.kt"}]}, {"id":"native.android.d7f735c1c846153f","source":"Dictate","surface":"android","sites":[{"kind":"resource-string","path":"apps/android/wear/src/main/res/values/strings.xml"}]}, - {"id":"native.android.5762cf2e05f1b649","source":"Dictation","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/GatewayTalkSetupReadiness.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatDictation.kt"},{"kind":"ui-named-argument","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawComponents.kt"}]}, + {"id":"native.android.5762cf2e05f1b649","source":"Dictation","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/GatewayTalkSetupReadiness.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatDictation.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"},{"kind":"ui-named-argument","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawComponents.kt"}]}, {"id":"native.android.20dc6eb26a60b243","source":"Dictation failed. Tap the microphone to try again.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatDictation.kt"}]}, {"id":"native.android.1bbbdb0fb0e3a5a3","source":"Disable","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt"}]}, {"id":"native.android.08b941c014e915d8","source":"Disabled","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt"}]}, @@ -608,7 +613,8 @@ {"id":"native.android.fccd9ea812fcb9a7","source":"Fast mode","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"}]}, {"id":"native.android.fb4539c805474a4a","source":"Faster","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"}]}, {"id":"native.android.f74ff12138dba28c","source":"Faster responses, higher usage of limits.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"}]}, - {"id":"native.android.a39fdbef34bb94f7","source":"Files","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/WorkspaceFilesScreen.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"}]}, + {"id":"native.android.d931ca172950d3af","source":"File","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatAttachmentSheet.kt"}]}, + {"id":"native.android.a39fdbef34bb94f7","source":"Files","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/WorkspaceFilesScreen.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatAttachmentSheet.kt"}]}, {"id":"native.android.76e464cbc35b12dc","source":"Files unavailable","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/WorkspaceFilesScreen.kt"}]}, {"id":"native.android.df6908aa0d65572b","source":"Find a model","surface":"android","sites":[{"kind":"resource-string","path":"apps/android/wear/src/main/res/values/strings.xml"}]}, {"id":"native.android.98780014fd21c179","source":"Find a session","surface":"android","sites":[{"kind":"resource-string","path":"apps/android/wear/src/main/res/values/strings.xml"}]}, @@ -636,6 +642,7 @@ {"id":"native.android.1254c17b35dde7ec","source":"Full access","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"}]}, {"id":"native.android.54d03602f88f7164","source":"Full access requires operator.admin access.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"}]}, {"id":"native.android.d5e39f78304400ca","source":"Full text is no longer available.","surface":"android","sites":[{"kind":"resource-string","path":"apps/android/wear/src/main/res/values/strings.xml"}]}, + {"id":"native.android.06a4b6cd363a8cf6","source":"Gallery","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatAttachmentSheet.kt"}]}, {"id":"native.android.44f6b5c185ccdd8a","source":"Gateway","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/HealthLogsSettingsScreen.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsRoute.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt"},{"kind":"resource-string","path":"apps/android/wear/src/main/res/values/strings.xml"}]}, {"id":"native.android.74392ebabe49e1f4","source":"Gateway Pending","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"}]}, {"id":"native.android.3eed634369e3fad1","source":"Gateway Required","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SystemAgentSettingsScreen.kt"}]}, @@ -686,6 +693,7 @@ {"id":"native.android.514ae6a832c165e5","source":"Gateways","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SidebarGatewayControl.kt"}]}, {"id":"native.android.a14a2fa261e5da6d","source":"Generate a QR code.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt"}]}, {"id":"native.android.07b388c4170d3d9a","source":"Generating voice…","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt"}]}, + {"id":"native.android.4374b928762021ee","source":"Getting location…","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatAttachmentSheet.kt"}]}, {"id":"native.android.e9a44c20e8be81a6","source":"Git commit %1$s","surface":"android","sites":[{"kind":"resource-string","path":"apps/android/app/src/main/res/values/strings.xml"}]}, {"id":"native.android.d290ea075b936750","source":"Git commit copied","surface":"android","sites":[{"kind":"resource-string","path":"apps/android/app/src/main/res/values/strings.xml"}]}, {"id":"native.android.f626602bddb5e2d8","source":"Go back","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt"}]}, @@ -794,7 +802,8 @@ {"id":"native.android.f72c3674b897b26b","source":"Loading thread","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"}]}, {"id":"native.android.39d0f39e7577de93","source":"Lobstering","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatWorkingIndicator.kt"}]}, {"id":"native.android.50d7b864e3049c14","source":"Local command center","surface":"android","sites":[{"kind":"ui-named-argument","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawComponents.kt"}]}, - {"id":"native.android.26ead13667e80461","source":"Location","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"}]}, + {"id":"native.android.26ead13667e80461","source":"Location","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatAttachmentSheet.kt"}]}, + {"id":"native.android.bc2e4b23ec27a152","source":"Location permission is required. Allow it in Android settings or try again.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatAttachmentSheet.kt"}]}, {"id":"native.android.969edab95fef7601","source":"Locked","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt"}]}, {"id":"native.android.4a16f82863c7c846","source":"Locked session model","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"}]}, {"id":"native.android.41683a66dca7c816","source":"Log Entry","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/HealthLogsSettingsScreen.kt"}]}, @@ -1114,7 +1123,7 @@ {"id":"native.android.d5d4090ede4afee1","source":"Phone ready","surface":"android","sites":[{"kind":"resource-string","path":"apps/android/wear/src/main/res/values/strings.xml"}]}, {"id":"native.android.fbd5c43d1da211e8","source":"Phone unavailable. Tap Reply to try again.","surface":"android","sites":[{"kind":"resource-string","path":"apps/android/wear/src/main/res/values/strings.xml"}]}, {"id":"native.android.053f7882b3d40f54","source":"Phone-controlled","surface":"android","sites":[{"kind":"resource-string","path":"apps/android/wear/src/main/res/values/strings.xml"}]}, - {"id":"native.android.1fb9e44a74c5b89c","source":"Photos","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/PermissionRequester.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"}]}, + {"id":"native.android.1fb9e44a74c5b89c","source":"Photos","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/PermissionRequester.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"}]}, {"id":"native.android.d9963d6e679fad17","source":"Pin","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt"}]}, {"id":"native.android.c6a60daf61c82d06","source":"Pin model","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"}]}, {"id":"native.android.3f461a5a2955e4cb","source":"Pinching","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatWorkingIndicator.kt"}]}, @@ -1673,6 +1682,7 @@ {"id":"native.android.6852e1c8b2caa7a7","source":"Use Refresh chat to check Gateway health.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"}]}, {"id":"native.android.5b1e5d258bb4cfac","source":"Use a private LAN IP for local setup, or enable Tailscale Serve / expose a wss:// gateway URL for remote access.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt"}]}, {"id":"native.android.ac59b3725ddbc6df","source":"Use a secure wss:// or Tailscale Serve Gateway, generate a full-access setup code in the Control UI or with openclaw qr, then scan or paste it below and reconnect to enable settings and upgrades.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"}]}, + {"id":"native.android.b0e4b3b47eea5d1a","source":"Use current location","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatAttachmentSheet.kt"}]}, {"id":"native.android.bb84dc61d88cb8ad","source":"Use only on a trusted private network.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt"}]}, {"id":"native.android.36237c9a6f58c8fd","source":"Use setup code","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt"}]}, {"id":"native.android.d9842759169543c8","source":"Use system trust","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayTrustDialog.kt"}]}, @@ -1690,7 +1700,7 @@ {"id":"native.android.4726d7b86dae14c2","source":"Version %1$s","surface":"android","sites":[{"kind":"resource-string","path":"apps/android/app/src/main/res/values/strings.xml"}]}, {"id":"native.android.950f4d664cfa14ce","source":"Version and update","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt"}]}, {"id":"native.android.3e463214b65f9187","source":"Video","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMediaPlayer.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"}]}, - {"id":"native.android.6baba6d73b10b7dd","source":"Videos","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"}]}, + {"id":"native.android.6baba6d73b10b7dd","source":"Videos","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatAttachmentSheet.kt"}]}, {"id":"native.android.c81431ac22026d3c","source":"View","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt"}]}, {"id":"native.android.422eebe3d68182c2","source":"View a machine screen","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt"}]}, {"id":"native.android.a7563fca20d2df98","source":"View all","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageDisclosure.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"}]}, @@ -1701,6 +1711,7 @@ {"id":"native.android.d01c87fe40aa4a44","source":"Voice Wake","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"}]}, {"id":"native.android.5d7610fd72c5cf1b","source":"Voice note","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMediaPlayer.kt"}]}, {"id":"native.android.21399aeaaf3f5db1","source":"Voice note · ${formatVoiceNoteDuration(duration)}","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"}]}, + {"id":"native.android.4e6ec17cd2dd01fc","source":"Voice options","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatDictation.kt"}]}, {"id":"native.android.de8a2316f9635d74","source":"Voice reply timed out; retrying queued turn","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/voice/MicCaptureManager.kt"}]}, {"id":"native.android.b722b22c96e92d3c","source":"Voice request failed","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/voice/MicCaptureManager.kt"}]}, {"id":"native.android.5e79bb0182a8ee02","source":"Wait for the current response to finish before starting a new chat.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt"}]}, diff --git a/apps/android/README.md b/apps/android/README.md index 8bace878031e..46e01df14fb5 100644 --- a/apps/android/README.md +++ b/apps/android/README.md @@ -5,7 +5,7 @@ OpenClaw Android is the officially released Google Play app. It connects to an O ### App features - Pair with a Gateway using a QR code, setup code, or manual connection. Gateway credentials are stored encrypted. -- Stream chat replies, choose models and reasoning effort, manage session permissions, and expand task progress. The compact composer keeps one control row; tap the model name for permissions and usage details, or the effort dial for Fast mode. Dictation, voice messages, and Talk are part of Chat, not a separate Voice tab. +- Stream chat replies, choose models and reasoning effort, manage session permissions, and expand task progress. The composer keeps attachments to the left of the draft and voice input to the right, with model, effort, and context controls below. Tap the microphone for dictation. While listening, a Stop icon replaces the microphone; tap it to finish listening. While starting or transcribing, a Close icon cancels that attempt. Long-press for voice messages or Talk. Tap the model name for permissions and usage details, or the effort dial for Fast mode. - Select agents, pin sessions, and browse available native session catalogs from the sidebar. Connecting creates or adopts a dedicated Android session without resetting its history. Native sessions keep their runtime-owned model: Android shows that ownership instead of offering a model change. New session starts independently of the current native thread. Generic child-session forks and new worktrees are unavailable for those sessions; supported message-level forks remain available. - Search from Overview or Settings to find settings by their displayed name or category, alongside quick actions and recent threads. Local destinations such as Appearance, Profile, and Licenses work without connecting a Gateway. Back from a settings detail returns to the screen that opened search; Desktop appears only when the connected Gateway supports it. - Choose a theme family, color mode, accent, and app language in **Settings → Appearance**. Theme and accent edits sync with a connected writable profile. Read-only or unknown-profile edits, including new edits after restarting offline, stay on the device; choose them again after connecting to sync. Already profile-bound edits wait for that profile to reconnect, without discarding or replacing newer device-local choices. @@ -120,14 +120,24 @@ space is limited; opening the keyboard does not move them to another region. If the keyboard covers that region entirely, dismiss the keyboard to reach the prompt again. -Chat actions and Add attachment (Photos, Videos, Files) menus stay in the safe +Chat actions and voice options menus stay in the safe region containing their trigger. These popups remain focusable without becoming keyboard (IME) targets. If folds, insets, or layout changes invalidate an open menu, it closes without choosing an action. Reopen it explicitly when space permits; it does not reopen automatically when the layout recovers. Dismissing the menu does not reset Chat's draft, editor, or reader state. -Chat's Model picker, its Permissions page, Thinking effort, Background tasks, and Switch branch sheets initially +Chat's attachment picker opens on Gallery, with File and Location tabs below. +Gallery opens Android's system photo picker without whole-library permission. +The embedded preview is not used: its Done callback can precede pending URI grants +and revocations, so it cannot supply a reliable final selection. File also provides +video selection. Location requests foreground permission and adds a map link to +the draft for review before sending. It respects the app's Precise Location toggle, +including changes made while capture is pending, using the same approximate grid +as node location responses. Closing a picker preserves the draft; switching +conversations retires the opening. + +Chat's attachment picker, Model picker, its Permissions page, Thinking effort, Background tasks, and Switch branch sheets initially use the largest safe region with usable sheet space, not the trigger's region. They keep that region while it remains usable. Valid geometry changes retain the same sheet and local state. An invalid opening closes without selecting an diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/LocationDisclosure.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/LocationDisclosure.kt new file mode 100644 index 000000000000..2589f3fd7eb9 --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/LocationDisclosure.kt @@ -0,0 +1,40 @@ +package ai.openclaw.app.node + +import android.location.Location +import android.location.LocationManager + +/** Shared disclosure boundary for node responses and user-requested chat attachments. */ +internal class LocationDisclosure( + private val preciseEnabled: () -> Boolean, + private val hasFinePermission: () -> Boolean, + private val capture: suspend (List, Long?, Long) -> Location, +) { + data class Fix( + val location: Location, + val isPrecise: Boolean, + ) + + suspend fun getLocation( + maxAgeMs: Long?, + timeoutMs: Long, + allowPrecise: Boolean = true, + ): Fix { + val initiallyPrecise = allowPrecise && preciseEnabled() && hasFinePermission() + val providers = + if (initiallyPrecise) { + listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER) + } else { + listOf(LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER) + } + val fix = capture(providers, maxAgeMs, timeoutMs) + // Recheck after suspension, and never upgrade an operation that began approximate. + val isPrecise = initiallyPrecise && preciseEnabled() && hasFinePermission() + return Fix(if (isPrecise) fix else coarsener.coarsen(fix), isPrecise) + } + + private companion object { + // One process-wide grid across every disclosure surface and reopening. Fresh random + // offsets per request or per sheet could be averaged to recover a precise position. + val coarsener by lazy { LocationCoarsener() } + } +} diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/LocationHandler.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/LocationHandler.kt index 93796a0e0ea2..f08b8ac231b2 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/LocationHandler.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/LocationHandler.kt @@ -6,7 +6,6 @@ import android.Manifest import android.content.Context import android.content.pm.PackageManager import android.location.Location -import android.location.LocationManager import androidx.core.content.ContextCompat import kotlinx.coroutines.CancellationException import kotlinx.coroutines.TimeoutCancellationException @@ -70,7 +69,12 @@ class LocationHandler private constructor( private val backgroundLocationEnabled: () -> Boolean, private val locationPreciseEnabled: () -> Boolean, ) { - private val coarsener by lazy { LocationCoarsener() } + private val disclosure = + LocationDisclosure( + preciseEnabled = locationPreciseEnabled, + hasFinePermission = { dataSource.hasFinePermission(appContext) }, + capture = dataSource::fetchLocation, + ) constructor( appContext: Context, @@ -135,21 +139,8 @@ class LocationHandler private constructor( ) } val (maxAgeMs, timeoutMs, desiredAccuracy) = parseLocationParams(paramsJson) - // A request may ask for less precision, but cannot override the user's limits. - val initiallyPrecise = - desiredAccuracy != "coarse" && locationPreciseEnabled() && dataSource.hasFinePermission(appContext) - val providers = - if (initiallyPrecise) { - listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER) - } else { - listOf(LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER) - } try { - val fix = dataSource.fetchLocation(providers, maxAgeMs, timeoutMs) - // Capture can suspend and switch dispatchers. Recheck at the response producer, - // without another suspension, and never upgrade a request that began approximate. - val isPrecise = initiallyPrecise && locationPreciseEnabled() && dataSource.hasFinePermission(appContext) - val location = if (isPrecise) fix else coarsener.coarsen(fix) + val (location, isPrecise) = disclosure.getLocation(maxAgeMs, timeoutMs, allowPrecise = desiredAccuracy != "coarse") val payload = buildJsonObject { put("lat", location.latitude) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatAttachmentSheet.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatAttachmentSheet.kt new file mode 100644 index 000000000000..07c61d5bd203 --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatAttachmentSheet.kt @@ -0,0 +1,237 @@ +package ai.openclaw.app.ui.chat + +import ai.openclaw.app.NodeApp +import ai.openclaw.app.i18n.nativeString +import ai.openclaw.app.node.LocationCaptureManager +import ai.openclaw.app.node.LocationDisclosure +import ai.openclaw.app.ui.AppModalBottomSheet +import ai.openclaw.app.ui.design.ClawTheme +import ai.openclaw.app.ui.foldAwareSheet +import android.Manifest +import android.content.pm.PackageManager +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material.icons.filled.Photo +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ModalBottomSheetProperties +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.launch +import java.util.Locale + +private enum class AttachmentTab { Gallery, File, Location } + +/** One owner-bound opening; external results still pass through the composer's media leases. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ChatAttachmentSheet( + opening: ChatModelPickerSession, + admit: () -> Boolean, + onDismiss: () -> Unit, + onBrowseGallery: () -> Unit, + onPickFile: () -> Unit, + onPickVideo: () -> Unit, + onLocation: (String) -> Unit, +) { + var tab by remember { mutableStateOf(AttachmentTab.Gallery) } + AppModalBottomSheet( + modifier = Modifier.foldAwareSheet(opening.geometry), + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = ClawTheme.colors.surface, + contentColor = ClawTheme.colors.text, + properties = ModalBottomSheetProperties(shouldDismissOnBackPress = false), + ) { + BackHandler { onDismiss() } + Column(Modifier.fillMaxWidth().heightIn(max = 560.dp)) { + Text( + text = + when (tab) { + AttachmentTab.Gallery -> nativeString("Gallery") + AttachmentTab.File -> nativeString("File") + AttachmentTab.Location -> nativeString("Location") + }, + style = ClawTheme.type.label, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp), + ) + Box(Modifier.fillMaxWidth().weight(1f).heightIn(min = 96.dp)) { + when (tab) { + AttachmentTab.Gallery -> { + // Embedded completion is not a settled URI selection; the activity result is. + AttachmentActions { + Button(onClick = { if (admit()) onBrowseGallery() }) { Text(nativeString("Choose from gallery")) } + } + } + + AttachmentTab.File -> { + AttachmentActions { + Button(onClick = { if (admit()) onPickFile() }) { Text(nativeString("Files")) } + TextButton(onClick = { if (admit()) onPickVideo() }) { Text(nativeString("Videos")) } + } + } + + AttachmentTab.Location -> { + LocationAttachment(admit = admit, onLocation = onLocation) + } + } + } + Row(Modifier.fillMaxWidth().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + AttachmentTab.entries.forEach { item -> + Surface( + onClick = { if (admit()) tab = item }, + modifier = + Modifier.weight(1f).semantics { + selected = tab == item + role = Role.Tab + }, + shape = RoundedCornerShape(20.dp), + color = if (tab == item) ClawTheme.colors.primary else Color.Transparent, + contentColor = if (tab == item) ClawTheme.colors.primaryText else ClawTheme.colors.text, + ) { + Column(Modifier.padding(12.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + when (item) { + AttachmentTab.Gallery -> Icons.Default.Photo + AttachmentTab.File -> Icons.Default.Description + AttachmentTab.Location -> Icons.Default.LocationOn + }, + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Text( + when (item) { + AttachmentTab.Gallery -> nativeString("Gallery") + AttachmentTab.File -> nativeString("File") + AttachmentTab.Location -> nativeString("Location") + }, + style = ClawTheme.type.caption, + ) + } + } + } + } + } + } +} + +@Composable +private fun AttachmentActions(content: @Composable () -> Unit) { + Column( + Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { content() } +} + +@Composable +internal fun LocationAttachment( + admit: () -> Boolean, + onLocation: (String) -> Unit, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val app = context.applicationContext as NodeApp + val disclosure = + remember(app) { + LocationDisclosure( + preciseEnabled = { app.prefs.locationPreciseEnabled.value }, + hasFinePermission = { + ContextCompat.checkSelfPermission(app, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED + }, + capture = LocationCaptureManager(app)::getLocation, + ) + } + var busy by remember { mutableStateOf(false) } + var failed by remember { mutableStateOf(false) } + var permissionDenied by remember { mutableStateOf(false) } + + fun capture() { + if (busy || !admit()) return + busy = true + failed = false + permissionDenied = false + scope.launch { + try { + val location = + disclosure + .getLocation( + maxAgeMs = 60_000, + timeoutMs = 15_000, + ).location + if (admit()) { + onLocation(String.format(Locale.ROOT, "https://www.google.com/maps?q=%.6f,%.6f", location.latitude, location.longitude)) + } + } catch (_: TimeoutCancellationException) { + currentCoroutineContext().ensureActive() + failed = true + } catch (error: CancellationException) { + throw error + } catch (_: Exception) { + failed = true + } finally { + busy = false + } + } + } + val permission = + rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result -> + if (result.values.any { it }) capture() else permissionDenied = true + } + AttachmentActions { + Text(nativeString("Add your current location to the draft. Review it before sending.")) + if (failed) Text(nativeString("Could not get your location. Check device location settings and try again."), color = ClawTheme.colors.warning) + if (permissionDenied) Text(nativeString("Location permission is required. Allow it in Android settings or try again."), color = ClawTheme.colors.warning) + Button( + enabled = !busy, + onClick = { + if (admit()) { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED) { + capture() + } else { + permission.launch(arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION)) + } + } + }, + ) { Text(if (busy) nativeString("Getting location…") else nativeString("Use current location")) } + } +} diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatDictation.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatDictation.kt index b153c978bc80..6699b536d1f7 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatDictation.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatDictation.kt @@ -467,10 +467,11 @@ internal fun ChatComposerMicButton( onToggleDictation: () -> Unit, onStartVoiceNote: () -> Unit, modifier: Modifier = Modifier, + onOpenVoiceOptions: (() -> Unit)? = null, ) { val dictationActive = dictationState.isActive val hapticFeedback = LocalHapticFeedback.current - val interactionEnabled = dictationActive || dictationEnabled || voiceNoteEnabled + val interactionEnabled = dictationActive || dictationEnabled || voiceNoteEnabled || onOpenVoiceOptions != null val longPressAction: (() -> Unit)? = if (voiceNoteEnabled) { { @@ -497,23 +498,30 @@ internal fun ChatComposerMicButton( enabled = interactionEnabled, onClickLabel = dictationActionLabel, role = Role.Button, - onLongClickLabel = if (voiceNoteEnabled) voiceNoteRecordLabel() else null, - onLongClick = longPressAction, + onLongClickLabel = + if (onOpenVoiceOptions != null) { + nativeString("Voice options") + } else if (voiceNoteEnabled) { + voiceNoteRecordLabel() + } else { + null + }, + onLongClick = if (dictationActive) null else onOpenVoiceOptions ?: longPressAction, onClick = { - if (dictationActive || dictationEnabled) onToggleDictation() + if (dictationActive || dictationEnabled) onToggleDictation() else onOpenVoiceOptions?.invoke() }, ), shape = CircleShape, color = Color.Transparent, contentColor = when { - dictationActive -> ClawTheme.colors.primaryText + dictationActive -> ClawTheme.colors.primary dictationEnabled || voiceNoteEnabled -> ClawTheme.colors.textMuted else -> ClawTheme.colors.textSubtle }, ) { Box( - modifier = Modifier.padding(8.dp).background(if (dictationActive) ClawTheme.colors.primary else Color.Transparent, CircleShape), + modifier = Modifier.padding(8.dp), contentAlignment = Alignment.Center, ) { Icon( diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatImageCodec.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatImageCodec.kt index f55db556c2d0..97d581f820b4 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatImageCodec.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatImageCodec.kt @@ -44,7 +44,7 @@ internal fun loadPickedMediaOrDocumentAttachment( val mimeType = normalizeSharedAttachmentMimeType(resolver.getType(uri)) if (!isStageableSharedAttachmentMimeType(mimeType)) throw IllegalStateException("unsupported attachment") val kind = sharedAttachmentKindForMimeType(mimeType) - if (kind == null || kind == SharedAttachmentKind.Image) throw IllegalStateException("unsupported attachment") + if (kind == null) throw IllegalStateException("unsupported attachment") return loadSharedAttachment(resolver, SharedAttachment(uri = uri, kind = kind, mimeType = requireNotNull(mimeType))) } 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 9a751984103f..c63787eec251 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 @@ -275,7 +275,6 @@ internal fun resolvePendingAssistantAutoSend( } internal enum class ChatComposerPrimaryAction { - StartTalk, None, Stop, Send, @@ -291,7 +290,7 @@ internal fun resolveChatComposerPrimaryAction( hasContent && !talkActive -> ChatComposerPrimaryAction.Send runActive -> ChatComposerPrimaryAction.Stop talkActive -> ChatComposerPrimaryAction.None - else -> ChatComposerPrimaryAction.StartTalk + else -> ChatComposerPrimaryAction.None } internal object ChatUserMessageDisclosurePolicy { @@ -580,6 +579,12 @@ internal fun ChatScreen( viewModel.isCurrentChatComposerOwner(expected) } } + val attachmentPicker = + remember(viewModel, pickerActivity, pickerView, lifecycleOwner) { + ChatModelPickerSessionOwner(pickerActivity, pickerView, lifecycleOwner.lifecycle) { expected -> + viewModel.isCurrentChatComposerOwner(expected) + } + } val branchPicker = remember(viewModel, pickerActivity, pickerView, lifecycleOwner) { ChatModelPickerSessionOwner(pickerActivity, pickerView, lifecycleOwner.lifecycle) { expected -> @@ -603,6 +608,7 @@ internal fun ChatScreen( backgroundTasks.publishFeatures(publication) reviewDiff.publishFeatures(publication) branchPicker.publishFeatures(publication) + attachmentPicker.publishFeatures(publication) } SideEffect { modelPicker.refreshTarget() @@ -610,15 +616,17 @@ internal fun ChatScreen( backgroundTasks.refreshTarget() reviewDiff.refreshTarget() branchPicker.refreshTarget() + attachmentPicker.refreshTarget() branchOpening?.let { isCurrentBranchOpening(it) } } - DisposableEffect(modelPicker, effortPicker, backgroundTasks, reviewDiff, branchPicker) { + DisposableEffect(modelPicker, effortPicker, backgroundTasks, reviewDiff, branchPicker, attachmentPicker) { onDispose { modelPicker.dispose() effortPicker.dispose() backgroundTasks.dispose() reviewDiff.dispose() branchPicker.dispose() + attachmentPicker.dispose() } } var detailsExpanded by rememberSaveable { mutableStateOf(false) } @@ -678,38 +686,39 @@ internal fun ChatScreen( val dictationState by dictationController.state.collectAsState() val dictationPartialTranscript by dictationController.partialTranscript.collectAsState() val dictationActive = dictationState.isActive - val pickImages = - rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents()) { uris -> - val lease = imagePickerOwnerCheckpoint.consume() ?: return@rememberLauncherForActivityResult - if (uris.isNullOrEmpty()) { - composerState.cancelMediaAcquisition(lease.authorizationId) - return@rememberLauncherForActivityResult - } - val importOwner = - if (shouldMigrateComposerDraft(lease.owner, currentPickerOwner, currentPickerMainSessionKey)) { - currentPickerOwner - } else { - lease.owner + + fun importGalleryMedia( + lease: ChatComposerMediaLease, + uris: List, + ) { + if (uris.isEmpty()) { + composerState.cancelMediaAcquisition(lease.authorizationId) + return + } + val importOwner = + if (shouldMigrateComposerDraft(lease.owner, currentPickerOwner, currentPickerMainSessionKey)) currentPickerOwner else lease.owner + viewModel.importChatComposerAttachments( + owner = importOwner, + mediaAuthorizationId = lease.authorizationId, + mainSessionKey = currentPickerMainSessionKey, + expectedCount = uris.size, + ) { + uris.take(CHAT_COMPOSER_MAX_ATTACHMENTS).mapNotNull { uri -> + try { + loadPickedMediaOrDocumentAttachment(resolver, uri) + } catch (err: CancellationException) { + throw err + } catch (_: Exception) { + null } - val selectedUris = uris.take(8) - viewModel.importChatComposerAttachments( - owner = importOwner, - mediaAuthorizationId = lease.authorizationId, - mainSessionKey = currentPickerMainSessionKey, - expectedCount = uris.size, - ) { - selectedUris - .mapNotNull { uri -> - try { - loadSizedImageAttachment(resolver, uri) - } catch (err: CancellationException) { - throw err - } catch (_: Throwable) { - null - } - } } } + } + val pickImages = + rememberLauncherForActivityResult(ActivityResultContracts.PickMultipleVisualMedia(CHAT_COMPOSER_MAX_ATTACHMENTS)) { uris -> + val lease = imagePickerOwnerCheckpoint.consume() ?: return@rememberLauncherForActivityResult + importGalleryMedia(lease, uris) + } val pickMediaOrDocument = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> val lease = filePickerOwnerCheckpoint.consume() ?: return@rememberLauncherForActivityResult @@ -1105,24 +1114,7 @@ internal fun ChatScreen( commands = chatCommands, onOpenEffortPicker = { effortPicker.open(composerOwner, sessionKey) }, onOpenModelPicker = { modelPicker.open(composerOwner, sessionKey) }, - onPickImages = { - if (!viewModel.isCurrentChatComposerOwner(composerOwner)) return@ChatComposer - val authorizationId = composerState.beginMediaAcquisition(composerOwner) ?: return@ChatComposer - imagePickerOwnerCheckpoint.begin(composerOwner, authorizationId) - pickImages.launch("image/*") - }, - onPickAudioOrDocument = { - if (!viewModel.isCurrentChatComposerOwner(composerOwner)) return@ChatComposer - val authorizationId = composerState.beginMediaAcquisition(composerOwner) ?: return@ChatComposer - filePickerOwnerCheckpoint.begin(composerOwner, authorizationId) - pickMediaOrDocument.launch(SHARED_AUDIO_DOCUMENT_MIME_TYPES) - }, - onPickVideo = { - if (!viewModel.isCurrentChatComposerOwner(composerOwner)) return@ChatComposer - val authorizationId = composerState.beginMediaAcquisition(composerOwner) ?: return@ChatComposer - filePickerOwnerCheckpoint.begin(composerOwner, authorizationId) - pickMediaOrDocument.launch(SHARED_VIDEO_MIME_TYPES) - }, + onOpenAttachments = { attachmentPicker.open(composerOwner, sessionKey) }, onRemoveAttachment = { id -> composerState.removeAttachments(composerOwner, setOf(id)) }, voiceNoteState = voiceNoteState, voiceNoteElapsedMs = voiceNoteElapsedMs, @@ -1222,6 +1214,56 @@ internal fun ChatScreen( ) } + attachmentPicker.visible?.let { opening -> + key(opening) { + ChatAttachmentSheet( + opening = opening, + admit = { attachmentPicker.admit(opening) }, + onDismiss = { attachmentPicker.retire(opening) }, + onBrowseGallery = { + if (attachmentPicker.admit(opening)) { + val owner = opening.composerOwner + val authorizationId = composerState.beginMediaAcquisition(owner) + if (authorizationId != null) { + imagePickerOwnerCheckpoint.begin(owner, authorizationId) + pickImages.launch(androidx.activity.result.PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo)) + } + attachmentPicker.retire(opening) + } + }, + onPickFile = { + if (attachmentPicker.admit(opening)) { + val owner = opening.composerOwner + val authorizationId = composerState.beginMediaAcquisition(owner) + if (authorizationId != null) { + filePickerOwnerCheckpoint.begin(owner, authorizationId) + pickMediaOrDocument.launch(SHARED_AUDIO_DOCUMENT_MIME_TYPES) + } + attachmentPicker.retire(opening) + } + }, + onPickVideo = { + if (attachmentPicker.admit(opening)) { + val owner = opening.composerOwner + val authorizationId = composerState.beginMediaAcquisition(owner) + if (authorizationId != null) { + filePickerOwnerCheckpoint.begin(owner, authorizationId) + pickMediaOrDocument.launch(SHARED_VIDEO_MIME_TYPES) + } + attachmentPicker.retire(opening) + } + }, + onLocation = { location -> + if (attachmentPicker.admit(opening)) { + val owner = opening.composerOwner + inputDrafts[owner] = mergeSharedChatText(location, inputDrafts[owner]) + attachmentPicker.retire(opening) + } + }, + ) + } + } + effortPicker.visible?.let { opening -> key(opening) { ChatEffortSheet( @@ -3155,7 +3197,10 @@ private fun minimumChatLineHeight(style: TextStyle): Int { @Composable private fun minimumChatInputHeight(): Dp { - val lineHeight = minimumChatLineHeight(chatDraftStyle()) + val style = chatDraftStyle() + val wrapped = rememberTextMeasurer().measure("H\nH", style = style) + // Multiline paragraph rounding can make a line one pixel taller than a single-line measurement. + val lineHeight = maxOf(minimumChatLineHeight(style), ceil(wrapped.getLineBottom(0) - wrapped.getLineTop(0)).toInt()) return with(LocalDensity.current) { // Match each separately rounded editor/action padding and the text's full pixel line. ( @@ -3198,9 +3243,7 @@ private fun ChatComposer( commands: List, onOpenEffortPicker: () -> Unit, onOpenModelPicker: () -> Unit, - onPickImages: () -> Unit, - onPickAudioOrDocument: () -> Unit, - onPickVideo: () -> Unit, + onOpenAttachments: () -> Unit, onRemoveAttachment: (String) -> Unit, voiceNoteState: VoiceNoteRecorderState, voiceNoteElapsedMs: Long, @@ -3347,9 +3390,7 @@ private fun ChatComposer( onOpenDetails = if (compactHeight) ({ onDetailsExpandedChange(true) }) else null, value = value, onValueChange = onValueChange, - onPickImages = onPickImages, - onPickAudioOrDocument = onPickAudioOrDocument, - onPickVideo = onPickVideo, + onOpenAttachments = onOpenAttachments, onStartVoiceNote = onStartVoiceNote, recordVoiceNoteEnabled = ownerReady && recordVoiceNoteEnabled, dictationState = dictationState, @@ -3444,7 +3485,10 @@ internal fun resolveChatEffortPosition( return ChatEffortPosition(optionIndex = selectedIndex, fraction = fraction) } -internal fun chatEffortNeedleAngle(position: ChatEffortPosition): Float? = position.fraction?.let { 150f + it * 240f } +internal fun chatEffortNeedleAngle( + position: ChatEffortPosition, + fastMode: Boolean = false, +): Float? = if (fastMode) 330f else position.fraction?.let { 180f + it * 120f } internal fun chatEffortVisualFraction( fraction: Float, @@ -3467,6 +3511,7 @@ private fun ChatThinkingLevelPicker( val description = nativeString("Thinking") val dialColor = if (enabled) ClawTheme.colors.textMuted else ClawTheme.colors.textSubtle val needleColor = if (enabled) ClawTheme.colors.text else ClawTheme.colors.textSubtle + val fastZoneColor = ClawTheme.colors.danger.copy(alpha = if (enabled) 1f else 0.5f) Surface( onClick = onOpen, enabled = enabled, @@ -3479,40 +3524,28 @@ private fun ChatThinkingLevelPicker( color = Color.Transparent, ) { Box(contentAlignment = Alignment.Center) { - Box(modifier = Modifier.size(20.dp).testTag("chat-thinking-gauge")) { - Canvas(modifier = Modifier.matchParentSize()) { - val dialStrokeWidth = 1.5.dp.toPx() - val needleStrokeWidth = 2.dp.toPx() - // The dial omits its bottom arc; center the visible ink with the other controls. - translate(top = size.height / 8f) { - drawArc(color = dialColor, startAngle = 150f, sweepAngle = 240f, useCenter = false, style = Stroke(width = dialStrokeWidth, cap = StrokeCap.Round)) - // An unadvertised effective level is not the minimum/Off position. - chatEffortNeedleAngle(position)?.let { angle -> - rotate(angle) { - drawLine( - color = needleColor, - start = center, - end = Offset(size.width * 0.82f, center.y), - strokeWidth = needleStrokeWidth, - cap = StrokeCap.Round, - ) - } - drawCircle(color = needleColor, radius = 1.25.dp.toPx(), center = center) - } - } + Canvas(modifier = Modifier.size(22.dp).testTag("chat-thinking-gauge")) { + val radius = size.width * 0.43f + val hub = Offset(center.x, size.height * 0.72f) + val bounds = Offset(hub.x - radius, hub.y - radius) + val dialSize = Size(radius * 2, radius * 2) + val stroke = Stroke(width = 2.dp.toPx(), cap = StrokeCap.Butt) + for (start in listOf(180f, 225f, 270f)) { + drawArc(dialColor, start, 39f, false, bounds, dialSize, style = stroke) } - if (fastMode) { - Box( - modifier = - Modifier - .align(Alignment.BottomEnd) - .size(8.dp) - .background(ClawTheme.colors.surface, CircleShape) - .testTag("chat-fast-mode-badge"), - contentAlignment = Alignment.Center, - ) { - Icon(Icons.Default.Bolt, contentDescription = null, modifier = Modifier.size(7.dp), tint = ClawTheme.colors.primary) + drawArc(fastZoneColor, 315f, 45f, false, bounds, dialSize, style = stroke) + // Fast mode occupies the red zone; otherwise the needle reflects advertised effort. + chatEffortNeedleAngle(position, fastMode)?.let { angle -> + rotate(angle, pivot = hub) { + drawLine( + color = needleColor, + start = hub, + end = Offset(hub.x + radius * 0.83f, hub.y), + strokeWidth = 2.dp.toPx(), + cap = StrokeCap.Round, + ) } + drawCircle(color = needleColor, radius = 1.5.dp.toPx(), center = hub) } } } @@ -4247,9 +4280,7 @@ private fun ChatInputPill( onOpenDetails: (() -> Unit)?, value: String, onValueChange: (String) -> Unit, - onPickImages: () -> Unit, - onPickAudioOrDocument: () -> Unit, - onPickVideo: () -> Unit, + onOpenAttachments: () -> Unit, onStartVoiceNote: () -> Unit, recordVoiceNoteEnabled: Boolean, dictationState: ChatDictationState, @@ -4279,7 +4310,7 @@ private fun ChatInputPill( modifier: Modifier = Modifier, ) { val hardwareEnterHandler = remember { PhysicalChatSendKeyHandler() } - var attachmentMenuExpanded by remember { mutableStateOf(false) } + var voiceOptionsExpanded by remember { mutableStateOf(false) } val draftStyle = chatDraftStyle() Surface( @@ -4291,47 +4322,119 @@ private fun ChatInputPill( shadowElevation = 1.dp, ) { Column { - ChatTextFieldValueAdapter( - value = value, - onValueChange = onValueChange, - keyHandler = hardwareEnterHandler, - ) { textFieldValue, updateTextFieldValue -> - BasicTextField( - value = textFieldValue, - enabled = inputEnabled, - // A pending IME callback must not edit the draft behind Details. - onValueChange = { if (inputEnabled) updateTextFieldValue(it) }, - textStyle = draftStyle.copy(color = ClawTheme.colors.text), - cursorBrush = SolidColor(ClawTheme.colors.primary), - minLines = 1, - maxLines = 6, - modifier = - Modifier - .fillMaxWidth() - // Reserve the action row before measuring the draft in the IME viewport. - .weight(1f, fill = false) - .heightIn(min = ClawTheme.spacing.touchTarget) - .padding(start = 14.dp, end = 14.dp, top = 8.dp, bottom = 4.dp) - .onPreInterceptKeyBeforeSoftKeyboard { event -> - inputEnabled && - hardwareEnterHandler.handle( - event = event, - sendEnabled = sendEnabled, - textEmpty = textFieldValue.text.isEmpty(), - compositionActive = textFieldValue.composition != null, - onSend = onSend, - ) - }, - decorationBox = { innerTextField -> - Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterStart) { - if (value.isEmpty()) { - // BasicTextField's line limit does not constrain its decoration. - Text(text = nativeString("Message OpenClaw"), style = draftStyle, color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis) - } - innerTextField() + Row( + modifier = Modifier.fillMaxWidth().weight(1f, fill = false).padding(horizontal = 4.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onOpenAttachments, enabled = inputEnabled, modifier = Modifier.size(ClawTheme.spacing.touchTarget)) { + Icon(Icons.Default.Add, contentDescription = nativeString("Add attachment"), modifier = Modifier.size(20.dp)) + } + ChatTextFieldValueAdapter( + value = value, + onValueChange = onValueChange, + keyHandler = hardwareEnterHandler, + ) { textFieldValue, updateTextFieldValue -> + val scroll = rememberScrollState() + var textLayout by remember { mutableStateOf(null) } + val selection = textFieldValue.selection + // Keep the moved endpoint through unrelated recomposition and manual reading. + var selectionFollow by remember { mutableStateOf(selection to selection.end) } + if (selection != selectionFollow.first) { + val offset = if (selection.start != selectionFollow.first.start) selection.start else selection.end + selectionFollow = selection to offset + } + val caret = textLayout?.takeIf { it.layoutInput.text.text == textFieldValue.text }?.getCursorRect(selectionFollow.second) + val viewportHeight = scroll.viewportSize + val sixLines = rememberTextMeasurer().measure("H\nH\nH\nH\nH\nH", style = draftStyle).size.height + // Keep the native field bounded for paging; only its decoration contents scroll. + // The unbounded inner text has no private overflow competing with this viewport. + // Scroll changes themselves are deliberately not effect keys: manual reading + // stays put until the selection or available geometry actually changes. + LaunchedEffect(selectionFollow, caret, viewportHeight, inputEnabled) { + val cursor = caret + if (inputEnabled && cursor != null && viewportHeight > 0) { + val top = scroll.value + val target = + when { + cursor.bottom > top + viewportHeight -> ceil(cursor.bottom - viewportHeight).toInt() + cursor.top < top -> kotlin.math.floor(cursor.top).toInt() + else -> top + } + if (target != top) scroll.scrollTo(target) } - }, - ) + } + Box( + Modifier + .weight(1f) + .heightIn(min = ClawTheme.spacing.touchTarget, max = with(LocalDensity.current) { sixLines.toDp() } + 12.dp) + .padding(start = 4.dp, end = 4.dp, top = 8.dp, bottom = 4.dp), + ) { + BasicTextField( + value = textFieldValue, + enabled = inputEnabled, + // A pending IME callback must not edit the draft behind Details. + onValueChange = { if (inputEnabled) updateTextFieldValue(it) }, + textStyle = draftStyle.copy(color = ClawTheme.colors.text), + cursorBrush = SolidColor(ClawTheme.colors.primary), + minLines = 1, + maxLines = Int.MAX_VALUE, + onTextLayout = { textLayout = it }, + modifier = + Modifier + .fillMaxWidth() + .semantics(mergeDescendants = true) {} + .onPreInterceptKeyBeforeSoftKeyboard { event -> + inputEnabled && + hardwareEnterHandler.handle( + event = event, + sendEnabled = sendEnabled, + textEmpty = textFieldValue.text.isEmpty(), + compositionActive = textFieldValue.composition != null, + onSend = onSend, + ) + }, + decorationBox = { innerTextField -> + Box(modifier = Modifier.fillMaxWidth().verticalScroll(scroll, enabled = inputEnabled), contentAlignment = Alignment.CenterStart) { + if (value.isEmpty()) { + // BasicTextField's line limit does not constrain its decoration. + Text(text = nativeString("Message OpenClaw"), style = draftStyle, color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + innerTextField() + } + }, + ) + } + } + + if (talkActive) { + LiveTalkButton(active = true, onClick = onToggleTalk) + } else { + Box { + ChatComposerMicButton( + dictationState = dictationState, + dictationEnabled = dictationEnabled, + voiceNoteEnabled = recordVoiceNoteEnabled, + onToggleDictation = onToggleDictation, + onStartVoiceNote = onStartVoiceNote, + onOpenVoiceOptions = { voiceOptionsExpanded = true }, + ) + FoldAwareDropdownMenu( + expanded = voiceOptionsExpanded, + onDismissRequest = { voiceOptionsExpanded = false }, + items = + buildList { + if (dictationEnabled) add(FoldAwareMenuItem("dictation", nativeString("Dictation"), onToggleDictation, Icons.Default.Mic)) + if (recordVoiceNoteEnabled) add(FoldAwareMenuItem("voice-note", voiceNoteRecordLabel(), onStartVoiceNote, Icons.Default.Mic)) + add(FoldAwareMenuItem("talk", nativeString("Start Talk"), onToggleTalk, Icons.Default.Mic)) + }, + ) + } + } + when (resolveChatComposerPrimaryAction(talkActive = talkActive, runActive = runActive, hasContent = hasContent)) { + ChatComposerPrimaryAction.Send -> SendButton(enabled = inputEnabled && sendEnabled, onClick = onSend) + ChatComposerPrimaryAction.Stop -> StopButton(onClick = onAbort) + ChatComposerPrimaryAction.None -> Unit + } } ChatComposerActivity( dictationState = dictationState, @@ -4341,7 +4444,7 @@ private fun ChatInputPill( modifier = Modifier.padding(horizontal = 14.dp), ) Row( - modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { if (onOpenDetails != null) { @@ -4349,24 +4452,10 @@ private fun ChatInputPill( Icon(Icons.Default.MoreVert, contentDescription = nativeString("Details")) } } - Box { - Surface(onClick = { attachmentMenuExpanded = true }, enabled = inputEnabled, modifier = Modifier.size(ClawTheme.spacing.touchTarget), shape = CircleShape, color = Color.Transparent, contentColor = ClawTheme.colors.textMuted) { - Box(contentAlignment = Alignment.Center) { - Icon(imageVector = Icons.Default.Add, contentDescription = nativeString("Add attachment"), modifier = Modifier.size(20.dp)) - } - } - FoldAwareDropdownMenu( - expanded = attachmentMenuExpanded && inputEnabled, - onDismissRequest = { attachmentMenuExpanded = false }, - items = - listOf( - FoldAwareMenuItem("photos", nativeString("Photos"), onPickImages, Icons.Default.Photo), - FoldAwareMenuItem("videos", nativeString("Videos"), onPickVideo, Icons.Default.Videocam), - FoldAwareMenuItem("files", nativeString("Files"), onPickAudioOrDocument, Icons.Default.AttachFile), - ), - ) - } - Row(modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + ) { ChatComposerModelPicker( label = selectedModelLabel, contextUsage = contextUsage, @@ -4386,22 +4475,28 @@ private fun ChatInputPill( ) } } - if (talkActive) { - LiveTalkButton(active = true, onClick = onToggleTalk) - } else { - ChatComposerMicButton( - dictationState = dictationState, - dictationEnabled = dictationEnabled, - voiceNoteEnabled = recordVoiceNoteEnabled, - onToggleDictation = onToggleDictation, - onStartVoiceNote = onStartVoiceNote, + val fraction = contextMeterWidth(contextUsage) + val summary = chatContextSummary(contextUsage) + Row( + Modifier.weight(1f).padding(horizontal = 8.dp).semantics { + contentDescription = nativeString("Context") + summary?.let { stateDescription = it.detail } + }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = fraction?.let { nativeString("Context \$percent", "${(it * 100).toInt()}%") } ?: nativeString("Context –"), + style = ClawTheme.type.caption, + color = ClawTheme.colors.textMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.End, + modifier = Modifier.weight(1f), ) - } - when (resolveChatComposerPrimaryAction(talkActive = talkActive, runActive = runActive, hasContent = hasContent)) { - ChatComposerPrimaryAction.Send -> SendButton(enabled = inputEnabled && sendEnabled, onClick = onSend) - ChatComposerPrimaryAction.StartTalk -> LiveTalkButton(active = false, onClick = onToggleTalk) - ChatComposerPrimaryAction.Stop -> StopButton(onClick = onAbort) - ChatComposerPrimaryAction.None -> Unit + Box(Modifier.width(36.dp).height(3.dp).background(ClawTheme.colors.borderStrong, CircleShape)) { + if (fraction != null) Box(Modifier.fillMaxWidth(fraction).height(3.dp).background(ClawTheme.colors.primary, CircleShape)) + } } } } diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatComposerLayoutTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatComposerLayoutTest.kt index 8ccce0e7c28b..02298445951e 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatComposerLayoutTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatComposerLayoutTest.kt @@ -67,6 +67,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MonotonicFrameClock import androidx.compose.runtime.SideEffect import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberCompositionContext @@ -271,6 +272,18 @@ class ChatComposerLayoutTest { WindowInfoTracker.reset() } + @Test + @Config(qualifiers = "w1000dp-h1000dp-hdpi") + fun attachmentButtonSharesTheInputRowAndModelStaysBelowIt() { + showChat() + val editor = composeRule.onNode(hasSetTextAction()).getUnclippedBoundsInRoot() + val add = composeRule.onNodeWithContentDescription(nativeString("Add attachment")).getUnclippedBoundsInRoot() + val model = composeRule.onNodeWithContentDescription(nativeString("Model")).getUnclippedBoundsInRoot() + assertTrue("Attachment button must be left of the editor", add.right <= editor.left) + assertTrue("Attachment button must overlap the input row vertically", add.top < editor.bottom && add.bottom > editor.top) + assertTrue("Model selector must remain below the editor", model.top >= editor.bottom) + } + @Test @Config(qualifiers = "w1000dp-h1000dp-hdpi") fun tabletopEnforcesPixelFloorsAndUsesTranslatedPostInsetBoundsOnce() { @@ -369,12 +382,12 @@ class ChatComposerLayoutTest { "The complete real text line fits at the exact floor: font=$font density=$density editor=$bounds textSize=${line.size} line=${line.getLineTop(0)}..${line.getLineBottom(0)} lower=$lowerFloor touch=$touch", bounds.height >= ceil(line.getLineBottom(0) - line.getLineTop(0)).toInt(), ) - val caret = line.getCursorRect(editor.fetchSemanticsNode().config[SemanticsProperties.TextSelectionRange].end) + val caret = visibleCaret(editor, line) assertTrue("The complete caret fits at equality: $caret in $bounds", caret.top >= 0 && caret.bottom <= bounds.height && caret.left >= 0 && caret.right <= bounds.width) val send = chatWindowBounds(composeRule.onNodeWithContentDescription(nativeString("Send"))) assertTrue("The full target fits at equality", send.height >= touch && send.top >= hinge.bottom && send.bottom <= offset.value.y + with(density) { height.value.roundToPx() }) } else { - assertTrue("One physical pixel below a floor must use the larger safe upper pane", bounds.bottom <= hinge.top) + assertTrue("One physical pixel below a floor must use the larger safe upper pane: font=$font bounds=$bounds hinge=$hinge panes=$upper,$lower,$paneWidth floors=$upperFloor,$lowerFloor,$widthFloor", bounds.bottom <= hinge.top) } } } @@ -1020,7 +1033,7 @@ class ChatComposerLayoutTest { } @Test - fun settledRunShowsSendForTextAndTalkForAnEmptyDraft() { + fun settledRunShowsSendForTextAndOneVoiceControlForAnEmptyDraft() { showChat(viewportWidth = 320.dp) composeRule.runOnIdle { controller.handleGatewayEvent( @@ -1028,13 +1041,14 @@ class ChatComposerLayoutTest { """{"sessionKey":"${AndroidScreenshotFixture.mainSessionKey}","runId":"android-screenshot-active-run","seq":1,"stream":"lifecycle","data":{"phase":"end"}}""", ) } - assertComposerControlsVisible(primaryAction = "Start Talk") + assertComposerControlsVisible(primaryAction = null) + composeRule.onNodeWithContentDescription(nativeString("Start Talk")).assertDoesNotExist() val editor = composeRule.onNode(hasSetTextAction()) editor.performTextReplacement("A short status update") assertComposerControlsVisible(primaryAction = "Send") composeRule.onNodeWithContentDescription(nativeString("Start Talk")).assertDoesNotExist() editor.performTextReplacement("") - assertComposerControlsVisible(primaryAction = "Start Talk") + assertComposerControlsVisible(primaryAction = null) composeRule.onNodeWithContentDescription(nativeString("Send")).assertDoesNotExist() } @@ -1155,7 +1169,9 @@ class ChatComposerLayoutTest { dictation.performClick() composeRule.onNodeWithText(nativeString("On-device speech recognition is unavailable.")).assertIsDisplayed() composeRule.onNodeWithText(nativeString("Record voice note")).assertDoesNotExist() - dictation.assert(SemanticsMatcher.keyNotDefined(SemanticsActions.OnLongClick)) + dictation.performSemanticsAction(SemanticsActions.OnLongClick) { action -> action() } + composeRule.onNodeWithText(nativeString("Record voice note")).assertDoesNotExist() + composeRule.onNodeWithText(nativeString("Start Talk")).assertIsDisplayed() editor.assertTextEquals("Draft after forgetting") } finally { ShadowSpeechRecognizer.setIsOnDeviceRecognitionAvailable(recognitionAvailable) @@ -1210,6 +1226,187 @@ class ChatComposerLayoutTest { assertComposerControlsVisible(talkActive = true) } + @Test + @Config(qualifiers = "w800dp-h800dp-mdpi") + fun composerResizeScreenshotProof() { + val height = mutableStateOf(720.dp) + showChat(viewportWidth = 320.dp, viewportHeight = { height.value }, fontScale = { 2f }, useChatShell = true) + val editor = composeRule.onNode(hasSetTextAction()) + editor.performClick().performTextReplacement("Short viewport draft") + applyChatImeInsets() + composeRule.runOnIdle { height.value = 360.dp } + composeRule.waitForIdle() + System.getenv("OPENCLAW_CHAT_WORK_PROOF_DIR")?.let { path -> + val folder = File(path).apply { mkdirs() } + val image = composeRule.onNodeWithTag("chat-viewport").captureToImage().asAndroidBitmap() + assertTrue(image.width >= 320 && image.height >= 360) + File(folder, "composer-resize.png").outputStream().use { assertTrue(image.compress(Bitmap.CompressFormat.PNG, 100, it)) } + } + assertCompleteComposerLineAboveIme() + } + + @Test + @Config(qualifiers = "w800dp-h800dp-mdpi") + fun composerViewportPreservesManualScrollAndRevealsSelectionAfterResize() { + val height = mutableStateOf(720.dp) + val offset = mutableStateOf(IntOffset.Zero) + showChat(viewportWidth = 360.dp, viewportHeight = { height.value }, fontScale = { 2f }, useChatShell = true, viewportOffset = { offset.value }) + val editor = composeRule.onNode(hasSetTextAction()) + val draft = (1..20).joinToString("\n") { "Draft line $it" } + editor.performClick().performTextReplacement(draft) + val identity = editor.fetchSemanticsNode().id + val range = { editor.fetchSemanticsNode().config[SemanticsProperties.VerticalScrollAxisRange] } + val atEnd = range().value() + assertTrue("Long draft scrolls to its caret", atEnd > 0f) + editor.performTouchInput { swipeDown() } + composeRule.waitForIdle() + val reading = range().value() + assertTrue("Manual scroll moves away from the caret", reading < atEnd) + composeRule.runOnIdle { offset.value = IntOffset(0, 1) } + composeRule.waitForIdle() + assertEquals("Unrelated placement must not snap manual reading back", reading, range().value(), 1f) + editor.performSemanticsAction(SemanticsActions.SetSelection) { assertTrue(it(0, 0, false)) } + composeRule.waitForIdle() + assertEquals("Moving selection reveals the start, not the bottom", 0f, range().value(), 1f) + applyChatImeInsets() + composeRule.runOnIdle { height.value = 360.dp } + composeRule.waitForIdle() + assertCompleteComposerLineAboveIme() + editor.performSemanticsAction(SemanticsActions.SetSelection) { assertTrue(it(draft.length, draft.length, false)) } + composeRule.waitForIdle() + assertCompleteComposerLineAboveIme() + assertTrue("Moving selection reveals the final line", range().value() > 0f) + assertEquals(identity, editor.fetchSemanticsNode().id) + editor.assertTextEquals(draft).assertIsFocused() + } + + @Test + @Config(qualifiers = "w800dp-h800dp-mdpi") + fun expandedSelectionFollowsTheMovedHandleWithoutResettingManualReading() { + showChat(viewportWidth = 360.dp, viewportHeight = { 720.dp }, useChatShell = true) + val editor = composeRule.onNode(hasSetTextAction()) + val draft = (1..20).joinToString("\n") { "Draft line $it" } + editor.performClick().performTextReplacement(draft) + val identity = editor.fetchSemanticsNode().id + val end = draft.length + + fun select( + start: Int, + finish: Int, + ) { + editor.performSemanticsAction(SemanticsActions.SetSelection) { assertTrue(it(start, finish, false)) } + composeRule.waitForIdle() + assertEquals(TextRange(start, finish), editor.fetchSemanticsNode().config[SemanticsProperties.TextSelectionRange]) + } + + fun assertVisible(offset: Int) { + val layouts = mutableListOf() + editor.performSemanticsAction(SemanticsActions.GetTextLayoutResult) { assertTrue(it(layouts)) } + val caret = visibleCaret(editor, layouts.single(), offset) + val height = editor.fetchSemanticsNode().boundsInRoot.height + assertTrue("Moved selection offset $offset must be wholly visible: $caret in height=$height", caret.top >= 0f && caret.bottom <= height) + } + + select(end - 4, end) + select(0, end) + captureComposerProof("editor-selection-start") + assertVisible(0) + assertEquals("Moving the start must preserve the end", end, editor.fetchSemanticsNode().config[SemanticsProperties.TextSelectionRange].end) + + // Reading away from the chosen handle is intentional. A settings event recomposes + // the actual input pill without changing its text, selection, or viewport geometry. + editor.performTouchInput { swipeUp() } + composeRule.waitForIdle() + val reading = editor.fetchSemanticsNode().config[SemanticsProperties.VerticalScrollAxisRange].value() + assertTrue("Manual reading must move away from the first line", reading > 0f) + composeRule.runOnIdle { + controller.handleGatewayEvent( + "sessions.changed", + """{"reason":"patch","session":{"key":"${AndroidScreenshotFixture.mainSessionKey}","thinkingLevel":"high","thinkingLevels":[{"id":"high","label":"high"}]}}""", + ) + } + composeRule.waitForIdle() + assertEquals("high", controller.thinkingLevel.value) + assertEquals("Unrelated recomposition must preserve manual reading", reading, editor.fetchSemanticsNode().config[SemanticsProperties.VerticalScrollAxisRange].value(), 0f) + assertEquals(TextRange(0, end), editor.fetchSemanticsNode().config[SemanticsProperties.TextSelectionRange]) + + select(0, 0) + assertVisible(0) + select(0, end) + assertVisible(end) + select(end / 2, end) + assertVisible(end / 2) + select(end / 2, 0) + assertVisible(0) + select(end, 0) + assertVisible(end) + select(end, end - 4) + assertVisible(end - 4) + assertEquals("Selection changes must keep the same editor", identity, editor.fetchSemanticsNode().id) + editor.assertTextEquals(draft).assertIsFocused() + } + + @Test + @Config(qualifiers = "w800dp-h800dp-mdpi", shadows = [ShortcutKeyCharacterMap::class]) + fun nativePageDownMovesOneVisibleEditorPage() = assertNativeEditorPage(direction = 1, extend = false) + + @Test + @Config(qualifiers = "w800dp-h800dp-mdpi", shadows = [ShortcutKeyCharacterMap::class]) + fun nativePageUpMovesOneVisibleEditorPage() = assertNativeEditorPage(direction = -1, extend = false) + + @Test + @Config(qualifiers = "w800dp-h800dp-mdpi", shadows = [ShortcutKeyCharacterMap::class]) + fun nativeShiftPageDownExtendsOneVisibleEditorPage() = assertNativeEditorPage(direction = 1, extend = true) + + @Test + @Config(qualifiers = "w800dp-h800dp-mdpi", shadows = [ShortcutKeyCharacterMap::class]) + fun nativeShiftPageUpExtendsOneVisibleEditorPage() = assertNativeEditorPage(direction = -1, extend = true) + + private fun assertNativeEditorPage( + direction: Int, + extend: Boolean, + ) { + showChat(viewportWidth = 360.dp, viewportHeight = { 720.dp }, useChatShell = true) + val editor = composeRule.onNode(hasSetTextAction()) + val draft = (1..20).joinToString("\n") { "Draft line $it" } + editor.performClick().performTextReplacement(draft) + val identity = editor.fetchSemanticsNode().id + val layouts = mutableListOf() + editor.performSemanticsAction(SemanticsActions.GetTextLayoutResult) { assertTrue(it(layouts)) } + val layout = layouts.single() + assertEquals(20, layout.lineCount) + val anchor = layout.getLineStart(10) + 4 + editor.performSemanticsAction(SemanticsActions.SetSelection) { assertTrue(it(anchor, anchor, false)) } + composeRule.waitForIdle() + val visibleHeight = editor.fetchSemanticsNode().boundsInRoot.height + assertTrue("The field must be a viewport, not the whole buffer", visibleHeight < layout.size.height) + val cursor = layout.getCursorRect(anchor) + val expected = layout.getOffsetForPosition(Offset(cursor.left, cursor.top + direction * visibleHeight)) + assertTrue("An interior page must not jump to either buffer edge", expected > 0 && expected < draft.length) + assertTrue("Paging must move in the requested direction", (expected - anchor) * direction > 0) + composeRule.runOnIdle { + if (extend) insetView.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_SHIFT_LEFT)) + val meta = if (extend) KeyEvent.META_SHIFT_ON or KeyEvent.META_SHIFT_LEFT_ON else 0 + val key = if (direction > 0) KeyEvent.KEYCODE_PAGE_DOWN else KeyEvent.KEYCODE_PAGE_UP + for (action in listOf(KeyEvent.ACTION_DOWN, KeyEvent.ACTION_UP)) { + val event = KeyEvent(0L, 0L, action, key, 0, meta) + if (!insetView.dispatchKeyEventPreIme(event)) insetView.dispatchKeyEvent(event) + } + if (extend) insetView.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_SHIFT_LEFT)) + } + composeRule.waitForIdle() + captureComposerProof("editor-page-${if (direction > 0) "down" else "up"}-${if (extend) "shift" else "caret"}") + assertEquals( + "Native paging must use the visible field height=$visibleHeight rather than buffer height=${layout.size.height}", + if (extend) TextRange(anchor, expected) else TextRange(expected), + editor.fetchSemanticsNode().config[SemanticsProperties.TextSelectionRange], + ) + val visible = visibleCaret(editor, layout) + assertTrue("The paged endpoint must remain visible", visible.top >= 0f && visible.bottom <= visibleHeight) + assertEquals(identity, editor.fetchSemanticsNode().id) + editor.assertTextEquals(draft).assertIsFocused() + } + @Test fun multilineDraftGrowsThroughSixLinesAndStopsGrowingAtTheSeventh() { showChat(viewportWidth = 360.dp, viewportHeight = { 640.dp }) @@ -1348,11 +1545,23 @@ class ChatComposerLayoutTest { assertCompleteComposerLineAboveIme() } assertTrue("The edited draft must still have a routable owner", controller.isCurrentComposerOwner(owner)) - composeRule.onNodeWithContentDescription(nativeString("Send")).assertIsEnabled().performClick() + editor.performSemanticsAction(SemanticsActions.OnClick) { assertTrue(it()) } + val send = + composeRule + .onNodeWithContentDescription(nativeString("Send")) + .assertIsEnabled() + .fetchSemanticsNode() + .config[SemanticsActions.OnClick] + .action!! + // An IME commit and send may arrive before the next recomposition or flow collection. + composeRule.runOnIdle { + checkNotNull(insetView.onCreateInputConnection(EditorInfo())).commitText(" final", 1) + assertTrue(send()) + } composeRule.waitUntil { composeRule.runOnIdle { sent.size == 1 } } - assertEquals(JsonPrimitive(edited), sent.single()["message"]) + assertEquals(JsonPrimitive(edited.substring(0, 13) + " final" + edited.substring(13)), sent.single()["message"]) editor.assert(SemanticsMatcher.expectValue(SemanticsProperties.EditableText, AnnotatedString(""))) } } @@ -1383,6 +1592,81 @@ class ChatComposerLayoutTest { } } + @Test + @Config(shadows = [ShortcutKeyCharacterMap::class]) + fun undoRedoPublishesCanonicalDraftBeforeSendAndRestore() { + prefs.gatewayRegistry.upsert( + GatewayRegistryEntry(stableId = AndroidScreenshotFixture.gatewayId, kind = GatewayRegistryEntryKind.MANUAL, name = "Test gateway"), + ) + prefs.gatewayRegistry.setActive(AndroidScreenshotFixture.gatewayId) + val savedDrafts = SavedStateHandle() + val viewModel = showChat(useChatShell = true, savedStateHandle = savedDrafts) + val owner = viewModel.captureChatShareOwner() + val otherOwner = owner.copy(sessionKey = "agent:main:other-draft") + val store = viewModel.chatComposerState + composeRule.runOnIdle { store.textDrafts[otherOwner] = "Other conversation" } + val editor = composeRule.onNode(hasSetTextAction()) + editor.performClick().performTextReplacement("Base") + // Move away and back so the legacy field recomposes a forced undo snapshot. + composeRule.runOnIdle { dispatchHardwareKey(insetView, KeyEvent.KEYCODE_DPAD_LEFT) } + composeRule.waitForIdle() + composeRule.runOnIdle { dispatchHardwareKey(insetView, KeyEvent.KEYCODE_DPAD_RIGHT) } + composeRule.waitForIdle() + editor.performTextInput(" suffix") + editor.assertTextEquals("Base suffix") + + fun command(key: Int) { + insetView.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_CTRL_LEFT)) + for (action in listOf(KeyEvent.ACTION_DOWN, KeyEvent.ACTION_UP)) { + insetView.dispatchKeyEvent(KeyEvent(0L, 0L, action, key, 0, KeyEvent.META_CTRL_ON or KeyEvent.META_CTRL_LEFT_ON)) + } + insetView.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_CTRL_LEFT)) + } + composeRule.runOnIdle { + command(KeyEvent.KEYCODE_Z) + assertEquals("Undo must synchronously update the canonical draft", "Base", store.textDrafts[owner]) + val restored = ChatComposerTextDraftStore(initial = chatComposerTextDraftsFromSnapshot(savedDrafts["chat-composer-text-drafts"])) + assertEquals("Base", restored[owner]) + assertEquals("Other conversation", restored[otherOwner]) + } + editor.assertTextEquals("Base") + composeRule.runOnIdle { + command(KeyEvent.KEYCODE_Y) + assertEquals("Redo must synchronously update the canonical draft", "Base suffix", store.textDrafts[owner]) + val restored = ChatComposerTextDraftStore(initial = chatComposerTextDraftsFromSnapshot(savedDrafts["chat-composer-text-drafts"])) + assertEquals("Redo must synchronously persist the draft", "Base suffix", restored[owner]) + } + editor.assertTextEquals("Base suffix") + composeRule.runOnIdle { + command(KeyEvent.KEYCODE_Z) + val send = store.beginSend(owner) + assertEquals(ChatComposerSendStartResult.Started, send.result) + assertEquals("Base", checkNotNull(send.request).message) + assertEquals("Other conversation", store.textDrafts[otherOwner]) + } + } + + // Robolectric 4.16.1 only handles Shift in its character map. Real Android + // reports control characters for Ctrl+letters, allowing Compose's shortcut path. + @org.robolectric.annotation.Implements(android.view.KeyCharacterMap::class) + class ShortcutKeyCharacterMap : org.robolectric.shadows.ShadowKeyCharacterMap() { + companion object { + @JvmStatic + @org.robolectric.annotation.Implementation(methodName = "nativeGetCharacter") + fun shortcutCharacter( + ptr: Long, + keyCode: Int, + metaState: Int, + ): Char = + if (metaState and KeyEvent.META_CTRL_ON != 0 && keyCode in KeyEvent.KEYCODE_A..KeyEvent.KEYCODE_Z) { + (keyCode - KeyEvent.KEYCODE_A + 1).toChar() + } else { + org.robolectric.shadows.ShadowKeyCharacterMap + .nativeGetCharacter(ptr, keyCode, metaState) + } + } + } + private fun applyChatImeInsets() { val keyboard = with(composeRule.density) { 220.dp.roundToPx() } val navigation = with(composeRule.density) { 24.dp.roundToPx() } @@ -1454,7 +1738,9 @@ class ChatComposerLayoutTest { editor.assertIsFocused().assertTextEquals(draft) } val attemptInput = composeRule.runOnIdle { prepareInput(insetView) } - composeRule.onNodeWithContentDescription(nativeString("Details")).performClick() + // Robolectric cannot dismiss its native magnifier when a pointer click disables the field. + // Open Details through accessibility; the assertions exercise real queued IME/key input. + composeRule.onNodeWithContentDescription(nativeString("Details")).performSemanticsAction(SemanticsActions.OnClick) { assertTrue(it()) } composeRule.runOnIdle { attemptInput() } composeRule.waitForIdle() composeRule.runOnIdle { @@ -1466,7 +1752,7 @@ class ChatComposerLayoutTest { editor.assertTextEquals(draft) assertEquals("Details must retain the same editor", editorId, editor.fetchSemanticsNode().id) assertEquals(TextRange(draft.length), editor.fetchSemanticsNode().config[SemanticsProperties.TextSelectionRange]) - editor.performClick() + editor.performSemanticsAction(SemanticsActions.OnClick) { assertTrue(it()) } composeRule.runOnIdle { dispatchHardwareKey(insetView, KeyEvent.KEYCODE_X) } editor.assertTextEquals(draft + "x") composeRule.runOnIdle { @@ -1498,13 +1784,57 @@ class ChatComposerLayoutTest { val send = composeRule.onNodeWithContentDescription(nativeString("Send")).getUnclippedBoundsInRoot() assertTrue("The complete action target must remain above the real IME", send.bottom <= visibleBottom) val node = editor.fetchSemanticsNode() - val selection = node.config[SemanticsProperties.TextSelectionRange] - val caret = layout.getCursorRect(selection.end).translate(node.positionInRoot) + val caret = visibleCaret(editor, layout).translate(node.positionInRoot) val caretTop = with(composeRule.density) { caret.top.toDp() } val caretBottom = with(composeRule.density) { caret.bottom.toDp() } assertTrue("The whole caret must be visible inside the editor: $caret within $bounds", caretTop >= bounds.top && caretBottom <= bounds.bottom) } + private fun visibleCaret( + editor: SemanticsNodeInteraction, + layout: TextLayoutResult, + offset: Int = editor.fetchSemanticsNode().config[SemanticsProperties.TextSelectionRange].end, + ): androidx.compose.ui.geometry.Rect { + var displacement = Offset.Zero + // GetTextLayoutResult is unscrolled. Measure and restore the field's actual scroll offset + // before comparing its cursor with viewport bounds, including wrapped drafts at large fonts. + // Compose omits scroll actions when the full text already fits. + val scroll = + editor.fetchSemanticsNode().config.getOrNull(SemanticsActions.ScrollByOffset) + ?: run { + // Legacy value fields expose their scrolled text through layout coordinates, + // not ScrollByOffset semantics. Measure actual placement without assuming + // the cursor is visible; this also catches resize-only scroll regressions. + val root = editor.fetchSemanticsNode().layoutInfo + + fun textLeaf(info: androidx.compose.ui.layout.LayoutInfo): androidx.compose.ui.layout.LayoutInfo? { + val children = + info.javaClass.methods + .first { it.name.startsWith("getChildren$") && it.parameterCount == 0 } + .invoke(info) as List<*> + if (children.isEmpty() && info.width == layout.size.width && info.height == layout.size.height) return info + return children.filterIsInstance().firstNotNullOfOrNull(::textLeaf) + } + val leaf = checkNotNull(textLeaf(root)) { "Missing placed text layout" } + val origin = leaf.coordinates.localToRoot(Offset.Zero) - editor.fetchSemanticsNode().positionInRoot + return layout.getCursorRect(offset).translate(origin) + } + composeRule.runOnIdle { + val clock = + object : MonotonicFrameClock { + private var time = 0L + + override suspend fun withFrameNanos(onFrame: (Long) -> R): R = onFrame(time.also { time += 16_000_000L }) + } + runBlocking(clock) { + displacement = scroll(Offset(0f, -layout.size.height.toFloat())) + scroll(-displacement) + } + } + // ScrollState places text at integer pixels; animation consumption can retain a fractional remainder. + return layout.getCursorRect(offset).translate(Offset(displacement.x.roundToInt().toFloat(), displacement.y.roundToInt().toFloat())) + } + @Test @Config(qualifiers = "w800dp-h800dp-mdpi") fun backgroundTasksInitialReadStartsBeforeNativePlacement() { @@ -3474,7 +3804,7 @@ class ChatComposerLayoutTest { } @Test - fun fastModeBadgeBelongsToTheGaugeGeometry() { + fun fastModeGaugeRetainsAccessibleStateWithoutAnOverlayBadge() { showChat(viewportWidth = 360.dp, viewportHeight = { 640.dp }) composeRule.runOnIdle { controller.handleGatewayEvent( @@ -3491,18 +3821,13 @@ class ChatComposerLayoutTest { } composeRule.onNodeWithContentDescription(nativeString("Thinking")).assertIsDisplayed() - val gauge = composeRule.onNodeWithTag("chat-thinking-gauge", useUnmergedTree = true).getUnclippedBoundsInRoot() - val badge = composeRule.onNodeWithTag("chat-fast-mode-badge", useUnmergedTree = true).getUnclippedBoundsInRoot() - val badgeCenterX = (badge.left.value + badge.right.value) / 2f - val badgeCenterY = (badge.top.value + badge.bottom.value) / 2f - val gaugeCenterX = (gauge.left.value + gauge.right.value) / 2f - val gaugeCenterY = (gauge.top.value + gauge.bottom.value) / 2f - - assertTrue("The Fast mode badge center must stay inside the gauge: $badge in $gauge", badgeCenterX in gauge.left.value..gauge.right.value) - assertTrue("The Fast mode badge center must stay inside the gauge: $badge in $gauge", badgeCenterY in gauge.top.value..gauge.bottom.value) - assertFalse( - "The Fast mode badge must not cover the needle hub: $badge over $gauge", - gaugeCenterX in badge.left.value..badge.right.value && gaugeCenterY in badge.top.value..badge.bottom.value, + composeRule.onNodeWithTag("chat-thinking-gauge", useUnmergedTree = true).assertIsDisplayed() + composeRule.onNodeWithTag("chat-fast-mode-badge", useUnmergedTree = true).assertDoesNotExist() + composeRule.onNodeWithContentDescription(nativeString("Thinking")).assert( + SemanticsMatcher.expectValue( + SemanticsProperties.StateDescription, + chatThinkingChipStateDescription(true, "high", listOf(ChatThinkingLevelOption("high", "high"))), + ), ) } @@ -4060,20 +4385,19 @@ class ChatComposerLayoutTest { showChat(viewportHeight = { 640.dp }, restorationTester = restoration) composeRule.onNode(hasSetTextAction()).performTextInput("retained menu draft") composeRule.onNodeWithContentDescription(nativeString("Add attachment")).performClick() - composeRule.onNodeWithText(nativeString("Photos")).assertIsDisplayed() - val old = - WindowInspector.getGlobalWindowViews().single { - it.isAttachedToWindow && (it.layoutParams as? WindowManager.LayoutParams)?.type == WindowManager.LayoutParams.TYPE_APPLICATION_SUB_PANEL - } + composeRule.onNode(isDialog()).assertIsDisplayed() + composeRule.onNode(hasText(nativeString("Gallery")) and hasClickAction()).assertIsSelected() restoration.emulateSavedInstanceStateRestore() composeRule.waitForIdle() - assertFalse(old.isAttachedToWindow) - composeRule.onNode(isPopup()).assertDoesNotExist() + composeRule.onNode(isDialog()).assertDoesNotExist() composeRule.onNode(hasSetTextAction()).assertTextEquals("retained menu draft") composeRule.onNodeWithContentDescription(nativeString("Add attachment")).performClick() - for (label in listOf("Photos", "Videos", "Files")) { - composeRule.onNodeWithText(nativeString(label)).assertIsDisplayed() + for (label in listOf("Gallery", "File", "Location")) { + composeRule.onNode(hasText(nativeString(label)) and hasClickAction()).assertIsDisplayed() } + composeRule.onNode(hasText(nativeString("File")) and hasClickAction()).performClick() + composeRule.onNodeWithText(nativeString("Files")).assertIsDisplayed() + composeRule.onNodeWithText(nativeString("Videos")).assertIsDisplayed() } @Test @@ -4401,9 +4725,11 @@ class ChatComposerLayoutTest { composeRule .onNode( SemanticsMatcher("voice-note long press") { node -> - node.config.getOrNull(SemanticsActions.OnLongClick)?.label == nativeString("Record voice note") + node.config.getOrNull(SemanticsActions.OnLongClick)?.label == nativeString("Voice options") }, ).performSemanticsAction(SemanticsActions.OnLongClick) { action -> action() } + composeRule.onNodeWithText(nativeString("Record voice note")).performClick() + composeRule.onNodeWithContentDescription(nativeString("Cancel voice note")).assertIsDisplayed() captureComposerProof("voice-controls-320-2.0") assertCompactComposerCircle(composeRule.onNodeWithContentDescription(nativeString("Cancel voice note"))) assertCompactComposerCircle(composeRule.onNodeWithContentDescription(nativeString("Finish voice note"))) @@ -5042,7 +5368,12 @@ class ChatComposerLayoutTest { ChatMessage("bubble-proof-answer", "assistant", listOf(ChatMessageContent(text = "Two reviews remain before release.")), null) } composeRule.onNodeWithText("Two reviews remain before release.", useUnmergedTree = true).assertIsDisplayed() - composeRule.onNodeWithContentDescription("Start Talk").assertIsDisplayed() + composeRule + .onNode( + SemanticsMatcher("voice options remain available") { node -> + node.config.getOrNull(SemanticsActions.OnLongClick)?.label == nativeString("Voice options") + }, + ).assertIsDisplayed() capture("confirmed") verifyGeometry("confirmed") assertTrue(geometryFailures.joinToString("\n"), geometryFailures.isEmpty()) @@ -5147,8 +5478,9 @@ class ChatComposerLayoutTest { layoutDirection: () -> LayoutDirection = { LayoutDirection.Ltr }, restorationTester: StateRestorationTester? = null, scene: AndroidScreenshotScene = AndroidScreenshotScene.Chat, + savedStateHandle: SavedStateHandle = SavedStateHandle(), ): MainViewModel { - val viewModel = MainViewModel(app, prefs, SavedStateHandle()) + val viewModel = MainViewModel(app, prefs, savedStateHandle) viewModelStore.put("chat", viewModel) viewModel.enterScreenshotFixtureMode(scene) val setContent = restorationTester?.let { it::setContent } ?: composeRule::setContent @@ -5241,14 +5573,14 @@ class ChatComposerLayoutTest { talkActive: Boolean = false, thinkingLabel: String = nativeString("Low"), modelLabel: String = "GPT-5.2", - primaryAction: String = "Stop", + primaryAction: String? = "Stop", ) { val viewport = composeRule.onNodeWithTag("chat-viewport").getUnclippedBoundsInRoot() val editorNode = composeRule.onNode(hasSetTextAction()).assertIsDisplayed() val editor = editorNode.getUnclippedBoundsInRoot() assertTrue("Editor must retain a visible line: $editor inside $viewport", editor.bottom > editor.top) val controls = - (listOf(primaryAction) + if (talkActive) listOf("End Talk") else emptyList()).map { label -> + (listOfNotNull(primaryAction) + if (talkActive) listOf("End Talk") else emptyList()).map { label -> composeRule.onNodeWithContentDescription(nativeString(label)).assertIsDisplayed().assertHasClickAction() } + listOf( @@ -5278,7 +5610,7 @@ class ChatComposerLayoutTest { if (composeRule.onAllNodesWithContentDescription(nativeString("Details")).fetchSemanticsNodes().isNotEmpty()) { controlBounds += composeRule.onNodeWithContentDescription(nativeString("Details")).assertIsDisplayed().getUnclippedBoundsInRoot() } - val primary = controlBounds.first() + val primary = primaryAction?.let { composeRule.onNodeWithContentDescription(nativeString(it)).getUnclippedBoundsInRoot() } val dictation = composeRule.onNode( SemanticsMatcher("dictation control") { node -> @@ -5288,21 +5620,19 @@ class ChatComposerLayoutTest { val voice = if (talkActive) { dictation.assertDoesNotExist() - controlBounds[1] + composeRule.onNodeWithContentDescription(nativeString("End Talk")).getUnclippedBoundsInRoot() } else { dictation.assertIsDisplayed().getUnclippedBoundsInRoot().also { controlBounds += it } } - assertTrue("Voice stays before the primary action", voice.right <= primary.left) - controlBounds.drop(1).forEach { bounds -> - assertEquals( - "Every control, including voice, must share the action row: $bounds versus $primary", - (primary.top.value + primary.bottom.value) / 2, - (bounds.top.value + bounds.bottom.value) / 2, - 1f, - ) - } - controlBounds.sortedBy { it.left }.zipWithNext().forEach { (left, right) -> - assertTrue("Adjacent touch targets must not overlap: $left and $right", left.right <= right.left) + primary?.let { assertTrue("Voice stays before the primary action", voice.right <= it.left) } + assertTrue("Voice stays beside the editor", voice.left >= editor.right && voice.top < editor.bottom && voice.bottom > editor.top) + for ((index, first) in controlBounds.withIndex()) { + for (second in controlBounds.drop(index + 1)) { + assertTrue( + "Touch targets must not overlap: $first and $second", + first.right <= second.left || second.right <= first.left || first.bottom <= second.top || second.bottom <= first.top, + ) + } } controlBounds.forEach { bounds -> val retainsTouchTarget = diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatEffortPositionTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatEffortPositionTest.kt index d66fd468989e..497e125874ea 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatEffortPositionTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatEffortPositionTest.kt @@ -17,7 +17,7 @@ class ChatEffortPositionTest { @Test fun advertisedLevelsShareOneEvenSliderAndNeedleScale() { val expectedFractions = listOf(0f, 0.25f, 0.5f, 0.75f, 1f) - val expectedAngles = listOf(150f, 210f, 270f, 330f, 390f) + val expectedAngles = listOf(180f, 210f, 240f, 270f, 300f) options.forEachIndexed { index, option -> val position = resolveChatEffortPosition(option.id, options) @@ -39,6 +39,13 @@ class ChatEffortPositionTest { assertNull(chatEffortNeedleAngle(position)) } + @Test + fun fastModeMovesNeedleIntoRedZoneRegardlessOfEffort() { + for (level in listOf("off", "high", "future-effort")) { + assertEquals(330f, chatEffortNeedleAngle(resolveChatEffortPosition(level, options), fastMode = true)) + } + } + @Test fun rtlMirrorsEveryVisualStop() { val fractions = chatEffortStopFractions(options.size) diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatImageCodecTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatImageCodecTest.kt index 4c3a23069cf5..a563afb7b801 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatImageCodecTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatImageCodecTest.kt @@ -134,6 +134,8 @@ class ChatImageCodecTest { val attachment = loadProviderImage(displayName = "vacation-photo.png") assertEquals("vacation-photo.jpg", attachment.fileName) + assertEquals("image/jpeg", attachment.mimeType) + assertTrue(requireNotNull(decodeBase64Bitmap(attachment.base64)).width > 0) } @Test @@ -191,7 +193,7 @@ class ChatImageCodecTest { val provider = TestImageContentProvider(image, displayName, failQuery) provider.attachInfo(RuntimeEnvironment.getApplication(), ProviderInfo().apply { this.authority = authority }) ShadowContentResolver.registerProviderInternal(authority, provider) - return loadSizedImageAttachment( + return loadPickedMediaOrDocumentAttachment( RuntimeEnvironment.getApplication().contentResolver, Uri.parse("content://$authority/images/42"), ) diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatScreenTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatScreenTest.kt index b2702c24abc9..5e38285b8fc5 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatScreenTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatScreenTest.kt @@ -154,7 +154,7 @@ class ChatScreenTest { resolveChatComposerPrimaryAction(talkActive = false, runActive = true, hasContent = false), ) assertEquals( - ChatComposerPrimaryAction.StartTalk, + ChatComposerPrimaryAction.None, resolveChatComposerPrimaryAction(talkActive = false, runActive = false, hasContent = false), ) } diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/LocationAttachmentTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/LocationAttachmentTest.kt new file mode 100644 index 000000000000..7085c4046809 --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/LocationAttachmentTest.kt @@ -0,0 +1,150 @@ +package ai.openclaw.app.ui.chat + +import ai.openclaw.app.ui.design.ClawDesignTheme +import android.Manifest +import android.content.Context +import android.location.Location +import android.location.LocationManager +import android.os.Looper +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.time.Duration + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class LocationAttachmentTest { + @get:Rule val composeRule = createComposeRule() + + @Test + fun cachedAttachmentHonorsDisabledPrecisionWithRetainedFineGrant() { + val app = RuntimeEnvironment.getApplication() + (app as ai.openclaw.app.NodeApp).prefs.setLocationPreciseEnabled(false) + shadowOf(app).grantPermissions(Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION) + val manager = app.getSystemService(Context.LOCATION_SERVICE) as LocationManager + shadowOf(manager).setProviderEnabled(LocationManager.GPS_PROVIDER, true) + val fix = + Location(LocationManager.GPS_PROVIDER).apply { + latitude = 12.345678 + longitude = 45.678912 + accuracy = 5f + time = System.currentTimeMillis() + } + shadowOf(manager).simulateLocation(LocationManager.GPS_PROVIDER, fix) + val links = mutableListOf() + composeRule.setContent { + ClawDesignTheme { LocationAttachment(admit = { true }, onLocation = links::add) } + } + composeRule.onNodeWithText("Use current location").performClick() + composeRule.runOnIdle { + assertEquals(1, links.size) + org.junit.Assert.assertNotEquals("https://www.google.com/maps?q=12.345678,45.678912", links.single()) + } + composeRule.onNodeWithText("Use current location").performClick() + composeRule.runOnIdle { assertEquals(links.first(), links.last()) } + } + + @Test + fun pendingAttachmentRechecksPrecisionAndNeverUpgradesApproximateCapture() { + val app = RuntimeEnvironment.getApplication() as ai.openclaw.app.NodeApp + shadowOf(app).grantPermissions(Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION) + val manager = app.getSystemService(Context.LOCATION_SERVICE) as LocationManager + val provider = shadowOf(manager) + provider.setProviderEnabled(LocationManager.NETWORK_PROVIDER, false) + provider.setProviderEnabled(LocationManager.GPS_PROVIDER, true) + val links = mutableListOf() + composeRule.setContent { + ClawDesignTheme { LocationAttachment(admit = { true }, onLocation = { links.add(it) }) } + } + for ((initial, final) in listOf(true to false, false to true)) { + val fix = + Location(LocationManager.GPS_PROVIDER).apply { + latitude = 12.345678 + longitude = 45.678912 + accuracy = 5f + time = System.currentTimeMillis() - 120_000 + } + composeRule.runOnIdle { + app.prefs.setLocationPreciseEnabled(initial) + provider.simulateLocation(LocationManager.GPS_PROVIDER, fix) + } + composeRule.onNodeWithText("Use current location").performClick() + composeRule.runOnIdle { + assertTrue(provider.getLocationRequests(LocationManager.GPS_PROVIDER).isNotEmpty()) + app.prefs.setLocationPreciseEnabled(final) + fix.time = System.currentTimeMillis() + provider.simulateLocation(LocationManager.GPS_PROVIDER, fix) + } + composeRule.waitForIdle() + composeRule.runOnIdle { + assertTrue(links.isNotEmpty()) + org.junit.Assert.assertNotEquals("https://www.google.com/maps?q=12.345678,45.678912", links.last()) + assertTrue(provider.getLocationRequests(LocationManager.GPS_PROVIDER).isEmpty()) + } + } + composeRule.runOnIdle { assertEquals(2, links.size) } + } + + @Test + fun locationTimeoutShowsRecoveryMessageAndAllowsRetry() { + val app = RuntimeEnvironment.getApplication() + shadowOf(app).grantPermissions(Manifest.permission.ACCESS_COARSE_LOCATION) + val manager = app.getSystemService(Context.LOCATION_SERVICE) as LocationManager + shadowOf(manager).setProviderEnabled(LocationManager.NETWORK_PROVIDER, true) + composeRule.setContent { + ClawDesignTheme { + LocationAttachment(admit = { true }, onLocation = { error("No location was supplied") }) + } + } + composeRule.onNodeWithText("Use current location").performClick() + composeRule.onNodeWithText("Getting location…").assertIsDisplayed() + composeRule.mainClock.advanceTimeBy(16_000) + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(16)) + composeRule.onNodeWithText("Could not get your location. Check device location settings and try again.").assertIsDisplayed() + composeRule.onNodeWithText("Use current location").assertIsEnabled() + composeRule.onNodeWithText("Use current location").performClick() + composeRule.onNodeWithText("Getting location…").assertIsDisplayed() + } + + @Test + fun closingLocationPanelCancelsProviderRequestAndIgnoresLateFix() { + val app = RuntimeEnvironment.getApplication() + shadowOf(app).grantPermissions(Manifest.permission.ACCESS_COARSE_LOCATION) + val manager = app.getSystemService(Context.LOCATION_SERVICE) as LocationManager + val provider = shadowOf(manager) + provider.setProviderEnabled(LocationManager.NETWORK_PROVIDER, true) + val visible = mutableStateOf(true) + var insertions = 0 + composeRule.setContent { + ClawDesignTheme { + if (visible.value) LocationAttachment(admit = { true }, onLocation = { insertions++ }) + } + } + composeRule.onNodeWithText("Use current location").performClick() + composeRule.runOnIdle { + assertTrue(provider.getLocationRequests(LocationManager.NETWORK_PROVIDER).isNotEmpty()) + visible.value = false + } + composeRule.waitForIdle() + composeRule.runOnIdle { + assertTrue(provider.getLocationRequests(LocationManager.NETWORK_PROVIDER).isEmpty()) + provider.simulateLocation(Location(LocationManager.NETWORK_PROVIDER)) + } + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(16)) + composeRule.runOnIdle { assertEquals(0, insertions) } + } +} diff --git a/docs/platforms/android.md b/docs/platforms/android.md index a68a0ec96715..561c2959285c 100644 --- a/docs/platforms/android.md +++ b/docs/platforms/android.md @@ -437,15 +437,18 @@ Camera commands (foreground only; permission-gated): `camera.snap` (jpg), `camer - Navigate through the sidebar's **Pages** menu. Voice input belongs to the Chat composer; there is no separate Voice tab. - Tap the composer microphone for on-device speech recognition that inserts a - transcript into the draft. Long-press the microphone to record a voice-note - attachment. The UI reports unavailable recognition, missing permission, + transcript into the draft. While listening, a Stop icon replaces the microphone; + tap it to finish listening. While starting or transcribing, a Close icon cancels + that attempt. Long-press the microphone to open **Voice options**, + then choose **Record voice note** to create an attachment. The UI reports + unavailable recognition, missing permission, busy/network failures, and no-speech outcomes instead of silently dropping the attempt. If dictation is unavailable and a Gateway is selected, **Record voice note** offers a new recording while keeping the draft. It does not recover speech from the failed dictation attempt or send anything automatically. -- Start continuous **Talk** from the Chat waveform. Dictation, voice-note - recording, and Talk are mutually exclusive microphone paths. +- To start continuous **Talk**, long-press the microphone and choose **Start Talk**. + Dictation, voice-note recording, and Talk are mutually exclusive microphone paths. - Your selected agent stays bound to Talk and the main chat when the same Gateway reconnects, including while its agent list refreshes. Removing that agent falls back to the Gateway default. Switching Gateways or restarting the app clears diff --git a/src/cli/program/register.backup.product-path.test.ts b/src/cli/program/register.backup.product-path.test.ts index c6d4eda375c8..1d5b31fbeb74 100644 --- a/src/cli/program/register.backup.product-path.test.ts +++ b/src/cli/program/register.backup.product-path.test.ts @@ -1,4 +1,3 @@ -import { spawn } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; import { Worker } from "node:worker_threads"; @@ -9,6 +8,7 @@ import { openOpenClawStateDatabase, } from "../../state/openclaw-state-db.js"; import { withOpenClawTestState } from "../../test-utils/openclaw-test-state.js"; +import { runCliProcessChild } from "../cli-process-child.test-helpers.js"; function runBackupCli(params: { env: NodeJS.ProcessEnv; @@ -16,37 +16,21 @@ function runBackupCli(params: { preloadPath?: string; includeWorkspace?: boolean; }): Promise<{ code: number | null; stdout: string; stderr: string }> { - return new Promise((resolve, reject) => { - const child = spawn( - process.execPath, - [ - ...(params.preloadPath ? ["--import", params.preloadPath] : []), - "--import", - "tsx", - path.resolve("src/entry.ts"), - "backup", - "create", - "--output", - params.outputPath, - ...(params.includeWorkspace ? [] : ["--no-include-workspace"]), - "--verify", - "--json", - ], - { - env: { ...params.env, OPENCLAW_TEST_RUNTIME_LOG: "1" }, - stdio: ["ignore", "pipe", "pipe"], - }, - ); - let stdout = ""; - let stderr = ""; - child.stdout.on("data", (chunk) => { - stdout += chunk; - }); - child.stderr.on("data", (chunk) => { - stderr += chunk; - }); - child.once("error", reject); - child.once("close", (code) => resolve({ code, stdout, stderr })); + return runCliProcessChild({ + nodeArgs: [ + ...(params.preloadPath ? ["--import", params.preloadPath] : []), + "--import", + "tsx", + path.resolve("src/entry.ts"), + "backup", + "create", + "--output", + params.outputPath, + ...(params.includeWorkspace ? [] : ["--no-include-workspace"]), + "--verify", + "--json", + ], + env: { ...params.env, OPENCLAW_TEST_RUNTIME_LOG: "1" }, }); } diff --git a/src/infra/update-managed-service-triage.test-support.ts b/src/infra/update-managed-service-triage.test-support.ts index 3f207a9271f1..485412844893 100644 --- a/src/infra/update-managed-service-triage.test-support.ts +++ b/src/infra/update-managed-service-triage.test-support.ts @@ -9,6 +9,7 @@ import { DatabaseSync } from "node:sqlite"; import { vi } from "vitest"; import { inspectManagedProcessGroup } from "../../scripts/lib/managed-child-process.mts"; import { resolveServiceManagerEnv } from "../daemon/service-process-env.js"; +import { resolveSystemdUnitPath } from "../daemon/systemd-service-files.js"; import { buildCliRespawnPlan } from "../entry.respawn.js"; import { getFileLockProcessStartTime, isPidAlive } from "../shared/pid-alive.js"; import { resolveTestNodeExecPath } from "../test-utils/node-process.js"; @@ -28,6 +29,13 @@ import { startManagedServiceUpdateHandoff } from "./update-managed-service-hando const testNodeExecPath = resolveTestNodeExecPath(); +// Readers may inspect either unit while another native controller publishes its state. +const nativeStatePublisher = `function publishState(file, value) { + const temporary = file + "." + process.pid + ".tmp"; + fs.writeFileSync(temporary, JSON.stringify(value)); + fs.renameSync(temporary, file); +}`; + export function triageRuntimeNodeOptions(): string { // Prepared JavaScript does not need a source loader in every fixing descendant. return resolveRuntimeWorkerUrl(triageTestRuntimeEntrypoints.continuation).pathname.endsWith(".ts") @@ -91,6 +99,7 @@ export async function createTriageBoundary( JSON.stringify({ name: mode === "startup" ? scope : updateScope, active: true }), ); const common = `const fs = require('node:fs'); +${nativeStatePublisher} const root = ${JSON.stringify(root)}; const scopeFile = ${JSON.stringify(scopeFile)}; const primaryFile = ${JSON.stringify(primaryFile)}; @@ -173,10 +182,10 @@ if (action === 'show') { event('restart-preserved', {scope:scope.name}); } else if (action === 'stop') { if (!name.endsWith('.scope')) { - primary.active = false; fs.writeFileSync(primaryFile,JSON.stringify(primary)); + primary.active = false; publishState(primaryFile,primary); } if (name.endsWith('.scope') || scope.name.startsWith('openclaw-triage-')) { - scope.active=false; fs.writeFileSync(scopeFile,JSON.stringify(scope)); + scope.active=false; publishState(scopeFile,scope); event('scope-stopped'); for (const member of fs.readdirSync(root+'/members')) { try { process.kill(Number(member), 'SIGTERM'); } catch {} @@ -193,7 +202,7 @@ if (action === 'show') { ` const args=process.argv.slice(2), index=args.findIndex(x=>!x.startsWith('--')); const name=args.find(x=>x.startsWith('--unit=')).slice(7); -fs.writeFileSync(scopeFile,JSON.stringify({name,active:true})); +publishState(scopeFile,{name,active:true}); event('attached', {name}); process.execve(args[index],args.slice(index),process.env); `, @@ -631,6 +640,13 @@ async function writeTriageMaintenanceProbe(params: { events: string; }): Promise { const { root, primaryFile, unit, events } = params; + // Container-aware Doctor also checks the installation reported by the native fixture. + const unitPath = resolveSystemdUnitPath({ HOME: root, OPENCLAW_SYSTEMD_UNIT: unit }); + await fs.mkdir(path.dirname(unitPath), { recursive: true }); + await fs.writeFile( + unitPath, + `[Service]\nExecStart=${testNodeExecPath} ${root}/dist/index.js gateway run\n`, + ); await fs.mkdir(path.join(root, "dist")); await fs.writeFile(path.join(root, "dist", "index.js"), ""); await fs.writeFile(path.join(root, "package.json"), JSON.stringify({ name: "openclaw" })); @@ -643,6 +659,7 @@ import os from 'node:os'; import { spawn } from 'node:child_process'; import { mock } from 'node:test'; const root=${JSON.stringify(root)}, primaryFile=${JSON.stringify(primaryFile)}; +${nativeStatePublisher} const event=(kind,data={})=>fs.appendFileSync(${JSON.stringify(events)},JSON.stringify({kind,pid:process.pid,...data})+'\\n'); const started=performance.now(); let sequence=0; const phase=(phase,data={})=>event('maintenance-phase',{phase,ppid:process.ppid,sequence:++sequence,elapsedMs:performance.now()-started,...data}); @@ -683,7 +700,7 @@ const {maybeStopManagedServiceBeforeMutableUpdate}=await import(${JSON.stringify phase('update-import-end'); if(process.argv[2]==='inactive'){ const primary=JSON.parse(fs.readFileSync(primaryFile,'utf8')); - fs.writeFileSync(primaryFile,JSON.stringify({...primary,active:false})); + publishState(primaryFile,{...primary,active:false}); // Exercise Linux's inactive-unit policy on macOS too. No PID/native probes // are needed on the corrected inactive branch; this is not native proof. Object.defineProperty(process,'platform',{value:'linux'}); diff --git a/src/infra/update-managed-service-triage.test.ts b/src/infra/update-managed-service-triage.test.ts index 36c82db013c6..8cb868864b6a 100644 --- a/src/infra/update-managed-service-triage.test.ts +++ b/src/infra/update-managed-service-triage.test.ts @@ -412,7 +412,31 @@ process.emit=function(kind,message,...args){ itUnix.each(["active", "inactive"] as const)( "keeps the fixer alive during %s primary maintenance", async (primary) => { - const boundary = await start("startup", undefined, primary); + const boundary = await start("startup", undefined, primary, async (root) => { + const file = path.join(root, "maintenance.mjs"); + const source = await fs.readFile(file, "utf8"); + await fs.writeFile( + file, + source.replace( + "if(process.argv[2]==='inactive'){", + ` +const write = fs.writeFileSync; +fs.writeFileSync = function(file, ...args) { + if (typeof file === "string" && file.startsWith(primaryFile)) { + // Observe the real controller between truncate and write, not only after publication. + write(file, ""); + const scope = JSON.parse(fs.readFileSync(root + "/scope.json", "utf8")).name; + const probe = process.getBuiltinModule("child_process").spawnSync( + process.execPath, [root + "/bin/systemctl", "--user", "show", scope], {encoding:"utf8"}, + ); + event("primary-snapshot-observed", {status:probe.status}); + } + return write(file, ...args); +}; +if(process.argv[2]==='inactive'){`, + ), + ); + }); await ready(boundary); expect(await boundary.control("commit")).toBe("committed"); await fixing(boundary); @@ -432,6 +456,9 @@ process.emit=function(kind,message,...args){ "outside", ); } else { + expect(events.find((event) => event.kind === "primary-snapshot-observed")).toMatchObject({ + status: 0, + }); expect(events.find((event) => event.kind === "doctor-maintenance")).toMatchObject({ admitted: true, }); diff --git a/test/scripts/worker-deploy-build-plugin.test.ts b/test/scripts/worker-deploy-build-plugin.test.ts index b6e9937f165b..526a7d142eb8 100644 --- a/test/scripts/worker-deploy-build-plugin.test.ts +++ b/test/scripts/worker-deploy-build-plugin.test.ts @@ -15,6 +15,7 @@ import { WORKER_DEPLOY_OPTIONAL_NATIVE_MODULE_ID, } from "../../scripts/lib/worker-deploy-build-plugin.mts"; import { createWorkerBundleProducer } from "../../src/gateway/worker-environments/bundle.js"; +import { WORKER_BUNDLE_ARTIFACT_PATHS } from "../../src/shared/worker-bundle-hash.js"; import { createFixtureLifetime } from "../helpers/fixture-lifetime.js"; import { runNodeScript } from "../helpers/run-node-script.js"; import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js"; @@ -56,6 +57,9 @@ describe("worker deploy build plugin", () => { describe("portable output", () => { const fixtureDirs = useAutoCleanupTempDirTracker(afterAll); const fixtureLifetime = createFixtureLifetime(); + const workerEntryNames = WORKER_BUNDLE_ARTIFACT_PATHS.map( + (artifact) => `worker/${artifact.replace(/\.mjs$/u, "")}`, + ); let preparedDist: string; let preparedArchive: string; @@ -81,12 +85,14 @@ describe("worker deploy build plugin", () => { const activationSource = fs.realpathSync( path.resolve("src/plugin-sdk/facade-activation-check.runtime.ts"), ); + // Mixed runtime/declaration graphs also contain worker paths, but are not archived. for (const sibling of configs.filter( (candidate) => candidate !== config && typeof candidate.entry === "object" && !Array.isArray(candidate.entry) && - Object.keys(candidate.entry).some((entry) => entry.startsWith("worker/")), + Object.keys(candidate.entry).length > 0 && + Object.keys(candidate.entry).every((entry) => workerEntryNames.includes(entry)), )) { const { bundles } = await build({ ...sibling, @@ -139,6 +145,11 @@ export { setRuntimeConfigSnapshot } from "../config/runtime-snapshot.js";`; ], }); try { + const builtEntries = vi + .mocked(build) + .mock.calls.flatMap(([options]) => Object.keys(options?.entry ?? {})); + expect(builtEntries.length).toBe(workerEntryNames.length); + expect(builtEntries.toSorted()).toEqual(workerEntryNames.toSorted()); // A dynamic import cycle can leave an unstaged root facade even with code splitting off. expect(bundles.flatMap((bundle) => bundle.chunks.map((chunk) => chunk.fileName))).toEqual([ "worker/worker.mjs",